@crouton-kit/humanloop 0.3.22 → 0.3.23

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 CHANGED
@@ -7,52 +7,11 @@ import { pickFromInbox } from './inbox/tui.js';
7
7
  import { deckPath, atomicWriteJson, readJson, stampCanvasNode } from './inbox/convention.js';
8
8
  import { getTerminalSize } from './tui/terminal.js';
9
9
  import { notifyDeck } from './inbox/deck-factories.js';
10
+ import { buildSummary } from './summary.js';
10
11
  const RESPONSE_SCHEMA_ID = 'humanloop.response/v2';
11
12
  function managedDir() {
12
13
  return mkdtempSync(join(tmpdir(), 'hl-ix-'));
13
14
  }
14
- /**
15
- * Deterministic, no-LLM resolution summary — one line per answered
16
- * interaction: `"<title>: <option label>[ — <freetext>]"`.
17
- */
18
- function buildSummary(deck, responses) {
19
- const byId = new Map(responses.map((r) => [r.id, r]));
20
- const lines = [];
21
- for (const it of deck.interactions) {
22
- const r = byId.get(it.id);
23
- if (r === undefined)
24
- continue;
25
- const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
26
- let picked;
27
- if (r.selectedOptionIds !== undefined) {
28
- const oc = r.optionComments;
29
- const parts = r.selectedOptionIds
30
- .map((id) => it.options.find((o) => o.id === id))
31
- .filter((o) => o !== undefined)
32
- .map((o) => {
33
- const note = oc !== undefined ? oc[o.id] : undefined;
34
- return typeof note === 'string' && note.length > 0
35
- ? `${o.label} ("${note}")`
36
- : o.label;
37
- });
38
- picked = parts.length > 0 ? parts.join(', ') : undefined;
39
- }
40
- else if (r.selectedOptionId !== undefined) {
41
- picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
42
- }
43
- let val;
44
- if (picked !== undefined && ft !== undefined)
45
- val = `${picked} — ${ft}`;
46
- else if (picked !== undefined)
47
- val = picked;
48
- else if (ft !== undefined)
49
- val = ft;
50
- else
51
- val = '(skipped)';
52
- lines.push(`${it.title}: ${val}`);
53
- }
54
- return lines.join('\n');
55
- }
56
15
  /**
57
16
  * Resolve a deck against an interaction directory and return the resolution
58
17
  * envelope. Writes `<dir>/deck.json` (the request, per the convention) and,
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { display } from './surfaces/display.js';
12
12
  import { renderMarkdown, checkMarkdown } from './render/termrender.js';
13
13
  import { scanInbox } from './inbox/scan.js';
14
14
  import { deckPath, atomicWriteJson, readJson, responsePath, stampCanvasNode, } from './inbox/convention.js';
15
+ import { buildSummary } from './summary.js';
15
16
  // ── Version ───────────────────────────────────────────────────────────────────
16
17
  const HL_VERSION = '0.2.1';
17
18
  function emitError(err, exitCode = 1) {
@@ -898,8 +899,15 @@ function tryReadJobResult(dir, kind) {
898
899
  if (!raw)
899
900
  return null;
900
901
  const dk = readJson(deckPath(dir));
902
+ // Prefer the summary persisted at write time. Legacy response.json files
903
+ // (written before the summary field existed) lack it — rebuild it from the
904
+ // deck if it's still on disk, else fall back to ''.
905
+ let summary = typeof raw.summary === 'string' ? raw.summary : '';
906
+ if (summary === '' && dk) {
907
+ summary = buildSummary(dk, raw.responses);
908
+ }
901
909
  return {
902
- summary: '',
910
+ summary,
903
911
  responsePath: rp,
904
912
  schema: 'humanloop.response/v2',
905
913
  responses: raw.responses,
@@ -21,6 +21,6 @@ export declare function isClaimed(dir: string): boolean;
21
21
  export declare function stampCanvasNode(deck: Deck): void;
22
22
  export declare function atomicWriteJson(path: string, value: unknown): void;
23
23
  export declare function readJson<T>(path: string): T | null;
24
- export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string): string;
24
+ export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
25
25
  export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
26
26
  export declare function clearProgress(dir: string): void;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, statSync, writeFileSync, renameSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
2
2
  import { dirname } from 'path';
3
+ import { buildSummary } from '../summary.js';
3
4
  // ── Path helpers ──────────────────────────────────────────────────────────────
4
5
  export function deckPath(dir) {
5
6
  return `${dir}/deck.json`;
@@ -81,9 +82,15 @@ export function readJson(path) {
81
82
  }
82
83
  }
83
84
  // ── High-level write helpers ──────────────────────────────────────────────────
84
- export function writeResponse(dir, responses, completedAt) {
85
+ export function writeResponse(dir, responses, completedAt, deck) {
85
86
  const p = responsePath(dir);
86
- atomicWriteJson(p, { responses, completedAt });
87
+ // Persist the deterministic summary alongside the raw responses so
88
+ // `hl job result` can return a populated summary without re-deriving it (or
89
+ // silently emitting ''). When the deck is known at write time we compute it
90
+ // here; the deck is deterministic input so this never diverges from ask()'s
91
+ // envelope summary.
92
+ const summary = deck !== undefined ? buildSummary(deck, responses) : '';
93
+ atomicWriteJson(p, { responses, completedAt, summary });
87
94
  return p;
88
95
  }
89
96
  export function writeProgress(dir, responses) {
package/dist/inbox/tui.js CHANGED
@@ -1,16 +1,7 @@
1
- import stringWidth from 'string-width';
2
1
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize, } from '../tui/terminal.js';
3
2
  import { diffFrame } from '../tui/render.js';
3
+ import { RESET, BOLD, DIM, ITALIC, CYAN, RED, GRAY, YELLOW, truncateRow } from '../tui/ansi.js';
4
4
  // ── ANSI helpers (local to this module) ──────────────────────────────────────
5
- const ESC = '\x1b[';
6
- const RESET = `${ESC}0m`;
7
- const BOLD = `${ESC}1m`;
8
- const DIM = `${ESC}2m`;
9
- const ITALIC = `${ESC}3m`;
10
- const CYAN = `${ESC}36m`;
11
- const RED = `${ESC}31m`;
12
- const GRAY = `${ESC}90m`;
13
- const YELLOW = `${ESC}33m`;
14
5
  function ansiColor(text, color) {
15
6
  switch (color) {
16
7
  case 'gray': return `${GRAY}${text}${RESET}`;
@@ -44,26 +35,6 @@ export function formatTimeAgo(iso) {
44
35
  return `${minutes}m ago`;
45
36
  return 'just now';
46
37
  }
47
- // ── truncate (ported from sisyphus src/tui/lib/format.ts:19-37) ──────────────
48
- function truncate(text, max) {
49
- const clean = text.replace(/\n/g, ' ').replace(/✅/g, '✓').replace(/❌/g, '✗').replace(/\p{Emoji_Presentation}/gu, '');
50
- if (max < 4)
51
- return clean.slice(0, max);
52
- const w = stringWidth(clean);
53
- if (w <= max)
54
- return clean;
55
- let result = clean;
56
- while (stringWidth(result) > max - 1 && result.length > 0) {
57
- const cut = result.lastIndexOf(' ', result.length - 2);
58
- if (cut > max * 0.4) {
59
- result = result.slice(0, cut);
60
- }
61
- else {
62
- result = result.slice(0, result.length - 1);
63
- }
64
- }
65
- return result + '…';
66
- }
67
38
  // ── buildInboxLines ───────────────────────────────────────────────────────────
68
39
  export function buildInboxLines(items, width, selectedIndex) {
69
40
  const lines = [];
@@ -93,11 +64,11 @@ export function buildInboxLines(items, width, selectedIndex) {
93
64
  else {
94
65
  row += ' ';
95
66
  }
96
- row += `${BOLD}${truncate(titleText, maxTitle)}${RESET}`;
67
+ row += `${BOLD}${truncateRow(titleText, maxTitle)}${RESET}`;
97
68
  row += ` ${DIM}${blocked}${RESET}`;
98
69
  lines.push(row);
99
70
  if (item.subtitle) {
100
- lines.push(` ${DIM}${truncate(item.subtitle, contentWidth - 6)}${RESET}`);
71
+ lines.push(` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`);
101
72
  }
102
73
  }
103
74
  return lines;
@@ -0,0 +1,9 @@
1
+ import type { Deck, InteractionResponse } from './types.js';
2
+ /**
3
+ * Deterministic, no-LLM resolution summary — one line per answered
4
+ * interaction: `"<title>: <option label>[ — <freetext>]"`. Shared by `ask()`
5
+ * (envelope summary), `writeResponse` (persisted into response.json at write
6
+ * time), and `hl job result` (fallback rebuild for legacy response.json files
7
+ * written before the summary field existed).
8
+ */
9
+ export declare function buildSummary(deck: Deck, responses: InteractionResponse[]): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Deterministic, no-LLM resolution summary — one line per answered
3
+ * interaction: `"<title>: <option label>[ — <freetext>]"`. Shared by `ask()`
4
+ * (envelope summary), `writeResponse` (persisted into response.json at write
5
+ * time), and `hl job result` (fallback rebuild for legacy response.json files
6
+ * written before the summary field existed).
7
+ */
8
+ export function buildSummary(deck, responses) {
9
+ const byId = new Map(responses.map((r) => [r.id, r]));
10
+ const lines = [];
11
+ for (const it of deck.interactions) {
12
+ const r = byId.get(it.id);
13
+ if (r === undefined)
14
+ continue;
15
+ const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
16
+ let picked;
17
+ if (r.selectedOptionIds !== undefined) {
18
+ const oc = r.optionComments;
19
+ const parts = r.selectedOptionIds
20
+ .map((id) => it.options.find((o) => o.id === id))
21
+ .filter((o) => o !== undefined)
22
+ .map((o) => {
23
+ const note = oc !== undefined ? oc[o.id] : undefined;
24
+ return typeof note === 'string' && note.length > 0
25
+ ? `${o.label} ("${note}")`
26
+ : o.label;
27
+ });
28
+ picked = parts.length > 0 ? parts.join(', ') : undefined;
29
+ }
30
+ else if (r.selectedOptionId !== undefined) {
31
+ picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
32
+ }
33
+ let val;
34
+ if (picked !== undefined && ft !== undefined)
35
+ val = `${picked} — ${ft}`;
36
+ else if (picked !== undefined)
37
+ val = picked;
38
+ else if (ft !== undefined)
39
+ val = ft;
40
+ else
41
+ val = '(skipped)';
42
+ lines.push(`${it.title}: ${val}`);
43
+ }
44
+ return lines.join('\n');
45
+ }
@@ -0,0 +1,43 @@
1
+ export declare const ESC = "\u001B[";
2
+ export declare const RESET = "\u001B[0m";
3
+ export declare const BOLD = "\u001B[1m";
4
+ export declare const DIM = "\u001B[2m";
5
+ export declare const ITALIC = "\u001B[3m";
6
+ export declare const RED = "\u001B[31m";
7
+ export declare const GREEN = "\u001B[32m";
8
+ export declare const YELLOW = "\u001B[33m";
9
+ export declare const CYAN = "\u001B[36m";
10
+ export declare const GRAY = "\u001B[90m";
11
+ export declare const REVERSE = "\u001B[7m";
12
+ export declare function sanitize(text: string): string;
13
+ export declare function singleLine(text: string): string;
14
+ export declare function hline(width: number, char?: string): string;
15
+ export declare function padRight(text: string, width: number): string;
16
+ /** Char-based width truncation with a trailing ellipsis. The canonical deck
17
+ * renderer variant. */
18
+ export declare function truncate(text: string, maxWidth: number): string;
19
+ /** Word-aware truncation that also flattens newlines and strips decorative
20
+ * emoji — tuned for dense inbox rows (ported from sisyphus format.ts). Kept
21
+ * distinct from `truncate` because those two surfaces want different behavior. */
22
+ export declare function truncateRow(text: string, max: number): string;
23
+ /** Word-wrap on whitespace; splits on `\n` into separate paragraphs. */
24
+ export declare function wrap(text: string, maxWidth: number): string[];
25
+ /** Hard character wrap that ignores word boundaries; splits on `\n`. Used for
26
+ * free-form input buffers where every character (including runs without
27
+ * spaces) must stay on screen. */
28
+ export declare function hardWrap(text: string, maxWidth: number): string[];
29
+ /**
30
+ * Pad each non-empty line with leading spaces to horizontally center the
31
+ * `contentWidth`-wide block within `cols`. Wide terminals get breathing room;
32
+ * narrow panes skip centering. Empty lines stay empty so frame diffing keeps
33
+ * them as cheap no-ops.
34
+ */
35
+ export declare function centerHorizontal(lines: string[], cols: number, contentWidth: number): string[];
36
+ /**
37
+ * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
38
+ * escape sequences through untouched. Every line written to the terminal must
39
+ * fit within the columns, or it physically wraps onto the next row — which
40
+ * breaks diffFrame's one-logical-line-per-row model and strands spillover text
41
+ * on rows the differ believes are empty (so it never erases them).
42
+ */
43
+ export declare function clipLine(line: string, maxWidth: number): string;
@@ -0,0 +1,210 @@
1
+ import stringWidth from 'string-width';
2
+ // ── ANSI escape constants ─────────────────────────────────────────────────────
3
+ // The single home for these — both the deck renderer (render.ts) and the inbox
4
+ // picker (inbox/tui.ts) import from here rather than keeping their own copies.
5
+ export const ESC = '\x1b[';
6
+ export const RESET = `${ESC}0m`;
7
+ export const BOLD = `${ESC}1m`;
8
+ export const DIM = `${ESC}2m`;
9
+ export const ITALIC = `${ESC}3m`;
10
+ export const RED = `${ESC}31m`;
11
+ export const GREEN = `${ESC}32m`;
12
+ export const YELLOW = `${ESC}33m`;
13
+ export const CYAN = `${ESC}36m`;
14
+ export const GRAY = `${ESC}90m`;
15
+ export const REVERSE = `${ESC}7m`;
16
+ // ── Text helpers ──────────────────────────────────────────────────────────────
17
+ const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
18
+ export function sanitize(text) {
19
+ if (typeof text !== 'string')
20
+ return '';
21
+ return text.replace(CONTROL_CHARS_RE, '');
22
+ }
23
+ export function singleLine(text) {
24
+ return sanitize(text).replace(/\s+/g, ' ').trim();
25
+ }
26
+ export function hline(width, char = '─') {
27
+ if (width < 1)
28
+ return '';
29
+ return char.repeat(width);
30
+ }
31
+ export function padRight(text, width) {
32
+ const w = stringWidth(text);
33
+ if (w >= width)
34
+ return text;
35
+ return text + ' '.repeat(width - w);
36
+ }
37
+ /** Char-based width truncation with a trailing ellipsis. The canonical deck
38
+ * renderer variant. */
39
+ export function truncate(text, maxWidth) {
40
+ if (maxWidth < 1)
41
+ return '';
42
+ if (stringWidth(text) <= maxWidth)
43
+ return text;
44
+ const chars = [...text];
45
+ let w = 0;
46
+ let out = '';
47
+ for (const ch of chars) {
48
+ const cw = stringWidth(ch);
49
+ if (w + cw + 1 > maxWidth)
50
+ break;
51
+ out += ch;
52
+ w += cw;
53
+ }
54
+ return out + '…';
55
+ }
56
+ /** Word-aware truncation that also flattens newlines and strips decorative
57
+ * emoji — tuned for dense inbox rows (ported from sisyphus format.ts). Kept
58
+ * distinct from `truncate` because those two surfaces want different behavior. */
59
+ export function truncateRow(text, max) {
60
+ const clean = text.replace(/\n/g, ' ').replace(/✅/g, '✓').replace(/❌/g, '✗').replace(/\p{Emoji_Presentation}/gu, '');
61
+ if (max < 4)
62
+ return clean.slice(0, max);
63
+ const w = stringWidth(clean);
64
+ if (w <= max)
65
+ return clean;
66
+ let result = clean;
67
+ while (stringWidth(result) > max - 1 && result.length > 0) {
68
+ const cut = result.lastIndexOf(' ', result.length - 2);
69
+ if (cut > max * 0.4) {
70
+ result = result.slice(0, cut);
71
+ }
72
+ else {
73
+ result = result.slice(0, result.length - 1);
74
+ }
75
+ }
76
+ return result + '…';
77
+ }
78
+ function sliceByWidth(s, maxWidth) {
79
+ let w = 0;
80
+ let out = '';
81
+ for (const ch of s) {
82
+ const cw = stringWidth(ch);
83
+ if (w + cw > maxWidth)
84
+ break;
85
+ out += ch;
86
+ w += cw;
87
+ }
88
+ if (out === '' && s.length > 0)
89
+ out = [...s][0];
90
+ return out;
91
+ }
92
+ /** Word-wrap on whitespace; splits on `\n` into separate paragraphs. */
93
+ export function wrap(text, maxWidth) {
94
+ if (maxWidth < 1)
95
+ return [text];
96
+ const out = [];
97
+ const paragraphs = text.split('\n');
98
+ for (let p = 0; p < paragraphs.length; p++) {
99
+ const para = paragraphs[p];
100
+ if (para === '') {
101
+ out.push('');
102
+ continue;
103
+ }
104
+ const words = para.split(/[ \t]+/).filter(Boolean);
105
+ let current = '';
106
+ for (let word of words) {
107
+ while (stringWidth(word) > maxWidth) {
108
+ if (current) {
109
+ out.push(current);
110
+ current = '';
111
+ }
112
+ const piece = sliceByWidth(word, maxWidth);
113
+ out.push(piece);
114
+ word = word.slice(piece.length);
115
+ }
116
+ const candidate = current ? `${current} ${word}` : word;
117
+ if (stringWidth(candidate) <= maxWidth) {
118
+ current = candidate;
119
+ }
120
+ else {
121
+ if (current)
122
+ out.push(current);
123
+ current = word;
124
+ }
125
+ }
126
+ if (current)
127
+ out.push(current);
128
+ }
129
+ return out.length > 0 ? out : [''];
130
+ }
131
+ /** Hard character wrap that ignores word boundaries; splits on `\n`. Used for
132
+ * free-form input buffers where every character (including runs without
133
+ * spaces) must stay on screen. */
134
+ export function hardWrap(text, maxWidth) {
135
+ if (maxWidth < 1)
136
+ return [text];
137
+ const segments = text.split('\n');
138
+ const out = [];
139
+ for (const seg of segments) {
140
+ if (seg.length === 0) {
141
+ out.push('');
142
+ continue;
143
+ }
144
+ let current = '';
145
+ let currentW = 0;
146
+ for (const ch of [...seg]) {
147
+ const cw = stringWidth(ch);
148
+ if (currentW + cw > maxWidth) {
149
+ out.push(current);
150
+ current = ch;
151
+ currentW = cw;
152
+ }
153
+ else {
154
+ current += ch;
155
+ currentW += cw;
156
+ }
157
+ }
158
+ out.push(current);
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * Pad each non-empty line with leading spaces to horizontally center the
164
+ * `contentWidth`-wide block within `cols`. Wide terminals get breathing room;
165
+ * narrow panes skip centering. Empty lines stay empty so frame diffing keeps
166
+ * them as cheap no-ops.
167
+ */
168
+ export function centerHorizontal(lines, cols, contentWidth) {
169
+ const extraPad = Math.max(0, Math.floor((cols - contentWidth) / 2));
170
+ if (extraPad === 0)
171
+ return lines;
172
+ const pad = ' '.repeat(extraPad);
173
+ return lines.map((line) => (line === '' ? '' : pad + line));
174
+ }
175
+ /**
176
+ * ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
177
+ * escape sequences through untouched. Every line written to the terminal must
178
+ * fit within the columns, or it physically wraps onto the next row — which
179
+ * breaks diffFrame's one-logical-line-per-row model and strands spillover text
180
+ * on rows the differ believes are empty (so it never erases them).
181
+ */
182
+ export function clipLine(line, maxWidth) {
183
+ if (maxWidth < 1)
184
+ return '';
185
+ if (stringWidth(line) <= maxWidth)
186
+ return line; // string-width ignores ANSI
187
+ let out = '';
188
+ let w = 0;
189
+ let i = 0;
190
+ let sawAnsi = false;
191
+ while (i < line.length) {
192
+ if (line[i] === '\x1b') {
193
+ const m = /^\x1b\[[0-9;?]*[a-zA-Z]|^\x1b[@-_]/.exec(line.slice(i));
194
+ if (m !== null) {
195
+ out += m[0];
196
+ i += m[0].length;
197
+ sawAnsi = true;
198
+ continue;
199
+ }
200
+ }
201
+ const ch = String.fromCodePoint(line.codePointAt(i));
202
+ const cw = stringWidth(ch);
203
+ if (w + cw > maxWidth)
204
+ break;
205
+ out += ch;
206
+ w += cw;
207
+ i += ch.length;
208
+ }
209
+ return sawAnsi ? out + RESET : out;
210
+ }
package/dist/tui/app.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
2
- import { dirname, resolve as resolvePath } from 'node:path';
2
+ import { dirname, resolve as resolvePath, join } from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { tmpdir } from 'node:os';
5
+ import { randomUUID } from 'node:crypto';
3
6
  import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
4
- import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
7
+ import { diffFrame, renderOverview, renderItemReview, renderFinal, clampItemReviewScroll } from './render.js';
5
8
  import { handleKeypress, assignShortcuts } from './input.js';
6
9
  import { readConversation } from '../conversation/reader.js';
7
10
  import { defaultGenerateVisual } from '../visuals/generate.js';
@@ -113,12 +116,14 @@ function fireVisuals(internals, interactions) {
113
116
  internals.state.visuals.set(interaction.id, r.ok
114
117
  ? { questionId: interaction.id, content: r.ansi, status: 'ready' }
115
118
  : { questionId: interaction.id, content: '', status: 'error' });
119
+ internals.callbacks.onDirty?.();
116
120
  }).catch(() => {
117
121
  if (!internals.mounted)
118
122
  return;
119
123
  if (!internals.state.interactions.some((x) => x.id === interaction.id))
120
124
  return;
121
125
  internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'error' });
126
+ internals.callbacks.onDirty?.();
122
127
  });
123
128
  }
124
129
  }
@@ -130,7 +135,7 @@ export function mountPanel(opts) {
130
135
  mounted: true,
131
136
  generateVisual: opts.generateVisual,
132
137
  progressPath: opts.progressPath,
133
- callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit },
138
+ callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit, onDirty: opts.onDirty },
134
139
  };
135
140
  assignShortcuts(internals.state.interactions);
136
141
  rebindPersist(internals);
@@ -168,6 +173,11 @@ export function mountPanel(opts) {
168
173
  internals.callbacks.onExit?.();
169
174
  }
170
175
  });
176
+ // Pre-render clamp (input layer): keep scrollOffset within the current
177
+ // body's bounds so u/d stay responsive. The renderer itself is pure.
178
+ if (internals.state.phase === 'item-review') {
179
+ clampItemReviewScroll(internals.state, internals.cols, internals.rows);
180
+ }
171
181
  },
172
182
  render() {
173
183
  if (!internals.mounted)
@@ -177,6 +187,10 @@ export function mountPanel(opts) {
177
187
  handleResize(cols, rows) {
178
188
  internals.cols = cols;
179
189
  internals.rows = rows;
190
+ // New dimensions change the scroll bounds — re-clamp before laying out.
191
+ if (internals.state.phase === 'item-review') {
192
+ clampItemReviewScroll(internals.state, cols, rows);
193
+ }
180
194
  return renderLines();
181
195
  },
182
196
  unmount() {
@@ -208,6 +222,17 @@ export function mountPanel(opts) {
208
222
  return true;
209
223
  return internals.state.phase === 'overview' && internals.state.inputMode === null;
210
224
  },
225
+ getInputBuffer() {
226
+ if (!internals.mounted || internals.state.inputMode === null)
227
+ return undefined;
228
+ return internals.state.inputMode.buffer;
229
+ },
230
+ setInputBuffer(text) {
231
+ if (!internals.mounted || internals.state.inputMode === null)
232
+ return;
233
+ internals.state.inputMode.buffer = text;
234
+ internals.state.inputMode.cursor = [...text].length;
235
+ },
211
236
  };
212
237
  }
213
238
  /**
@@ -285,7 +310,7 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
285
310
  panel?.unmount();
286
311
  const completedAt = new Date().toISOString();
287
312
  // Resolved supersedes in-progress: write response.json, drop progress.json.
288
- const rp = writeResponse(dir, responses, completedAt);
313
+ const rp = writeResponse(dir, responses, completedAt, currentDeck);
289
314
  clearProgress(dir);
290
315
  resolve({ responses, completedAt, responsePath: rp, deck: currentDeck });
291
316
  };
@@ -304,6 +329,12 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
304
329
  onExit: () => {
305
330
  finalize(lastResponses);
306
331
  },
332
+ // Async visual finished loading between keystrokes — repaint so the
333
+ // "loading context..." placeholder is replaced immediately.
334
+ onDirty: () => {
335
+ if (panel !== null)
336
+ flushHost(panel.render());
337
+ },
307
338
  });
308
339
  flushHost(panel.render());
309
340
  // ── Live deck reload ──────────────────────────────────────────────────
@@ -349,8 +380,85 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
349
380
  panel.loadDeck(nextDeck, { progressPath: progressPathFor(dir) });
350
381
  flushHost(panel.render());
351
382
  }, 500);
383
+ // $EDITOR escape hatch (ctrl+o): the host owns the stdin listener + raw
384
+ // mode, so it — not handleInputMode — is where the editor round-trip has
385
+ // to live. Detected here before delegating to the panel; only acts while
386
+ // an input-mode buffer exists (panel.getInputBuffer() undefined otherwise).
387
+ const runEditorEscapeHatch = (buffer) => {
388
+ if (panel === null)
389
+ return;
390
+ process.stdin.removeListener('data', onData);
391
+ process.stdout.removeListener('resize', onResize);
392
+ const tmpFile = join(tmpdir(), `hl-input-${randomUUID()}.txt`);
393
+ const editor = process.env.EDITOR || 'vi';
394
+ let next = buffer;
395
+ let errorMessage = null;
396
+ // Everything from suspending the TUI through the editor round-trip can
397
+ // throw (tmpfile write, spawn, readback) — a tmpdir write failure alone
398
+ // (e.g. /tmp full) used to escape with the listeners detached and the
399
+ // real terminal never restored. The try/finally below guarantees the
400
+ // finally branch — tmpfile cleanup, TUI restore, listener re-attach,
401
+ // resize-aware redraw — runs on every path, success or failure.
402
+ try {
403
+ restoreTerminal();
404
+ writeFileSync(tmpFile, buffer);
405
+ const result = spawnSync(editor, [tmpFile], { stdio: 'inherit' });
406
+ if (result.error) {
407
+ errorMessage = `$EDITOR ("${editor}") failed to launch: ${result.error.message}`;
408
+ }
409
+ else if (result.signal !== null) {
410
+ errorMessage = `$EDITOR ("${editor}") was killed by signal ${result.signal}`;
411
+ }
412
+ else if (result.status !== 0) {
413
+ errorMessage = `$EDITOR ("${editor}") exited with status ${result.status}`;
414
+ }
415
+ else {
416
+ let read = readFileSync(tmpFile, 'utf8');
417
+ // Editors conventionally append a trailing newline on save; strip
418
+ // exactly one so a round-trip that added nothing doesn't grow the
419
+ // buffer by a blank line.
420
+ if (read.endsWith('\n') && !buffer.endsWith('\n'))
421
+ read = read.slice(0, -1);
422
+ next = read;
423
+ }
424
+ }
425
+ catch (err) {
426
+ errorMessage = `$EDITOR round-trip failed: ${err instanceof Error ? err.message : String(err)}`;
427
+ }
428
+ finally {
429
+ try {
430
+ unlinkSync(tmpFile);
431
+ }
432
+ catch { /* best-effort cleanup — may never have been created */ }
433
+ setupTerminal();
434
+ process.stdin.on('data', onData);
435
+ process.stdout.on('resize', onResize);
436
+ // On any failure the original buffer is kept untouched. Re-sample the
437
+ // terminal size (it may have changed while the editor had it) so the
438
+ // post-editor redraw lays out for the CURRENT dimensions rather than
439
+ // the panel's stale cols/rows, same as the live resize listener does.
440
+ panel.setInputBuffer(errorMessage === null ? next : buffer);
441
+ const { cols: c, rows: r } = getTerminalSize();
442
+ const lines = panel.handleResize(c, r);
443
+ if (errorMessage !== null) {
444
+ while (lines.length < r)
445
+ lines.push('');
446
+ lines[r - 1] = ` ${errorMessage}`.slice(0, c);
447
+ }
448
+ prevFrameLocal = [];
449
+ process.stdout.write('\x1b[2J\x1b[H');
450
+ flushHost(lines);
451
+ }
452
+ };
352
453
  onData = (data) => {
353
454
  const { input: inp, key } = parseKeypress(data);
455
+ if (key.ctrl && inp === 'o') {
456
+ const buf = panel.getInputBuffer();
457
+ if (buf !== undefined) {
458
+ runEditorEscapeHatch(buf);
459
+ return;
460
+ }
461
+ }
354
462
  panel.handleKey(inp, key);
355
463
  flushHost(panel.render());
356
464
  };