@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/index.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export { mountPanel, validateInput, launchTui } from './tui/app.js';
|
|
2
|
-
export { defaultGenerateVisual } from './visuals/generate.js';
|
|
3
2
|
export { launchReview } from './editor/review.js';
|
|
4
3
|
export { launchReview as review } from './editor/review.js';
|
|
5
4
|
export type { ReviewOptions } from './editor/review.js';
|
|
@@ -13,6 +12,10 @@ export type { CompletionHandler, InboxRootRegistration, InboxRootStatus, Registe
|
|
|
13
12
|
export { submitDeck, submitReview, readTicketResult, finalizeDeck, finalizeReview, completeDeck, completeReview, cancelTicket, cancelTicketResult, } from './inbox/tickets.js';
|
|
14
13
|
export type { SubmitDeckOptions, SubmitReviewOptions } from './inbox/tickets.js';
|
|
15
14
|
export { dispatchCompletion, reconcileCompletions } from './inbox/completion.js';
|
|
15
|
+
export { requestFollowUp, cancelFollowUp, submitFollowUpResult, readFollowUp } from './inbox/followup.js';
|
|
16
|
+
export type { FollowUpRequestEvent, FollowUpRequest, FollowUpResult } from './inbox/followup.js';
|
|
17
|
+
export { VISUAL_CAPABILITY, canonicalizeInteraction, canonicalInteractionJson, parseVisualRequestEvent, readVisualRequest, readVisualResult, readVisualRequestForEvent, startVisualRequest, submitVisualResult, cancelVisualRequest, cancelVisualRequestsForTicket, dispatchVisualCleanup, listVisualCleanupObligations, listVisualCleanupObligationsForRoot, reconcileVisualRequestsForTicket, reconcileStaleVisualRequestsForRoot, } from './inbox/visual.js';
|
|
18
|
+
export type { VisualClaimIdentity, VisualCleanupObligation, VisualProtocolRequest, VisualRequestEvent, VisualProtocolResult, VisualResultSubmission, StartVisualRequestOptions, StartedVisualRequest, VisualStartDeliveryResult, VisualCleanupDeliveryResult, VisualCleanupTask, VisualRequestReconciliation, } from './inbox/visual.js';
|
|
16
19
|
export { claimTicket, heartbeatClaim, releaseClaim, readTicketClaim } from './inbox/claim.js';
|
|
17
20
|
export type { TicketClaim, ClaimOptions } from './inbox/claim.js';
|
|
18
21
|
export { renderMarkdown, checkMarkdown, ensureRenderer, isRendererReady, } from './render/termrender.js';
|
|
@@ -21,6 +24,5 @@ export { notifyDeck } from './inbox/deck-factories.js';
|
|
|
21
24
|
export type { NotifyDeckOpts } from './inbox/deck-factories.js';
|
|
22
25
|
export { deckPath, reviewPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
|
|
23
26
|
export type { InteractionState } from './inbox/convention.js';
|
|
24
|
-
export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, MountedPanel, MountedPanelOpts,
|
|
27
|
+
export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, CanonicalInteractionOption, CanonicalInteraction, MountedPanel, MountedPanelOpts, VisualProvider, VisualRequest, VisualHandle, VisualResult, VisualBlock, FollowUpState, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, ClaimSummary, TicketSummary, DeckTicketSummary, ReviewTicketSummary, ReviewDescriptor, DeckTicketResult, ReviewTicketResult, CanceledTicketResult, TicketResult, CompletionEvent, } from './types.js';
|
|
25
28
|
export type { Key } from './tui/terminal.js';
|
|
26
|
-
export type { ConversationMessage } from './conversation/reader.js';
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export { mountPanel, validateInput, launchTui } from './tui/app.js';
|
|
2
|
-
export { defaultGenerateVisual } from './visuals/generate.js';
|
|
3
2
|
export { launchReview } from './editor/review.js';
|
|
4
3
|
export { launchReview as review } from './editor/review.js';
|
|
5
4
|
// Interaction-layer surface (SDK).
|
|
@@ -11,6 +10,8 @@ export { scanInbox } from './inbox/scan.js';
|
|
|
11
10
|
export { registerInboxRoot, unregisterInboxRoot, listInboxRoots, managedInboxRoot } from './inbox/registry.js';
|
|
12
11
|
export { submitDeck, submitReview, readTicketResult, finalizeDeck, finalizeReview, completeDeck, completeReview, cancelTicket, cancelTicketResult, } from './inbox/tickets.js';
|
|
13
12
|
export { dispatchCompletion, reconcileCompletions } from './inbox/completion.js';
|
|
13
|
+
export { requestFollowUp, cancelFollowUp, submitFollowUpResult, readFollowUp } from './inbox/followup.js';
|
|
14
|
+
export { VISUAL_CAPABILITY, canonicalizeInteraction, canonicalInteractionJson, parseVisualRequestEvent, readVisualRequest, readVisualResult, readVisualRequestForEvent, startVisualRequest, submitVisualResult, cancelVisualRequest, cancelVisualRequestsForTicket, dispatchVisualCleanup, listVisualCleanupObligations, listVisualCleanupObligationsForRoot, reconcileVisualRequestsForTicket, reconcileStaleVisualRequestsForRoot, } from './inbox/visual.js';
|
|
14
15
|
export { claimTicket, heartbeatClaim, releaseClaim, readTicketClaim } from './inbox/claim.js';
|
|
15
16
|
// Renderer binding — the sole org-wide termrender caller. Consumers
|
|
16
17
|
// (sisyphus md-render / ask-schema) route markdown through these.
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import type { Deck, InteractionResponse, MountedPanel, MountedPanelOpts,
|
|
1
|
+
import type { Deck, InteractionResponse, MountedPanel, MountedPanelOpts, VisualProvider } from '../types.js';
|
|
2
2
|
/** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
|
|
3
3
|
* Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
|
|
4
4
|
* with sisyphus). Kept exported for back-compat. */
|
|
5
5
|
export declare function validateInput(parsed: unknown): Deck;
|
|
6
6
|
export declare function mountPanel(opts: MountedPanelOpts): MountedPanel;
|
|
7
7
|
export interface ResolveDirOpts {
|
|
8
|
-
|
|
9
|
-
sessionId?: string;
|
|
10
|
-
/** Explicit visual generator; overrides the sessionId default. */
|
|
11
|
-
generateVisual?: GenerateVisual;
|
|
8
|
+
visualProvider?: VisualProvider;
|
|
12
9
|
cols?: number;
|
|
13
10
|
rows?: number;
|
|
14
11
|
}
|
|
@@ -31,7 +28,7 @@ export declare function resolveInteractionDir(dir: string, deck: Deck, opts?: Re
|
|
|
31
28
|
responsePath: string;
|
|
32
29
|
deck: Deck;
|
|
33
30
|
}>;
|
|
34
|
-
export declare function launchTui(decisionsPath: string,
|
|
31
|
+
export declare function launchTui(decisionsPath: string, opts?: ResolveDirOpts): Promise<{
|
|
35
32
|
responses: InteractionResponse[];
|
|
36
33
|
completedAt: string;
|
|
37
34
|
}>;
|
package/dist/tui/app.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
2
|
-
import { dirname, resolve as resolvePath, join } from 'node:path';
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
6
4
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
|
|
7
5
|
import { diffFrame, renderOverview, renderItemReview, renderFinal, renderHandoff, clampItemReviewScroll } from './render.js';
|
|
8
6
|
import { handleKeypress, assignShortcuts } from './input.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
7
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
8
|
+
import { editBufferInEditor } from '../editor/roundtrip.js';
|
|
11
9
|
import { validateDeck } from '../inbox/deck-schema.js';
|
|
10
|
+
import { canonicalizeInteraction } from '../inbox/visual.js';
|
|
12
11
|
import { progressPath as progressPathFor, deckPath as deckPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
13
12
|
import { startWebServer } from '../browser/server.js';
|
|
14
13
|
import { openBrowser } from '../browser/open.js';
|
|
@@ -19,7 +18,7 @@ export function validateInput(parsed) {
|
|
|
19
18
|
return validateDeck(parsed);
|
|
20
19
|
}
|
|
21
20
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
22
|
-
function buildInitialState(deck) {
|
|
21
|
+
function buildInitialState(deck, editorAvailable = false, followUpAvailable = false) {
|
|
23
22
|
// Single-question decks skip the overview list — there's nothing to overview,
|
|
24
23
|
// and overview hides the option hotkeys so users press 'y' and nothing happens.
|
|
25
24
|
const initialPhase = deck.interactions.length === 1 ? 'item-review' : 'overview';
|
|
@@ -55,8 +54,12 @@ function buildInitialState(deck) {
|
|
|
55
54
|
preAnsweredIds,
|
|
56
55
|
inputMode: null,
|
|
57
56
|
selectedAction: 0,
|
|
58
|
-
|
|
57
|
+
bodyMode: 'question',
|
|
59
58
|
scrollOffset: 0,
|
|
59
|
+
bodyScrollOffsets: { question: 0, visual: 0 },
|
|
60
|
+
editorAvailable,
|
|
61
|
+
followUpAvailable,
|
|
62
|
+
followUp: undefined,
|
|
60
63
|
};
|
|
61
64
|
}
|
|
62
65
|
function collectResponses(state) {
|
|
@@ -104,25 +107,72 @@ function rebindPersist(internals) {
|
|
|
104
107
|
internals.callbacks.onProgress?.(responses);
|
|
105
108
|
};
|
|
106
109
|
}
|
|
110
|
+
function visualRenderWidth(cols) {
|
|
111
|
+
return Math.max(1, Math.min(cols - 4, 76));
|
|
112
|
+
}
|
|
113
|
+
function renderVisualMarkdown(markdown, cols) {
|
|
114
|
+
return renderMarkdown(markdown, visualRenderWidth(cols)).join('\n');
|
|
115
|
+
}
|
|
116
|
+
/** Reflow ready Visual Markdown locally without issuing another request. */
|
|
117
|
+
function rerenderVisuals(internals) {
|
|
118
|
+
for (const [id, visual] of internals.state.visuals) {
|
|
119
|
+
if (visual.status !== 'ready' || visual.markdown === undefined)
|
|
120
|
+
continue;
|
|
121
|
+
internals.state.visuals.set(id, { ...visual, content: renderVisualMarkdown(visual.markdown, internals.cols) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function visualRequestInteraction(interaction) {
|
|
125
|
+
return canonicalizeInteraction(interaction);
|
|
126
|
+
}
|
|
127
|
+
function retireVisuals(internals) {
|
|
128
|
+
internals.visualGeneration += 1;
|
|
129
|
+
const handles = [...internals.visualHandles.values()];
|
|
130
|
+
internals.visualHandles.clear();
|
|
131
|
+
internals.state.visuals.clear();
|
|
132
|
+
for (const handle of handles) {
|
|
133
|
+
try {
|
|
134
|
+
handle.cancel();
|
|
135
|
+
}
|
|
136
|
+
catch { /* stale UI currency remains authoritative even if a host cancel fails */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
107
139
|
function fireVisuals(internals, interactions) {
|
|
108
|
-
|
|
140
|
+
const provider = internals.visualProvider;
|
|
141
|
+
if (provider === undefined)
|
|
109
142
|
return;
|
|
110
|
-
const
|
|
143
|
+
const generation = internals.visualGeneration;
|
|
144
|
+
const generationId = randomUUID();
|
|
111
145
|
for (const interaction of interactions) {
|
|
146
|
+
const requestId = randomUUID();
|
|
112
147
|
internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'loading' });
|
|
113
|
-
|
|
114
|
-
|
|
148
|
+
let handle;
|
|
149
|
+
try {
|
|
150
|
+
handle = provider({ requestId, generationId, interaction: visualRequestInteraction(interaction) });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'error' });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
internals.visualHandles.set(requestId, handle);
|
|
157
|
+
handle.result.then((result) => {
|
|
158
|
+
if (internals.visualHandles.get(requestId) !== handle)
|
|
159
|
+
return;
|
|
160
|
+
internals.visualHandles.delete(requestId);
|
|
161
|
+
if (!internals.mounted || generation !== internals.visualGeneration)
|
|
115
162
|
return;
|
|
116
|
-
if (!internals.state.interactions.some((
|
|
163
|
+
if (!internals.state.interactions.some((candidate) => candidate.id === interaction.id))
|
|
117
164
|
return;
|
|
118
|
-
internals.state.visuals.set(interaction.id,
|
|
119
|
-
? { questionId: interaction.id, content:
|
|
165
|
+
internals.state.visuals.set(interaction.id, result.status === 'ready' && typeof result.markdown === 'string'
|
|
166
|
+
? { questionId: interaction.id, content: renderVisualMarkdown(result.markdown, internals.cols), markdown: result.markdown, status: 'ready' }
|
|
120
167
|
: { questionId: interaction.id, content: '', status: 'error' });
|
|
121
168
|
internals.callbacks.onDirty?.();
|
|
122
169
|
}).catch(() => {
|
|
123
|
-
if (
|
|
170
|
+
if (internals.visualHandles.get(requestId) !== handle)
|
|
124
171
|
return;
|
|
125
|
-
|
|
172
|
+
internals.visualHandles.delete(requestId);
|
|
173
|
+
if (!internals.mounted || generation !== internals.visualGeneration)
|
|
174
|
+
return;
|
|
175
|
+
if (!internals.state.interactions.some((candidate) => candidate.id === interaction.id))
|
|
126
176
|
return;
|
|
127
177
|
internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'error' });
|
|
128
178
|
internals.callbacks.onDirty?.();
|
|
@@ -130,14 +180,24 @@ function fireVisuals(internals, interactions) {
|
|
|
130
180
|
}
|
|
131
181
|
}
|
|
132
182
|
export function mountPanel(opts) {
|
|
183
|
+
const followUpAvailable = opts.followUpAvailable !== false
|
|
184
|
+
&& opts.onFollowUpRequest !== undefined
|
|
185
|
+
&& opts.onFollowUpCancel !== undefined;
|
|
133
186
|
const internals = {
|
|
134
|
-
state: buildInitialState(opts.deck),
|
|
187
|
+
state: buildInitialState(opts.deck, opts.onEditorRequest !== undefined, followUpAvailable),
|
|
135
188
|
cols: opts.cols,
|
|
136
189
|
rows: opts.rows,
|
|
137
190
|
mounted: true,
|
|
138
|
-
|
|
191
|
+
visualProvider: opts.visualProvider,
|
|
192
|
+
visualGeneration: 0,
|
|
193
|
+
visualHandles: new Map(),
|
|
139
194
|
progressPath: opts.progressPath,
|
|
140
|
-
|
|
195
|
+
followUpAvailable,
|
|
196
|
+
callbacks: {
|
|
197
|
+
onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit,
|
|
198
|
+
onDirty: opts.onDirty, onEditorRequest: opts.onEditorRequest,
|
|
199
|
+
onFollowUpRequest: opts.onFollowUpRequest, onFollowUpCancel: opts.onFollowUpCancel,
|
|
200
|
+
},
|
|
141
201
|
};
|
|
142
202
|
assignShortcuts(internals.state.interactions);
|
|
143
203
|
rebindPersist(internals);
|
|
@@ -156,6 +216,10 @@ export function mountPanel(opts) {
|
|
|
156
216
|
handleKey(input, key) {
|
|
157
217
|
if (!internals.mounted)
|
|
158
218
|
return;
|
|
219
|
+
if (key.ctrl && input === 'o' && internals.state.inputMode !== null && internals.callbacks.onEditorRequest !== undefined) {
|
|
220
|
+
internals.callbacks.onEditorRequest();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
159
223
|
const onAutoComplete = () => {
|
|
160
224
|
const responses = collectResponses(internals.state);
|
|
161
225
|
if (internals.progressPath !== undefined) {
|
|
@@ -174,6 +238,9 @@ export function mountPanel(opts) {
|
|
|
174
238
|
else {
|
|
175
239
|
internals.callbacks.onExit?.();
|
|
176
240
|
}
|
|
241
|
+
}, {
|
|
242
|
+
request: (question) => internals.callbacks.onFollowUpRequest?.(question),
|
|
243
|
+
cancel: () => internals.callbacks.onFollowUpCancel?.(),
|
|
177
244
|
});
|
|
178
245
|
// Pre-render clamp (input layer): keep scrollOffset within the current
|
|
179
246
|
// body's bounds so u/d stay responsive. The renderer itself is pure.
|
|
@@ -189,22 +256,31 @@ export function mountPanel(opts) {
|
|
|
189
256
|
handleResize(cols, rows) {
|
|
190
257
|
internals.cols = cols;
|
|
191
258
|
internals.rows = rows;
|
|
192
|
-
//
|
|
259
|
+
// Width changes reflow saved visual markdown, so clamp against the new
|
|
260
|
+
// content and geometry rather than the stale pre-resize wrapping.
|
|
261
|
+
rerenderVisuals(internals);
|
|
193
262
|
if (internals.state.phase === 'item-review') {
|
|
194
263
|
clampItemReviewScroll(internals.state, cols, rows);
|
|
195
264
|
}
|
|
196
265
|
return renderLines();
|
|
197
266
|
},
|
|
198
267
|
unmount() {
|
|
268
|
+
if (!internals.mounted)
|
|
269
|
+
return;
|
|
199
270
|
internals.mounted = false;
|
|
200
|
-
internals
|
|
271
|
+
retireVisuals(internals);
|
|
201
272
|
internals.state.persist = undefined;
|
|
202
273
|
},
|
|
203
274
|
loadDeck(deck, loadOpts) {
|
|
204
275
|
if (!internals.mounted)
|
|
205
276
|
return;
|
|
206
277
|
const prior = collectResponses(internals.state);
|
|
207
|
-
|
|
278
|
+
// Retire before replacing capability: old handles become non-current,
|
|
279
|
+
// then the next generation uses only the freshly derived provider.
|
|
280
|
+
retireVisuals(internals);
|
|
281
|
+
if (loadOpts !== undefined && 'visualProvider' in loadOpts)
|
|
282
|
+
internals.visualProvider = loadOpts.visualProvider;
|
|
283
|
+
internals.state = buildInitialState(deck, internals.callbacks.onEditorRequest !== undefined, internals.followUpAvailable);
|
|
208
284
|
if (loadOpts !== undefined && loadOpts.progressPath !== undefined) {
|
|
209
285
|
internals.progressPath = loadOpts.progressPath;
|
|
210
286
|
}
|
|
@@ -227,6 +303,25 @@ export function mountPanel(opts) {
|
|
|
227
303
|
}
|
|
228
304
|
fireVisuals(internals, deck.interactions);
|
|
229
305
|
},
|
|
306
|
+
setFollowUpHandlers(available, onRequest, onCancel) {
|
|
307
|
+
if (!internals.mounted)
|
|
308
|
+
return;
|
|
309
|
+
internals.callbacks.onFollowUpRequest = onRequest;
|
|
310
|
+
internals.callbacks.onFollowUpCancel = onCancel;
|
|
311
|
+
const enabled = available && onRequest !== undefined && onCancel !== undefined;
|
|
312
|
+
internals.followUpAvailable = enabled;
|
|
313
|
+
internals.state.followUpAvailable = enabled;
|
|
314
|
+
if (!enabled) {
|
|
315
|
+
internals.state.followUp = undefined;
|
|
316
|
+
if (internals.state.inputMode?.kind === 'follow-up')
|
|
317
|
+
internals.state.inputMode = null;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
setFollowUpState(state) {
|
|
321
|
+
if (!internals.mounted || !internals.state.followUpAvailable)
|
|
322
|
+
return;
|
|
323
|
+
internals.state.followUp = state;
|
|
324
|
+
},
|
|
230
325
|
canAcceptHostKeys() {
|
|
231
326
|
if (!internals.mounted)
|
|
232
327
|
return false;
|
|
@@ -264,24 +359,10 @@ export function mountPanel(opts) {
|
|
|
264
359
|
* returned `deck` is the one actually answered (post-reload).
|
|
265
360
|
*/
|
|
266
361
|
export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
267
|
-
let conversationContext = '';
|
|
268
|
-
if (opts.sessionId !== undefined) {
|
|
269
|
-
try {
|
|
270
|
-
const conv = readConversation(opts.sessionId);
|
|
271
|
-
conversationContext = conv.map((m) => `${m.role}: ${m.content}`).join('\n\n');
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
// empty context — proceed without visuals context
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
362
|
setupTerminal();
|
|
278
363
|
const term = getTerminalSize();
|
|
279
364
|
const cols = opts.cols ?? term.cols;
|
|
280
365
|
const rows = opts.rows ?? term.rows;
|
|
281
|
-
const generateVisual = opts.generateVisual ??
|
|
282
|
-
(opts.sessionId !== undefined
|
|
283
|
-
? (interaction) => defaultGenerateVisual(interaction, conversationContext)
|
|
284
|
-
: undefined);
|
|
285
366
|
return new Promise((resolve) => {
|
|
286
367
|
let panel = null;
|
|
287
368
|
let prevFrameLocal = [];
|
|
@@ -368,7 +449,12 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
368
449
|
progressPath: progressPathFor(dir),
|
|
369
450
|
cols,
|
|
370
451
|
rows,
|
|
371
|
-
|
|
452
|
+
visualProvider: opts.visualProvider,
|
|
453
|
+
onEditorRequest: () => {
|
|
454
|
+
const buffer = panel?.getInputBuffer();
|
|
455
|
+
if (buffer !== undefined)
|
|
456
|
+
runEditorEscapeHatch(buffer);
|
|
457
|
+
},
|
|
372
458
|
onProgress: (responses) => {
|
|
373
459
|
lastResponses = responses;
|
|
374
460
|
if (panel !== null)
|
|
@@ -444,68 +530,22 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
444
530
|
return;
|
|
445
531
|
process.stdin.removeListener('data', onData);
|
|
446
532
|
process.stdout.removeListener('resize', onResize);
|
|
447
|
-
|
|
448
|
-
const editor = process.env.EDITOR || 'vi';
|
|
449
|
-
let next = buffer;
|
|
450
|
-
let errorMessage = null;
|
|
451
|
-
// Everything from suspending the TUI through the editor round-trip can
|
|
452
|
-
// throw (tmpfile write, spawn, readback) — a tmpdir write failure alone
|
|
453
|
-
// (e.g. /tmp full) used to escape with the listeners detached and the
|
|
454
|
-
// real terminal never restored. The try/finally below guarantees the
|
|
455
|
-
// finally branch — tmpfile cleanup, TUI restore, listener re-attach,
|
|
456
|
-
// resize-aware redraw — runs on every path, success or failure.
|
|
533
|
+
let result = { text: buffer };
|
|
457
534
|
try {
|
|
458
535
|
restoreTerminal();
|
|
459
|
-
|
|
460
|
-
// $EDITOR is conventionally a shell fragment, not a bare binary —
|
|
461
|
-
// "code --wait", "emacsclient -t" — so run it through the shell (as
|
|
462
|
-
// git does) with the filename passed safely as a positional arg.
|
|
463
|
-
const result = spawnSync('/bin/sh', ['-c', `${editor} "$1"`, 'sh', tmpFile], { stdio: 'inherit' });
|
|
464
|
-
if (result.error) {
|
|
465
|
-
errorMessage = `$EDITOR ("${editor}") failed to launch: ${result.error.message}`;
|
|
466
|
-
}
|
|
467
|
-
else if (result.signal !== null) {
|
|
468
|
-
errorMessage = `$EDITOR ("${editor}") was killed by signal ${result.signal}`;
|
|
469
|
-
}
|
|
470
|
-
else if (result.status === 127 || result.status === 126) {
|
|
471
|
-
// Shell couldn't exec the editor: 127 = not found, 126 = not executable.
|
|
472
|
-
errorMessage = `$EDITOR ("${editor}") failed to launch (shell exit ${result.status})`;
|
|
473
|
-
}
|
|
474
|
-
else if (result.status !== 0) {
|
|
475
|
-
errorMessage = `$EDITOR ("${editor}") exited with status ${result.status}`;
|
|
476
|
-
}
|
|
477
|
-
else {
|
|
478
|
-
let read = readFileSync(tmpFile, 'utf8');
|
|
479
|
-
// Editors conventionally append a trailing newline on save; strip
|
|
480
|
-
// exactly one so a round-trip that added nothing doesn't grow the
|
|
481
|
-
// buffer by a blank line.
|
|
482
|
-
if (read.endsWith('\n') && !buffer.endsWith('\n'))
|
|
483
|
-
read = read.slice(0, -1);
|
|
484
|
-
next = read;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
catch (err) {
|
|
488
|
-
errorMessage = `$EDITOR round-trip failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
536
|
+
result = editBufferInEditor(buffer);
|
|
489
537
|
}
|
|
490
538
|
finally {
|
|
491
|
-
try {
|
|
492
|
-
unlinkSync(tmpFile);
|
|
493
|
-
}
|
|
494
|
-
catch { /* best-effort cleanup — may never have been created */ }
|
|
495
539
|
setupTerminal();
|
|
496
540
|
process.stdin.on('data', onData);
|
|
497
541
|
process.stdout.on('resize', onResize);
|
|
498
|
-
|
|
499
|
-
// terminal size (it may have changed while the editor had it) so the
|
|
500
|
-
// post-editor redraw lays out for the CURRENT dimensions rather than
|
|
501
|
-
// the panel's stale cols/rows, same as the live resize listener does.
|
|
502
|
-
panel.setInputBuffer(errorMessage === null ? next : buffer);
|
|
542
|
+
panel.setInputBuffer(result.text);
|
|
503
543
|
const { cols: c, rows: r } = getTerminalSize();
|
|
504
544
|
const lines = panel.handleResize(c, r);
|
|
505
|
-
if (
|
|
545
|
+
if (result.error !== undefined) {
|
|
506
546
|
while (lines.length < r)
|
|
507
547
|
lines.push('');
|
|
508
|
-
lines[r - 1] = ` ${
|
|
548
|
+
lines[r - 1] = ` ${result.error}`.slice(0, c);
|
|
509
549
|
}
|
|
510
550
|
prevFrameLocal = [];
|
|
511
551
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -583,13 +623,6 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
583
623
|
}
|
|
584
624
|
return;
|
|
585
625
|
}
|
|
586
|
-
if (key.ctrl && inp === 'o') {
|
|
587
|
-
const buf = panel.getInputBuffer();
|
|
588
|
-
if (buf !== undefined) {
|
|
589
|
-
runEditorEscapeHatch(buf);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
626
|
// 'w' hands the current interaction off to the browser. Gated on
|
|
594
627
|
// canAcceptHostKeys() (not mid comment/freetext) so it never shadows a
|
|
595
628
|
// literal 'w' typed into a buffer; 'w' is also reserved from option
|
|
@@ -607,7 +640,7 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
607
640
|
}
|
|
608
641
|
// ── launchTui — file-path entry over the dir resolver (a kept public export
|
|
609
642
|
// per the interaction-layer plan; consumed until consumers move to ask()) ──
|
|
610
|
-
export async function launchTui(decisionsPath,
|
|
643
|
+
export async function launchTui(decisionsPath, opts = {}) {
|
|
611
644
|
if (!existsSync(decisionsPath)) {
|
|
612
645
|
throw new Error(`Decisions file not found: ${decisionsPath}`);
|
|
613
646
|
}
|
|
@@ -616,6 +649,6 @@ export async function launchTui(decisionsPath, sessionId) {
|
|
|
616
649
|
// The interaction dir is the deck file's directory; progress/response live
|
|
617
650
|
// there per the convention.
|
|
618
651
|
const dir = dirname(resolvePath(decisionsPath));
|
|
619
|
-
const { responses, completedAt } = await resolveInteractionDir(dir, deck,
|
|
652
|
+
const { responses, completedAt } = await resolveInteractionDir(dir, deck, opts);
|
|
620
653
|
return { responses, completedAt };
|
|
621
654
|
}
|
package/dist/tui/input.d.ts
CHANGED
|
@@ -2,5 +2,9 @@ import type { TuiState, Interaction } from '../types.js';
|
|
|
2
2
|
import type { Key } from './terminal.js';
|
|
3
3
|
export type RenderFn = () => void;
|
|
4
4
|
export type ExitFn = () => void;
|
|
5
|
+
export interface FollowUpActions {
|
|
6
|
+
request(question: string): void;
|
|
7
|
+
cancel(): void;
|
|
8
|
+
}
|
|
5
9
|
export declare function assignShortcuts(interactions: Interaction[]): void;
|
|
6
|
-
export declare function handleKeypress(input: string, key: Key, state: TuiState, render: RenderFn, exit: ExitFn): void;
|
|
10
|
+
export declare function handleKeypress(input: string, key: Key, state: TuiState, render: RenderFn, exit: ExitFn, followUp?: FollowUpActions): void;
|
package/dist/tui/input.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 'w' is reserved for the host-level "open in browser" handoff (see
|
|
2
2
|
// tui/app.ts resolveInteractionDir) — never auto-assignable as an option
|
|
3
3
|
// shortcut, or pressing it would race the handoff against picking that option.
|
|
4
|
-
const RESERVED = new Set(['c', 'r', 'n', 'p', 'q', 'j', 'k', 'u', 'd', 'w', ' ']);
|
|
4
|
+
const RESERVED = new Set(['c', 'r', 'n', 'p', 'q', 'j', 'k', 'u', 'd', 'w', ' ', '?']);
|
|
5
5
|
export function assignShortcuts(interactions) {
|
|
6
6
|
for (const it of interactions) {
|
|
7
7
|
const used = new Set(it.options.map((o) => o.shortcut).filter((s) => s !== undefined));
|
|
@@ -32,7 +32,14 @@ export function assignShortcuts(interactions) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
export function handleKeypress(input, key, state, render, exit) {
|
|
35
|
+
export function handleKeypress(input, key, state, render, exit, followUp) {
|
|
36
|
+
if (key.backTab) {
|
|
37
|
+
state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
|
|
38
|
+
state.bodyMode = state.bodyMode === 'question' ? 'visual' : 'question';
|
|
39
|
+
state.scrollOffset = state.bodyScrollOffsets[state.bodyMode];
|
|
40
|
+
render();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
36
43
|
if (key.ctrl && input === 'c') {
|
|
37
44
|
exit();
|
|
38
45
|
return;
|
|
@@ -42,7 +49,7 @@ export function handleKeypress(input, key, state, render, exit) {
|
|
|
42
49
|
// render cycle.
|
|
43
50
|
state.hint = undefined;
|
|
44
51
|
if (state.inputMode) {
|
|
45
|
-
handleInputMode(input, key, state, render);
|
|
52
|
+
handleInputMode(input, key, state, render, followUp);
|
|
46
53
|
checkAutoExit(state, exit);
|
|
47
54
|
return;
|
|
48
55
|
}
|
|
@@ -51,7 +58,7 @@ export function handleKeypress(input, key, state, render, exit) {
|
|
|
51
58
|
handleOverview(input, key, state, render, exit);
|
|
52
59
|
break;
|
|
53
60
|
case 'item-review':
|
|
54
|
-
handleItemReview(input, key, state, render);
|
|
61
|
+
handleItemReview(input, key, state, render, followUp);
|
|
55
62
|
checkAutoExit(state, exit);
|
|
56
63
|
break;
|
|
57
64
|
case 'final':
|
|
@@ -91,7 +98,8 @@ function handleOverview(input, key, state, render, exit) {
|
|
|
91
98
|
if (key.return || input === ' ') {
|
|
92
99
|
state.phase = 'item-review';
|
|
93
100
|
state.selectedAction = 0;
|
|
94
|
-
state.
|
|
101
|
+
state.bodyMode = 'question';
|
|
102
|
+
state.scrollOffset = state.bodyScrollOffsets.question;
|
|
95
103
|
render();
|
|
96
104
|
return;
|
|
97
105
|
}
|
|
@@ -124,7 +132,15 @@ function handleOverview(input, key, state, render, exit) {
|
|
|
124
132
|
}
|
|
125
133
|
}
|
|
126
134
|
// ── Item Review ──────────────────────────────────────────────────────────────
|
|
127
|
-
function handleItemReview(input, key, state, render) {
|
|
135
|
+
function handleItemReview(input, key, state, render, followUp) {
|
|
136
|
+
if (input === '?' && state.followUpAvailable) {
|
|
137
|
+
if (state.followUp?.status === 'running')
|
|
138
|
+
followUp?.cancel();
|
|
139
|
+
else
|
|
140
|
+
state.inputMode = { kind: 'follow-up', buffer: '', cursor: 0 };
|
|
141
|
+
render();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
128
144
|
const interaction = state.interactions[state.currentIndex];
|
|
129
145
|
if (input === 'n') {
|
|
130
146
|
advanceItem(state, 1);
|
|
@@ -138,22 +154,18 @@ function handleItemReview(input, key, state, render) {
|
|
|
138
154
|
}
|
|
139
155
|
// q / Esc step back to the deck overview (one level up from a card).
|
|
140
156
|
if (input === 'q' || key.escape) {
|
|
157
|
+
state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
|
|
141
158
|
state.phase = 'overview';
|
|
142
159
|
render();
|
|
143
160
|
return;
|
|
144
161
|
}
|
|
145
|
-
// Space toggles the focused option for multi-select
|
|
162
|
+
// Space toggles the focused option for multi-select.
|
|
146
163
|
if (input === ' ' && interaction.multiSelect
|
|
147
164
|
&& state.selectedAction < interaction.options.length) {
|
|
148
165
|
toggleMulti(state, interaction, interaction.options[state.selectedAction].id);
|
|
149
166
|
render();
|
|
150
167
|
return;
|
|
151
168
|
}
|
|
152
|
-
if (input === ' ') {
|
|
153
|
-
state.detailExpanded = !state.detailExpanded;
|
|
154
|
-
render();
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
169
|
// Body scroll: u/d, PageUp/PageDown, or Ctrl+D / Ctrl+U (half-page), Ctrl+E / Ctrl+Y (line).
|
|
158
170
|
// Plain u/d exists because tmux configs commonly bind C-d/C-u for pane scroll
|
|
159
171
|
// and intercept them before they reach the app. Render clamps state.scrollOffset,
|
|
@@ -273,8 +285,20 @@ function handleInteractionAction(input, key, state, interaction, render) {
|
|
|
273
285
|
}
|
|
274
286
|
}
|
|
275
287
|
// ── Input Mode ───────────────────────────────────────────────────────────────
|
|
276
|
-
function handleInputMode(input, key, state, render) {
|
|
288
|
+
function handleInputMode(input, key, state, render, followUp) {
|
|
277
289
|
const mode = state.inputMode;
|
|
290
|
+
if (mode.kind === 'follow-up' && key.return) {
|
|
291
|
+
const question = mode.buffer.trim();
|
|
292
|
+
if (question.length === 0) {
|
|
293
|
+
state.hint = 'Enter a question, or esc to cancel';
|
|
294
|
+
render();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
followUp?.request(question);
|
|
298
|
+
state.inputMode = null;
|
|
299
|
+
render();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
278
302
|
if (key.escape) {
|
|
279
303
|
state.inputMode = null;
|
|
280
304
|
render();
|
|
@@ -549,8 +573,9 @@ function advanceItem(state, direction) {
|
|
|
549
573
|
}
|
|
550
574
|
state.currentIndex = next;
|
|
551
575
|
state.selectedAction = 0;
|
|
552
|
-
state.
|
|
576
|
+
state.bodyMode = 'question';
|
|
553
577
|
state.scrollOffset = 0;
|
|
578
|
+
state.bodyScrollOffsets = { question: 0, visual: 0 };
|
|
554
579
|
}
|
|
555
580
|
/**
|
|
556
581
|
* Move to the next interaction WITHOUT a response, falling through to the
|
|
@@ -570,8 +595,9 @@ function advanceToNextUnanswered(state) {
|
|
|
570
595
|
}
|
|
571
596
|
state.currentIndex = next;
|
|
572
597
|
state.selectedAction = 0;
|
|
573
|
-
state.
|
|
598
|
+
state.bodyMode = 'question';
|
|
574
599
|
state.scrollOffset = 0;
|
|
600
|
+
state.bodyScrollOffsets = { question: 0, visual: 0 };
|
|
575
601
|
}
|
|
576
602
|
function actionCount(interaction) {
|
|
577
603
|
return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
|
package/dist/tui/log.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
const ROTATIONS = 2;
|
|
6
|
+
function stateHome() {
|
|
7
|
+
return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state');
|
|
8
|
+
}
|
|
9
|
+
export function popupLogPath() {
|
|
10
|
+
return join(stateHome(), 'humanloop', 'inbox-popup.log');
|
|
11
|
+
}
|
|
12
|
+
function rotateIfNeeded(path, nextLineBytes) {
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return;
|
|
15
|
+
let size;
|
|
16
|
+
try {
|
|
17
|
+
size = statSync(path).size;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (size + nextLineBytes < MAX_BYTES)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
rmSync(`${path}.${ROTATIONS}`, { force: true });
|
|
26
|
+
}
|
|
27
|
+
catch { /* best-effort */ }
|
|
28
|
+
for (let index = ROTATIONS - 1; index >= 1; index -= 1) {
|
|
29
|
+
const source = `${path}.${index}`;
|
|
30
|
+
const target = `${path}.${index + 1}`;
|
|
31
|
+
try {
|
|
32
|
+
if (existsSync(source))
|
|
33
|
+
renameSync(source, target);
|
|
34
|
+
}
|
|
35
|
+
catch { /* best-effort */ }
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
renameSync(path, `${path}.1`);
|
|
39
|
+
}
|
|
40
|
+
catch { /* best-effort */ }
|
|
41
|
+
}
|
|
42
|
+
export function logPopupEvent(event, fields = {}) {
|
|
43
|
+
const path = popupLogPath();
|
|
44
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), component: 'inbox-popup', event, ...fields });
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
47
|
+
rotateIfNeeded(path, Buffer.byteLength(`${line}\n`, 'utf8'));
|
|
48
|
+
appendFileSync(path, `${line}\n`, 'utf8');
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* Logging is best-effort so the inbox shortcut remains available. */
|
|
52
|
+
}
|
|
53
|
+
}
|