revspec 0.2.2 → 0.4.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.
@@ -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,104 @@
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
+
57
+ if (sequence) {
58
+ const seq = sequence.first + keyStr;
59
+ clearTimeout(sequence.timer);
60
+ sequence = null;
61
+
62
+ const action = sequenceBindings.get(seq);
63
+ if (action) return action;
64
+ }
65
+
66
+ // Check ctrl variants first
67
+ if (key.ctrl && key.name) {
68
+ const action = singleBindings.get(`C-${key.name}`);
69
+ if (action) return action;
70
+ }
71
+
72
+ // Check if this starts a sequence (but not if ctrl is held)
73
+ if (!key.ctrl && sequenceStarters.has(keyStr)) {
74
+ sequence = {
75
+ first: keyStr,
76
+ timer: setTimeout(() => { sequence = null; }, timeout),
77
+ };
78
+ return null;
79
+ }
80
+
81
+ // Shift variants
82
+ if (key.shift && key.name) {
83
+ const upper = key.name.toUpperCase();
84
+ const action = singleBindings.get(upper);
85
+ if (action) return action;
86
+ }
87
+
88
+ return singleBindings.get(keyStr) ?? null;
89
+ }
90
+
91
+ function pendingStr(): string | null {
92
+ if (!sequence) return null;
93
+ return `${sequence.first}...`;
94
+ }
95
+
96
+ function destroy(): void {
97
+ if (sequence) {
98
+ clearTimeout(sequence.timer);
99
+ sequence = null;
100
+ }
101
+ }
102
+
103
+ return { match, pending: pendingStr, destroy };
104
+ }
@@ -0,0 +1,251 @@
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, code) into styled segments.
18
+ * Strips syntax markers and returns display text with style info.
19
+ */
20
+ export function parseInlineMarkdown(text: string): StyledSegment[] {
21
+ const segments: StyledSegment[] = [];
22
+ // Order matters: **bold** before *italic*
23
+ const regex = /(\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`)/g;
24
+ let pos = 0;
25
+ let match;
26
+ while ((match = regex.exec(text)) !== null) {
27
+ if (match.index > pos) {
28
+ segments.push({ text: text.slice(pos, match.index) });
29
+ }
30
+ if (match[2] !== undefined) {
31
+ // **bold**
32
+ segments.push({ text: match[2], attributes: TextAttributes.BOLD });
33
+ } else if (match[3] !== undefined) {
34
+ // *italic*
35
+ segments.push({ text: match[3], attributes: TextAttributes.ITALIC });
36
+ } else if (match[4] !== undefined) {
37
+ // `code`
38
+ segments.push({ text: match[4], fg: theme.green });
39
+ }
40
+ pos = match.index + match[0].length;
41
+ }
42
+ if (pos < text.length) {
43
+ segments.push({ text: text.slice(pos) });
44
+ }
45
+ if (segments.length === 0) {
46
+ segments.push({ text });
47
+ }
48
+ return segments;
49
+ }
50
+
51
+ /**
52
+ * Parse a full line of markdown into styled segments.
53
+ * Handles block-level syntax (headings, lists, blockquotes, hr)
54
+ * and delegates inline content to parseInlineMarkdown.
55
+ */
56
+ export function parseMarkdownLine(line: string): StyledSegment[] {
57
+ // Heading: # ... ###### (strip markers, bold + colored)
58
+ const headingMatch = line.match(/^(#{1,6})\s+(.*)/);
59
+ if (headingMatch) {
60
+ const level = headingMatch[1].length;
61
+ const color = level <= 2 ? theme.blue : theme.mauve;
62
+ // Parse inline markdown within heading text
63
+ const inner = parseInlineMarkdown(headingMatch[2]);
64
+ return inner.map((s) => ({
65
+ ...s,
66
+ fg: s.fg ?? color,
67
+ attributes: (s.attributes ?? 0) | TextAttributes.BOLD,
68
+ }));
69
+ }
70
+
71
+ // Horizontal rule: --- or *** or ___
72
+ if (/^(\s*[-*_]\s*){3,}$/.test(line)) {
73
+ return [{ text: "\u2500".repeat(40), fg: theme.textDim, attributes: TextAttributes.DIM }];
74
+ }
75
+
76
+ // Blockquote: > text
77
+ if (line.startsWith("> ")) {
78
+ const inner = parseInlineMarkdown(line.slice(2));
79
+ return [
80
+ { text: "\u2502 ", fg: theme.mauve },
81
+ ...inner.map((s) => ({
82
+ ...s,
83
+ fg: s.fg ?? theme.textDim,
84
+ attributes: (s.attributes ?? 0) | TextAttributes.ITALIC,
85
+ })),
86
+ ];
87
+ }
88
+
89
+ // Unordered list: - item, * item, + item
90
+ const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)/);
91
+ if (ulMatch) {
92
+ return [
93
+ { text: ulMatch[1] + "\u2022 ", fg: theme.yellow },
94
+ ...parseInlineMarkdown(ulMatch[3]),
95
+ ];
96
+ }
97
+
98
+ // Ordered list: 1. item
99
+ const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
100
+ if (olMatch) {
101
+ return [
102
+ { text: `${olMatch[1]}${olMatch[2]}. `, fg: theme.yellow },
103
+ ...parseInlineMarkdown(olMatch[3]),
104
+ ];
105
+ }
106
+
107
+ // Regular line — parse inline markdown only
108
+ return parseInlineMarkdown(line);
109
+ }
110
+
111
+ /**
112
+ * Add styled segments as TextNodeRenderable children to a parent.
113
+ */
114
+ export function addSegments(parent: TextRenderable, segments: StyledSegment[], defaultFg: string, bg?: string): void {
115
+ for (const seg of segments) {
116
+ const node = TextNodeRenderable.fromString(seg.text, {
117
+ fg: seg.fg ?? defaultFg,
118
+ attributes: seg.attributes,
119
+ bg,
120
+ });
121
+ parent.add(node);
122
+ }
123
+ }
124
+
125
+ // --- Table rendering ---
126
+
127
+ export const SEPARATOR_RE = /^\|[\s:]*-+[\s:]*(\|[\s:]*-+[\s:]*)*\|?\s*$/;
128
+
129
+ /** Split a table row into trimmed cell values (strips outer pipes). */
130
+ export function parseTableCells(line: string): string[] {
131
+ const trimmed = line.trim();
132
+ // Remove leading/trailing pipes and split
133
+ const inner = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
134
+ const withoutTrailing = inner.endsWith("|") ? inner.slice(0, -1) : inner;
135
+ return withoutTrailing.split("|").map((c) => c.trim());
136
+ }
137
+
138
+ /** Compute the display width of a string (strips inline markdown markers). */
139
+ export function displayWidth(text: string): number {
140
+ // Remove **bold**, *italic*, `code` markers to get display length
141
+ return text
142
+ .replace(/\*\*(.+?)\*\*/g, "$1")
143
+ .replace(/\*(.+?)\*/g, "$1")
144
+ .replace(/`([^`]+)`/g, "$1")
145
+ .length;
146
+ }
147
+
148
+ export interface TableBlock {
149
+ startIndex: number;
150
+ lines: string[];
151
+ separatorIndex: number; // relative to startIndex, -1 if none
152
+ colWidths: number[];
153
+ }
154
+
155
+ /** Scan ahead from a starting `|` line and collect the full table block. */
156
+ export function collectTable(specLines: string[], start: number): TableBlock {
157
+ const lines: string[] = [];
158
+ let i = start;
159
+ while (i < specLines.length && specLines[i].trimStart().startsWith("|")) {
160
+ lines.push(specLines[i]);
161
+ i++;
162
+ }
163
+
164
+ // Find separator row
165
+ let separatorIndex = -1;
166
+ for (let j = 0; j < lines.length; j++) {
167
+ if (SEPARATOR_RE.test(lines[j])) {
168
+ separatorIndex = j;
169
+ break;
170
+ }
171
+ }
172
+
173
+ // Calculate column widths from all non-separator rows
174
+ const allCells = lines
175
+ .filter((_, j) => j !== separatorIndex)
176
+ .map(parseTableCells);
177
+ const maxCols = Math.max(...allCells.map((r) => r.length), 0);
178
+ const colWidths: number[] = new Array(maxCols).fill(0);
179
+ for (const row of allCells) {
180
+ for (let c = 0; c < row.length; c++) {
181
+ colWidths[c] = Math.max(colWidths[c], displayWidth(row[c]));
182
+ }
183
+ }
184
+ // Minimum width of 3 per column
185
+ for (let c = 0; c < colWidths.length; c++) {
186
+ colWidths[c] = Math.max(colWidths[c], 3);
187
+ }
188
+
189
+ return { startIndex: start, lines, separatorIndex, colWidths };
190
+ }
191
+
192
+ /** Render a table separator row with box-drawing characters. */
193
+ export function renderTableSeparator(parent: TextRenderable, colWidths: number[]): void {
194
+ const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
195
+ const line = "\u251c" + parts.join("\u253c") + "\u2524";
196
+ parent.add(TextNodeRenderable.fromString(line, { fg: theme.textDim }));
197
+ }
198
+
199
+ /** Render a table data/header row with padded, styled cells. */
200
+ export function renderTableRow(
201
+ parent: TextRenderable,
202
+ cells: string[],
203
+ colWidths: number[],
204
+ isHeader: boolean,
205
+ ): void {
206
+ for (let c = 0; c < colWidths.length; c++) {
207
+ const cellText = c < cells.length ? cells[c] : "";
208
+ const dw = displayWidth(cellText);
209
+ const padding = Math.max(0, colWidths[c] - dw);
210
+
211
+ // Left border
212
+ parent.add(TextNodeRenderable.fromString(
213
+ c === 0 ? "\u2502 " : " \u2502 ",
214
+ { fg: theme.textDim }
215
+ ));
216
+
217
+ // Cell content — parse inline markdown, apply header bold
218
+ const segments = parseInlineMarkdown(cellText);
219
+ for (const seg of segments) {
220
+ const attrs = isHeader
221
+ ? (seg.attributes ?? 0) | TextAttributes.BOLD
222
+ : seg.attributes;
223
+ parent.add(TextNodeRenderable.fromString(seg.text, {
224
+ fg: seg.fg ?? theme.text,
225
+ attributes: attrs,
226
+ }));
227
+ }
228
+
229
+ // Padding
230
+ if (padding > 0) {
231
+ parent.add(TextNodeRenderable.fromString(" ".repeat(padding), {}));
232
+ }
233
+ }
234
+ // Right border
235
+ parent.add(TextNodeRenderable.fromString(
236
+ " \u2502",
237
+ { fg: theme.textDim }
238
+ ));
239
+ }
240
+
241
+ /** Render a top or bottom border for the table. */
242
+ export function renderTableBorder(parent: TextRenderable, colWidths: number[], position: "top" | "bottom"): void {
243
+ const [left, mid, right] = position === "top"
244
+ ? ["\u250c", "\u252c", "\u2510"]
245
+ : ["\u2514", "\u2534", "\u2518"];
246
+ const parts = colWidths.map((w) => "\u2500".repeat(w + 2));
247
+ parent.add(TextNodeRenderable.fromString(
248
+ left + parts.join(mid) + right,
249
+ { fg: theme.textDim }
250
+ ));
251
+ }
@@ -0,0 +1,49 @@
1
+ export const theme = {
2
+ // Surfaces
3
+ base: "#1e1e2e",
4
+ backgroundPanel: "#313244",
5
+ backgroundElement: "#45475a",
6
+
7
+ // Text hierarchy
8
+ text: "#cdd6f4",
9
+ textMuted: "#a6adc8",
10
+ textDim: "#6c7086",
11
+
12
+ // Semantic accents
13
+ blue: "#89b4fa",
14
+ green: "#a6e3a1",
15
+ red: "#f38ba8",
16
+ yellow: "#f9e2af",
17
+ mauve: "#cba6f7",
18
+
19
+ // Borders
20
+ border: "#45475a",
21
+ borderAccent: "#89b4fa",
22
+
23
+ // Status
24
+ success: "#a6e3a1",
25
+ warning: "#f9e2af",
26
+ error: "#f38ba8",
27
+ info: "#89b4fa",
28
+ } as const;
29
+
30
+ export const STATUS_ICONS: Record<string, string> = {
31
+ open: "\u258c", // ▌ half block
32
+ pending: "\u25cb", // ○ circle
33
+ resolved: "\u2713", // ✓ checkmark
34
+ outdated: "\u223c", // ∼ tilde
35
+ };
36
+
37
+ export const SPLIT_BORDER = {
38
+ topLeft: " ",
39
+ topRight: " ",
40
+ bottomLeft: " ",
41
+ bottomRight: " ",
42
+ horizontal: " ",
43
+ vertical: "\u2503",
44
+ topT: " ",
45
+ bottomT: " ",
46
+ leftT: "\u2503",
47
+ rightT: " ",
48
+ cross: " ",
49
+ } as const;
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { createKeybindRegistry } from "../../../src/tui/ui/keybinds";
3
+
4
+ function makeKey(name: string, opts: { ctrl?: boolean; shift?: boolean; sequence?: string } = {}): any {
5
+ return { name, ctrl: opts.ctrl ?? false, shift: opts.shift ?? false, sequence: opts.sequence ?? name };
6
+ }
7
+
8
+ describe("createKeybindRegistry", () => {
9
+ it("matches single keys", () => {
10
+ const reg = createKeybindRegistry([
11
+ { key: "j", action: "down" },
12
+ { key: "k", action: "up" },
13
+ ]);
14
+ expect(reg.match(makeKey("j"))).toBe("down");
15
+ expect(reg.match(makeKey("k"))).toBe("up");
16
+ expect(reg.match(makeKey("x"))).toBeNull();
17
+ reg.destroy();
18
+ });
19
+
20
+ it("matches ctrl keys", () => {
21
+ const reg = createKeybindRegistry([
22
+ { key: "C-d", action: "half-page-down" },
23
+ ]);
24
+ expect(reg.match(makeKey("d", { ctrl: true }))).toBe("half-page-down");
25
+ expect(reg.match(makeKey("d"))).toBeNull();
26
+ reg.destroy();
27
+ });
28
+
29
+ it("matches shift keys", () => {
30
+ const reg = createKeybindRegistry([
31
+ { key: "G", action: "goto-bottom" },
32
+ { key: "R", action: "resolve-all" },
33
+ ]);
34
+ expect(reg.match(makeKey("g", { shift: true }))).toBe("goto-bottom");
35
+ expect(reg.match(makeKey("r", { shift: true }))).toBe("resolve-all");
36
+ reg.destroy();
37
+ });
38
+
39
+ it("matches two-key sequences", () => {
40
+ const reg = createKeybindRegistry([
41
+ { key: "gg", action: "goto-top" },
42
+ { key: "dd", action: "delete" },
43
+ ]);
44
+ expect(reg.match(makeKey("g"))).toBeNull();
45
+ expect(reg.pending()).toBe("g...");
46
+ expect(reg.match(makeKey("g"))).toBe("goto-top");
47
+ expect(reg.pending()).toBeNull();
48
+ reg.destroy();
49
+ });
50
+
51
+ it("clears sequence on invalid second key", () => {
52
+ const reg = createKeybindRegistry([
53
+ { key: "gg", action: "goto-top" },
54
+ { key: "j", action: "down" },
55
+ ]);
56
+ expect(reg.match(makeKey("g"))).toBeNull();
57
+ expect(reg.match(makeKey("x"))).toBeNull();
58
+ expect(reg.match(makeKey("j"))).toBe("down");
59
+ reg.destroy();
60
+ });
61
+
62
+ it("handles bracket sequences", () => {
63
+ const reg = createKeybindRegistry([
64
+ { key: "]t", action: "next-thread" },
65
+ { key: "[t", action: "prev-thread" },
66
+ ]);
67
+ expect(reg.match(makeKey("]", { sequence: "]" }))).toBeNull();
68
+ expect(reg.match(makeKey("t"))).toBe("next-thread");
69
+ reg.destroy();
70
+ });
71
+ });
package/src/tui/theme.ts DELETED
@@ -1,34 +0,0 @@
1
- export const theme = {
2
- // Base surfaces
3
- base: "#1e1e2e",
4
- surface0: "#313244",
5
- surface1: "#45475a",
6
-
7
- // Text hierarchy
8
- text: "#cdd6f4",
9
- subtext: "#a6adc8",
10
- overlay: "#6c7086",
11
-
12
- // Semantic accents
13
- blue: "#89b4fa",
14
- green: "#a6e3a1",
15
- red: "#f38ba8",
16
- yellow: "#f9e2af",
17
- mauve: "#cba6f7",
18
-
19
- // Derived roles
20
- borderComment: "#89b4fa",
21
- borderThread: "#f9e2af", // yellow for informational view
22
- borderList: "#cba6f7",
23
- borderConfirm: "#f38ba8",
24
- borderSearch: "#89b4fa",
25
- hintFg: "#6c7086",
26
- hintBg: "#313244",
27
- } as const;
28
-
29
- export const STATUS_ICONS: Record<string, string> = {
30
- open: "\u258c", // ▌ half block
31
- pending: "\u258c", // ▌ half block
32
- resolved: "\u2713", // ✓ checkmark
33
- outdated: "\u258c", // ▌ half block
34
- };