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