@crouton-kit/humanloop 0.1.2 → 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,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) {
@@ -146,44 +167,39 @@ function hardWrap(text, maxWidth) {
146
167
  return out;
147
168
  }
148
169
  // ── Frame buffer ─────────────────────────────────────────────────────────────
149
- let prevFrame = [];
150
- export function flush(lines) {
151
- const { rows } = getTerminalSize();
152
- process.stdout.write('\x1b[?2026h');
170
+ export function diffFrame(prevFrame, nextLines, rows) {
171
+ const writes = [];
153
172
  for (let i = 0; i < rows; i++) {
154
- const line = i < lines.length ? lines[i] : '';
173
+ const line = i < nextLines.length ? nextLines[i] : '';
155
174
  if (prevFrame[i] !== line) {
156
- process.stdout.write(`${ESC}${i + 1};1H${ESC}2K${line}`);
175
+ writes.push(`${ESC}${i + 1};1H${ESC}2K${line}`);
157
176
  }
158
177
  }
159
- process.stdout.write('\x1b[?2026l');
160
- prevFrame = [...lines];
178
+ return { writes, nextPrevFrame: [...nextLines] };
161
179
  }
162
180
  // ── Renderers ────────────────────────────────────────────────────────────────
163
- export function renderOverview(state) {
164
- const { cols, rows } = getTerminalSize();
181
+ export function renderOverview(state, cols, rows) {
165
182
  const lines = [];
166
183
  const title = `${BOLD}${CYAN} Decisions ${RESET}`;
167
- const progress = `${state.answers.size}/${state.questions.length} answered`;
184
+ const progress = `${state.responses.size}/${state.interactions.length} answered`;
168
185
  lines.push('');
169
186
  lines.push(` ${title} ${DIM}${progress}${RESET}`);
170
187
  lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
171
188
  lines.push('');
172
189
  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}`;
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);
179
195
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
180
- const labelMax = Math.max(10, cols - 20);
196
+ const labelMax = Math.max(10, cols - 16);
181
197
  rowsBuf.push({
182
- line: ` ${cursor}${icon} ${truncate(label, labelMax)} ${typeTag}`,
198
+ line: ` ${cursor}${icon} ${truncate(label, labelMax)}`,
183
199
  questionIndex: i,
184
200
  });
185
- if (answer) {
186
- const summary = singleLine(answerSummary(answer));
201
+ if (response) {
202
+ const summary = singleLine(responseSummary(response, interaction));
187
203
  const summaryMax = Math.max(10, cols - 10);
188
204
  rowsBuf.push({
189
205
  line: ` ${DIM}${truncate(summary, summaryMax)}${RESET}`,
@@ -191,11 +207,9 @@ export function renderOverview(state) {
191
207
  });
192
208
  }
193
209
  }
194
- // Reserve space for header (4 already pushed) + footer (3) + scroll hints (2).
195
210
  const reserved = 4 + 3 + 2;
196
211
  const available = Math.max(1, rows - reserved);
197
212
  let scroll = state.scrollOffset || 0;
198
- // Find first row matching currentIndex; ensure it's in [scroll, scroll+available).
199
213
  const focusRow = rowsBuf.findIndex((r) => r.questionIndex === state.currentIndex);
200
214
  if (focusRow >= 0) {
201
215
  if (focusRow < scroll)
@@ -204,7 +218,6 @@ export function renderOverview(state) {
204
218
  scroll = focusRow - available + 1;
205
219
  }
206
220
  scroll = Math.max(0, Math.min(scroll, Math.max(0, rowsBuf.length - available)));
207
- state.scrollOffset = scroll;
208
221
  if (scroll > 0) {
209
222
  lines.push(` ${DIM}↑ ${scroll} more above${RESET}`);
210
223
  }
@@ -226,129 +239,169 @@ export function renderOverview(state) {
226
239
  lines.push('');
227
240
  return lines.slice(0, rows);
228
241
  }
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}`);
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}`);
246
256
  }
247
- for (const line of wrap(sanitize(q.rationale), maxW)) {
248
- 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
+ }
249
261
  }
250
- lines.push('');
251
- // 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('');
252
281
  if (visual) {
253
282
  if (visual.status === 'loading') {
254
- lines.push(` ${DIM}loading context...${RESET}`);
283
+ postLines.push(` ${DIM}loading context...${RESET}`);
284
+ postLines.push('');
255
285
  }
256
286
  else if (visual.status === 'error') {
257
- lines.push(` ${YELLOW}visual context unavailable${RESET}`);
287
+ postLines.push(` ${YELLOW}visual context unavailable${RESET}`);
288
+ postLines.push('');
258
289
  }
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}`);
290
+ else if (!state.detailExpanded) {
291
+ postLines.push(` ${DIM}[space] expand context${RESET}`);
292
+ postLines.push('');
265
293
  }
266
- else {
267
- lines.push(` ${DIM}[space] expand context${RESET}`);
268
- }
269
- lines.push('');
270
294
  }
271
- // Input mode
272
295
  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}`);
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}`);
278
316
  const bufLines = hardWrap(state.inputMode.buffer, maxW - 1);
279
317
  for (let i = 0; i < bufLines.length; i++) {
280
318
  const isLast = i === bufLines.length - 1;
281
- lines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
319
+ postLines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
282
320
  }
283
- lines.push('');
284
- 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`);
285
327
  }
286
328
  else {
287
- // Actions
288
- lines.push(...renderActions(q, state.selectedAction, answer));
329
+ postLines.push(...renderActions(interaction, state.selectedAction, response));
289
330
  }
290
- // Footer
291
- while (lines.length < rows - 1)
292
- 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
293
354
  const footerParts = [
294
355
  `${DIM}n/p${RESET} prev/next`,
295
356
  `${DIM}space${RESET} expand`,
296
357
  `${DIM}q${RESET} overview`,
297
358
  ];
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.
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)
302
368
  if (lines.length > rows) {
303
- return [...lines.slice(0, rows - 1), lines[lines.length - 1]];
369
+ return [...lines.slice(0, rows - 1), footer];
304
370
  }
305
371
  return lines;
306
372
  }
307
- function renderActions(q, selectedAction, existing) {
373
+ function renderActions(interaction, selectedAction, existing) {
308
374
  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}`);
321
- }
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}`);
322
383
  }
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])}`);
330
- }
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}`);
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}`);
335
388
  }
336
- else {
337
- 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}`);
338
392
  }
339
393
  if (existing) {
340
394
  lines.push('');
341
- lines.push(` ${GREEN}Current: ${answerSummary(existing)}${RESET}`);
395
+ lines.push(` ${GREEN}Current: ${responseSummary(existing, interaction)}${RESET}`);
342
396
  }
343
397
  return lines;
344
398
  }
345
- export function renderFinal(state) {
346
- const { cols, rows } = getTerminalSize();
399
+ export function renderFinal(state, cols, rows) {
347
400
  const header = [];
348
401
  const footer = [];
349
402
  const maxW = Math.min(cols - 4, 60);
350
- const total = state.questions.length;
351
- const answered = state.answers.size;
403
+ const total = state.interactions.length;
404
+ const answered = state.responses.size;
352
405
  header.push('');
353
406
  header.push(` ${BOLD}${CYAN} Summary ${RESET}`);
354
407
  header.push(` ${DIM}${hline(maxW)}${RESET}`);
@@ -361,17 +414,14 @@ export function renderFinal(state) {
361
414
  footer.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
362
415
  }
363
416
  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
417
  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);
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);
372
422
  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}`);
423
+ if (response) {
424
+ questionRows.push(` ${DIM}${truncate(singleLine(responseSummary(response, interaction)), Math.max(10, maxW - 6))}${RESET}`);
375
425
  }
376
426
  }
377
427
  const available = Math.max(1, rows - header.length - footer.length - 1);
@@ -387,15 +437,15 @@ export function renderFinal(state) {
387
437
  lines.push('');
388
438
  return lines.slice(0, rows);
389
439
  }
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
- }
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)';
401
451
  }
@@ -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/types.d.ts CHANGED
@@ -1,50 +1,36 @@
1
- export type QuestionType = 'validation' | 'choice' | 'freetext';
2
- export interface ValidationQuestion {
1
+ import type { Key } from './tui/terminal.js';
2
+ export type InteractionKind = 'notify' | 'validation' | 'decision' | 'context' | 'error';
3
+ export interface InteractionOption {
3
4
  id: string;
4
- type: 'validation';
5
- statement: string;
6
- rationale: string;
5
+ label: string;
6
+ description?: string;
7
+ shortcut?: string;
7
8
  }
8
- export interface ChoiceQuestion {
9
+ export interface Interaction {
9
10
  id: string;
10
- type: 'choice';
11
- question: string;
12
- rationale: string;
13
- options: string[];
11
+ title: string;
12
+ subtitle?: string;
13
+ body?: string;
14
+ bodyPath?: string;
15
+ options: InteractionOption[];
16
+ allowFreetext?: boolean;
17
+ freetextLabel?: string;
18
+ kind?: InteractionKind;
14
19
  }
15
- export interface FreetextQuestion {
20
+ export interface InteractionResponse {
16
21
  id: string;
17
- type: 'freetext';
18
- question: string;
19
- rationale: string;
22
+ selectedOptionId?: string;
23
+ freetext?: string;
20
24
  }
21
- export type Question = ValidationQuestion | ChoiceQuestion | FreetextQuestion;
22
- export interface DecisionsInput {
23
- title?: string;
24
- questions: Question[];
25
- }
26
- export interface ValidationAnswer {
27
- id: string;
28
- type: 'validation';
29
- approved: boolean;
30
- comment?: string;
31
- }
32
- export interface ChoiceAnswer {
33
- id: string;
34
- type: 'choice';
35
- selected: string;
36
- isCustom: boolean;
37
- comment?: string;
38
- }
39
- export interface FreetextAnswer {
40
- id: string;
41
- type: 'freetext';
42
- response: string;
25
+ export interface DeckSource {
26
+ sessionName?: string;
27
+ askedBy?: string;
28
+ blockedSince?: string;
43
29
  }
44
- export type Answer = ValidationAnswer | ChoiceAnswer | FreetextAnswer;
45
- export interface DecisionsOutput {
46
- answers: Answer[];
47
- completedAt: string;
30
+ export interface Deck {
31
+ title?: string;
32
+ source?: DeckSource;
33
+ interactions: Interaction[];
48
34
  }
49
35
  export interface VisualBlock {
50
36
  questionId: string;
@@ -55,18 +41,16 @@ export type Phase = 'overview' | 'item-review' | 'final';
55
41
  export type InputMode = null | {
56
42
  kind: 'comment';
57
43
  buffer: string;
44
+ selectedOptionId?: string;
58
45
  } | {
59
46
  kind: 'freetext';
60
47
  buffer: string;
61
- } | {
62
- kind: 'custom-option';
63
- buffer: string;
64
48
  };
65
49
  export interface TuiState {
66
50
  phase: Phase;
67
51
  currentIndex: number;
68
- questions: Question[];
69
- answers: Map<string, Answer>;
52
+ interactions: Interaction[];
53
+ responses: Map<string, InteractionResponse>;
70
54
  visuals: Map<string, VisualBlock>;
71
55
  inputMode: InputMode;
72
56
  selectedAction: number;
@@ -74,3 +58,31 @@ export interface TuiState {
74
58
  scrollOffset: number;
75
59
  persist?: () => void;
76
60
  }
61
+ export type GenerateVisual = (interaction: Interaction) => Promise<{
62
+ ok: true;
63
+ ansi: string;
64
+ markdown: string;
65
+ } | {
66
+ ok: false;
67
+ error: string;
68
+ }>;
69
+ export interface MountedPanelOpts {
70
+ deck: Deck;
71
+ progressPath?: string;
72
+ generateVisual?: GenerateVisual;
73
+ cols: number;
74
+ rows: number;
75
+ onProgress?: (responses: InteractionResponse[]) => void;
76
+ onComplete?: (responses: InteractionResponse[]) => void;
77
+ onExit?: () => void;
78
+ }
79
+ export interface MountedPanel {
80
+ handleKey(input: string, key: Key): void;
81
+ render(): string[];
82
+ handleResize(cols: number, rows: number): string[];
83
+ unmount(): void;
84
+ loadDeck(deck: Deck, opts?: {
85
+ progressPath?: string;
86
+ }): void;
87
+ canAcceptHostKeys(): boolean;
88
+ }
package/dist/types.js CHANGED
@@ -1,2 +1,2 @@
1
- // ── Input: what the agent writes ─────────────────────────────────────────────
1
+ // ── v2 shapes (v1 schema dropped per cycle-16 user pivot — humanloop is v2-only) ──
2
2
  export {};