revspec 0.4.0 → 0.6.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/CLAUDE.md +3 -1
- package/README.md +48 -10
- package/bun.lock +3 -0
- package/package.json +6 -3
- package/src/state/review-state.ts +5 -0
- package/src/tui/app.ts +65 -34
- package/src/tui/comment-input.ts +15 -3
- package/src/tui/help.ts +123 -38
- package/src/tui/pager.ts +36 -14
- package/src/tui/search.ts +9 -4
- package/src/tui/status-bar.ts +17 -7
- package/src/tui/thread-list.ts +1 -1
- package/src/tui/ui/keybinds.ts +4 -2
- package/src/tui/ui/markdown.ts +50 -9
- package/test/e2e/__snapshots__/snapshot.test.ts.snap +31 -0
- package/test/e2e/fixtures/spec.md +36 -0
- package/test/e2e/harness.ts +80 -0
- package/test/e2e/snapshot.test.ts +182 -0
- package/test/{cli-reply.test.ts → integration/cli-reply.test.ts} +2 -2
- package/test/{cli-watch.test.ts → integration/cli-watch.test.ts} +2 -2
- package/test/{cli.test.ts → integration/cli.test.ts} +3 -3
- package/test/{e2e-live.test.ts → integration/e2e-live.test.ts} +4 -4
- package/test/{live-interaction.test.ts → integration/live-interaction.test.ts} +4 -4
- package/test/{protocol → unit/protocol}/live-events.test.ts +1 -1
- package/test/{protocol → unit/protocol}/live-merge.test.ts +3 -3
- package/test/{protocol → unit/protocol}/merge.test.ts +2 -2
- package/test/{protocol → unit/protocol}/read.test.ts +2 -2
- package/test/{protocol → unit/protocol}/types.test.ts +1 -1
- package/test/{protocol → unit/protocol}/write.test.ts +2 -2
- package/test/{state → unit/state}/review-state.test.ts +2 -2
- package/test/{tui → unit/tui}/pager.test.ts +3 -3
- package/test/{tui → unit/tui}/ui/keybinds.test.ts +1 -1
- /package/test/{opentui-smoke.test.ts → integration/opentui-smoke.test.ts} +0 -0
package/CLAUDE.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
- Tech: Bun + TypeScript + @opentui/core
|
|
4
4
|
- npm: `revspec` | GitHub: icyrainz/revspec
|
|
5
5
|
- Run: `bun run bin/revspec.ts <file.md>`
|
|
6
|
-
- Test: `bun test`
|
|
6
|
+
- Test: `bun run test` (~3s, excludes E2E)
|
|
7
|
+
- E2E: `bun run test:e2e` (~25s, bun-pty snapshots — only run before release, update with `--update-snapshots`)
|
|
8
|
+
- All: `bun run test:all` (everything)
|
|
7
9
|
- Release: `./scripts/release.sh` (version is set manually in package.json)
|
|
8
10
|
- Dev: `bun link` to symlink local build to global `revspec` command
|
|
9
11
|
|
package/README.md
CHANGED
|
@@ -29,28 +29,56 @@ revspec spec.md
|
|
|
29
29
|
|
|
30
30
|
Opens a TUI in line mode with vim-style navigation. Press `c` on any line to open a thread and start commenting.
|
|
31
31
|
|
|
32
|
+
### Markdown rendering
|
|
33
|
+
|
|
34
|
+
Revspec renders markdown in-place (toggle with `m`):
|
|
35
|
+
|
|
36
|
+
- **Headings** — colored and bold, `#`–`######`
|
|
37
|
+
- **Inline** — bold (`**`/`__`), italic (`*`/`_`), bold-italic (`***`), strikethrough (`~~`), `code`, [links](url)
|
|
38
|
+
- **Fenced code blocks** — ` ``` ` markers dimmed, body in green
|
|
39
|
+
- **Tables** — box-drawing borders, header row bolded, auto-column-widths
|
|
40
|
+
- **Lists** — unordered (`•`), ordered, task lists (`☐`/`☑`)
|
|
41
|
+
- **Blockquotes** — bar gutter, italicized text
|
|
42
|
+
- **Cursor line** highlighting across all elements
|
|
43
|
+
- **Search highlights** — colored match segments
|
|
44
|
+
|
|
32
45
|
### Keybindings
|
|
33
46
|
|
|
47
|
+
**Navigation**
|
|
48
|
+
|
|
34
49
|
| Key | Action |
|
|
35
50
|
|-----|--------|
|
|
36
51
|
| `j/k` | Move cursor down/up |
|
|
37
52
|
| `gg` / `G` | Go to top / bottom |
|
|
38
53
|
| `Ctrl+D/U` | Half page down/up |
|
|
39
|
-
| `
|
|
40
|
-
|
|
|
41
|
-
| `r` | Resolve thread (toggle) |
|
|
42
|
-
| `R` | Resolve all pending |
|
|
43
|
-
| `dd` | Delete draft comment (double-tap) |
|
|
44
|
-
| `/` | Search |
|
|
54
|
+
| `zz` | Center cursor line in viewport |
|
|
55
|
+
| `/` | Search (smartcase: lowercase = case-insensitive, any uppercase = case-sensitive) |
|
|
45
56
|
| `n/N` | Next/prev search match |
|
|
57
|
+
| `Esc` | Clear search highlights |
|
|
46
58
|
| `]t/[t` | Next/prev thread |
|
|
47
59
|
| `]r/[r` | Next/prev unread AI reply |
|
|
48
|
-
|
|
60
|
+
|
|
61
|
+
**Review**
|
|
62
|
+
|
|
63
|
+
| Key | Action |
|
|
64
|
+
|-----|--------|
|
|
65
|
+
| `c` | Open thread / comment on line |
|
|
66
|
+
| `r` | Resolve thread (toggle) |
|
|
67
|
+
| `R` | Resolve all pending |
|
|
68
|
+
| `dd` | Delete thread (with confirm) |
|
|
69
|
+
| `T` | List threads |
|
|
49
70
|
| `a` | Approve spec |
|
|
71
|
+
|
|
72
|
+
**Commands**
|
|
73
|
+
|
|
74
|
+
| Key | Action |
|
|
75
|
+
|-----|--------|
|
|
50
76
|
| `:w` | Merge changes to review JSON |
|
|
51
|
-
| `:wq` | Merge and quit |
|
|
52
|
-
| `:q` | Quit (
|
|
77
|
+
| `:wq` / `:qw` | Merge and quit |
|
|
78
|
+
| `:q` | Quit (blocks if unsaved) |
|
|
53
79
|
| `:q!` | Quit without merging |
|
|
80
|
+
| `:{N}` | Jump to line N (e.g. `:42`) |
|
|
81
|
+
| `Ctrl+C` | Quit without merging |
|
|
54
82
|
| `?` | Help |
|
|
55
83
|
|
|
56
84
|
### Thread popup
|
|
@@ -58,7 +86,7 @@ Opens a TUI in line mode with vim-style navigation. Press `c` on any line to ope
|
|
|
58
86
|
The thread popup has two modes:
|
|
59
87
|
|
|
60
88
|
- **Insert mode** — type your comment, `Tab` sends, `Esc` switches to normal mode
|
|
61
|
-
- **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation
|
|
89
|
+
- **Normal mode** — `j/k` and `Ctrl+D/U` scroll the conversation, `gg/G` top/bottom, `c` to reply, `r` to resolve, `Esc` to close
|
|
62
90
|
|
|
63
91
|
## Live AI Integration
|
|
64
92
|
|
|
@@ -112,6 +140,16 @@ Install the `/revspec` skill for Claude Code:
|
|
|
112
140
|
|
|
113
141
|
Then use `/revspec` in Claude Code after generating a spec.
|
|
114
142
|
|
|
143
|
+
## Testing
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
bun test # Run all tests (~70s)
|
|
147
|
+
bun test test/e2e # E2E snapshot tests only (~66s)
|
|
148
|
+
bun test --update-snapshots # Regenerate snapshots after UI changes
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
E2E tests use `bun-pty` to spawn revspec in a pseudo-terminal (80x24), send keystrokes, capture plain-text screen output, and compare against saved snapshots. Covers: navigation, search, overlays (help, comment, thread list, confirm), thread creation/resolve/delete, command mode, and context-sensitive hints.
|
|
152
|
+
|
|
115
153
|
## Protocol
|
|
116
154
|
|
|
117
155
|
Communication happens through a JSONL file (`spec.review.live.jsonl`) — append-only, both sides write to it. On session end, events are merged into `spec.review.json`.
|
package/bun.lock
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@types/bun": "latest",
|
|
12
|
+
"bun-pty": "^0.4.8",
|
|
12
13
|
},
|
|
13
14
|
"peerDependencies": {
|
|
14
15
|
"typescript": "^5",
|
|
@@ -110,6 +111,8 @@
|
|
|
110
111
|
|
|
111
112
|
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
|
112
113
|
|
|
114
|
+
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
|
115
|
+
|
|
113
116
|
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
|
114
117
|
|
|
115
118
|
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
package/package.json
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "revspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"revspec": "./bin/revspec.ts"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
9
|
"start": "bun run bin/revspec.ts",
|
|
10
|
-
"test": "bun test"
|
|
10
|
+
"test": "bun test test/unit test/integration",
|
|
11
|
+
"test:e2e": "bun test test/e2e",
|
|
12
|
+
"test:all": "bun test"
|
|
11
13
|
},
|
|
12
14
|
"devDependencies": {
|
|
13
|
-
"@types/bun": "latest"
|
|
15
|
+
"@types/bun": "latest",
|
|
16
|
+
"bun-pty": "^0.4.8"
|
|
14
17
|
},
|
|
15
18
|
"peerDependencies": {
|
|
16
19
|
"typescript": "^5"
|
|
@@ -136,6 +136,11 @@ export class ReviewState {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
deleteThread(threadId: string): void {
|
|
140
|
+
this.threads = this.threads.filter((t) => t.id !== threadId);
|
|
141
|
+
this._unreadThreadIds.delete(threadId);
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
addOwnerReply(threadId: string, text: string, ts?: number): void {
|
|
140
145
|
const thread = this.threads.find((t) => t.id === threadId);
|
|
141
146
|
if (!thread) return;
|
package/src/tui/app.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { buildPagerNodes, createPager, countExtraVisualLines, type PagerComponen
|
|
|
17
17
|
import {
|
|
18
18
|
buildTopBar,
|
|
19
19
|
buildBottomBar,
|
|
20
|
+
setBottomBarMessage,
|
|
20
21
|
createTopBar,
|
|
21
22
|
createBottomBar,
|
|
22
23
|
type TopBarComponents,
|
|
@@ -129,7 +130,8 @@ export async function runTui(
|
|
|
129
130
|
|
|
130
131
|
buildPagerNodes(pager.lineNode, state, searchQuery, state.unreadThreadIds);
|
|
131
132
|
buildTopBar(topBar, specFile, state, state.unreadCount(), specMtimeChanged);
|
|
132
|
-
|
|
133
|
+
const hasThread = !!state.threadAtLine(state.cursorLine);
|
|
134
|
+
buildBottomBar(bottomBar, commandBuffer, hasThread);
|
|
133
135
|
renderer.requestRender();
|
|
134
136
|
}
|
|
135
137
|
|
|
@@ -221,12 +223,12 @@ export async function runTui(
|
|
|
221
223
|
if (cmd === "w") {
|
|
222
224
|
// Merge JSONL -> JSON, stay open
|
|
223
225
|
doMerge();
|
|
224
|
-
bottomBar
|
|
226
|
+
setBottomBarMessage(bottomBar, " \u2714 Merged to review JSON");
|
|
225
227
|
renderer.requestRender();
|
|
226
228
|
setTimeout(() => { refreshPager(); }, 1200);
|
|
227
229
|
return "stay";
|
|
228
230
|
}
|
|
229
|
-
if (cmd === "wq") {
|
|
231
|
+
if (cmd === "wq" || cmd === "qw") {
|
|
230
232
|
// Merge and exit
|
|
231
233
|
mergeAndExit(resolve);
|
|
232
234
|
return "merged";
|
|
@@ -234,7 +236,7 @@ export async function runTui(
|
|
|
234
236
|
if (cmd === "q") {
|
|
235
237
|
// Exit only if merged (no pending changes)
|
|
236
238
|
if (hasPendingChanges()) {
|
|
237
|
-
bottomBar
|
|
239
|
+
setBottomBarMessage(bottomBar, " Unmerged changes. Use :w to save or :q! to discard");
|
|
238
240
|
renderer.requestRender();
|
|
239
241
|
setTimeout(() => { refreshPager(); }, 2000);
|
|
240
242
|
return "stay";
|
|
@@ -251,6 +253,14 @@ export async function runTui(
|
|
|
251
253
|
keybinds.destroy();
|
|
252
254
|
return "exit";
|
|
253
255
|
}
|
|
256
|
+
// :{N} — jump to line number
|
|
257
|
+
const lineNum = parseInt(cmd, 10);
|
|
258
|
+
if (!isNaN(lineNum) && lineNum > 0) {
|
|
259
|
+
state.cursorLine = Math.min(lineNum, state.lineCount);
|
|
260
|
+
ensureCursorVisible();
|
|
261
|
+
refreshPager();
|
|
262
|
+
return "stay";
|
|
263
|
+
}
|
|
254
264
|
return "stay"; // unknown command, ignore
|
|
255
265
|
}
|
|
256
266
|
|
|
@@ -359,11 +369,14 @@ export async function runTui(
|
|
|
359
369
|
currentLine: number,
|
|
360
370
|
direction: 1 | -1
|
|
361
371
|
): number | null {
|
|
362
|
-
|
|
372
|
+
// Smartcase: if query has any uppercase, case-sensitive
|
|
373
|
+
const caseSensitive = query !== query.toLowerCase();
|
|
374
|
+
const q = caseSensitive ? query : query.toLowerCase();
|
|
363
375
|
const total = lines.length;
|
|
364
376
|
for (let offset = 1; offset <= total; offset++) {
|
|
365
377
|
const i = ((currentLine - 1) + offset * direction + total) % total;
|
|
366
|
-
|
|
378
|
+
const line = caseSensitive ? lines[i] : lines[i].toLowerCase();
|
|
379
|
+
if (line.includes(q)) {
|
|
367
380
|
return i + 1; // 1-based
|
|
368
381
|
}
|
|
369
382
|
}
|
|
@@ -384,7 +397,7 @@ export async function runTui(
|
|
|
384
397
|
{ key: "n", action: "search-next" },
|
|
385
398
|
{ key: "N", action: "search-prev" },
|
|
386
399
|
{ key: "c", action: "comment" },
|
|
387
|
-
{ key: "
|
|
400
|
+
{ key: "T", action: "thread-list" },
|
|
388
401
|
{ key: "r", action: "resolve" },
|
|
389
402
|
{ key: "R", action: "resolve-all" },
|
|
390
403
|
{ key: "dd", action: "delete-draft" },
|
|
@@ -393,11 +406,12 @@ export async function runTui(
|
|
|
393
406
|
{ key: "[t", action: "prev-thread" },
|
|
394
407
|
{ key: "]r", action: "next-unread" },
|
|
395
408
|
{ key: "[r", action: "prev-unread" },
|
|
409
|
+
{ key: "zz", action: "center-cursor" },
|
|
396
410
|
{ key: "?", action: "help" },
|
|
397
411
|
{ key: "/", action: "search" },
|
|
398
412
|
{ key: ":", action: "command-mode" },
|
|
399
413
|
];
|
|
400
|
-
const keybinds = createKeybindRegistry(bindings);
|
|
414
|
+
const keybinds = createKeybindRegistry(bindings, 300);
|
|
401
415
|
|
|
402
416
|
refreshPager();
|
|
403
417
|
renderer.start();
|
|
@@ -458,9 +472,13 @@ export async function runTui(
|
|
|
458
472
|
return;
|
|
459
473
|
}
|
|
460
474
|
|
|
461
|
-
// Ctrl+C to exit —
|
|
475
|
+
// Ctrl+C to exit — quit without merging (same as :q!)
|
|
462
476
|
if (key.ctrl && key.name === "c") {
|
|
463
|
-
|
|
477
|
+
appendEvent(jsonlPath, { type: "session-end", author: "reviewer", ts: Date.now() });
|
|
478
|
+
liveWatcher.stop();
|
|
479
|
+
keybinds.destroy();
|
|
480
|
+
renderer.destroy();
|
|
481
|
+
resolve();
|
|
464
482
|
return;
|
|
465
483
|
}
|
|
466
484
|
|
|
@@ -480,7 +498,7 @@ export async function runTui(
|
|
|
480
498
|
if (!action) {
|
|
481
499
|
const p = keybinds.pending();
|
|
482
500
|
if (p) {
|
|
483
|
-
bottomBar
|
|
501
|
+
setBottomBarMessage(bottomBar, ` ${p}`);
|
|
484
502
|
renderer.requestRender();
|
|
485
503
|
}
|
|
486
504
|
return;
|
|
@@ -525,6 +543,14 @@ export async function runTui(
|
|
|
525
543
|
ensureCursorVisible();
|
|
526
544
|
refreshPager();
|
|
527
545
|
break;
|
|
546
|
+
case "center-cursor": {
|
|
547
|
+
const extra = countExtraVisualLines(state.specLines, state.cursorLine - 1);
|
|
548
|
+
const cursorRow = state.cursorLine - 1 + extra;
|
|
549
|
+
const halfView = Math.floor(pageSize() / 2);
|
|
550
|
+
pager.scrollBox.scrollTo(Math.max(0, cursorRow - halfView));
|
|
551
|
+
refreshPager();
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
528
554
|
case "search-next":
|
|
529
555
|
if (searchQuery) {
|
|
530
556
|
const match = findNextMatch(state.specLines, searchQuery, state.cursorLine, 1);
|
|
@@ -533,7 +559,7 @@ export async function runTui(
|
|
|
533
559
|
ensureCursorVisible();
|
|
534
560
|
}
|
|
535
561
|
} else {
|
|
536
|
-
bottomBar
|
|
562
|
+
setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
|
|
537
563
|
renderer.requestRender();
|
|
538
564
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
539
565
|
}
|
|
@@ -547,7 +573,7 @@ export async function runTui(
|
|
|
547
573
|
ensureCursorVisible();
|
|
548
574
|
}
|
|
549
575
|
} else {
|
|
550
|
-
bottomBar
|
|
576
|
+
setBottomBarMessage(bottomBar, " No active search \u2014 use / to search");
|
|
551
577
|
renderer.requestRender();
|
|
552
578
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
553
579
|
}
|
|
@@ -570,7 +596,7 @@ export async function runTui(
|
|
|
570
596
|
const msg = wasResolved
|
|
571
597
|
? ` \u21a9 Reopened thread #${thread.id}`
|
|
572
598
|
: ` \u2714 Resolved thread #${thread.id}`;
|
|
573
|
-
bottomBar
|
|
599
|
+
setBottomBarMessage(bottomBar, msg);
|
|
574
600
|
renderer.requestRender();
|
|
575
601
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
576
602
|
}
|
|
@@ -584,7 +610,7 @@ export async function runTui(
|
|
|
584
610
|
appendEvent(jsonlPath, { type: "resolve", threadId: t.id, author: "reviewer", ts: Date.now() });
|
|
585
611
|
}
|
|
586
612
|
refreshPager();
|
|
587
|
-
bottomBar
|
|
613
|
+
setBottomBarMessage(bottomBar, ` \u2714 Resolved ${pending} pending thread(s)`);
|
|
588
614
|
renderer.requestRender();
|
|
589
615
|
setTimeout(() => { refreshPager(); }, 1500);
|
|
590
616
|
break;
|
|
@@ -592,26 +618,31 @@ export async function runTui(
|
|
|
592
618
|
case "delete-draft": {
|
|
593
619
|
const thread = state.threadAtLine(state.cursorLine);
|
|
594
620
|
if (!thread) break;
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
621
|
+
const deleteOverlay = createConfirm({
|
|
622
|
+
renderer,
|
|
623
|
+
title: "Delete Thread",
|
|
624
|
+
message: `Delete thread #${thread.id} on line ${thread.line}?`,
|
|
625
|
+
onConfirm: () => {
|
|
626
|
+
dismissOverlay();
|
|
627
|
+
state.deleteThread(thread.id);
|
|
628
|
+
appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
|
|
629
|
+
refreshPager();
|
|
630
|
+
setBottomBarMessage(bottomBar, ` \u2714 Deleted thread #${thread.id}`);
|
|
631
|
+
renderer.requestRender();
|
|
632
|
+
setTimeout(() => { refreshPager(); }, 1500);
|
|
633
|
+
},
|
|
634
|
+
onCancel: () => {
|
|
635
|
+
dismissOverlay();
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
showOverlay(deleteOverlay);
|
|
608
639
|
break;
|
|
609
640
|
}
|
|
610
641
|
case "approve":
|
|
611
642
|
if (state.canApprove()) {
|
|
612
643
|
const confirmOverlay = createConfirm({
|
|
613
644
|
renderer,
|
|
614
|
-
message: "Approve spec and proceed to implementation?
|
|
645
|
+
message: "Approve spec and proceed to implementation?",
|
|
615
646
|
onConfirm: () => {
|
|
616
647
|
dismissOverlay();
|
|
617
648
|
appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
|
|
@@ -628,7 +659,7 @@ export async function runTui(
|
|
|
628
659
|
const msg = total === 0
|
|
629
660
|
? "No threads to approve"
|
|
630
661
|
: `${total} thread${total !== 1 ? "s" : ""} still open/pending`;
|
|
631
|
-
bottomBar
|
|
662
|
+
setBottomBarMessage(bottomBar, ` \u26a0 ${msg}`);
|
|
632
663
|
renderer.requestRender();
|
|
633
664
|
setTimeout(() => { refreshPager(); }, 2000);
|
|
634
665
|
}
|
|
@@ -638,8 +669,8 @@ export async function runTui(
|
|
|
638
669
|
if (next !== null) {
|
|
639
670
|
state.cursorLine = next;
|
|
640
671
|
ensureCursorVisible();
|
|
641
|
-
refreshPager();
|
|
642
672
|
}
|
|
673
|
+
refreshPager();
|
|
643
674
|
break;
|
|
644
675
|
}
|
|
645
676
|
case "prev-thread": {
|
|
@@ -647,8 +678,8 @@ export async function runTui(
|
|
|
647
678
|
if (prev !== null) {
|
|
648
679
|
state.cursorLine = prev;
|
|
649
680
|
ensureCursorVisible();
|
|
650
|
-
refreshPager();
|
|
651
681
|
}
|
|
682
|
+
refreshPager();
|
|
652
683
|
break;
|
|
653
684
|
}
|
|
654
685
|
case "next-unread": {
|
|
@@ -656,8 +687,8 @@ export async function runTui(
|
|
|
656
687
|
if (nextLine !== null) {
|
|
657
688
|
state.cursorLine = nextLine;
|
|
658
689
|
ensureCursorVisible();
|
|
659
|
-
refreshPager();
|
|
660
690
|
}
|
|
691
|
+
refreshPager();
|
|
661
692
|
break;
|
|
662
693
|
}
|
|
663
694
|
case "prev-unread": {
|
|
@@ -665,8 +696,8 @@ export async function runTui(
|
|
|
665
696
|
if (prevLine !== null) {
|
|
666
697
|
state.cursorLine = prevLine;
|
|
667
698
|
ensureCursorVisible();
|
|
668
|
-
refreshPager();
|
|
669
699
|
}
|
|
700
|
+
refreshPager();
|
|
670
701
|
break;
|
|
671
702
|
}
|
|
672
703
|
case "help":
|
package/src/tui/comment-input.ts
CHANGED
|
@@ -50,16 +50,17 @@ function createThreadView(
|
|
|
50
50
|
{ key: "NORMAL", action: "" },
|
|
51
51
|
{ key: "c", action: "reply" },
|
|
52
52
|
{ key: "r", action: "resolve" },
|
|
53
|
-
{ key: "
|
|
53
|
+
{ key: "q", action: "close" },
|
|
54
54
|
];
|
|
55
55
|
const insertHints = [
|
|
56
56
|
{ key: "INSERT", action: "" },
|
|
57
57
|
{ key: "Tab", action: "send" },
|
|
58
|
-
{ key: "Esc", action: "
|
|
58
|
+
{ key: "Esc", action: "normal" },
|
|
59
59
|
];
|
|
60
60
|
|
|
61
61
|
// --- State ---
|
|
62
62
|
let mode: "normal" | "insert" = "insert";
|
|
63
|
+
let pendingG: ReturnType<typeof setTimeout> | null = null;
|
|
63
64
|
|
|
64
65
|
// Build the textarea now (we need it in the key handler closure)
|
|
65
66
|
const textarea = new TextareaRenderable(renderer, {
|
|
@@ -151,10 +152,19 @@ function createThreadView(
|
|
|
151
152
|
case "g":
|
|
152
153
|
if (key.shift) {
|
|
153
154
|
// G = go to bottom
|
|
155
|
+
if (pendingG) { clearTimeout(pendingG); pendingG = null; }
|
|
154
156
|
scrollBox.scrollTo(scrollBox.scrollHeight);
|
|
155
157
|
renderer.requestRender();
|
|
158
|
+
} else if (pendingG) {
|
|
159
|
+
// gg = go to top
|
|
160
|
+
clearTimeout(pendingG);
|
|
161
|
+
pendingG = null;
|
|
162
|
+
scrollBox.scrollTo(0);
|
|
163
|
+
renderer.requestRender();
|
|
164
|
+
} else {
|
|
165
|
+
// First g — wait for second
|
|
166
|
+
pendingG = setTimeout(() => { pendingG = null; }, 300);
|
|
156
167
|
}
|
|
157
|
-
// TODO: gg = go to top (needs double-tap tracking)
|
|
158
168
|
return;
|
|
159
169
|
}
|
|
160
170
|
};
|
|
@@ -173,6 +183,7 @@ function createThreadView(
|
|
|
173
183
|
hints: insertHints,
|
|
174
184
|
});
|
|
175
185
|
|
|
186
|
+
|
|
176
187
|
// --- Scrollable conversation history ---
|
|
177
188
|
const scrollBox = dialog.content;
|
|
178
189
|
|
|
@@ -272,6 +283,7 @@ function createThreadView(
|
|
|
272
283
|
return {
|
|
273
284
|
container: dialog.container,
|
|
274
285
|
cleanup() {
|
|
286
|
+
if (pendingG) clearTimeout(pendingG);
|
|
275
287
|
renderer.keyInput.off("keypress", keyHandler);
|
|
276
288
|
dialog.cleanup();
|
|
277
289
|
textarea.destroy();
|