@crouton-kit/humanloop 0.3.1 → 0.3.4
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.js +4 -2
- package/dist/cli.js +83 -3
- package/dist/render/termrender.js +24 -3
- package/dist/tui/app.d.ts +7 -0
- package/dist/tui/app.js +67 -3
- package/dist/tui/input.js +19 -1
- package/dist/tui/terminal.d.ts +1 -0
- package/dist/tui/terminal.js +8 -0
- package/dist/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -55,13 +55,15 @@ export async function ask(deck, opts = {}) {
|
|
|
55
55
|
const dir = opts.dir ?? managedDir();
|
|
56
56
|
mkdirSync(dir, { recursive: true });
|
|
57
57
|
atomicWriteJson(deckPath(dir), deck);
|
|
58
|
-
const { responses, completedAt, responsePath } = await resolveInteractionDir(dir, deck, {
|
|
58
|
+
const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
|
|
59
59
|
sessionId: opts.sessionId,
|
|
60
60
|
cols: opts.cols,
|
|
61
61
|
rows: opts.rows,
|
|
62
62
|
});
|
|
63
63
|
return {
|
|
64
|
-
|
|
64
|
+
// `answeredDeck` === `deck` unless an agent ran `hl deck update`
|
|
65
|
+
// mid-flight; the summary must describe the questions actually answered.
|
|
66
|
+
summary: buildSummary(answeredDeck, responses),
|
|
65
67
|
responsePath,
|
|
66
68
|
schema: RESPONSE_SCHEMA_ID,
|
|
67
69
|
responses,
|
package/dist/cli.js
CHANGED
|
@@ -313,7 +313,17 @@ program
|
|
|
313
313
|
.helpOption('-h, --help', 'Show help')
|
|
314
314
|
.addHelpCommand(false);
|
|
315
315
|
// ── deck ──────────────────────────────────────────────────────────────────────
|
|
316
|
-
const deckCmd = program.command('deck').description('Write questions, get answers from the human
|
|
316
|
+
const deckCmd = program.command('deck').description('Write questions, get answers from the human.\n' +
|
|
317
|
+
'\n' +
|
|
318
|
+
'Children:\n' +
|
|
319
|
+
' hl deck ask — spawn the decisions TUI, return a job handle | use when: posing material decisions\n' +
|
|
320
|
+
' hl deck update — replace the deck of a LIVE ask job in place | use when: the questions changed after ask\n' +
|
|
321
|
+
' hl deck validate — preflight a deck object, no side effects | use when: checking a deck before ask\n' +
|
|
322
|
+
'\n' +
|
|
323
|
+
'A `deck update` rewrites the live job\'s deck.json; the TUI pane the\n' +
|
|
324
|
+
'human is looking at reloads it automatically within ~1s (answers whose\n' +
|
|
325
|
+
'interaction ids still exist are kept). Read this leaf\'s -h before calling\n' +
|
|
326
|
+
'it — it mutates a session a human is actively in.');
|
|
317
327
|
deckCmd
|
|
318
328
|
.command('ask')
|
|
319
329
|
.description('Kickoff: spawn the decisions TUI and return immediately.\n' +
|
|
@@ -324,7 +334,9 @@ deckCmd
|
|
|
324
334
|
'\n' +
|
|
325
335
|
'Effects: writes <dir>/deck.json, <dir>/progress.json (live),\n' +
|
|
326
336
|
' <dir>/response.json (on finish), <dir>/job.log (JSONL).\n' +
|
|
327
|
-
' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n'
|
|
337
|
+
' Spawns TUI detached in a tmux pane when tmux=true and $TMUX set.\n' +
|
|
338
|
+
' While the job is live the TUI watches <dir>/deck.json: a later\n' +
|
|
339
|
+
' `hl deck update` rewrites it and the pane reloads automatically.\n')
|
|
328
340
|
.helpOption('-h, --help', 'Show help')
|
|
329
341
|
.action(async () => {
|
|
330
342
|
const input = parseStdinJson();
|
|
@@ -407,7 +419,7 @@ deckCmd
|
|
|
407
419
|
process.stdout.write(JSON.stringify({
|
|
408
420
|
job_id: jobId,
|
|
409
421
|
dir,
|
|
410
|
-
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes.`,
|
|
422
|
+
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes. If the questions change before they answer, pipe {"job_id":"${jobId}","deck":{...}} to hl deck update — the pane reloads automatically.`,
|
|
411
423
|
}) + '\n');
|
|
412
424
|
process.exit(0);
|
|
413
425
|
}
|
|
@@ -435,6 +447,74 @@ deckCmd
|
|
|
435
447
|
});
|
|
436
448
|
}
|
|
437
449
|
});
|
|
450
|
+
deckCmd
|
|
451
|
+
.command('update')
|
|
452
|
+
.description('Replace the deck of a LIVE ask job; the human\'s TUI pane reloads.\n' +
|
|
453
|
+
'\n' +
|
|
454
|
+
'stdin { job_id: string (required), deck: object (required) }\n' +
|
|
455
|
+
'stdout { ok: true, job_id: string, interactions: int, follow_up: string }\n' +
|
|
456
|
+
'\n' +
|
|
457
|
+
'The TUI watches deck.json and reloads within ~1s of this write. Answers\n' +
|
|
458
|
+
'whose interaction id still exists in the new deck are preserved; new or\n' +
|
|
459
|
+
'id-changed interactions appear unanswered. In-flight unsubmitted input\n' +
|
|
460
|
+
'(a comment being typed) is discarded on reload.\n' +
|
|
461
|
+
'\n' +
|
|
462
|
+
'Errors: job_not_found (no such job_id) | job_not_live (already\n' +
|
|
463
|
+
'done/failed/canceled — nothing to reload) | deck_invalid (deck rejected;\n' +
|
|
464
|
+
'the old deck stays in place, run hl deck validate first).\n' +
|
|
465
|
+
'\n' +
|
|
466
|
+
'Effects: atomically rewrites <dir>/deck.json; appends a deck_updated\n' +
|
|
467
|
+
'event to <dir>/job.log. No effect on response.json/progress.json.\n')
|
|
468
|
+
.helpOption('-h, --help', 'Show help')
|
|
469
|
+
.action(() => {
|
|
470
|
+
const input = parseStdinJson();
|
|
471
|
+
if (!input.job_id || typeof input.job_id !== 'string') {
|
|
472
|
+
emitError({ error: 'bad_input', message: 'job_id is required', field: 'job_id', next: 'Provide: {"job_id": "<id>", "deck": {...}}' });
|
|
473
|
+
}
|
|
474
|
+
if (!input.deck || typeof input.deck !== 'object') {
|
|
475
|
+
emitError({ error: 'bad_input', message: 'deck is required and must be an object', field: 'deck', next: "Run: echo '{\"kind\":\"deck\"}' | hl schema show" });
|
|
476
|
+
}
|
|
477
|
+
const dir = resolveJobDir(input.job_id);
|
|
478
|
+
if (!existsSync(dir) || !existsSync(deckPath(dir))) {
|
|
479
|
+
emitError({ error: 'job_not_found', message: `Job not found: ${input.job_id}`, next: 'Check the job_id returned by hl deck ask.' });
|
|
480
|
+
}
|
|
481
|
+
const state = detectJobState(dir);
|
|
482
|
+
if (state !== 'live') {
|
|
483
|
+
emitError({
|
|
484
|
+
error: 'job_not_live',
|
|
485
|
+
message: `Job is ${state}; its deck can no longer be reloaded.`,
|
|
486
|
+
received: state,
|
|
487
|
+
next: 'The human already finished. Start a fresh deck with hl deck ask.',
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
let deck;
|
|
491
|
+
try {
|
|
492
|
+
const _v = {};
|
|
493
|
+
void _v;
|
|
494
|
+
deck = validateDeck(input.deck);
|
|
495
|
+
}
|
|
496
|
+
catch (validationErr) {
|
|
497
|
+
emitError({
|
|
498
|
+
error: 'deck_invalid',
|
|
499
|
+
message: `deck validation failed: ${validationErr instanceof Error ? validationErr.message : String(validationErr)}`,
|
|
500
|
+
received: input.deck,
|
|
501
|
+
next: "The live deck is unchanged. Fix the deck, then: echo '{\"deck\":{...}}' | hl deck validate",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
atomicWriteJson(deckPath(dir), deck);
|
|
505
|
+
appendJobLog(dir, {
|
|
506
|
+
level: 'info', event: 'deck_updated',
|
|
507
|
+
message: `deck replaced (${deck.interactions.length} interaction(s)); pane reloads on next watch tick`,
|
|
508
|
+
data: { jobId: basename(dir), interactions: deck.interactions.length },
|
|
509
|
+
});
|
|
510
|
+
process.stdout.write(JSON.stringify({
|
|
511
|
+
ok: true,
|
|
512
|
+
job_id: basename(dir),
|
|
513
|
+
interactions: deck.interactions.length,
|
|
514
|
+
follow_up: `The pane reloads within ~1s. Still resolve with hl job result {"job_id":"${basename(dir)}","wait":true}.`,
|
|
515
|
+
}) + '\n');
|
|
516
|
+
process.exit(0);
|
|
517
|
+
});
|
|
438
518
|
deckCmd
|
|
439
519
|
.command('validate')
|
|
440
520
|
.description('Preflight deck validation — no side effects.\n' +
|
|
@@ -44,6 +44,22 @@ function binaryOk() {
|
|
|
44
44
|
return false;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
// Returns the termrender version installed in the managed venv (via
|
|
48
|
+
// importlib.metadata), or null if the venv is missing/broken or termrender is
|
|
49
|
+
// not installed. Used by ensureRenderer() to detect drift from the pin and
|
|
50
|
+
// trigger a reinstall — otherwise a venv provisioned at an older pin sticks
|
|
51
|
+
// forever (binaryOk passes for any working binary, regardless of version).
|
|
52
|
+
function installedVersion() {
|
|
53
|
+
if (!existsSync(VENV_PYTHON))
|
|
54
|
+
return null;
|
|
55
|
+
try {
|
|
56
|
+
const out = execFileSync(VENV_PYTHON, ['-c', 'import importlib.metadata as m; print(m.version("termrender"))'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
|
|
57
|
+
return out.trim() || null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
47
63
|
function uvAvailable() {
|
|
48
64
|
try {
|
|
49
65
|
execFileSync('uv', ['--version'], { stdio: 'pipe', timeout: 5000 });
|
|
@@ -70,7 +86,7 @@ export function ensureRenderer() {
|
|
|
70
86
|
rendererState = 'unavailable';
|
|
71
87
|
return;
|
|
72
88
|
}
|
|
73
|
-
if (binaryOk()) {
|
|
89
|
+
if (binaryOk() && installedVersion() === TERMRENDER_VERSION) {
|
|
74
90
|
rendererState = 'ready';
|
|
75
91
|
return;
|
|
76
92
|
}
|
|
@@ -81,7 +97,12 @@ export function ensureRenderer() {
|
|
|
81
97
|
return;
|
|
82
98
|
}
|
|
83
99
|
try {
|
|
84
|
-
|
|
100
|
+
// Skip venv creation on drift (venv exists with wrong termrender version)
|
|
101
|
+
// — `uv pip install` into the existing venv replaces it in place. Only
|
|
102
|
+
// create when the venv directory is genuinely absent.
|
|
103
|
+
if (!existsSync(VENV_DIR)) {
|
|
104
|
+
execFileSync('uv', ['venv', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
|
|
105
|
+
}
|
|
85
106
|
execFileSync('uv', ['pip', 'install', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
|
|
86
107
|
}
|
|
87
108
|
catch (err) {
|
|
@@ -89,7 +110,7 @@ export function ensureRenderer() {
|
|
|
89
110
|
rendererState = 'unavailable';
|
|
90
111
|
return;
|
|
91
112
|
}
|
|
92
|
-
rendererState = binaryOk() ? 'ready' : 'unavailable';
|
|
113
|
+
rendererState = (binaryOk() && installedVersion() === TERMRENDER_VERSION) ? 'ready' : 'unavailable';
|
|
93
114
|
if (rendererState === 'unavailable') {
|
|
94
115
|
process.stderr.write('[hl] termrender install completed but health check failed; using plaintext fallback\n');
|
|
95
116
|
}
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -18,11 +18,18 @@ export interface ResolveDirOpts {
|
|
|
18
18
|
* with skips) write `<dir>/response.json` atomically and drop the progress
|
|
19
19
|
* file. A hard process kill leaves `progress.json` for a later resume —
|
|
20
20
|
* `tryResume` (unchanged logic) reads the new dir-derived path.
|
|
21
|
+
*
|
|
22
|
+
* While the panel is mounted, `<dir>/deck.json` is polled for changes (an
|
|
23
|
+
* agent calling `hl deck update`). On a valid rewrite the panel is reloaded
|
|
24
|
+
* in place via `loadDeck`, so the human's pane reflects the new questions
|
|
25
|
+
* without a respawn; answers for surviving interaction ids are kept. The
|
|
26
|
+
* returned `deck` is the one actually answered (post-reload).
|
|
21
27
|
*/
|
|
22
28
|
export declare function resolveInteractionDir(dir: string, deck: Deck, opts?: ResolveDirOpts): Promise<{
|
|
23
29
|
responses: InteractionResponse[];
|
|
24
30
|
completedAt: string;
|
|
25
31
|
responsePath: string;
|
|
32
|
+
deck: Deck;
|
|
26
33
|
}>;
|
|
27
34
|
export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<{
|
|
28
35
|
responses: InteractionResponse[];
|
package/dist/tui/app.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
1
|
+
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
2
2
|
import { dirname, resolve as resolvePath } from 'node:path';
|
|
3
3
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
|
|
4
4
|
import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
|
|
@@ -6,7 +6,7 @@ import { handleKeypress, assignShortcuts } from './input.js';
|
|
|
6
6
|
import { readConversation } from '../conversation/reader.js';
|
|
7
7
|
import { defaultGenerateVisual } from '../visuals/generate.js';
|
|
8
8
|
import { validateDeck } from '../inbox/deck-schema.js';
|
|
9
|
-
import { progressPath as progressPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
9
|
+
import { progressPath as progressPathFor, deckPath as deckPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
10
10
|
/** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
|
|
11
11
|
* Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
|
|
12
12
|
* with sisyphus). Kept exported for back-compat. */
|
|
@@ -179,6 +179,11 @@ export function mountPanel(opts) {
|
|
|
179
179
|
return false;
|
|
180
180
|
return internals.state.inputMode === null;
|
|
181
181
|
},
|
|
182
|
+
atDeckTop() {
|
|
183
|
+
if (!internals.mounted)
|
|
184
|
+
return true;
|
|
185
|
+
return internals.state.phase === 'overview' && internals.state.inputMode === null;
|
|
186
|
+
},
|
|
182
187
|
};
|
|
183
188
|
}
|
|
184
189
|
/**
|
|
@@ -187,6 +192,12 @@ export function mountPanel(opts) {
|
|
|
187
192
|
* with skips) write `<dir>/response.json` atomically and drop the progress
|
|
188
193
|
* file. A hard process kill leaves `progress.json` for a later resume —
|
|
189
194
|
* `tryResume` (unchanged logic) reads the new dir-derived path.
|
|
195
|
+
*
|
|
196
|
+
* While the panel is mounted, `<dir>/deck.json` is polled for changes (an
|
|
197
|
+
* agent calling `hl deck update`). On a valid rewrite the panel is reloaded
|
|
198
|
+
* in place via `loadDeck`, so the human's pane reflects the new questions
|
|
199
|
+
* without a respawn; answers for surviving interaction ids are kept. The
|
|
200
|
+
* returned `deck` is the one actually answered (post-reload).
|
|
190
201
|
*/
|
|
191
202
|
export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
192
203
|
let conversationContext = '';
|
|
@@ -212,6 +223,12 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
212
223
|
let prevFrameLocal = [];
|
|
213
224
|
let lastResponses = [];
|
|
214
225
|
let onData;
|
|
226
|
+
// The deck the human is actually answering. An agent may replace it
|
|
227
|
+
// mid-flight via `hl deck update` (atomic deck.json rewrite); the poller
|
|
228
|
+
// below reloads the panel in place and tracks the live deck here so the
|
|
229
|
+
// returned envelope/summary describes what was answered, not the kickoff.
|
|
230
|
+
let currentDeck = deck;
|
|
231
|
+
let deckWatch = null;
|
|
215
232
|
const flushHost = (lines) => {
|
|
216
233
|
const { rows: currentRows } = getTerminalSize();
|
|
217
234
|
const { writes, nextPrevFrame } = diffFrame(prevFrameLocal, lines, currentRows);
|
|
@@ -222,6 +239,10 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
222
239
|
prevFrameLocal = nextPrevFrame;
|
|
223
240
|
};
|
|
224
241
|
const finalize = (responses) => {
|
|
242
|
+
if (deckWatch !== null) {
|
|
243
|
+
clearInterval(deckWatch);
|
|
244
|
+
deckWatch = null;
|
|
245
|
+
}
|
|
225
246
|
restoreTerminal();
|
|
226
247
|
process.stdin.removeListener('data', onData);
|
|
227
248
|
panel?.unmount();
|
|
@@ -229,7 +250,7 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
229
250
|
// Resolved supersedes in-progress: write response.json, drop progress.json.
|
|
230
251
|
const rp = writeResponse(dir, responses, completedAt);
|
|
231
252
|
clearProgress(dir);
|
|
232
|
-
resolve({ responses, completedAt, responsePath: rp });
|
|
253
|
+
resolve({ responses, completedAt, responsePath: rp, deck: currentDeck });
|
|
233
254
|
};
|
|
234
255
|
panel = mountPanel({
|
|
235
256
|
deck,
|
|
@@ -248,6 +269,49 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
248
269
|
},
|
|
249
270
|
});
|
|
250
271
|
flushHost(panel.render());
|
|
272
|
+
// ── Live deck reload ──────────────────────────────────────────────────
|
|
273
|
+
// Poll deck.json mtime (cheap stat; full read only on change). atomicWrite
|
|
274
|
+
// does write-tmp + rename, so stat/read always see a whole file — no
|
|
275
|
+
// fs.watch rename flakiness. The TUI never writes deck.json, so there is
|
|
276
|
+
// no feedback loop. A structurally identical rewrite is ignored so a
|
|
277
|
+
// no-op touch never disrupts the human mid-answer.
|
|
278
|
+
const deckFile = deckPathFor(dir);
|
|
279
|
+
const deckMtime = () => {
|
|
280
|
+
try {
|
|
281
|
+
return statSync(deckFile).mtimeMs;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
let lastDeckMtime = deckMtime();
|
|
288
|
+
let lastDeckJson = JSON.stringify(currentDeck);
|
|
289
|
+
deckWatch = setInterval(() => {
|
|
290
|
+
if (panel === null)
|
|
291
|
+
return;
|
|
292
|
+
const m = deckMtime();
|
|
293
|
+
if (m === 0 || m === lastDeckMtime)
|
|
294
|
+
return;
|
|
295
|
+
lastDeckMtime = m;
|
|
296
|
+
let nextDeck;
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(readFileSync(deckFile, 'utf8'));
|
|
299
|
+
nextDeck = validateDeck(parsed);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
// Mid-rename, invalid, or rejected by schema: keep the live deck,
|
|
303
|
+
// retry on the next tick. `hl deck update` validates before writing,
|
|
304
|
+
// so a persistently bad file is an out-of-band edit, not our concern.
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const nextJson = JSON.stringify(nextDeck);
|
|
308
|
+
if (nextJson === lastDeckJson)
|
|
309
|
+
return; // touch / identical content
|
|
310
|
+
lastDeckJson = nextJson;
|
|
311
|
+
currentDeck = nextDeck;
|
|
312
|
+
panel.loadDeck(nextDeck, { progressPath: progressPathFor(dir) });
|
|
313
|
+
flushHost(panel.render());
|
|
314
|
+
}, 500);
|
|
251
315
|
onData = (data) => {
|
|
252
316
|
const { input: inp, key } = parseKeypress(data);
|
|
253
317
|
panel.handleKey(inp, key);
|
package/dist/tui/input.js
CHANGED
|
@@ -117,7 +117,8 @@ function handleItemReview(input, key, state, render) {
|
|
|
117
117
|
render();
|
|
118
118
|
return;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
// q / Esc step back to the deck overview (one level up from a card).
|
|
121
|
+
if (input === 'q' || key.escape) {
|
|
121
122
|
state.phase = 'overview';
|
|
122
123
|
render();
|
|
123
124
|
return;
|
|
@@ -261,6 +262,11 @@ function handleInputMode(input, key, state, render) {
|
|
|
261
262
|
render();
|
|
262
263
|
return;
|
|
263
264
|
}
|
|
265
|
+
if (key.backspace && key.meta) {
|
|
266
|
+
mode.buffer = deleteWordBack(mode.buffer);
|
|
267
|
+
render();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
264
270
|
if (key.backspace) {
|
|
265
271
|
const chars = [...mode.buffer];
|
|
266
272
|
chars.pop();
|
|
@@ -277,11 +283,23 @@ function handleInputMode(input, key, state, render) {
|
|
|
277
283
|
render();
|
|
278
284
|
}
|
|
279
285
|
}
|
|
286
|
+
function deleteWordBack(buffer) {
|
|
287
|
+
const chars = [...buffer];
|
|
288
|
+
while (chars.length > 0 && /\s/.test(chars[chars.length - 1]))
|
|
289
|
+
chars.pop();
|
|
290
|
+
while (chars.length > 0 && !/\s/.test(chars[chars.length - 1]))
|
|
291
|
+
chars.pop();
|
|
292
|
+
return chars.join('');
|
|
293
|
+
}
|
|
280
294
|
// ── Final ────────────────────────────────────────────────────────────────────
|
|
281
295
|
function handleFinal(input, key, state, render, exit) {
|
|
282
296
|
if (key.return) {
|
|
283
297
|
exit();
|
|
284
298
|
}
|
|
299
|
+
else if (key.escape) {
|
|
300
|
+
state.phase = 'overview';
|
|
301
|
+
render();
|
|
302
|
+
}
|
|
285
303
|
else if (input === 'p') {
|
|
286
304
|
state.phase = 'item-review';
|
|
287
305
|
state.currentIndex = state.interactions.length - 1;
|
package/dist/tui/terminal.d.ts
CHANGED
package/dist/tui/terminal.js
CHANGED
|
@@ -5,6 +5,7 @@ function emptyKey() {
|
|
|
5
5
|
return: false,
|
|
6
6
|
escape: false,
|
|
7
7
|
ctrl: false,
|
|
8
|
+
meta: false,
|
|
8
9
|
tab: false,
|
|
9
10
|
backspace: false,
|
|
10
11
|
};
|
|
@@ -24,6 +25,13 @@ export function parseKeypress(data) {
|
|
|
24
25
|
key.return = true;
|
|
25
26
|
return { input: '', key };
|
|
26
27
|
}
|
|
28
|
+
// Alt+Backspace: terminals send ESC followed by DEL/BS. Must precede the
|
|
29
|
+
// bare-ESC check so the two-byte sequence isn't swallowed as plain escape.
|
|
30
|
+
if (str === '\x1b\x7f' || str === '\x1b\b') {
|
|
31
|
+
key.meta = true;
|
|
32
|
+
key.backspace = true;
|
|
33
|
+
return { input: '', key };
|
|
34
|
+
}
|
|
27
35
|
if (str === '\x1b') {
|
|
28
36
|
key.escape = true;
|
|
29
37
|
return { input: '', key };
|
package/dist/types.d.ts
CHANGED
|
@@ -156,4 +156,10 @@ export interface MountedPanel {
|
|
|
156
156
|
progressPath?: string;
|
|
157
157
|
}): void;
|
|
158
158
|
canAcceptHostKeys(): boolean;
|
|
159
|
+
/**
|
|
160
|
+
* True when the deck is at its top level: overview phase with no active
|
|
161
|
+
* comment/freetext input. A host that owns mount/unmount uses this to decide
|
|
162
|
+
* whether Esc should step back inside the deck (false) or tear it down (true).
|
|
163
|
+
*/
|
|
164
|
+
atDeckTop(): boolean;
|
|
159
165
|
}
|