@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.
- 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 +288 -153
- package/dist/tui/input.d.ts +2 -1
- package/dist/tui/input.js +142 -123
- package/dist/tui/render.d.ts +9 -5
- package/dist/tui/render.js +238 -153
- 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) {
|
|
@@ -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
|
-
|
|
150
|
-
|
|
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 <
|
|
189
|
+
const line = i < nextLines.length ? nextLines[i] : '';
|
|
155
190
|
if (prevFrame[i] !== line) {
|
|
156
|
-
|
|
191
|
+
writes.push(`${ESC}${i + 1};1H${ESC}2K${line}`);
|
|
157
192
|
}
|
|
158
193
|
}
|
|
159
|
-
|
|
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.
|
|
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.
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
const icon =
|
|
177
|
-
const label = singleLine(
|
|
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 -
|
|
212
|
+
const labelMax = Math.max(10, cols - 16);
|
|
181
213
|
rowsBuf.push({
|
|
182
|
-
line: ` ${cursor}${icon} ${truncate(label, labelMax)}
|
|
214
|
+
line: ` ${cursor}${icon} ${truncate(label, labelMax)}`,
|
|
183
215
|
questionIndex: i,
|
|
184
216
|
});
|
|
185
|
-
if (
|
|
186
|
-
const summary = singleLine(
|
|
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
|
-
|
|
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
|
|
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}`);
|
|
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
|
-
|
|
248
|
-
|
|
276
|
+
if (interaction.subtitle) {
|
|
277
|
+
for (const line of wrap(sanitize(interaction.subtitle), maxW)) {
|
|
278
|
+
preLines.push(` ${DIM}${line}${RESET}`);
|
|
279
|
+
}
|
|
249
280
|
}
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
302
|
+
postLines.push(` ${DIM}loading context...${RESET}`);
|
|
303
|
+
postLines.push('');
|
|
255
304
|
}
|
|
256
305
|
else if (visual.status === 'error') {
|
|
257
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
const label =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
338
|
+
postLines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
|
|
282
339
|
}
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
lines.push(...renderActions(q, state.selectedAction, answer));
|
|
348
|
+
postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
|
|
289
349
|
}
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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(
|
|
394
|
+
function renderActions(interaction, selectedAction, maxW, existing) {
|
|
308
395
|
const lines = [];
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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])}`);
|
|
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
|
-
|
|
337
|
-
|
|
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: ${
|
|
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.
|
|
351
|
-
const answered = state.
|
|
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
|
|
369
|
-
const
|
|
370
|
-
const icon =
|
|
371
|
-
const label = singleLine(
|
|
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 (
|
|
374
|
-
questionRows.push(` ${DIM}${truncate(singleLine(
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
}
|
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>;
|