revspec 0.5.0 → 0.7.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 +84 -67
- package/bin/revspec.ts +4 -38
- package/package.json +20 -3
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +69 -41
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +168 -107
- package/src/tui/comment-input.ts +21 -14
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +77 -20
- package/src/tui/pager.ts +4 -2
- package/src/tui/search.ts +9 -4
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -8
- package/src/tui/thread-list.ts +62 -22
- package/src/tui/ui/keymap.ts +55 -0
- package/.github/workflows/ci.yml +0 -18
- package/CLAUDE.md +0 -27
- package/bun.lock +0 -213
- package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
- package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
- package/scripts/install-skill.sh +0 -20
- package/scripts/release.sh +0 -52
- package/test/cli-reply.test.ts +0 -140
- package/test/cli-watch.test.ts +0 -216
- package/test/cli.test.ts +0 -160
- package/test/e2e-live.test.ts +0 -171
- package/test/live-interaction.test.ts +0 -398
- package/test/opentui-smoke.test.ts +0 -12
- package/test/protocol/live-events.test.ts +0 -509
- package/test/protocol/live-merge.test.ts +0 -167
- package/test/protocol/merge.test.ts +0 -100
- package/test/protocol/read.test.ts +0 -92
- package/test/protocol/types.test.ts +0 -95
- package/test/protocol/write.test.ts +0 -72
- package/test/state/review-state.test.ts +0 -399
- package/test/tui/pager.test.ts +0 -159
- package/test/tui/ui/keybinds.test.ts +0 -71
- package/tsconfig.json +0 -14
package/src/tui/confirm.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "@opentui/core";
|
|
6
6
|
import { theme } from "./ui/theme";
|
|
7
7
|
import { createDialog } from "./ui/dialog";
|
|
8
|
+
import { CONFIRM_HINTS } from "./ui/keymap";
|
|
8
9
|
|
|
9
10
|
export interface ConfirmOptions {
|
|
10
11
|
renderer: CliRenderer;
|
|
@@ -36,10 +37,7 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
|
|
|
36
37
|
left: "25%",
|
|
37
38
|
borderColor: theme.warning,
|
|
38
39
|
onDismiss: onCancel,
|
|
39
|
-
hints:
|
|
40
|
-
{ key: "y", action: "yes" },
|
|
41
|
-
{ key: "n/Esc", action: "no" },
|
|
42
|
-
],
|
|
40
|
+
hints: CONFIRM_HINTS,
|
|
43
41
|
});
|
|
44
42
|
|
|
45
43
|
const msgText = new TextRenderable(renderer, {
|
|
@@ -52,13 +50,13 @@ export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
|
|
|
52
50
|
dialog.content.add(msgText);
|
|
53
51
|
|
|
54
52
|
const extraKeyHandler = (key: KeyEvent) => {
|
|
55
|
-
if (key.name === "y") {
|
|
53
|
+
if (key.name === "y" || key.name === "return") {
|
|
56
54
|
key.preventDefault();
|
|
57
55
|
key.stopPropagation();
|
|
58
56
|
onConfirm();
|
|
59
57
|
return;
|
|
60
58
|
}
|
|
61
|
-
if (key.name === "
|
|
59
|
+
if (key.name === "q") {
|
|
62
60
|
key.preventDefault();
|
|
63
61
|
key.stopPropagation();
|
|
64
62
|
onCancel();
|
package/src/tui/help.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "@opentui/core";
|
|
7
7
|
import { theme } from "./ui/theme";
|
|
8
8
|
import { createDialog } from "./ui/dialog";
|
|
9
|
+
import { HELP_HINTS } from "./ui/keymap";
|
|
9
10
|
|
|
10
11
|
export interface HelpOverlay {
|
|
11
12
|
container: import("@opentui/core").BoxRenderable;
|
|
@@ -50,15 +51,12 @@ export function createHelp(opts: {
|
|
|
50
51
|
renderer,
|
|
51
52
|
title: "Help",
|
|
52
53
|
width: "60%",
|
|
53
|
-
height: Math.min(
|
|
54
|
+
height: Math.min(34, renderer.height - 2),
|
|
54
55
|
top: "10%",
|
|
55
56
|
left: "20%",
|
|
56
57
|
borderColor: theme.info,
|
|
57
58
|
onDismiss: onClose,
|
|
58
|
-
hints:
|
|
59
|
-
{ key: "q/?/Esc", action: "close" },
|
|
60
|
-
{ key: "j/k", action: "scroll" },
|
|
61
|
-
],
|
|
59
|
+
hints: HELP_HINTS,
|
|
62
60
|
});
|
|
63
61
|
|
|
64
62
|
// Version header
|
|
@@ -71,41 +69,58 @@ export function createHelp(opts: {
|
|
|
71
69
|
wrapMode: "none",
|
|
72
70
|
}));
|
|
73
71
|
|
|
72
|
+
addHelpSection(dialog.content, renderer, "Quick Start", [
|
|
73
|
+
" Navigate to a line and press c to comment.",
|
|
74
|
+
" The AI replies in real-time via the thread popup.",
|
|
75
|
+
" Press r to resolve, S to submit for rewrite.",
|
|
76
|
+
" Press A to approve when done.",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
addHelpSection(dialog.content, renderer, "Thread Popup", [
|
|
80
|
+
" New thread: INSERT mode — type and Tab to send.",
|
|
81
|
+
" Existing thread: NORMAL mode — read conversation,",
|
|
82
|
+
" c to reply, r to resolve, q/Esc to close.",
|
|
83
|
+
]);
|
|
84
|
+
|
|
74
85
|
addHelpSection(dialog.content, renderer, "Navigation", [
|
|
75
86
|
" j/k Down/up",
|
|
76
|
-
" gg
|
|
77
|
-
" G Go to last line / scroll to bottom",
|
|
87
|
+
" gg/G Top/bottom",
|
|
78
88
|
" Ctrl+d/u Half page down/up",
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
89
|
+
" zz Center cursor line",
|
|
90
|
+
" / Search (smartcase)",
|
|
91
|
+
" n/N Next/prev match",
|
|
92
|
+
" Esc Clear search",
|
|
82
93
|
" ]t/[t Next/prev thread",
|
|
83
|
-
" ]r/[r Next/prev unread
|
|
94
|
+
" ]r/[r Next/prev unread",
|
|
84
95
|
]);
|
|
85
96
|
|
|
86
97
|
addHelpSection(dialog.content, renderer, "Review", [
|
|
87
|
-
" c Comment / view thread
|
|
88
|
-
" r Resolve thread",
|
|
98
|
+
" c Comment / view thread",
|
|
99
|
+
" r Resolve thread (toggle)",
|
|
89
100
|
" R Resolve all pending",
|
|
90
|
-
" dd Delete thread
|
|
91
|
-
"
|
|
92
|
-
"
|
|
101
|
+
" dd Delete thread",
|
|
102
|
+
" t List threads",
|
|
103
|
+
" S Submit for rewrite",
|
|
104
|
+
" A Approve spec",
|
|
93
105
|
]);
|
|
94
106
|
|
|
95
107
|
addHelpSection(dialog.content, renderer, "Commands", [
|
|
96
|
-
" :
|
|
97
|
-
" :q
|
|
98
|
-
" :
|
|
99
|
-
"
|
|
108
|
+
" :q/:wq Quit (warns if unresolved)",
|
|
109
|
+
" :q! Force quit",
|
|
110
|
+
" :{N} Jump to line N",
|
|
111
|
+
" Ctrl+C Force quit",
|
|
100
112
|
]);
|
|
101
113
|
|
|
102
114
|
// Trailing blank line
|
|
103
115
|
dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
104
116
|
|
|
117
|
+
let pendingG: ReturnType<typeof setTimeout> | null = null;
|
|
118
|
+
|
|
105
119
|
const extraKeyHandler = (key: KeyEvent) => {
|
|
106
120
|
if (key.name === "q" || key.sequence === "?") {
|
|
107
121
|
key.preventDefault();
|
|
108
122
|
key.stopPropagation();
|
|
123
|
+
if (pendingG) { clearTimeout(pendingG); pendingG = null; }
|
|
109
124
|
onClose();
|
|
110
125
|
return;
|
|
111
126
|
}
|
|
@@ -123,6 +138,47 @@ export function createHelp(opts: {
|
|
|
123
138
|
renderer.requestRender();
|
|
124
139
|
return;
|
|
125
140
|
}
|
|
141
|
+
// G = goto bottom
|
|
142
|
+
if (key.name === "g" && key.shift) {
|
|
143
|
+
key.preventDefault();
|
|
144
|
+
key.stopPropagation();
|
|
145
|
+
if (pendingG) { clearTimeout(pendingG); pendingG = null; }
|
|
146
|
+
dialog.content.scrollTo(dialog.content.scrollHeight);
|
|
147
|
+
renderer.requestRender();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// gg = goto top
|
|
151
|
+
if (key.name === "g" && !key.shift && !key.ctrl) {
|
|
152
|
+
key.preventDefault();
|
|
153
|
+
key.stopPropagation();
|
|
154
|
+
if (pendingG) {
|
|
155
|
+
clearTimeout(pendingG);
|
|
156
|
+
pendingG = null;
|
|
157
|
+
dialog.content.scrollTo(0);
|
|
158
|
+
renderer.requestRender();
|
|
159
|
+
} else {
|
|
160
|
+
pendingG = setTimeout(() => { pendingG = null; }, 300);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Ctrl+D = half page down
|
|
165
|
+
if (key.ctrl && key.name === "d") {
|
|
166
|
+
key.preventDefault();
|
|
167
|
+
key.stopPropagation();
|
|
168
|
+
const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
|
|
169
|
+
dialog.content.scrollTo(Math.min(dialog.content.scrollTop + half, dialog.content.scrollHeight));
|
|
170
|
+
renderer.requestRender();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Ctrl+U = half page up
|
|
174
|
+
if (key.ctrl && key.name === "u") {
|
|
175
|
+
key.preventDefault();
|
|
176
|
+
key.stopPropagation();
|
|
177
|
+
const half = Math.max(1, Math.floor((renderer.height - 4) / 2));
|
|
178
|
+
dialog.content.scrollTo(Math.max(dialog.content.scrollTop - half, 0));
|
|
179
|
+
renderer.requestRender();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
126
182
|
};
|
|
127
183
|
|
|
128
184
|
renderer.keyInput.on("keypress", extraKeyHandler);
|
|
@@ -130,6 +186,7 @@ export function createHelp(opts: {
|
|
|
130
186
|
return {
|
|
131
187
|
container: dialog.container,
|
|
132
188
|
cleanup() {
|
|
189
|
+
if (pendingG) clearTimeout(pendingG);
|
|
133
190
|
dialog.cleanup();
|
|
134
191
|
renderer.keyInput.off("keypress", extraKeyHandler);
|
|
135
192
|
},
|
package/src/tui/pager.ts
CHANGED
|
@@ -35,7 +35,8 @@ export function buildPagerContent(state: ReviewState, searchQuery?: string | nul
|
|
|
35
35
|
const prefix = isCursor ? ">" : " ";
|
|
36
36
|
let specText = state.specLines[i];
|
|
37
37
|
if (searchQuery) {
|
|
38
|
-
const
|
|
38
|
+
const csSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
39
|
+
const regex = new RegExp(searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), csSensitive ? "g" : "gi");
|
|
39
40
|
specText = specText.replace(regex, (match) => `>>${match}<<`);
|
|
40
41
|
}
|
|
41
42
|
let indicator = " ";
|
|
@@ -173,7 +174,8 @@ export function buildPagerNodes(lineNode: TextRenderable, state: ReviewState, se
|
|
|
173
174
|
} else if (searchQuery) {
|
|
174
175
|
// When searching, show colored match segments (no markdown styling)
|
|
175
176
|
const escaped = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
176
|
-
const
|
|
177
|
+
const caseSensitive = searchQuery !== searchQuery.toLowerCase();
|
|
178
|
+
const searchRegex = new RegExp(`(${escaped})`, caseSensitive ? "g" : "gi");
|
|
177
179
|
const parts = specText.split(searchRegex);
|
|
178
180
|
for (let p = 0; p < parts.length; p++) {
|
|
179
181
|
const part = parts[p];
|
package/src/tui/search.ts
CHANGED
|
@@ -83,18 +83,23 @@ export function createSearch(opts: SearchOptions): SearchOverlay {
|
|
|
83
83
|
if (key.name === "return") {
|
|
84
84
|
key.preventDefault();
|
|
85
85
|
key.stopPropagation();
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
86
|
+
const raw = input.value.trim();
|
|
87
|
+
if (raw.length === 0) {
|
|
88
88
|
onCancel();
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Smartcase: if query has any uppercase, case-sensitive; otherwise case-insensitive
|
|
93
|
+
const caseSensitive = raw !== raw.toLowerCase();
|
|
94
|
+
const query = caseSensitive ? raw : raw.toLowerCase();
|
|
95
|
+
|
|
92
96
|
// Search forward from cursor, wrapping around
|
|
93
97
|
const total = specLines.length;
|
|
94
98
|
for (let offset = 1; offset <= total; offset++) {
|
|
95
99
|
const i = (cursorLine - 1 + offset) % total;
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const line = caseSensitive ? specLines[i] : specLines[i].toLowerCase();
|
|
101
|
+
if (line.includes(query)) {
|
|
102
|
+
onResult(i + 1, raw); // 1-based line number + original query (preserves case for n/N)
|
|
98
103
|
return;
|
|
99
104
|
}
|
|
100
105
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
type KeyEvent,
|
|
6
|
+
} from "@opentui/core";
|
|
7
|
+
import { theme } from "./ui/theme";
|
|
8
|
+
|
|
9
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
+
|
|
11
|
+
export interface SpinnerOverlay {
|
|
12
|
+
container: BoxRenderable;
|
|
13
|
+
cleanup: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createSpinner(opts: {
|
|
17
|
+
renderer: CliRenderer;
|
|
18
|
+
message: string;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
onTimeout: () => void;
|
|
22
|
+
}): SpinnerOverlay {
|
|
23
|
+
const { renderer, message, onCancel, onTimeout, timeoutMs = 120_000 } = opts;
|
|
24
|
+
|
|
25
|
+
const container = new BoxRenderable(renderer, {
|
|
26
|
+
position: "absolute",
|
|
27
|
+
top: "40%",
|
|
28
|
+
left: "25%",
|
|
29
|
+
width: "50%",
|
|
30
|
+
height: 5,
|
|
31
|
+
zIndex: 100,
|
|
32
|
+
backgroundColor: theme.backgroundPanel,
|
|
33
|
+
border: true,
|
|
34
|
+
borderStyle: "single",
|
|
35
|
+
borderColor: theme.blue,
|
|
36
|
+
title: " Submitting ",
|
|
37
|
+
flexDirection: "column",
|
|
38
|
+
paddingLeft: 2,
|
|
39
|
+
paddingRight: 2,
|
|
40
|
+
paddingTop: 1,
|
|
41
|
+
alignItems: "center",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const text = new TextRenderable(renderer, {
|
|
45
|
+
content: `${SPINNER_FRAMES[0]} ${message}`,
|
|
46
|
+
width: "100%",
|
|
47
|
+
height: 1,
|
|
48
|
+
fg: theme.text,
|
|
49
|
+
wrapMode: "none",
|
|
50
|
+
});
|
|
51
|
+
container.add(text);
|
|
52
|
+
|
|
53
|
+
let frame = 0;
|
|
54
|
+
const spinInterval = setInterval(() => {
|
|
55
|
+
frame = (frame + 1) % SPINNER_FRAMES.length;
|
|
56
|
+
text.content = `${SPINNER_FRAMES[frame]} ${message}`;
|
|
57
|
+
renderer.requestRender();
|
|
58
|
+
}, 80);
|
|
59
|
+
|
|
60
|
+
const timeout = setTimeout(() => {
|
|
61
|
+
onTimeout();
|
|
62
|
+
}, timeoutMs);
|
|
63
|
+
|
|
64
|
+
const keyHandler = (key: KeyEvent) => {
|
|
65
|
+
if (key.ctrl && key.name === "c") {
|
|
66
|
+
key.preventDefault();
|
|
67
|
+
key.stopPropagation();
|
|
68
|
+
onCancel();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
container,
|
|
75
|
+
cleanup() {
|
|
76
|
+
clearInterval(spinInterval);
|
|
77
|
+
clearTimeout(timeout);
|
|
78
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/tui/status-bar.ts
CHANGED
|
@@ -2,7 +2,8 @@ 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
|
|
5
|
+
import { buildHints } from "./ui/hint-bar";
|
|
6
|
+
import { PAGER_HINTS } from "./ui/keymap";
|
|
6
7
|
|
|
7
8
|
export interface TopBarComponents {
|
|
8
9
|
box: BoxRenderable;
|
|
@@ -83,16 +84,16 @@ export function buildBottomBar(bar: BottomBarComponents, commandBuffer: string |
|
|
|
83
84
|
t.add(TextNodeRenderable.fromString(` :${commandBuffer}`, { fg: theme.text }));
|
|
84
85
|
return;
|
|
85
86
|
}
|
|
86
|
-
const hints
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
const hints = [
|
|
88
|
+
PAGER_HINTS.navigate,
|
|
89
|
+
PAGER_HINTS.comment,
|
|
89
90
|
];
|
|
90
91
|
if (hasThread) {
|
|
91
|
-
hints.push(
|
|
92
|
-
hints.push({ key: "dd", action: "delete thread" });
|
|
92
|
+
hints.push(PAGER_HINTS.resolve);
|
|
93
93
|
}
|
|
94
|
-
hints.push(
|
|
95
|
-
hints.push(
|
|
94
|
+
hints.push(PAGER_HINTS.submit);
|
|
95
|
+
hints.push(PAGER_HINTS.approve);
|
|
96
|
+
hints.push(PAGER_HINTS.help);
|
|
96
97
|
buildHints(t, hints);
|
|
97
98
|
}
|
|
98
99
|
|
package/src/tui/thread-list.ts
CHANGED
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
SelectRenderable,
|
|
4
4
|
SelectRenderableEvents,
|
|
5
5
|
type CliRenderer,
|
|
6
|
+
type KeyEvent,
|
|
6
7
|
} from "@opentui/core";
|
|
7
8
|
import type { Thread } from "../protocol/types";
|
|
8
9
|
import { theme, STATUS_ICONS } from "./ui/theme";
|
|
9
10
|
import { createDialog } from "./ui/dialog";
|
|
11
|
+
import { THREAD_LIST_HINTS } from "./ui/keymap";
|
|
10
12
|
|
|
11
13
|
export interface ThreadListOptions {
|
|
12
14
|
renderer: CliRenderer;
|
|
@@ -24,46 +26,44 @@ const MAX_PREVIEW_LENGTH = 50;
|
|
|
24
26
|
|
|
25
27
|
function previewText(thread: Thread): string {
|
|
26
28
|
if (thread.messages.length === 0) return "(empty)";
|
|
27
|
-
const
|
|
28
|
-
const text =
|
|
29
|
+
const first = thread.messages[0];
|
|
30
|
+
const text = first.text.replace(/\n/g, " ");
|
|
29
31
|
if (text.length <= MAX_PREVIEW_LENGTH) return text;
|
|
30
32
|
return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
|
-
* Create a thread list overlay showing
|
|
36
|
+
* Create a thread list overlay showing all threads.
|
|
35
37
|
* Select + Enter: jump to that thread's line.
|
|
36
38
|
* Escape: cancel.
|
|
37
39
|
*/
|
|
38
40
|
export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
39
41
|
const { renderer, threads, onSelect, onCancel } = opts;
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
(t) => t.status === "open" || t.status === "pending"
|
|
43
|
+
const allThreads = threads.filter(
|
|
44
|
+
(t) => t.status === "open" || t.status === "pending" || t.status === "resolved"
|
|
44
45
|
);
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const activeCount = threads.filter(
|
|
47
|
+
(t) => t.status === "open" || t.status === "pending"
|
|
48
|
+
).length;
|
|
47
49
|
|
|
48
50
|
const dialog = createDialog({
|
|
49
51
|
renderer,
|
|
50
|
-
title: `Threads (${
|
|
52
|
+
title: `Threads (${activeCount} active, ${allThreads.length} total)`,
|
|
51
53
|
width: "70%",
|
|
52
54
|
height: "60%",
|
|
53
55
|
top: "15%",
|
|
54
56
|
left: "15%",
|
|
55
57
|
borderColor: theme.mauve,
|
|
56
58
|
onDismiss: onCancel,
|
|
57
|
-
hints:
|
|
58
|
-
{ key: "j/k", action: "navigate" },
|
|
59
|
-
{ key: "Enter", action: "jump" },
|
|
60
|
-
{ key: "Esc", action: "close" },
|
|
61
|
-
],
|
|
59
|
+
hints: THREAD_LIST_HINTS,
|
|
62
60
|
});
|
|
63
61
|
|
|
64
|
-
|
|
62
|
+
let keyHandler: ((key: KeyEvent) => void) | null = null;
|
|
63
|
+
|
|
64
|
+
if (allThreads.length === 0) {
|
|
65
65
|
const emptyMsg = new TextRenderable(renderer, {
|
|
66
|
-
content: "No
|
|
66
|
+
content: "No threads. Press [Esc] to close.",
|
|
67
67
|
width: "100%",
|
|
68
68
|
height: 1,
|
|
69
69
|
fg: theme.textDim,
|
|
@@ -71,8 +71,7 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
71
71
|
});
|
|
72
72
|
dialog.content.add(emptyMsg);
|
|
73
73
|
} else {
|
|
74
|
-
|
|
75
|
-
const selectOptions = activeThreads.map((t) => {
|
|
74
|
+
const selectOptions = allThreads.map((t) => {
|
|
76
75
|
const icon = STATUS_ICONS[t.status];
|
|
77
76
|
return {
|
|
78
77
|
name: `${icon} #${t.id} line ${t.line}: ${previewText(t)}`,
|
|
@@ -100,20 +99,61 @@ export function createThreadList(opts: ThreadListOptions): ThreadListOverlay {
|
|
|
100
99
|
|
|
101
100
|
dialog.content.add(select);
|
|
102
101
|
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
renderer.focusRenderable(select);
|
|
104
|
+
renderer.requestRender();
|
|
105
|
+
}, 0);
|
|
105
106
|
|
|
106
|
-
//
|
|
107
|
+
// SelectRenderable ITEM_SELECTED event
|
|
107
108
|
select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
108
109
|
const selected = select.getSelectedOption();
|
|
109
110
|
if (selected && selected.value != null) {
|
|
110
111
|
onSelect(selected.value as number);
|
|
111
112
|
}
|
|
112
113
|
});
|
|
114
|
+
|
|
115
|
+
// Manual key handler — SelectRenderable focus is unreliable
|
|
116
|
+
keyHandler = (key: KeyEvent) => {
|
|
117
|
+
if (key.name === "q") {
|
|
118
|
+
key.preventDefault();
|
|
119
|
+
key.stopPropagation();
|
|
120
|
+
onCancel();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (key.name === "return" || key.name === "y") {
|
|
124
|
+
key.preventDefault();
|
|
125
|
+
key.stopPropagation();
|
|
126
|
+
const selected = select.getSelectedOption();
|
|
127
|
+
if (selected && selected.value != null) {
|
|
128
|
+
onSelect(selected.value as number);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (key.name === "j" || key.name === "down") {
|
|
133
|
+
key.preventDefault();
|
|
134
|
+
key.stopPropagation();
|
|
135
|
+
select.moveDown();
|
|
136
|
+
renderer.requestRender();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (key.name === "k" || key.name === "up") {
|
|
140
|
+
key.preventDefault();
|
|
141
|
+
key.stopPropagation();
|
|
142
|
+
select.moveUp();
|
|
143
|
+
renderer.requestRender();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
113
148
|
}
|
|
114
149
|
|
|
115
150
|
return {
|
|
116
151
|
container: dialog.container,
|
|
117
|
-
cleanup
|
|
152
|
+
cleanup() {
|
|
153
|
+
dialog.cleanup();
|
|
154
|
+
if (keyHandler) {
|
|
155
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
118
158
|
};
|
|
119
159
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Hint } from "./hint-bar";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Central keymap definitions.
|
|
5
|
+
* All keybinding display labels live here — components import hints from this file.
|
|
6
|
+
* To remap a key, change it here and in the bindings array in app.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// --- Main pager ---
|
|
10
|
+
|
|
11
|
+
export const PAGER_HINTS = {
|
|
12
|
+
navigate: { key: "j/k", action: "navigate" } as Hint,
|
|
13
|
+
comment: { key: "c", action: "comment" } as Hint,
|
|
14
|
+
resolve: { key: "r", action: "resolve" } as Hint,
|
|
15
|
+
submit: { key: "S", action: "submit" } as Hint,
|
|
16
|
+
approve: { key: "A", action: "approve" } as Hint,
|
|
17
|
+
help: { key: "?", action: "help" } as Hint,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// --- Thread popup ---
|
|
21
|
+
|
|
22
|
+
export const THREAD_NORMAL_HINTS: Hint[] = [
|
|
23
|
+
{ key: "NORMAL", action: "" },
|
|
24
|
+
{ key: "c", action: "reply" },
|
|
25
|
+
{ key: "r", action: "resolve" },
|
|
26
|
+
{ key: "q/Esc", action: "close" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const THREAD_INSERT_HINTS: Hint[] = [
|
|
30
|
+
{ key: "INSERT", action: "" },
|
|
31
|
+
{ key: "Tab", action: "send" },
|
|
32
|
+
{ key: "Esc", action: "normal" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// --- Thread list ---
|
|
36
|
+
|
|
37
|
+
export const THREAD_LIST_HINTS: Hint[] = [
|
|
38
|
+
{ key: "j/k", action: "navigate" },
|
|
39
|
+
{ key: "y/Enter", action: "jump" },
|
|
40
|
+
{ key: "q/Esc", action: "close" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// --- Help overlay ---
|
|
44
|
+
|
|
45
|
+
export const HELP_HINTS: Hint[] = [
|
|
46
|
+
{ key: "j/k", action: "navigate" },
|
|
47
|
+
{ key: "q/?/Esc", action: "close" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// --- Confirm dialog ---
|
|
51
|
+
|
|
52
|
+
export const CONFIRM_HINTS: Hint[] = [
|
|
53
|
+
{ key: "y/Enter", action: "yes" },
|
|
54
|
+
{ key: "q/Esc", action: "no" },
|
|
55
|
+
];
|
package/.github/workflows/ci.yml
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
pull_request:
|
|
8
|
-
|
|
9
|
-
jobs:
|
|
10
|
-
test:
|
|
11
|
-
runs-on: ubuntu-latest
|
|
12
|
-
steps:
|
|
13
|
-
- uses: actions/checkout@v4
|
|
14
|
-
- uses: oven-sh/setup-bun@v2
|
|
15
|
-
with:
|
|
16
|
-
bun-version: latest
|
|
17
|
-
- run: bun install
|
|
18
|
-
- run: bun test
|
package/CLAUDE.md
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
# Revspec
|
|
2
|
-
|
|
3
|
-
- Tech: Bun + TypeScript + @opentui/core
|
|
4
|
-
- npm: `revspec` | GitHub: icyrainz/revspec
|
|
5
|
-
- Run: `bun run bin/revspec.ts <file.md>`
|
|
6
|
-
- Test: `bun test`
|
|
7
|
-
- Release: `./scripts/release.sh` (version is set manually in package.json)
|
|
8
|
-
- Dev: `bun link` to symlink local build to global `revspec` command
|
|
9
|
-
|
|
10
|
-
## OpenTUI Gotchas
|
|
11
|
-
- Don't use StyledText at all — BigInt FFI crash happens even on small content
|
|
12
|
-
- Don't use ANSI escape codes in TextRenderable content (renders as literal text)
|
|
13
|
-
- MarkdownRenderable needs `syntaxStyle` + `conceal: true` for proper rendering
|
|
14
|
-
- Use `visible: false` to hide renderables, not removal/re-addition
|
|
15
|
-
- ScrollBox: don't use `stickyScroll` with manual scrolling (fights scroll position)
|
|
16
|
-
- ScrollBox: `scrollBy` overshoots silently on large deltas — use `scrollTo` with clamped position
|
|
17
|
-
- Textarea consumes Ctrl+D/U (emacs bindings) — blur textarea in normal mode for vim-style scroll
|
|
18
|
-
|
|
19
|
-
## Conventions
|
|
20
|
-
- Line mode is default, markdown mode via `m` toggle
|
|
21
|
-
- Tab to submit in all text inputs (works through tmux)
|
|
22
|
-
- Destructive actions need confirmation (dd double-tap, approve confirm dialog)
|
|
23
|
-
- All review actions auto-switch to line mode
|
|
24
|
-
- Thread popup uses vim-style normal/insert modes (blur textarea in normal)
|
|
25
|
-
- Hint bars use `[key] action` bracket format consistently
|
|
26
|
-
- No inline comment previews in pager — gutter indicators only (▌/█/✓)
|
|
27
|
-
- Live integration: JSONL for communication, `revspec watch`/`reply` CLI subcommands
|