@crouton-kit/humanloop 0.1.3 → 0.2.1
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/api.d.ts +35 -0
- package/dist/api.js +119 -0
- package/dist/cli.js +348 -97
- package/dist/editor/review.d.ts +24 -0
- package/dist/editor/review.js +425 -0
- package/dist/inbox/convention.d.ts +17 -0
- package/dist/inbox/convention.js +87 -0
- package/dist/inbox/deck-schema.d.ts +41 -0
- package/dist/inbox/deck-schema.js +109 -0
- package/dist/inbox/scan.d.ts +2 -0
- package/dist/inbox/scan.js +62 -0
- package/dist/inbox/tui.d.ts +9 -0
- package/dist/inbox/tui.js +158 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +13 -0
- package/dist/render/termrender.d.ts +36 -0
- package/dist/render/termrender.js +236 -0
- package/dist/render/version.d.ts +1 -0
- package/dist/render/version.js +1 -0
- package/dist/scripts/install-renderer.d.ts +2 -0
- package/dist/scripts/install-renderer.js +16 -0
- package/dist/surfaces/display.d.ts +5 -0
- package/dist/surfaces/display.js +19 -0
- package/dist/tui/app.d.ts +24 -1
- package/dist/tui/app.js +52 -114
- package/dist/tui/input.js +19 -3
- package/dist/tui/render.js +48 -53
- package/dist/tui/tmux.d.ts +4 -6
- package/dist/tui/tmux.js +6 -4
- package/dist/types.d.ts +65 -0
- package/dist/visuals/generate.js +2 -27
- package/package.json +4 -2
package/dist/tui/app.js
CHANGED
|
@@ -1,110 +1,25 @@
|
|
|
1
1
|
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
|
2
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
2
3
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
|
|
3
4
|
import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
|
|
4
5
|
import { handleKeypress, assignShortcuts } from './input.js';
|
|
5
6
|
import { readConversation } from '../conversation/reader.js';
|
|
6
7
|
import { defaultGenerateVisual } from '../visuals/generate.js';
|
|
8
|
+
import { validateDeck } from '../inbox/deck-schema.js';
|
|
9
|
+
import { progressPath as progressPathFor, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
10
|
+
/** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
|
|
11
|
+
* Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
|
|
12
|
+
* with sisyphus). Kept exported for back-compat. */
|
|
7
13
|
export function validateInput(parsed) {
|
|
8
|
-
|
|
9
|
-
throw new Error('Deck file must be a JSON object with an `interactions` array');
|
|
10
|
-
}
|
|
11
|
-
const obj = parsed;
|
|
12
|
-
if (!Array.isArray(obj.interactions)) {
|
|
13
|
-
throw new Error('`interactions` must be an array');
|
|
14
|
-
}
|
|
15
|
-
if (obj.interactions.length === 0) {
|
|
16
|
-
throw new Error('No interactions in deck file');
|
|
17
|
-
}
|
|
18
|
-
if (obj.title !== undefined && typeof obj.title !== 'string') {
|
|
19
|
-
throw new Error('`title` must be a string when present');
|
|
20
|
-
}
|
|
21
|
-
const seen = new Set();
|
|
22
|
-
const validated = [];
|
|
23
|
-
for (let i = 0; i < obj.interactions.length; i++) {
|
|
24
|
-
const raw = obj.interactions[i];
|
|
25
|
-
const where = `interactions[${i}]`;
|
|
26
|
-
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
27
|
-
throw new Error(`${where} must be an object`);
|
|
28
|
-
}
|
|
29
|
-
if (typeof raw.id !== 'string' || raw.id === '') {
|
|
30
|
-
throw new Error(`${where}.id must be a non-empty string`);
|
|
31
|
-
}
|
|
32
|
-
if (seen.has(raw.id)) {
|
|
33
|
-
throw new Error(`Duplicate interaction id: ${JSON.stringify(raw.id)}`);
|
|
34
|
-
}
|
|
35
|
-
seen.add(raw.id);
|
|
36
|
-
if (typeof raw.title !== 'string' || raw.title === '') {
|
|
37
|
-
throw new Error(`${where}.title must be a non-empty string`);
|
|
38
|
-
}
|
|
39
|
-
if (!Array.isArray(raw.options)) {
|
|
40
|
-
throw new Error(`${where}.options must be an array`);
|
|
41
|
-
}
|
|
42
|
-
const opts = [];
|
|
43
|
-
for (let j = 0; j < raw.options.length; j++) {
|
|
44
|
-
const o = raw.options[j];
|
|
45
|
-
const owhere = `${where}.options[${j}]`;
|
|
46
|
-
if (typeof o !== 'object' || o === null || Array.isArray(o)) {
|
|
47
|
-
throw new Error(`${owhere} must be an object`);
|
|
48
|
-
}
|
|
49
|
-
if (typeof o.id !== 'string' || o.id === '') {
|
|
50
|
-
throw new Error(`${owhere}.id must be a non-empty string`);
|
|
51
|
-
}
|
|
52
|
-
if (typeof o.label !== 'string') {
|
|
53
|
-
throw new Error(`${owhere}.label must be a string`);
|
|
54
|
-
}
|
|
55
|
-
const opt = { id: o.id, label: o.label };
|
|
56
|
-
if (o.description !== undefined) {
|
|
57
|
-
if (typeof o.description !== 'string')
|
|
58
|
-
throw new Error(`${owhere}.description must be a string`);
|
|
59
|
-
opt.description = o.description;
|
|
60
|
-
}
|
|
61
|
-
if (o.shortcut !== undefined) {
|
|
62
|
-
if (typeof o.shortcut !== 'string')
|
|
63
|
-
throw new Error(`${owhere}.shortcut must be a string`);
|
|
64
|
-
opt.shortcut = o.shortcut;
|
|
65
|
-
}
|
|
66
|
-
opts.push(opt);
|
|
67
|
-
}
|
|
68
|
-
const interaction = { id: raw.id, title: raw.title, options: opts };
|
|
69
|
-
if (raw.subtitle !== undefined) {
|
|
70
|
-
if (typeof raw.subtitle !== 'string')
|
|
71
|
-
throw new Error(`${where}.subtitle must be a string`);
|
|
72
|
-
interaction.subtitle = raw.subtitle;
|
|
73
|
-
}
|
|
74
|
-
if (raw.body !== undefined) {
|
|
75
|
-
if (typeof raw.body !== 'string')
|
|
76
|
-
throw new Error(`${where}.body must be a string`);
|
|
77
|
-
interaction.body = raw.body;
|
|
78
|
-
}
|
|
79
|
-
if (raw.bodyPath !== undefined) {
|
|
80
|
-
if (typeof raw.bodyPath !== 'string')
|
|
81
|
-
throw new Error(`${where}.bodyPath must be a string`);
|
|
82
|
-
interaction.bodyPath = raw.bodyPath;
|
|
83
|
-
}
|
|
84
|
-
if (raw.freetextLabel !== undefined) {
|
|
85
|
-
if (typeof raw.freetextLabel !== 'string')
|
|
86
|
-
throw new Error(`${where}.freetextLabel must be a string`);
|
|
87
|
-
interaction.freetextLabel = raw.freetextLabel;
|
|
88
|
-
}
|
|
89
|
-
if (raw.allowFreetext !== undefined) {
|
|
90
|
-
if (typeof raw.allowFreetext !== 'boolean')
|
|
91
|
-
throw new Error(`${where}.allowFreetext must be a boolean`);
|
|
92
|
-
interaction.allowFreetext = raw.allowFreetext;
|
|
93
|
-
}
|
|
94
|
-
if (raw.kind !== undefined) {
|
|
95
|
-
interaction.kind = raw.kind;
|
|
96
|
-
}
|
|
97
|
-
validated.push(interaction);
|
|
98
|
-
}
|
|
99
|
-
const deck = { interactions: validated };
|
|
100
|
-
if (obj.title !== undefined)
|
|
101
|
-
deck.title = obj.title;
|
|
102
|
-
return deck;
|
|
14
|
+
return validateDeck(parsed);
|
|
103
15
|
}
|
|
104
16
|
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
105
17
|
function buildInitialState(deck) {
|
|
18
|
+
// Single-question decks skip the overview list — there's nothing to overview,
|
|
19
|
+
// and overview hides the option hotkeys so users press 'y' and nothing happens.
|
|
20
|
+
const initialPhase = deck.interactions.length === 1 ? 'item-review' : 'overview';
|
|
106
21
|
return {
|
|
107
|
-
phase:
|
|
22
|
+
phase: initialPhase,
|
|
108
23
|
currentIndex: 0,
|
|
109
24
|
interactions: deck.interactions,
|
|
110
25
|
responses: new Map(),
|
|
@@ -266,17 +181,18 @@ export function mountPanel(opts) {
|
|
|
266
181
|
},
|
|
267
182
|
};
|
|
268
183
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Resolve an interaction directory in place: mount the panel TUI keyed off
|
|
186
|
+
* `<dir>/progress.json`, and on finish (full completion OR human-finished
|
|
187
|
+
* with skips) write `<dir>/response.json` atomically and drop the progress
|
|
188
|
+
* file. A hard process kill leaves `progress.json` for a later resume —
|
|
189
|
+
* `tryResume` (unchanged logic) reads the new dir-derived path.
|
|
190
|
+
*/
|
|
191
|
+
export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
276
192
|
let conversationContext = '';
|
|
277
|
-
if (sessionId !== undefined) {
|
|
193
|
+
if (opts.sessionId !== undefined) {
|
|
278
194
|
try {
|
|
279
|
-
const conv = readConversation(sessionId);
|
|
195
|
+
const conv = readConversation(opts.sessionId);
|
|
280
196
|
conversationContext = conv.map((m) => `${m.role}: ${m.content}`).join('\n\n');
|
|
281
197
|
}
|
|
282
198
|
catch {
|
|
@@ -284,7 +200,13 @@ export async function launchTui(decisionsPath, sessionId) {
|
|
|
284
200
|
}
|
|
285
201
|
}
|
|
286
202
|
setupTerminal();
|
|
287
|
-
const
|
|
203
|
+
const term = getTerminalSize();
|
|
204
|
+
const cols = opts.cols ?? term.cols;
|
|
205
|
+
const rows = opts.rows ?? term.rows;
|
|
206
|
+
const generateVisual = opts.generateVisual ??
|
|
207
|
+
(opts.sessionId !== undefined
|
|
208
|
+
? (interaction) => defaultGenerateVisual(interaction, conversationContext)
|
|
209
|
+
: undefined);
|
|
288
210
|
return new Promise((resolve) => {
|
|
289
211
|
let panel = null;
|
|
290
212
|
let prevFrameLocal = [];
|
|
@@ -299,28 +221,30 @@ export async function launchTui(decisionsPath, sessionId) {
|
|
|
299
221
|
process.stdout.write('\x1b[?2026l');
|
|
300
222
|
prevFrameLocal = nextPrevFrame;
|
|
301
223
|
};
|
|
302
|
-
const
|
|
224
|
+
const finalize = (responses) => {
|
|
303
225
|
restoreTerminal();
|
|
304
226
|
process.stdin.removeListener('data', onData);
|
|
305
227
|
panel?.unmount();
|
|
306
|
-
|
|
228
|
+
const completedAt = new Date().toISOString();
|
|
229
|
+
// Resolved supersedes in-progress: write response.json, drop progress.json.
|
|
230
|
+
const rp = writeResponse(dir, responses, completedAt);
|
|
231
|
+
clearProgress(dir);
|
|
232
|
+
resolve({ responses, completedAt, responsePath: rp });
|
|
307
233
|
};
|
|
308
234
|
panel = mountPanel({
|
|
309
235
|
deck,
|
|
310
|
-
progressPath:
|
|
236
|
+
progressPath: progressPathFor(dir),
|
|
311
237
|
cols,
|
|
312
238
|
rows,
|
|
313
|
-
generateVisual
|
|
314
|
-
? (interaction) => defaultGenerateVisual(interaction, conversationContext)
|
|
315
|
-
: undefined,
|
|
239
|
+
generateVisual,
|
|
316
240
|
onProgress: (responses) => {
|
|
317
241
|
lastResponses = responses;
|
|
318
242
|
if (panel !== null)
|
|
319
243
|
flushHost(panel.render());
|
|
320
244
|
},
|
|
321
|
-
onComplete,
|
|
245
|
+
onComplete: finalize,
|
|
322
246
|
onExit: () => {
|
|
323
|
-
|
|
247
|
+
finalize(lastResponses);
|
|
324
248
|
},
|
|
325
249
|
});
|
|
326
250
|
flushHost(panel.render());
|
|
@@ -332,3 +256,17 @@ export async function launchTui(decisionsPath, sessionId) {
|
|
|
332
256
|
process.stdin.on('data', onData);
|
|
333
257
|
});
|
|
334
258
|
}
|
|
259
|
+
// ── launchTui — file-path entry over the dir resolver (a kept public export
|
|
260
|
+
// per the interaction-layer plan; consumed until consumers move to ask()) ──
|
|
261
|
+
export async function launchTui(decisionsPath, sessionId) {
|
|
262
|
+
if (!existsSync(decisionsPath)) {
|
|
263
|
+
throw new Error(`Decisions file not found: ${decisionsPath}`);
|
|
264
|
+
}
|
|
265
|
+
const raw = readFileSync(decisionsPath, 'utf8');
|
|
266
|
+
const deck = validateInput(JSON.parse(raw));
|
|
267
|
+
// The interaction dir is the deck file's directory; progress/response live
|
|
268
|
+
// there per the convention.
|
|
269
|
+
const dir = dirname(resolvePath(decisionsPath));
|
|
270
|
+
const { responses, completedAt } = await resolveInteractionDir(dir, deck, { sessionId });
|
|
271
|
+
return { responses, completedAt };
|
|
272
|
+
}
|
package/dist/tui/input.js
CHANGED
|
@@ -62,18 +62,21 @@ function handleOverview(input, key, state, render, exit) {
|
|
|
62
62
|
if (input === 'j' || key.downArrow) {
|
|
63
63
|
state.currentIndex = Math.min(state.currentIndex + 1, state.interactions.length - 1);
|
|
64
64
|
render();
|
|
65
|
+
return;
|
|
65
66
|
}
|
|
66
|
-
|
|
67
|
+
if (input === 'k' || key.upArrow) {
|
|
67
68
|
state.currentIndex = Math.max(state.currentIndex - 1, 0);
|
|
68
69
|
render();
|
|
70
|
+
return;
|
|
69
71
|
}
|
|
70
|
-
|
|
72
|
+
if (key.return || input === ' ') {
|
|
71
73
|
state.phase = 'item-review';
|
|
72
74
|
state.selectedAction = 0;
|
|
73
75
|
state.detailExpanded = false;
|
|
74
76
|
render();
|
|
77
|
+
return;
|
|
75
78
|
}
|
|
76
|
-
|
|
79
|
+
if (input === 'q') {
|
|
77
80
|
if (state.responses.size >= state.interactions.length) {
|
|
78
81
|
exit();
|
|
79
82
|
}
|
|
@@ -81,6 +84,19 @@ function handleOverview(input, key, state, render, exit) {
|
|
|
81
84
|
state.phase = 'final';
|
|
82
85
|
render();
|
|
83
86
|
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Quick-answer: option shortcut for the focused interaction. Lets users
|
|
90
|
+
// answer from the overview list without pressing Enter first.
|
|
91
|
+
const interaction = state.interactions[state.currentIndex];
|
|
92
|
+
if (interaction !== undefined) {
|
|
93
|
+
const matched = interaction.options.find((o) => o.shortcut === input);
|
|
94
|
+
if (matched !== undefined) {
|
|
95
|
+
submitOption(state, interaction, matched.id, undefined);
|
|
96
|
+
// Don't auto-advance the cursor — users may want to re-answer the same
|
|
97
|
+
// question. The response icon flips ✓ and they can j/k away when ready.
|
|
98
|
+
render();
|
|
99
|
+
}
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
102
|
// ── Item Review ──────────────────────────────────────────────────────────────
|
package/dist/tui/render.js
CHANGED
|
@@ -1,45 +1,5 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import stringWidth from 'string-width';
|
|
3
|
-
|
|
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
|
-
}
|
|
2
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
43
3
|
// ── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
44
4
|
const ESC = '\x1b[';
|
|
45
5
|
const RESET = `${ESC}0m`;
|
|
@@ -166,6 +126,22 @@ function hardWrap(text, maxWidth) {
|
|
|
166
126
|
}
|
|
167
127
|
return out;
|
|
168
128
|
}
|
|
129
|
+
// ── Horizontal centering ─────────────────────────────────────────────────────
|
|
130
|
+
/**
|
|
131
|
+
* Pad each non-empty line with leading spaces to horizontally center the
|
|
132
|
+
* `contentWidth`-wide block within `cols`. Wide terminals (dashboard, full
|
|
133
|
+
* screen) get visual breathing room; narrow panes (split tmux pane next to a
|
|
134
|
+
* spawning agent) skip centering because there's nothing to center.
|
|
135
|
+
*
|
|
136
|
+
* Empty lines stay empty so frame diffing can keep them as cheap no-ops.
|
|
137
|
+
*/
|
|
138
|
+
function centerHorizontal(lines, cols, contentWidth) {
|
|
139
|
+
const extraPad = Math.max(0, Math.floor((cols - contentWidth) / 2));
|
|
140
|
+
if (extraPad === 0)
|
|
141
|
+
return lines;
|
|
142
|
+
const pad = ' '.repeat(extraPad);
|
|
143
|
+
return lines.map((line) => (line === '' ? '' : pad + line));
|
|
144
|
+
}
|
|
169
145
|
// ── Frame buffer ─────────────────────────────────────────────────────────────
|
|
170
146
|
export function diffFrame(prevFrame, nextLines, rows) {
|
|
171
147
|
const writes = [];
|
|
@@ -237,7 +213,10 @@ export function renderOverview(state, cols, rows) {
|
|
|
237
213
|
lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
|
|
238
214
|
while (lines.length < rows)
|
|
239
215
|
lines.push('');
|
|
240
|
-
|
|
216
|
+
// Overview content extends roughly cols-16 wide for option labels; center
|
|
217
|
+
// against a 60-col cap (the divider width) when the terminal is much wider.
|
|
218
|
+
const centered = centerHorizontal(lines.slice(0, rows), cols, Math.min(cols, 60) + 2);
|
|
219
|
+
return centered;
|
|
241
220
|
}
|
|
242
221
|
export function renderItemReview(state, cols, rows) {
|
|
243
222
|
const interaction = state.interactions[state.currentIndex];
|
|
@@ -263,7 +242,7 @@ export function renderItemReview(state, cols, rows) {
|
|
|
263
242
|
const bodyLines = [];
|
|
264
243
|
if (interaction.body) {
|
|
265
244
|
bodyLines.push('');
|
|
266
|
-
for (const line of
|
|
245
|
+
for (const line of renderMarkdown(interaction.body, maxW)) {
|
|
267
246
|
bodyLines.push(` ${line}`);
|
|
268
247
|
}
|
|
269
248
|
}
|
|
@@ -307,7 +286,7 @@ export function renderItemReview(state, cols, rows) {
|
|
|
307
286
|
? opts.find((o) => o.id === attachedId)
|
|
308
287
|
: undefined;
|
|
309
288
|
const valueText = attached !== undefined
|
|
310
|
-
? `${CYAN}${
|
|
289
|
+
? `${CYAN}${singleLine(attached.label)}${RESET}`
|
|
311
290
|
: `${DIM}none${RESET}`;
|
|
312
291
|
attachedLine = ` ${DIM}attached:${RESET} ${valueText} ${DIM}[tab to cycle]${RESET}`;
|
|
313
292
|
}
|
|
@@ -326,7 +305,7 @@ export function renderItemReview(state, cols, rows) {
|
|
|
326
305
|
postLines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
|
|
327
306
|
}
|
|
328
307
|
else {
|
|
329
|
-
postLines.push(...renderActions(interaction, state.selectedAction, response));
|
|
308
|
+
postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
|
|
330
309
|
}
|
|
331
310
|
// Window the body
|
|
332
311
|
const reservedRows = preLines.length + postLines.length + 1; // +1 for footer
|
|
@@ -365,21 +344,37 @@ export function renderItemReview(state, cols, rows) {
|
|
|
365
344
|
lines.push('');
|
|
366
345
|
lines.push(footer);
|
|
367
346
|
// Final clamp (safety net for very small terminals)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
347
|
+
const clamped = lines.length > rows
|
|
348
|
+
? [...lines.slice(0, rows - 1), footer]
|
|
349
|
+
: lines;
|
|
350
|
+
// Content occupies maxW cols of body + 2 cols of left prefix — center the
|
|
351
|
+
// whole block when the terminal is wider than that.
|
|
352
|
+
return centerHorizontal(clamped, cols, maxW + 2);
|
|
372
353
|
}
|
|
373
|
-
function renderActions(interaction, selectedAction, existing) {
|
|
354
|
+
function renderActions(interaction, selectedAction, maxW, existing) {
|
|
374
355
|
const lines = [];
|
|
375
356
|
const opts = interaction.options;
|
|
357
|
+
// Prefix on first row: " X [s] " — 2 + 1 (cursor) + 1 + 3 ([s]) + 1 = 8 visible cols.
|
|
358
|
+
// Continuation rows align under the label so each option reads as a block.
|
|
359
|
+
const prefixWidth = 8;
|
|
360
|
+
const indent = ' '.repeat(prefixWidth);
|
|
361
|
+
const contentMax = Math.max(20, maxW - prefixWidth);
|
|
376
362
|
for (let i = 0; i < opts.length; i++) {
|
|
377
363
|
const o = opts[i];
|
|
378
364
|
const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
|
|
379
365
|
const sc = o.shortcut ?? ' ';
|
|
380
366
|
const keyBadge = `${DIM}[${sc}]${RESET}`;
|
|
381
|
-
const
|
|
382
|
-
|
|
367
|
+
const labelLines = wrap(sanitize(o.label), contentMax);
|
|
368
|
+
for (let j = 0; j < labelLines.length; j++) {
|
|
369
|
+
const prefix = j === 0 ? ` ${cursor} ${keyBadge} ` : indent;
|
|
370
|
+
lines.push(`${prefix}${labelLines[j]}`);
|
|
371
|
+
}
|
|
372
|
+
if (o.description) {
|
|
373
|
+
const descLines = wrap(`— ${sanitize(o.description)}`, contentMax);
|
|
374
|
+
for (const dl of descLines) {
|
|
375
|
+
lines.push(`${indent}${DIM}${dl}${RESET}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
383
378
|
}
|
|
384
379
|
if (interaction.allowFreetext && opts.length > 0) {
|
|
385
380
|
const cursor = opts.length === selectedAction ? `${CYAN}▸${RESET}` : ' ';
|
|
@@ -435,7 +430,7 @@ export function renderFinal(state, cols, rows) {
|
|
|
435
430
|
const lines = [...header, ...visible, ...footer];
|
|
436
431
|
while (lines.length < rows)
|
|
437
432
|
lines.push('');
|
|
438
|
-
return lines.slice(0, rows);
|
|
433
|
+
return centerHorizontal(lines.slice(0, rows), cols, maxW + 2);
|
|
439
434
|
}
|
|
440
435
|
export function responseSummary(r, interaction) {
|
|
441
436
|
const opt = r.selectedOptionId
|
package/dist/tui/tmux.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
export interface TuiOutput {
|
|
3
|
-
responses: InteractionResponse[];
|
|
4
|
-
completedAt: string;
|
|
5
|
-
}
|
|
1
|
+
import type { ResolutionEnvelope } from '../types.js';
|
|
6
2
|
export interface TmuxDispatchOpts {
|
|
7
3
|
sessionId?: string;
|
|
8
4
|
visuals: boolean;
|
|
5
|
+
/** Interaction dir forwarded to the child so response.json lands there. */
|
|
6
|
+
dir: string;
|
|
9
7
|
}
|
|
10
|
-
export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<
|
|
8
|
+
export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<ResolutionEnvelope>;
|
package/dist/tui/tmux.js
CHANGED
|
@@ -15,8 +15,10 @@ function buildChildCmd(file, resultPath, opts) {
|
|
|
15
15
|
const parts = [
|
|
16
16
|
shellQuote(process.execPath),
|
|
17
17
|
shellQuote(scriptPath),
|
|
18
|
-
'
|
|
18
|
+
'ask',
|
|
19
19
|
shellQuote(file),
|
|
20
|
+
'--dir',
|
|
21
|
+
shellQuote(opts.dir),
|
|
20
22
|
'--write-to',
|
|
21
23
|
shellQuote(resultPath),
|
|
22
24
|
];
|
|
@@ -29,8 +31,8 @@ function buildChildCmd(file, resultPath, opts) {
|
|
|
29
31
|
return parts.join(' ');
|
|
30
32
|
}
|
|
31
33
|
export async function dispatchToTmuxPane(file, opts) {
|
|
32
|
-
const
|
|
33
|
-
const resultPath = join(
|
|
34
|
+
const parentTmp = mkdtempSync(join(tmpdir(), 'hl-'));
|
|
35
|
+
const resultPath = join(parentTmp, 'result.json');
|
|
34
36
|
const cmd = buildChildCmd(file, resultPath, opts);
|
|
35
37
|
// Capture the spawned pane id so we can detect if the user closes it
|
|
36
38
|
// without finishing — otherwise the parent would poll forever.
|
|
@@ -65,7 +67,7 @@ export async function dispatchToTmuxPane(file, opts) {
|
|
|
65
67
|
}
|
|
66
68
|
catch { /* ignore */ }
|
|
67
69
|
try {
|
|
68
|
-
rmdirSync(
|
|
70
|
+
rmdirSync(parentTmp);
|
|
69
71
|
}
|
|
70
72
|
catch { /* ignore */ }
|
|
71
73
|
return JSON.parse(json);
|
package/dist/types.d.ts
CHANGED
|
@@ -32,6 +32,32 @@ export interface Deck {
|
|
|
32
32
|
source?: DeckSource;
|
|
33
33
|
interactions: Interaction[];
|
|
34
34
|
}
|
|
35
|
+
export interface FeedbackComment {
|
|
36
|
+
id: string;
|
|
37
|
+
/** 1-based source line where the comment is anchored (start). */
|
|
38
|
+
line: number;
|
|
39
|
+
/** 1-based source line where the anchored range ends (== line for one line). */
|
|
40
|
+
endLine: number;
|
|
41
|
+
/** Exact selected substring when the human made a visual selection. */
|
|
42
|
+
quote?: string;
|
|
43
|
+
/** 0-based byte column where a partial (charwise) selection starts on `line`. */
|
|
44
|
+
colStart?: number;
|
|
45
|
+
/** 0-based exclusive byte column where the selection ends on `endLine`. */
|
|
46
|
+
colEnd?: number;
|
|
47
|
+
/** Full source text of the anchored line(s) — context for the agent. */
|
|
48
|
+
lineText: string;
|
|
49
|
+
comment: string;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
}
|
|
52
|
+
export interface FeedbackResult {
|
|
53
|
+
file: string;
|
|
54
|
+
submitted: boolean;
|
|
55
|
+
/** True when submitted with zero comments — human signalled "looks good". */
|
|
56
|
+
approved: boolean;
|
|
57
|
+
comments: FeedbackComment[];
|
|
58
|
+
submittedAt?: string;
|
|
59
|
+
savedAt: string;
|
|
60
|
+
}
|
|
35
61
|
export interface VisualBlock {
|
|
36
62
|
questionId: string;
|
|
37
63
|
content: string;
|
|
@@ -58,6 +84,45 @@ export interface TuiState {
|
|
|
58
84
|
scrollOffset: number;
|
|
59
85
|
persist?: () => void;
|
|
60
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolution contract returned by `ask`/`inbox`. On-disk `response.json` stays
|
|
89
|
+
* `{ responses, completedAt }`; `responsePath` points at it. `hl schema
|
|
90
|
+
* response` returns the JSON Schema this `schema` id names.
|
|
91
|
+
*/
|
|
92
|
+
export interface ResolutionEnvelope {
|
|
93
|
+
/** 1 line/interaction "<title>: <option label>[ — <freetext>]"; deterministic, no LLM. */
|
|
94
|
+
summary: string;
|
|
95
|
+
/** Absolute path to response.json. */
|
|
96
|
+
responsePath: string;
|
|
97
|
+
schema: 'humanloop.response/v2';
|
|
98
|
+
/** Inline (small). */
|
|
99
|
+
responses: InteractionResponse[];
|
|
100
|
+
/** ISO timestamp. */
|
|
101
|
+
completedAt: string;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* One pending interaction discovered by `scanInbox`. Read from the
|
|
105
|
+
* `deck.json` header only — never the full deck.
|
|
106
|
+
*/
|
|
107
|
+
export interface InboxItem {
|
|
108
|
+
dir: string;
|
|
109
|
+
id: string;
|
|
110
|
+
title?: string;
|
|
111
|
+
subtitle?: string;
|
|
112
|
+
kind?: InteractionKind;
|
|
113
|
+
/** `deck.source.blockedSince` ?? `statSync(deck.json).mtime` (ISO). */
|
|
114
|
+
blockedSince: string;
|
|
115
|
+
source?: DeckSource;
|
|
116
|
+
}
|
|
117
|
+
/** Options for `display()` — the live-watch tmux pane surface. */
|
|
118
|
+
export interface DisplayOpts {
|
|
119
|
+
/** Pass `--watch` so the pane live-updates on edits. Default true. */
|
|
120
|
+
watch?: boolean;
|
|
121
|
+
/** `'auto'` (default) splits until the pane budget, then opens a new window. */
|
|
122
|
+
window?: 'auto' | 'split' | 'new';
|
|
123
|
+
/** Pane budget per window before `'auto'` opens a new window. Default 3. */
|
|
124
|
+
maxPanes?: number;
|
|
125
|
+
}
|
|
61
126
|
export type GenerateVisual = (interaction: Interaction) => Promise<{
|
|
62
127
|
ok: true;
|
|
63
128
|
ansi: string;
|
package/dist/visuals/generate.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { query } from '@r-cli/sdk';
|
|
2
|
-
import {
|
|
2
|
+
import { renderMarkdown } from '../render/termrender.js';
|
|
3
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
4
|
|
|
5
5
|
# Length
|
|
@@ -68,31 +68,6 @@ async function callHaiku(prompt, systemPrompt) {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
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
71
|
// defaultGenerateVisual matches the GenerateVisual contract for use with
|
|
97
72
|
// mountPanel. Width is read from process.stdout.columns so callers that
|
|
98
73
|
// embed humanloop in a sub-region should supply their own closure that bakes
|
|
@@ -113,7 +88,7 @@ export async function defaultGenerateVisual(interaction, conversationContext) {
|
|
|
113
88
|
.replace(/^```[\w]*\n?/gm, '')
|
|
114
89
|
.replace(/^```\s*$/gm, '')
|
|
115
90
|
.trim();
|
|
116
|
-
const ansi =
|
|
91
|
+
const ansi = renderMarkdown(markdown, width).join('\n');
|
|
117
92
|
return { ok: true, ansi, markdown };
|
|
118
93
|
}
|
|
119
94
|
return { ok: false, error: 'haiku returned no output' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crouton-kit/humanloop",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Human-in-the-loop decision TUI — agents write questions, humans answer them",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -25,12 +25,14 @@
|
|
|
25
25
|
"build": "tsc",
|
|
26
26
|
"dev": "tsx src/cli.ts",
|
|
27
27
|
"link": "npm link",
|
|
28
|
+
"postinstall": "node dist/scripts/install-renderer.js || true",
|
|
28
29
|
"test": "tsx src/__tests__/mount-panel.test.ts"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@r-cli/sdk": "^1.3.0",
|
|
32
33
|
"commander": "^13.0.0",
|
|
33
|
-
"string-width": "^7.0.0"
|
|
34
|
+
"string-width": "^7.0.0",
|
|
35
|
+
"zod": "^4.3.6"
|
|
34
36
|
},
|
|
35
37
|
"devDependencies": {
|
|
36
38
|
"@types/node": "^22.0.0",
|