revspec 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -29,6 +29,19 @@ revspec spec.md
29
29
 
30
30
  Opens a TUI in line mode with vim-style navigation. Press `c` on any line to open a thread and start commenting.
31
31
 
32
+ ### Markdown rendering
33
+
34
+ Revspec renders markdown in-place (toggle with `m`):
35
+
36
+ - **Headings** — colored and bold, `#`–`######`
37
+ - **Inline** — bold (`**`/`__`), italic (`*`/`_`), bold-italic (`***`), strikethrough (`~~`), `code`, [links](url)
38
+ - **Fenced code blocks** — fence markers dimmed, body in green
39
+ - **Tables** — box-drawing borders, header row bolded, auto-column-widths
40
+ - **Lists** — unordered (`•`), ordered, task lists (`☐`/`☑`)
41
+ - **Blockquotes** — bar gutter, italicized text
42
+ - **Cursor line** highlighting across all elements
43
+ - **Search highlights** — colored match segments
44
+
32
45
  ### Keybindings
33
46
 
34
47
  | Key | Action |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "revspec",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "revspec": "./bin/revspec.ts"
@@ -136,6 +136,11 @@ export class ReviewState {
136
136
  }
137
137
  }
138
138
 
139
+ deleteThread(threadId: string): void {
140
+ this.threads = this.threads.filter((t) => t.id !== threadId);
141
+ this._unreadThreadIds.delete(threadId);
142
+ }
143
+
139
144
  addOwnerReply(threadId: string, text: string, ts?: number): void {
140
145
  const thread = this.threads.find((t) => t.id === threadId);
141
146
  if (!thread) return;
package/src/tui/app.ts CHANGED
@@ -17,6 +17,7 @@ import { buildPagerNodes, createPager, countExtraVisualLines, type PagerComponen
17
17
  import {
18
18
  buildTopBar,
19
19
  buildBottomBar,
20
+ setBottomBarMessage,
20
21
  createTopBar,
21
22
  createBottomBar,
22
23
  type TopBarComponents,
@@ -129,7 +130,8 @@ export async function runTui(
129
130
 
130
131
  buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
131
132
  buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
132
- buildBottomBar(bottomBar, commandBuffer);
133
+ const hasThread = !!state.threadAtLine(state.cursorLine);
134
+ buildBottomBar(bottomBar, commandBuffer, hasThread);
133
135
  renderer.requestRender();
134
136
  }
135
137
 
@@ -221,12 +223,12 @@ export async function runTui(
221
223
  if (cmd === "w") {
222
224
  // Merge JSONL -> JSON, stay open
223
225
  doMerge();
224
- bottomBar.text.content = " \u2714 Merged to review JSON";
226
+ setBottomBarMessage(bottomBar, " \u2714 Merged to review JSON");
225
227
  renderer.requestRender();
226
228
  setTimeout(() => { refreshPager(); }, 1200);
227
229
  return "stay";
228
230
  }
229
- if (cmd === "wq") {
231
+ if (cmd === "wq" || cmd === "qw") {
230
232
  // Merge and exit
231
233
  mergeAndExit(resolve);
232
234
  return "merged";
@@ -234,7 +236,7 @@ export async function runTui(
234
236
  if (cmd === "q") {
235
237
  // Exit only if merged (no pending changes)
236
238
  if (hasPendingChanges()) {
237
- bottomBar.text.content = " Unmerged changes. Use :w to save or :q! to discard";
239
+ setBottomBarMessage(bottomBar, " Unmerged changes. Use :w to save or :q! to discard");
238
240
  renderer.requestRender();
239
241
  setTimeout(() => { refreshPager(); }, 2000);
240
242
  return "stay";
@@ -480,7 +482,7 @@ export async function runTui(
480
482
  if (!action) {
481
483
  const p = keybinds.pending();
482
484
  if (p) {
483
- bottomBar.text.content = ` ${p}`;
485
+ setBottomBarMessage(bottomBar, ` ${p}`);
484
486
  renderer.requestRender();
485
487
  }
486
488
  return;
@@ -533,7 +535,7 @@ export async function runTui(
533
535
  ensureCursorVisible();
534
536
  }
535
537
  } else {
536
- bottomBar.text.content = " No active search \u2014 use / to search";
538
+ setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
537
539
  renderer.requestRender();
538
540
  setTimeout(() => { refreshPager(); }, 1500);
539
541
  }
@@ -547,7 +549,7 @@ export async function runTui(
547
549
  ensureCursorVisible();
548
550
  }
549
551
  } else {
550
- bottomBar.text.content = " No active search \u2014 use / to search";
552
+ setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
551
553
  renderer.requestRender();
552
554
  setTimeout(() => { refreshPager(); }, 1500);
553
555
  }
@@ -570,7 +572,7 @@ export async function runTui(
570
572
  const msg = wasResolved
571
573
  ? ` \u21a9 Reopened thread #${thread.id}`
572
574
  : ` \u2714 Resolved thread #${thread.id}`;
573
- bottomBar.text.content = msg;
575
+ setBottomBarMessage(bottomBar, msg);
574
576
  renderer.requestRender();
575
577
  setTimeout(() => { refreshPager(); }, 1500);
576
578
  }
@@ -584,7 +586,7 @@ export async function runTui(
584
586
  appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
585
587
  }
586
588
  refreshPager();
587
- bottomBar.text.content = ` \u2714 Resolved ${pending} pending thread(s)`;
589
+ setBottomBarMessage(bottomBar, ` \u2714 Resolved ${pending} pending thread(s)`);
588
590
  renderer.requestRender();
589
591
  setTimeout(() => { refreshPager(); }, 1500);
590
592
  break;
@@ -592,26 +594,31 @@ export async function runTui(
592
594
  case "delete-draft": {
593
595
  const thread = state.threadAtLine(state.cursorLine);
594
596
  if (!thread) break;
595
- const hadReviewerMsg = thread.messages.some((m) => m.author === "reviewer");
596
- if (hadReviewerMsg) {
597
- state.deleteLastDraftMessage(thread.id);
598
- appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
599
- refreshPager();
600
- bottomBar.text.content = " \u2714 Deleted draft comment";
601
- renderer.requestRender();
602
- setTimeout(() => { refreshPager(); }, 1500);
603
- } else {
604
- bottomBar.text.content = " No reviewer message to delete";
605
- renderer.requestRender();
606
- setTimeout(() => { refreshPager(); }, 1500);
607
- }
597
+ const deleteOverlay = createConfirm({
598
+ renderer,
599
+ title: "Delete Thread",
600
+ message: `Delete thread #${thread.id} on line ${thread.line}?`,
601
+ onConfirm: () => {
602
+ dismissOverlay();
603
+ state.deleteThread(thread.id);
604
+ appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
605
+ refreshPager();
606
+ setBottomBarMessage(bottomBar, ` \u2714 Deleted thread #${thread.id}`);
607
+ renderer.requestRender();
608
+ setTimeout(() => { refreshPager(); }, 1500);
609
+ },
610
+ onCancel: () => {
611
+ dismissOverlay();
612
+ },
613
+ });
614
+ showOverlay(deleteOverlay);
608
615
  break;
609
616
  }
610
617
  case "approve":
611
618
  if (state.canApprove()) {
612
619
  const confirmOverlay = createConfirm({
613
620
  renderer,
614
- message: "Approve spec and proceed to implementation? [y/n]",
621
+ message: "Approve spec and proceed to implementation?",
615
622
  onConfirm: () => {
616
623
  dismissOverlay();
617
624
  appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
@@ -628,7 +635,7 @@ export async function runTui(
628
635
  const msg = total === 0
629
636
  ? "No threads to approve"
630
637
  : `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
631
- bottomBar.text.content = ` \u26a0 ${msg}`;
638
+ setBottomBarMessage(bottomBar, ` \u26a0 ${msg}`);
632
639
  renderer.requestRender();
633
640
  setTimeout(() => { refreshPager(); }, 2000);
634
641
  }
@@ -638,8 +645,8 @@ export async function runTui(
638
645
  if (next !== null) {
639
646
  state.cursorLine = next;
640
647
  ensureCursorVisible();
641
- refreshPager();
642
648
  }
649
+ refreshPager();
643
650
  break;
644
651
  }
645
652
  case "prev-thread": {
@@ -647,8 +654,8 @@ export async function runTui(
647
654
  if (prev !== null) {
648
655
  state.cursorLine = prev;
649
656
  ensureCursorVisible();
650
- refreshPager();
651
657
  }
658
+ refreshPager();
652
659
  break;
653
660
  }
654
661
  case "next-unread": {
@@ -656,8 +663,8 @@ export async function runTui(
656
663
  if (nextLine !== null) {
657
664
  state.cursorLine = nextLine;
658
665
  ensureCursorVisible();
659
- refreshPager();
660
666
  }
667
+ refreshPager();
661
668
  break;
662
669
  }
663
670
  case "prev-unread": {
@@ -665,8 +672,8 @@ export async function runTui(
665
672
  if (prevLine !== null) {
666
673
  state.cursorLine = prevLine;
667
674
  ensureCursorVisible();
668
- refreshPager();
669
675
  }
676
+ refreshPager();
670
677
  break;
671
678
  }
672
679
  case "help":
@@ -173,6 +173,7 @@ function createThreadView(
173
173
  hints: insertHints,
174
174
  });
175
175
 
176
+
176
177
  // --- Scrollable conversation history ---
177
178
  const scrollBox = dialog.content;
178
179
 
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,37 +46,6 @@ 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",
@@ -68,14 +61,46 @@ export function createHelp(opts: {
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, "Navigation", [
75
+ " j/k Down/up",
76
+ " gg Go to first line / scroll to top",
77
+ " G Go to last line / scroll to bottom",
78
+ " Ctrl+d/u Half page down/up",
79
+ " / Search",
80
+ " n/N Next/prev search match",
81
+ " Esc Clear search highlights",
82
+ " ]t/[t Next/prev thread",
83
+ " ]r/[r Next/prev unread thread",
84
+ ]);
85
+
86
+ addHelpSection(dialog.content, renderer, "Review", [
87
+ " c Comment / view thread / reply",
88
+ " r Resolve thread",
89
+ " R Resolve all pending",
90
+ " dd Delete thread (with confirm)",
91
+ " l List threads",
92
+ " a Approve spec",
93
+ ]);
94
+
95
+ addHelpSection(dialog.content, renderer, "Commands", [
96
+ " :w Show save status",
97
+ " :q Quit (blocks if unsaved)",
98
+ " :wq Save and quit",
99
+ " :q! Quit without saving",
100
+ ]);
77
101
 
78
- dialog.content.add(content);
102
+ // Trailing blank line
103
+ dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
79
104
 
80
105
  const extraKeyHandler = (key: KeyEvent) => {
81
106
  if (key.name === "q" || key.sequence === "?") {
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;
@@ -54,7 +49,9 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
54
49
  indicator = "\u258c";
55
50
  }
56
51
  }
57
- lines.push(`${prefix}${indicator}${padLineNum(lineNum)} ${specText}`);
52
+ const numStr = String(lineNum);
53
+ const padded = " ".repeat(numWidth - numStr.length) + numStr;
54
+ lines.push(`${prefix}${indicator}${padded} ${specText}`);
58
55
  }
59
56
  return lines.join("\n");
60
57
  }
@@ -69,6 +66,11 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
69
66
  export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, searchQuery?: string | null, unreadThreadIds?: ReadonlySet<string>): void {
70
67
  lineNode.clear();
71
68
 
69
+ // Calculate dynamic gutter width based on total line count
70
+ const numWidth = Math.max(String(state.lineCount).length, 3);
71
+ // Blank gutter for table borders: prefix(1) + indicator(1) + numWidth + spaces(2)
72
+ const gutterBlank = " ".repeat(2 + numWidth + 2);
73
+
72
74
  // Pre-scan for table blocks so we can calculate column widths
73
75
  const tableBlocks = new Map<number, TableBlock>();
74
76
  for (let i = 0; i < state.specLines.length; i++) {
@@ -81,6 +83,9 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
81
83
  }
82
84
  }
83
85
 
86
+ // Track fenced code block state
87
+ let inCodeBlock = false;
88
+
84
89
  for (let i = 0; i < state.specLines.length; i++) {
85
90
  const lineNum = i + 1;
86
91
  const thread = state.threadAtLine(lineNum);
@@ -113,7 +118,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
113
118
 
114
119
  // Top border before first table row (on its own visual line with blank gutter)
115
120
  if (isTable && relIdx === 0) {
116
- lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
121
+ lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
117
122
  renderTableBorder(lineNode, tableBlock.colWidths, "top");
118
123
  lineNode.add(TextNodeRenderable.fromString("\n", {}));
119
124
  }
@@ -127,13 +132,28 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
127
132
  indicator,
128
133
  { fg: indicatorColor, bg: isCursor ? theme.backgroundElement : undefined }
129
134
  ));
135
+ const numStr = String(lineNum);
136
+ const paddedNum = " ".repeat(numWidth - numStr.length) + numStr;
130
137
  lineNode.add(TextNodeRenderable.fromString(
131
- `${padLineNum(lineNum)} `,
138
+ `${paddedNum} `,
132
139
  { fg: theme.textDim, attributes: TextAttributes.DIM, bg: isCursor ? theme.backgroundElement : undefined }
133
140
  ));
134
141
 
135
- // Spec text — table or regular markdown
136
- if (isTable) {
142
+ // Spec text — fenced code block, table, or regular markdown
143
+ if (specText.trimStart().startsWith("```")) {
144
+ inCodeBlock = !inCodeBlock;
145
+ // Render the fence line itself as dim
146
+ lineNode.add(TextNodeRenderable.fromString(specText, {
147
+ fg: theme.textDim,
148
+ bg: isCursor ? theme.backgroundElement : undefined,
149
+ }));
150
+ } else if (inCodeBlock) {
151
+ // Inside code block — render as green, no markdown parsing
152
+ lineNode.add(TextNodeRenderable.fromString(specText, {
153
+ fg: theme.green,
154
+ bg: isCursor ? theme.backgroundElement : undefined,
155
+ }));
156
+ } else if (isTable) {
137
157
  if (relIdx === tableBlock.separatorIndex) {
138
158
  // Separator row → box-drawing line
139
159
  renderTableSeparator(lineNode, tableBlock.colWidths);
@@ -147,7 +167,7 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
147
167
  // Bottom border after last row (on its own visual line with blank gutter)
148
168
  if (relIdx === tableBlock.lines.length - 1) {
149
169
  lineNode.add(TextNodeRenderable.fromString("\n", {}));
150
- lineNode.add(TextNodeRenderable.fromString(" ", { fg: theme.textDim }));
170
+ lineNode.add(TextNodeRenderable.fromString(gutterBlank, { fg: theme.textDim }));
151
171
  renderTableBorder(lineNode, tableBlock.colWidths, "bottom");
152
172
  }
153
173
  } else if (searchQuery) {
@@ -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,35 @@ 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 = [
86
+ const hints: Hint[] = [
78
87
  { key: "j/k", action: "move" },
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
+ hints.push({ key: "dd", action: "delete thread" });
93
+ }
94
+ hints.push({ key: "/", action: "search" });
95
+ hints.push({ key: "?", action: "help" });
84
96
  buildHints(t, hints);
85
97
  }
86
98
 
@@ -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
  }