revspec 0.1.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/.github/workflows/ci.yml +18 -0
- package/README.md +90 -0
- package/bin/revspec.ts +109 -0
- package/bun.lock +213 -0
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +2139 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +331 -0
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +141 -0
- package/docs/superpowers/specs/claude-code-integration-notes.md +26 -0
- package/package.json +21 -0
- package/scripts/release.sh +76 -0
- package/src/protocol/merge.ts +52 -0
- package/src/protocol/read.ts +25 -0
- package/src/protocol/types.ts +55 -0
- package/src/protocol/write.ts +10 -0
- package/src/state/review-state.ts +136 -0
- package/src/tui/app.ts +691 -0
- package/src/tui/comment-input.ts +189 -0
- package/src/tui/confirm.ts +93 -0
- package/src/tui/help.ts +134 -0
- package/src/tui/pager.ts +158 -0
- package/src/tui/search.ts +119 -0
- package/src/tui/status-bar.ts +76 -0
- package/src/tui/theme.ts +34 -0
- package/src/tui/thread-list.ts +145 -0
- package/test/cli.test.ts +151 -0
- package/test/opentui-smoke.test.ts +12 -0
- package/test/protocol/merge.test.ts +100 -0
- package/test/protocol/read.test.ts +92 -0
- package/test/protocol/types.test.ts +95 -0
- package/test/protocol/write.test.ts +72 -0
- package/test/state/review-state.test.ts +326 -0
- package/test/tui/pager.test.ts +184 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
TextareaRenderable,
|
|
5
|
+
ScrollBoxRenderable,
|
|
6
|
+
type CliRenderer,
|
|
7
|
+
type KeyEvent,
|
|
8
|
+
} from "@opentui/core";
|
|
9
|
+
import type { Thread } from "../protocol/types";
|
|
10
|
+
import { theme } from "./theme";
|
|
11
|
+
|
|
12
|
+
export interface CommentInputOptions {
|
|
13
|
+
renderer: CliRenderer;
|
|
14
|
+
line: number;
|
|
15
|
+
existingThread: Thread | null;
|
|
16
|
+
onSubmit: (text: string) => void;
|
|
17
|
+
onResolve: () => void;
|
|
18
|
+
onCancel: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CommentInputOverlay {
|
|
22
|
+
container: BoxRenderable;
|
|
23
|
+
cleanup: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MAX_CONTEXT_LENGTH = 80;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a unified comment/thread overlay.
|
|
30
|
+
* - New comment: just a text input
|
|
31
|
+
* - Existing thread: scrollable conversation + reply input + resolve action
|
|
32
|
+
*
|
|
33
|
+
* Tab submits, Ctrl+R resolves, Esc cancels.
|
|
34
|
+
*/
|
|
35
|
+
export function createCommentInput(opts: CommentInputOptions): CommentInputOverlay {
|
|
36
|
+
const { renderer, line, existingThread, onSubmit, onResolve, onCancel } = opts;
|
|
37
|
+
|
|
38
|
+
const hasThread = existingThread && existingThread.messages.length > 0;
|
|
39
|
+
const label = existingThread
|
|
40
|
+
? `Thread #${existingThread.id} (line ${line}) [${existingThread.status.toUpperCase()}]`
|
|
41
|
+
: `New comment on line ${line}`;
|
|
42
|
+
|
|
43
|
+
// Larger overlay for threads with conversation, smaller for new comments
|
|
44
|
+
const overlayHeight = hasThread ? "80%" : 10;
|
|
45
|
+
|
|
46
|
+
const container = new BoxRenderable(renderer, {
|
|
47
|
+
position: "absolute",
|
|
48
|
+
top: hasThread ? "5%" : "30%",
|
|
49
|
+
left: "10%",
|
|
50
|
+
width: "80%",
|
|
51
|
+
height: overlayHeight,
|
|
52
|
+
zIndex: 100,
|
|
53
|
+
backgroundColor: theme.base,
|
|
54
|
+
border: true,
|
|
55
|
+
borderStyle: "single",
|
|
56
|
+
borderColor: theme.borderComment,
|
|
57
|
+
title: ` ${label} `,
|
|
58
|
+
flexDirection: "column",
|
|
59
|
+
padding: 1,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Show full thread conversation in a scrollable area
|
|
63
|
+
if (hasThread) {
|
|
64
|
+
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
65
|
+
width: "100%",
|
|
66
|
+
flexGrow: 1,
|
|
67
|
+
flexShrink: 1,
|
|
68
|
+
scrollY: true,
|
|
69
|
+
scrollX: false,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const lines: string[] = [];
|
|
73
|
+
for (const msg of existingThread!.messages) {
|
|
74
|
+
const authorLabel = msg.author === "human" ? "You" : " AI";
|
|
75
|
+
lines.push(`${authorLabel}:`);
|
|
76
|
+
for (const textLine of msg.text.split("\n")) {
|
|
77
|
+
lines.push(` ${textLine}`);
|
|
78
|
+
}
|
|
79
|
+
lines.push("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const messageText = new TextRenderable(renderer, {
|
|
83
|
+
content: lines.join("\n"),
|
|
84
|
+
width: "100%",
|
|
85
|
+
fg: theme.text,
|
|
86
|
+
wrapMode: "word",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
scrollBox.add(messageText);
|
|
90
|
+
container.add(scrollBox);
|
|
91
|
+
|
|
92
|
+
// Scroll to bottom to show latest message
|
|
93
|
+
setTimeout(() => {
|
|
94
|
+
scrollBox.scrollTo(scrollBox.scrollHeight);
|
|
95
|
+
renderer.requestRender();
|
|
96
|
+
}, 0);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Separator between conversation and input
|
|
100
|
+
if (hasThread) {
|
|
101
|
+
const sep = new TextRenderable(renderer, {
|
|
102
|
+
content: " Reply:",
|
|
103
|
+
width: "100%",
|
|
104
|
+
height: 1,
|
|
105
|
+
fg: theme.subtext,
|
|
106
|
+
wrapMode: "none",
|
|
107
|
+
});
|
|
108
|
+
container.add(sep);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const textarea = new TextareaRenderable(renderer, {
|
|
112
|
+
width: "100%",
|
|
113
|
+
height: hasThread ? 4 : undefined,
|
|
114
|
+
flexGrow: hasThread ? 0 : 1,
|
|
115
|
+
backgroundColor: theme.surface0,
|
|
116
|
+
textColor: theme.text,
|
|
117
|
+
focusedBackgroundColor: theme.surface0,
|
|
118
|
+
focusedTextColor: theme.text,
|
|
119
|
+
wrapMode: "word",
|
|
120
|
+
placeholder: hasThread ? "Type your reply..." : "Type your comment...",
|
|
121
|
+
placeholderColor: theme.overlay,
|
|
122
|
+
initialValue: "",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Hint line — show resolve option only for existing threads
|
|
126
|
+
const hintText = hasThread
|
|
127
|
+
? " [Tab] submit [Ctrl+R] resolve [Esc] cancel"
|
|
128
|
+
: " [Tab] submit [Esc] cancel";
|
|
129
|
+
|
|
130
|
+
const hint = new TextRenderable(renderer, {
|
|
131
|
+
content: hintText,
|
|
132
|
+
width: "100%",
|
|
133
|
+
height: 1,
|
|
134
|
+
fg: theme.hintFg,
|
|
135
|
+
bg: theme.hintBg,
|
|
136
|
+
wrapMode: "none",
|
|
137
|
+
truncate: true,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
container.add(textarea);
|
|
141
|
+
container.add(hint);
|
|
142
|
+
|
|
143
|
+
// Focus textarea
|
|
144
|
+
setTimeout(() => {
|
|
145
|
+
textarea.focus();
|
|
146
|
+
renderer.requestRender();
|
|
147
|
+
}, 0);
|
|
148
|
+
|
|
149
|
+
let submitted = false;
|
|
150
|
+
|
|
151
|
+
const keyHandler = (key: KeyEvent) => {
|
|
152
|
+
if (key.name === "escape") {
|
|
153
|
+
key.preventDefault();
|
|
154
|
+
key.stopPropagation();
|
|
155
|
+
onCancel();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Tab submits
|
|
159
|
+
if (key.name === "tab") {
|
|
160
|
+
key.preventDefault();
|
|
161
|
+
key.stopPropagation();
|
|
162
|
+
if (submitted) return;
|
|
163
|
+
submitted = true;
|
|
164
|
+
const text = textarea.plainText.trim();
|
|
165
|
+
if (text.length > 0) {
|
|
166
|
+
onSubmit(text);
|
|
167
|
+
} else {
|
|
168
|
+
onCancel();
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// Ctrl+R resolves thread (only for existing threads)
|
|
173
|
+
if (key.ctrl && key.name === "r" && hasThread) {
|
|
174
|
+
key.preventDefault();
|
|
175
|
+
key.stopPropagation();
|
|
176
|
+
onResolve();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
182
|
+
|
|
183
|
+
function cleanup(): void {
|
|
184
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
185
|
+
textarea.destroy();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { container, cleanup };
|
|
189
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
type KeyEvent,
|
|
6
|
+
} from "@opentui/core";
|
|
7
|
+
import { theme } from "./theme";
|
|
8
|
+
|
|
9
|
+
export interface ConfirmOptions {
|
|
10
|
+
renderer: CliRenderer;
|
|
11
|
+
message: string;
|
|
12
|
+
onConfirm: () => void;
|
|
13
|
+
onCancel: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ConfirmOverlay {
|
|
17
|
+
container: BoxRenderable;
|
|
18
|
+
cleanup: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a confirmation dialog overlay.
|
|
23
|
+
* Shows a message with [y/n] prompt.
|
|
24
|
+
* y → confirm, n/Esc → cancel
|
|
25
|
+
*/
|
|
26
|
+
export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
|
|
27
|
+
const { renderer, message, onConfirm, onCancel } = opts;
|
|
28
|
+
|
|
29
|
+
// Centered dialog
|
|
30
|
+
const container = new BoxRenderable(renderer, {
|
|
31
|
+
position: "absolute",
|
|
32
|
+
top: "35%",
|
|
33
|
+
left: "25%",
|
|
34
|
+
width: "50%",
|
|
35
|
+
height: 7,
|
|
36
|
+
zIndex: 100,
|
|
37
|
+
backgroundColor: theme.base,
|
|
38
|
+
border: true,
|
|
39
|
+
borderStyle: "single",
|
|
40
|
+
borderColor: theme.borderConfirm,
|
|
41
|
+
title: " Confirm ",
|
|
42
|
+
flexDirection: "column",
|
|
43
|
+
padding: 1,
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
justifyContent: "center",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const msgText = new TextRenderable(renderer, {
|
|
49
|
+
content: message,
|
|
50
|
+
width: "100%",
|
|
51
|
+
height: 1,
|
|
52
|
+
fg: theme.text,
|
|
53
|
+
wrapMode: "none",
|
|
54
|
+
truncate: true,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const hint = new TextRenderable(renderer, {
|
|
58
|
+
content: " [y] yes [n/Esc] no",
|
|
59
|
+
width: "100%",
|
|
60
|
+
height: 1,
|
|
61
|
+
fg: theme.hintFg,
|
|
62
|
+
bg: theme.hintBg,
|
|
63
|
+
wrapMode: "none",
|
|
64
|
+
truncate: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
container.add(msgText);
|
|
68
|
+
container.add(hint);
|
|
69
|
+
|
|
70
|
+
// Key handler
|
|
71
|
+
const keyHandler = (key: KeyEvent) => {
|
|
72
|
+
if (key.name === "y") {
|
|
73
|
+
key.preventDefault();
|
|
74
|
+
key.stopPropagation();
|
|
75
|
+
onConfirm();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (key.name === "n" || key.name === "escape") {
|
|
79
|
+
key.preventDefault();
|
|
80
|
+
key.stopPropagation();
|
|
81
|
+
onCancel();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
87
|
+
|
|
88
|
+
function cleanup(): void {
|
|
89
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { container, cleanup };
|
|
93
|
+
}
|
package/src/tui/help.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
ScrollBoxRenderable,
|
|
4
|
+
TextRenderable,
|
|
5
|
+
type CliRenderer,
|
|
6
|
+
type KeyEvent,
|
|
7
|
+
} from "@opentui/core";
|
|
8
|
+
import { theme } from "./theme";
|
|
9
|
+
|
|
10
|
+
export interface HelpOverlay {
|
|
11
|
+
container: BoxRenderable;
|
|
12
|
+
cleanup: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Create a help overlay popup showing all keybindings.
|
|
17
|
+
* Dismissable with `?` or `Esc`.
|
|
18
|
+
*/
|
|
19
|
+
export function createHelp(opts: {
|
|
20
|
+
renderer: CliRenderer;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
}): HelpOverlay {
|
|
23
|
+
const { renderer, onClose } = opts;
|
|
24
|
+
|
|
25
|
+
const helpText = [
|
|
26
|
+
"",
|
|
27
|
+
" Navigation",
|
|
28
|
+
" j/k Down/up",
|
|
29
|
+
" gg Go to first line / scroll to top",
|
|
30
|
+
" G Go to last line / scroll to bottom",
|
|
31
|
+
" Ctrl+d/u Half page down/up",
|
|
32
|
+
" / Search",
|
|
33
|
+
" n/N Next/prev search match",
|
|
34
|
+
" ]t/[t Next/prev thread",
|
|
35
|
+
"",
|
|
36
|
+
" View",
|
|
37
|
+
" m Toggle markdown / line mode",
|
|
38
|
+
"",
|
|
39
|
+
" Review (switches to line mode)",
|
|
40
|
+
" c Comment / view thread / reply",
|
|
41
|
+
" r Resolve thread",
|
|
42
|
+
" R Resolve all pending",
|
|
43
|
+
" dd Delete draft comment (double-tap)",
|
|
44
|
+
" l List threads",
|
|
45
|
+
" a Approve spec",
|
|
46
|
+
"",
|
|
47
|
+
" Commands",
|
|
48
|
+
" :w Save draft",
|
|
49
|
+
" :q Quit (blocks if unsaved)",
|
|
50
|
+
" :wq Save and quit",
|
|
51
|
+
" :q! Quit without saving",
|
|
52
|
+
"",
|
|
53
|
+
].join("\n");
|
|
54
|
+
|
|
55
|
+
// Overlay container - centered popup
|
|
56
|
+
const container = new BoxRenderable(renderer, {
|
|
57
|
+
position: "absolute",
|
|
58
|
+
top: "10%",
|
|
59
|
+
left: "20%",
|
|
60
|
+
width: "60%",
|
|
61
|
+
height: Math.min(26, renderer.height - 2),
|
|
62
|
+
zIndex: 100,
|
|
63
|
+
backgroundColor: theme.base,
|
|
64
|
+
border: true,
|
|
65
|
+
borderStyle: "single",
|
|
66
|
+
borderColor: theme.borderThread,
|
|
67
|
+
title: " Help ",
|
|
68
|
+
flexDirection: "column",
|
|
69
|
+
padding: 0,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
73
|
+
width: "100%",
|
|
74
|
+
flexGrow: 1,
|
|
75
|
+
flexShrink: 1,
|
|
76
|
+
scrollY: true,
|
|
77
|
+
scrollX: false,
|
|
78
|
+
backgroundColor: theme.base,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const content = new TextRenderable(renderer, {
|
|
82
|
+
content: helpText,
|
|
83
|
+
width: "100%",
|
|
84
|
+
fg: theme.text,
|
|
85
|
+
wrapMode: "none",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
scrollBox.add(content);
|
|
89
|
+
|
|
90
|
+
const hint = new TextRenderable(renderer, {
|
|
91
|
+
content: " [q/?/Esc] close [j/k] scroll",
|
|
92
|
+
width: "100%",
|
|
93
|
+
height: 1,
|
|
94
|
+
fg: theme.hintFg,
|
|
95
|
+
bg: theme.hintBg,
|
|
96
|
+
wrapMode: "none",
|
|
97
|
+
truncate: true,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
container.add(scrollBox);
|
|
101
|
+
container.add(hint);
|
|
102
|
+
|
|
103
|
+
// Key handler
|
|
104
|
+
const keyHandler = (key: KeyEvent) => {
|
|
105
|
+
if (key.name === "escape" || key.name === "q" || key.sequence === "?") {
|
|
106
|
+
key.preventDefault();
|
|
107
|
+
key.stopPropagation();
|
|
108
|
+
onClose();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (key.name === "j" || key.name === "down") {
|
|
112
|
+
key.preventDefault();
|
|
113
|
+
key.stopPropagation();
|
|
114
|
+
scrollBox.scrollBy(1);
|
|
115
|
+
renderer.requestRender();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (key.name === "k" || key.name === "up") {
|
|
119
|
+
key.preventDefault();
|
|
120
|
+
key.stopPropagation();
|
|
121
|
+
scrollBox.scrollBy(-1);
|
|
122
|
+
renderer.requestRender();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
128
|
+
|
|
129
|
+
function cleanup(): void {
|
|
130
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { container, cleanup };
|
|
134
|
+
}
|
package/src/tui/pager.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { ReviewState } from "../state/review-state";
|
|
2
|
+
import type { Thread } from "../protocol/types";
|
|
3
|
+
import {
|
|
4
|
+
ScrollBoxRenderable,
|
|
5
|
+
TextRenderable,
|
|
6
|
+
MarkdownRenderable,
|
|
7
|
+
SyntaxStyle,
|
|
8
|
+
parseColor,
|
|
9
|
+
type CliRenderer,
|
|
10
|
+
} from "@opentui/core";
|
|
11
|
+
import { theme, STATUS_ICONS } from "./theme";
|
|
12
|
+
|
|
13
|
+
const MAX_HINT_LENGTH = 40;
|
|
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
|
+
function threadHint(thread: Thread): string {
|
|
22
|
+
if (thread.messages.length === 0) return "";
|
|
23
|
+
const last = thread.messages[thread.messages.length - 1];
|
|
24
|
+
const text = last.text.replace(/\n/g, " ");
|
|
25
|
+
if (text.length <= MAX_HINT_LENGTH) return text;
|
|
26
|
+
return text.slice(0, MAX_HINT_LENGTH - 1) + "\u2026";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build plain text line-mode content (for commenting).
|
|
31
|
+
* Each line: cursor marker + lineNum + content + thread indicator + hint.
|
|
32
|
+
*/
|
|
33
|
+
export function buildPagerContent(state: ReviewState, searchQuery?: string | null): string {
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < state.specLines.length; i++) {
|
|
37
|
+
const lineNum = i + 1;
|
|
38
|
+
const thread = state.threadAtLine(lineNum);
|
|
39
|
+
const isCursor = lineNum === state.cursorLine;
|
|
40
|
+
|
|
41
|
+
const prefix = isCursor ? ">" : " ";
|
|
42
|
+
let specText = state.specLines[i];
|
|
43
|
+
|
|
44
|
+
if (searchQuery) {
|
|
45
|
+
const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
|
|
46
|
+
specText = specText.replace(regex, (match) => `>>${match}<<`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let line = `${prefix}${padLineNum(lineNum)} ${specText}`;
|
|
50
|
+
|
|
51
|
+
if (thread) {
|
|
52
|
+
const icon = STATUS_ICONS[thread.status];
|
|
53
|
+
const hint = threadHint(thread);
|
|
54
|
+
line += ` ${icon} ${hint}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
lines.push(line);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createMarkdownStyle(): SyntaxStyle {
|
|
64
|
+
return SyntaxStyle.fromStyles({
|
|
65
|
+
default: { fg: parseColor(theme.text) },
|
|
66
|
+
"markup.heading": { fg: parseColor(theme.blue), bold: true },
|
|
67
|
+
"markup.heading.1": { fg: parseColor(theme.blue), bold: true },
|
|
68
|
+
"markup.heading.2": { fg: parseColor(theme.blue), bold: true },
|
|
69
|
+
"markup.heading.3": { fg: parseColor(theme.mauve), bold: true },
|
|
70
|
+
"markup.heading.4": { fg: parseColor(theme.mauve) },
|
|
71
|
+
"markup.heading.5": { fg: parseColor(theme.mauve) },
|
|
72
|
+
"markup.heading.6": { fg: parseColor(theme.mauve) },
|
|
73
|
+
"markup.bold": { fg: parseColor(theme.text), bold: true },
|
|
74
|
+
"markup.strong": { fg: parseColor(theme.text), bold: true },
|
|
75
|
+
"markup.italic": { fg: parseColor(theme.text), italic: true },
|
|
76
|
+
"markup.link": { fg: parseColor(theme.blue) },
|
|
77
|
+
"markup.link.url": { fg: parseColor(theme.blue) },
|
|
78
|
+
"markup.list": { fg: parseColor(theme.yellow) },
|
|
79
|
+
"markup.raw": { fg: parseColor(theme.green) },
|
|
80
|
+
"markup.raw.inline": { fg: parseColor(theme.green) },
|
|
81
|
+
"string": { fg: parseColor(theme.green) },
|
|
82
|
+
"comment": { fg: parseColor(theme.overlay) },
|
|
83
|
+
"punctuation.special": { fg: parseColor(theme.overlay) },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PagerComponents {
|
|
88
|
+
scrollBox: ScrollBoxRenderable;
|
|
89
|
+
/** Plain text node for line mode */
|
|
90
|
+
lineNode: TextRenderable;
|
|
91
|
+
/** Rendered markdown node for reading mode */
|
|
92
|
+
markdownNode: MarkdownRenderable;
|
|
93
|
+
/** Current mode */
|
|
94
|
+
mode: "markdown" | "line";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Create the pager with both a markdown view and a line-mode view.
|
|
99
|
+
* Only one is visible at a time. Toggle with `m`.
|
|
100
|
+
*/
|
|
101
|
+
export function createPager(renderer: CliRenderer): PagerComponents {
|
|
102
|
+
// Line mode (default) — plain text with line numbers, cursor, thread indicators
|
|
103
|
+
const lineNode = new TextRenderable(renderer, {
|
|
104
|
+
content: "",
|
|
105
|
+
width: "100%",
|
|
106
|
+
wrapMode: "none",
|
|
107
|
+
fg: theme.text,
|
|
108
|
+
bg: theme.base,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Markdown mode — rendered markdown, full-width, beautiful reading
|
|
112
|
+
const markdownNode = new MarkdownRenderable(renderer, {
|
|
113
|
+
content: "",
|
|
114
|
+
width: "100%",
|
|
115
|
+
syntaxStyle: createMarkdownStyle(),
|
|
116
|
+
conceal: true,
|
|
117
|
+
visible: false, // hidden by default — line mode is default
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Scrollable container
|
|
121
|
+
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
122
|
+
width: "100%",
|
|
123
|
+
flexGrow: 1,
|
|
124
|
+
flexShrink: 1,
|
|
125
|
+
scrollY: true,
|
|
126
|
+
scrollX: false,
|
|
127
|
+
backgroundColor: theme.base,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
scrollBox.add(markdownNode);
|
|
131
|
+
scrollBox.add(lineNode);
|
|
132
|
+
|
|
133
|
+
return { scrollBox, lineNode, markdownNode, mode: "line" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Toggle between markdown and line mode.
|
|
138
|
+
*/
|
|
139
|
+
export function togglePagerMode(pager: PagerComponents): void {
|
|
140
|
+
if (pager.mode === "markdown") {
|
|
141
|
+
pager.mode = "line";
|
|
142
|
+
pager.markdownNode.visible = false;
|
|
143
|
+
pager.lineNode.visible = true;
|
|
144
|
+
} else {
|
|
145
|
+
pager.mode = "markdown";
|
|
146
|
+
pager.lineNode.visible = false;
|
|
147
|
+
pager.markdownNode.visible = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Switch to line mode (for commenting).
|
|
153
|
+
*/
|
|
154
|
+
export function ensureLineMode(pager: PagerComponents): void {
|
|
155
|
+
if (pager.mode !== "line") {
|
|
156
|
+
togglePagerMode(pager);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
InputRenderable,
|
|
4
|
+
TextRenderable,
|
|
5
|
+
type CliRenderer,
|
|
6
|
+
type KeyEvent,
|
|
7
|
+
} from "@opentui/core";
|
|
8
|
+
import { theme } from "./theme";
|
|
9
|
+
|
|
10
|
+
export interface SearchOptions {
|
|
11
|
+
renderer: CliRenderer;
|
|
12
|
+
specLines: string[];
|
|
13
|
+
cursorLine: number;
|
|
14
|
+
onResult: (lineNumber: number, query: string) => void;
|
|
15
|
+
onCancel: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SearchOverlay {
|
|
19
|
+
container: BoxRenderable;
|
|
20
|
+
cleanup: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create a search overlay at the bottom of the screen.
|
|
25
|
+
* On Enter: search forward from cursorLine, wrapping around.
|
|
26
|
+
* On Escape: cancel.
|
|
27
|
+
*/
|
|
28
|
+
export function createSearch(opts: SearchOptions): SearchOverlay {
|
|
29
|
+
const { renderer, specLines, cursorLine, onResult, onCancel } = opts;
|
|
30
|
+
|
|
31
|
+
// Container bar at bottom
|
|
32
|
+
const container = new BoxRenderable(renderer, {
|
|
33
|
+
position: "absolute",
|
|
34
|
+
bottom: 0,
|
|
35
|
+
left: 0,
|
|
36
|
+
width: "100%",
|
|
37
|
+
height: 1,
|
|
38
|
+
zIndex: 100,
|
|
39
|
+
backgroundColor: theme.surface0,
|
|
40
|
+
flexDirection: "row",
|
|
41
|
+
alignItems: "center",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Search label
|
|
45
|
+
const label = new TextRenderable(renderer, {
|
|
46
|
+
content: " / ",
|
|
47
|
+
width: 3,
|
|
48
|
+
height: 1,
|
|
49
|
+
fg: theme.yellow,
|
|
50
|
+
bg: theme.surface0,
|
|
51
|
+
wrapMode: "none",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Search input
|
|
55
|
+
const input = new InputRenderable(renderer, {
|
|
56
|
+
width: "100%",
|
|
57
|
+
flexGrow: 1,
|
|
58
|
+
backgroundColor: theme.surface0,
|
|
59
|
+
textColor: theme.text,
|
|
60
|
+
focusedBackgroundColor: theme.surface1,
|
|
61
|
+
focusedTextColor: theme.text,
|
|
62
|
+
placeholder: "Search...",
|
|
63
|
+
placeholderColor: theme.overlay,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
container.add(label);
|
|
67
|
+
container.add(input);
|
|
68
|
+
|
|
69
|
+
// Focus the input after mount (same pattern as comment-input)
|
|
70
|
+
setTimeout(() => {
|
|
71
|
+
input.focus();
|
|
72
|
+
renderer.requestRender();
|
|
73
|
+
}, 0);
|
|
74
|
+
|
|
75
|
+
// Key handler
|
|
76
|
+
const keyHandler = (key: KeyEvent) => {
|
|
77
|
+
if (key.name === "escape") {
|
|
78
|
+
key.preventDefault();
|
|
79
|
+
key.stopPropagation();
|
|
80
|
+
onCancel();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (key.name === "return") {
|
|
84
|
+
key.preventDefault();
|
|
85
|
+
key.stopPropagation();
|
|
86
|
+
const query = input.value.trim().toLowerCase();
|
|
87
|
+
if (query.length === 0) {
|
|
88
|
+
onCancel();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Search forward from cursor, wrapping around
|
|
93
|
+
const total = specLines.length;
|
|
94
|
+
for (let offset = 1; offset <= total; offset++) {
|
|
95
|
+
const i = (cursorLine - 1 + offset) % total;
|
|
96
|
+
if (specLines[i].toLowerCase().includes(query)) {
|
|
97
|
+
onResult(i + 1, query); // 1-based line number + query
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// No match found — show feedback in the input placeholder, keep search open
|
|
103
|
+
input.placeholder = "No match";
|
|
104
|
+
input.placeholderColor = theme.red;
|
|
105
|
+
input.value = "";
|
|
106
|
+
renderer.requestRender();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
112
|
+
|
|
113
|
+
function cleanup(): void {
|
|
114
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
115
|
+
input.destroy();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { container, cleanup };
|
|
119
|
+
}
|