@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.
- package/dist/api.js +1 -42
- package/dist/cli.js +9 -1
- package/dist/inbox/convention.d.ts +1 -1
- package/dist/inbox/convention.js +9 -2
- package/dist/inbox/tui.js +13 -35
- package/dist/summary.d.ts +9 -0
- package/dist/summary.js +45 -0
- package/dist/tui/ansi.d.ts +43 -0
- package/dist/tui/ansi.js +210 -0
- package/dist/tui/app.js +128 -6
- package/dist/tui/input.js +132 -20
- package/dist/tui/render.d.ts +18 -2
- package/dist/tui/render.js +75 -163
- package/dist/tui/terminal.d.ts +11 -0
- package/dist/tui/terminal.js +53 -1
- package/dist/types.d.ts +18 -0
- package/dist/visuals/generate.js +25 -41
- package/package.json +1 -1
package/dist/tui/render.js
CHANGED
|
@@ -1,157 +1,18 @@
|
|
|
1
|
-
import stringWidth from 'string-width';
|
|
2
1
|
import { renderMarkdown } from '../render/termrender.js';
|
|
3
|
-
|
|
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 <
|
|
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: [...
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
212
|
+
for (const hintLine of wrap(sanitize(state.hint), maxW)) {
|
|
213
|
+
postLines.push(` ${YELLOW}${hintLine}${RESET}`);
|
|
214
|
+
}
|
|
330
215
|
}
|
|
331
|
-
//
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tui/terminal.d.ts
CHANGED
|
@@ -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;
|
package/dist/tui/terminal.js
CHANGED
|
@@ -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 === '\
|
|
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
|
}
|
package/dist/visuals/generate.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
:::
|
|
18
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|