@crouton-kit/humanloop 0.1.0 → 0.1.3

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.
@@ -1,5 +1,45 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import stringWidth from 'string-width';
2
- import { getTerminalSize } from './terminal.js';
3
+ // ── Termrender body rendering ────────────────────────────────────────────────
4
+ let _termrenderAvail = null;
5
+ function isTermrenderAvailable() {
6
+ if (_termrenderAvail !== null)
7
+ return _termrenderAvail;
8
+ try {
9
+ execFileSync('termrender', ['--version'], { stdio: 'pipe', timeout: 3000 });
10
+ _termrenderAvail = true;
11
+ }
12
+ catch {
13
+ _termrenderAvail = false;
14
+ }
15
+ return _termrenderAvail;
16
+ }
17
+ const _bodyCache = new Map();
18
+ function renderBody(text, width) {
19
+ const key = `${text}\0${width}`;
20
+ const cached = _bodyCache.get(key);
21
+ if (cached)
22
+ return cached;
23
+ if (isTermrenderAvailable()) {
24
+ try {
25
+ const out = execFileSync('termrender', ['--width', String(width)], {
26
+ input: text,
27
+ encoding: 'utf-8',
28
+ timeout: 5000,
29
+ stdio: ['pipe', 'pipe', 'pipe'],
30
+ });
31
+ const lines = out.split('\n');
32
+ if (lines.length > 0 && lines[lines.length - 1] === '')
33
+ lines.pop();
34
+ _bodyCache.set(key, lines);
35
+ return lines;
36
+ }
37
+ catch { /* fall through */ }
38
+ }
39
+ const fallback = wrap(sanitize(text), width);
40
+ _bodyCache.set(key, fallback);
41
+ return fallback;
42
+ }
3
43
  // ── ANSI helpers ─────────────────────────────────────────────────────────────
4
44
  const ESC = '\x1b[';
5
45
  const RESET = `${ESC}0m`;
@@ -8,24 +48,32 @@ const DIM = `${ESC}2m`;
8
48
  const ITALIC = `${ESC}3m`;
9
49
  const GREEN = `${ESC}32m`;
10
50
  const YELLOW = `${ESC}33m`;
11
- const BLUE = `${ESC}34m`;
12
- const MAGENTA = `${ESC}35m`;
13
51
  const CYAN = `${ESC}36m`;
14
- const GRAY = `${ESC}90m`;
15
- const BG_BLUE = `${ESC}44m`;
16
- const WHITE = `${ESC}37m`;
52
+ const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
53
+ export function sanitize(text) {
54
+ if (typeof text !== 'string')
55
+ return '';
56
+ return text.replace(CONTROL_CHARS_RE, '');
57
+ }
58
+ function singleLine(text) {
59
+ return sanitize(text).replace(/\s+/g, ' ').trim();
60
+ }
17
61
  function truncate(text, maxWidth) {
62
+ if (maxWidth < 1)
63
+ return '';
18
64
  if (stringWidth(text) <= maxWidth)
19
65
  return text;
66
+ const chars = [...text];
20
67
  let w = 0;
21
- let i = 0;
22
- for (; i < text.length; i++) {
23
- const cw = stringWidth(text[i]);
68
+ let out = '';
69
+ for (const ch of chars) {
70
+ const cw = stringWidth(ch);
24
71
  if (w + cw + 1 > maxWidth)
25
72
  break;
73
+ out += ch;
26
74
  w += cw;
27
75
  }
28
- return text.slice(0, i) + '…';
76
+ return out + '…';
29
77
  }
30
78
  function padRight(text, width) {
31
79
  const w = stringWidth(text);
@@ -34,26 +82,61 @@ function padRight(text, width) {
34
82
  return text + ' '.repeat(width - w);
35
83
  }
36
84
  function hline(width, char = '─') {
85
+ if (width < 1)
86
+ return '';
37
87
  return char.repeat(width);
38
88
  }
39
89
  function wrap(text, maxWidth) {
40
- const words = text.split(/\s+/);
41
- const lines = [];
42
- let current = '';
43
- for (const word of words) {
44
- const candidate = current ? `${current} ${word}` : word;
45
- if (stringWidth(candidate) <= maxWidth) {
46
- current = candidate;
90
+ if (maxWidth < 1)
91
+ return [text];
92
+ const out = [];
93
+ const paragraphs = text.split('\n');
94
+ for (let p = 0; p < paragraphs.length; p++) {
95
+ const para = paragraphs[p];
96
+ if (para === '') {
97
+ out.push('');
98
+ continue;
47
99
  }
48
- else {
49
- if (current)
50
- lines.push(current);
51
- current = word;
100
+ const words = para.split(/[ \t]+/).filter(Boolean);
101
+ let current = '';
102
+ for (let word of words) {
103
+ while (stringWidth(word) > maxWidth) {
104
+ if (current) {
105
+ out.push(current);
106
+ current = '';
107
+ }
108
+ const piece = sliceByWidth(word, maxWidth);
109
+ out.push(piece);
110
+ word = word.slice(piece.length);
111
+ }
112
+ const candidate = current ? `${current} ${word}` : word;
113
+ if (stringWidth(candidate) <= maxWidth) {
114
+ current = candidate;
115
+ }
116
+ else {
117
+ if (current)
118
+ out.push(current);
119
+ current = word;
120
+ }
52
121
  }
122
+ if (current)
123
+ out.push(current);
53
124
  }
54
- if (current)
55
- lines.push(current);
56
- return lines.length > 0 ? lines : [''];
125
+ return out.length > 0 ? out : [''];
126
+ }
127
+ function sliceByWidth(s, maxWidth) {
128
+ let w = 0;
129
+ let out = '';
130
+ for (const ch of s) {
131
+ const cw = stringWidth(ch);
132
+ if (w + cw > maxWidth)
133
+ break;
134
+ out += ch;
135
+ w += cw;
136
+ }
137
+ if (out === '' && s.length > 0)
138
+ out = [...s][0];
139
+ return out;
57
140
  }
58
141
  function hardWrap(text, maxWidth) {
59
142
  if (maxWidth < 1)
@@ -67,7 +150,7 @@ function hardWrap(text, maxWidth) {
67
150
  }
68
151
  let current = '';
69
152
  let currentW = 0;
70
- for (const ch of seg) {
153
+ for (const ch of [...seg]) {
71
154
  const cw = stringWidth(ch);
72
155
  if (currentW + cw > maxWidth) {
73
156
  out.push(current);
@@ -84,196 +167,285 @@ function hardWrap(text, maxWidth) {
84
167
  return out;
85
168
  }
86
169
  // ── Frame buffer ─────────────────────────────────────────────────────────────
87
- let prevFrame = [];
88
- export function flush(lines) {
89
- const { rows } = getTerminalSize();
90
- process.stdout.write('\x1b[?2026h');
170
+ export function diffFrame(prevFrame, nextLines, rows) {
171
+ const writes = [];
91
172
  for (let i = 0; i < rows; i++) {
92
- const line = i < lines.length ? lines[i] : '';
173
+ const line = i < nextLines.length ? nextLines[i] : '';
93
174
  if (prevFrame[i] !== line) {
94
- process.stdout.write(`${ESC}${i + 1};1H${ESC}2K${line}`);
175
+ writes.push(`${ESC}${i + 1};1H${ESC}2K${line}`);
95
176
  }
96
177
  }
97
- process.stdout.write('\x1b[?2026l');
98
- prevFrame = [...lines];
178
+ return { writes, nextPrevFrame: [...nextLines] };
99
179
  }
100
180
  // ── Renderers ────────────────────────────────────────────────────────────────
101
- export function renderOverview(state) {
102
- const { cols, rows } = getTerminalSize();
181
+ export function renderOverview(state, cols, rows) {
103
182
  const lines = [];
104
183
  const title = `${BOLD}${CYAN} Decisions ${RESET}`;
105
- const progress = `${state.answers.size}/${state.questions.length} answered`;
184
+ const progress = `${state.responses.size}/${state.interactions.length} answered`;
106
185
  lines.push('');
107
186
  lines.push(` ${title} ${DIM}${progress}${RESET}`);
108
187
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
109
188
  lines.push('');
110
- for (let i = 0; i < state.questions.length; i++) {
111
- const q = state.questions[i];
112
- const answer = state.answers.get(q.id);
113
- const icon = answer ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
114
- const label = q.type === 'validation' ? q.statement : q.question;
115
- const typeTag = `${DIM}[${q.type}]${RESET}`;
189
+ const rowsBuf = [];
190
+ for (let i = 0; i < state.interactions.length; i++) {
191
+ const interaction = state.interactions[i];
192
+ const response = state.responses.get(interaction.id);
193
+ const icon = response ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
194
+ const label = singleLine(interaction.title);
116
195
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
117
- lines.push(` ${cursor}${icon} ${truncate(label, cols - 20)} ${typeTag}`);
118
- if (answer) {
119
- const summary = answerSummary(answer);
120
- lines.push(` ${DIM}${truncate(summary, cols - 10)}${RESET}`);
196
+ const labelMax = Math.max(10, cols - 16);
197
+ rowsBuf.push({
198
+ line: ` ${cursor}${icon} ${truncate(label, labelMax)}`,
199
+ questionIndex: i,
200
+ });
201
+ if (response) {
202
+ const summary = singleLine(responseSummary(response, interaction));
203
+ const summaryMax = Math.max(10, cols - 10);
204
+ rowsBuf.push({
205
+ line: ` ${DIM}${truncate(summary, summaryMax)}${RESET}`,
206
+ questionIndex: i,
207
+ });
121
208
  }
122
209
  }
123
- lines.push('');
210
+ const reserved = 4 + 3 + 2;
211
+ const available = Math.max(1, rows - reserved);
212
+ let scroll = state.scrollOffset || 0;
213
+ const focusRow = rowsBuf.findIndex((r) => r.questionIndex === state.currentIndex);
214
+ if (focusRow >= 0) {
215
+ if (focusRow < scroll)
216
+ scroll = focusRow;
217
+ if (focusRow >= scroll + available)
218
+ scroll = focusRow - available + 1;
219
+ }
220
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, rowsBuf.length - available)));
221
+ if (scroll > 0) {
222
+ lines.push(` ${DIM}↑ ${scroll} more above${RESET}`);
223
+ }
224
+ else {
225
+ lines.push('');
226
+ }
227
+ const end = Math.min(rowsBuf.length, scroll + available);
228
+ for (let i = scroll; i < end; i++)
229
+ lines.push(rowsBuf[i].line);
230
+ if (end < rowsBuf.length) {
231
+ lines.push(` ${DIM}↓ ${rowsBuf.length - end} more below${RESET}`);
232
+ }
233
+ else {
234
+ lines.push('');
235
+ }
124
236
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
125
237
  lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
126
238
  while (lines.length < rows)
127
239
  lines.push('');
128
- return lines;
240
+ return lines.slice(0, rows);
129
241
  }
130
- export function renderItemReview(state) {
131
- const { cols, rows } = getTerminalSize();
132
- const lines = [];
133
- const q = state.questions[state.currentIndex];
134
- const visual = state.visuals.get(q.id);
135
- const answer = state.answers.get(q.id);
136
- const maxW = Math.min(cols - 4, 76);
137
- // Header
138
- const pos = `${state.currentIndex + 1}/${state.questions.length}`;
139
- lines.push('');
140
- lines.push(` ${BOLD}${CYAN}[${pos}]${RESET} ${DIM}${q.type}${RESET}`);
141
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
142
- lines.push('');
143
- // Question / Statement
144
- const headline = q.type === 'validation' ? q.statement : q.question;
145
- for (const line of wrap(headline, maxW)) {
146
- lines.push(` ${BOLD}${line}${RESET}`);
242
+ export function renderItemReview(state, cols, rows) {
243
+ const interaction = state.interactions[state.currentIndex];
244
+ const visual = state.visuals.get(interaction.id);
245
+ const response = state.responses.get(interaction.id);
246
+ const maxW = Math.min(cols - 4, 120);
247
+ // Pre-body: position, divider, title, subtitle (always visible)
248
+ const preLines = [];
249
+ const pos = `${state.currentIndex + 1}/${state.interactions.length}`;
250
+ preLines.push('');
251
+ preLines.push(` ${BOLD}${CYAN}[${pos}]${RESET}`);
252
+ preLines.push(` ${DIM}${hline(maxW)}${RESET}`);
253
+ preLines.push('');
254
+ for (const line of wrap(sanitize(interaction.title), maxW)) {
255
+ preLines.push(` ${BOLD}${line}${RESET}`);
147
256
  }
148
- for (const line of wrap(q.rationale, maxW)) {
149
- lines.push(` ${ITALIC}${GRAY}${line}${RESET}`);
257
+ if (interaction.subtitle) {
258
+ for (const line of wrap(sanitize(interaction.subtitle), maxW)) {
259
+ preLines.push(` ${DIM}${line}${RESET}`);
260
+ }
150
261
  }
151
- lines.push('');
152
- // Visual context
262
+ // Body: rendered question body + expanded visual block (scrollable)
263
+ const bodyLines = [];
264
+ if (interaction.body) {
265
+ bodyLines.push('');
266
+ for (const line of renderBody(interaction.body, maxW)) {
267
+ bodyLines.push(` ${line}`);
268
+ }
269
+ }
270
+ if (visual && visual.status === 'ready' && state.detailExpanded) {
271
+ bodyLines.push('');
272
+ bodyLines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
273
+ for (const vl of visual.content.split('\n')) {
274
+ bodyLines.push(` ${vl}`);
275
+ }
276
+ bodyLines.push(` ${DIM}${hline(maxW)}${RESET}`);
277
+ }
278
+ // Post-body: visual status hint, input buffer or actions, footer (always visible)
279
+ const postLines = [];
280
+ postLines.push('');
153
281
  if (visual) {
154
282
  if (visual.status === 'loading') {
155
- lines.push(` ${DIM}loading context...${RESET}`);
283
+ postLines.push(` ${DIM}loading context...${RESET}`);
284
+ postLines.push('');
156
285
  }
157
286
  else if (visual.status === 'error') {
158
- lines.push(` ${YELLOW}visual context unavailable${RESET}`);
287
+ postLines.push(` ${YELLOW}visual context unavailable${RESET}`);
288
+ postLines.push('');
159
289
  }
160
- else if (state.detailExpanded) {
161
- lines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
162
- for (const vl of visual.content.split('\n')) {
163
- lines.push(` ${vl}`);
164
- }
165
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
290
+ else if (!state.detailExpanded) {
291
+ postLines.push(` ${DIM}[space] expand context${RESET}`);
292
+ postLines.push('');
166
293
  }
167
- else {
168
- lines.push(` ${DIM}[space] expand context${RESET}`);
169
- }
170
- lines.push('');
171
294
  }
172
- // Input mode
173
295
  if (state.inputMode) {
174
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
175
- const label = state.inputMode.kind === 'comment' ? 'Comment'
176
- : state.inputMode.kind === 'freetext' ? 'Response'
177
- : 'Custom option';
178
- lines.push(` ${YELLOW}${label}:${RESET}`);
296
+ postLines.push(` ${DIM}${hline(maxW)}${RESET}`);
297
+ const label = interaction.freetextLabel !== undefined
298
+ ? interaction.freetextLabel
299
+ : state.inputMode.kind === 'comment' ? 'Comment' : 'Response';
300
+ // Show attached option (comment mode only) — Tab cycles
301
+ let attachedLine;
302
+ if (state.inputMode.kind === 'comment') {
303
+ const attachedId = state.inputMode.selectedOptionId;
304
+ const opts = interaction.options;
305
+ if (opts.length > 0) {
306
+ const attached = attachedId !== undefined
307
+ ? opts.find((o) => o.id === attachedId)
308
+ : undefined;
309
+ const valueText = attached !== undefined
310
+ ? `${CYAN}${sanitize(attached.label)}${RESET}`
311
+ : `${DIM}none${RESET}`;
312
+ attachedLine = ` ${DIM}attached:${RESET} ${valueText} ${DIM}[tab to cycle]${RESET}`;
313
+ }
314
+ }
315
+ postLines.push(` ${YELLOW}${label}:${RESET}`);
179
316
  const bufLines = hardWrap(state.inputMode.buffer, maxW - 1);
180
317
  for (let i = 0; i < bufLines.length; i++) {
181
318
  const isLast = i === bufLines.length - 1;
182
- lines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
319
+ postLines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
183
320
  }
184
- lines.push('');
185
- lines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
321
+ if (attachedLine !== undefined) {
322
+ postLines.push('');
323
+ postLines.push(attachedLine);
324
+ }
325
+ postLines.push('');
326
+ postLines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
186
327
  }
187
328
  else {
188
- // Actions
189
- lines.push(...renderActions(q, state.selectedAction, answer));
329
+ postLines.push(...renderActions(interaction, state.selectedAction, response));
190
330
  }
191
- // Footer
192
- while (lines.length < rows - 1)
193
- lines.push('');
331
+ // Window the body
332
+ const reservedRows = preLines.length + postLines.length + 1; // +1 for footer
333
+ const bodyHeight = Math.max(1, rows - reservedRows);
334
+ const overflows = bodyLines.length > bodyHeight;
335
+ let scroll = state.scrollOffset || 0;
336
+ const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
337
+ scroll = Math.max(0, Math.min(scroll, maxScroll));
338
+ state.scrollOffset = scroll;
339
+ let visibleBody;
340
+ if (overflows) {
341
+ visibleBody = bodyLines.slice(scroll, scroll + bodyHeight);
342
+ if (scroll > 0) {
343
+ visibleBody[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
344
+ }
345
+ const remainingBelow = bodyLines.length - (scroll + bodyHeight);
346
+ if (remainingBelow > 0) {
347
+ visibleBody[visibleBody.length - 1] = ` ${DIM}↓ ${remainingBelow} more below${RESET}`;
348
+ }
349
+ }
350
+ else {
351
+ visibleBody = bodyLines;
352
+ }
353
+ // Footer hint — mention scroll keys when body overflows
194
354
  const footerParts = [
195
355
  `${DIM}n/p${RESET} prev/next`,
196
356
  `${DIM}space${RESET} expand`,
197
357
  `${DIM}q${RESET} overview`,
198
358
  ];
199
- lines.push(` ${footerParts.join(' ')}`);
359
+ if (overflows)
360
+ footerParts.unshift(`${DIM}u/d${RESET} scroll`);
361
+ const footer = ` ${footerParts.join(' ')}`;
362
+ // Assemble — pad to fill rows so post-body sits at the bottom
363
+ const lines = [...preLines, ...visibleBody, ...postLines];
364
+ while (lines.length < rows - 1)
365
+ lines.push('');
366
+ lines.push(footer);
367
+ // Final clamp (safety net for very small terminals)
368
+ if (lines.length > rows) {
369
+ return [...lines.slice(0, rows - 1), footer];
370
+ }
200
371
  return lines;
201
372
  }
202
- function renderActions(q, selectedAction, existing) {
373
+ function renderActions(interaction, selectedAction, existing) {
203
374
  const lines = [];
204
- if (q.type === 'validation') {
205
- const actions = [
206
- { key: '1', label: 'Approve', desc: 'accept as stated' },
207
- { key: '2', label: 'Approve + comment', desc: 'accept with note' },
208
- { key: '3', label: 'Reject', desc: 'do not accept as stated' },
209
- { key: '4', label: 'Comment', desc: 'feedback without decision' },
210
- ];
211
- for (let i = 0; i < actions.length; i++) {
212
- const a = actions[i];
213
- const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
214
- const keyBadge = `${DIM}[${a.key}]${RESET}`;
215
- lines.push(` ${cursor} ${keyBadge} ${a.label} ${DIM}— ${a.desc}${RESET}`);
216
- }
375
+ const opts = interaction.options;
376
+ for (let i = 0; i < opts.length; i++) {
377
+ const o = opts[i];
378
+ const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
379
+ const sc = o.shortcut ?? ' ';
380
+ const keyBadge = `${DIM}[${sc}]${RESET}`;
381
+ const desc = o.description ? ` ${DIM}— ${sanitize(o.description)}${RESET}` : '';
382
+ lines.push(` ${cursor} ${keyBadge} ${sanitize(o.label)}${desc}`);
217
383
  }
218
- else if (q.type === 'choice') {
219
- for (let i = 0; i < q.options.length; i++) {
220
- const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
221
- const keyBadge = `${DIM}[${i + 1}]${RESET}`;
222
- lines.push(` ${cursor} ${keyBadge} ${q.options[i]}`);
223
- }
224
- const otherIdx = q.options.length;
225
- const cursor = otherIdx === selectedAction ? `${CYAN}▸${RESET}` : ' ';
226
- lines.push(` ${cursor} ${DIM}[${otherIdx + 1}]${RESET} ${ITALIC}Other (custom)${RESET}`);
384
+ if (interaction.allowFreetext && opts.length > 0) {
385
+ const cursor = opts.length === selectedAction ? `${CYAN}▸${RESET}` : ' ';
386
+ const label = interaction.freetextLabel !== undefined ? interaction.freetextLabel : 'Add comment';
387
+ lines.push(` ${cursor} ${DIM}[c]${RESET} ${label}`);
227
388
  }
228
- else {
229
- lines.push(` ${DIM}[r]${RESET} Enter response`);
389
+ else if (interaction.allowFreetext && opts.length === 0) {
390
+ const ftLabel = interaction.freetextLabel !== undefined ? interaction.freetextLabel : 'Enter response';
391
+ lines.push(` ${DIM}[r]${RESET} ${ftLabel}`);
230
392
  }
231
393
  if (existing) {
232
394
  lines.push('');
233
- lines.push(` ${GREEN}Current: ${answerSummary(existing)}${RESET}`);
395
+ lines.push(` ${GREEN}Current: ${responseSummary(existing, interaction)}${RESET}`);
234
396
  }
235
397
  return lines;
236
398
  }
237
- export function renderFinal(state) {
238
- const { cols, rows } = getTerminalSize();
239
- const lines = [];
399
+ export function renderFinal(state, cols, rows) {
400
+ const header = [];
401
+ const footer = [];
240
402
  const maxW = Math.min(cols - 4, 60);
241
- const total = state.questions.length;
242
- const answered = state.answers.size;
243
- lines.push('');
244
- lines.push(` ${BOLD}${CYAN} Summary ${RESET}`);
245
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
246
- lines.push('');
247
- lines.push(` ${answered}/${total} questions answered`);
248
- lines.push('');
249
- for (const q of state.questions) {
250
- const answer = state.answers.get(q.id);
251
- const icon = answer ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
252
- const label = q.type === 'validation' ? q.statement : q.question;
253
- lines.push(` ${icon} ${truncate(label, maxW - 4)}`);
254
- if (answer) {
255
- lines.push(` ${DIM}${truncate(answerSummary(answer), maxW - 6)}${RESET}`);
403
+ const total = state.interactions.length;
404
+ const answered = state.responses.size;
405
+ header.push('');
406
+ header.push(` ${BOLD}${CYAN} Summary ${RESET}`);
407
+ header.push(` ${DIM}${hline(maxW)}${RESET}`);
408
+ header.push('');
409
+ header.push(` ${answered}/${total} questions answered`);
410
+ header.push('');
411
+ footer.push('');
412
+ footer.push(` ${DIM}${hline(maxW)}${RESET}`);
413
+ if (answered < total) {
414
+ footer.push(` ${YELLOW}${total - answered} unanswered press p to go back${RESET}`);
415
+ }
416
+ footer.push(` ${DIM}enter${RESET} submit ${DIM}p${RESET} go back`);
417
+ const questionRows = [];
418
+ for (const interaction of state.interactions) {
419
+ const response = state.responses.get(interaction.id);
420
+ const icon = response ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
421
+ const label = singleLine(interaction.title);
422
+ questionRows.push(` ${icon} ${truncate(label, Math.max(10, maxW - 4))}`);
423
+ if (response) {
424
+ questionRows.push(` ${DIM}${truncate(singleLine(responseSummary(response, interaction)), Math.max(10, maxW - 6))}${RESET}`);
256
425
  }
257
426
  }
258
- lines.push('');
259
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
260
- if (answered < total) {
261
- lines.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
427
+ const available = Math.max(1, rows - header.length - footer.length - 1);
428
+ let visible = questionRows;
429
+ if (questionRows.length > available) {
430
+ visible = [
431
+ ...questionRows.slice(0, available - 1),
432
+ ` ${DIM}… ${questionRows.length - (available - 1)} more rows omitted${RESET}`,
433
+ ];
262
434
  }
263
- lines.push(` ${DIM}enter${RESET} submit ${DIM}p${RESET} go back`);
435
+ const lines = [...header, ...visible, ...footer];
264
436
  while (lines.length < rows)
265
437
  lines.push('');
266
- return lines;
438
+ return lines.slice(0, rows);
267
439
  }
268
- function answerSummary(a) {
269
- switch (a.type) {
270
- case 'validation':
271
- return a.approved
272
- ? (a.comment ? `approved: "${a.comment}"` : 'approved')
273
- : (a.comment ? `commented: "${a.comment}"` : 'commented');
274
- case 'choice':
275
- return a.isCustom ? `custom: "${a.selected}"` : a.selected;
276
- case 'freetext':
277
- return a.response;
278
- }
440
+ export function responseSummary(r, interaction) {
441
+ const opt = r.selectedOptionId
442
+ ? interaction.options.find((o) => o.id === r.selectedOptionId)
443
+ : undefined;
444
+ if (opt && r.freetext)
445
+ return `${sanitize(opt.label)}: "${sanitize(r.freetext)}"`;
446
+ if (opt)
447
+ return sanitize(opt.label);
448
+ if (r.freetext)
449
+ return sanitize(r.freetext);
450
+ return '(empty)';
279
451
  }
@@ -41,6 +41,11 @@ export function parseKeypress(data) {
41
41
  const ch = String.fromCharCode(str.charCodeAt(0) + 64).toLowerCase();
42
42
  return { input: ch, key };
43
43
  }
44
+ // Multi-byte chunks (paste, multi-byte UTF-8, unknown escape sequences)
45
+ // are returned as-is in `input`; the input-mode handler is responsible for
46
+ // sanitising them before appending to its buffer. Top-level handlers
47
+ // ignore strings of length > 1, which is the desired behaviour for
48
+ // accidentally pasted text in overview/item-review.
44
49
  return { input: str, key };
45
50
  }
46
51
  export function setupTerminal() {
@@ -1,6 +1,10 @@
1
- import type { DecisionsOutput } from '../types.js';
1
+ import type { InteractionResponse } from '../types.js';
2
+ export interface TuiOutput {
3
+ responses: InteractionResponse[];
4
+ completedAt: string;
5
+ }
2
6
  export interface TmuxDispatchOpts {
3
7
  sessionId?: string;
4
8
  visuals: boolean;
5
9
  }
6
- export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<DecisionsOutput>;
10
+ export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<TuiOutput>;
package/dist/tui/tmux.js CHANGED
@@ -32,12 +32,30 @@ export async function dispatchToTmuxPane(file, opts) {
32
32
  const dir = mkdtempSync(join(tmpdir(), 'hl-'));
33
33
  const resultPath = join(dir, 'result.json');
34
34
  const cmd = buildChildCmd(file, resultPath, opts);
35
- execFileSync('tmux', ['split-window', '-h', '-d', cmd], { stdio: 'ignore' });
36
- await new Promise((resolve) => {
35
+ // Capture the spawned pane id so we can detect if the user closes it
36
+ // without finishing otherwise the parent would poll forever.
37
+ const paneId = execFileSync('tmux', ['split-window', '-P', '-F', '#{pane_id}', '-h', '-d', cmd], { encoding: 'utf8' }).trim();
38
+ await new Promise((resolve, reject) => {
37
39
  const poll = setInterval(() => {
38
40
  if (existsSync(resultPath)) {
39
41
  clearInterval(poll);
40
42
  resolve();
43
+ return;
44
+ }
45
+ // Check the pane is still alive. If it's gone and there's still no
46
+ // result file, the child died (closed pane, crash, etc).
47
+ try {
48
+ const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
49
+ encoding: 'utf8',
50
+ });
51
+ if (!panes.split('\n').map((s) => s.trim()).includes(paneId)) {
52
+ clearInterval(poll);
53
+ reject(new Error(`tmux pane ${paneId} closed before writing a result`));
54
+ }
55
+ }
56
+ catch (err) {
57
+ clearInterval(poll);
58
+ reject(new Error(`tmux list-panes failed: ${err instanceof Error ? err.message : String(err)}`));
41
59
  }
42
60
  }, 150);
43
61
  });