@crouton-kit/humanloop 0.3.38 → 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 +26 -2
- package/dist/inbox/controller.js +265 -34
- 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/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 +40 -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/tui/app.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
2
|
-
import { dirname, resolve as resolvePath
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
6
3
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
|
|
7
4
|
import { diffFrame, renderOverview, renderItemReview, renderFinal, renderHandoff, clampItemReviewScroll } from './render.js';
|
|
8
5
|
import { handleKeypress, assignShortcuts } from './input.js';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
6
|
+
import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
|
|
7
|
+
import { visualRenderWidth } from '../visuals/generate.js';
|
|
8
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
9
|
+
import { editBufferInEditor } from '../editor/roundtrip.js';
|
|
11
10
|
import { validateDeck } from '../inbox/deck-schema.js';
|
|
12
11
|
import { progressPath as progressPathFor, deckPath as deckPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
13
12
|
import { startWebServer } from '../browser/server.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,23 +107,36 @@ function rebindPersist(internals) {
|
|
|
104
107
|
internals.callbacks.onProgress?.(responses);
|
|
105
108
|
};
|
|
106
109
|
}
|
|
110
|
+
function renderVisualMarkdown(markdown, cols) {
|
|
111
|
+
return renderMarkdown(markdown, visualRenderWidth(cols)).join('\n');
|
|
112
|
+
}
|
|
113
|
+
/** Reflow ready visual context locally; resizing must never invoke the model. */
|
|
114
|
+
function rerenderVisuals(internals) {
|
|
115
|
+
for (const [id, visual] of internals.state.visuals) {
|
|
116
|
+
if (visual.status !== 'ready' || visual.markdown === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
internals.state.visuals.set(id, { ...visual, content: renderVisualMarkdown(visual.markdown, internals.cols) });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
107
121
|
function fireVisuals(internals, interactions) {
|
|
108
122
|
if (internals.generateVisual === undefined)
|
|
109
123
|
return;
|
|
110
124
|
const gen = internals.generateVisual;
|
|
125
|
+
const generation = ++internals.visualGeneration;
|
|
111
126
|
for (const interaction of interactions) {
|
|
127
|
+
const generationCols = internals.cols;
|
|
112
128
|
internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'loading' });
|
|
113
|
-
gen(interaction).then((r) => {
|
|
114
|
-
if (!internals.mounted)
|
|
129
|
+
gen(interaction, generationCols).then((r) => {
|
|
130
|
+
if (!internals.mounted || generation !== internals.visualGeneration)
|
|
115
131
|
return;
|
|
116
132
|
if (!internals.state.interactions.some((x) => x.id === interaction.id))
|
|
117
133
|
return;
|
|
118
134
|
internals.state.visuals.set(interaction.id, r.ok
|
|
119
|
-
? { questionId: interaction.id, content: r.ansi, status: 'ready' }
|
|
135
|
+
? { questionId: interaction.id, content: internals.cols === generationCols ? r.ansi : renderVisualMarkdown(r.markdown, internals.cols), markdown: r.markdown, status: 'ready' }
|
|
120
136
|
: { questionId: interaction.id, content: '', status: 'error' });
|
|
121
137
|
internals.callbacks.onDirty?.();
|
|
122
138
|
}).catch(() => {
|
|
123
|
-
if (!internals.mounted)
|
|
139
|
+
if (!internals.mounted || generation !== internals.visualGeneration)
|
|
124
140
|
return;
|
|
125
141
|
if (!internals.state.interactions.some((x) => x.id === interaction.id))
|
|
126
142
|
return;
|
|
@@ -130,14 +146,23 @@ function fireVisuals(internals, interactions) {
|
|
|
130
146
|
}
|
|
131
147
|
}
|
|
132
148
|
export function mountPanel(opts) {
|
|
149
|
+
const followUpAvailable = opts.followUpAvailable !== false
|
|
150
|
+
&& opts.onFollowUpRequest !== undefined
|
|
151
|
+
&& opts.onFollowUpCancel !== undefined;
|
|
133
152
|
const internals = {
|
|
134
|
-
state: buildInitialState(opts.deck),
|
|
153
|
+
state: buildInitialState(opts.deck, opts.onEditorRequest !== undefined, followUpAvailable),
|
|
135
154
|
cols: opts.cols,
|
|
136
155
|
rows: opts.rows,
|
|
137
156
|
mounted: true,
|
|
138
157
|
generateVisual: opts.generateVisual,
|
|
158
|
+
visualGeneration: 0,
|
|
139
159
|
progressPath: opts.progressPath,
|
|
140
|
-
|
|
160
|
+
followUpAvailable,
|
|
161
|
+
callbacks: {
|
|
162
|
+
onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit,
|
|
163
|
+
onDirty: opts.onDirty, onEditorRequest: opts.onEditorRequest,
|
|
164
|
+
onFollowUpRequest: opts.onFollowUpRequest, onFollowUpCancel: opts.onFollowUpCancel,
|
|
165
|
+
},
|
|
141
166
|
};
|
|
142
167
|
assignShortcuts(internals.state.interactions);
|
|
143
168
|
rebindPersist(internals);
|
|
@@ -156,6 +181,10 @@ export function mountPanel(opts) {
|
|
|
156
181
|
handleKey(input, key) {
|
|
157
182
|
if (!internals.mounted)
|
|
158
183
|
return;
|
|
184
|
+
if (key.ctrl && input === 'o' && internals.state.inputMode !== null && internals.callbacks.onEditorRequest !== undefined) {
|
|
185
|
+
internals.callbacks.onEditorRequest();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
159
188
|
const onAutoComplete = () => {
|
|
160
189
|
const responses = collectResponses(internals.state);
|
|
161
190
|
if (internals.progressPath !== undefined) {
|
|
@@ -174,6 +203,9 @@ export function mountPanel(opts) {
|
|
|
174
203
|
else {
|
|
175
204
|
internals.callbacks.onExit?.();
|
|
176
205
|
}
|
|
206
|
+
}, {
|
|
207
|
+
request: (question) => internals.callbacks.onFollowUpRequest?.(question),
|
|
208
|
+
cancel: () => internals.callbacks.onFollowUpCancel?.(),
|
|
177
209
|
});
|
|
178
210
|
// Pre-render clamp (input layer): keep scrollOffset within the current
|
|
179
211
|
// body's bounds so u/d stay responsive. The renderer itself is pure.
|
|
@@ -189,7 +221,9 @@ export function mountPanel(opts) {
|
|
|
189
221
|
handleResize(cols, rows) {
|
|
190
222
|
internals.cols = cols;
|
|
191
223
|
internals.rows = rows;
|
|
192
|
-
//
|
|
224
|
+
// Width changes reflow saved visual markdown, so clamp against the new
|
|
225
|
+
// content and geometry rather than the stale pre-resize wrapping.
|
|
226
|
+
rerenderVisuals(internals);
|
|
193
227
|
if (internals.state.phase === 'item-review') {
|
|
194
228
|
clampItemReviewScroll(internals.state, cols, rows);
|
|
195
229
|
}
|
|
@@ -204,7 +238,7 @@ export function mountPanel(opts) {
|
|
|
204
238
|
if (!internals.mounted)
|
|
205
239
|
return;
|
|
206
240
|
const prior = collectResponses(internals.state);
|
|
207
|
-
internals.state = buildInitialState(deck);
|
|
241
|
+
internals.state = buildInitialState(deck, internals.callbacks.onEditorRequest !== undefined, internals.followUpAvailable);
|
|
208
242
|
if (loadOpts !== undefined && loadOpts.progressPath !== undefined) {
|
|
209
243
|
internals.progressPath = loadOpts.progressPath;
|
|
210
244
|
}
|
|
@@ -227,6 +261,25 @@ export function mountPanel(opts) {
|
|
|
227
261
|
}
|
|
228
262
|
fireVisuals(internals, deck.interactions);
|
|
229
263
|
},
|
|
264
|
+
setFollowUpHandlers(available, onRequest, onCancel) {
|
|
265
|
+
if (!internals.mounted)
|
|
266
|
+
return;
|
|
267
|
+
internals.callbacks.onFollowUpRequest = onRequest;
|
|
268
|
+
internals.callbacks.onFollowUpCancel = onCancel;
|
|
269
|
+
const enabled = available && onRequest !== undefined && onCancel !== undefined;
|
|
270
|
+
internals.followUpAvailable = enabled;
|
|
271
|
+
internals.state.followUpAvailable = enabled;
|
|
272
|
+
if (!enabled) {
|
|
273
|
+
internals.state.followUp = undefined;
|
|
274
|
+
if (internals.state.inputMode?.kind === 'follow-up')
|
|
275
|
+
internals.state.inputMode = null;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
setFollowUpState(state) {
|
|
279
|
+
if (!internals.mounted || !internals.state.followUpAvailable)
|
|
280
|
+
return;
|
|
281
|
+
internals.state.followUp = state;
|
|
282
|
+
},
|
|
230
283
|
canAcceptHostKeys() {
|
|
231
284
|
if (!internals.mounted)
|
|
232
285
|
return false;
|
|
@@ -264,24 +317,11 @@ export function mountPanel(opts) {
|
|
|
264
317
|
* returned `deck` is the one actually answered (post-reload).
|
|
265
318
|
*/
|
|
266
319
|
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
320
|
setupTerminal();
|
|
278
321
|
const term = getTerminalSize();
|
|
279
322
|
const cols = opts.cols ?? term.cols;
|
|
280
323
|
const rows = opts.rows ?? term.rows;
|
|
281
|
-
const generateVisual = opts.generateVisual ??
|
|
282
|
-
(opts.sessionId !== undefined
|
|
283
|
-
? (interaction) => defaultGenerateVisual(interaction, conversationContext)
|
|
284
|
-
: undefined);
|
|
324
|
+
const generateVisual = opts.generateVisual ?? (opts.sessionId === undefined ? undefined : visualGeneratorForConversationSession(opts.sessionId));
|
|
285
325
|
return new Promise((resolve) => {
|
|
286
326
|
let panel = null;
|
|
287
327
|
let prevFrameLocal = [];
|
|
@@ -369,6 +409,11 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
369
409
|
cols,
|
|
370
410
|
rows,
|
|
371
411
|
generateVisual,
|
|
412
|
+
onEditorRequest: () => {
|
|
413
|
+
const buffer = panel?.getInputBuffer();
|
|
414
|
+
if (buffer !== undefined)
|
|
415
|
+
runEditorEscapeHatch(buffer);
|
|
416
|
+
},
|
|
372
417
|
onProgress: (responses) => {
|
|
373
418
|
lastResponses = responses;
|
|
374
419
|
if (panel !== null)
|
|
@@ -444,68 +489,22 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
444
489
|
return;
|
|
445
490
|
process.stdin.removeListener('data', onData);
|
|
446
491
|
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.
|
|
492
|
+
let result = { text: buffer };
|
|
457
493
|
try {
|
|
458
494
|
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)}`;
|
|
495
|
+
result = editBufferInEditor(buffer);
|
|
489
496
|
}
|
|
490
497
|
finally {
|
|
491
|
-
try {
|
|
492
|
-
unlinkSync(tmpFile);
|
|
493
|
-
}
|
|
494
|
-
catch { /* best-effort cleanup — may never have been created */ }
|
|
495
498
|
setupTerminal();
|
|
496
499
|
process.stdin.on('data', onData);
|
|
497
500
|
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);
|
|
501
|
+
panel.setInputBuffer(result.text);
|
|
503
502
|
const { cols: c, rows: r } = getTerminalSize();
|
|
504
503
|
const lines = panel.handleResize(c, r);
|
|
505
|
-
if (
|
|
504
|
+
if (result.error !== undefined) {
|
|
506
505
|
while (lines.length < r)
|
|
507
506
|
lines.push('');
|
|
508
|
-
lines[r - 1] = ` ${
|
|
507
|
+
lines[r - 1] = ` ${result.error}`.slice(0, c);
|
|
509
508
|
}
|
|
510
509
|
prevFrameLocal = [];
|
|
511
510
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -583,13 +582,6 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
583
582
|
}
|
|
584
583
|
return;
|
|
585
584
|
}
|
|
586
|
-
if (key.ctrl && inp === 'o') {
|
|
587
|
-
const buf = panel.getInputBuffer();
|
|
588
|
-
if (buf !== undefined) {
|
|
589
|
-
runEditorEscapeHatch(buf);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
585
|
// 'w' hands the current interaction off to the browser. Gated on
|
|
594
586
|
// canAcceptHostKeys() (not mid comment/freetext) so it never shadows a
|
|
595
587
|
// literal 'w' typed into a buffer; 'w' is also reserved from option
|
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
|
+
}
|
package/dist/tui/render.js
CHANGED
|
@@ -134,30 +134,46 @@ function buildItemReviewLayout(state, cols, rows) {
|
|
|
134
134
|
// and live in the scrollable region so long content never overflows the fixed
|
|
135
135
|
// header — agents put rich prose in either field, so both must render markdown.
|
|
136
136
|
const bodyLines = [];
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
if (state.bodyMode === 'question') {
|
|
138
|
+
if (interaction.subtitle) {
|
|
139
|
+
bodyLines.push('');
|
|
140
|
+
for (const line of renderMarkdown(interaction.subtitle, maxW)) {
|
|
141
|
+
bodyLines.push(` ${line}`);
|
|
142
|
+
}
|
|
141
143
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
if (interaction.body) {
|
|
145
|
+
bodyLines.push('');
|
|
146
|
+
for (const line of renderMarkdown(interaction.body, maxW)) {
|
|
147
|
+
bodyLines.push(` ${line}`);
|
|
148
|
+
}
|
|
147
149
|
}
|
|
148
150
|
}
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
if (state.bodyMode === 'visual') {
|
|
152
|
+
if (visual?.status === 'ready') {
|
|
153
|
+
bodyLines.push('');
|
|
154
|
+
bodyLines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
|
|
155
|
+
for (const vl of visual.content.split('\n')) {
|
|
156
|
+
bodyLines.push(` ${vl}`);
|
|
157
|
+
}
|
|
158
|
+
bodyLines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
159
|
+
}
|
|
160
|
+
if (state.followUp !== undefined && state.followUp.status !== 'idle') {
|
|
161
|
+
bodyLines.push('');
|
|
162
|
+
bodyLines.push(` ${DIM}── follow-up ${hline(Math.max(0, maxW - 14))}${RESET}`);
|
|
163
|
+
if (state.followUp.status === 'running')
|
|
164
|
+
bodyLines.push(` ${DIM}consulting…${RESET}`);
|
|
165
|
+
else if (state.followUp.status === 'ready') {
|
|
166
|
+
for (const line of renderMarkdown(state.followUp.markdown, maxW))
|
|
167
|
+
bodyLines.push(` ${line}`);
|
|
168
|
+
}
|
|
169
|
+
else
|
|
170
|
+
bodyLines.push(` ${YELLOW}${state.followUp.error}${RESET}`);
|
|
154
171
|
}
|
|
155
|
-
bodyLines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
156
172
|
}
|
|
157
173
|
// Post-body: visual status hint, input buffer or actions, footer (always visible)
|
|
158
174
|
const postLines = [];
|
|
159
175
|
postLines.push('');
|
|
160
|
-
if (visual) {
|
|
176
|
+
if (state.bodyMode === 'visual' && visual) {
|
|
161
177
|
if (visual.status === 'loading') {
|
|
162
178
|
postLines.push(` ${DIM}loading context...${RESET}`);
|
|
163
179
|
postLines.push('');
|
|
@@ -166,10 +182,6 @@ function buildItemReviewLayout(state, cols, rows) {
|
|
|
166
182
|
postLines.push(` ${YELLOW}visual context unavailable${RESET}`);
|
|
167
183
|
postLines.push('');
|
|
168
184
|
}
|
|
169
|
-
else if (!state.detailExpanded) {
|
|
170
|
-
postLines.push(` ${DIM}[space] expand context${RESET}`);
|
|
171
|
-
postLines.push('');
|
|
172
|
-
}
|
|
173
185
|
}
|
|
174
186
|
if (state.inputMode) {
|
|
175
187
|
postLines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
@@ -204,7 +216,7 @@ function buildItemReviewLayout(state, cols, rows) {
|
|
|
204
216
|
postLines.push(attachedLine);
|
|
205
217
|
}
|
|
206
218
|
postLines.push('');
|
|
207
|
-
postLines.push(` ${DIM}enter${RESET} submit ${DIM}^J/⌥⏎${RESET} newline ${DIM}^O${RESET} editor ${DIM}esc${RESET} cancel`);
|
|
219
|
+
postLines.push(` ${DIM}enter${RESET} submit ${DIM}^J/⌥⏎${RESET} newline${state.editorAvailable ? ` ${DIM}^O${RESET} editor` : ''} ${DIM}esc${RESET} cancel`);
|
|
208
220
|
}
|
|
209
221
|
else {
|
|
210
222
|
postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
|
|
@@ -236,6 +248,7 @@ function buildItemReviewLayout(state, cols, rows) {
|
|
|
236
248
|
export function clampItemReviewScroll(state, cols, rows) {
|
|
237
249
|
const { maxScroll } = buildItemReviewLayout(state, cols, rows);
|
|
238
250
|
state.scrollOffset = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
|
|
251
|
+
state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
|
|
239
252
|
}
|
|
240
253
|
export function renderItemReview(state, cols, rows) {
|
|
241
254
|
const { interaction, preLines, bodyLines, postLines, maxW, bodyHeight, maxScroll, overflows } = buildItemReviewLayout(state, cols, rows);
|
|
@@ -262,18 +275,22 @@ export function renderItemReview(state, cols, rows) {
|
|
|
262
275
|
`${DIM}n/p${RESET} prev/next`,
|
|
263
276
|
`${DIM}space${RESET} toggle`,
|
|
264
277
|
`${DIM}enter${RESET} confirm`,
|
|
278
|
+
`${DIM}shift-tab${RESET} ${state.bodyMode === 'question' ? 'visual' : 'question'}`,
|
|
265
279
|
`${DIM}q${RESET} overview`,
|
|
266
280
|
]
|
|
267
281
|
: [
|
|
268
282
|
`${DIM}n/p${RESET} prev/next`,
|
|
269
|
-
`${DIM}
|
|
283
|
+
`${DIM}shift-tab${RESET} ${state.bodyMode === 'question' ? 'visual' : 'question'}`,
|
|
270
284
|
`${DIM}q${RESET} overview`,
|
|
271
285
|
];
|
|
272
286
|
if (overflows) {
|
|
273
287
|
footerParts.unshift(state.inputMode ? `${DIM}pgup/pgdn${RESET} scroll` : `${DIM}u/d${RESET} scroll`);
|
|
274
288
|
}
|
|
275
|
-
if (state.inputMode === null)
|
|
289
|
+
if (state.inputMode === null) {
|
|
290
|
+
if (state.followUpAvailable)
|
|
291
|
+
footerParts.push(`${DIM}?${RESET} follow-up`);
|
|
276
292
|
footerParts.push(`${DIM}w${RESET} browser`);
|
|
293
|
+
}
|
|
277
294
|
const footer = ` ${footerParts.join(' ')}`;
|
|
278
295
|
// Assemble — pad to fill rows so post-body sits at the bottom
|
|
279
296
|
const lines = [...preLines, ...visibleBody, ...postLines];
|
package/dist/tui/terminal.d.ts
CHANGED
package/dist/tui/terminal.js
CHANGED
|
@@ -17,6 +17,7 @@ function emptyKey() {
|
|
|
17
17
|
ctrl: false,
|
|
18
18
|
meta: false,
|
|
19
19
|
tab: false,
|
|
20
|
+
backTab: false,
|
|
20
21
|
backspace: false,
|
|
21
22
|
};
|
|
22
23
|
}
|
|
@@ -69,6 +70,10 @@ export function parseKeypress(data) {
|
|
|
69
70
|
key.del = true;
|
|
70
71
|
return { input: '', key };
|
|
71
72
|
}
|
|
73
|
+
if (str === '\x1b[Z') {
|
|
74
|
+
key.backTab = true;
|
|
75
|
+
return { input: '', key };
|
|
76
|
+
}
|
|
72
77
|
// Alt+Enter inserts a newline in freetext (distinct from Enter=submit). Must
|
|
73
78
|
// precede the bare-ESC and meta-backspace checks so the two-byte sequence
|
|
74
79
|
// isn't swallowed as a lone escape.
|