@crouton-kit/humanloop 0.1.0 → 0.1.2
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 +3 -0
- package/dist/tui/app.d.ts +2 -1
- package/dist/tui/app.js +70 -4
- package/dist/tui/input.js +14 -3
- package/dist/tui/render.d.ts +1 -0
- package/dist/tui/render.js +172 -50
- package/dist/tui/terminal.js +5 -0
- package/dist/tui/tmux.js +20 -2
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -139,6 +139,9 @@ program
|
|
|
139
139
|
else if (msg.includes('JSON')) {
|
|
140
140
|
process.stderr.write('\nFix: the decisions file must be valid JSON matching `hl schema`.\n');
|
|
141
141
|
}
|
|
142
|
+
else if (msg.startsWith('questions[') || msg.includes('Duplicate question id') || msg.includes('must be')) {
|
|
143
|
+
process.stderr.write('\nFix: the decisions file must match `hl schema`. Run `hl schema` to see the required shape.\n');
|
|
144
|
+
}
|
|
142
145
|
process.exit(1);
|
|
143
146
|
}
|
|
144
147
|
});
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import type { DecisionsOutput } from '../types.js';
|
|
1
|
+
import type { DecisionsInput, DecisionsOutput } from '../types.js';
|
|
2
|
+
export declare function validateInput(parsed: unknown): DecisionsInput;
|
|
2
3
|
export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<DecisionsOutput>;
|
package/dist/tui/app.js
CHANGED
|
@@ -4,15 +4,81 @@ import { flush, renderOverview, renderItemReview, renderFinal } from './render.j
|
|
|
4
4
|
import { handleKeypress } from './input.js';
|
|
5
5
|
import { readConversation } from '../conversation/reader.js';
|
|
6
6
|
import { generateVisuals } from '../visuals/generate.js';
|
|
7
|
+
// Validate the parsed JSON before opening the terminal so bad agent input
|
|
8
|
+
// fails with a clear error instead of crashing inside the TUI.
|
|
9
|
+
export function validateInput(parsed) {
|
|
10
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
11
|
+
throw new Error('Decisions file must be a JSON object with a `questions` array');
|
|
12
|
+
}
|
|
13
|
+
const obj = parsed;
|
|
14
|
+
if (!Array.isArray(obj.questions)) {
|
|
15
|
+
throw new Error('`questions` must be an array');
|
|
16
|
+
}
|
|
17
|
+
if (obj.questions.length === 0) {
|
|
18
|
+
throw new Error('No questions in decisions file');
|
|
19
|
+
}
|
|
20
|
+
if (obj.title !== undefined && typeof obj.title !== 'string') {
|
|
21
|
+
throw new Error('`title` must be a string when present');
|
|
22
|
+
}
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const validated = [];
|
|
25
|
+
for (let i = 0; i < obj.questions.length; i++) {
|
|
26
|
+
const q = obj.questions[i];
|
|
27
|
+
const where = `questions[${i}]`;
|
|
28
|
+
if (typeof q !== 'object' || q === null || Array.isArray(q)) {
|
|
29
|
+
throw new Error(`${where} must be an object`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof q.id !== 'string' || q.id === '') {
|
|
32
|
+
throw new Error(`${where}.id must be a non-empty string`);
|
|
33
|
+
}
|
|
34
|
+
if (seen.has(q.id)) {
|
|
35
|
+
throw new Error(`Duplicate question id: ${JSON.stringify(q.id)}`);
|
|
36
|
+
}
|
|
37
|
+
seen.add(q.id);
|
|
38
|
+
if (q.type === 'validation') {
|
|
39
|
+
if (typeof q.statement !== 'string')
|
|
40
|
+
throw new Error(`${where}.statement must be a string`);
|
|
41
|
+
if (typeof q.rationale !== 'string')
|
|
42
|
+
throw new Error(`${where}.rationale must be a string`);
|
|
43
|
+
validated.push({ id: q.id, type: 'validation', statement: q.statement, rationale: q.rationale });
|
|
44
|
+
}
|
|
45
|
+
else if (q.type === 'choice') {
|
|
46
|
+
if (typeof q.question !== 'string')
|
|
47
|
+
throw new Error(`${where}.question must be a string`);
|
|
48
|
+
if (typeof q.rationale !== 'string')
|
|
49
|
+
throw new Error(`${where}.rationale must be a string`);
|
|
50
|
+
if (!Array.isArray(q.options))
|
|
51
|
+
throw new Error(`${where}.options must be an array`);
|
|
52
|
+
if (q.options.length < 2)
|
|
53
|
+
throw new Error(`${where}.options must have at least 2 items (got ${q.options.length})`);
|
|
54
|
+
const opts = [];
|
|
55
|
+
for (let j = 0; j < q.options.length; j++) {
|
|
56
|
+
if (typeof q.options[j] !== 'string')
|
|
57
|
+
throw new Error(`${where}.options[${j}] must be a string`);
|
|
58
|
+
opts.push(q.options[j]);
|
|
59
|
+
}
|
|
60
|
+
validated.push({ id: q.id, type: 'choice', question: q.question, rationale: q.rationale, options: opts });
|
|
61
|
+
}
|
|
62
|
+
else if (q.type === 'freetext') {
|
|
63
|
+
if (typeof q.question !== 'string')
|
|
64
|
+
throw new Error(`${where}.question must be a string`);
|
|
65
|
+
if (typeof q.rationale !== 'string')
|
|
66
|
+
throw new Error(`${where}.rationale must be a string`);
|
|
67
|
+
validated.push({ id: q.id, type: 'freetext', question: q.question, rationale: q.rationale });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
throw new Error(`${where}.type must be "validation" | "choice" | "freetext" (got ${JSON.stringify(q.type)})`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { title: obj.title, questions: validated };
|
|
74
|
+
}
|
|
7
75
|
export async function launchTui(decisionsPath, sessionId) {
|
|
8
76
|
if (!existsSync(decisionsPath)) {
|
|
9
77
|
throw new Error(`Decisions file not found: ${decisionsPath}`);
|
|
10
78
|
}
|
|
11
79
|
const raw = readFileSync(decisionsPath, 'utf8');
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
throw new Error('No questions in decisions file');
|
|
15
|
-
}
|
|
80
|
+
const parsed = JSON.parse(raw);
|
|
81
|
+
const input = validateInput(parsed);
|
|
16
82
|
const state = {
|
|
17
83
|
phase: 'overview',
|
|
18
84
|
currentIndex: 0,
|
package/dist/tui/input.js
CHANGED
|
@@ -181,12 +181,23 @@ function handleInputMode(input, key, state, render) {
|
|
|
181
181
|
return;
|
|
182
182
|
}
|
|
183
183
|
if (key.backspace) {
|
|
184
|
-
|
|
184
|
+
// Drop the last *codepoint*, not the last UTF-16 code unit, so backspace
|
|
185
|
+
// on an emoji removes the whole glyph instead of leaving a lone surrogate.
|
|
186
|
+
const chars = [...mode.buffer];
|
|
187
|
+
chars.pop();
|
|
188
|
+
mode.buffer = chars.join('');
|
|
185
189
|
render();
|
|
186
190
|
return;
|
|
187
191
|
}
|
|
188
|
-
|
|
189
|
-
|
|
192
|
+
// Accept any printable input — including pasted multi-char chunks and
|
|
193
|
+
// multi-byte UTF-8 (emoji / CJK). Strip control bytes (ESC sequences,
|
|
194
|
+
// bracketed-paste markers, BEL, BS, CR) so they can't corrupt the TUI.
|
|
195
|
+
const cleaned = input
|
|
196
|
+
.replace(/\x1b\[20[01]~/g, '') // bracketed-paste start/end markers
|
|
197
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '') // CSI sequences
|
|
198
|
+
.replace(/[\x00-\x1F\x7F]/g, ''); // C0 controls and DEL
|
|
199
|
+
if (cleaned.length > 0) {
|
|
200
|
+
mode.buffer += cleaned;
|
|
190
201
|
render();
|
|
191
202
|
}
|
|
192
203
|
}
|
package/dist/tui/render.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TuiState } from '../types.js';
|
|
2
|
+
export declare function sanitize(text: string): string;
|
|
2
3
|
export declare function flush(lines: string[]): void;
|
|
3
4
|
export declare function renderOverview(state: TuiState): string[];
|
|
4
5
|
export declare function renderItemReview(state: TuiState): string[];
|
package/dist/tui/render.js
CHANGED
|
@@ -14,18 +14,38 @@ const CYAN = `${ESC}36m`;
|
|
|
14
14
|
const GRAY = `${ESC}90m`;
|
|
15
15
|
const BG_BLUE = `${ESC}44m`;
|
|
16
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
|
+
const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
|
|
22
|
+
export function sanitize(text) {
|
|
23
|
+
if (typeof text !== 'string')
|
|
24
|
+
return '';
|
|
25
|
+
return text.replace(CONTROL_CHARS_RE, '');
|
|
26
|
+
}
|
|
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
|
+
function singleLine(text) {
|
|
30
|
+
return sanitize(text).replace(/\s+/g, ' ').trim();
|
|
31
|
+
}
|
|
17
32
|
function truncate(text, maxWidth) {
|
|
33
|
+
if (maxWidth < 1)
|
|
34
|
+
return '';
|
|
18
35
|
if (stringWidth(text) <= maxWidth)
|
|
19
36
|
return text;
|
|
37
|
+
// Iterate by codepoint, not UTF-16 code unit, so surrogate pairs don't split.
|
|
38
|
+
const chars = [...text];
|
|
20
39
|
let w = 0;
|
|
21
|
-
let
|
|
22
|
-
for (
|
|
23
|
-
const cw = stringWidth(
|
|
40
|
+
let out = '';
|
|
41
|
+
for (const ch of chars) {
|
|
42
|
+
const cw = stringWidth(ch);
|
|
24
43
|
if (w + cw + 1 > maxWidth)
|
|
25
44
|
break;
|
|
45
|
+
out += ch;
|
|
26
46
|
w += cw;
|
|
27
47
|
}
|
|
28
|
-
return
|
|
48
|
+
return out + '…';
|
|
29
49
|
}
|
|
30
50
|
function padRight(text, width) {
|
|
31
51
|
const w = stringWidth(text);
|
|
@@ -34,26 +54,67 @@ function padRight(text, width) {
|
|
|
34
54
|
return text + ' '.repeat(width - w);
|
|
35
55
|
}
|
|
36
56
|
function hline(width, char = '─') {
|
|
57
|
+
if (width < 1)
|
|
58
|
+
return '';
|
|
37
59
|
return char.repeat(width);
|
|
38
60
|
}
|
|
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.
|
|
39
63
|
function wrap(text, maxWidth) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
64
|
+
if (maxWidth < 1)
|
|
65
|
+
return [text];
|
|
66
|
+
const out = [];
|
|
67
|
+
const paragraphs = text.split('\n');
|
|
68
|
+
for (let p = 0; p < paragraphs.length; p++) {
|
|
69
|
+
const para = paragraphs[p];
|
|
70
|
+
if (para === '') {
|
|
71
|
+
out.push('');
|
|
72
|
+
continue;
|
|
47
73
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
74
|
+
const words = para.split(/[ \t]+/).filter(Boolean);
|
|
75
|
+
let current = '';
|
|
76
|
+
for (let word of words) {
|
|
77
|
+
// Hard-break a word that's wider than the line.
|
|
78
|
+
while (stringWidth(word) > maxWidth) {
|
|
79
|
+
if (current) {
|
|
80
|
+
out.push(current);
|
|
81
|
+
current = '';
|
|
82
|
+
}
|
|
83
|
+
const piece = sliceByWidth(word, maxWidth);
|
|
84
|
+
out.push(piece);
|
|
85
|
+
word = word.slice(piece.length);
|
|
86
|
+
}
|
|
87
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
88
|
+
if (stringWidth(candidate) <= maxWidth) {
|
|
89
|
+
current = candidate;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
if (current)
|
|
93
|
+
out.push(current);
|
|
94
|
+
current = word;
|
|
95
|
+
}
|
|
52
96
|
}
|
|
97
|
+
if (current)
|
|
98
|
+
out.push(current);
|
|
53
99
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
100
|
+
return out.length > 0 ? out : [''];
|
|
101
|
+
}
|
|
102
|
+
// Take the longest prefix of `s` whose visible width is <= maxWidth.
|
|
103
|
+
function sliceByWidth(s, maxWidth) {
|
|
104
|
+
let w = 0;
|
|
105
|
+
let out = '';
|
|
106
|
+
for (const ch of s) {
|
|
107
|
+
const cw = stringWidth(ch);
|
|
108
|
+
if (w + cw > maxWidth)
|
|
109
|
+
break;
|
|
110
|
+
out += ch;
|
|
111
|
+
w += cw;
|
|
112
|
+
}
|
|
113
|
+
// Always advance at least one character so we don't loop forever on
|
|
114
|
+
// a single zero-width or oversized glyph.
|
|
115
|
+
if (out === '' && s.length > 0)
|
|
116
|
+
out = [...s][0];
|
|
117
|
+
return out;
|
|
57
118
|
}
|
|
58
119
|
function hardWrap(text, maxWidth) {
|
|
59
120
|
if (maxWidth < 1)
|
|
@@ -67,7 +128,8 @@ function hardWrap(text, maxWidth) {
|
|
|
67
128
|
}
|
|
68
129
|
let current = '';
|
|
69
130
|
let currentW = 0;
|
|
70
|
-
|
|
131
|
+
// Iterate by codepoint so emoji surrogate pairs stay intact.
|
|
132
|
+
for (const ch of [...seg]) {
|
|
71
133
|
const cw = stringWidth(ch);
|
|
72
134
|
if (currentW + cw > maxWidth) {
|
|
73
135
|
out.push(current);
|
|
@@ -107,25 +169,62 @@ export function renderOverview(state) {
|
|
|
107
169
|
lines.push(` ${title} ${DIM}${progress}${RESET}`);
|
|
108
170
|
lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
|
|
109
171
|
lines.push('');
|
|
172
|
+
const rowsBuf = [];
|
|
110
173
|
for (let i = 0; i < state.questions.length; i++) {
|
|
111
174
|
const q = state.questions[i];
|
|
112
175
|
const answer = state.answers.get(q.id);
|
|
113
176
|
const icon = answer ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
|
|
114
|
-
const label = q.type === 'validation' ? q.statement : q.question;
|
|
177
|
+
const label = singleLine(q.type === 'validation' ? q.statement : q.question);
|
|
115
178
|
const typeTag = `${DIM}[${q.type}]${RESET}`;
|
|
116
179
|
const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
|
|
117
|
-
|
|
180
|
+
const labelMax = Math.max(10, cols - 20);
|
|
181
|
+
rowsBuf.push({
|
|
182
|
+
line: ` ${cursor}${icon} ${truncate(label, labelMax)} ${typeTag}`,
|
|
183
|
+
questionIndex: i,
|
|
184
|
+
});
|
|
118
185
|
if (answer) {
|
|
119
|
-
const summary = answerSummary(answer);
|
|
120
|
-
|
|
186
|
+
const summary = singleLine(answerSummary(answer));
|
|
187
|
+
const summaryMax = Math.max(10, cols - 10);
|
|
188
|
+
rowsBuf.push({
|
|
189
|
+
line: ` ${DIM}${truncate(summary, summaryMax)}${RESET}`,
|
|
190
|
+
questionIndex: i,
|
|
191
|
+
});
|
|
121
192
|
}
|
|
122
193
|
}
|
|
123
|
-
|
|
194
|
+
// Reserve space for header (4 already pushed) + footer (3) + scroll hints (2).
|
|
195
|
+
const reserved = 4 + 3 + 2;
|
|
196
|
+
const available = Math.max(1, rows - reserved);
|
|
197
|
+
let scroll = state.scrollOffset || 0;
|
|
198
|
+
// Find first row matching currentIndex; ensure it's in [scroll, scroll+available).
|
|
199
|
+
const focusRow = rowsBuf.findIndex((r) => r.questionIndex === state.currentIndex);
|
|
200
|
+
if (focusRow >= 0) {
|
|
201
|
+
if (focusRow < scroll)
|
|
202
|
+
scroll = focusRow;
|
|
203
|
+
if (focusRow >= scroll + available)
|
|
204
|
+
scroll = focusRow - available + 1;
|
|
205
|
+
}
|
|
206
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, rowsBuf.length - available)));
|
|
207
|
+
state.scrollOffset = scroll;
|
|
208
|
+
if (scroll > 0) {
|
|
209
|
+
lines.push(` ${DIM}↑ ${scroll} more above${RESET}`);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
lines.push('');
|
|
213
|
+
}
|
|
214
|
+
const end = Math.min(rowsBuf.length, scroll + available);
|
|
215
|
+
for (let i = scroll; i < end; i++)
|
|
216
|
+
lines.push(rowsBuf[i].line);
|
|
217
|
+
if (end < rowsBuf.length) {
|
|
218
|
+
lines.push(` ${DIM}↓ ${rowsBuf.length - end} more below${RESET}`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
lines.push('');
|
|
222
|
+
}
|
|
124
223
|
lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
|
|
125
224
|
lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
|
|
126
225
|
while (lines.length < rows)
|
|
127
226
|
lines.push('');
|
|
128
|
-
return lines;
|
|
227
|
+
return lines.slice(0, rows);
|
|
129
228
|
}
|
|
130
229
|
export function renderItemReview(state) {
|
|
131
230
|
const { cols, rows } = getTerminalSize();
|
|
@@ -141,11 +240,11 @@ export function renderItemReview(state) {
|
|
|
141
240
|
lines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
142
241
|
lines.push('');
|
|
143
242
|
// Question / Statement
|
|
144
|
-
const headline = q.type === 'validation' ? q.statement : q.question;
|
|
243
|
+
const headline = sanitize(q.type === 'validation' ? q.statement : q.question);
|
|
145
244
|
for (const line of wrap(headline, maxW)) {
|
|
146
245
|
lines.push(` ${BOLD}${line}${RESET}`);
|
|
147
246
|
}
|
|
148
|
-
for (const line of wrap(q.rationale, maxW)) {
|
|
247
|
+
for (const line of wrap(sanitize(q.rationale), maxW)) {
|
|
149
248
|
lines.push(` ${ITALIC}${GRAY}${line}${RESET}`);
|
|
150
249
|
}
|
|
151
250
|
lines.push('');
|
|
@@ -197,6 +296,12 @@ export function renderItemReview(state) {
|
|
|
197
296
|
`${DIM}q${RESET} overview`,
|
|
198
297
|
];
|
|
199
298
|
lines.push(` ${footerParts.join(' ')}`);
|
|
299
|
+
// If the headline + visual + actions overflowed the viewport, the footer
|
|
300
|
+
// would otherwise scroll off the bottom. Clip to `rows` so flush() never
|
|
301
|
+
// writes more rows than the terminal has.
|
|
302
|
+
if (lines.length > rows) {
|
|
303
|
+
return [...lines.slice(0, rows - 1), lines[lines.length - 1]];
|
|
304
|
+
}
|
|
200
305
|
return lines;
|
|
201
306
|
}
|
|
202
307
|
function renderActions(q, selectedAction, existing) {
|
|
@@ -218,12 +323,15 @@ function renderActions(q, selectedAction, existing) {
|
|
|
218
323
|
else if (q.type === 'choice') {
|
|
219
324
|
for (let i = 0; i < q.options.length; i++) {
|
|
220
325
|
const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
|
|
221
|
-
|
|
222
|
-
|
|
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])}`);
|
|
223
330
|
}
|
|
224
331
|
const otherIdx = q.options.length;
|
|
225
332
|
const cursor = otherIdx === selectedAction ? `${CYAN}▸${RESET}` : ' ';
|
|
226
|
-
|
|
333
|
+
const otherBadge = otherIdx < 9 ? `${DIM}[${otherIdx + 1}]${RESET}` : `${DIM} ${RESET}`;
|
|
334
|
+
lines.push(` ${cursor} ${otherBadge} ${ITALIC}Other (custom)${RESET}`);
|
|
227
335
|
}
|
|
228
336
|
else {
|
|
229
337
|
lines.push(` ${DIM}[r]${RESET} Enter response`);
|
|
@@ -236,44 +344,58 @@ function renderActions(q, selectedAction, existing) {
|
|
|
236
344
|
}
|
|
237
345
|
export function renderFinal(state) {
|
|
238
346
|
const { cols, rows } = getTerminalSize();
|
|
239
|
-
const
|
|
347
|
+
const header = [];
|
|
348
|
+
const footer = [];
|
|
240
349
|
const maxW = Math.min(cols - 4, 60);
|
|
241
350
|
const total = state.questions.length;
|
|
242
351
|
const answered = state.answers.size;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
352
|
+
header.push('');
|
|
353
|
+
header.push(` ${BOLD}${CYAN} Summary ${RESET}`);
|
|
354
|
+
header.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
355
|
+
header.push('');
|
|
356
|
+
header.push(` ${answered}/${total} questions answered`);
|
|
357
|
+
header.push('');
|
|
358
|
+
footer.push('');
|
|
359
|
+
footer.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
360
|
+
if (answered < total) {
|
|
361
|
+
footer.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
|
|
362
|
+
}
|
|
363
|
+
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
|
+
const questionRows = [];
|
|
249
368
|
for (const q of state.questions) {
|
|
250
369
|
const answer = state.answers.get(q.id);
|
|
251
370
|
const icon = answer ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
|
|
252
|
-
const label = q.type === 'validation' ? q.statement : q.question;
|
|
253
|
-
|
|
371
|
+
const label = singleLine(q.type === 'validation' ? q.statement : q.question);
|
|
372
|
+
questionRows.push(` ${icon} ${truncate(label, Math.max(10, maxW - 4))}`);
|
|
254
373
|
if (answer) {
|
|
255
|
-
|
|
374
|
+
questionRows.push(` ${DIM}${truncate(singleLine(answerSummary(answer)), Math.max(10, maxW - 6))}${RESET}`);
|
|
256
375
|
}
|
|
257
376
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
|
|
377
|
+
const available = Math.max(1, rows - header.length - footer.length - 1);
|
|
378
|
+
let visible = questionRows;
|
|
379
|
+
if (questionRows.length > available) {
|
|
380
|
+
visible = [
|
|
381
|
+
...questionRows.slice(0, available - 1),
|
|
382
|
+
` ${DIM}… ${questionRows.length - (available - 1)} more rows omitted${RESET}`,
|
|
383
|
+
];
|
|
262
384
|
}
|
|
263
|
-
lines
|
|
385
|
+
const lines = [...header, ...visible, ...footer];
|
|
264
386
|
while (lines.length < rows)
|
|
265
387
|
lines.push('');
|
|
266
|
-
return lines;
|
|
388
|
+
return lines.slice(0, rows);
|
|
267
389
|
}
|
|
268
390
|
function answerSummary(a) {
|
|
269
391
|
switch (a.type) {
|
|
270
392
|
case 'validation':
|
|
271
393
|
return a.approved
|
|
272
|
-
? (a.comment ? `approved: "${a.comment}"` : 'approved')
|
|
273
|
-
: (a.comment ? `commented: "${a.comment}"` : 'commented');
|
|
394
|
+
? (a.comment ? `approved: "${sanitize(a.comment)}"` : 'approved')
|
|
395
|
+
: (a.comment ? `commented: "${sanitize(a.comment)}"` : 'commented');
|
|
274
396
|
case 'choice':
|
|
275
|
-
return a.isCustom ? `custom: "${a.selected}"` : a.selected;
|
|
397
|
+
return a.isCustom ? `custom: "${sanitize(a.selected)}"` : sanitize(a.selected);
|
|
276
398
|
case 'freetext':
|
|
277
|
-
return a.response;
|
|
399
|
+
return sanitize(a.response);
|
|
278
400
|
}
|
|
279
401
|
}
|
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.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
|
});
|