@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.
- package/dist/cli.js +81 -91
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/tui/app.d.ts +7 -3
- package/dist/tui/app.js +284 -152
- package/dist/tui/input.d.ts +2 -1
- package/dist/tui/input.js +123 -120
- package/dist/tui/render.d.ts +9 -5
- package/dist/tui/render.js +199 -149
- package/dist/tui/tmux.d.ts +6 -2
- package/dist/types.d.ts +57 -45
- package/dist/types.js +1 -1
- package/dist/visuals/generate.d.ts +9 -4
- package/dist/visuals/generate.js +30 -39
- package/package.json +14 -2
package/dist/tui/render.js
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import stringWidth from 'string-width';
|
|
2
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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 <
|
|
173
|
+
const line = i < nextLines.length ? nextLines[i] : '';
|
|
155
174
|
if (prevFrame[i] !== line) {
|
|
156
|
-
|
|
175
|
+
writes.push(`${ESC}${i + 1};1H${ESC}2K${line}`);
|
|
157
176
|
}
|
|
158
177
|
}
|
|
159
|
-
|
|
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.
|
|
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.
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const icon =
|
|
177
|
-
const label = singleLine(
|
|
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 -
|
|
196
|
+
const labelMax = Math.max(10, cols - 16);
|
|
181
197
|
rowsBuf.push({
|
|
182
|
-
line: ` ${cursor}${icon} ${truncate(label, labelMax)}
|
|
198
|
+
line: ` ${cursor}${icon} ${truncate(label, labelMax)}`,
|
|
183
199
|
questionIndex: i,
|
|
184
200
|
});
|
|
185
|
-
if (
|
|
186
|
-
const summary = singleLine(
|
|
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
|
|
231
|
-
const
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
248
|
-
|
|
257
|
+
if (interaction.subtitle) {
|
|
258
|
+
for (const line of wrap(sanitize(interaction.subtitle), maxW)) {
|
|
259
|
+
preLines.push(` ${DIM}${line}${RESET}`);
|
|
260
|
+
}
|
|
249
261
|
}
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
283
|
+
postLines.push(` ${DIM}loading context...${RESET}`);
|
|
284
|
+
postLines.push('');
|
|
255
285
|
}
|
|
256
286
|
else if (visual.status === 'error') {
|
|
257
|
-
|
|
287
|
+
postLines.push(` ${YELLOW}visual context unavailable${RESET}`);
|
|
288
|
+
postLines.push('');
|
|
258
289
|
}
|
|
259
|
-
else if (state.detailExpanded) {
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
274
|
-
const label =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
319
|
+
postLines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
|
|
282
320
|
}
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
lines.push(...renderActions(q, state.selectedAction, answer));
|
|
329
|
+
postLines.push(...renderActions(interaction, state.selectedAction, response));
|
|
289
330
|
}
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
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),
|
|
369
|
+
return [...lines.slice(0, rows - 1), footer];
|
|
304
370
|
}
|
|
305
371
|
return lines;
|
|
306
372
|
}
|
|
307
|
-
function renderActions(
|
|
373
|
+
function renderActions(interaction, selectedAction, existing) {
|
|
308
374
|
const lines = [];
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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: ${
|
|
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.
|
|
351
|
-
const answered = state.
|
|
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
|
|
369
|
-
const
|
|
370
|
-
const icon =
|
|
371
|
-
const label = singleLine(
|
|
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 (
|
|
374
|
-
questionRows.push(` ${DIM}${truncate(singleLine(
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
}
|
package/dist/tui/tmux.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type {
|
|
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<
|
|
10
|
+
export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<TuiOutput>;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,50 +1,36 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
label: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
shortcut?: string;
|
|
7
8
|
}
|
|
8
|
-
export interface
|
|
9
|
+
export interface Interaction {
|
|
9
10
|
id: string;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
20
|
+
export interface InteractionResponse {
|
|
16
21
|
id: string;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
rationale: string;
|
|
22
|
+
selectedOptionId?: string;
|
|
23
|
+
freetext?: string;
|
|
20
24
|
}
|
|
21
|
-
export
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
// ──
|
|
1
|
+
// ── v2 shapes (v1 schema dropped per cycle-16 user pivot — humanloop is v2-only) ──
|
|
2
2
|
export {};
|