@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.
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import type { DecisionsOutput } from '../types.js';
2
+ export interface TmuxDispatchOpts {
3
+ sessionId?: string;
4
+ visuals: boolean;
5
+ }
6
+ export declare function dispatchToTmuxPane(file: string, opts: TmuxDispatchOpts): Promise<DecisionsOutput>;
@@ -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
+ }
@@ -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,2 @@
1
+ // ── Input: what the agent writes ─────────────────────────────────────────────
2
+ export {};
@@ -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
+ }