@crouton-kit/humanloop 0.1.0
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.d.ts +2 -0
- package/dist/cli.js +211 -0
- package/dist/conversation/reader.d.ts +6 -0
- package/dist/conversation/reader.js +58 -0
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +136 -0
- package/dist/tui/input.d.ts +5 -0
- package/dist/tui/input.js +257 -0
- package/dist/tui/render.d.ts +5 -0
- package/dist/tui/render.js +279 -0
- package/dist/tui/terminal.d.ts +20 -0
- package/dist/tui/terminal.js +68 -0
- package/dist/tui/tmux.d.ts +6 -0
- package/dist/tui/tmux.js +54 -0
- package/dist/types.d.ts +76 -0
- package/dist/types.js +2 -0
- package/dist/visuals/generate.d.ts +4 -0
- package/dist/visuals/generate.js +129 -0
- package/package.json +28 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { TuiState } from '../types.js';
|
|
2
|
+
export declare function flush(lines: string[]): void;
|
|
3
|
+
export declare function renderOverview(state: TuiState): string[];
|
|
4
|
+
export declare function renderItemReview(state: TuiState): string[];
|
|
5
|
+
export declare function renderFinal(state: TuiState): string[];
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
import { getTerminalSize } from './terminal.js';
|
|
3
|
+
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
4
|
+
const ESC = '\x1b[';
|
|
5
|
+
const RESET = `${ESC}0m`;
|
|
6
|
+
const BOLD = `${ESC}1m`;
|
|
7
|
+
const DIM = `${ESC}2m`;
|
|
8
|
+
const ITALIC = `${ESC}3m`;
|
|
9
|
+
const GREEN = `${ESC}32m`;
|
|
10
|
+
const YELLOW = `${ESC}33m`;
|
|
11
|
+
const BLUE = `${ESC}34m`;
|
|
12
|
+
const MAGENTA = `${ESC}35m`;
|
|
13
|
+
const CYAN = `${ESC}36m`;
|
|
14
|
+
const GRAY = `${ESC}90m`;
|
|
15
|
+
const BG_BLUE = `${ESC}44m`;
|
|
16
|
+
const WHITE = `${ESC}37m`;
|
|
17
|
+
function truncate(text, maxWidth) {
|
|
18
|
+
if (stringWidth(text) <= maxWidth)
|
|
19
|
+
return text;
|
|
20
|
+
let w = 0;
|
|
21
|
+
let i = 0;
|
|
22
|
+
for (; i < text.length; i++) {
|
|
23
|
+
const cw = stringWidth(text[i]);
|
|
24
|
+
if (w + cw + 1 > maxWidth)
|
|
25
|
+
break;
|
|
26
|
+
w += cw;
|
|
27
|
+
}
|
|
28
|
+
return text.slice(0, i) + '…';
|
|
29
|
+
}
|
|
30
|
+
function padRight(text, width) {
|
|
31
|
+
const w = stringWidth(text);
|
|
32
|
+
if (w >= width)
|
|
33
|
+
return text;
|
|
34
|
+
return text + ' '.repeat(width - w);
|
|
35
|
+
}
|
|
36
|
+
function hline(width, char = '─') {
|
|
37
|
+
return char.repeat(width);
|
|
38
|
+
}
|
|
39
|
+
function wrap(text, maxWidth) {
|
|
40
|
+
const words = text.split(/\s+/);
|
|
41
|
+
const lines = [];
|
|
42
|
+
let current = '';
|
|
43
|
+
for (const word of words) {
|
|
44
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
45
|
+
if (stringWidth(candidate) <= maxWidth) {
|
|
46
|
+
current = candidate;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
if (current)
|
|
50
|
+
lines.push(current);
|
|
51
|
+
current = word;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (current)
|
|
55
|
+
lines.push(current);
|
|
56
|
+
return lines.length > 0 ? lines : [''];
|
|
57
|
+
}
|
|
58
|
+
function hardWrap(text, maxWidth) {
|
|
59
|
+
if (maxWidth < 1)
|
|
60
|
+
return [text];
|
|
61
|
+
const segments = text.split('\n');
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const seg of segments) {
|
|
64
|
+
if (seg.length === 0) {
|
|
65
|
+
out.push('');
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
let current = '';
|
|
69
|
+
let currentW = 0;
|
|
70
|
+
for (const ch of seg) {
|
|
71
|
+
const cw = stringWidth(ch);
|
|
72
|
+
if (currentW + cw > maxWidth) {
|
|
73
|
+
out.push(current);
|
|
74
|
+
current = ch;
|
|
75
|
+
currentW = cw;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
current += ch;
|
|
79
|
+
currentW += cw;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
out.push(current);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
// ── Frame buffer ─────────────────────────────────────────────────────────────
|
|
87
|
+
let prevFrame = [];
|
|
88
|
+
export function flush(lines) {
|
|
89
|
+
const { rows } = getTerminalSize();
|
|
90
|
+
process.stdout.write('\x1b[?2026h');
|
|
91
|
+
for (let i = 0; i < rows; i++) {
|
|
92
|
+
const line = i < lines.length ? lines[i] : '';
|
|
93
|
+
if (prevFrame[i] !== line) {
|
|
94
|
+
process.stdout.write(`${ESC}${i + 1};1H${ESC}2K${line}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
process.stdout.write('\x1b[?2026l');
|
|
98
|
+
prevFrame = [...lines];
|
|
99
|
+
}
|
|
100
|
+
// ── Renderers ────────────────────────────────────────────────────────────────
|
|
101
|
+
export function renderOverview(state) {
|
|
102
|
+
const { cols, rows } = getTerminalSize();
|
|
103
|
+
const lines = [];
|
|
104
|
+
const title = `${BOLD}${CYAN} Decisions ${RESET}`;
|
|
105
|
+
const progress = `${state.answers.size}/${state.questions.length} answered`;
|
|
106
|
+
lines.push('');
|
|
107
|
+
lines.push(` ${title} ${DIM}${progress}${RESET}`);
|
|
108
|
+
lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
|
|
109
|
+
lines.push('');
|
|
110
|
+
for (let i = 0; i < state.questions.length; i++) {
|
|
111
|
+
const q = state.questions[i];
|
|
112
|
+
const answer = state.answers.get(q.id);
|
|
113
|
+
const icon = answer ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`;
|
|
114
|
+
const label = q.type === 'validation' ? q.statement : q.question;
|
|
115
|
+
const typeTag = `${DIM}[${q.type}]${RESET}`;
|
|
116
|
+
const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
|
|
117
|
+
lines.push(` ${cursor}${icon} ${truncate(label, cols - 20)} ${typeTag}`);
|
|
118
|
+
if (answer) {
|
|
119
|
+
const summary = answerSummary(answer);
|
|
120
|
+
lines.push(` ${DIM}${truncate(summary, cols - 10)}${RESET}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
lines.push('');
|
|
124
|
+
lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
|
|
125
|
+
lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
|
|
126
|
+
while (lines.length < rows)
|
|
127
|
+
lines.push('');
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
export function renderItemReview(state) {
|
|
131
|
+
const { cols, rows } = getTerminalSize();
|
|
132
|
+
const lines = [];
|
|
133
|
+
const q = state.questions[state.currentIndex];
|
|
134
|
+
const visual = state.visuals.get(q.id);
|
|
135
|
+
const answer = state.answers.get(q.id);
|
|
136
|
+
const maxW = Math.min(cols - 4, 76);
|
|
137
|
+
// Header
|
|
138
|
+
const pos = `${state.currentIndex + 1}/${state.questions.length}`;
|
|
139
|
+
lines.push('');
|
|
140
|
+
lines.push(` ${BOLD}${CYAN}[${pos}]${RESET} ${DIM}${q.type}${RESET}`);
|
|
141
|
+
lines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
142
|
+
lines.push('');
|
|
143
|
+
// Question / Statement
|
|
144
|
+
const headline = q.type === 'validation' ? q.statement : q.question;
|
|
145
|
+
for (const line of wrap(headline, maxW)) {
|
|
146
|
+
lines.push(` ${BOLD}${line}${RESET}`);
|
|
147
|
+
}
|
|
148
|
+
for (const line of wrap(q.rationale, maxW)) {
|
|
149
|
+
lines.push(` ${ITALIC}${GRAY}${line}${RESET}`);
|
|
150
|
+
}
|
|
151
|
+
lines.push('');
|
|
152
|
+
// Visual context
|
|
153
|
+
if (visual) {
|
|
154
|
+
if (visual.status === 'loading') {
|
|
155
|
+
lines.push(` ${DIM}loading context...${RESET}`);
|
|
156
|
+
}
|
|
157
|
+
else if (visual.status === 'error') {
|
|
158
|
+
lines.push(` ${YELLOW}visual context unavailable${RESET}`);
|
|
159
|
+
}
|
|
160
|
+
else if (state.detailExpanded) {
|
|
161
|
+
lines.push(` ${DIM}── context ${hline(maxW - 12)}${RESET}`);
|
|
162
|
+
for (const vl of visual.content.split('\n')) {
|
|
163
|
+
lines.push(` ${vl}`);
|
|
164
|
+
}
|
|
165
|
+
lines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
lines.push(` ${DIM}[space] expand context${RESET}`);
|
|
169
|
+
}
|
|
170
|
+
lines.push('');
|
|
171
|
+
}
|
|
172
|
+
// Input mode
|
|
173
|
+
if (state.inputMode) {
|
|
174
|
+
lines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
175
|
+
const label = state.inputMode.kind === 'comment' ? 'Comment'
|
|
176
|
+
: state.inputMode.kind === 'freetext' ? 'Response'
|
|
177
|
+
: 'Custom option';
|
|
178
|
+
lines.push(` ${YELLOW}${label}:${RESET}`);
|
|
179
|
+
const bufLines = hardWrap(state.inputMode.buffer, maxW - 1);
|
|
180
|
+
for (let i = 0; i < bufLines.length; i++) {
|
|
181
|
+
const isLast = i === bufLines.length - 1;
|
|
182
|
+
lines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
|
|
183
|
+
}
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// Actions
|
|
189
|
+
lines.push(...renderActions(q, state.selectedAction, answer));
|
|
190
|
+
}
|
|
191
|
+
// Footer
|
|
192
|
+
while (lines.length < rows - 1)
|
|
193
|
+
lines.push('');
|
|
194
|
+
const footerParts = [
|
|
195
|
+
`${DIM}n/p${RESET} prev/next`,
|
|
196
|
+
`${DIM}space${RESET} expand`,
|
|
197
|
+
`${DIM}q${RESET} overview`,
|
|
198
|
+
];
|
|
199
|
+
lines.push(` ${footerParts.join(' ')}`);
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
function renderActions(q, selectedAction, existing) {
|
|
203
|
+
const lines = [];
|
|
204
|
+
if (q.type === 'validation') {
|
|
205
|
+
const actions = [
|
|
206
|
+
{ key: '1', label: 'Approve', desc: 'accept as stated' },
|
|
207
|
+
{ key: '2', label: 'Approve + comment', desc: 'accept with note' },
|
|
208
|
+
{ key: '3', label: 'Reject', desc: 'do not accept as stated' },
|
|
209
|
+
{ key: '4', label: 'Comment', desc: 'feedback without decision' },
|
|
210
|
+
];
|
|
211
|
+
for (let i = 0; i < actions.length; i++) {
|
|
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
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else if (q.type === 'choice') {
|
|
219
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
220
|
+
const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
|
|
221
|
+
const keyBadge = `${DIM}[${i + 1}]${RESET}`;
|
|
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}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
lines.push(` ${DIM}[r]${RESET} Enter response`);
|
|
230
|
+
}
|
|
231
|
+
if (existing) {
|
|
232
|
+
lines.push('');
|
|
233
|
+
lines.push(` ${GREEN}Current: ${answerSummary(existing)}${RESET}`);
|
|
234
|
+
}
|
|
235
|
+
return lines;
|
|
236
|
+
}
|
|
237
|
+
export function renderFinal(state) {
|
|
238
|
+
const { cols, rows } = getTerminalSize();
|
|
239
|
+
const lines = [];
|
|
240
|
+
const maxW = Math.min(cols - 4, 60);
|
|
241
|
+
const total = state.questions.length;
|
|
242
|
+
const answered = state.answers.size;
|
|
243
|
+
lines.push('');
|
|
244
|
+
lines.push(` ${BOLD}${CYAN} Summary ${RESET}`);
|
|
245
|
+
lines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
246
|
+
lines.push('');
|
|
247
|
+
lines.push(` ${answered}/${total} questions answered`);
|
|
248
|
+
lines.push('');
|
|
249
|
+
for (const q of state.questions) {
|
|
250
|
+
const answer = state.answers.get(q.id);
|
|
251
|
+
const icon = answer ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
|
|
252
|
+
const label = q.type === 'validation' ? q.statement : q.question;
|
|
253
|
+
lines.push(` ${icon} ${truncate(label, maxW - 4)}`);
|
|
254
|
+
if (answer) {
|
|
255
|
+
lines.push(` ${DIM}${truncate(answerSummary(answer), maxW - 6)}${RESET}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
lines.push('');
|
|
259
|
+
lines.push(` ${DIM}${hline(maxW)}${RESET}`);
|
|
260
|
+
if (answered < total) {
|
|
261
|
+
lines.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
|
|
262
|
+
}
|
|
263
|
+
lines.push(` ${DIM}enter${RESET} submit ${DIM}p${RESET} go back`);
|
|
264
|
+
while (lines.length < rows)
|
|
265
|
+
lines.push('');
|
|
266
|
+
return lines;
|
|
267
|
+
}
|
|
268
|
+
function answerSummary(a) {
|
|
269
|
+
switch (a.type) {
|
|
270
|
+
case 'validation':
|
|
271
|
+
return a.approved
|
|
272
|
+
? (a.comment ? `approved: "${a.comment}"` : 'approved')
|
|
273
|
+
: (a.comment ? `commented: "${a.comment}"` : 'commented');
|
|
274
|
+
case 'choice':
|
|
275
|
+
return a.isCustom ? `custom: "${a.selected}"` : a.selected;
|
|
276
|
+
case 'freetext':
|
|
277
|
+
return a.response;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface Key {
|
|
2
|
+
upArrow: boolean;
|
|
3
|
+
downArrow: boolean;
|
|
4
|
+
return: boolean;
|
|
5
|
+
escape: boolean;
|
|
6
|
+
ctrl: boolean;
|
|
7
|
+
tab: boolean;
|
|
8
|
+
backspace: boolean;
|
|
9
|
+
}
|
|
10
|
+
export type KeypressHandler = (input: string, key: Key) => void;
|
|
11
|
+
export declare function parseKeypress(data: Buffer): {
|
|
12
|
+
input: string;
|
|
13
|
+
key: Key;
|
|
14
|
+
};
|
|
15
|
+
export declare function setupTerminal(): void;
|
|
16
|
+
export declare function restoreTerminal(): void;
|
|
17
|
+
export declare function getTerminalSize(): {
|
|
18
|
+
cols: number;
|
|
19
|
+
rows: number;
|
|
20
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
function emptyKey() {
|
|
2
|
+
return {
|
|
3
|
+
upArrow: false,
|
|
4
|
+
downArrow: false,
|
|
5
|
+
return: false,
|
|
6
|
+
escape: false,
|
|
7
|
+
ctrl: false,
|
|
8
|
+
tab: false,
|
|
9
|
+
backspace: false,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function parseKeypress(data) {
|
|
13
|
+
const str = data.toString('utf8');
|
|
14
|
+
const key = emptyKey();
|
|
15
|
+
if (str === '\x1b[A') {
|
|
16
|
+
key.upArrow = true;
|
|
17
|
+
return { input: '', key };
|
|
18
|
+
}
|
|
19
|
+
if (str === '\x1b[B') {
|
|
20
|
+
key.downArrow = true;
|
|
21
|
+
return { input: '', key };
|
|
22
|
+
}
|
|
23
|
+
if (str === '\r' || str === '\n') {
|
|
24
|
+
key.return = true;
|
|
25
|
+
return { input: '', key };
|
|
26
|
+
}
|
|
27
|
+
if (str === '\x1b') {
|
|
28
|
+
key.escape = true;
|
|
29
|
+
return { input: '', key };
|
|
30
|
+
}
|
|
31
|
+
if (str === '\t') {
|
|
32
|
+
key.tab = true;
|
|
33
|
+
return { input: '', key };
|
|
34
|
+
}
|
|
35
|
+
if (str === '\x7f' || str === '\b') {
|
|
36
|
+
key.backspace = true;
|
|
37
|
+
return { input: '', key };
|
|
38
|
+
}
|
|
39
|
+
if (str.length === 1 && str.charCodeAt(0) < 32) {
|
|
40
|
+
key.ctrl = true;
|
|
41
|
+
const ch = String.fromCharCode(str.charCodeAt(0) + 64).toLowerCase();
|
|
42
|
+
return { input: ch, key };
|
|
43
|
+
}
|
|
44
|
+
return { input: str, key };
|
|
45
|
+
}
|
|
46
|
+
export function setupTerminal() {
|
|
47
|
+
if (!process.stdin.isTTY) {
|
|
48
|
+
throw new Error('hl requires an interactive terminal (TTY)');
|
|
49
|
+
}
|
|
50
|
+
process.stdin.setRawMode(true);
|
|
51
|
+
process.stdin.resume();
|
|
52
|
+
process.stdin.setEncoding('utf8');
|
|
53
|
+
process.stdout.write('\x1b[?25l'); // hide cursor
|
|
54
|
+
process.stdout.write('\x1b[?1049h'); // alt screen
|
|
55
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear
|
|
56
|
+
}
|
|
57
|
+
export function restoreTerminal() {
|
|
58
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
59
|
+
process.stdout.write('\x1b[?1049l'); // restore screen
|
|
60
|
+
process.stdin.setRawMode(false);
|
|
61
|
+
process.stdin.pause();
|
|
62
|
+
}
|
|
63
|
+
export function getTerminalSize() {
|
|
64
|
+
return {
|
|
65
|
+
cols: process.stdout.columns || 80,
|
|
66
|
+
rows: process.stdout.rows || 24,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/dist/tui/tmux.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
function shellQuote(s) {
|
|
6
|
+
if (s.length > 0 && /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s))
|
|
7
|
+
return s;
|
|
8
|
+
return "'" + s.replace(/'/g, `'\\''`) + "'";
|
|
9
|
+
}
|
|
10
|
+
function buildChildCmd(file, resultPath, opts) {
|
|
11
|
+
const scriptPath = process.argv[1];
|
|
12
|
+
if (!scriptPath) {
|
|
13
|
+
throw new Error('Cannot determine hl script path from process.argv[1]');
|
|
14
|
+
}
|
|
15
|
+
const parts = [
|
|
16
|
+
shellQuote(process.execPath),
|
|
17
|
+
shellQuote(scriptPath),
|
|
18
|
+
'create',
|
|
19
|
+
shellQuote(file),
|
|
20
|
+
'--write-to',
|
|
21
|
+
shellQuote(resultPath),
|
|
22
|
+
];
|
|
23
|
+
if (opts.sessionId) {
|
|
24
|
+
parts.push('--session-id', shellQuote(opts.sessionId));
|
|
25
|
+
}
|
|
26
|
+
if (!opts.visuals) {
|
|
27
|
+
parts.push('--no-visuals');
|
|
28
|
+
}
|
|
29
|
+
return parts.join(' ');
|
|
30
|
+
}
|
|
31
|
+
export async function dispatchToTmuxPane(file, opts) {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), 'hl-'));
|
|
33
|
+
const resultPath = join(dir, 'result.json');
|
|
34
|
+
const cmd = buildChildCmd(file, resultPath, opts);
|
|
35
|
+
execFileSync('tmux', ['split-window', '-h', '-d', cmd], { stdio: 'ignore' });
|
|
36
|
+
await new Promise((resolve) => {
|
|
37
|
+
const poll = setInterval(() => {
|
|
38
|
+
if (existsSync(resultPath)) {
|
|
39
|
+
clearInterval(poll);
|
|
40
|
+
resolve();
|
|
41
|
+
}
|
|
42
|
+
}, 150);
|
|
43
|
+
});
|
|
44
|
+
const json = readFileSync(resultPath, 'utf8');
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(resultPath);
|
|
47
|
+
}
|
|
48
|
+
catch { /* ignore */ }
|
|
49
|
+
try {
|
|
50
|
+
rmdirSync(dir);
|
|
51
|
+
}
|
|
52
|
+
catch { /* ignore */ }
|
|
53
|
+
return JSON.parse(json);
|
|
54
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type QuestionType = 'validation' | 'choice' | 'freetext';
|
|
2
|
+
export interface ValidationQuestion {
|
|
3
|
+
id: string;
|
|
4
|
+
type: 'validation';
|
|
5
|
+
statement: string;
|
|
6
|
+
rationale: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ChoiceQuestion {
|
|
9
|
+
id: string;
|
|
10
|
+
type: 'choice';
|
|
11
|
+
question: string;
|
|
12
|
+
rationale: string;
|
|
13
|
+
options: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface FreetextQuestion {
|
|
16
|
+
id: string;
|
|
17
|
+
type: 'freetext';
|
|
18
|
+
question: string;
|
|
19
|
+
rationale: string;
|
|
20
|
+
}
|
|
21
|
+
export type Question = ValidationQuestion | ChoiceQuestion | FreetextQuestion;
|
|
22
|
+
export interface DecisionsInput {
|
|
23
|
+
title?: string;
|
|
24
|
+
questions: Question[];
|
|
25
|
+
}
|
|
26
|
+
export interface ValidationAnswer {
|
|
27
|
+
id: string;
|
|
28
|
+
type: 'validation';
|
|
29
|
+
approved: boolean;
|
|
30
|
+
comment?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ChoiceAnswer {
|
|
33
|
+
id: string;
|
|
34
|
+
type: 'choice';
|
|
35
|
+
selected: string;
|
|
36
|
+
isCustom: boolean;
|
|
37
|
+
comment?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface FreetextAnswer {
|
|
40
|
+
id: string;
|
|
41
|
+
type: 'freetext';
|
|
42
|
+
response: string;
|
|
43
|
+
}
|
|
44
|
+
export type Answer = ValidationAnswer | ChoiceAnswer | FreetextAnswer;
|
|
45
|
+
export interface DecisionsOutput {
|
|
46
|
+
answers: Answer[];
|
|
47
|
+
completedAt: string;
|
|
48
|
+
}
|
|
49
|
+
export interface VisualBlock {
|
|
50
|
+
questionId: string;
|
|
51
|
+
content: string;
|
|
52
|
+
status: 'loading' | 'ready' | 'error';
|
|
53
|
+
}
|
|
54
|
+
export type Phase = 'overview' | 'item-review' | 'final';
|
|
55
|
+
export type InputMode = null | {
|
|
56
|
+
kind: 'comment';
|
|
57
|
+
buffer: string;
|
|
58
|
+
} | {
|
|
59
|
+
kind: 'freetext';
|
|
60
|
+
buffer: string;
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'custom-option';
|
|
63
|
+
buffer: string;
|
|
64
|
+
};
|
|
65
|
+
export interface TuiState {
|
|
66
|
+
phase: Phase;
|
|
67
|
+
currentIndex: number;
|
|
68
|
+
questions: Question[];
|
|
69
|
+
answers: Map<string, Answer>;
|
|
70
|
+
visuals: Map<string, VisualBlock>;
|
|
71
|
+
inputMode: InputMode;
|
|
72
|
+
selectedAction: number;
|
|
73
|
+
detailExpanded: boolean;
|
|
74
|
+
scrollOffset: number;
|
|
75
|
+
persist?: () => void;
|
|
76
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Question, VisualBlock } from '../types.js';
|
|
2
|
+
import type { ConversationMessage } from '../conversation/reader.js';
|
|
3
|
+
export type VisualUpdateCallback = (questionId: string, block: VisualBlock) => void;
|
|
4
|
+
export declare function generateVisuals(questions: Question[], conversation: ConversationMessage[], onUpdate: VisualUpdateCallback, width?: number): Promise<void>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { query } from '@r-cli/sdk';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
const VISUAL_SYSTEM_PROMPT = `You're briefing a CTO-level engineer in the 30 seconds before they decide. They've been off this problem for days; they need a fast re-ground in what *already exists* — the files, data flow, or constraint they're deciding inside of — not a lecture on tradeoffs.
|
|
4
|
+
|
|
5
|
+
# Length
|
|
6
|
+
|
|
7
|
+
Target 15–25 lines. Hard cap 30. A tight paragraph with two file refs is often perfect — don't pad.
|
|
8
|
+
|
|
9
|
+
# What to write
|
|
10
|
+
|
|
11
|
+
Lead with *what is*, not *what could be*. Name the actual files, functions, tables, or data structures in play. Reference them as \`path/to/file.ts:123\` so they can jump to it. Skip preamble. Skip "here are the tradeoffs." Skip explaining the alternative — they're deciding, they know the alternative exists.
|
|
12
|
+
|
|
13
|
+
If one sentence captures the current state, write one sentence. If they need to see a flow, draw it. If they need to compare 3+ options across same dimensions, use a table. Don't reach for a directive unless it genuinely clarifies — plain prose + bullet lists is the default.
|
|
14
|
+
|
|
15
|
+
# Directives (termrender-flavored markdown)
|
|
16
|
+
|
|
17
|
+
:::panel{title="T" color="c"} Bordered box (colors: red|green|yellow|blue|magenta|cyan|white|gray)
|
|
18
|
+
:::tree{color="c"} Indented hierarchy (2-space indent = nesting)
|
|
19
|
+
:::table Markdown table with borders
|
|
20
|
+
:::note / :::warning Callouts
|
|
21
|
+
|
|
22
|
+
Each opens with ::: and closes with :::. Standard markdown also works: **bold**, *italic*, \`code\`, bullets.
|
|
23
|
+
|
|
24
|
+
# Critical: ASCII art must live inside a :::panel
|
|
25
|
+
|
|
26
|
+
Plain text outside directives gets reflowed — box-drawing will be destroyed. If you draw a flow diagram or ASCII box, wrap it in \`:::panel\` to preserve it verbatim.
|
|
27
|
+
|
|
28
|
+
# Grounding — the single most important rule
|
|
29
|
+
|
|
30
|
+
**Only name files, functions, variables, or patterns that actually appear in the conversation history provided.** Do not invent plausible-sounding file paths, class names, or dependencies. If the conversation doesn't ground a fact, don't assert it. When in doubt, speak at a higher level of abstraction ("the state file," "the render loop") rather than making up a specific identifier.
|
|
31
|
+
|
|
32
|
+
If the conversation doesn't contain enough context to write a grounded briefing, write a very short briefing that honestly reflects what little is known — a one-paragraph summary is better than a confident fabrication.
|
|
33
|
+
|
|
34
|
+
# Hard rules
|
|
35
|
+
|
|
36
|
+
- Never nest directives (no :::panel containing :::table)
|
|
37
|
+
- Never wrap output in backtick fences
|
|
38
|
+
- Never repeat the question/statement text
|
|
39
|
+
- Never write "tradeoffs to consider" or "here are some options"
|
|
40
|
+
- Never describe an alternative architecture — just describe the current one
|
|
41
|
+
- Never recommend an option or tell the user how to decide. They are the decider. You describe.
|
|
42
|
+
- Never ask the user a question back. You are producing a briefing, not a conversation.
|
|
43
|
+
- Do NOT use these section headings: **Recommendation:**, **Decide by:**, **Trade-off:**, **Why it matters:**, **What you're locking in:**. These invite editorializing. Use neutral labels like **Current state:**, **Constraint:**, or none at all.
|
|
44
|
+
- 30 lines maximum`;
|
|
45
|
+
async function callHaiku(prompt, systemPrompt) {
|
|
46
|
+
try {
|
|
47
|
+
const session = await query({
|
|
48
|
+
prompt,
|
|
49
|
+
options: {
|
|
50
|
+
model: 'haiku',
|
|
51
|
+
maxTurns: 1,
|
|
52
|
+
systemPrompt,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
let text = '';
|
|
56
|
+
for await (const msg of session) {
|
|
57
|
+
if (msg.type === 'assistant' && msg.message?.content) {
|
|
58
|
+
for (const block of msg.message.content) {
|
|
59
|
+
if (block.type === 'text')
|
|
60
|
+
text += block.text;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return text.trim() || null;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
process.stderr.write(`[hl] Haiku call failed: ${err instanceof Error ? err.message : err}\n`);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function renderWithTermrender(markdown, width) {
|
|
72
|
+
// First attempt
|
|
73
|
+
const result = tryTermrender(markdown, width);
|
|
74
|
+
if (result !== null)
|
|
75
|
+
return result;
|
|
76
|
+
// Fallback: strip all directives and render as plain markdown
|
|
77
|
+
const stripped = markdown.replace(/^:{3,}\w*.*$/gm, '').trim();
|
|
78
|
+
const fallback = tryTermrender(stripped, width);
|
|
79
|
+
return fallback ?? markdown;
|
|
80
|
+
}
|
|
81
|
+
function tryTermrender(markdown, width) {
|
|
82
|
+
try {
|
|
83
|
+
return execSync(`termrender -w ${width}`, {
|
|
84
|
+
input: markdown,
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
timeout: 5000,
|
|
87
|
+
env: { ...process.env, TERMRENDER_COLOR: '1' },
|
|
88
|
+
}).trimEnd();
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
const stderr = err.stderr || '';
|
|
92
|
+
process.stderr.write(`[hl] termrender: ${stderr.split('\n')[0]}\n`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function generateVisuals(questions, conversation, onUpdate, width = 72) {
|
|
97
|
+
const conversationText = conversation
|
|
98
|
+
.map(m => `${m.role}: ${m.content}`)
|
|
99
|
+
.join('\n\n');
|
|
100
|
+
const tasks = questions.map(async (question) => {
|
|
101
|
+
const questionText = question.type === 'validation'
|
|
102
|
+
? `Decision to validate: "${question.statement}"\nRationale: ${question.rationale}`
|
|
103
|
+
: `Question: "${question.question}"\nRationale: ${question.rationale}${question.type === 'choice'
|
|
104
|
+
? `\nOptions: ${question.options.join(', ')}`
|
|
105
|
+
: ''}`;
|
|
106
|
+
const prompt = `Here is the conversation so far:\n\n${conversationText}\n\n---\n\nGenerate a visual context block for this decision point:\n\n${questionText}`;
|
|
107
|
+
const result = await callHaiku(prompt, VISUAL_SYSTEM_PROMPT);
|
|
108
|
+
if (result) {
|
|
109
|
+
const cleaned = result
|
|
110
|
+
.replace(/^```[\w]*\n?/gm, '')
|
|
111
|
+
.replace(/^```\s*$/gm, '')
|
|
112
|
+
.trim();
|
|
113
|
+
const rendered = renderWithTermrender(cleaned, width);
|
|
114
|
+
onUpdate(question.id, {
|
|
115
|
+
questionId: question.id,
|
|
116
|
+
content: rendered,
|
|
117
|
+
status: 'ready',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
onUpdate(question.id, {
|
|
122
|
+
questionId: question.id,
|
|
123
|
+
content: '',
|
|
124
|
+
status: 'error',
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
await Promise.all(tasks);
|
|
129
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crouton-kit/humanloop",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hl": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"commands"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"dev": "tsx src/cli.ts",
|
|
16
|
+
"link": "npm link"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@r-cli/sdk": "^1.3.0",
|
|
20
|
+
"commander": "^13.0.0",
|
|
21
|
+
"string-width": "^7.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"tsx": "^4.0.0",
|
|
26
|
+
"typescript": "^5.7.0"
|
|
27
|
+
}
|
|
28
|
+
}
|