@crouton-kit/humanloop 0.3.21 → 0.3.23

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.
@@ -1,157 +1,18 @@
1
- import stringWidth from 'string-width';
2
1
  import { renderMarkdown } from '../render/termrender.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 CYAN = `${ESC}36m`;
12
- const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
13
- export function sanitize(text) {
14
- if (typeof text !== 'string')
15
- return '';
16
- return text.replace(CONTROL_CHARS_RE, '');
17
- }
18
- function singleLine(text) {
19
- return sanitize(text).replace(/\s+/g, ' ').trim();
20
- }
21
- function truncate(text, maxWidth) {
22
- if (maxWidth < 1)
23
- return '';
24
- if (stringWidth(text) <= maxWidth)
25
- return text;
26
- const chars = [...text];
27
- let w = 0;
28
- let out = '';
29
- for (const ch of chars) {
30
- const cw = stringWidth(ch);
31
- if (w + cw + 1 > maxWidth)
32
- break;
33
- out += ch;
34
- w += cw;
35
- }
36
- return out + '…';
37
- }
38
- function padRight(text, width) {
39
- const w = stringWidth(text);
40
- if (w >= width)
41
- return text;
42
- return text + ' '.repeat(width - w);
43
- }
44
- function hline(width, char = '─') {
45
- if (width < 1)
46
- return '';
47
- return char.repeat(width);
48
- }
49
- function wrap(text, maxWidth) {
50
- if (maxWidth < 1)
51
- return [text];
52
- const out = [];
53
- const paragraphs = text.split('\n');
54
- for (let p = 0; p < paragraphs.length; p++) {
55
- const para = paragraphs[p];
56
- if (para === '') {
57
- out.push('');
58
- continue;
59
- }
60
- const words = para.split(/[ \t]+/).filter(Boolean);
61
- let current = '';
62
- for (let word of words) {
63
- while (stringWidth(word) > maxWidth) {
64
- if (current) {
65
- out.push(current);
66
- current = '';
67
- }
68
- const piece = sliceByWidth(word, maxWidth);
69
- out.push(piece);
70
- word = word.slice(piece.length);
71
- }
72
- const candidate = current ? `${current} ${word}` : word;
73
- if (stringWidth(candidate) <= maxWidth) {
74
- current = candidate;
75
- }
76
- else {
77
- if (current)
78
- out.push(current);
79
- current = word;
80
- }
81
- }
82
- if (current)
83
- out.push(current);
84
- }
85
- return out.length > 0 ? out : [''];
86
- }
87
- function sliceByWidth(s, maxWidth) {
88
- let w = 0;
89
- let out = '';
90
- for (const ch of s) {
91
- const cw = stringWidth(ch);
92
- if (w + cw > maxWidth)
93
- break;
94
- out += ch;
95
- w += cw;
96
- }
97
- if (out === '' && s.length > 0)
98
- out = [...s][0];
99
- return out;
100
- }
101
- function hardWrap(text, maxWidth) {
102
- if (maxWidth < 1)
103
- return [text];
104
- const segments = text.split('\n');
105
- const out = [];
106
- for (const seg of segments) {
107
- if (seg.length === 0) {
108
- out.push('');
109
- continue;
110
- }
111
- let current = '';
112
- let currentW = 0;
113
- for (const ch of [...seg]) {
114
- const cw = stringWidth(ch);
115
- if (currentW + cw > maxWidth) {
116
- out.push(current);
117
- current = ch;
118
- currentW = cw;
119
- }
120
- else {
121
- current += ch;
122
- currentW += cw;
123
- }
124
- }
125
- out.push(current);
126
- }
127
- return out;
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
- }
2
+ import { ESC, RESET, BOLD, DIM, ITALIC, GREEN, YELLOW, CYAN, REVERSE, sanitize, singleLine, truncate, hline, wrap, hardWrap, centerHorizontal, clipLine, } from './ansi.js';
145
3
  // ── Frame buffer ─────────────────────────────────────────────────────────────
146
- export function diffFrame(prevFrame, nextLines, rows) {
4
+ export function diffFrame(prevFrame, nextLines, rows, cols) {
5
+ const clipped = cols !== undefined
6
+ ? nextLines.map((l) => clipLine(l, cols))
7
+ : nextLines;
147
8
  const writes = [];
148
9
  for (let i = 0; i < rows; i++) {
149
- const line = i < nextLines.length ? nextLines[i] : '';
10
+ const line = i < clipped.length ? clipped[i] : '';
150
11
  if (prevFrame[i] !== line) {
151
12
  writes.push(`${ESC}${i + 1};1H${ESC}2K${line}`);
152
13
  }
153
14
  }
154
- return { writes, nextPrevFrame: [...nextLines] };
15
+ return { writes, nextPrevFrame: [...clipped] };
155
16
  }
156
17
  // ── Renderers ────────────────────────────────────────────────────────────────
157
18
  export function renderOverview(state, cols, rows) {
@@ -221,7 +82,29 @@ export function renderOverview(state, cols, rows) {
221
82
  const centered = centerHorizontal(lines.slice(0, rows), cols, Math.min(cols, 60) + 2);
222
83
  return centered;
223
84
  }
224
- export function renderItemReview(state, cols, rows) {
85
+ /**
86
+ * Render a (possibly multiline) input buffer with a visible cursor at
87
+ * `cursor` (a code-point index). A private-use sentinel is inserted at the
88
+ * cursor position, the result is hard-wrapped (so `\n` and width-wrapping
89
+ * behave exactly as they would without a cursor), then the sentinel is
90
+ * swapped for a reverse-video block on whichever wrapped line it landed on.
91
+ * `stringWidth('\uE000') === 1`, so it occupies exactly one column and never
92
+ * throws off the wrap math.
93
+ */
94
+ export function renderInputBuffer(buffer, cursor, maxWidth) {
95
+ const chars = [...buffer];
96
+ const at = Math.max(0, Math.min(cursor, chars.length));
97
+ const withCaret = [...chars.slice(0, at), '\uE000', ...chars.slice(at)].join('');
98
+ const wrapped = hardWrap(withCaret, maxWidth);
99
+ return wrapped.map((line) => (line.includes('\uE000') ? line.replace('\uE000', `${REVERSE} ${RESET}`) : line));
100
+ }
101
+ /**
102
+ * Build the item-review frame parts (pre/body/post) and derive the body's
103
+ * scroll bounds. Pure: reads state, mutates nothing. Both `renderItemReview`
104
+ * and the pre-render `clampItemReviewScroll` go through this, so the layout
105
+ * math has a single source of truth.
106
+ */
107
+ function buildItemReviewLayout(state, cols, rows) {
225
108
  const interaction = state.interactions[state.currentIndex];
226
109
  const visual = state.visuals.get(interaction.id);
227
110
  const response = state.responses.get(interaction.id);
@@ -301,23 +184,23 @@ export function renderItemReview(state, cols, rows) {
301
184
  ? opts.find((o) => o.id === attachedId)
302
185
  : undefined;
303
186
  const valueText = attached !== undefined
304
- ? `${CYAN}${singleLine(attached.label)}${RESET}`
187
+ ? `${CYAN}${truncate(singleLine(attached.label), Math.max(10, maxW - 28))}${RESET}`
305
188
  : `${DIM}none (overall)${RESET}`;
306
189
  attachedLine = ` ${DIM}attached:${RESET} ${valueText} ${DIM}[tab to cycle]${RESET}`;
307
190
  }
308
191
  }
309
- postLines.push(` ${YELLOW}${label}:${RESET}`);
310
- const bufLines = hardWrap(state.inputMode.buffer, maxW - 1);
311
- for (let i = 0; i < bufLines.length; i++) {
312
- const isLast = i === bufLines.length - 1;
313
- postLines.push(` ${bufLines[i]}${isLast ? '█' : ''}`);
192
+ for (const labelLine of wrap(`${singleLine(label)}:`, maxW)) {
193
+ postLines.push(` ${YELLOW}${labelLine}${RESET}`);
314
194
  }
195
+ const bufLines = renderInputBuffer(state.inputMode.buffer, state.inputMode.cursor, maxW - 1);
196
+ for (const line of bufLines)
197
+ postLines.push(` ${line}`);
315
198
  if (attachedLine !== undefined) {
316
199
  postLines.push('');
317
200
  postLines.push(attachedLine);
318
201
  }
319
202
  postLines.push('');
320
- postLines.push(` ${DIM}enter${RESET} submit ${DIM}esc${RESET} cancel`);
203
+ postLines.push(` ${DIM}enter${RESET} submit ${DIM}^J/⌥⏎${RESET} newline ${DIM}^O${RESET} editor ${DIM}esc${RESET} cancel`);
321
204
  }
322
205
  else {
323
206
  postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
@@ -326,16 +209,35 @@ export function renderItemReview(state, cols, rows) {
326
209
  // just above the footer; cleared on the next keypress.
327
210
  if (state.hint !== undefined && state.hint.length > 0) {
328
211
  postLines.push('');
329
- postLines.push(` ${YELLOW}${sanitize(state.hint)}${RESET}`);
212
+ for (const hintLine of wrap(sanitize(state.hint), maxW)) {
213
+ postLines.push(` ${YELLOW}${hintLine}${RESET}`);
214
+ }
330
215
  }
331
- // Window the body
216
+ // Derive the scroll window bounds. This builder never writes back into state
217
+ // — the host clamps state.scrollOffset via clampItemReviewScroll before render.
332
218
  const reservedRows = preLines.length + postLines.length + 1; // +1 for footer
333
219
  const bodyHeight = Math.max(1, rows - reservedRows);
334
- const overflows = bodyLines.length > bodyHeight;
335
- let scroll = state.scrollOffset || 0;
336
220
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
337
- scroll = Math.max(0, Math.min(scroll, maxScroll));
338
- state.scrollOffset = scroll;
221
+ return {
222
+ interaction, preLines, bodyLines, postLines,
223
+ maxW, bodyHeight, maxScroll, overflows: bodyLines.length > bodyHeight,
224
+ };
225
+ }
226
+ /**
227
+ * Clamp state.scrollOffset to the current body's scroll bounds. The host runs
228
+ * this as a pre-render step (from the input path) so `renderItemReview` stays a
229
+ * pure function of state — the clamp keeps u/d scrolling responsive without the
230
+ * renderer mutating state mid-frame.
231
+ */
232
+ export function clampItemReviewScroll(state, cols, rows) {
233
+ const { maxScroll } = buildItemReviewLayout(state, cols, rows);
234
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
235
+ }
236
+ export function renderItemReview(state, cols, rows) {
237
+ const { interaction, preLines, bodyLines, postLines, maxW, bodyHeight, maxScroll, overflows } = buildItemReviewLayout(state, cols, rows);
238
+ // Read-only clamp: renderer never mutates state (clampItemReviewScroll, run
239
+ // pre-render, keeps state.scrollOffset itself in bounds).
240
+ const scroll = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
339
241
  let visibleBody;
340
242
  if (overflows) {
341
243
  visibleBody = bodyLines.slice(scroll, scroll + bodyHeight);
@@ -428,15 +330,25 @@ function renderActions(interaction, selectedAction, maxW, existing) {
428
330
  label = 'Add overall comment (c on an option for per-option)';
429
331
  else
430
332
  label = 'Add comment';
431
- lines.push(` ${cursor} ${DIM}[c]${RESET} ${label}`);
333
+ const ftLines = wrap(sanitize(label), contentMax);
334
+ for (let j = 0; j < ftLines.length; j++) {
335
+ const prefix = j === 0 ? ` ${cursor} ${DIM}[c]${RESET} ` : ' '.repeat(8);
336
+ lines.push(`${prefix}${ftLines[j]}`);
337
+ }
432
338
  }
433
339
  else if (interaction.allowFreetext && opts.length === 0) {
434
340
  const ftLabel = interaction.freetextLabel !== undefined ? interaction.freetextLabel : 'Enter response';
435
- lines.push(` ${DIM}[r]${RESET} ${ftLabel}`);
341
+ const ftLines = wrap(sanitize(ftLabel), contentMax);
342
+ for (let j = 0; j < ftLines.length; j++) {
343
+ const prefix = j === 0 ? ` ${DIM}[r]${RESET} ` : ' '.repeat(6);
344
+ lines.push(`${prefix}${ftLines[j]}`);
345
+ }
436
346
  }
437
347
  if (existing) {
438
348
  lines.push('');
439
- lines.push(` ${GREEN}Current: ${responseSummary(existing, interaction)}${RESET}`);
349
+ for (const curLine of wrap(`Current: ${responseSummary(existing, interaction)}`, maxW)) {
350
+ lines.push(` ${GREEN}${curLine}${RESET}`);
351
+ }
440
352
  }
441
353
  return lines;
442
354
  }
@@ -1,7 +1,18 @@
1
1
  export interface Key {
2
2
  upArrow: boolean;
3
3
  downArrow: boolean;
4
+ leftArrow: boolean;
5
+ rightArrow: boolean;
6
+ /** Word-wise cursor motion (ctrl/alt + arrow, or readline alt-b / alt-f). */
7
+ wordLeft: boolean;
8
+ wordRight: boolean;
9
+ home: boolean;
10
+ end: boolean;
11
+ /** Forward delete (the Del key / \x1b[3~). */
12
+ del: boolean;
4
13
  return: boolean;
14
+ /** Newline-insert chord (ctrl+j / alt+enter) — distinct from return=submit. */
15
+ newline: boolean;
5
16
  escape: boolean;
6
17
  ctrl: boolean;
7
18
  meta: boolean;
@@ -2,7 +2,15 @@ function emptyKey() {
2
2
  return {
3
3
  upArrow: false,
4
4
  downArrow: false,
5
+ leftArrow: false,
6
+ rightArrow: false,
7
+ wordLeft: false,
8
+ wordRight: false,
9
+ home: false,
10
+ end: false,
11
+ del: false,
5
12
  return: false,
13
+ newline: false,
6
14
  escape: false,
7
15
  ctrl: false,
8
16
  meta: false,
@@ -21,10 +29,54 @@ export function parseKeypress(data) {
21
29
  key.downArrow = true;
22
30
  return { input: '', key };
23
31
  }
24
- if (str === '\r' || str === '\n') {
32
+ if (str === '\x1b[C') {
33
+ key.rightArrow = true;
34
+ return { input: '', key };
35
+ }
36
+ if (str === '\x1b[D') {
37
+ key.leftArrow = true;
38
+ return { input: '', key };
39
+ }
40
+ // Word-wise motion: ctrl/alt + left/right (xterm modifier encodings) and the
41
+ // readline alt-b / alt-f bindings. Must precede the bare-ESC checks below.
42
+ if (str === '\x1b[1;5C' || str === '\x1b[1;3C' || str === '\x1bf') {
43
+ key.wordRight = true;
44
+ return { input: '', key };
45
+ }
46
+ if (str === '\x1b[1;5D' || str === '\x1b[1;3D' || str === '\x1bb') {
47
+ key.wordLeft = true;
48
+ return { input: '', key };
49
+ }
50
+ if (str === '\x1b[H' || str === '\x1b[1~') {
51
+ key.home = true;
52
+ return { input: '', key };
53
+ }
54
+ if (str === '\x1b[F' || str === '\x1b[4~') {
55
+ key.end = true;
56
+ return { input: '', key };
57
+ }
58
+ if (str === '\x1b[3~') {
59
+ key.del = true;
60
+ return { input: '', key };
61
+ }
62
+ // Alt+Enter inserts a newline in freetext (distinct from Enter=submit). Must
63
+ // precede the bare-ESC and meta-backspace checks so the two-byte sequence
64
+ // isn't swallowed as a lone escape.
65
+ if (str === '\x1b\r' || str === '\x1b\n') {
66
+ key.newline = true;
67
+ return { input: '', key };
68
+ }
69
+ // Enter (submit) is CR; ctrl+j (LF) inserts a newline. Splitting them lets
70
+ // freetext use ctrl+j for a hard newline while Enter still submits. In a raw
71
+ // TTY the Enter key sends CR, so this split is safe.
72
+ if (str === '\r') {
25
73
  key.return = true;
26
74
  return { input: '', key };
27
75
  }
76
+ if (str === '\n') {
77
+ key.newline = true;
78
+ return { input: '', key };
79
+ }
28
80
  // Alt+Backspace: terminals send ESC followed by DEL/BS. Must precede the
29
81
  // bare-ESC check so the two-byte sequence isn't swallowed as plain escape.
30
82
  // iTerm2 (and many readline configs) instead map Option/Alt+Backspace to a
package/dist/types.d.ts CHANGED
@@ -102,10 +102,12 @@ export type Phase = 'overview' | 'item-review' | 'final';
102
102
  export type InputMode = null | {
103
103
  kind: 'comment';
104
104
  buffer: string;
105
+ cursor: number;
105
106
  selectedOptionId?: string;
106
107
  } | {
107
108
  kind: 'freetext';
108
109
  buffer: string;
110
+ cursor: number;
109
111
  };
110
112
  export interface TuiState {
111
113
  phase: Phase;
@@ -181,6 +183,11 @@ export interface MountedPanelOpts {
181
183
  onProgress?: (responses: InteractionResponse[]) => void;
182
184
  onComplete?: (responses: InteractionResponse[]) => void;
183
185
  onExit?: () => void;
186
+ /** Fired when panel state changes outside a keypress — currently when an
187
+ * async visual finishes loading. Hosts wire this to a repaint so
188
+ * "loading context..." is replaced the moment context arrives, rather than
189
+ * sticking until the next keystroke. Optional / backward-compatible. */
190
+ onDirty?: () => void;
184
191
  }
185
192
  export interface MountedPanel {
186
193
  handleKey(input: string, key: Key): void;
@@ -197,4 +204,15 @@ export interface MountedPanel {
197
204
  * whether Esc should step back inside the deck (false) or tear it down (true).
198
205
  */
199
206
  atDeckTop(): boolean;
207
+ /**
208
+ * Live comment/freetext buffer, or undefined when not in input mode. Lets a
209
+ * host implement the $EDITOR escape hatch (ctrl+o) without reaching into
210
+ * panel-internal state: read the buffer here before spawning the editor.
211
+ */
212
+ getInputBuffer(): string | undefined;
213
+ /**
214
+ * Replace the input-mode buffer (e.g. after an $EDITOR round-trip) and move
215
+ * the cursor to the end. No-op when not in input mode.
216
+ */
217
+ setInputBuffer(text: string): void;
200
218
  }
@@ -1,47 +1,21 @@
1
1
  import { query } from '@r-cli/sdk';
2
2
  import { renderMarkdown } from '../render/termrender.js';
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.
3
+ const VISUAL_SYSTEM_PROMPT = `You are re-grounding a decision-maker in the moment before they answer the question below. They were deep in this problem but got pulled away; in the next 20 seconds they need to remember *what is actually in play* — the current state, the files, the constraint this decision lives inside — so the question stops feeling cold and they can answer with confidence.
4
4
 
5
- # Length
5
+ Write from the conversation history you're given. Lead with what *is*: the real files, functions, data, and constraints that ground this decision, named concretely (as \`path/to/file.ts:123\` so they can jump straight there). Reconstruct just enough of how they arrived here to make the question legible — no more.
6
6
 
7
- Target 15–25 lines. Hard cap 30. A tight paragraph with two file refs is often perfect don't pad.
7
+ Keep it tight: usually a short paragraph or a few bullets, 30 lines hard cap. Say less when less is true. Choose whatever shape carries the meaning fastest — prose by default, a list when enumerating, a table only to compare several things across the same dimensions, a small diagram when the structure itself is the point. You're trusted to pick; don't force a format.
8
8
 
9
- # What to write
9
+ The one rule that matters: **only reference files, identifiers, and facts that actually appear in the conversation.** Never invent a plausible-looking path or name. If the conversation is thin, write a short honest briefing about what little is grounded — that beats confident fabrication every time.
10
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.
11
+ And stay in your lane: don't restate the question, don't recommend an option or tell them how to decide, don't lay out tradeoffs or sketch alternatives. They own the decision; you only reconstruct the ground it stands on.
12
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
- :::callout{type="info|warning|error|success"} Status callout with icon
20
- ::::columns / :::col{width="50%"} Side-by-side layout (use 4 colons on the outer columns)
21
-
22
- Each opens with ::: and closes with :::. GFM tables (\`| col | col |\` with a \`| --- |\` separator) render directly — no directive needed. 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
- - When nesting directives, the outer fence needs strictly more colons than the inner — e.g. \`::::columns\` wrapping \`:::col\`. Don't nest a panel inside a panel.
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`;
13
+ Formatting: plain markdown renders (**bold**, *italic*, \`code\`, bullets, and GFM tables). These termrender directives are available if one genuinely helps anything you draw with box-drawing/ASCII must sit inside a :::panel or it will be reflowed and destroyed:
14
+ :::panel{title="T" color="cyan"} bordered box (red|green|yellow|blue|magenta|cyan|white|gray)
15
+ :::tree{color="c"} indented hierarchy (2-space indent = nesting)
16
+ :::callout{type="info|warning|error|success"} status callout
17
+ ::::columns / :::col{width="50%"} side-by-side (outer fence needs strictly more colons)
18
+ Never wrap the whole output in a code fence.`;
45
19
  async function callHaiku(prompt, systemPrompt) {
46
20
  try {
47
21
  const session = await query({
@@ -72,16 +46,26 @@ async function callHaiku(prompt, systemPrompt) {
72
46
  // mountPanel. Width is read from process.stdout.columns so callers that
73
47
  // embed humanloop in a sub-region should supply their own closure that bakes
74
48
  // in the correct width.
49
+ // Cap on how much conversation we hand the generator. Recency is what grounds
50
+ // the decision in front of the human, so when history is long we keep the tail
51
+ // (most recent messages) rather than the head.
52
+ const MAX_CONTEXT_CHARS = 24000;
75
53
  export async function defaultGenerateVisual(interaction, conversationContext) {
76
54
  const width = Math.max(40, Math.min((process.stdout.columns || 80) - 4, 76));
77
55
  const optionsSummary = interaction.options.length > 0
78
56
  ? `\nOptions: ${interaction.options.map((o) => o.label).join(' | ')}`
79
57
  : '';
80
58
  const subtitleLine = interaction.subtitle ? `\nContext: ${interaction.subtitle}` : '';
81
- const questionText = `Title: "${interaction.title}"${subtitleLine}${optionsSummary}`;
82
- const prompt = conversationContext
83
- ? `Here is the conversation so far:\n\n${conversationContext}\n\n---\n\nGenerate a visual context block for this decision point:\n\n${questionText}`
84
- : `Generate a visual context block for this decision point:\n\n${questionText}`;
59
+ // The body is the richest statement of what's being asked — hand it to the
60
+ // generator so the briefing grounds in the actual question, not just its title.
61
+ const bodyLine = interaction.body ? `\nDetail:\n${interaction.body}` : '';
62
+ const questionText = `Title: "${interaction.title}"${subtitleLine}${bodyLine}${optionsSummary}`;
63
+ const trimmedContext = conversationContext.length > MAX_CONTEXT_CHARS
64
+ ? `…(earlier conversation trimmed)…\n\n${conversationContext.slice(-MAX_CONTEXT_CHARS)}`
65
+ : conversationContext;
66
+ const prompt = trimmedContext
67
+ ? `Here is the conversation so far:\n\n${trimmedContext}\n\n---\n\nThe human is about to answer this decision. Re-ground them in what's in play:\n\n${questionText}`
68
+ : `The human is about to answer this decision. Re-ground them in what's in play:\n\n${questionText}`;
85
69
  const result = await callHaiku(prompt, VISUAL_SYSTEM_PROMPT);
86
70
  if (result) {
87
71
  const markdown = result
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/humanloop",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
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",