@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/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
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
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: 'overview',
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
- // ── launchTui shim ────────────────────────────────────────────────────────────
270
- export async function launchTui(decisionsPath, sessionId) {
271
- if (!existsSync(decisionsPath)) {
272
- throw new Error(`Decisions file not found: ${decisionsPath}`);
273
- }
274
- const raw = readFileSync(decisionsPath, 'utf8');
275
- const deck = validateInput(JSON.parse(raw));
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 { cols, rows } = getTerminalSize();
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 onComplete = (responses) => {
224
+ const finalize = (responses) => {
303
225
  restoreTerminal();
304
226
  process.stdin.removeListener('data', onData);
305
227
  panel?.unmount();
306
- resolve({ responses, completedAt: new Date().toISOString() });
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: `${decisionsPath}.progress.json`,
236
+ progressPath: progressPathFor(dir),
311
237
  cols,
312
238
  rows,
313
- generateVisual: sessionId !== undefined
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
- onComplete(lastResponses);
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
- else if (input === 'k' || key.upArrow) {
67
+ if (input === 'k' || key.upArrow) {
67
68
  state.currentIndex = Math.max(state.currentIndex - 1, 0);
68
69
  render();
70
+ return;
69
71
  }
70
- else if (key.return) {
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
- else if (input === 'q') {
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 ──────────────────────────────────────────────────────────────
@@ -1,45 +1,5 @@
1
- import { execFileSync } from 'node:child_process';
2
1
  import stringWidth from 'string-width';
3
- // ── Termrender body rendering ────────────────────────────────────────────────
4
- let _termrenderAvail = null;
5
- function isTermrenderAvailable() {
6
- if (_termrenderAvail !== null)
7
- return _termrenderAvail;
8
- try {
9
- execFileSync('termrender', ['--version'], { stdio: 'pipe', timeout: 3000 });
10
- _termrenderAvail = true;
11
- }
12
- catch {
13
- _termrenderAvail = false;
14
- }
15
- return _termrenderAvail;
16
- }
17
- const _bodyCache = new Map();
18
- function renderBody(text, width) {
19
- const key = `${text}\0${width}`;
20
- const cached = _bodyCache.get(key);
21
- if (cached)
22
- return cached;
23
- if (isTermrenderAvailable()) {
24
- try {
25
- const out = execFileSync('termrender', ['--width', String(width)], {
26
- input: text,
27
- encoding: 'utf-8',
28
- timeout: 5000,
29
- stdio: ['pipe', 'pipe', 'pipe'],
30
- });
31
- const lines = out.split('\n');
32
- if (lines.length > 0 && lines[lines.length - 1] === '')
33
- lines.pop();
34
- _bodyCache.set(key, lines);
35
- return lines;
36
- }
37
- catch { /* fall through */ }
38
- }
39
- const fallback = wrap(sanitize(text), width);
40
- _bodyCache.set(key, fallback);
41
- return fallback;
42
- }
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
- return lines.slice(0, rows);
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 renderBody(interaction.body, maxW)) {
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}${sanitize(attached.label)}${RESET}`
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
- if (lines.length > rows) {
369
- return [...lines.slice(0, rows - 1), footer];
370
- }
371
- return lines;
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 desc = o.description ? ` ${DIM}— ${sanitize(o.description)}${RESET}` : '';
382
- lines.push(` ${cursor} ${keyBadge} ${sanitize(o.label)}${desc}`);
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
@@ -1,10 +1,8 @@
1
- import type { InteractionResponse } from '../types.js';
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<TuiOutput>;
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
- 'create',
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 dir = mkdtempSync(join(tmpdir(), 'hl-'));
33
- const resultPath = join(dir, 'result.json');
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(dir);
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;
@@ -1,5 +1,5 @@
1
1
  import { query } from '@r-cli/sdk';
2
- import { execSync } from 'child_process';
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 = renderWithTermrender(markdown, width);
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",
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",