@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.
- package/dist/browser/server.d.ts +15 -5
- package/dist/browser/server.js +110 -14
- package/dist/cli.js +0 -0
- package/dist/conversation/reader.d.ts +15 -1
- package/dist/conversation/reader.js +320 -30
- 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 +36 -1
- package/dist/inbox/controller.js +335 -28
- package/dist/inbox/convention.d.ts +7 -0
- package/dist/inbox/convention.js +32 -0
- 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 +2 -0
- package/dist/inbox/registry.js +6 -2
- package/dist/inbox/tickets.js +8 -2
- package/dist/inbox/tui.d.ts +1 -1
- package/dist/inbox/tui.js +58 -25
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/render/termrender.js +25 -26
- package/dist/tui/app.d.ts +1 -1
- package/dist/tui/app.js +80 -88
- 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 +42 -23
- 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 +39 -2
- package/dist/visuals/conversation.d.ts +7 -0
- package/dist/visuals/conversation.js +16 -0
- package/dist/visuals/generate.d.ts +3 -1
- package/dist/visuals/generate.js +8 -6
- package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +7 -2
package/dist/inbox/completion.js
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/dist/inbox/controller.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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() {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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;
|