@crouton-kit/humanloop 0.3.38 → 0.4.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 (46) hide show
  1. package/dist/api.d.ts +3 -2
  2. package/dist/api.js +2 -2
  3. package/dist/browser/server.d.ts +15 -5
  4. package/dist/browser/server.js +110 -14
  5. package/dist/cli.js +0 -0
  6. package/dist/editor/roundtrip.d.ts +5 -0
  7. package/dist/editor/roundtrip.js +36 -0
  8. package/dist/inbox/completion.js +1 -27
  9. package/dist/inbox/controller.d.ts +32 -3
  10. package/dist/inbox/controller.js +427 -35
  11. package/dist/inbox/convention.d.ts +9 -2
  12. package/dist/inbox/convention.js +51 -3
  13. package/dist/inbox/deck-adapter.d.ts +10 -2
  14. package/dist/inbox/deck-adapter.js +23 -12
  15. package/dist/inbox/deck-schema.d.ts +2 -0
  16. package/dist/inbox/deck-schema.js +4 -4
  17. package/dist/inbox/followup.d.ts +57 -0
  18. package/dist/inbox/followup.js +117 -0
  19. package/dist/inbox/registry.d.ts +4 -0
  20. package/dist/inbox/registry.js +9 -4
  21. package/dist/inbox/tickets.d.ts +5 -0
  22. package/dist/inbox/tickets.js +25 -32
  23. package/dist/inbox/tui.d.ts +1 -1
  24. package/dist/inbox/tui.js +58 -25
  25. package/dist/inbox/visual.d.ts +130 -0
  26. package/dist/inbox/visual.js +747 -0
  27. package/dist/index.d.ts +5 -3
  28. package/dist/index.js +2 -1
  29. package/dist/tui/app.d.ts +3 -6
  30. package/dist/tui/app.js +130 -97
  31. package/dist/tui/input.d.ts +5 -1
  32. package/dist/tui/input.js +41 -15
  33. package/dist/tui/log.d.ts +2 -0
  34. package/dist/tui/log.js +53 -0
  35. package/dist/tui/render.js +44 -27
  36. package/dist/tui/terminal.d.ts +1 -0
  37. package/dist/tui/terminal.js +5 -0
  38. package/dist/tui/tmux.js +44 -22
  39. package/dist/types.d.ts +71 -7
  40. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  41. package/dist/web/index.html +1 -1
  42. package/package.json +6 -3
  43. package/dist/conversation/reader.d.ts +0 -6
  44. package/dist/conversation/reader.js +0 -58
  45. package/dist/visuals/generate.d.ts +0 -9
  46. package/dist/visuals/generate.js +0 -79
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, watch } from 'node:fs';
2
+ import { join } from 'node:path';
2
3
  import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
3
4
  import { diffFrame } from '../tui/render.js';
4
5
  import { renderMarkdown } from '../render/termrender.js';
@@ -9,13 +10,72 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
9
10
  import { buildInboxLines } from './tui.js';
10
11
  import { inboxLayout } from './layout.js';
11
12
  import { scanInbox } from './scan.js';
12
- import { inboxRootsDirectory, listInboxRoots } from './registry.js';
13
+ import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
13
14
  import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
14
- import { completeDeck, readTicketResult } from './tickets.js';
15
+ import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
15
16
  import { reconcileCompletions } from './completion.js';
16
- import { clearProgress, deckPath, progressPath, readJson, reviewPath } from './convention.js';
17
+ import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath, visualsDir } from './convention.js';
17
18
  import { DeckAdapter } from './deck-adapter.js';
19
+ import { validateDeck } from './deck-schema.js';
18
20
  import { ReviewAdapter } from './review-adapter.js';
21
+ import { editBufferInEditor } from '../editor/roundtrip.js';
22
+ import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
23
+ import { cancelVisualRequest, dispatchVisualCleanup, listVisualCleanupObligationsForRoot, readVisualResult, reconcileStaleVisualRequestsForRoot, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
24
+ /** One controller-owned retry loop for durable cancellation only. */
25
+ class VisualCleanupExecutor {
26
+ roots;
27
+ timer;
28
+ dispatching = new Set();
29
+ stopped = false;
30
+ constructor(roots) {
31
+ this.roots = roots;
32
+ }
33
+ reconcile() {
34
+ if (this.stopped)
35
+ return;
36
+ let tasks = [];
37
+ for (const root of this.roots()) {
38
+ try {
39
+ tasks.push(...listVisualCleanupObligationsForRoot(root));
40
+ }
41
+ catch { /* the next root reconciliation retries durable work */ }
42
+ }
43
+ tasks = tasks.filter((task) => !this.dispatching.has(this.key(task)));
44
+ const now = Date.now();
45
+ for (const task of tasks.filter((candidate) => Date.parse(candidate.nextAttemptAt) <= now))
46
+ this.dispatch(task);
47
+ this.arm(tasks.filter((task) => !this.dispatching.has(this.key(task))));
48
+ }
49
+ stop() {
50
+ this.stopped = true;
51
+ if (this.timer !== undefined)
52
+ clearTimeout(this.timer);
53
+ this.timer = undefined;
54
+ }
55
+ dispatch(task) {
56
+ const key = this.key(task);
57
+ if (this.dispatching.has(key))
58
+ return;
59
+ this.dispatching.add(key);
60
+ void dispatchVisualCleanup(task.root, task.dir, task.requestId).catch(() => undefined).finally(() => {
61
+ this.dispatching.delete(key);
62
+ this.reconcile();
63
+ });
64
+ }
65
+ arm(tasks) {
66
+ if (this.timer !== undefined)
67
+ clearTimeout(this.timer);
68
+ this.timer = undefined;
69
+ const earliest = tasks.reduce((next, task) => {
70
+ const due = Date.parse(task.nextAttemptAt);
71
+ return Number.isFinite(due) && (next === undefined || due < next) ? due : next;
72
+ }, undefined);
73
+ if (earliest === undefined)
74
+ return;
75
+ this.timer = setTimeout(() => { this.timer = undefined; this.reconcile(); }, Math.max(0, earliest - Date.now()));
76
+ }
77
+ key(task) { return `${task.root}\u0000${task.dir}\u0000${task.requestId}`; }
78
+ }
19
79
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
20
80
  export class InboxController {
21
81
  options;
@@ -24,13 +84,25 @@ export class InboxController {
24
84
  items = [];
25
85
  selectedDir;
26
86
  selectedIndex = 0;
87
+ /** Scroll state belongs to the passive preview, never to an editable deck. */
88
+ previewScrollOffset = 0;
27
89
  screen = 'list';
28
90
  adapter;
91
+ activeDeck;
29
92
  reviewAdapter;
30
93
  deckBrowser;
94
+ /** Blocks terminal input until browser finalization/shutdown is quiescent. */
95
+ deckBrowserTakingBack = false;
96
+ /** The single accepted submit that crossed the browser boundary. */
97
+ deckBrowserFinalizing;
31
98
  deckBrowserStarting = false;
99
+ deckBrowserStartingGeneration;
100
+ /** Invalidates an in-flight asynchronous browser start when ownership ends. */
101
+ deckBrowserGeneration = 0;
32
102
  claim;
33
103
  reconciling = false;
104
+ visualReconciling = false;
105
+ visualCleanupExecutor;
34
106
  suspended = false;
35
107
  submittingDir;
36
108
  stdinListener;
@@ -50,7 +122,9 @@ export class InboxController {
50
122
  const size = getTerminalSize();
51
123
  this.cols = options.cols ?? size.cols;
52
124
  this.rows = options.rows ?? size.rows;
125
+ this.visualCleanupExecutor = new VisualCleanupExecutor(() => this.resolvedRoots());
53
126
  this.rescan();
127
+ this.reconcileVisualWork();
54
128
  }
55
129
  snapshot() {
56
130
  return { items: [...this.items], selectedDir: this.selectedDir, screen: this.screen, inputBuffer: this.adapter?.inputBuffer() };
@@ -79,10 +153,12 @@ export class InboxController {
79
153
  if (this.items.length === 0) {
80
154
  this.selectedIndex = 0;
81
155
  this.selectedDir = undefined;
156
+ this.previewScrollOffset = 0;
82
157
  return;
83
158
  }
84
159
  this.selectedIndex = Math.min(priorIndex, this.items.length - 1);
85
160
  this.selectedDir = this.items[this.selectedIndex].dir;
161
+ this.previewScrollOffset = 0;
86
162
  }
87
163
  invalidate() { this.rescan(); this.repaint(); }
88
164
  resize(cols = getTerminalSize().cols, rows = getTerminalSize().rows) {
@@ -96,7 +172,7 @@ export class InboxController {
96
172
  const geometry = inboxLayout(this.cols, this.rows, this.screen);
97
173
  if (geometry.mode === 'minimum')
98
174
  return [`${YELLOW}Resize terminal to at least 60×18 to use inbox.${RESET}`];
99
- const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex);
175
+ const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex, geometry.height);
100
176
  const detail = this.detailLines(geometry.detailWidth, geometry.height);
101
177
  if (geometry.mode === 'list')
102
178
  return this.withStatus(list);
@@ -126,6 +202,11 @@ export class InboxController {
126
202
  this.close();
127
203
  return;
128
204
  }
205
+ // Take-back is an ownership boundary, not an instantaneous UI toggle:
206
+ // discard every key until a pre-existing browser finalizer and the listener
207
+ // have both settled, so terminal edits cannot race browser completion.
208
+ if (this.deckBrowserTakingBack)
209
+ return;
129
210
  if (this.deckBrowser !== undefined) {
130
211
  if (input === 'w' || input === 'W')
131
212
  void this.takeBackDeckBrowser();
@@ -144,7 +225,14 @@ export class InboxController {
144
225
  this.close();
145
226
  return;
146
227
  }
147
- if (input === 'j' || key.downArrow)
228
+ // Passive previews share the deck's documented scroll bindings without
229
+ // claiming or mounting an editable panel. Ctrl+E/Y are line-wise aliases;
230
+ // u/d and Ctrl+U/D/Page keys move a useful chunk.
231
+ if (input === 'd' || key.pageDown || (key.ctrl && (input === 'd' || input === 'e')))
232
+ this.scrollPreview(input === 'e' ? 1 : 10);
233
+ else if (input === 'u' || key.pageUp || (key.ctrl && (input === 'u' || input === 'y')))
234
+ this.scrollPreview(input === 'y' ? -1 : -10);
235
+ else if (input === 'j' || key.downArrow)
148
236
  this.select(this.selectedIndex + 1);
149
237
  else if (input === 'k' || key.upArrow)
150
238
  this.select(this.selectedIndex - 1);
@@ -168,17 +256,24 @@ export class InboxController {
168
256
  return;
169
257
  }
170
258
  this.claim = { dir: item.dir, token: claim.token };
171
- const deck = readJson(deckPath(item.dir));
172
- if (deck === null) {
259
+ const deck = this.readDeck(item.dir);
260
+ if (deck === undefined) {
173
261
  releaseClaim(item.dir, claim.token);
174
262
  this.claim = undefined;
175
263
  this.invalidate();
176
264
  return;
177
265
  }
178
- if (item.interactionKind === 'notify') {
179
- void this.complete([{ id: deck.interactions[0]?.id ?? 'notify', selectedOptionId: deck.interactions[0]?.options[0]?.id }]);
180
- return;
266
+ const root = ticketRoot(item.dir);
267
+ if (root !== null) {
268
+ // A newly acquired claim makes every older running generation stale
269
+ // before the panel can mint its own work. This only dispatches cleanup.
270
+ const retirement = reconcileVisualRequestsForTicket(root, item.dir, claim.token);
271
+ void retirement.delivery.finally(() => this.visualCleanupExecutor.reconcile());
181
272
  }
273
+ // Notifications use the same canonical deck panel as every other deck:
274
+ // opening is not acknowledgement; panel completion is.
275
+ this.activeDeck = deck;
276
+ const followUp = this.followUpHandlers(item.dir, deck);
182
277
  this.screen = 'detail';
183
278
  this.adapter = new DeckAdapter({
184
279
  dir: item.dir,
@@ -186,9 +281,16 @@ export class InboxController {
186
281
  cols: this.detailSize().cols,
187
282
  rows: this.detailSize().rows,
188
283
  onDirty: () => this.repaint(),
284
+ visualProvider: this.visualProviderFor(item.dir, deck, claim.token),
285
+ onEditorRequest: () => this.editActiveDeckInput(),
286
+ followUpAvailable: followUp.available,
287
+ onFollowUpRequest: followUp.onRequest,
288
+ onFollowUpCancel: followUp.onCancel,
189
289
  onBack: () => { this.leaveDetail(); this.repaint(); },
190
290
  onComplete: (responses) => { void this.complete(responses); },
191
291
  });
292
+ if (followUp.available)
293
+ this.adapter.setFollowUpState(this.followUpViewState(item.dir));
192
294
  this.watchSelected(item.dir);
193
295
  }
194
296
  /** Claim the review, hand the whole popup TTY to the native editor, then
@@ -234,7 +336,73 @@ export class InboxController {
234
336
  }
235
337
  }
236
338
  }
237
- reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
339
+ reloadSelectedDeck() {
340
+ if (this.adapter === undefined || this.claim === undefined) {
341
+ this.repaint();
342
+ return;
343
+ }
344
+ const deck = this.readDeck(this.claim.dir);
345
+ if (deck === undefined) {
346
+ this.repaint();
347
+ return;
348
+ }
349
+ this.activeDeck = deck;
350
+ const followUp = this.followUpHandlers(this.claim.dir, deck);
351
+ this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
352
+ // Reload replaces the capability before it mints the next panel generation.
353
+ this.adapter.reload(deck, this.visualProviderFor(this.claim.dir, deck, this.claim.token));
354
+ if (followUp.available)
355
+ this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
356
+ this.repaint();
357
+ }
358
+ followUpHandlers(dir, deck) {
359
+ const root = ticketRoot(dir);
360
+ const available = root !== null && registeredInboxRoot(root)?.followUpHandler !== undefined && isAnswerBearingDeck(deck);
361
+ if (!available)
362
+ return { available: false };
363
+ return {
364
+ available: true,
365
+ onRequest: (question) => {
366
+ requestFollowUp(root, dir, { question });
367
+ this.refreshFollowUp(dir);
368
+ },
369
+ onCancel: () => {
370
+ cancelFollowUp(root, dir);
371
+ this.refreshFollowUp(dir);
372
+ },
373
+ };
374
+ }
375
+ readDeck(dir) {
376
+ const deck = readJson(deckPath(dir));
377
+ if (deck === null)
378
+ return undefined;
379
+ try {
380
+ return validateDeck(deck);
381
+ }
382
+ catch {
383
+ return undefined;
384
+ }
385
+ }
386
+ followUpViewState(dir) {
387
+ const { request, result } = readFollowUp(dir);
388
+ if (request === null)
389
+ return { status: 'idle' };
390
+ if (result !== null && result.requestId === request.requestId) {
391
+ return result.status === 'ready'
392
+ ? { status: 'ready', markdown: result.markdown }
393
+ : { status: 'error', error: result.error };
394
+ }
395
+ return request.state === 'running' ? { status: 'running' } : { status: 'idle' };
396
+ }
397
+ refreshFollowUp(dir) {
398
+ if (this.adapter === undefined || this.claim?.dir !== dir || this.activeDeck === undefined)
399
+ return;
400
+ const followUp = this.followUpHandlers(dir, this.activeDeck);
401
+ this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
402
+ if (followUp.available)
403
+ this.adapter.setFollowUpState(this.followUpViewState(dir));
404
+ this.repaint();
405
+ }
238
406
  /** Hand the selected deck to its browser surface while retaining this ticket's claim. */
239
407
  async openDeckBrowser() {
240
408
  if (this.deckBrowser !== undefined || this.deckBrowserStarting || this.adapter === undefined || this.claim === undefined)
@@ -246,26 +414,71 @@ export class InboxController {
246
414
  if (deck === null)
247
415
  return;
248
416
  this.deckBrowserStarting = true;
417
+ const generation = ++this.deckBrowserGeneration;
418
+ this.deckBrowserStartingGeneration = generation;
419
+ const claim = this.claim;
420
+ const adapter = this.adapter;
249
421
  try {
250
422
  const start = this.options.startDeckBrowser ?? startWebServer;
251
423
  let browser;
252
424
  browser = await start({
253
425
  dir: item.dir,
254
426
  deck,
255
- onSubmit: () => { void this.finishDeckBrowser(item.dir, browser); },
427
+ finalize: async (responses) => this.finalizeDeckBrowser(item.dir, claim.token, generation, responses),
428
+ onSubmit: () => this.finishDeckBrowser(item.dir, browser),
256
429
  });
430
+ // Closing, taking back, or losing the claim while listen() was pending
431
+ // retires this start. Stop the fresh listener before it can open a tab.
432
+ if (this.closed || generation !== this.deckBrowserGeneration || this.claim?.token !== claim.token || this.adapter !== adapter) {
433
+ await browser.stop();
434
+ return;
435
+ }
257
436
  this.deckBrowser = browser;
258
437
  (this.options.openBrowser ?? openBrowser)(browser.url);
259
438
  }
260
439
  catch (error) {
261
- this.status = error instanceof Error ? error.message : String(error);
440
+ if (!this.closed && generation === this.deckBrowserGeneration)
441
+ this.status = error instanceof Error ? error.message : String(error);
262
442
  }
263
443
  finally {
264
- this.deckBrowserStarting = false;
444
+ if (this.deckBrowserStartingGeneration === generation) {
445
+ this.deckBrowserStarting = false;
446
+ this.deckBrowserStartingGeneration = undefined;
447
+ }
265
448
  this.repaint();
266
449
  }
267
450
  }
268
- /** The browser has atomically published the response; reconcile its owner delivery. */
451
+ /** Finalize through this controller's claim-safe lifecycle before HTTP acks. */
452
+ async finalizeDeckBrowser(dir, token, generation, responses) {
453
+ // The generation advances synchronously at take-back's ownership boundary.
454
+ // A request that reaches the listener after that point must fail before it
455
+ // can write, even while stop() is still closing HTTP connections.
456
+ if (this.deckBrowserTakingBack || generation !== this.deckBrowserGeneration || this.claim?.dir !== dir || this.claim.token !== token) {
457
+ throw new Error('browser handoff no longer owns this ticket');
458
+ }
459
+ // The server's HTTP single-assignment marker is published after its
460
+ // finalizer resolves, so simultaneous tabs can both reach us first. Share
461
+ // the first finalizer rather than starting another write or replacing the
462
+ // promise take-back must wait on.
463
+ if (this.deckBrowserFinalizing !== undefined)
464
+ return this.deckBrowserFinalizing;
465
+ const finalizing = (async () => {
466
+ await this.finishDeck(dir, responses, token);
467
+ const result = readTicketResult(dir);
468
+ if (result?.kind !== 'deck')
469
+ throw new Error('ticket did not produce a deck result');
470
+ return { completedAt: result.completedAt, responsePath: responsePath(dir) };
471
+ })();
472
+ this.deckBrowserFinalizing = finalizing;
473
+ try {
474
+ return await finalizing;
475
+ }
476
+ finally {
477
+ if (this.deckBrowserFinalizing === finalizing)
478
+ this.deckBrowserFinalizing = undefined;
479
+ }
480
+ }
481
+ /** The browser has finalized through the controller; reconcile its owner delivery. */
269
482
  async finishDeckBrowser(dir, browser) {
270
483
  if (this.deckBrowser === browser)
271
484
  this.deckBrowser = undefined;
@@ -279,14 +492,29 @@ export class InboxController {
279
492
  /** Return browser authority to the terminal deck without changing its draft. */
280
493
  async takeBackDeckBrowser() {
281
494
  const browser = this.deckBrowser;
282
- if (browser === undefined)
495
+ if (browser === undefined || this.deckBrowserTakingBack)
283
496
  return;
284
- this.deckBrowser = undefined;
497
+ // Invalidate newly-arriving browser submits before awaiting anything. Keep
498
+ // the handoff mounted and terminal input blocked until its running submit
499
+ // (if any) has conclusively won or failed and the listener is gone.
500
+ this.deckBrowserTakingBack = true;
501
+ this.deckBrowserGeneration++;
285
502
  try {
503
+ // A request that crossed the server boundary before this ownership change
504
+ // must own its normal finish path: it includes the HTTP 200 flush and
505
+ // onSubmit convergence before it stops the listener. Do not force-close
506
+ // that response after merely observing controller persistence.
507
+ const lifecycle = browser.pendingSubmitLifecycle?.();
508
+ if (lifecycle !== undefined && await lifecycle.catch(() => false))
509
+ return;
286
510
  await browser.requestTakeBack();
287
511
  await browser.stop();
288
512
  }
289
513
  finally {
514
+ if (this.deckBrowser === browser)
515
+ this.deckBrowser = undefined;
516
+ this.deckBrowserTakingBack = false;
517
+ this.rescan();
290
518
  this.repaint(true);
291
519
  }
292
520
  }
@@ -294,11 +522,15 @@ export class InboxController {
294
522
  if (this.closed)
295
523
  return;
296
524
  this.closed = true;
525
+ this.deckBrowserGeneration++;
526
+ this.deckBrowserTakingBack = false;
527
+ this.deckBrowserStarting = false;
297
528
  void this.reviewAdapter?.stop();
298
529
  const browser = this.deckBrowser;
299
530
  this.deckBrowser = undefined;
300
531
  void browser?.stop();
301
532
  this.leaveDetail();
533
+ this.visualCleanupExecutor.stop();
302
534
  for (const watcher of this.watchers)
303
535
  watcher.close();
304
536
  this.watchers = [];
@@ -365,7 +597,26 @@ export class InboxController {
365
597
  this.reconcileRoots();
366
598
  this.repaint();
367
599
  }
368
- /** Give the raw TTY to a child process (native review editor). */
600
+ /** The controller owns the terminal handoff and repaint around $EDITOR. */
601
+ editActiveDeckInput() {
602
+ const buffer = this.adapter?.inputBuffer();
603
+ if (buffer === undefined)
604
+ return;
605
+ this.suspendForChild();
606
+ let result = { text: buffer };
607
+ try {
608
+ result = editBufferInEditor(buffer);
609
+ }
610
+ finally {
611
+ this.resumeAfterChild();
612
+ this.adapter?.setInputBuffer(result.text);
613
+ this.resize();
614
+ if (result.error !== undefined)
615
+ this.status = result.error;
616
+ this.repaint(true);
617
+ }
618
+ }
619
+ /** Give the raw TTY to a child process (native review editor or $EDITOR). */
369
620
  suspendForChild() {
370
621
  this.suspended = true;
371
622
  if (this.stdinListener !== undefined)
@@ -405,10 +656,107 @@ export class InboxController {
405
656
  this.reconciling = false;
406
657
  }
407
658
  })();
659
+ this.reconcileVisualWork();
660
+ }
661
+ /** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
662
+ reconcileVisualWork() {
663
+ if (this.visualReconciling)
664
+ return;
665
+ this.visualReconciling = true;
666
+ const roots = this.resolvedRoots();
667
+ void (async () => {
668
+ try {
669
+ const deliveries = roots.flatMap((root) => {
670
+ try {
671
+ return reconcileStaleVisualRequestsForRoot(root).map((entry) => entry.delivery);
672
+ }
673
+ catch {
674
+ return [];
675
+ }
676
+ });
677
+ await Promise.all(deliveries);
678
+ }
679
+ finally {
680
+ this.visualReconciling = false;
681
+ this.visualCleanupExecutor.reconcile();
682
+ }
683
+ })();
684
+ }
685
+ /** Automatic ticket capability is deliberately marker + current-root-handler only. */
686
+ visualProviderFor(dir, deck, claimToken) {
687
+ // An inline provider is intentional host injection, independent of ticket capability.
688
+ if (this.options.visualProvider !== undefined)
689
+ return this.options.visualProvider;
690
+ const root = ticketRoot(dir);
691
+ if (root === null || deck.source?.visual !== VISUAL_CAPABILITY || registeredInboxRoot(root)?.visualHandler === undefined)
692
+ return undefined;
693
+ return (request) => this.startTicketVisual(root, dir, claimToken, request);
694
+ }
695
+ startTicketVisual(root, dir, claimToken, request) {
696
+ let watcher;
697
+ let settled = false;
698
+ let settle;
699
+ const result = new Promise((resolve) => { settle = resolve; });
700
+ const finish = (outcome) => {
701
+ if (settled)
702
+ return;
703
+ settled = true;
704
+ watcher?.close();
705
+ watcher = undefined;
706
+ settle(outcome);
707
+ };
708
+ let started;
709
+ try {
710
+ started = startVisualRequest({ root, dir, claimToken, request });
711
+ }
712
+ catch (error) {
713
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
714
+ return { result, cancel: () => { } };
715
+ }
716
+ const reread = () => {
717
+ if (settled)
718
+ return;
719
+ try {
720
+ const outcome = readVisualResult(root, dir, request.requestId);
721
+ if (outcome === null)
722
+ return;
723
+ finish(outcome.status === 'ready'
724
+ ? { status: 'ready', markdown: outcome.markdown }
725
+ : { status: 'error', error: outcome.error });
726
+ }
727
+ catch (error) {
728
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
729
+ }
730
+ };
731
+ try {
732
+ // Install the watch before the first durable reread so a publication in
733
+ // the narrow setup window is either observed or found by that reread.
734
+ watcher = watch(join(visualsDir(dir), request.requestId), () => reread());
735
+ watcher.once('error', (error) => finish({ status: 'error', error: error.message }));
736
+ reread();
737
+ }
738
+ catch (error) {
739
+ finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
740
+ }
741
+ void started.delivery.then(reread, (error) => finish({ status: 'error', error: error instanceof Error ? error.message : String(error) }));
742
+ return {
743
+ result,
744
+ cancel: () => {
745
+ watcher?.close();
746
+ watcher = undefined;
747
+ void cancelVisualRequest(root, dir, request.requestId).finally(() => this.visualCleanupExecutor.reconcile());
748
+ },
749
+ };
408
750
  }
409
751
  leaveDetail(release = true) {
752
+ this.deckBrowserGeneration++;
753
+ // A stale listener will stop itself after its await, but this controller
754
+ // may immediately claim another ticket and start a fresh browser handoff.
755
+ this.deckBrowserStarting = false;
410
756
  this.adapter?.close();
411
757
  this.adapter = undefined;
758
+ this.visualCleanupExecutor.reconcile();
759
+ this.activeDeck = undefined;
412
760
  this.selectedWatcher?.close();
413
761
  this.selectedWatcher = undefined;
414
762
  if (release && this.claim !== undefined)
@@ -419,9 +767,15 @@ export class InboxController {
419
767
  select(index) {
420
768
  if (this.items.length === 0)
421
769
  return;
422
- this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
770
+ const next = Math.max(0, Math.min(index, this.items.length - 1));
771
+ if (next !== this.selectedIndex)
772
+ this.previewScrollOffset = 0;
773
+ this.selectedIndex = next;
423
774
  this.selectedDir = this.items[this.selectedIndex].dir;
424
775
  }
776
+ scrollPreview(delta) {
777
+ this.previewScrollOffset = Math.max(0, this.previewScrollOffset + delta);
778
+ }
425
779
  detailSize() {
426
780
  const layout = inboxLayout(this.cols, this.rows, this.screen);
427
781
  return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
@@ -434,6 +788,15 @@ export class InboxController {
434
788
  if (this.adapter !== undefined)
435
789
  return this.adapter.render();
436
790
  const selected = this.items[this.selectedIndex];
791
+ if (selected === undefined)
792
+ return this.previewViewport(this.passiveDetailLines(width), width, rows);
793
+ const footer = selected.kind === 'deck'
794
+ ? [` ${DIM}Enter${RESET} opens the full ticket ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}Active ask:${RESET} c comment u/d scroll w browser ${DIM}q${RESET} close`]
795
+ : [` ${DIM}Enter${RESET} opens the full review ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}q${RESET} close`];
796
+ return [...this.previewViewport(this.passiveDetailLines(width), width, Math.max(0, rows - footer.length)), ...footer.map((line) => clipLine(line, width))];
797
+ }
798
+ passiveDetailLines(width) {
799
+ const selected = this.items[this.selectedIndex];
437
800
  if (selected === undefined)
438
801
  return [` ${DIM}Select a pending interaction.${RESET}`];
439
802
  const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
@@ -454,18 +817,18 @@ export class InboxController {
454
817
  else
455
818
  for (const rendered of renderMarkdown(md, Math.max(1, width - 2)))
456
819
  lines.push(` ${rendered}`);
457
- lines.push('', ` ${DIM}Enter${RESET} start review ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
458
- while (lines.length < rows)
459
- lines.push('');
460
- // Rendered markdown carries ANSI; slicing by column count would sever
461
- // escape sequences, so review preview lines are returned unsliced.
462
- return lines;
463
820
  }
464
- if (selected.kind === 'deck') {
821
+ else {
465
822
  const deck = readJson(deckPath(selected.dir));
466
823
  if (deck !== null) {
467
824
  for (const interaction of deck.interactions) {
468
825
  lines.push('', ` ${BOLD}${interaction.title}${RESET}`);
826
+ if (interaction.subtitle)
827
+ for (const rendered of renderMarkdown(interaction.subtitle, Math.max(1, width - 2)))
828
+ lines.push(` ${rendered}`);
829
+ if (interaction.body)
830
+ for (const rendered of renderMarkdown(interaction.body, Math.max(1, width - 2)))
831
+ lines.push(` ${rendered}`);
469
832
  for (const option of interaction.options)
470
833
  lines.push(` ${DIM}• ${option.label}${RESET}`);
471
834
  }
@@ -473,12 +836,33 @@ export class InboxController {
473
836
  lines.push('', ` ${DIM}${Array.isArray(saved) ? saved.length : 0} saved responses${RESET}`);
474
837
  }
475
838
  }
476
- if (selected.subtitle)
477
- lines.push('', ` ${selected.subtitle}`);
478
- lines.push('', ` ${DIM}Enter${RESET} open ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
479
- while (lines.length < rows)
480
- lines.push('');
481
- return lines.map((line) => line.slice(0, width));
839
+ return lines.map((line) => clipLine(line, width));
840
+ }
841
+ previewViewport(lines, width, rows) {
842
+ if (rows < 1)
843
+ return [];
844
+ const maxOffset = Math.max(0, lines.length - Math.max(1, rows - 1));
845
+ this.previewScrollOffset = Math.min(this.previewScrollOffset, maxOffset);
846
+ const start = this.previewScrollOffset;
847
+ const hasAbove = start > 0;
848
+ // Reserve an indicator row before selecting content so a remaining tail is
849
+ // always signalled instead of silently disappearing below the viewport.
850
+ let contentRows = rows - (hasAbove ? 1 : 0);
851
+ let end = Math.min(lines.length, start + contentRows);
852
+ const hasBelow = end < lines.length;
853
+ if (hasBelow) {
854
+ contentRows--;
855
+ end = Math.min(lines.length, start + Math.max(0, contentRows));
856
+ }
857
+ const out = [];
858
+ if (hasAbove)
859
+ out.push(` ${DIM}↑ more above${RESET}`);
860
+ out.push(...lines.slice(start, end));
861
+ if (hasBelow)
862
+ out.push(` ${DIM}↓ more below${RESET}`);
863
+ while (out.length < rows)
864
+ out.push('');
865
+ return out.map((line) => clipLine(line, width));
482
866
  }
483
867
  withStatus(lines) {
484
868
  if (this.status === undefined || this.rows < 1)
@@ -521,10 +905,15 @@ export class InboxController {
521
905
  this.selectedWatcher?.close();
522
906
  try {
523
907
  this.selectedWatcher = watch(dir, (_event, file) => {
524
- if (file === 'deck.json')
908
+ if (file === 'deck.json') {
525
909
  this.reloadSelectedDeck();
526
- else
527
- this.invalidate();
910
+ return;
911
+ }
912
+ if (file === 'followup-result.json' || file === 'followup-request.json') {
913
+ this.refreshFollowUp(dir);
914
+ return;
915
+ }
916
+ this.invalidate();
528
917
  });
529
918
  }
530
919
  catch {
@@ -535,6 +924,9 @@ export class InboxController {
535
924
  function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
536
925
  /** M-i (Option/Alt+I) reaches the controller as the two-byte ESC-i sequence. */
537
926
  function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1bI'; }
927
+ function isAnswerBearingDeck(deck) {
928
+ return deck.interactions.some((interaction) => interaction.kind !== 'notify');
929
+ }
538
930
  /** Prefer a human-meaningful source label, falling back to the raw node id
539
931
  * before an opaque "unknown source" — a crouter ticket always carries nodeId. */
540
932
  function sourceLabel(source) { return source.sessionName ?? source.askedBy ?? source.nodeId ?? 'unknown source'; }