revspec 0.6.0 → 0.7.1
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 +60 -68
- package/bin/revspec.ts +4 -38
- package/package.json +15 -1
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +122 -58
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +145 -108
- package/src/tui/comment-input.ts +9 -13
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +13 -16
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -6
- 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 -29
- package/bun.lock +0 -216
- 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/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
- package/test/e2e/fixtures/spec.md +0 -36
- package/test/e2e/harness.ts +0 -80
- package/test/e2e/snapshot.test.ts +0 -182
- package/test/integration/cli-reply.test.ts +0 -140
- package/test/integration/cli-watch.test.ts +0 -216
- package/test/integration/cli.test.ts +0 -160
- package/test/integration/e2e-live.test.ts +0 -171
- package/test/integration/live-interaction.test.ts +0 -398
- package/test/integration/opentui-smoke.test.ts +0 -12
- package/test/unit/protocol/live-events.test.ts +0 -509
- package/test/unit/protocol/live-merge.test.ts +0 -167
- package/test/unit/protocol/merge.test.ts +0 -100
- package/test/unit/protocol/read.test.ts +0 -92
- package/test/unit/protocol/types.test.ts +0 -95
- package/test/unit/protocol/write.test.ts +0 -72
- package/test/unit/state/review-state.test.ts +0 -399
- package/test/unit/tui/pager.test.ts +0 -159
- package/test/unit/tui/ui/keybinds.test.ts +0 -71
- package/tsconfig.json +0 -14
|
@@ -1,1025 +0,0 @@
|
|
|
1
|
-
# UI Refactor: Adopt OpenCode Patterns
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Replace ad-hoc UI code with a reusable `ui/` layer borrowed from opencode patterns, refactor keybindings from monolithic switch to registry, rewrite all overlays to use shared dialog factory.
|
|
6
|
-
|
|
7
|
-
**Architecture:** Create `src/tui/ui/` with theme, dialog, hint-bar, keybinds, and markdown modules. Overlays become thin wrappers around `createDialog()`. App.ts keybinding switch becomes an action map dispatched by a keybind registry. Pager keeps TextNodeRenderable approach but uses extracted markdown parser.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** Bun, TypeScript, @opentui/core (BoxRenderable, TextRenderable, TextNodeRenderable, ScrollBoxRenderable, TextareaRenderable, SelectRenderable)
|
|
10
|
-
|
|
11
|
-
**Reference:** opencode at `~/repo/opencode/packages/opencode/src/cli/cmd/tui/` for patterns
|
|
12
|
-
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
## File Structure
|
|
16
|
-
|
|
17
|
-
**New files:**
|
|
18
|
-
- `src/tui/ui/theme.ts` — Expanded semantic theme (replaces `src/tui/theme.ts`)
|
|
19
|
-
- `src/tui/ui/dialog.ts` — Universal dialog factory with backdrop, border, hint bar
|
|
20
|
-
- `src/tui/ui/hint-bar.ts` — Reusable `[key] action` hint bar builder
|
|
21
|
-
- `src/tui/ui/keybinds.ts` — Keybind registry with sequence support (gg, dd, ]t)
|
|
22
|
-
- `src/tui/ui/markdown.ts` — Extracted inline markdown parser + table rendering
|
|
23
|
-
|
|
24
|
-
**Modified files:**
|
|
25
|
-
- `src/tui/app.ts` — Use keybind registry + dialog system, remove switch statement
|
|
26
|
-
- `src/tui/pager.ts` — Import from ui/markdown and ui/theme instead of local
|
|
27
|
-
- `src/tui/status-bar.ts` — Import from ui/theme and ui/hint-bar
|
|
28
|
-
- `src/tui/comment-input.ts` — Rewrite using createDialog()
|
|
29
|
-
- `src/tui/thread-list.ts` — Rewrite using createDialog()
|
|
30
|
-
- `src/tui/help.ts` — Rewrite using createDialog()
|
|
31
|
-
- `src/tui/confirm.ts` — Rewrite using createDialog()
|
|
32
|
-
- `src/tui/search.ts` — Import from ui/theme (stays as bottom bar, not dialog)
|
|
33
|
-
|
|
34
|
-
**Deleted files:**
|
|
35
|
-
- `src/tui/theme.ts` — Replaced by ui/theme.ts
|
|
36
|
-
|
|
37
|
-
---
|
|
38
|
-
|
|
39
|
-
## Chunk 1: UI Primitives
|
|
40
|
-
|
|
41
|
-
### Task 1: Expanded Theme
|
|
42
|
-
|
|
43
|
-
**Files:**
|
|
44
|
-
- Create: `src/tui/ui/theme.ts`
|
|
45
|
-
|
|
46
|
-
- [ ] **Step 1: Create ui/theme.ts with semantic color roles**
|
|
47
|
-
|
|
48
|
-
```typescript
|
|
49
|
-
// Catppuccin Mocha base + semantic roles matching opencode patterns
|
|
50
|
-
export const theme = {
|
|
51
|
-
// Surfaces
|
|
52
|
-
base: "#1e1e2e",
|
|
53
|
-
backgroundPanel: "#313244", // dialog/overlay backgrounds (was surface0)
|
|
54
|
-
backgroundElement: "#45475a", // hover/active states (was surface1)
|
|
55
|
-
|
|
56
|
-
// Text hierarchy
|
|
57
|
-
text: "#cdd6f4",
|
|
58
|
-
textMuted: "#a6adc8", // secondary info (was subtext)
|
|
59
|
-
textDim: "#6c7086", // hints, line numbers (was overlay)
|
|
60
|
-
|
|
61
|
-
// Semantic accents
|
|
62
|
-
blue: "#89b4fa",
|
|
63
|
-
green: "#a6e3a1",
|
|
64
|
-
red: "#f38ba8",
|
|
65
|
-
yellow: "#f9e2af",
|
|
66
|
-
mauve: "#cba6f7",
|
|
67
|
-
|
|
68
|
-
// Borders
|
|
69
|
-
border: "#45475a", // default border color
|
|
70
|
-
borderAccent: "#89b4fa", // active/focused border
|
|
71
|
-
|
|
72
|
-
// Status (matching opencode)
|
|
73
|
-
success: "#a6e3a1",
|
|
74
|
-
warning: "#f9e2af",
|
|
75
|
-
error: "#f38ba8",
|
|
76
|
-
info: "#89b4fa",
|
|
77
|
-
} as const;
|
|
78
|
-
|
|
79
|
-
export const STATUS_ICONS: Record<string, string> = {
|
|
80
|
-
open: "\u258c", // ▌
|
|
81
|
-
pending: "\u258c", // ▌
|
|
82
|
-
resolved: "\u2713", // ✓
|
|
83
|
-
outdated: "\u258c", // ▌
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
// SplitBorder chars (opencode pattern — left vertical line only)
|
|
87
|
-
export const SPLIT_BORDER = {
|
|
88
|
-
topLeft: " ",
|
|
89
|
-
topRight: " ",
|
|
90
|
-
bottomLeft: " ",
|
|
91
|
-
bottomRight: " ",
|
|
92
|
-
horizontal: " ",
|
|
93
|
-
vertical: "\u2503", // ┃
|
|
94
|
-
} as const;
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
- [ ] **Step 2: Run tests**
|
|
98
|
-
|
|
99
|
-
Run: `bun test`
|
|
100
|
-
Expected: PASS (no tests reference theme internals directly)
|
|
101
|
-
|
|
102
|
-
- [ ] **Step 3: Commit**
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
git add src/tui/ui/theme.ts
|
|
106
|
-
git commit -m "feat(ui): add expanded semantic theme"
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
### Task 2: Hint Bar Builder
|
|
110
|
-
|
|
111
|
-
**Files:**
|
|
112
|
-
- Create: `src/tui/ui/hint-bar.ts`
|
|
113
|
-
|
|
114
|
-
- [ ] **Step 1: Create hint-bar.ts**
|
|
115
|
-
|
|
116
|
-
```typescript
|
|
117
|
-
import { TextRenderable, TextNodeRenderable } from "@opentui/core";
|
|
118
|
-
import { theme } from "./theme";
|
|
119
|
-
|
|
120
|
-
export interface Hint {
|
|
121
|
-
key: string;
|
|
122
|
-
action: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Build styled hint bar content: [key] action [key] action
|
|
127
|
-
* Clears existing content and adds TextNodeRenderable children.
|
|
128
|
-
*/
|
|
129
|
-
export function buildHints(text: TextRenderable, hints: Hint[]): void {
|
|
130
|
-
text.clear();
|
|
131
|
-
text.add(TextNodeRenderable.fromString(" ", {}));
|
|
132
|
-
for (let i = 0; i < hints.length; i++) {
|
|
133
|
-
const h = hints[i];
|
|
134
|
-
text.add(TextNodeRenderable.fromString(`[${h.key}]`, { fg: theme.blue }));
|
|
135
|
-
text.add(TextNodeRenderable.fromString(` ${h.action}`, { fg: theme.textMuted }));
|
|
136
|
-
if (i < hints.length - 1) {
|
|
137
|
-
text.add(TextNodeRenderable.fromString(" ", {}));
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
- [ ] **Step 2: Commit**
|
|
144
|
-
|
|
145
|
-
```bash
|
|
146
|
-
git add src/tui/ui/hint-bar.ts
|
|
147
|
-
git commit -m "feat(ui): add reusable hint bar builder"
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
### Task 3: Dialog Factory
|
|
151
|
-
|
|
152
|
-
**Files:**
|
|
153
|
-
- Create: `src/tui/ui/dialog.ts`
|
|
154
|
-
|
|
155
|
-
- [ ] **Step 1: Create dialog.ts**
|
|
156
|
-
|
|
157
|
-
Dialog factory inspired by opencode — creates a BoxRenderable with backdrop, border, scroll content area, and hint bar. Wires Esc/Ctrl+C dismissal automatically.
|
|
158
|
-
|
|
159
|
-
```typescript
|
|
160
|
-
import {
|
|
161
|
-
BoxRenderable,
|
|
162
|
-
ScrollBoxRenderable,
|
|
163
|
-
TextRenderable,
|
|
164
|
-
type CliRenderer,
|
|
165
|
-
type KeyEvent,
|
|
166
|
-
} from "@opentui/core";
|
|
167
|
-
import { theme, SPLIT_BORDER } from "./theme";
|
|
168
|
-
import { buildHints, type Hint } from "./hint-bar";
|
|
169
|
-
|
|
170
|
-
export interface DialogOptions {
|
|
171
|
-
renderer: CliRenderer;
|
|
172
|
-
title: string;
|
|
173
|
-
width?: string | number; // default "80%"
|
|
174
|
-
height?: string | number; // default "85%"
|
|
175
|
-
top?: string | number; // default "5%"
|
|
176
|
-
left?: string | number; // default "10%"
|
|
177
|
-
borderColor?: string; // default theme.border
|
|
178
|
-
onDismiss: () => void;
|
|
179
|
-
hints?: Hint[];
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export interface DialogComponents {
|
|
183
|
-
/** The outermost container — add to renderer.root */
|
|
184
|
-
container: BoxRenderable;
|
|
185
|
-
/** Scrollable content area — add children here */
|
|
186
|
-
content: ScrollBoxRenderable;
|
|
187
|
-
/** Hint bar at bottom — update via setHints() */
|
|
188
|
-
hintText: TextRenderable;
|
|
189
|
-
/** Update hint bar content */
|
|
190
|
-
setHints: (hints: Hint[]) => void;
|
|
191
|
-
/** Cleanup key listeners */
|
|
192
|
-
cleanup: () => void;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function createDialog(opts: DialogOptions): DialogComponents {
|
|
196
|
-
const {
|
|
197
|
-
renderer, title, onDismiss,
|
|
198
|
-
width = "80%", height = "85%",
|
|
199
|
-
top = "5%", left = "10%",
|
|
200
|
-
borderColor = theme.border,
|
|
201
|
-
hints = [],
|
|
202
|
-
} = opts;
|
|
203
|
-
|
|
204
|
-
// Container with left-border accent (opencode SplitBorder pattern)
|
|
205
|
-
const container = new BoxRenderable(renderer, {
|
|
206
|
-
position: "absolute",
|
|
207
|
-
top,
|
|
208
|
-
left,
|
|
209
|
-
width,
|
|
210
|
-
height,
|
|
211
|
-
zIndex: 100,
|
|
212
|
-
backgroundColor: theme.backgroundPanel,
|
|
213
|
-
border: true,
|
|
214
|
-
borderStyle: "single",
|
|
215
|
-
borderColor,
|
|
216
|
-
customBorderChars: SPLIT_BORDER,
|
|
217
|
-
title: ` ${title} `,
|
|
218
|
-
flexDirection: "column",
|
|
219
|
-
paddingLeft: 1,
|
|
220
|
-
paddingRight: 1,
|
|
221
|
-
paddingTop: 1,
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
// Scrollable content area
|
|
225
|
-
const content = new ScrollBoxRenderable(renderer, {
|
|
226
|
-
width: "100%",
|
|
227
|
-
flexGrow: 1,
|
|
228
|
-
flexShrink: 1,
|
|
229
|
-
scrollY: true,
|
|
230
|
-
scrollX: false,
|
|
231
|
-
});
|
|
232
|
-
container.add(content);
|
|
233
|
-
|
|
234
|
-
// Hint bar at bottom
|
|
235
|
-
const hintBox = new BoxRenderable(renderer, {
|
|
236
|
-
width: "100%",
|
|
237
|
-
height: 1,
|
|
238
|
-
flexShrink: 0,
|
|
239
|
-
backgroundColor: theme.backgroundElement,
|
|
240
|
-
});
|
|
241
|
-
const hintText = new TextRenderable(renderer, {
|
|
242
|
-
content: "",
|
|
243
|
-
width: "100%",
|
|
244
|
-
fg: theme.textMuted,
|
|
245
|
-
wrapMode: "none",
|
|
246
|
-
truncate: true,
|
|
247
|
-
});
|
|
248
|
-
hintBox.add(hintText);
|
|
249
|
-
container.add(hintBox);
|
|
250
|
-
|
|
251
|
-
if (hints.length > 0) {
|
|
252
|
-
buildHints(hintText, hints);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function setHints(newHints: Hint[]): void {
|
|
256
|
-
buildHints(hintText, newHints);
|
|
257
|
-
renderer.requestRender();
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Esc/Ctrl+C dismissal
|
|
261
|
-
const keyHandler = (key: KeyEvent) => {
|
|
262
|
-
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
263
|
-
key.preventDefault();
|
|
264
|
-
key.stopPropagation();
|
|
265
|
-
onDismiss();
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
renderer.keyInput.on("keypress", keyHandler);
|
|
269
|
-
|
|
270
|
-
function cleanup(): void {
|
|
271
|
-
renderer.keyInput.off("keypress", keyHandler);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return { container, content, hintText, setHints, cleanup };
|
|
275
|
-
}
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
- [ ] **Step 2: Commit**
|
|
279
|
-
|
|
280
|
-
```bash
|
|
281
|
-
git add src/tui/ui/dialog.ts
|
|
282
|
-
git commit -m "feat(ui): add dialog factory with backdrop and hint bar"
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
### Task 4: Keybind Registry
|
|
286
|
-
|
|
287
|
-
**Files:**
|
|
288
|
-
- Create: `src/tui/ui/keybinds.ts`
|
|
289
|
-
|
|
290
|
-
- [ ] **Step 1: Create keybinds.ts**
|
|
291
|
-
|
|
292
|
-
Registry-based keybind matching with sequence support (gg, dd, ]t, [r). Replaces the 280-line switch statement + pending timers in app.ts.
|
|
293
|
-
|
|
294
|
-
```typescript
|
|
295
|
-
import type { KeyEvent } from "@opentui/core";
|
|
296
|
-
|
|
297
|
-
export interface KeyBinding {
|
|
298
|
-
/** Key pattern: "j", "G" (shift), "C-d" (ctrl), "gg" / "dd" / "]t" (sequence) */
|
|
299
|
-
key: string;
|
|
300
|
-
action: string;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
interface SequenceState {
|
|
304
|
-
first: string;
|
|
305
|
-
timer: ReturnType<typeof setTimeout>;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export interface KeybindRegistry {
|
|
309
|
-
match: (key: KeyEvent) => string | null;
|
|
310
|
-
/** Get pending sequence prefix for display (e.g., "]...") */
|
|
311
|
-
pending: () => string | null;
|
|
312
|
-
destroy: () => void;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
export function createKeybindRegistry(bindings: KeyBinding[], timeout = 500): KeybindRegistry {
|
|
316
|
-
let sequence: SequenceState | null = null;
|
|
317
|
-
|
|
318
|
-
// Separate single-key and sequence bindings
|
|
319
|
-
const singleBindings = new Map<string, string>();
|
|
320
|
-
const sequenceBindings = new Map<string, string>(); // "gg" → action
|
|
321
|
-
|
|
322
|
-
for (const b of bindings) {
|
|
323
|
-
if (b.key.length === 2 && !b.key.startsWith("C-")) {
|
|
324
|
-
sequenceBindings.set(b.key, b.action);
|
|
325
|
-
} else {
|
|
326
|
-
singleBindings.set(b.key, b.action);
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Collect all valid sequence first chars
|
|
331
|
-
const sequenceStarters = new Set<string>();
|
|
332
|
-
for (const key of sequenceBindings.keys()) {
|
|
333
|
-
sequenceStarters.add(key[0]);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function keyToString(key: KeyEvent): string {
|
|
337
|
-
if (key.ctrl && key.name) return `C-${key.name}`;
|
|
338
|
-
if (key.shift && key.name) return key.name.toUpperCase();
|
|
339
|
-
return key.sequence || key.name || "";
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function match(key: KeyEvent): string | null {
|
|
343
|
-
const keyStr = keyToString(key);
|
|
344
|
-
|
|
345
|
-
// Check if we're in a sequence
|
|
346
|
-
if (sequence) {
|
|
347
|
-
const seq = sequence.first + keyStr;
|
|
348
|
-
clearTimeout(sequence.timer);
|
|
349
|
-
sequence = null;
|
|
350
|
-
|
|
351
|
-
const action = sequenceBindings.get(seq);
|
|
352
|
-
if (action) return action;
|
|
353
|
-
// Invalid second key — fall through to single match
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// Check if this starts a sequence
|
|
357
|
-
if (sequenceStarters.has(keyStr)) {
|
|
358
|
-
// But also check if there's a ctrl version (C-d should not start "dd" sequence)
|
|
359
|
-
if (key.ctrl) {
|
|
360
|
-
const ctrlKey = `C-${key.name}`;
|
|
361
|
-
const action = singleBindings.get(ctrlKey);
|
|
362
|
-
if (action) return action;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
sequence = {
|
|
366
|
-
first: keyStr,
|
|
367
|
-
timer: setTimeout(() => { sequence = null; }, timeout),
|
|
368
|
-
};
|
|
369
|
-
return null; // waiting for second key
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// Single key match
|
|
373
|
-
// Check ctrl variants first
|
|
374
|
-
if (key.ctrl && key.name) {
|
|
375
|
-
const action = singleBindings.get(`C-${key.name}`);
|
|
376
|
-
if (action) return action;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Check shift variants
|
|
380
|
-
if (key.shift && key.name) {
|
|
381
|
-
const upper = key.name.toUpperCase();
|
|
382
|
-
const action = singleBindings.get(upper);
|
|
383
|
-
if (action) return action;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Plain key
|
|
387
|
-
const action = singleBindings.get(keyStr);
|
|
388
|
-
if (action) return action;
|
|
389
|
-
|
|
390
|
-
return null;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function pendingStr(): string | null {
|
|
394
|
-
if (!sequence) return null;
|
|
395
|
-
return `${sequence.first}...`;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function destroy(): void {
|
|
399
|
-
if (sequence) {
|
|
400
|
-
clearTimeout(sequence.timer);
|
|
401
|
-
sequence = null;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return { match, pending: pendingStr, destroy };
|
|
406
|
-
}
|
|
407
|
-
```
|
|
408
|
-
|
|
409
|
-
- [ ] **Step 2: Write test for keybind registry**
|
|
410
|
-
|
|
411
|
-
Create: `test/tui/ui/keybinds.test.ts`
|
|
412
|
-
|
|
413
|
-
```typescript
|
|
414
|
-
import { describe, expect, it } from "bun:test";
|
|
415
|
-
import { createKeybindRegistry } from "../../../src/tui/ui/keybinds";
|
|
416
|
-
|
|
417
|
-
function makeKey(name: string, opts: { ctrl?: boolean; shift?: boolean; sequence?: string } = {}): any {
|
|
418
|
-
return { name, ctrl: opts.ctrl ?? false, shift: opts.shift ?? false, sequence: opts.sequence ?? name };
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
describe("createKeybindRegistry", () => {
|
|
422
|
-
it("matches single keys", () => {
|
|
423
|
-
const reg = createKeybindRegistry([
|
|
424
|
-
{ key: "j", action: "down" },
|
|
425
|
-
{ key: "k", action: "up" },
|
|
426
|
-
]);
|
|
427
|
-
expect(reg.match(makeKey("j"))).toBe("down");
|
|
428
|
-
expect(reg.match(makeKey("k"))).toBe("up");
|
|
429
|
-
expect(reg.match(makeKey("x"))).toBeNull();
|
|
430
|
-
reg.destroy();
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
it("matches ctrl keys", () => {
|
|
434
|
-
const reg = createKeybindRegistry([
|
|
435
|
-
{ key: "C-d", action: "half-page-down" },
|
|
436
|
-
]);
|
|
437
|
-
expect(reg.match(makeKey("d", { ctrl: true }))).toBe("half-page-down");
|
|
438
|
-
expect(reg.match(makeKey("d"))).toBeNull();
|
|
439
|
-
reg.destroy();
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
it("matches shift keys", () => {
|
|
443
|
-
const reg = createKeybindRegistry([
|
|
444
|
-
{ key: "G", action: "goto-bottom" },
|
|
445
|
-
{ key: "R", action: "resolve-all" },
|
|
446
|
-
]);
|
|
447
|
-
expect(reg.match(makeKey("g", { shift: true }))).toBe("goto-bottom");
|
|
448
|
-
expect(reg.match(makeKey("r", { shift: true }))).toBe("resolve-all");
|
|
449
|
-
reg.destroy();
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it("matches two-key sequences", () => {
|
|
453
|
-
const reg = createKeybindRegistry([
|
|
454
|
-
{ key: "gg", action: "goto-top" },
|
|
455
|
-
{ key: "dd", action: "delete" },
|
|
456
|
-
]);
|
|
457
|
-
// First key returns null (pending)
|
|
458
|
-
expect(reg.match(makeKey("g"))).toBeNull();
|
|
459
|
-
expect(reg.pending()).toBe("g...");
|
|
460
|
-
// Second key completes
|
|
461
|
-
expect(reg.match(makeKey("g"))).toBe("goto-top");
|
|
462
|
-
expect(reg.pending()).toBeNull();
|
|
463
|
-
reg.destroy();
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
it("clears sequence on invalid second key", () => {
|
|
467
|
-
const reg = createKeybindRegistry([
|
|
468
|
-
{ key: "gg", action: "goto-top" },
|
|
469
|
-
{ key: "j", action: "down" },
|
|
470
|
-
]);
|
|
471
|
-
expect(reg.match(makeKey("g"))).toBeNull();
|
|
472
|
-
// Invalid second key for "g?" sequence
|
|
473
|
-
expect(reg.match(makeKey("x"))).toBeNull();
|
|
474
|
-
// Should work normally now
|
|
475
|
-
expect(reg.match(makeKey("j"))).toBe("down");
|
|
476
|
-
reg.destroy();
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it("handles bracket sequences", () => {
|
|
480
|
-
const reg = createKeybindRegistry([
|
|
481
|
-
{ key: "]t", action: "next-thread" },
|
|
482
|
-
{ key: "[t", action: "prev-thread" },
|
|
483
|
-
]);
|
|
484
|
-
expect(reg.match(makeKey("]", { sequence: "]" }))).toBeNull();
|
|
485
|
-
expect(reg.match(makeKey("t"))).toBe("next-thread");
|
|
486
|
-
reg.destroy();
|
|
487
|
-
});
|
|
488
|
-
});
|
|
489
|
-
```
|
|
490
|
-
|
|
491
|
-
- [ ] **Step 3: Run test**
|
|
492
|
-
|
|
493
|
-
Run: `bun test test/tui/ui/keybinds.test.ts`
|
|
494
|
-
Expected: PASS
|
|
495
|
-
|
|
496
|
-
- [ ] **Step 4: Commit**
|
|
497
|
-
|
|
498
|
-
```bash
|
|
499
|
-
git add src/tui/ui/keybinds.ts test/tui/ui/keybinds.test.ts
|
|
500
|
-
git commit -m "feat(ui): add keybind registry with sequence support"
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
### Task 5: Extract Markdown Parser
|
|
504
|
-
|
|
505
|
-
**Files:**
|
|
506
|
-
- Create: `src/tui/ui/markdown.ts`
|
|
507
|
-
- Modify: `src/tui/pager.ts`
|
|
508
|
-
|
|
509
|
-
- [ ] **Step 1: Extract markdown + table code from pager.ts to ui/markdown.ts**
|
|
510
|
-
|
|
511
|
-
Move these functions from pager.ts → ui/markdown.ts:
|
|
512
|
-
- `StyledSegment` interface
|
|
513
|
-
- `parseInlineMarkdown()`
|
|
514
|
-
- `parseMarkdownLine()`
|
|
515
|
-
- `addSegments()`
|
|
516
|
-
- `SEPARATOR_RE`, `parseTableCells()`, `displayWidth()`
|
|
517
|
-
- `TableBlock` interface, `collectTable()`
|
|
518
|
-
- `renderTableSeparator()`, `renderTableRow()`, `renderTableBorder()`
|
|
519
|
-
|
|
520
|
-
Update imports: use `theme` from `./theme` (ui/theme.ts).
|
|
521
|
-
|
|
522
|
-
Export all public functions.
|
|
523
|
-
|
|
524
|
-
- [ ] **Step 2: Update pager.ts imports**
|
|
525
|
-
|
|
526
|
-
Replace local function definitions with:
|
|
527
|
-
```typescript
|
|
528
|
-
import { parseMarkdownLine, addSegments, collectTable, renderTableBorder, renderTableSeparator, renderTableRow, parseTableCells, type TableBlock } from "./ui/markdown";
|
|
529
|
-
import { theme, STATUS_ICONS } from "./ui/theme";
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
- [ ] **Step 3: Run tests**
|
|
533
|
-
|
|
534
|
-
Run: `bun test`
|
|
535
|
-
Expected: PASS (all 163 tests)
|
|
536
|
-
|
|
537
|
-
- [ ] **Step 4: Commit**
|
|
538
|
-
|
|
539
|
-
```bash
|
|
540
|
-
git add src/tui/ui/markdown.ts src/tui/pager.ts
|
|
541
|
-
git commit -m "refactor: extract markdown parser to ui/markdown"
|
|
542
|
-
```
|
|
543
|
-
|
|
544
|
-
---
|
|
545
|
-
|
|
546
|
-
## Chunk 2: Rewrite Overlays + App
|
|
547
|
-
|
|
548
|
-
### Task 6: Rewrite Help Overlay
|
|
549
|
-
|
|
550
|
-
**Files:**
|
|
551
|
-
- Modify: `src/tui/help.ts`
|
|
552
|
-
|
|
553
|
-
- [ ] **Step 1: Rewrite using createDialog()**
|
|
554
|
-
|
|
555
|
-
```typescript
|
|
556
|
-
import { TextRenderable, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
557
|
-
import { createDialog, type DialogComponents } from "./ui/dialog";
|
|
558
|
-
import { theme } from "./ui/theme";
|
|
559
|
-
|
|
560
|
-
export interface HelpOverlay {
|
|
561
|
-
container: import("@opentui/core").BoxRenderable;
|
|
562
|
-
cleanup: () => void;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
export function createHelp(opts: {
|
|
566
|
-
renderer: CliRenderer;
|
|
567
|
-
version: string;
|
|
568
|
-
onClose: () => void;
|
|
569
|
-
}): HelpOverlay {
|
|
570
|
-
const { renderer, version, onClose } = opts;
|
|
571
|
-
|
|
572
|
-
const dialog = createDialog({
|
|
573
|
-
renderer,
|
|
574
|
-
title: "Help",
|
|
575
|
-
width: "60%",
|
|
576
|
-
height: Math.min(26, renderer.height - 2),
|
|
577
|
-
top: "10%",
|
|
578
|
-
left: "20%",
|
|
579
|
-
borderColor: theme.info,
|
|
580
|
-
onDismiss: onClose,
|
|
581
|
-
hints: [
|
|
582
|
-
{ key: "q/?/Esc", action: "close" },
|
|
583
|
-
{ key: "j/k", action: "scroll" },
|
|
584
|
-
],
|
|
585
|
-
});
|
|
586
|
-
|
|
587
|
-
const helpText = [
|
|
588
|
-
"",
|
|
589
|
-
` revspec v${version}`,
|
|
590
|
-
"",
|
|
591
|
-
" Navigation",
|
|
592
|
-
" j/k Down/up",
|
|
593
|
-
" gg Go to first line",
|
|
594
|
-
" G Go to last line",
|
|
595
|
-
" Ctrl+d/u Half page down/up",
|
|
596
|
-
" / Search",
|
|
597
|
-
" n/N Next/prev search match",
|
|
598
|
-
" Esc Clear search highlights",
|
|
599
|
-
" ]t/[t Next/prev thread",
|
|
600
|
-
" ]r/[r Next/prev unread thread",
|
|
601
|
-
"",
|
|
602
|
-
" Review",
|
|
603
|
-
" c Comment / view thread / reply",
|
|
604
|
-
" r Resolve thread",
|
|
605
|
-
" R Resolve all pending",
|
|
606
|
-
" dd Delete draft comment",
|
|
607
|
-
" l List threads",
|
|
608
|
-
" a Approve spec",
|
|
609
|
-
"",
|
|
610
|
-
" Commands",
|
|
611
|
-
" :w Save review",
|
|
612
|
-
" :q Save and quit",
|
|
613
|
-
" :wq Save and quit",
|
|
614
|
-
" :q! Quit without saving",
|
|
615
|
-
"",
|
|
616
|
-
].join("\n");
|
|
617
|
-
|
|
618
|
-
const content = new TextRenderable(renderer, {
|
|
619
|
-
content: helpText,
|
|
620
|
-
width: "100%",
|
|
621
|
-
fg: theme.text,
|
|
622
|
-
wrapMode: "none",
|
|
623
|
-
});
|
|
624
|
-
dialog.content.add(content);
|
|
625
|
-
|
|
626
|
-
// Additional key handler for q/? and j/k scroll
|
|
627
|
-
const extraKeyHandler = (key: KeyEvent) => {
|
|
628
|
-
if (key.name === "q" || key.sequence === "?") {
|
|
629
|
-
key.preventDefault();
|
|
630
|
-
key.stopPropagation();
|
|
631
|
-
onClose();
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
if (key.name === "j" || key.name === "down") {
|
|
635
|
-
key.preventDefault();
|
|
636
|
-
key.stopPropagation();
|
|
637
|
-
dialog.content.scrollBy(1);
|
|
638
|
-
renderer.requestRender();
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
if (key.name === "k" || key.name === "up") {
|
|
642
|
-
key.preventDefault();
|
|
643
|
-
key.stopPropagation();
|
|
644
|
-
dialog.content.scrollBy(-1);
|
|
645
|
-
renderer.requestRender();
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
648
|
-
};
|
|
649
|
-
renderer.keyInput.on("keypress", extraKeyHandler);
|
|
650
|
-
|
|
651
|
-
return {
|
|
652
|
-
container: dialog.container,
|
|
653
|
-
cleanup() {
|
|
654
|
-
dialog.cleanup();
|
|
655
|
-
renderer.keyInput.off("keypress", extraKeyHandler);
|
|
656
|
-
},
|
|
657
|
-
};
|
|
658
|
-
}
|
|
659
|
-
```
|
|
660
|
-
|
|
661
|
-
- [ ] **Step 2: Run tests + manual test**
|
|
662
|
-
|
|
663
|
-
Run: `bun test`
|
|
664
|
-
|
|
665
|
-
- [ ] **Step 3: Commit**
|
|
666
|
-
|
|
667
|
-
```bash
|
|
668
|
-
git add src/tui/help.ts
|
|
669
|
-
git commit -m "refactor: rewrite help overlay using createDialog()"
|
|
670
|
-
```
|
|
671
|
-
|
|
672
|
-
### Task 7: Rewrite Confirm Dialog
|
|
673
|
-
|
|
674
|
-
**Files:**
|
|
675
|
-
- Modify: `src/tui/confirm.ts`
|
|
676
|
-
|
|
677
|
-
- [ ] **Step 1: Rewrite using createDialog()**
|
|
678
|
-
|
|
679
|
-
```typescript
|
|
680
|
-
import { TextRenderable, type CliRenderer, type KeyEvent } from "@opentui/core";
|
|
681
|
-
import { createDialog } from "./ui/dialog";
|
|
682
|
-
import { theme } from "./ui/theme";
|
|
683
|
-
|
|
684
|
-
export interface ConfirmOverlay {
|
|
685
|
-
container: import("@opentui/core").BoxRenderable;
|
|
686
|
-
cleanup: () => void;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
export function createConfirm(opts: {
|
|
690
|
-
renderer: CliRenderer;
|
|
691
|
-
message: string;
|
|
692
|
-
onConfirm: () => void;
|
|
693
|
-
onCancel: () => void;
|
|
694
|
-
}): ConfirmOverlay {
|
|
695
|
-
const { renderer, message, onConfirm, onCancel } = opts;
|
|
696
|
-
|
|
697
|
-
const dialog = createDialog({
|
|
698
|
-
renderer,
|
|
699
|
-
title: "Confirm",
|
|
700
|
-
width: "50%",
|
|
701
|
-
height: 7,
|
|
702
|
-
top: "35%",
|
|
703
|
-
left: "25%",
|
|
704
|
-
borderColor: theme.warning,
|
|
705
|
-
onDismiss: onCancel,
|
|
706
|
-
hints: [
|
|
707
|
-
{ key: "y", action: "yes" },
|
|
708
|
-
{ key: "n/Esc", action: "no" },
|
|
709
|
-
],
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
const msgText = new TextRenderable(renderer, {
|
|
713
|
-
content: ` ${message}`,
|
|
714
|
-
width: "100%",
|
|
715
|
-
fg: theme.text,
|
|
716
|
-
wrapMode: "word",
|
|
717
|
-
});
|
|
718
|
-
dialog.content.add(msgText);
|
|
719
|
-
|
|
720
|
-
const extraKeyHandler = (key: KeyEvent) => {
|
|
721
|
-
if (key.name === "y") {
|
|
722
|
-
key.preventDefault();
|
|
723
|
-
key.stopPropagation();
|
|
724
|
-
onConfirm();
|
|
725
|
-
return;
|
|
726
|
-
}
|
|
727
|
-
if (key.name === "n") {
|
|
728
|
-
key.preventDefault();
|
|
729
|
-
key.stopPropagation();
|
|
730
|
-
onCancel();
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
};
|
|
734
|
-
renderer.keyInput.on("keypress", extraKeyHandler);
|
|
735
|
-
|
|
736
|
-
return {
|
|
737
|
-
container: dialog.container,
|
|
738
|
-
cleanup() {
|
|
739
|
-
dialog.cleanup();
|
|
740
|
-
renderer.keyInput.off("keypress", extraKeyHandler);
|
|
741
|
-
},
|
|
742
|
-
};
|
|
743
|
-
}
|
|
744
|
-
```
|
|
745
|
-
|
|
746
|
-
- [ ] **Step 2: Commit**
|
|
747
|
-
|
|
748
|
-
```bash
|
|
749
|
-
git add src/tui/confirm.ts
|
|
750
|
-
git commit -m "refactor: rewrite confirm dialog using createDialog()"
|
|
751
|
-
```
|
|
752
|
-
|
|
753
|
-
### Task 8: Rewrite Thread List
|
|
754
|
-
|
|
755
|
-
**Files:**
|
|
756
|
-
- Modify: `src/tui/thread-list.ts`
|
|
757
|
-
|
|
758
|
-
- [ ] **Step 1: Rewrite using createDialog()**
|
|
759
|
-
|
|
760
|
-
Keep SelectRenderable for the list, but use createDialog() for the container.
|
|
761
|
-
|
|
762
|
-
```typescript
|
|
763
|
-
import {
|
|
764
|
-
SelectRenderable,
|
|
765
|
-
SelectRenderableEvents,
|
|
766
|
-
type CliRenderer,
|
|
767
|
-
} from "@opentui/core";
|
|
768
|
-
import type { Thread } from "../protocol/types";
|
|
769
|
-
import { createDialog } from "./ui/dialog";
|
|
770
|
-
import { theme, STATUS_ICONS } from "./ui/theme";
|
|
771
|
-
|
|
772
|
-
export interface ThreadListOverlay {
|
|
773
|
-
container: import("@opentui/core").BoxRenderable;
|
|
774
|
-
cleanup: () => void;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
const MAX_PREVIEW_LENGTH = 50;
|
|
778
|
-
|
|
779
|
-
function previewText(thread: Thread): string {
|
|
780
|
-
if (thread.messages.length === 0) return "(empty)";
|
|
781
|
-
const last = thread.messages[thread.messages.length - 1];
|
|
782
|
-
const text = last.text.replace(/\n/g, " ");
|
|
783
|
-
if (text.length <= MAX_PREVIEW_LENGTH) return text;
|
|
784
|
-
return text.slice(0, MAX_PREVIEW_LENGTH - 1) + "\u2026";
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
export function createThreadList(opts: {
|
|
788
|
-
renderer: CliRenderer;
|
|
789
|
-
threads: Thread[];
|
|
790
|
-
onSelect: (lineNumber: number) => void;
|
|
791
|
-
onCancel: () => void;
|
|
792
|
-
}): ThreadListOverlay {
|
|
793
|
-
const { renderer, threads, onSelect, onCancel } = opts;
|
|
794
|
-
const activeThreads = threads.filter(t => t.status === "open" || t.status === "pending");
|
|
795
|
-
|
|
796
|
-
const dialog = createDialog({
|
|
797
|
-
renderer,
|
|
798
|
-
title: `Threads (${activeThreads.length} active)`,
|
|
799
|
-
width: "70%",
|
|
800
|
-
height: "60%",
|
|
801
|
-
top: "15%",
|
|
802
|
-
left: "15%",
|
|
803
|
-
borderColor: theme.mauve,
|
|
804
|
-
onDismiss: onCancel,
|
|
805
|
-
hints: [
|
|
806
|
-
{ key: "j/k", action: "navigate" },
|
|
807
|
-
{ key: "Enter", action: "jump" },
|
|
808
|
-
{ key: "Esc", action: "close" },
|
|
809
|
-
],
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
if (activeThreads.length > 0) {
|
|
813
|
-
const selectOptions = activeThreads.map(t => ({
|
|
814
|
-
name: `${STATUS_ICONS[t.status]} #${t.id} line ${t.line}: ${previewText(t)}`,
|
|
815
|
-
description: `${t.status} - ${t.messages.length} message(s)`,
|
|
816
|
-
value: t.line,
|
|
817
|
-
}));
|
|
818
|
-
|
|
819
|
-
const select = new SelectRenderable(renderer, {
|
|
820
|
-
width: "100%",
|
|
821
|
-
flexGrow: 1,
|
|
822
|
-
options: selectOptions,
|
|
823
|
-
selectedIndex: 0,
|
|
824
|
-
backgroundColor: theme.backgroundPanel,
|
|
825
|
-
textColor: theme.text,
|
|
826
|
-
focusedBackgroundColor: theme.backgroundPanel,
|
|
827
|
-
focusedTextColor: theme.text,
|
|
828
|
-
selectedBackgroundColor: theme.backgroundElement,
|
|
829
|
-
selectedTextColor: theme.mauve,
|
|
830
|
-
descriptionColor: theme.textDim,
|
|
831
|
-
selectedDescriptionColor: theme.textMuted,
|
|
832
|
-
showDescription: true,
|
|
833
|
-
wrapSelection: true,
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
dialog.content.add(select);
|
|
837
|
-
renderer.focusRenderable(select);
|
|
838
|
-
|
|
839
|
-
select.on(SelectRenderableEvents.ITEM_SELECTED, () => {
|
|
840
|
-
const selected = select.getSelectedOption();
|
|
841
|
-
if (selected?.value != null) onSelect(selected.value as number);
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
return {
|
|
846
|
-
container: dialog.container,
|
|
847
|
-
cleanup: dialog.cleanup,
|
|
848
|
-
};
|
|
849
|
-
}
|
|
850
|
-
```
|
|
851
|
-
|
|
852
|
-
- [ ] **Step 2: Commit**
|
|
853
|
-
|
|
854
|
-
```bash
|
|
855
|
-
git add src/tui/thread-list.ts
|
|
856
|
-
git commit -m "refactor: rewrite thread-list using createDialog()"
|
|
857
|
-
```
|
|
858
|
-
|
|
859
|
-
### Task 9: Rewrite Comment Input
|
|
860
|
-
|
|
861
|
-
**Files:**
|
|
862
|
-
- Modify: `src/tui/comment-input.ts`
|
|
863
|
-
|
|
864
|
-
- [ ] **Step 1: Rewrite using createDialog()**
|
|
865
|
-
|
|
866
|
-
Keep the thread conversation view (message boxes, normal/insert mode, textarea) but use createDialog() for the container. The dialog's built-in Esc handler needs to be overridden since Esc switches modes in insert mode.
|
|
867
|
-
|
|
868
|
-
This is the most complex overlay — keep the existing thread rendering logic (renderMessage, appendToConversation, normal/insert mode) but replace the manual BoxRenderable container with createDialog().
|
|
869
|
-
|
|
870
|
-
Key changes:
|
|
871
|
-
- Use `createDialog()` for container + hint bar
|
|
872
|
-
- Use `dialog.setHints()` to switch between normal/insert hints
|
|
873
|
-
- The dialog's Esc handler calls onCancel in normal mode; in insert mode, Esc switches to normal mode (handled by the extra key handler)
|
|
874
|
-
- Keep all thread rendering, message appending, scrolling logic
|
|
875
|
-
|
|
876
|
-
- [ ] **Step 2: Run tests + manual test**
|
|
877
|
-
|
|
878
|
-
Run: `bun test`
|
|
879
|
-
|
|
880
|
-
- [ ] **Step 3: Commit**
|
|
881
|
-
|
|
882
|
-
```bash
|
|
883
|
-
git add src/tui/comment-input.ts
|
|
884
|
-
git commit -m "refactor: rewrite comment-input using createDialog()"
|
|
885
|
-
```
|
|
886
|
-
|
|
887
|
-
### Task 10: Update Imports Across Codebase
|
|
888
|
-
|
|
889
|
-
**Files:**
|
|
890
|
-
- Modify: `src/tui/pager.ts` — import theme from ui/theme
|
|
891
|
-
- Modify: `src/tui/status-bar.ts` — import theme from ui/theme, use buildHints
|
|
892
|
-
- Modify: `src/tui/search.ts` — import theme from ui/theme
|
|
893
|
-
- Delete: `src/tui/theme.ts`
|
|
894
|
-
|
|
895
|
-
- [ ] **Step 1: Update all theme imports**
|
|
896
|
-
|
|
897
|
-
Change all `import { theme } from "./theme"` to `import { theme } from "./ui/theme"` in:
|
|
898
|
-
- pager.ts
|
|
899
|
-
- status-bar.ts
|
|
900
|
-
- search.ts
|
|
901
|
-
|
|
902
|
-
- [ ] **Step 2: Update status-bar.ts to use buildHints**
|
|
903
|
-
|
|
904
|
-
Replace the manual hint building in `buildBottomBar` with `buildHints()` from ui/hint-bar.
|
|
905
|
-
|
|
906
|
-
- [ ] **Step 3: Delete src/tui/theme.ts**
|
|
907
|
-
|
|
908
|
-
- [ ] **Step 4: Run tests**
|
|
909
|
-
|
|
910
|
-
Run: `bun test`
|
|
911
|
-
Expected: PASS
|
|
912
|
-
|
|
913
|
-
- [ ] **Step 5: Commit**
|
|
914
|
-
|
|
915
|
-
```bash
|
|
916
|
-
git add -A
|
|
917
|
-
git commit -m "refactor: consolidate theme imports, delete old theme.ts"
|
|
918
|
-
```
|
|
919
|
-
|
|
920
|
-
### Task 11: Refactor app.ts Keybindings
|
|
921
|
-
|
|
922
|
-
**Files:**
|
|
923
|
-
- Modify: `src/tui/app.ts`
|
|
924
|
-
|
|
925
|
-
- [ ] **Step 1: Replace switch statement with keybind registry**
|
|
926
|
-
|
|
927
|
-
In app.ts, replace the `switch (key.name)` block (lines 454-734) and the pending timer state variables with:
|
|
928
|
-
|
|
929
|
-
1. Define bindings array:
|
|
930
|
-
```typescript
|
|
931
|
-
const bindings: KeyBinding[] = [
|
|
932
|
-
{ key: "j", action: "cursor-down" },
|
|
933
|
-
{ key: "k", action: "cursor-up" },
|
|
934
|
-
{ key: "C-d", action: "half-page-down" },
|
|
935
|
-
{ key: "C-u", action: "half-page-up" },
|
|
936
|
-
{ key: "G", action: "goto-bottom" },
|
|
937
|
-
{ key: "gg", action: "goto-top" },
|
|
938
|
-
{ key: "n", action: "search-next" },
|
|
939
|
-
{ key: "N", action: "search-prev" },
|
|
940
|
-
{ key: "c", action: "comment" },
|
|
941
|
-
{ key: "l", action: "thread-list" },
|
|
942
|
-
{ key: "r", action: "resolve" },
|
|
943
|
-
{ key: "R", action: "resolve-all" },
|
|
944
|
-
{ key: "dd", action: "delete-draft" },
|
|
945
|
-
{ key: "a", action: "approve" },
|
|
946
|
-
{ key: "]t", action: "next-thread" },
|
|
947
|
-
{ key: "[t", action: "prev-thread" },
|
|
948
|
-
{ key: "]r", action: "next-unread" },
|
|
949
|
-
{ key: "[r", action: "prev-unread" },
|
|
950
|
-
{ key: "?", action: "help" },
|
|
951
|
-
{ key: "/", action: "search" },
|
|
952
|
-
{ key: ":", action: "command-mode" },
|
|
953
|
-
];
|
|
954
|
-
```
|
|
955
|
-
|
|
956
|
-
2. Create registry: `const keybinds = createKeybindRegistry(bindings);`
|
|
957
|
-
|
|
958
|
-
3. Replace the switch with action dispatch:
|
|
959
|
-
```typescript
|
|
960
|
-
const action = keybinds.match(key);
|
|
961
|
-
if (!action) {
|
|
962
|
-
// Show pending sequence hint if waiting
|
|
963
|
-
const p = keybinds.pending();
|
|
964
|
-
if (p) {
|
|
965
|
-
bottomBar.text.content = ` ${p}`;
|
|
966
|
-
renderer.requestRender();
|
|
967
|
-
}
|
|
968
|
-
return;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
const actions: Record<string, () => void> = {
|
|
972
|
-
"cursor-down": () => { ... },
|
|
973
|
-
"cursor-up": () => { ... },
|
|
974
|
-
// ... all actions
|
|
975
|
-
};
|
|
976
|
-
|
|
977
|
-
actions[action]?.();
|
|
978
|
-
```
|
|
979
|
-
|
|
980
|
-
4. Remove the pending timer state vars (bracketPending, deletePendingTimer, gPendingTimer) — now handled by the registry.
|
|
981
|
-
|
|
982
|
-
- [ ] **Step 2: Run tests**
|
|
983
|
-
|
|
984
|
-
Run: `bun test`
|
|
985
|
-
Expected: PASS
|
|
986
|
-
|
|
987
|
-
- [ ] **Step 3: Commit**
|
|
988
|
-
|
|
989
|
-
```bash
|
|
990
|
-
git add src/tui/app.ts
|
|
991
|
-
git commit -m "refactor: replace switch statement with keybind registry"
|
|
992
|
-
```
|
|
993
|
-
|
|
994
|
-
### Task 12: Final Cleanup
|
|
995
|
-
|
|
996
|
-
- [ ] **Step 1: Run full test suite**
|
|
997
|
-
|
|
998
|
-
Run: `bun test`
|
|
999
|
-
Expected: All 163+ tests pass
|
|
1000
|
-
|
|
1001
|
-
- [ ] **Step 2: Manual test with revspec**
|
|
1002
|
-
|
|
1003
|
-
```bash
|
|
1004
|
-
bun link
|
|
1005
|
-
revspec /tmp/test-spec.md
|
|
1006
|
-
```
|
|
1007
|
-
|
|
1008
|
-
Verify:
|
|
1009
|
-
- Pager renders with inline markdown
|
|
1010
|
-
- Help overlay (?) opens with new dialog style
|
|
1011
|
-
- Comment input (c) works with thread view
|
|
1012
|
-
- Thread list (l) shows active threads
|
|
1013
|
-
- Confirm dialog (a) shows approve prompt
|
|
1014
|
-
- Search (/) works from bottom bar
|
|
1015
|
-
- Keybindings: j/k, gg, G, Ctrl+d/u, ]t/[t, dd all work
|
|
1016
|
-
- :q exits cleanly
|
|
1017
|
-
|
|
1018
|
-
- [ ] **Step 3: Commit any fixes**
|
|
1019
|
-
|
|
1020
|
-
- [ ] **Step 4: Final commit**
|
|
1021
|
-
|
|
1022
|
-
```bash
|
|
1023
|
-
git add -A
|
|
1024
|
-
git commit -m "chore: UI refactor complete — opencode patterns adopted"
|
|
1025
|
-
```
|