revspec 0.3.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 +13 -0
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +1025 -0
- package/package.json +1 -1
- package/src/state/review-state.ts +5 -0
- package/src/tui/app.ts +189 -234
- package/src/tui/comment-input.ts +146 -144
- package/src/tui/confirm.ts +29 -43
- package/src/tui/help.ts +77 -76
- package/src/tui/pager.ts +54 -267
- package/src/tui/search.ts +6 -6
- package/src/tui/status-bar.ts +27 -24
- package/src/tui/thread-list.ts +29 -55
- package/src/tui/ui/dialog.ts +106 -0
- package/src/tui/ui/hint-bar.ts +20 -0
- package/src/tui/ui/keybinds.ts +106 -0
- package/src/tui/ui/markdown.ts +292 -0
- package/src/tui/ui/theme.ts +49 -0
- package/test/tui/ui/keybinds.test.ts +71 -0
- package/src/tui/theme.ts +0 -34
package/src/tui/thread-list.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
BoxRenderable,
|
|
3
2
|
TextRenderable,
|
|
4
3
|
SelectRenderable,
|
|
5
4
|
SelectRenderableEvents,
|
|
6
5
|
type CliRenderer,
|
|
7
|
-
type KeyEvent,
|
|
8
6
|
} from "@opentui/core";
|
|
9
7
|
import type { Thread } from "../protocol/types";
|
|
10
|
-
import { theme, STATUS_ICONS } from "./theme";
|
|
8
|
+
import { theme, STATUS_ICONS } from "./ui/theme";
|
|
9
|
+
import { createDialog } from "./ui/dialog";
|
|
11
10
|
|
|
12
11
|
export interface ThreadListOptions {
|
|
13
12
|
renderer: CliRenderer;
|
|
@@ -17,7 +16,7 @@ export interface ThreadListOptions {
|
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
export interface ThreadListOverlay {
|
|
20
|
-
container: BoxRenderable;
|
|
19
|
+
container: import("@opentui/core").BoxRenderable;
|
|
21
20
|
cleanup: () => void;
|
|
22
21
|
}
|
|
23
22
|
|
|
@@ -25,7 +24,7 @@ const MAX_PREVIEW_LENGTH = 50;
|
|
|
25
24
|
|
|
26
25
|
function previewText(thread: Thread): string {
|
|
27
26
|
if (thread.messages.length === 0) return "(empty)";
|
|
28
|
-
const last = thread.messages[
|
|
27
|
+
const last = thread.messages[0];
|
|
29
28
|
const text = last.text.replace(/\n/g, " ");
|
|
30
29
|
if (text.length <= MAX_PREVIEW_LENGTH) return text;
|
|
31
30
|
return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
|
|
@@ -44,21 +43,22 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
44
43
|
(t) => t.status === "open" || t.status === "pending"
|
|
45
44
|
);
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
const count = activeThreads.length;
|
|
47
|
+
|
|
48
|
+
const dialog = createDialog({
|
|
49
|
+
renderer,
|
|
50
|
+
title: `Threads (${count} active)`,
|
|
52
51
|
width: "70%",
|
|
53
52
|
height: "60%",
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
top: "15%",
|
|
54
|
+
left: "15%",
|
|
55
|
+
borderColor: theme.mauve,
|
|
56
|
+
onDismiss: onCancel,
|
|
57
|
+
hints: [
|
|
58
|
+
{ key: "j/k", action: "navigate" },
|
|
59
|
+
{ key: "Enter", action: "jump" },
|
|
60
|
+
{ key: "Esc", action: "close" },
|
|
61
|
+
],
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
if (activeThreads.length === 0) {
|
|
@@ -66,10 +66,10 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
66
66
|
content: "No active threads. Press [Esc] to close.",
|
|
67
67
|
width: "100%",
|
|
68
68
|
height: 1,
|
|
69
|
-
fg: theme.
|
|
69
|
+
fg: theme.textDim,
|
|
70
70
|
wrapMode: "none",
|
|
71
71
|
});
|
|
72
|
-
|
|
72
|
+
dialog.content.add(emptyMsg);
|
|
73
73
|
} else {
|
|
74
74
|
// Build select options from threads
|
|
75
75
|
const selectOptions = activeThreads.map((t) => {
|
|
@@ -86,19 +86,19 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
86
86
|
flexGrow: 1,
|
|
87
87
|
options: selectOptions,
|
|
88
88
|
selectedIndex: 0,
|
|
89
|
-
backgroundColor: theme.
|
|
89
|
+
backgroundColor: theme.backgroundPanel,
|
|
90
90
|
textColor: theme.text,
|
|
91
|
-
focusedBackgroundColor: theme.
|
|
91
|
+
focusedBackgroundColor: theme.backgroundPanel,
|
|
92
92
|
focusedTextColor: theme.text,
|
|
93
|
-
selectedBackgroundColor: theme.
|
|
93
|
+
selectedBackgroundColor: theme.backgroundElement,
|
|
94
94
|
selectedTextColor: "#f5c2e7",
|
|
95
|
-
descriptionColor: theme.
|
|
96
|
-
selectedDescriptionColor: theme.
|
|
95
|
+
descriptionColor: theme.textDim,
|
|
96
|
+
selectedDescriptionColor: theme.textMuted,
|
|
97
97
|
showDescription: true,
|
|
98
98
|
wrapSelection: true,
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
dialog.content.add(select);
|
|
102
102
|
|
|
103
103
|
// Focus the select so it handles j/k navigation
|
|
104
104
|
renderer.focusRenderable(select);
|
|
@@ -112,34 +112,8 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
112
112
|
});
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
width: "100%",
|
|
119
|
-
height: 1,
|
|
120
|
-
fg: theme.hintFg,
|
|
121
|
-
bg: theme.hintBg,
|
|
122
|
-
wrapMode: "none",
|
|
123
|
-
truncate: true,
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
container.add(hint);
|
|
127
|
-
|
|
128
|
-
// Key handler for Esc
|
|
129
|
-
const keyHandler = (key: KeyEvent) => {
|
|
130
|
-
if (key.name === "escape") {
|
|
131
|
-
key.preventDefault();
|
|
132
|
-
key.stopPropagation();
|
|
133
|
-
onCancel();
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
115
|
+
return {
|
|
116
|
+
container: dialog.container,
|
|
117
|
+
cleanup: dialog.cleanup,
|
|
136
118
|
};
|
|
137
|
-
|
|
138
|
-
renderer.keyInput.on("keypress", keyHandler);
|
|
139
|
-
|
|
140
|
-
function cleanup(): void {
|
|
141
|
-
renderer.keyInput.off("keypress", keyHandler);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return { container, cleanup };
|
|
145
119
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
ScrollBoxRenderable,
|
|
4
|
+
TextRenderable,
|
|
5
|
+
type CliRenderer,
|
|
6
|
+
type KeyEvent,
|
|
7
|
+
} from "@opentui/core";
|
|
8
|
+
import { theme } from "./theme";
|
|
9
|
+
import { buildHints, type Hint } from "./hint-bar";
|
|
10
|
+
|
|
11
|
+
export interface DialogOptions {
|
|
12
|
+
renderer: CliRenderer;
|
|
13
|
+
title: string;
|
|
14
|
+
width?: string | number;
|
|
15
|
+
height?: string | number;
|
|
16
|
+
top?: string | number;
|
|
17
|
+
left?: string | number;
|
|
18
|
+
borderColor?: string;
|
|
19
|
+
onDismiss: () => void;
|
|
20
|
+
hints?: Hint[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DialogComponents {
|
|
24
|
+
container: BoxRenderable;
|
|
25
|
+
content: ScrollBoxRenderable;
|
|
26
|
+
hintText: TextRenderable;
|
|
27
|
+
setHints: (hints: Hint[]) => void;
|
|
28
|
+
cleanup: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createDialog(opts: DialogOptions): DialogComponents {
|
|
32
|
+
const {
|
|
33
|
+
renderer, title, onDismiss,
|
|
34
|
+
width = "80%", height = "85%",
|
|
35
|
+
top = "5%", left = "10%",
|
|
36
|
+
borderColor = theme.border,
|
|
37
|
+
hints = [],
|
|
38
|
+
} = opts;
|
|
39
|
+
|
|
40
|
+
const container = new BoxRenderable(renderer, {
|
|
41
|
+
position: "absolute",
|
|
42
|
+
top,
|
|
43
|
+
left,
|
|
44
|
+
width,
|
|
45
|
+
height,
|
|
46
|
+
zIndex: 100,
|
|
47
|
+
backgroundColor: theme.backgroundPanel,
|
|
48
|
+
border: true,
|
|
49
|
+
borderStyle: "single",
|
|
50
|
+
borderColor,
|
|
51
|
+
title: ` ${title} `,
|
|
52
|
+
flexDirection: "column",
|
|
53
|
+
paddingLeft: 1,
|
|
54
|
+
paddingRight: 1,
|
|
55
|
+
paddingTop: 1,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const content = new ScrollBoxRenderable(renderer, {
|
|
59
|
+
width: "100%",
|
|
60
|
+
flexGrow: 1,
|
|
61
|
+
flexShrink: 1,
|
|
62
|
+
scrollY: true,
|
|
63
|
+
scrollX: false,
|
|
64
|
+
});
|
|
65
|
+
container.add(content);
|
|
66
|
+
|
|
67
|
+
const hintBox = new BoxRenderable(renderer, {
|
|
68
|
+
width: "100%",
|
|
69
|
+
height: 1,
|
|
70
|
+
flexShrink: 0,
|
|
71
|
+
backgroundColor: theme.backgroundElement,
|
|
72
|
+
});
|
|
73
|
+
const hintText = new TextRenderable(renderer, {
|
|
74
|
+
content: "",
|
|
75
|
+
width: "100%",
|
|
76
|
+
fg: theme.textMuted,
|
|
77
|
+
wrapMode: "none",
|
|
78
|
+
truncate: true,
|
|
79
|
+
});
|
|
80
|
+
hintBox.add(hintText);
|
|
81
|
+
container.add(hintBox);
|
|
82
|
+
|
|
83
|
+
if (hints.length > 0) {
|
|
84
|
+
buildHints(hintText, hints);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setHints(newHints: Hint[]): void {
|
|
88
|
+
buildHints(hintText, newHints);
|
|
89
|
+
renderer.requestRender();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const keyHandler = (key: KeyEvent) => {
|
|
93
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
94
|
+
key.preventDefault();
|
|
95
|
+
key.stopPropagation();
|
|
96
|
+
onDismiss();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
100
|
+
|
|
101
|
+
function cleanup(): void {
|
|
102
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { container, content, hintText, setHints, cleanup };
|
|
106
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { TextRenderable, TextNodeRenderable } from "@opentui/core";
|
|
2
|
+
import { theme } from "./theme";
|
|
3
|
+
|
|
4
|
+
export interface Hint {
|
|
5
|
+
key: string;
|
|
6
|
+
action: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildHints(text: TextRenderable, hints: Hint[]): void {
|
|
10
|
+
text.clear();
|
|
11
|
+
text.add(TextNodeRenderable.fromString(" ", {}));
|
|
12
|
+
for (let i = 0; i < hints.length; i++) {
|
|
13
|
+
const h = hints[i];
|
|
14
|
+
text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: theme.blue }));
|
|
15
|
+
text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
|
|
16
|
+
if (i < hints.length - 1) {
|
|
17
|
+
text.add(TextNodeRenderable.fromString(" ", {}));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { KeyEvent } from "@opentui/core";
|
|
2
|
+
|
|
3
|
+
export interface KeyBinding {
|
|
4
|
+
key: string;
|
|
5
|
+
action: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface SequenceState {
|
|
9
|
+
first: string;
|
|
10
|
+
timer: ReturnType<typeof setTimeout>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface KeybindRegistry {
|
|
14
|
+
match: (key: KeyEvent) => string | null;
|
|
15
|
+
pending: () => string | null;
|
|
16
|
+
destroy: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): KeybindRegistry {
|
|
20
|
+
let sequence: SequenceState | null = null;
|
|
21
|
+
|
|
22
|
+
const singleBindings = new Map<string, string>();
|
|
23
|
+
const sequenceBindings = new Map<string, string>();
|
|
24
|
+
|
|
25
|
+
// Sequence keys: exactly 2 chars, each is a single printable keystroke.
|
|
26
|
+
// Named keys like "up", "down" are NOT sequences even though length === 2.
|
|
27
|
+
// Sequences: "gg", "dd", "]t", "[r" — char + char combos.
|
|
28
|
+
// We detect named keys by checking: if both chars are lowercase letters AND
|
|
29
|
+
// the combo is different from char+char (i.e., it's a word), it's a named key.
|
|
30
|
+
// Simple heuristic: sequences always have either repeated chars or non-alpha first char.
|
|
31
|
+
const NAMED_KEYS = new Set(["up", "fn"]);
|
|
32
|
+
|
|
33
|
+
for (const b of bindings) {
|
|
34
|
+
if (b.key.length === 2 && !b.key.startsWith("C-") && !NAMED_KEYS.has(b.key)) {
|
|
35
|
+
sequenceBindings.set(b.key, b.action);
|
|
36
|
+
} else {
|
|
37
|
+
singleBindings.set(b.key, b.action);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sequenceStarters = new Set<string>();
|
|
42
|
+
for (const key of sequenceBindings.keys()) {
|
|
43
|
+
sequenceStarters.add(key[0]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function keyToString(key: KeyEvent): string {
|
|
47
|
+
if (key.ctrl && key.name) return `C-${key.name}`;
|
|
48
|
+
// For shifted keys, prefer sequence (gives "?", ":", etc.) over name.toUpperCase()
|
|
49
|
+
if (key.shift && key.sequence) return key.sequence;
|
|
50
|
+
if (key.shift && key.name) return key.name.toUpperCase();
|
|
51
|
+
return key.sequence || key.name || "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function match(key: KeyEvent): string | null {
|
|
55
|
+
const keyStr = keyToString(key);
|
|
56
|
+
let skipSequenceCheck = false;
|
|
57
|
+
|
|
58
|
+
if (sequence) {
|
|
59
|
+
const seq = sequence.first + keyStr;
|
|
60
|
+
clearTimeout(sequence.timer);
|
|
61
|
+
sequence = null;
|
|
62
|
+
|
|
63
|
+
const action = sequenceBindings.get(seq);
|
|
64
|
+
if (action) return action;
|
|
65
|
+
skipSequenceCheck = true; // Don't start a new sequence with the failed second key
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check ctrl variants first
|
|
69
|
+
if (key.ctrl && key.name) {
|
|
70
|
+
const action = singleBindings.get(`C-${key.name}`);
|
|
71
|
+
if (action) return action;
|
|
72
|
+
}
|
|
73
|
+
|
|
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)) {
|
|
76
|
+
sequence = {
|
|
77
|
+
first: keyStr,
|
|
78
|
+
timer: setTimeout(() => { sequence = null; }, timeout),
|
|
79
|
+
};
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Shift variants
|
|
84
|
+
if (key.shift && key.name) {
|
|
85
|
+
const upper = key.name.toUpperCase();
|
|
86
|
+
const action = singleBindings.get(upper);
|
|
87
|
+
if (action) return action;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return singleBindings.get(keyStr) ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pendingStr(): string | null {
|
|
94
|
+
if (!sequence) return null;
|
|
95
|
+
return `${sequence.first}...`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function destroy(): void {
|
|
99
|
+
if (sequence) {
|
|
100
|
+
clearTimeout(sequence.timer);
|
|
101
|
+
sequence = null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { match, pending: pendingStr, destroy };
|
|
106
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TextRenderable,
|
|
3
|
+
TextNodeRenderable,
|
|
4
|
+
TextAttributes,
|
|
5
|
+
} from "@opentui/core";
|
|
6
|
+
import { theme } from "./theme";
|
|
7
|
+
|
|
8
|
+
// --- Inline markdown parser ---
|
|
9
|
+
|
|
10
|
+
export interface StyledSegment {
|
|
11
|
+
text: string;
|
|
12
|
+
fg?: string;
|
|
13
|
+
attributes?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parse inline markdown (bold italic, bold, italic, code, links, strikethrough) into styled segments.
|
|
18
|
+
* Strips syntax markers and returns display text with style info.
|
|
19
|
+
* Order matters: longer patterns first (***bold italic*** before **bold** before *italic*).
|
|
20
|
+
*/
|
|
21
|
+
export function parseInlineMarkdown(text: string): StyledSegment[] {
|
|
22
|
+
const segments: StyledSegment[] = [];
|
|
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;
|
|
33
|
+
let pos = 0;
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = regex.exec(text)) !== null) {
|
|
36
|
+
if (match.index > pos) {
|
|
37
|
+
segments.push({ text: text.slice(pos, match.index) });
|
|
38
|
+
}
|
|
39
|
+
if (match[2] !== undefined) {
|
|
40
|
+
// ***bold italic***
|
|
41
|
+
segments.push({ text: match[2], attributes: TextAttributes.BOLD | TextAttributes.ITALIC });
|
|
42
|
+
} else if (match[3] !== undefined) {
|
|
43
|
+
// **bold**
|
|
44
|
+
segments.push({ text: match[3], attributes: TextAttributes.BOLD });
|
|
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) {
|
|
61
|
+
// `code`
|
|
62
|
+
segments.push({ text: match[9], fg: theme.mauve });
|
|
63
|
+
}
|
|
64
|
+
pos = match.index + match[0].length;
|
|
65
|
+
}
|
|
66
|
+
if (pos < text.length) {
|
|
67
|
+
segments.push({ text: text.slice(pos) });
|
|
68
|
+
}
|
|
69
|
+
if (segments.length === 0) {
|
|
70
|
+
segments.push({ text });
|
|
71
|
+
}
|
|
72
|
+
return segments;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse a full line of markdown into styled segments.
|
|
77
|
+
* Handles block-level syntax (headings, lists, blockquotes, hr)
|
|
78
|
+
* and delegates inline content to parseInlineMarkdown.
|
|
79
|
+
*/
|
|
80
|
+
export function parseMarkdownLine(line: string): StyledSegment[] {
|
|
81
|
+
// Heading: # ... ###### (strip markers, bold + colored)
|
|
82
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
|
|
83
|
+
if (headingMatch) {
|
|
84
|
+
const level = headingMatch[1].length;
|
|
85
|
+
const color = level <= 2 ? theme.blue : theme.mauve;
|
|
86
|
+
// Parse inline markdown within heading text
|
|
87
|
+
const inner = parseInlineMarkdown(headingMatch[2]);
|
|
88
|
+
return inner.map((s) => ({
|
|
89
|
+
...s,
|
|
90
|
+
fg: s.fg ?? color,
|
|
91
|
+
attributes: (s.attributes ?? 0) | TextAttributes.BOLD,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Horizontal rule: --- or *** or ___
|
|
96
|
+
if (/^(\s*[-*_]\s*){3,}$/.test(line)) {
|
|
97
|
+
return [{ text: "\u2500".repeat(40), fg: theme.textDim, attributes: TextAttributes.DIM }];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Blockquote: > text
|
|
101
|
+
if (line.startsWith("> ")) {
|
|
102
|
+
const inner = parseInlineMarkdown(line.slice(2));
|
|
103
|
+
return [
|
|
104
|
+
{ text: "\u2502 ", fg: theme.mauve },
|
|
105
|
+
...inner.map((s) => ({
|
|
106
|
+
...s,
|
|
107
|
+
fg: s.fg ?? theme.textDim,
|
|
108
|
+
attributes: (s.attributes ?? 0) | TextAttributes.ITALIC,
|
|
109
|
+
})),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
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
|
+
|
|
125
|
+
// Unordered list: - item, * item, + item
|
|
126
|
+
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
|
|
127
|
+
if (ulMatch) {
|
|
128
|
+
return [
|
|
129
|
+
{ text: ulMatch[1] + "\u2022 ", fg: theme.yellow },
|
|
130
|
+
...parseInlineMarkdown(ulMatch[3]),
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Ordered list: 1. item
|
|
135
|
+
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
|
|
136
|
+
if (olMatch) {
|
|
137
|
+
return [
|
|
138
|
+
{ text: `${olMatch[1]}${olMatch[2]}. `, fg: theme.yellow },
|
|
139
|
+
...parseInlineMarkdown(olMatch[3]),
|
|
140
|
+
];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Regular line — parse inline markdown only
|
|
144
|
+
return parseInlineMarkdown(line);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Add styled segments as TextNodeRenderable children to a parent.
|
|
149
|
+
*/
|
|
150
|
+
export function addSegments(parent: TextRenderable, segments: StyledSegment[], defaultFg: string, bg?: string): void {
|
|
151
|
+
for (const seg of segments) {
|
|
152
|
+
const node = TextNodeRenderable.fromString(seg.text, {
|
|
153
|
+
fg: seg.fg ?? defaultFg,
|
|
154
|
+
attributes: seg.attributes,
|
|
155
|
+
bg,
|
|
156
|
+
});
|
|
157
|
+
parent.add(node);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- Table rendering ---
|
|
162
|
+
|
|
163
|
+
export const SEPARATOR_RE = /^\|[\s:]*-+[\s:]*(\|[\s:]*-+[\s:]*)*\|?\s*$/;
|
|
164
|
+
|
|
165
|
+
/** Split a table row into trimmed cell values (strips outer pipes). */
|
|
166
|
+
export function parseTableCells(line: string): string[] {
|
|
167
|
+
const trimmed = line.trim();
|
|
168
|
+
// Remove leading/trailing pipes and split
|
|
169
|
+
const inner = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
|
|
170
|
+
const withoutTrailing = inner.endsWith("|") ? inner.slice(0, -1) : inner;
|
|
171
|
+
return withoutTrailing.split("|").map((c) => c.trim());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Compute the display width of a string (strips inline markdown markers). */
|
|
175
|
+
export function displayWidth(text: string): number {
|
|
176
|
+
// Remove all inline markdown markers to get display length
|
|
177
|
+
return text
|
|
178
|
+
.replace(/\*\*\*(.+?)\*\*\*/g, "$1")
|
|
179
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
180
|
+
.replace(/\*(.+?)\*/g, "$1")
|
|
181
|
+
.replace(/__(.+?)__/g, "$1")
|
|
182
|
+
.replace(/_(.+?)_/g, "$1")
|
|
183
|
+
.replace(/~~(.+?)~~/g, "$1")
|
|
184
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
185
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
186
|
+
.length;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface TableBlock {
|
|
190
|
+
startIndex: number;
|
|
191
|
+
lines: string[];
|
|
192
|
+
separatorIndex: number; // relative to startIndex, -1 if none
|
|
193
|
+
colWidths: number[];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Scan ahead from a starting `|` line and collect the full table block. */
|
|
197
|
+
export function collectTable(specLines: string[], start: number): TableBlock {
|
|
198
|
+
const lines: string[] = [];
|
|
199
|
+
let i = start;
|
|
200
|
+
while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
|
|
201
|
+
lines.push(specLines[i]);
|
|
202
|
+
i++;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Find separator row
|
|
206
|
+
let separatorIndex = -1;
|
|
207
|
+
for (let j = 0; j < lines.length; j++) {
|
|
208
|
+
if (SEPARATOR_RE.test(lines[j])) {
|
|
209
|
+
separatorIndex = j;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Calculate column widths from all non-separator rows
|
|
215
|
+
const allCells = lines
|
|
216
|
+
.filter((_, j) => j !== separatorIndex)
|
|
217
|
+
.map(parseTableCells);
|
|
218
|
+
const maxCols = Math.max(...allCells.map((r) => r.length), 0);
|
|
219
|
+
const colWidths: number[] = new Array(maxCols).fill(0);
|
|
220
|
+
for (const row of allCells) {
|
|
221
|
+
for (let c = 0; c < row.length; c++) {
|
|
222
|
+
colWidths[c] = Math.max(colWidths[c], displayWidth(row[c]));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Minimum width of 3 per column
|
|
226
|
+
for (let c = 0; c < colWidths.length; c++) {
|
|
227
|
+
colWidths[c] = Math.max(colWidths[c], 3);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { startIndex: start, lines, separatorIndex, colWidths };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Render a table separator row with box-drawing characters. */
|
|
234
|
+
export function renderTableSeparator(parent: TextRenderable, colWidths: number[]): void {
|
|
235
|
+
const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
|
|
236
|
+
const line = "\u251c" + parts.join("\u253c") + "\u2524";
|
|
237
|
+
parent.add(TextNodeRenderable.fromString(line, { fg: theme.textDim }));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Render a table data/header row with padded, styled cells. */
|
|
241
|
+
export function renderTableRow(
|
|
242
|
+
parent: TextRenderable,
|
|
243
|
+
cells: string[],
|
|
244
|
+
colWidths: number[],
|
|
245
|
+
isHeader: boolean,
|
|
246
|
+
): void {
|
|
247
|
+
for (let c = 0; c < colWidths.length; c++) {
|
|
248
|
+
const cellText = c < cells.length ? cells[c] : "";
|
|
249
|
+
const dw = displayWidth(cellText);
|
|
250
|
+
const padding = Math.max(0, colWidths[c] - dw);
|
|
251
|
+
|
|
252
|
+
// Left border
|
|
253
|
+
parent.add(TextNodeRenderable.fromString(
|
|
254
|
+
c === 0 ? "\u2502 " : " \u2502 ",
|
|
255
|
+
{ fg: theme.textDim }
|
|
256
|
+
));
|
|
257
|
+
|
|
258
|
+
// Cell content — parse inline markdown, apply header bold
|
|
259
|
+
const segments = parseInlineMarkdown(cellText);
|
|
260
|
+
for (const seg of segments) {
|
|
261
|
+
const attrs = isHeader
|
|
262
|
+
? (seg.attributes ?? 0) | TextAttributes.BOLD
|
|
263
|
+
: seg.attributes;
|
|
264
|
+
parent.add(TextNodeRenderable.fromString(seg.text, {
|
|
265
|
+
fg: seg.fg ?? theme.text,
|
|
266
|
+
attributes: attrs,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Padding
|
|
271
|
+
if (padding > 0) {
|
|
272
|
+
parent.add(TextNodeRenderable.fromString(" ".repeat(padding), {}));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Right border
|
|
276
|
+
parent.add(TextNodeRenderable.fromString(
|
|
277
|
+
" \u2502",
|
|
278
|
+
{ fg: theme.textDim }
|
|
279
|
+
));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Render a top or bottom border for the table. */
|
|
283
|
+
export function renderTableBorder(parent: TextRenderable, colWidths: number[], position: "top" | "bottom"): void {
|
|
284
|
+
const [left, mid, right] = position === "top"
|
|
285
|
+
? ["\u250c", "\u252c", "\u2510"]
|
|
286
|
+
: ["\u2514", "\u2534", "\u2518"];
|
|
287
|
+
const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
|
|
288
|
+
parent.add(TextNodeRenderable.fromString(
|
|
289
|
+
left + parts.join(mid) + right,
|
|
290
|
+
{ fg: theme.textDim }
|
|
291
|
+
));
|
|
292
|
+
}
|