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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }