@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.
- package/dist/api.d.ts +3 -2
- package/dist/api.js +2 -2
- package/dist/browser/server.d.ts +15 -5
- package/dist/browser/server.js +110 -14
- package/dist/cli.js +0 -0
- package/dist/editor/roundtrip.d.ts +5 -0
- package/dist/editor/roundtrip.js +36 -0
- package/dist/inbox/completion.js +1 -27
- package/dist/inbox/controller.d.ts +32 -3
- package/dist/inbox/controller.js +427 -35
- package/dist/inbox/convention.d.ts +9 -2
- package/dist/inbox/convention.js +51 -3
- package/dist/inbox/deck-adapter.d.ts +10 -2
- package/dist/inbox/deck-adapter.js +23 -12
- package/dist/inbox/deck-schema.d.ts +2 -0
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/followup.d.ts +57 -0
- package/dist/inbox/followup.js +117 -0
- package/dist/inbox/registry.d.ts +4 -0
- package/dist/inbox/registry.js +9 -4
- package/dist/inbox/tickets.d.ts +5 -0
- package/dist/inbox/tickets.js +25 -32
- package/dist/inbox/tui.d.ts +1 -1
- package/dist/inbox/tui.js +58 -25
- package/dist/inbox/visual.d.ts +130 -0
- package/dist/inbox/visual.js +747 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/tui/app.d.ts +3 -6
- package/dist/tui/app.js +130 -97
- package/dist/tui/input.d.ts +5 -1
- package/dist/tui/input.js +41 -15
- package/dist/tui/log.d.ts +2 -0
- package/dist/tui/log.js +53 -0
- package/dist/tui/render.js +44 -27
- package/dist/tui/terminal.d.ts +1 -0
- package/dist/tui/terminal.js +5 -0
- package/dist/tui/tmux.js +44 -22
- package/dist/types.d.ts +71 -7
- package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +6 -3
- package/dist/conversation/reader.d.ts +0 -6
- package/dist/conversation/reader.js +0 -58
- package/dist/visuals/generate.d.ts +0 -9
- package/dist/visuals/generate.js +0 -79
package/dist/inbox/controller.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
172
|
-
if (deck ===
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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() {
|
|
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
|
-
|
|
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.
|
|
440
|
+
if (!this.closed && generation === this.deckBrowserGeneration)
|
|
441
|
+
this.status = error instanceof Error ? error.message : String(error);
|
|
262
442
|
}
|
|
263
443
|
finally {
|
|
264
|
-
this.
|
|
444
|
+
if (this.deckBrowserStartingGeneration === generation) {
|
|
445
|
+
this.deckBrowserStarting = false;
|
|
446
|
+
this.deckBrowserStartingGeneration = undefined;
|
|
447
|
+
}
|
|
265
448
|
this.repaint();
|
|
266
449
|
}
|
|
267
450
|
}
|
|
268
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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'; }
|