@crouton-kit/humanloop 0.1.2 → 0.1.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.
@@ -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,13 @@ 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`;
17
- // Strip ANSI escape sequences and other C0/C1 control bytes from user-supplied
18
- // text so it can't poison the alt-screen buffer (cursor moves, color bleed,
19
- // embedded \x1b[2J that clears the screen, etc). Keeps \n and \t which the
20
- // wrappers handle explicitly.
21
52
  const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
22
53
  export function sanitize(text) {
23
54
  if (typeof text !== 'string')
24
55
  return '';
25
56
  return text.replace(CONTROL_CHARS_RE, '');
26
57
  }
27
- // For one-line displays (overview rows, summaries): collapse all whitespace
28
- // — including newlines and tabs — to single spaces so the row stays one line.
29
58
  function singleLine(text) {
30
59
  return sanitize(text).replace(/\s+/g, ' ').trim();
31
60
  }
@@ -34,7 +63,6 @@ function truncate(text, maxWidth) {
34
63
  return '';
35
64
  if (stringWidth(text) <= maxWidth)
36
65
  return text;
37
- // Iterate by codepoint, not UTF-16 code unit, so surrogate pairs don't split.
38
66
  const chars = [...text];
39
67
  let w = 0;
40
68
  let out = '';
@@ -58,8 +86,6 @@ function hline(width, char = '─') {
58
86
  return '';
59
87
  return char.repeat(width);
60
88
  }
61
- // Word-wrap that ALSO respects \n as a hard break and ALSO breaks oversized
62
- // words at maxWidth so a single 200-char token doesn't overflow the frame.
63
89
  function wrap(text, maxWidth) {
64
90
  if (maxWidth < 1)
65
91
  return [text];
@@ -74,7 +100,6 @@ function wrap(text, maxWidth) {
74
100
  const words = para.split(/[ \t]+/).filter(Boolean);
75
101
  let current = '';
76
102
  for (let word of words) {
77
- // Hard-break a word that's wider than the line.
78
103
  while (stringWidth(word) > maxWidth) {
79
104
  if (current) {
80
105
  out.push(current);
@@ -99,7 +124,6 @@ function wrap(text, maxWidth) {
99
124
  }
100
125
  return out.length > 0 ? out : [''];
101
126
  }
102
- // Take the longest prefix of `s` whose visible width is <= maxWidth.
103
127
  function sliceByWidth(s, maxWidth) {
104
128
  let w = 0;
105
129
  let out = '';
@@ -110,8 +134,6 @@ function sliceByWidth(s, maxWidth) {
110
134
  out += ch;
111
135
  w += cw;
112
136
  }
113
- // Always advance at least one character so we don't loop forever on
114
- // a single zero-width or oversized glyph.
115
137
  if (out === '' && s.length > 0)
116
138
  out = [...s][0];
117
139
  return out;
@@ -128,7 +150,6 @@ function hardWrap(text, maxWidth) {
128
150
  }
129
151
  let current = '';
130
152
  let currentW = 0;
131
- // Iterate by codepoint so emoji surrogate pairs stay intact.
132
153
  for (const ch of [...seg]) {
133
154
  const cw = stringWidth(ch);
134
155
  if (currentW + cw > maxWidth) {
@@ -145,45 +166,56 @@ function hardWrap(text, maxWidth) {
145
166
  }
146
167
  return out;
147
168
  }
169
+ // ── Horizontal centering ─────────────────────────────────────────────────────
170
+ /**
171
+ * Pad each non-empty line with leading spaces to horizontally center the
172
+ * `contentWidth`-wide block within `cols`. Wide terminals (dashboard, full
173
+ * screen) get visual breathing room; narrow panes (split tmux pane next to a
174
+ * spawning agent) skip centering because there's nothing to center.
175
+ *
176
+ * Empty lines stay empty so frame diffing can keep them as cheap no-ops.
177
+ */
178
+ function centerHorizontal(lines, cols, contentWidth) {
179
+ const extraPad = Math.max(0, Math.floor((cols - contentWidth) / 2));
180
+ if (extraPad === 0)
181
+ return lines;
182
+ const pad = ' '.repeat(extraPad);
183
+ return lines.map((line) => (line === '' ? '' : pad + line));
184
+ }
148
185
  // ── Frame buffer ─────────────────────────────────────────────────────────────
149
- let prevFrame = [];
150
- export function flush(lines) {
151
- const { rows } = getTerminalSize();
152
- process.stdout.write('\x1b[?2026h');
186
+ export function diffFrame(prevFrame, nextLines, rows) {
187
+ const writes = [];
153
188
  for (let i = 0; i < rows; i++) {
154
- const line = i < lines.length ? lines[i] : '';
189
+ const line = i < nextLines.length ? nextLines[i] : '';
155
190
  if (prevFrame[i] !== line) {
156
- process.stdout.write(`${ESC}${i + 1};1H${ESC}2K${line}`);
191
+ writes.push(`${ESC}${i + 1};1H${ESC}2K${line}`);
157
192
  }
158
193
  }
159
- process.stdout.write('\x1b[?2026l');
160
- prevFrame = [...lines];
194
+ return { writes, nextPrevFrame: [...nextLines] };
161
195
  }
162
196
  // ── Renderers ────────────────────────────────────────────────────────────────
163
- export function renderOverview(state) {
164
- const { cols, rows } = getTerminalSize();
197
+ export function renderOverview(state, cols, rows) {
165
198
  const lines = [];
166
199
  const title = `${BOLD}${CYAN} Decisions ${RESET}`;
167
- const progress = `${state.answers.size}/${state.questions.length} answered`;
200
+ const progress = `${state.responses.size}/${state.interactions.length} answered`;
168
201
  lines.push('');
169
202
  lines.push(` ${title} ${DIM}${progress}${RESET}`);
170
203
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
171
204
  lines.push('');
172
205
  const rowsBuf = [];
173
- for (let i = 0; i < state.questions.length; i++) {
174
- const q = state.questions[i];
175
- const answer = state.answers.get(q.id);
176
- const icon = answer ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
177
- const label = singleLine(q.type === 'validation' ? q.statement : q.question);
178
- const typeTag = `${DIM}[${q.type}]${RESET}`;
206
+ for (let i = 0; i < state.interactions.length; i++) {
207
+ const interaction = state.interactions[i];
208
+ const response = state.responses.get(interaction.id);
209
+ const icon = response ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
210
+ const label = singleLine(interaction.title);
179
211
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
180
- const labelMax = Math.max(10, cols - 20);
212
+ const labelMax = Math.max(10, cols - 16);
181
213
  rowsBuf.push({
182
- line: ` ${cursor}${icon} ${truncate(label, labelMax)} ${typeTag}`,
214
+ line: ` ${cursor}${icon} ${truncate(label, labelMax)}`,
183
215
  questionIndex: i,
184
216
  });
185
- if (answer) {
186
- const summary = singleLine(answerSummary(answer));
217
+ if (response) {
218
+ const summary = singleLine(responseSummary(response, interaction));
187
219
  const summaryMax = Math.max(10, cols - 10);
188
220
  rowsBuf.push({
189
221
  line: ` ${DIM}${truncate(summary, summaryMax)}${RESET}`,
@@ -191,11 +223,9 @@ export function renderOverview(state) {
191
223
  });
192
224
  }
193
225
  }
194
- // Reserve space for header (4 already pushed) + footer (3) + scroll hints (2).
195
226
  const reserved = 4 + 3 + 2;
196
227
  const available = Math.max(1, rows - reserved);
197
228
  let scroll = state.scrollOffset || 0;
198
- // Find first row matching currentIndex; ensure it's in [scroll, scroll+available).
199
229
  const focusRow = rowsBuf.findIndex((r) => r.questionIndex === state.currentIndex);
200
230
  if (focusRow >= 0) {
201
231
  if (focusRow < scroll)
@@ -204,7 +234,6 @@ export function renderOverview(state) {
204
234
  scroll = focusRow - available + 1;
205
235
  }
206
236
  scroll = Math.max(0, Math.min(scroll, Math.max(0, rowsBuf.length - available)));
207
- state.scrollOffset = scroll;
208
237
  if (scroll > 0) {
209
238
  lines.push(` ${DIM}↑ ${scroll} more above${RESET}`);
210
239
  }
@@ -224,131 +253,190 @@ export function renderOverview(state) {
224
253
  lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
225
254
  while (lines.length < rows)
226
255
  lines.push('');
227
- return lines.slice(0, rows);
256
+ // Overview content extends roughly cols-16 wide for option labels; center
257
+ // against a 60-col cap (the divider width) when the terminal is much wider.
258
+ const centered = centerHorizontal(lines.slice(0, rows), cols, Math.min(cols, 60) + 2);
259
+ return centered;
228
260
  }
229
- export function renderItemReview(state) {
230
- const { cols, rows } = getTerminalSize();
231
- const lines = [];
232
- const q = state.questions[state.currentIndex];
233
- const visual = state.visuals.get(q.id);
234
- const answer = state.answers.get(q.id);
235
- const maxW = Math.min(cols - 4, 76);
236
- // Header
237
- const pos = `${state.currentIndex + 1}/${state.questions.length}`;
238
- lines.push('');
239
- lines.push(` ${BOLD}${CYAN}[${pos}]${RESET} ${DIM}${q.type}${RESET}`);
240
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
241
- lines.push('');
242
- // Question / Statement
243
- const headline = sanitize(q.type === 'validation' ? q.statement : q.question);
244
- for (const line of wrap(headline, maxW)) {
245
- lines.push(` ${BOLD}${line}${RESET}`);
261
+ export function renderItemReview(state, cols, rows) {
262
+ const interaction = state.interactions[state.currentIndex];
263
+ const visual = state.visuals.get(interaction.id);
264
+ const response = state.responses.get(interaction.id);
265
+ const maxW = Math.min(cols - 4, 120);
266
+ // Pre-body: position, divider, title, subtitle (always visible)
267
+ const preLines = [];
268
+ const pos = `${state.currentIndex + 1}/${state.interactions.length}`;
269
+ preLines.push('');
270
+ preLines.push(` ${BOLD}${CYAN}[${pos}]${RESET}`);
271
+ preLines.push(` ${DIM}${hline(maxW)}${RESET}`);
272
+ preLines.push('');
273
+ for (const line of wrap(sanitize(interaction.title), maxW)) {
274
+ preLines.push(` ${BOLD}${line}${RESET}`);
246
275
  }
247
- for (const line of wrap(sanitize(q.rationale), maxW)) {
248
- lines.push(` ${ITALIC}${GRAY}${line}${RESET}`);
276
+ if (interaction.subtitle) {
277
+ for (const line of wrap(sanitize(interaction.subtitle), maxW)) {
278
+ preLines.push(` ${DIM}${line}${RESET}`);
279
+ }
249
280
  }
250
- lines.push('');
251
- // Visual context
281
+ // Body: rendered question body + expanded visual block (scrollable)
282
+ const bodyLines = [];
283
+ if (interaction.body) {
284
+ bodyLines.push('');
285
+ for (const line of renderBody(interaction.body, maxW)) {
286
+ bodyLines.push(` ${line}`);
287
+ }
288
+ }
289
+ if (visual && visual.status === 'ready' && state.detailExpanded) {
290
+ bodyLines.push('');
291
+ bodyLines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
292
+ for (const vl of visual.content.split('\n')) {
293
+ bodyLines.push(` ${vl}`);
294
+ }
295
+ bodyLines.push(` ${DIM}${hline(maxW)}${RESET}`);
296
+ }
297
+ // Post-body: visual status hint, input buffer or actions, footer (always visible)
298
+ const postLines = [];
299
+ postLines.push('');
252
300
  if (visual) {
253
301
  if (visual.status === 'loading') {
254
- lines.push(` ${DIM}loading context...${RESET}`);
302
+ postLines.push(` ${DIM}loading context...${RESET}`);
303
+ postLines.push('');
255
304
  }
256
305
  else if (visual.status === 'error') {
257
- lines.push(` ${YELLOW}visual context unavailable${RESET}`);
258
- }
259
- else if (state.detailExpanded) {
260
- lines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
261
- for (const vl of visual.content.split('\n')) {
262
- lines.push(` ${vl}`);
263
- }
264
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
306
+ postLines.push(` ${YELLOW}visual context unavailable${RESET}`);
307
+ postLines.push('');
265
308
  }
266
- else {
267
- lines.push(` ${DIM}[space] expand context${RESET}`);
309
+ else if (!state.detailExpanded) {
310
+ postLines.push(` ${DIM}[space] expand context${RESET}`);
311
+ postLines.push('');
268
312
  }
269
- lines.push('');
270
313
  }
271
- // Input mode
272
314
  if (state.inputMode) {
273
- lines.push(` ${DIM}${hline(maxW)}${RESET}`);
274
- const label = state.inputMode.kind === 'comment' ? 'Comment'
275
- : state.inputMode.kind === 'freetext' ? 'Response'
276
- : 'Custom option';
277
- lines.push(` ${YELLOW}${label}:${RESET}`);
315
+ postLines.push(` ${DIM}${hline(maxW)}${RESET}`);
316
+ const label = interaction.freetextLabel !== undefined
317
+ ? interaction.freetextLabel
318
+ : state.inputMode.kind === 'comment' ? 'Comment' : 'Response';
319
+ // Show attached option (comment mode only) — Tab cycles
320
+ let attachedLine;
321
+ if (state.inputMode.kind === 'comment') {
322
+ const attachedId = state.inputMode.selectedOptionId;
323
+ const opts = interaction.options;
324
+ if (opts.length > 0) {
325
+ const attached = attachedId !== undefined
326
+ ? opts.find((o) => o.id === attachedId)
327
+ : undefined;
328
+ const valueText = attached !== undefined
329
+ ? `${CYAN}${singleLine(attached.label)}${RESET}`
330
+ : `${DIM}none${RESET}`;
331
+ attachedLine = ` ${DIM}attached:${RESET} ${valueText} ${DIM}[tab to cycle]${RESET}`;
332
+ }
333
+ }
334
+ postLines.push(` ${YELLOW}${label}:${RESET}`);
278
335
  const bufLines = hardWrap(state.inputMode.buffer, maxW - 1);
279
336
  for (let i = 0; i < bufLines.length; i++) {
280
337
  const isLast = i === bufLines.length - 1;
281
- lines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
338
+ postLines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
282
339
  }
283
- lines.push('');
284
- lines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
340
+ if (attachedLine !== undefined) {
341
+ postLines.push('');
342
+ postLines.push(attachedLine);
343
+ }
344
+ postLines.push('');
345
+ postLines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
285
346
  }
286
347
  else {
287
- // Actions
288
- lines.push(...renderActions(q, state.selectedAction, answer));
348
+ postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
289
349
  }
290
- // Footer
291
- while (lines.length < rows - 1)
292
- lines.push('');
350
+ // Window the body
351
+ const reservedRows = preLines.length + postLines.length + 1; // +1 for footer
352
+ const bodyHeight = Math.max(1, rows - reservedRows);
353
+ const overflows = bodyLines.length > bodyHeight;
354
+ let scroll = state.scrollOffset || 0;
355
+ const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
356
+ scroll = Math.max(0, Math.min(scroll, maxScroll));
357
+ state.scrollOffset = scroll;
358
+ let visibleBody;
359
+ if (overflows) {
360
+ visibleBody = bodyLines.slice(scroll, scroll + bodyHeight);
361
+ if (scroll > 0) {
362
+ visibleBody[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
363
+ }
364
+ const remainingBelow = bodyLines.length - (scroll + bodyHeight);
365
+ if (remainingBelow > 0) {
366
+ visibleBody[visibleBody.length - 1] = ` ${DIM}↓ ${remainingBelow} more below${RESET}`;
367
+ }
368
+ }
369
+ else {
370
+ visibleBody = bodyLines;
371
+ }
372
+ // Footer hint — mention scroll keys when body overflows
293
373
  const footerParts = [
294
374
  `${DIM}n/p${RESET} prev/next`,
295
375
  `${DIM}space${RESET} expand`,
296
376
  `${DIM}q${RESET} overview`,
297
377
  ];
298
- lines.push(` ${footerParts.join(' ')}`);
299
- // If the headline + visual + actions overflowed the viewport, the footer
300
- // would otherwise scroll off the bottom. Clip to `rows` so flush() never
301
- // writes more rows than the terminal has.
302
- if (lines.length > rows) {
303
- return [...lines.slice(0, rows - 1), lines[lines.length - 1]];
304
- }
305
- return lines;
378
+ if (overflows)
379
+ footerParts.unshift(`${DIM}u/d${RESET} scroll`);
380
+ const footer = ` ${footerParts.join(' ')}`;
381
+ // Assemble pad to fill rows so post-body sits at the bottom
382
+ const lines = [...preLines, ...visibleBody, ...postLines];
383
+ while (lines.length < rows - 1)
384
+ lines.push('');
385
+ lines.push(footer);
386
+ // Final clamp (safety net for very small terminals)
387
+ const clamped = lines.length > rows
388
+ ? [...lines.slice(0, rows - 1), footer]
389
+ : lines;
390
+ // Content occupies maxW cols of body + 2 cols of left prefix — center the
391
+ // whole block when the terminal is wider than that.
392
+ return centerHorizontal(clamped, cols, maxW + 2);
306
393
  }
307
- function renderActions(q, selectedAction, existing) {
394
+ function renderActions(interaction, selectedAction, maxW, existing) {
308
395
  const lines = [];
309
- if (q.type === 'validation') {
310
- const actions = [
311
- { key: '1', label: 'Approve', desc: 'accept as stated' },
312
- { key: '2', label: 'Approve + comment', desc: 'accept with note' },
313
- { key: '3', label: 'Reject', desc: 'do not accept as stated' },
314
- { key: '4', label: 'Comment', desc: 'feedback without decision' },
315
- ];
316
- for (let i = 0; i < actions.length; i++) {
317
- const a = actions[i];
318
- const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
319
- const keyBadge = `${DIM}[${a.key}]${RESET}`;
320
- lines.push(` ${cursor} ${keyBadge} ${a.label} ${DIM}— ${a.desc}${RESET}`);
396
+ const opts = interaction.options;
397
+ // Prefix on first row: " X [s] " — 2 + 1 (cursor) + 1 + 3 ([s]) + 1 = 8 visible cols.
398
+ // Continuation rows align under the label so each option reads as a block.
399
+ const prefixWidth = 8;
400
+ const indent = ' '.repeat(prefixWidth);
401
+ const contentMax = Math.max(20, maxW - prefixWidth);
402
+ for (let i = 0; i < opts.length; i++) {
403
+ const o = opts[i];
404
+ const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
405
+ const sc = o.shortcut ?? ' ';
406
+ const keyBadge = `${DIM}[${sc}]${RESET}`;
407
+ const labelLines = wrap(sanitize(o.label), contentMax);
408
+ for (let j = 0; j < labelLines.length; j++) {
409
+ const prefix = j === 0 ? ` ${cursor} ${keyBadge} ` : indent;
410
+ lines.push(`${prefix}${labelLines[j]}`);
321
411
  }
322
- }
323
- else if (q.type === 'choice') {
324
- for (let i = 0; i < q.options.length; i++) {
325
- const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
326
- // Numeric shortcut only for 1..9 — past that, the digit '1' would fire
327
- // before the user can type the second digit, so we use a blank pad.
328
- const keyBadge = i < 9 ? `${DIM}[${i + 1}]${RESET}` : `${DIM} ${RESET}`;
329
- lines.push(` ${cursor} ${keyBadge} ${sanitize(q.options[i])}`);
412
+ if (o.description) {
413
+ const descLines = wrap(`— ${sanitize(o.description)}`, contentMax);
414
+ for (const dl of descLines) {
415
+ lines.push(`${indent}${DIM}${dl}${RESET}`);
416
+ }
330
417
  }
331
- const otherIdx = q.options.length;
332
- const cursor = otherIdx === selectedAction ? `${CYAN}▸${RESET}` : ' ';
333
- const otherBadge = otherIdx < 9 ? `${DIM}[${otherIdx + 1}]${RESET}` : `${DIM} ${RESET}`;
334
- lines.push(` ${cursor} ${otherBadge} ${ITALIC}Other (custom)${RESET}`);
335
418
  }
336
- else {
337
- lines.push(` ${DIM}[r]${RESET} Enter response`);
419
+ if (interaction.allowFreetext && opts.length > 0) {
420
+ const cursor = opts.length === selectedAction ? `${CYAN}▸${RESET}` : ' ';
421
+ const label = interaction.freetextLabel !== undefined ? interaction.freetextLabel : 'Add comment';
422
+ lines.push(` ${cursor} ${DIM}[c]${RESET} ${label}`);
423
+ }
424
+ else if (interaction.allowFreetext && opts.length === 0) {
425
+ const ftLabel = interaction.freetextLabel !== undefined ? interaction.freetextLabel : 'Enter response';
426
+ lines.push(` ${DIM}[r]${RESET} ${ftLabel}`);
338
427
  }
339
428
  if (existing) {
340
429
  lines.push('');
341
- lines.push(` ${GREEN}Current: ${answerSummary(existing)}${RESET}`);
430
+ lines.push(` ${GREEN}Current: ${responseSummary(existing, interaction)}${RESET}`);
342
431
  }
343
432
  return lines;
344
433
  }
345
- export function renderFinal(state) {
346
- const { cols, rows } = getTerminalSize();
434
+ export function renderFinal(state, cols, rows) {
347
435
  const header = [];
348
436
  const footer = [];
349
437
  const maxW = Math.min(cols - 4, 60);
350
- const total = state.questions.length;
351
- const answered = state.answers.size;
438
+ const total = state.interactions.length;
439
+ const answered = state.responses.size;
352
440
  header.push('');
353
441
  header.push(` ${BOLD}${CYAN} Summary ${RESET}`);
354
442
  header.push(` ${DIM}${hline(maxW)}${RESET}`);
@@ -361,17 +449,14 @@ export function renderFinal(state) {
361
449
  footer.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
362
450
  }
363
451
  footer.push(` ${DIM}enter${RESET} submit ${DIM}p${RESET} go back`);
364
- // Build per-question rows so we can clip to fit the viewport while
365
- // keeping the header + footer always visible (the keybind hint at the
366
- // bottom is essential — without it the user can't submit).
367
452
  const questionRows = [];
368
- for (const q of state.questions) {
369
- const answer = state.answers.get(q.id);
370
- const icon = answer ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
371
- const label = singleLine(q.type === 'validation' ? q.statement : q.question);
453
+ for (const interaction of state.interactions) {
454
+ const response = state.responses.get(interaction.id);
455
+ const icon = response ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
456
+ const label = singleLine(interaction.title);
372
457
  questionRows.push(` ${icon} ${truncate(label, Math.max(10, maxW - 4))}`);
373
- if (answer) {
374
- questionRows.push(` ${DIM}${truncate(singleLine(answerSummary(answer)), Math.max(10, maxW - 6))}${RESET}`);
458
+ if (response) {
459
+ questionRows.push(` ${DIM}${truncate(singleLine(responseSummary(response, interaction)), Math.max(10, maxW - 6))}${RESET}`);
375
460
  }
376
461
  }
377
462
  const available = Math.max(1, rows - header.length - footer.length - 1);
@@ -385,17 +470,17 @@ export function renderFinal(state) {
385
470
  const lines = [...header, ...visible, ...footer];
386
471
  while (lines.length < rows)
387
472
  lines.push('');
388
- return lines.slice(0, rows);
473
+ return centerHorizontal(lines.slice(0, rows), cols, maxW + 2);
389
474
  }
390
- function answerSummary(a) {
391
- switch (a.type) {
392
- case 'validation':
393
- return a.approved
394
- ? (a.comment ? `approved: "${sanitize(a.comment)}"` : 'approved')
395
- : (a.comment ? `commented: "${sanitize(a.comment)}"` : 'commented');
396
- case 'choice':
397
- return a.isCustom ? `custom: "${sanitize(a.selected)}"` : sanitize(a.selected);
398
- case 'freetext':
399
- return sanitize(a.response);
400
- }
475
+ export function responseSummary(r, interaction) {
476
+ const opt = r.selectedOptionId
477
+ ? interaction.options.find((o) => o.id === r.selectedOptionId)
478
+ : undefined;
479
+ if (opt && r.freetext)
480
+ return `${sanitize(opt.label)}: "${sanitize(r.freetext)}"`;
481
+ if (opt)
482
+ return sanitize(opt.label);
483
+ if (r.freetext)
484
+ return sanitize(r.freetext);
485
+ return '(empty)';
401
486
  }
@@ -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>;