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.
Files changed (33) hide show
  1. package/CLAUDE.md +3 -1
  2. package/README.md +48 -10
  3. package/bun.lock +3 -0
  4. package/package.json +6 -3
  5. package/src/state/review-state.ts +5 -0
  6. package/src/tui/app.ts +65 -34
  7. package/src/tui/comment-input.ts +15 -3
  8. package/src/tui/help.ts +123 -38
  9. package/src/tui/pager.ts +36 -14
  10. package/src/tui/search.ts +9 -4
  11. package/src/tui/status-bar.ts +17 -7
  12. package/src/tui/thread-list.ts +1 -1
  13. package/src/tui/ui/keybinds.ts +4 -2
  14. package/src/tui/ui/markdown.ts +50 -9
  15. package/test/e2e/__snapshots__/snapshot.test.ts.snap +31 -0
  16. package/test/e2e/fixtures/spec.md +36 -0
  17. package/test/e2e/harness.ts +80 -0
  18. package/test/e2e/snapshot.test.ts +182 -0
  19. package/test/{cli-reply.test.ts → integration/cli-reply.test.ts} +2 -2
  20. package/test/{cli-watch.test.ts → integration/cli-watch.test.ts} +2 -2
  21. package/test/{cli.test.ts → integration/cli.test.ts} +3 -3
  22. package/test/{e2e-live.test.ts → integration/e2e-live.test.ts} +4 -4
  23. package/test/{live-interaction.test.ts → integration/live-interaction.test.ts} +4 -4
  24. package/test/{protocol → unit/protocol}/live-events.test.ts +1 -1
  25. package/test/{protocol → unit/protocol}/live-merge.test.ts +3 -3
  26. package/test/{protocol → unit/protocol}/merge.test.ts +2 -2
  27. package/test/{protocol → unit/protocol}/read.test.ts +2 -2
  28. package/test/{protocol → unit/protocol}/types.test.ts +1 -1
  29. package/test/{protocol → unit/protocol}/write.test.ts +2 -2
  30. package/test/{state → unit/state}/review-state.test.ts +2 -2
  31. package/test/{tui → unit/tui}/pager.test.ts +3 -3
  32. package/test/{tui → unit/tui}/ui/keybinds.test.ts +1 -1
  33. /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
- | `m` | Toggle markdown / line mode |
40
- | `c` | Open thread / comment on line |
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
- | `l` | List threads |
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 (only if merged) |
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 history, `c` to reply, `r` to resolve, `Esc` to close
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.4.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
- buildBottomBar(bottomBar, commandBuffer);
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.text.content = " \u2714 Merged to review JSON";
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.text.content = " Unmerged changes. Use :w to save or :q! to discard";
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
- const q = query.toLowerCase();
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
- if (lines[i].toLowerCase().includes(q)) {
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: "l", action: "thread-list" },
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 — merge and quit
475
+ // Ctrl+C to exit — quit without merging (same as :q!)
462
476
  if (key.ctrl && key.name === "c") {
463
- mergeAndExit(resolve);
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.text.content = ` ${p}`;
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.text.content = " No active search \u2014 use / to search";
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.text.content = " No active search \u2014 use / to search";
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.text.content = msg;
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.text.content = ` \u2714 Resolved ${pending} pending thread(s)`;
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 hadReviewerMsg = thread.messages.some((m) => m.author === "reviewer");
596
- if (hadReviewerMsg) {
597
- state.deleteLastDraftMessage(thread.id);
598
- appendEvent(jsonlPath, { type: "delete", threadId: thread.id, author: "reviewer", ts: Date.now() });
599
- refreshPager();
600
- bottomBar.text.content = " \u2714 Deleted draft comment";
601
- renderer.requestRender();
602
- setTimeout(() => { refreshPager(); }, 1500);
603
- } else {
604
- bottomBar.text.content = " No reviewer message to delete";
605
- renderer.requestRender();
606
- setTimeout(() => { refreshPager(); }, 1500);
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? [y/n]",
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.text.content = ` \u26a0 ${msg}`;
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":
@@ -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: "Esc/q", action: "close" },
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: "back" },
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();