@crouton-kit/humanloop 0.3.37 → 0.3.39

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 (44) hide show
  1. package/dist/browser/server.d.ts +15 -5
  2. package/dist/browser/server.js +110 -14
  3. package/dist/cli.js +0 -0
  4. package/dist/conversation/reader.d.ts +15 -1
  5. package/dist/conversation/reader.js +320 -30
  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 +36 -1
  10. package/dist/inbox/controller.js +335 -28
  11. package/dist/inbox/convention.d.ts +7 -0
  12. package/dist/inbox/convention.js +32 -0
  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 +2 -0
  20. package/dist/inbox/registry.js +6 -2
  21. package/dist/inbox/tickets.js +8 -2
  22. package/dist/inbox/tui.d.ts +1 -1
  23. package/dist/inbox/tui.js +58 -25
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.js +1 -0
  26. package/dist/render/termrender.js +25 -26
  27. package/dist/tui/app.d.ts +1 -1
  28. package/dist/tui/app.js +80 -88
  29. package/dist/tui/input.d.ts +5 -1
  30. package/dist/tui/input.js +41 -15
  31. package/dist/tui/log.d.ts +2 -0
  32. package/dist/tui/log.js +53 -0
  33. package/dist/tui/render.js +42 -23
  34. package/dist/tui/terminal.d.ts +1 -0
  35. package/dist/tui/terminal.js +5 -0
  36. package/dist/tui/tmux.js +44 -22
  37. package/dist/types.d.ts +39 -2
  38. package/dist/visuals/conversation.d.ts +7 -0
  39. package/dist/visuals/conversation.js +16 -0
  40. package/dist/visuals/generate.d.ts +3 -1
  41. package/dist/visuals/generate.js +8 -6
  42. package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
  43. package/dist/web/index.html +1 -1
  44. package/package.json +7 -2
@@ -1,7 +1,6 @@
1
1
  import { readdirSync, statSync, unlinkSync } from 'node:fs';
2
- import { spawn } from 'node:child_process';
3
2
  import { basename, resolve } from 'node:path';
4
- import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, withExclusiveDirectoryLockAsync } from './convention.js';
3
+ import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, runHandler, withExclusiveDirectoryLockAsync } from './convention.js';
5
4
  import { registeredInboxRoot } from './registry.js';
6
5
  import { readTicketResult, ticketRoot } from './tickets.js';
7
6
  import { validateReviewProjection } from './deck-schema.js';
@@ -12,31 +11,6 @@ function receiptMatches(dir) {
12
11
  function eventFor(root, dir, result) {
13
12
  return { schema: 'humanloop.completion/v1', root, dir, ticketId: basename(dir), kind: result.kind, outcome: result.kind === 'canceled' ? 'canceled' : 'resolved', responsePath: responsePath(dir) };
14
13
  }
15
- async function runHandler(command, args, event) {
16
- await new Promise((resolvePromise, rejectPromise) => {
17
- // stdout stays ignored (a handler acknowledges by exit code, never stdout),
18
- // but stderr is captured so a nonzero exit surfaces the handler's own
19
- // diagnostics in the delivery-error record instead of a bare exit code.
20
- const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
21
- let stderr = '';
22
- child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
23
- stderr = stderr.slice(-8192); });
24
- let timedOut = false;
25
- const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
26
- child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
27
- child.once('exit', (code, signal) => {
28
- clearTimeout(timeout);
29
- const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
30
- if (timedOut)
31
- rejectPromise(new Error(`completion handler timed out after 30 seconds${detail}`));
32
- else if (code === 0)
33
- resolvePromise();
34
- else
35
- rejectPromise(new Error(`completion handler failed (${signal ?? code ?? 'unknown'})${detail}`));
36
- });
37
- child.stdin.end(`${JSON.stringify(event)}\n`);
38
- });
39
- }
40
14
  function projectReview(dir, result) {
41
15
  if (result.kind !== 'review')
42
16
  return;
@@ -1,11 +1,17 @@
1
1
  import type { InteractionResponse, TicketSummary } from '../types.js';
2
2
  import type { Key } from '../tui/terminal.js';
3
+ import { startWebServer } from '../browser/server.js';
4
+ import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
3
5
  export interface InboxControllerOptions {
4
6
  roots?: string[];
5
7
  cols?: number;
6
8
  rows?: number;
7
9
  scan?: (roots?: string[]) => TicketSummary[];
8
10
  completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
11
+ startDeckBrowser?: typeof startWebServer;
12
+ openBrowser?: (url: string) => void;
13
+ /** Test seam; production uses the standard conversation-backed visual generator. */
14
+ visualGeneratorForSession?: typeof visualGeneratorForConversationSession;
9
15
  }
10
16
  type Screen = 'list' | 'detail';
11
17
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
@@ -16,9 +22,21 @@ export declare class InboxController {
16
22
  private items;
17
23
  private selectedDir;
18
24
  private selectedIndex;
25
+ /** Scroll state belongs to the passive preview, never to an editable deck. */
26
+ private previewScrollOffset;
19
27
  private screen;
20
28
  private adapter;
29
+ private activeDeck;
21
30
  private reviewAdapter;
31
+ private deckBrowser;
32
+ /** Blocks terminal input until browser finalization/shutdown is quiescent. */
33
+ private deckBrowserTakingBack;
34
+ /** The single accepted submit that crossed the browser boundary. */
35
+ private deckBrowserFinalizing;
36
+ private deckBrowserStarting;
37
+ private deckBrowserStartingGeneration;
38
+ /** Invalidates an in-flight asynchronous browser start when ownership ends. */
39
+ private deckBrowserGeneration;
22
40
  private claim;
23
41
  private reconciling;
24
42
  private suspended;
@@ -51,10 +69,24 @@ export declare class InboxController {
51
69
  * in ReviewAdapter; the controller only owns the terminal handoff. */
52
70
  private activateReview;
53
71
  reloadSelectedDeck(): void;
72
+ private followUpHandlers;
73
+ private readDeck;
74
+ private followUpViewState;
75
+ private refreshFollowUp;
76
+ /** Hand the selected deck to its browser surface while retaining this ticket's claim. */
77
+ private openDeckBrowser;
78
+ /** Finalize through this controller's claim-safe lifecycle before HTTP acks. */
79
+ private finalizeDeckBrowser;
80
+ /** The browser has finalized through the controller; reconcile its owner delivery. */
81
+ private finishDeckBrowser;
82
+ /** Return browser authority to the terminal deck without changing its draft. */
83
+ private takeBackDeckBrowser;
54
84
  close(): void;
55
85
  run(): Promise<void>;
56
86
  private complete;
57
- /** Give the raw TTY to a child process (native review editor). */
87
+ /** The controller owns the terminal handoff and repaint around $EDITOR. */
88
+ private editActiveDeckInput;
89
+ /** Give the raw TTY to a child process (native review editor or $EDITOR). */
58
90
  private suspendForChild;
59
91
  /** Retake the TTY after the child exits and force a full repaint. */
60
92
  private resumeAfterChild;
@@ -64,8 +96,11 @@ export declare class InboxController {
64
96
  private reconcileRoots;
65
97
  private leaveDetail;
66
98
  private select;
99
+ private scrollPreview;
67
100
  private detailSize;
68
101
  private detailLines;
102
+ private passiveDetailLines;
103
+ private previewViewport;
69
104
  private withStatus;
70
105
  private repaint;
71
106
  private watchRoots;
@@ -2,17 +2,24 @@ import { readFileSync, watch } from 'node:fs';
2
2
  import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
3
3
  import { diffFrame } from '../tui/render.js';
4
4
  import { renderMarkdown } from '../render/termrender.js';
5
+ import { startWebServer } from '../browser/server.js';
6
+ import { openBrowser } from '../browser/open.js';
7
+ import { renderHandoff } from '../tui/render.js';
5
8
  import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
6
9
  import { buildInboxLines } from './tui.js';
7
10
  import { inboxLayout } from './layout.js';
8
11
  import { scanInbox } from './scan.js';
9
- import { inboxRootsDirectory, listInboxRoots } from './registry.js';
12
+ import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './registry.js';
10
13
  import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
11
- import { completeDeck, readTicketResult } from './tickets.js';
14
+ import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
12
15
  import { reconcileCompletions } from './completion.js';
13
- import { clearProgress, deckPath, progressPath, readJson, reviewPath } from './convention.js';
16
+ import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath } from './convention.js';
14
17
  import { DeckAdapter } from './deck-adapter.js';
18
+ import { validateDeck } from './deck-schema.js';
15
19
  import { ReviewAdapter } from './review-adapter.js';
20
+ import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
21
+ import { editBufferInEditor } from '../editor/roundtrip.js';
22
+ import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
16
23
  /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
17
24
  export class InboxController {
18
25
  options;
@@ -21,9 +28,21 @@ export class InboxController {
21
28
  items = [];
22
29
  selectedDir;
23
30
  selectedIndex = 0;
31
+ /** Scroll state belongs to the passive preview, never to an editable deck. */
32
+ previewScrollOffset = 0;
24
33
  screen = 'list';
25
34
  adapter;
35
+ activeDeck;
26
36
  reviewAdapter;
37
+ deckBrowser;
38
+ /** Blocks terminal input until browser finalization/shutdown is quiescent. */
39
+ deckBrowserTakingBack = false;
40
+ /** The single accepted submit that crossed the browser boundary. */
41
+ deckBrowserFinalizing;
42
+ deckBrowserStarting = false;
43
+ deckBrowserStartingGeneration;
44
+ /** Invalidates an in-flight asynchronous browser start when ownership ends. */
45
+ deckBrowserGeneration = 0;
27
46
  claim;
28
47
  reconciling = false;
29
48
  suspended = false;
@@ -74,10 +93,12 @@ export class InboxController {
74
93
  if (this.items.length === 0) {
75
94
  this.selectedIndex = 0;
76
95
  this.selectedDir = undefined;
96
+ this.previewScrollOffset = 0;
77
97
  return;
78
98
  }
79
99
  this.selectedIndex = Math.min(priorIndex, this.items.length - 1);
80
100
  this.selectedDir = this.items[this.selectedIndex].dir;
101
+ this.previewScrollOffset = 0;
81
102
  }
82
103
  invalidate() { this.rescan(); this.repaint(); }
83
104
  resize(cols = getTerminalSize().cols, rows = getTerminalSize().rows) {
@@ -91,7 +112,7 @@ export class InboxController {
91
112
  const geometry = inboxLayout(this.cols, this.rows, this.screen);
92
113
  if (geometry.mode === 'minimum')
93
114
  return [`${YELLOW}Resize terminal to at least 60×18 to use inbox.${RESET}`];
94
- const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex);
115
+ const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex, geometry.height);
95
116
  const detail = this.detailLines(geometry.detailWidth, geometry.height);
96
117
  if (geometry.mode === 'list')
97
118
  return this.withStatus(list);
@@ -121,7 +142,21 @@ export class InboxController {
121
142
  this.close();
122
143
  return;
123
144
  }
145
+ // Take-back is an ownership boundary, not an instantaneous UI toggle:
146
+ // discard every key until a pre-existing browser finalizer and the listener
147
+ // have both settled, so terminal edits cannot race browser completion.
148
+ if (this.deckBrowserTakingBack)
149
+ return;
150
+ if (this.deckBrowser !== undefined) {
151
+ if (input === 'w' || input === 'W')
152
+ void this.takeBackDeckBrowser();
153
+ return;
154
+ }
124
155
  if (this.screen === 'detail' && this.adapter !== undefined) {
156
+ if ((input === 'w' || input === 'W') && this.adapter.canAcceptHostKeys()) {
157
+ void this.openDeckBrowser();
158
+ return;
159
+ }
125
160
  this.adapter.handleKey(input, key);
126
161
  this.repaint();
127
162
  return;
@@ -130,7 +165,14 @@ export class InboxController {
130
165
  this.close();
131
166
  return;
132
167
  }
133
- if (input === 'j' || key.downArrow)
168
+ // Passive previews share the deck's documented scroll bindings without
169
+ // claiming or mounting an editable panel. Ctrl+E/Y are line-wise aliases;
170
+ // u/d and Ctrl+U/D/Page keys move a useful chunk.
171
+ if (input === 'd' || key.pageDown || (key.ctrl && (input === 'd' || input === 'e')))
172
+ this.scrollPreview(input === 'e' ? 1 : 10);
173
+ else if (input === 'u' || key.pageUp || (key.ctrl && (input === 'u' || input === 'y')))
174
+ this.scrollPreview(input === 'y' ? -1 : -10);
175
+ else if (input === 'j' || key.downArrow)
134
176
  this.select(this.selectedIndex + 1);
135
177
  else if (input === 'k' || key.upArrow)
136
178
  this.select(this.selectedIndex - 1);
@@ -161,10 +203,10 @@ export class InboxController {
161
203
  this.invalidate();
162
204
  return;
163
205
  }
164
- if (item.interactionKind === 'notify') {
165
- void this.complete([{ id: deck.interactions[0]?.id ?? 'notify', selectedOptionId: deck.interactions[0]?.options[0]?.id }]);
166
- return;
167
- }
206
+ // Notifications use the same canonical deck panel as every other deck:
207
+ // opening is not acknowledgement; panel completion is.
208
+ this.activeDeck = deck;
209
+ const followUp = this.followUpHandlers(item.dir, deck);
168
210
  this.screen = 'detail';
169
211
  this.adapter = new DeckAdapter({
170
212
  dir: item.dir,
@@ -172,9 +214,16 @@ export class InboxController {
172
214
  cols: this.detailSize().cols,
173
215
  rows: this.detailSize().rows,
174
216
  onDirty: () => this.repaint(),
217
+ generateVisual: deck.source?.originatingConversationSessionId === undefined ? undefined : (this.options.visualGeneratorForSession ?? visualGeneratorForConversationSession)(deck.source.originatingConversationSessionId),
218
+ onEditorRequest: () => this.editActiveDeckInput(),
219
+ followUpAvailable: followUp.available,
220
+ onFollowUpRequest: followUp.onRequest,
221
+ onFollowUpCancel: followUp.onCancel,
175
222
  onBack: () => { this.leaveDetail(); this.repaint(); },
176
223
  onComplete: (responses) => { void this.complete(responses); },
177
224
  });
225
+ if (followUp.available)
226
+ this.adapter.setFollowUpState(this.followUpViewState(item.dir));
178
227
  this.watchSelected(item.dir);
179
228
  }
180
229
  /** Claim the review, hand the whole popup TTY to the native editor, then
@@ -220,12 +269,198 @@ export class InboxController {
220
269
  }
221
270
  }
222
271
  }
223
- reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
272
+ reloadSelectedDeck() {
273
+ if (this.adapter === undefined || this.claim === undefined) {
274
+ this.repaint();
275
+ return;
276
+ }
277
+ const deck = this.readDeck(this.claim.dir);
278
+ if (deck === undefined) {
279
+ this.repaint();
280
+ return;
281
+ }
282
+ this.activeDeck = deck;
283
+ const followUp = this.followUpHandlers(this.claim.dir, deck);
284
+ this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
285
+ this.adapter.reload(deck);
286
+ if (followUp.available)
287
+ this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
288
+ this.repaint();
289
+ }
290
+ followUpHandlers(dir, deck) {
291
+ const root = ticketRoot(dir);
292
+ const available = root !== null && registeredInboxRoot(root)?.followUpHandler !== undefined && isAnswerBearingDeck(deck);
293
+ if (!available)
294
+ return { available: false };
295
+ return {
296
+ available: true,
297
+ onRequest: (question) => {
298
+ requestFollowUp(root, dir, { question });
299
+ this.refreshFollowUp(dir);
300
+ },
301
+ onCancel: () => {
302
+ cancelFollowUp(root, dir);
303
+ this.refreshFollowUp(dir);
304
+ },
305
+ };
306
+ }
307
+ readDeck(dir) {
308
+ const deck = readJson(deckPath(dir));
309
+ if (deck === null)
310
+ return undefined;
311
+ try {
312
+ return validateDeck(deck);
313
+ }
314
+ catch {
315
+ return undefined;
316
+ }
317
+ }
318
+ followUpViewState(dir) {
319
+ const { request, result } = readFollowUp(dir);
320
+ if (request === null)
321
+ return { status: 'idle' };
322
+ if (result !== null && result.requestId === request.requestId) {
323
+ return result.status === 'ready'
324
+ ? { status: 'ready', markdown: result.markdown }
325
+ : { status: 'error', error: result.error };
326
+ }
327
+ return request.state === 'running' ? { status: 'running' } : { status: 'idle' };
328
+ }
329
+ refreshFollowUp(dir) {
330
+ if (this.adapter === undefined || this.claim?.dir !== dir || this.activeDeck === undefined)
331
+ return;
332
+ const followUp = this.followUpHandlers(dir, this.activeDeck);
333
+ this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
334
+ if (followUp.available)
335
+ this.adapter.setFollowUpState(this.followUpViewState(dir));
336
+ this.repaint();
337
+ }
338
+ /** Hand the selected deck to its browser surface while retaining this ticket's claim. */
339
+ async openDeckBrowser() {
340
+ if (this.deckBrowser !== undefined || this.deckBrowserStarting || this.adapter === undefined || this.claim === undefined)
341
+ return;
342
+ const item = this.items[this.selectedIndex];
343
+ if (item?.kind !== 'deck' || item.dir !== this.claim.dir)
344
+ return;
345
+ const deck = readJson(deckPath(item.dir));
346
+ if (deck === null)
347
+ return;
348
+ this.deckBrowserStarting = true;
349
+ const generation = ++this.deckBrowserGeneration;
350
+ this.deckBrowserStartingGeneration = generation;
351
+ const claim = this.claim;
352
+ const adapter = this.adapter;
353
+ try {
354
+ const start = this.options.startDeckBrowser ?? startWebServer;
355
+ let browser;
356
+ browser = await start({
357
+ dir: item.dir,
358
+ deck,
359
+ finalize: async (responses) => this.finalizeDeckBrowser(item.dir, claim.token, generation, responses),
360
+ onSubmit: () => this.finishDeckBrowser(item.dir, browser),
361
+ });
362
+ // Closing, taking back, or losing the claim while listen() was pending
363
+ // retires this start. Stop the fresh listener before it can open a tab.
364
+ if (this.closed || generation !== this.deckBrowserGeneration || this.claim?.token !== claim.token || this.adapter !== adapter) {
365
+ await browser.stop();
366
+ return;
367
+ }
368
+ this.deckBrowser = browser;
369
+ (this.options.openBrowser ?? openBrowser)(browser.url);
370
+ }
371
+ catch (error) {
372
+ if (!this.closed && generation === this.deckBrowserGeneration)
373
+ this.status = error instanceof Error ? error.message : String(error);
374
+ }
375
+ finally {
376
+ if (this.deckBrowserStartingGeneration === generation) {
377
+ this.deckBrowserStarting = false;
378
+ this.deckBrowserStartingGeneration = undefined;
379
+ }
380
+ this.repaint();
381
+ }
382
+ }
383
+ /** Finalize through this controller's claim-safe lifecycle before HTTP acks. */
384
+ async finalizeDeckBrowser(dir, token, generation, responses) {
385
+ // The generation advances synchronously at take-back's ownership boundary.
386
+ // A request that reaches the listener after that point must fail before it
387
+ // can write, even while stop() is still closing HTTP connections.
388
+ if (this.deckBrowserTakingBack || generation !== this.deckBrowserGeneration || this.claim?.dir !== dir || this.claim.token !== token) {
389
+ throw new Error('browser handoff no longer owns this ticket');
390
+ }
391
+ // The server's HTTP single-assignment marker is published after its
392
+ // finalizer resolves, so simultaneous tabs can both reach us first. Share
393
+ // the first finalizer rather than starting another write or replacing the
394
+ // promise take-back must wait on.
395
+ if (this.deckBrowserFinalizing !== undefined)
396
+ return this.deckBrowserFinalizing;
397
+ const finalizing = (async () => {
398
+ await this.finishDeck(dir, responses, token);
399
+ const result = readTicketResult(dir);
400
+ if (result?.kind !== 'deck')
401
+ throw new Error('ticket did not produce a deck result');
402
+ return { completedAt: result.completedAt, responsePath: responsePath(dir) };
403
+ })();
404
+ this.deckBrowserFinalizing = finalizing;
405
+ try {
406
+ return await finalizing;
407
+ }
408
+ finally {
409
+ if (this.deckBrowserFinalizing === finalizing)
410
+ this.deckBrowserFinalizing = undefined;
411
+ }
412
+ }
413
+ /** The browser has finalized through the controller; reconcile its owner delivery. */
414
+ async finishDeckBrowser(dir, browser) {
415
+ if (this.deckBrowser === browser)
416
+ this.deckBrowser = undefined;
417
+ await browser.stop();
418
+ if (this.claim?.dir === dir)
419
+ this.leaveDetail();
420
+ this.rescan();
421
+ this.reconcileRoots();
422
+ this.repaint();
423
+ }
424
+ /** Return browser authority to the terminal deck without changing its draft. */
425
+ async takeBackDeckBrowser() {
426
+ const browser = this.deckBrowser;
427
+ if (browser === undefined || this.deckBrowserTakingBack)
428
+ return;
429
+ // Invalidate newly-arriving browser submits before awaiting anything. Keep
430
+ // the handoff mounted and terminal input blocked until its running submit
431
+ // (if any) has conclusively won or failed and the listener is gone.
432
+ this.deckBrowserTakingBack = true;
433
+ this.deckBrowserGeneration++;
434
+ try {
435
+ // A request that crossed the server boundary before this ownership change
436
+ // must own its normal finish path: it includes the HTTP 200 flush and
437
+ // onSubmit convergence before it stops the listener. Do not force-close
438
+ // that response after merely observing controller persistence.
439
+ const lifecycle = browser.pendingSubmitLifecycle?.();
440
+ if (lifecycle !== undefined && await lifecycle.catch(() => false))
441
+ return;
442
+ await browser.requestTakeBack();
443
+ await browser.stop();
444
+ }
445
+ finally {
446
+ if (this.deckBrowser === browser)
447
+ this.deckBrowser = undefined;
448
+ this.deckBrowserTakingBack = false;
449
+ this.rescan();
450
+ this.repaint(true);
451
+ }
452
+ }
224
453
  close() {
225
454
  if (this.closed)
226
455
  return;
227
456
  this.closed = true;
457
+ this.deckBrowserGeneration++;
458
+ this.deckBrowserTakingBack = false;
459
+ this.deckBrowserStarting = false;
228
460
  void this.reviewAdapter?.stop();
461
+ const browser = this.deckBrowser;
462
+ this.deckBrowser = undefined;
463
+ void browser?.stop();
229
464
  this.leaveDetail();
230
465
  for (const watcher of this.watchers)
231
466
  watcher.close();
@@ -293,7 +528,26 @@ export class InboxController {
293
528
  this.reconcileRoots();
294
529
  this.repaint();
295
530
  }
296
- /** Give the raw TTY to a child process (native review editor). */
531
+ /** The controller owns the terminal handoff and repaint around $EDITOR. */
532
+ editActiveDeckInput() {
533
+ const buffer = this.adapter?.inputBuffer();
534
+ if (buffer === undefined)
535
+ return;
536
+ this.suspendForChild();
537
+ let result = { text: buffer };
538
+ try {
539
+ result = editBufferInEditor(buffer);
540
+ }
541
+ finally {
542
+ this.resumeAfterChild();
543
+ this.adapter?.setInputBuffer(result.text);
544
+ this.resize();
545
+ if (result.error !== undefined)
546
+ this.status = result.error;
547
+ this.repaint(true);
548
+ }
549
+ }
550
+ /** Give the raw TTY to a child process (native review editor or $EDITOR). */
297
551
  suspendForChild() {
298
552
  this.suspended = true;
299
553
  if (this.stdinListener !== undefined)
@@ -335,8 +589,13 @@ export class InboxController {
335
589
  })();
336
590
  }
337
591
  leaveDetail(release = true) {
592
+ this.deckBrowserGeneration++;
593
+ // A stale listener will stop itself after its await, but this controller
594
+ // may immediately claim another ticket and start a fresh browser handoff.
595
+ this.deckBrowserStarting = false;
338
596
  this.adapter?.close();
339
597
  this.adapter = undefined;
598
+ this.activeDeck = undefined;
340
599
  this.selectedWatcher?.close();
341
600
  this.selectedWatcher = undefined;
342
601
  if (release && this.claim !== undefined)
@@ -347,17 +606,36 @@ export class InboxController {
347
606
  select(index) {
348
607
  if (this.items.length === 0)
349
608
  return;
350
- this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
609
+ const next = Math.max(0, Math.min(index, this.items.length - 1));
610
+ if (next !== this.selectedIndex)
611
+ this.previewScrollOffset = 0;
612
+ this.selectedIndex = next;
351
613
  this.selectedDir = this.items[this.selectedIndex].dir;
352
614
  }
615
+ scrollPreview(delta) {
616
+ this.previewScrollOffset = Math.max(0, this.previewScrollOffset + delta);
617
+ }
353
618
  detailSize() {
354
619
  const layout = inboxLayout(this.cols, this.rows, this.screen);
355
620
  return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
356
621
  }
357
622
  detailLines(width, rows) {
623
+ if (this.deckBrowser !== undefined)
624
+ return renderHandoff(this.deckBrowser.url, width, rows);
625
+ if (this.deckBrowserStarting)
626
+ return [` ${DIM}Opening browser review…${RESET}`];
358
627
  if (this.adapter !== undefined)
359
628
  return this.adapter.render();
360
629
  const selected = this.items[this.selectedIndex];
630
+ if (selected === undefined)
631
+ return this.previewViewport(this.passiveDetailLines(width), width, rows);
632
+ const footer = selected.kind === 'deck'
633
+ ? [` ${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`]
634
+ : [` ${DIM}Enter${RESET} opens the full review ${DIM}u/d${RESET} scroll ${DIM}j/k${RESET} select`, ` ${DIM}q${RESET} close`];
635
+ return [...this.previewViewport(this.passiveDetailLines(width), width, Math.max(0, rows - footer.length)), ...footer.map((line) => clipLine(line, width))];
636
+ }
637
+ passiveDetailLines(width) {
638
+ const selected = this.items[this.selectedIndex];
361
639
  if (selected === undefined)
362
640
  return [` ${DIM}Select a pending interaction.${RESET}`];
363
641
  const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${sourceLabel(selected.source)}${RESET}`];
@@ -378,18 +656,18 @@ export class InboxController {
378
656
  else
379
657
  for (const rendered of renderMarkdown(md, Math.max(1, width - 2)))
380
658
  lines.push(` ${rendered}`);
381
- lines.push('', ` ${DIM}Enter${RESET} start review ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
382
- while (lines.length < rows)
383
- lines.push('');
384
- // Rendered markdown carries ANSI; slicing by column count would sever
385
- // escape sequences, so review preview lines are returned unsliced.
386
- return lines;
387
659
  }
388
- if (selected.kind === 'deck') {
660
+ else {
389
661
  const deck = readJson(deckPath(selected.dir));
390
662
  if (deck !== null) {
391
663
  for (const interaction of deck.interactions) {
392
664
  lines.push('', ` ${BOLD}${interaction.title}${RESET}`);
665
+ if (interaction.subtitle)
666
+ for (const rendered of renderMarkdown(interaction.subtitle, Math.max(1, width - 2)))
667
+ lines.push(` ${rendered}`);
668
+ if (interaction.body)
669
+ for (const rendered of renderMarkdown(interaction.body, Math.max(1, width - 2)))
670
+ lines.push(` ${rendered}`);
393
671
  for (const option of interaction.options)
394
672
  lines.push(` ${DIM}• ${option.label}${RESET}`);
395
673
  }
@@ -397,12 +675,33 @@ export class InboxController {
397
675
  lines.push('', ` ${DIM}${Array.isArray(saved) ? saved.length : 0} saved responses${RESET}`);
398
676
  }
399
677
  }
400
- if (selected.subtitle)
401
- lines.push('', ` ${selected.subtitle}`);
402
- lines.push('', ` ${DIM}Enter${RESET} open ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
403
- while (lines.length < rows)
404
- lines.push('');
405
- return lines.map((line) => line.slice(0, width));
678
+ return lines.map((line) => clipLine(line, width));
679
+ }
680
+ previewViewport(lines, width, rows) {
681
+ if (rows < 1)
682
+ return [];
683
+ const maxOffset = Math.max(0, lines.length - Math.max(1, rows - 1));
684
+ this.previewScrollOffset = Math.min(this.previewScrollOffset, maxOffset);
685
+ const start = this.previewScrollOffset;
686
+ const hasAbove = start > 0;
687
+ // Reserve an indicator row before selecting content so a remaining tail is
688
+ // always signalled instead of silently disappearing below the viewport.
689
+ let contentRows = rows - (hasAbove ? 1 : 0);
690
+ let end = Math.min(lines.length, start + contentRows);
691
+ const hasBelow = end < lines.length;
692
+ if (hasBelow) {
693
+ contentRows--;
694
+ end = Math.min(lines.length, start + Math.max(0, contentRows));
695
+ }
696
+ const out = [];
697
+ if (hasAbove)
698
+ out.push(` ${DIM}↑ more above${RESET}`);
699
+ out.push(...lines.slice(start, end));
700
+ if (hasBelow)
701
+ out.push(` ${DIM}↓ more below${RESET}`);
702
+ while (out.length < rows)
703
+ out.push('');
704
+ return out.map((line) => clipLine(line, width));
406
705
  }
407
706
  withStatus(lines) {
408
707
  if (this.status === undefined || this.rows < 1)
@@ -445,10 +744,15 @@ export class InboxController {
445
744
  this.selectedWatcher?.close();
446
745
  try {
447
746
  this.selectedWatcher = watch(dir, (_event, file) => {
448
- if (file === 'deck.json')
747
+ if (file === 'deck.json') {
449
748
  this.reloadSelectedDeck();
450
- else
451
- this.invalidate();
749
+ return;
750
+ }
751
+ if (file === 'followup-result.json' || file === 'followup-request.json') {
752
+ this.refreshFollowUp(dir);
753
+ return;
754
+ }
755
+ this.invalidate();
452
756
  });
453
757
  }
454
758
  catch {
@@ -459,6 +763,9 @@ export class InboxController {
459
763
  function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
460
764
  /** M-i (Option/Alt+I) reaches the controller as the two-byte ESC-i sequence. */
461
765
  function isToggleCloseChord(input) { return input === '\x1bi' || input === '\x1bI'; }
766
+ function isAnswerBearingDeck(deck) {
767
+ return deck.interactions.some((interaction) => interaction.kind !== 'notify');
768
+ }
462
769
  /** Prefer a human-meaningful source label, falling back to the raw node id
463
770
  * before an opaque "unknown source" — a crouter ticket always carries nodeId. */
464
771
  function sourceLabel(source) { return source.sessionName ?? source.askedBy ?? source.nodeId ?? 'unknown source'; }
@@ -6,9 +6,16 @@ export declare function progressPath(dir: string): string;
6
6
  export declare function claimPath(dir: string): string;
7
7
  export declare function deliveryPath(dir: string): string;
8
8
  export declare function deliveryErrorPath(dir: string): string;
9
+ export declare function followupRequestPath(dir: string): string;
10
+ export declare function followupResultPath(dir: string): string;
9
11
  export declare function visualsDir(dir: string): string;
10
12
  export declare function visualMdPath(dir: string, id: string): string;
11
13
  export declare function visualAnsiPath(dir: string, id: string): string;
14
+ /** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
15
+ * acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
16
+ * Error carrying the handler's captured stderr. Shared by completion delivery
17
+ * and follow-up dispatch — both invoke a registered handler the same way. */
18
+ export declare function runHandler(command: string, args: string[], event: unknown): Promise<void>;
12
19
  export type InteractionState = 'pending' | 'claimed' | 'resolved' | 'missing';
13
20
  export declare function interactionState(dir: string): InteractionState;
14
21
  export declare function isResolved(dir: string): boolean;