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/comment-input.ts
CHANGED
|
@@ -2,12 +2,12 @@ import {
|
|
|
2
2
|
BoxRenderable,
|
|
3
3
|
TextRenderable,
|
|
4
4
|
TextareaRenderable,
|
|
5
|
-
ScrollBoxRenderable,
|
|
6
5
|
type CliRenderer,
|
|
7
6
|
type KeyEvent,
|
|
8
7
|
} from "@opentui/core";
|
|
9
8
|
import type { Thread, Message } from "../protocol/types";
|
|
10
|
-
import { theme } from "./theme";
|
|
9
|
+
import { theme } from "./ui/theme";
|
|
10
|
+
import { createDialog } from "./ui/dialog";
|
|
11
11
|
|
|
12
12
|
export interface CommentInputOptions {
|
|
13
13
|
renderer: CliRenderer;
|
|
@@ -42,30 +42,140 @@ function createThreadView(
|
|
|
42
42
|
onResolve: () => void,
|
|
43
43
|
onCancel: () => void,
|
|
44
44
|
): CommentInputOverlay {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
const title = thread.id
|
|
46
|
+
? `Thread #${thread.id} (line ${line})`
|
|
47
|
+
: `New comment on line ${line}`;
|
|
48
|
+
|
|
49
|
+
const normalHints = [
|
|
50
|
+
{ key: "NORMAL", action: "" },
|
|
51
|
+
{ key: "c", action: "reply" },
|
|
52
|
+
{ key: "r", action: "resolve" },
|
|
53
|
+
{ key: "Esc/q", action: "close" },
|
|
54
|
+
];
|
|
55
|
+
const insertHints = [
|
|
56
|
+
{ key: "INSERT", action: "" },
|
|
57
|
+
{ key: "Tab", action: "send" },
|
|
58
|
+
{ key: "Esc", action: "back" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// --- State ---
|
|
62
|
+
let mode: "normal" | "insert" = "insert";
|
|
63
|
+
|
|
64
|
+
// Build the textarea now (we need it in the key handler closure)
|
|
65
|
+
const textarea = new TextareaRenderable(renderer, {
|
|
66
|
+
width: "100%",
|
|
67
|
+
height: 4,
|
|
68
|
+
flexGrow: 0,
|
|
69
|
+
flexShrink: 0,
|
|
70
|
+
backgroundColor: theme.backgroundElement,
|
|
71
|
+
textColor: theme.textDim,
|
|
72
|
+
focusedBackgroundColor: theme.backgroundPanel,
|
|
73
|
+
focusedTextColor: theme.text,
|
|
74
|
+
wrapMode: "word",
|
|
75
|
+
placeholder: "Press c to reply...",
|
|
76
|
+
placeholderColor: theme.textDim,
|
|
77
|
+
initialValue: "",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Register our comprehensive key handler FIRST (before createDialog) so it
|
|
81
|
+
// fires before the dialog's Esc handler and can stopPropagation in insert mode.
|
|
82
|
+
const keyHandler = (key: KeyEvent) => {
|
|
83
|
+
if (mode === "insert") {
|
|
84
|
+
// --- INSERT MODE ---
|
|
85
|
+
if (key.name === "escape") {
|
|
86
|
+
key.preventDefault();
|
|
87
|
+
key.stopPropagation();
|
|
88
|
+
enterNormal();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (key.name === "tab") {
|
|
92
|
+
key.preventDefault();
|
|
93
|
+
key.stopPropagation();
|
|
94
|
+
const text = textarea.plainText.trim();
|
|
95
|
+
if (text.length === 0) return;
|
|
96
|
+
onSubmit(text);
|
|
97
|
+
appendToConversation({ author: "reviewer", text, ts: Date.now() });
|
|
98
|
+
textarea.selectAll();
|
|
99
|
+
textarea.deleteChar();
|
|
100
|
+
enterNormal();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// All other keys: let textarea handle them (don't preventDefault)
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// --- NORMAL MODE (textarea blurred, all keys are ours) ---
|
|
108
|
+
key.preventDefault();
|
|
109
|
+
key.stopPropagation();
|
|
110
|
+
|
|
111
|
+
switch (key.name) {
|
|
112
|
+
case "escape":
|
|
113
|
+
case "q":
|
|
114
|
+
onCancel();
|
|
115
|
+
return;
|
|
116
|
+
|
|
117
|
+
case "c":
|
|
118
|
+
enterInsert();
|
|
119
|
+
return;
|
|
120
|
+
|
|
121
|
+
case "r":
|
|
122
|
+
onResolve();
|
|
123
|
+
return;
|
|
124
|
+
|
|
125
|
+
case "j":
|
|
126
|
+
case "down":
|
|
127
|
+
scrollBox.scrollBy({ x: 0, y: 1 });
|
|
128
|
+
renderer.requestRender();
|
|
129
|
+
return;
|
|
130
|
+
|
|
131
|
+
case "k":
|
|
132
|
+
case "up":
|
|
133
|
+
scrollBox.scrollBy({ x: 0, y: -1 });
|
|
134
|
+
renderer.requestRender();
|
|
135
|
+
return;
|
|
136
|
+
|
|
137
|
+
case "d":
|
|
138
|
+
if (key.ctrl) {
|
|
139
|
+
scrollBox.scrollBy({ x: 0, y: 5 });
|
|
140
|
+
renderer.requestRender();
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
|
|
144
|
+
case "u":
|
|
145
|
+
if (key.ctrl) {
|
|
146
|
+
scrollBox.scrollBy({ x: 0, y: -5 });
|
|
147
|
+
renderer.requestRender();
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
|
|
151
|
+
case "g":
|
|
152
|
+
if (key.shift) {
|
|
153
|
+
// G = go to bottom
|
|
154
|
+
scrollBox.scrollTo(scrollBox.scrollHeight);
|
|
155
|
+
renderer.requestRender();
|
|
156
|
+
}
|
|
157
|
+
// TODO: gg = go to top (needs double-tap tracking)
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
renderer.keyInput.on("keypress", keyHandler);
|
|
163
|
+
|
|
164
|
+
// Now create the dialog (its Esc handler registers after ours)
|
|
165
|
+
// Pass no-op onDismiss — we handle all keys ourselves above.
|
|
166
|
+
const dialog = createDialog({
|
|
167
|
+
renderer,
|
|
168
|
+
title,
|
|
49
169
|
width: "80%",
|
|
50
170
|
height: "85%",
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
borderStyle: "single",
|
|
55
|
-
borderColor: theme.borderComment,
|
|
56
|
-
title: thread.id ? ` Thread #${thread.id} (line ${line}) ` : ` New comment on line ${line} `,
|
|
57
|
-
flexDirection: "column",
|
|
58
|
-
padding: 1,
|
|
171
|
+
borderColor: theme.blue,
|
|
172
|
+
onDismiss: onCancel,
|
|
173
|
+
hints: insertHints,
|
|
59
174
|
});
|
|
60
175
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
flexGrow: 1,
|
|
65
|
-
flexShrink: 1,
|
|
66
|
-
scrollY: true,
|
|
67
|
-
scrollX: false,
|
|
68
|
-
});
|
|
176
|
+
|
|
177
|
+
// --- Scrollable conversation history ---
|
|
178
|
+
const scrollBox = dialog.content;
|
|
69
179
|
|
|
70
180
|
function renderMessage(msg: Message): BoxRenderable {
|
|
71
181
|
const isReviewer = msg.author === "reviewer";
|
|
@@ -87,7 +197,7 @@ function createThreadView(
|
|
|
87
197
|
content: tsStr ? `${label} ${tsStr}` : label,
|
|
88
198
|
width: "100%",
|
|
89
199
|
height: 1,
|
|
90
|
-
fg: theme.
|
|
200
|
+
fg: theme.textMuted,
|
|
91
201
|
wrapMode: "none",
|
|
92
202
|
});
|
|
93
203
|
msgBox.add(header);
|
|
@@ -107,64 +217,32 @@ function createThreadView(
|
|
|
107
217
|
for (const msg of thread.messages) {
|
|
108
218
|
scrollBox.add(renderMessage(msg));
|
|
109
219
|
}
|
|
110
|
-
container.add(scrollBox);
|
|
111
220
|
|
|
112
221
|
// --- Separator ---
|
|
113
222
|
const sep = new TextRenderable(renderer, {
|
|
114
223
|
content: "\u2500".repeat(40),
|
|
115
224
|
width: "100%",
|
|
116
225
|
height: 1,
|
|
117
|
-
fg: theme.
|
|
226
|
+
fg: theme.backgroundElement,
|
|
118
227
|
wrapMode: "none",
|
|
119
228
|
});
|
|
120
|
-
container.add(sep);
|
|
229
|
+
dialog.container.add(sep);
|
|
121
230
|
|
|
122
|
-
// ---
|
|
123
|
-
|
|
124
|
-
width: "100%",
|
|
125
|
-
height: 4,
|
|
126
|
-
flexGrow: 0,
|
|
127
|
-
flexShrink: 0,
|
|
128
|
-
backgroundColor: theme.surface1,
|
|
129
|
-
textColor: theme.overlay,
|
|
130
|
-
focusedBackgroundColor: theme.surface0,
|
|
131
|
-
focusedTextColor: theme.text,
|
|
132
|
-
wrapMode: "word",
|
|
133
|
-
placeholder: "Press c to reply...",
|
|
134
|
-
placeholderColor: theme.overlay,
|
|
135
|
-
initialValue: "",
|
|
136
|
-
});
|
|
137
|
-
container.add(textarea);
|
|
138
|
-
|
|
139
|
-
// --- Hint bar (changes with mode) ---
|
|
140
|
-
const hintNormal = " [c] reply [r] resolve [Esc] close";
|
|
141
|
-
const hintInsert = " [Tab] send [Esc] back";
|
|
142
|
-
|
|
143
|
-
const hint = new TextRenderable(renderer, {
|
|
144
|
-
content: hintInsert,
|
|
145
|
-
width: "100%",
|
|
146
|
-
height: 1,
|
|
147
|
-
fg: theme.hintFg,
|
|
148
|
-
bg: theme.hintBg,
|
|
149
|
-
wrapMode: "none",
|
|
150
|
-
truncate: true,
|
|
151
|
-
});
|
|
152
|
-
container.add(hint);
|
|
153
|
-
|
|
154
|
-
// --- State ---
|
|
155
|
-
let mode: "normal" | "insert" = "insert";
|
|
231
|
+
// --- Textarea (visible in both modes, focused only in insert) ---
|
|
232
|
+
dialog.container.add(textarea);
|
|
156
233
|
|
|
234
|
+
// --- Mode helpers (need dialog.setHints available) ---
|
|
157
235
|
function enterInsert(): void {
|
|
158
236
|
mode = "insert";
|
|
159
237
|
textarea.focus();
|
|
160
|
-
|
|
238
|
+
dialog.setHints(insertHints);
|
|
161
239
|
renderer.requestRender();
|
|
162
240
|
}
|
|
163
241
|
|
|
164
242
|
function enterNormal(): void {
|
|
165
243
|
mode = "normal";
|
|
166
244
|
textarea.blur();
|
|
167
|
-
|
|
245
|
+
dialog.setHints(normalHints);
|
|
168
246
|
renderer.requestRender();
|
|
169
247
|
}
|
|
170
248
|
|
|
@@ -192,89 +270,13 @@ function createThreadView(
|
|
|
192
270
|
}, 50);
|
|
193
271
|
}
|
|
194
272
|
|
|
195
|
-
const keyHandler = (key: KeyEvent) => {
|
|
196
|
-
if (mode === "insert") {
|
|
197
|
-
// --- INSERT MODE ---
|
|
198
|
-
if (key.name === "escape") {
|
|
199
|
-
key.preventDefault(); key.stopPropagation();
|
|
200
|
-
enterNormal();
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (key.name === "tab") {
|
|
204
|
-
key.preventDefault(); key.stopPropagation();
|
|
205
|
-
const text = textarea.plainText.trim();
|
|
206
|
-
if (text.length === 0) return;
|
|
207
|
-
onSubmit(text);
|
|
208
|
-
appendToConversation({ author: "reviewer", text, ts: Date.now() });
|
|
209
|
-
textarea.selectAll();
|
|
210
|
-
textarea.deleteChar();
|
|
211
|
-
enterNormal();
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
// All other keys: let textarea handle them (don't preventDefault)
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// --- NORMAL MODE (textarea blurred, all keys are ours) ---
|
|
219
|
-
key.preventDefault(); key.stopPropagation();
|
|
220
|
-
|
|
221
|
-
switch (key.name) {
|
|
222
|
-
case "escape":
|
|
223
|
-
case "q":
|
|
224
|
-
onCancel();
|
|
225
|
-
return;
|
|
226
|
-
|
|
227
|
-
case "c":
|
|
228
|
-
enterInsert();
|
|
229
|
-
return;
|
|
230
|
-
|
|
231
|
-
case "r":
|
|
232
|
-
onResolve();
|
|
233
|
-
return;
|
|
234
|
-
|
|
235
|
-
case "j":
|
|
236
|
-
case "down":
|
|
237
|
-
scrollBox.scrollBy({ x: 0, y: 1 });
|
|
238
|
-
renderer.requestRender();
|
|
239
|
-
return;
|
|
240
|
-
|
|
241
|
-
case "k":
|
|
242
|
-
case "up":
|
|
243
|
-
scrollBox.scrollBy({ x: 0, y: -1 });
|
|
244
|
-
renderer.requestRender();
|
|
245
|
-
return;
|
|
246
|
-
|
|
247
|
-
case "d":
|
|
248
|
-
if (key.ctrl) {
|
|
249
|
-
// Use same scrollBy as j/k, just more lines
|
|
250
|
-
scrollBox.scrollBy({ x: 0, y: 5 });
|
|
251
|
-
renderer.requestRender();
|
|
252
|
-
}
|
|
253
|
-
return;
|
|
254
|
-
|
|
255
|
-
case "u":
|
|
256
|
-
if (key.ctrl) {
|
|
257
|
-
scrollBox.scrollBy({ x: 0, y: -5 });
|
|
258
|
-
renderer.requestRender();
|
|
259
|
-
}
|
|
260
|
-
return;
|
|
261
|
-
|
|
262
|
-
case "g":
|
|
263
|
-
if (key.shift) {
|
|
264
|
-
// G = go to bottom
|
|
265
|
-
scrollBox.scrollTo(scrollBox.scrollHeight);
|
|
266
|
-
renderer.requestRender();
|
|
267
|
-
}
|
|
268
|
-
// TODO: gg = go to top (needs double-tap tracking)
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
renderer.keyInput.on("keypress", keyHandler);
|
|
274
|
-
|
|
275
273
|
return {
|
|
276
|
-
container,
|
|
277
|
-
cleanup() {
|
|
274
|
+
container: dialog.container,
|
|
275
|
+
cleanup() {
|
|
276
|
+
renderer.keyInput.off("keypress", keyHandler);
|
|
277
|
+
dialog.cleanup();
|
|
278
|
+
textarea.destroy();
|
|
279
|
+
},
|
|
278
280
|
threadId: thread.id,
|
|
279
281
|
addMessage(msg: Message) { appendToConversation(msg); },
|
|
280
282
|
};
|
package/src/tui/confirm.ts
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
|
-
BoxRenderable,
|
|
3
2
|
TextRenderable,
|
|
4
3
|
type CliRenderer,
|
|
5
4
|
type KeyEvent,
|
|
6
5
|
} from "@opentui/core";
|
|
7
|
-
import { theme } from "./theme";
|
|
6
|
+
import { theme } from "./ui/theme";
|
|
7
|
+
import { createDialog } from "./ui/dialog";
|
|
8
8
|
|
|
9
9
|
export interface ConfirmOptions {
|
|
10
10
|
renderer: CliRenderer;
|
|
11
11
|
message: string;
|
|
12
|
+
title?: string;
|
|
12
13
|
onConfirm: () => void;
|
|
13
14
|
onCancel: () => void;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export interface ConfirmOverlay {
|
|
17
|
-
container: BoxRenderable;
|
|
18
|
+
container: import("@opentui/core").BoxRenderable;
|
|
18
19
|
cleanup: () => void;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -24,70 +25,55 @@ export interface ConfirmOverlay {
|
|
|
24
25
|
* y → confirm, n/Esc → cancel
|
|
25
26
|
*/
|
|
26
27
|
export function createConfirm(opts: ConfirmOptions): ConfirmOverlay {
|
|
27
|
-
const { renderer, message, onConfirm, onCancel } = opts;
|
|
28
|
+
const { renderer, message, title = "Confirm", onConfirm, onCancel } = opts;
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
const dialog = createDialog({
|
|
31
|
+
renderer,
|
|
32
|
+
title,
|
|
33
|
+
width: "50%",
|
|
34
|
+
height: 9,
|
|
32
35
|
top: "35%",
|
|
33
36
|
left: "25%",
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
borderColor: theme.borderConfirm,
|
|
41
|
-
title: " Confirm ",
|
|
42
|
-
flexDirection: "column",
|
|
43
|
-
padding: 1,
|
|
44
|
-
alignItems: "center",
|
|
45
|
-
justifyContent: "center",
|
|
37
|
+
borderColor: theme.warning,
|
|
38
|
+
onDismiss: onCancel,
|
|
39
|
+
hints: [
|
|
40
|
+
{ key: "y", action: "yes" },
|
|
41
|
+
{ key: "n/Esc", action: "no" },
|
|
42
|
+
],
|
|
46
43
|
});
|
|
47
44
|
|
|
48
45
|
const msgText = new TextRenderable(renderer, {
|
|
49
46
|
content: message,
|
|
50
47
|
width: "100%",
|
|
51
|
-
height: 1,
|
|
52
48
|
fg: theme.text,
|
|
53
|
-
wrapMode: "
|
|
54
|
-
truncate: true,
|
|
49
|
+
wrapMode: "word",
|
|
55
50
|
});
|
|
56
51
|
|
|
57
|
-
|
|
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
|
-
});
|
|
52
|
+
dialog.content.add(msgText);
|
|
66
53
|
|
|
67
|
-
|
|
68
|
-
container.add(hint);
|
|
69
|
-
|
|
70
|
-
// Key handler
|
|
71
|
-
const keyHandler = (key: KeyEvent) => {
|
|
54
|
+
const extraKeyHandler = (key: KeyEvent) => {
|
|
72
55
|
if (key.name === "y") {
|
|
73
56
|
key.preventDefault();
|
|
74
57
|
key.stopPropagation();
|
|
75
58
|
onConfirm();
|
|
76
59
|
return;
|
|
77
60
|
}
|
|
78
|
-
if (key.name === "n"
|
|
61
|
+
if (key.name === "n") {
|
|
79
62
|
key.preventDefault();
|
|
80
63
|
key.stopPropagation();
|
|
81
64
|
onCancel();
|
|
82
65
|
return;
|
|
83
66
|
}
|
|
67
|
+
// Esc is handled by dialog's built-in handler (calls onDismiss = onCancel)
|
|
84
68
|
};
|
|
85
69
|
|
|
86
|
-
renderer.keyInput.on("keypress",
|
|
87
|
-
|
|
88
|
-
function cleanup(): void {
|
|
89
|
-
renderer.keyInput.off("keypress", keyHandler);
|
|
90
|
-
}
|
|
70
|
+
renderer.keyInput.on("keypress", extraKeyHandler);
|
|
91
71
|
|
|
92
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
container: dialog.container,
|
|
74
|
+
cleanup() {
|
|
75
|
+
dialog.cleanup();
|
|
76
|
+
renderer.keyInput.off("keypress", extraKeyHandler);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
93
79
|
}
|
package/src/tui/help.ts
CHANGED
|
@@ -1,20 +1,43 @@
|
|
|
1
1
|
import {
|
|
2
|
-
BoxRenderable,
|
|
3
|
-
ScrollBoxRenderable,
|
|
4
2
|
TextRenderable,
|
|
5
3
|
type CliRenderer,
|
|
6
4
|
type KeyEvent,
|
|
5
|
+
type ScrollBoxRenderable,
|
|
7
6
|
} from "@opentui/core";
|
|
8
|
-
import { theme } from "./theme";
|
|
7
|
+
import { theme } from "./ui/theme";
|
|
8
|
+
import { createDialog } from "./ui/dialog";
|
|
9
9
|
|
|
10
10
|
export interface HelpOverlay {
|
|
11
|
-
container: BoxRenderable;
|
|
11
|
+
container: import("@opentui/core").BoxRenderable;
|
|
12
12
|
cleanup: () => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
function addHelpSection(container: ScrollBoxRenderable, renderer: CliRenderer, title: string, lines: string[]): void {
|
|
16
|
+
// Blank line before section
|
|
17
|
+
container.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
18
|
+
// Section header in blue
|
|
19
|
+
container.add(new TextRenderable(renderer, {
|
|
20
|
+
content: ` ${title}`,
|
|
21
|
+
width: "100%",
|
|
22
|
+
height: 1,
|
|
23
|
+
fg: theme.blue,
|
|
24
|
+
wrapMode: "none",
|
|
25
|
+
}));
|
|
26
|
+
// Content lines
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
container.add(new TextRenderable(renderer, {
|
|
29
|
+
content: line,
|
|
30
|
+
width: "100%",
|
|
31
|
+
height: 1,
|
|
32
|
+
fg: theme.text,
|
|
33
|
+
wrapMode: "none",
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
15
38
|
/**
|
|
16
39
|
* Create a help overlay popup showing all keybindings.
|
|
17
|
-
* Dismissable with
|
|
40
|
+
* Dismissable with `?`, `q`, or `Esc`.
|
|
18
41
|
*/
|
|
19
42
|
export function createHelp(opts: {
|
|
20
43
|
renderer: CliRenderer;
|
|
@@ -23,11 +46,32 @@ export function createHelp(opts: {
|
|
|
23
46
|
}): HelpOverlay {
|
|
24
47
|
const { renderer, version, onClose } = opts;
|
|
25
48
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"",
|
|
30
|
-
|
|
49
|
+
const dialog = createDialog({
|
|
50
|
+
renderer,
|
|
51
|
+
title: "Help",
|
|
52
|
+
width: "60%",
|
|
53
|
+
height: Math.min(26, renderer.height - 2),
|
|
54
|
+
top: "10%",
|
|
55
|
+
left: "20%",
|
|
56
|
+
borderColor: theme.info,
|
|
57
|
+
onDismiss: onClose,
|
|
58
|
+
hints: [
|
|
59
|
+
{ key: "q/?/Esc", action: "close" },
|
|
60
|
+
{ key: "j/k", action: "scroll" },
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Version header
|
|
65
|
+
dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
66
|
+
dialog.content.add(new TextRenderable(renderer, {
|
|
67
|
+
content: ` revspec v${version}`,
|
|
68
|
+
width: "100%",
|
|
69
|
+
height: 1,
|
|
70
|
+
fg: theme.textMuted,
|
|
71
|
+
wrapMode: "none",
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
addHelpSection(dialog.content, renderer, "Navigation", [
|
|
31
75
|
" j/k Down/up",
|
|
32
76
|
" gg Go to first line / scroll to top",
|
|
33
77
|
" G Go to last line / scroll to bottom",
|
|
@@ -37,74 +81,29 @@ export function createHelp(opts: {
|
|
|
37
81
|
" Esc Clear search highlights",
|
|
38
82
|
" ]t/[t Next/prev thread",
|
|
39
83
|
" ]r/[r Next/prev unread thread",
|
|
40
|
-
|
|
41
|
-
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
addHelpSection(dialog.content, renderer, "Review", [
|
|
42
87
|
" c Comment / view thread / reply",
|
|
43
88
|
" r Resolve thread",
|
|
44
89
|
" R Resolve all pending",
|
|
45
|
-
" dd Delete
|
|
90
|
+
" dd Delete thread (with confirm)",
|
|
46
91
|
" l List threads",
|
|
47
92
|
" a Approve spec",
|
|
48
|
-
|
|
49
|
-
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
addHelpSection(dialog.content, renderer, "Commands", [
|
|
50
96
|
" :w Show save status",
|
|
51
|
-
" :q
|
|
97
|
+
" :q Quit (blocks if unsaved)",
|
|
52
98
|
" :wq Save and quit",
|
|
53
99
|
" :q! Quit without saving",
|
|
54
|
-
|
|
55
|
-
].join("\n");
|
|
56
|
-
|
|
57
|
-
// Overlay container - centered popup
|
|
58
|
-
const container = new BoxRenderable(renderer, {
|
|
59
|
-
position: "absolute",
|
|
60
|
-
top: "10%",
|
|
61
|
-
left: "20%",
|
|
62
|
-
width: "60%",
|
|
63
|
-
height: Math.min(26, renderer.height - 2),
|
|
64
|
-
zIndex: 100,
|
|
65
|
-
backgroundColor: theme.base,
|
|
66
|
-
border: true,
|
|
67
|
-
borderStyle: "single",
|
|
68
|
-
borderColor: theme.borderThread,
|
|
69
|
-
title: " Help ",
|
|
70
|
-
flexDirection: "column",
|
|
71
|
-
padding: 0,
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
75
|
-
width: "100%",
|
|
76
|
-
flexGrow: 1,
|
|
77
|
-
flexShrink: 1,
|
|
78
|
-
scrollY: true,
|
|
79
|
-
scrollX: false,
|
|
80
|
-
backgroundColor: theme.base,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
const content = new TextRenderable(renderer, {
|
|
84
|
-
content: helpText,
|
|
85
|
-
width: "100%",
|
|
86
|
-
fg: theme.text,
|
|
87
|
-
wrapMode: "none",
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
scrollBox.add(content);
|
|
91
|
-
|
|
92
|
-
const hint = new TextRenderable(renderer, {
|
|
93
|
-
content: " [q/?/Esc] close [j/k] scroll",
|
|
94
|
-
width: "100%",
|
|
95
|
-
height: 1,
|
|
96
|
-
fg: theme.hintFg,
|
|
97
|
-
bg: theme.hintBg,
|
|
98
|
-
wrapMode: "none",
|
|
99
|
-
truncate: true,
|
|
100
|
-
});
|
|
100
|
+
]);
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
// Trailing blank line
|
|
103
|
+
dialog.content.add(new TextRenderable(renderer, { content: "", width: "100%", height: 1, wrapMode: "none" }));
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (key.name === "escape" || key.name === "q" || key.sequence === "?") {
|
|
105
|
+
const extraKeyHandler = (key: KeyEvent) => {
|
|
106
|
+
if (key.name === "q" || key.sequence === "?") {
|
|
108
107
|
key.preventDefault();
|
|
109
108
|
key.stopPropagation();
|
|
110
109
|
onClose();
|
|
@@ -113,24 +112,26 @@ export function createHelp(opts: {
|
|
|
113
112
|
if (key.name === "j" || key.name === "down") {
|
|
114
113
|
key.preventDefault();
|
|
115
114
|
key.stopPropagation();
|
|
116
|
-
|
|
115
|
+
dialog.content.scrollTo(Math.min(dialog.content.scrollTop + 1, dialog.content.scrollHeight));
|
|
117
116
|
renderer.requestRender();
|
|
118
117
|
return;
|
|
119
118
|
}
|
|
120
119
|
if (key.name === "k" || key.name === "up") {
|
|
121
120
|
key.preventDefault();
|
|
122
121
|
key.stopPropagation();
|
|
123
|
-
|
|
122
|
+
dialog.content.scrollTo(Math.max(dialog.content.scrollTop - 1, 0));
|
|
124
123
|
renderer.requestRender();
|
|
125
124
|
return;
|
|
126
125
|
}
|
|
127
126
|
};
|
|
128
127
|
|
|
129
|
-
renderer.keyInput.on("keypress",
|
|
128
|
+
renderer.keyInput.on("keypress", extraKeyHandler);
|
|
130
129
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
130
|
+
return {
|
|
131
|
+
container: dialog.container,
|
|
132
|
+
cleanup() {
|
|
133
|
+
dialog.cleanup();
|
|
134
|
+
renderer.keyInput.off("keypress", extraKeyHandler);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
136
137
|
}
|