@crouton-kit/humanloop 0.3.22 → 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 +3 -32
- 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 +112 -4
- package/dist/tui/input.js +132 -20
- package/dist/tui/render.d.ts +17 -9
- package/dist/tui/render.js +50 -191
- 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/api.js
CHANGED
|
@@ -7,52 +7,11 @@ import { pickFromInbox } from './inbox/tui.js';
|
|
|
7
7
|
import { deckPath, atomicWriteJson, readJson, stampCanvasNode } from './inbox/convention.js';
|
|
8
8
|
import { getTerminalSize } from './tui/terminal.js';
|
|
9
9
|
import { notifyDeck } from './inbox/deck-factories.js';
|
|
10
|
+
import { buildSummary } from './summary.js';
|
|
10
11
|
const RESPONSE_SCHEMA_ID = 'humanloop.response/v2';
|
|
11
12
|
function managedDir() {
|
|
12
13
|
return mkdtempSync(join(tmpdir(), 'hl-ix-'));
|
|
13
14
|
}
|
|
14
|
-
/**
|
|
15
|
-
* Deterministic, no-LLM resolution summary — one line per answered
|
|
16
|
-
* interaction: `"<title>: <option label>[ — <freetext>]"`.
|
|
17
|
-
*/
|
|
18
|
-
function buildSummary(deck, responses) {
|
|
19
|
-
const byId = new Map(responses.map((r) => [r.id, r]));
|
|
20
|
-
const lines = [];
|
|
21
|
-
for (const it of deck.interactions) {
|
|
22
|
-
const r = byId.get(it.id);
|
|
23
|
-
if (r === undefined)
|
|
24
|
-
continue;
|
|
25
|
-
const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
|
|
26
|
-
let picked;
|
|
27
|
-
if (r.selectedOptionIds !== undefined) {
|
|
28
|
-
const oc = r.optionComments;
|
|
29
|
-
const parts = r.selectedOptionIds
|
|
30
|
-
.map((id) => it.options.find((o) => o.id === id))
|
|
31
|
-
.filter((o) => o !== undefined)
|
|
32
|
-
.map((o) => {
|
|
33
|
-
const note = oc !== undefined ? oc[o.id] : undefined;
|
|
34
|
-
return typeof note === 'string' && note.length > 0
|
|
35
|
-
? `${o.label} ("${note}")`
|
|
36
|
-
: o.label;
|
|
37
|
-
});
|
|
38
|
-
picked = parts.length > 0 ? parts.join(', ') : undefined;
|
|
39
|
-
}
|
|
40
|
-
else if (r.selectedOptionId !== undefined) {
|
|
41
|
-
picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
|
|
42
|
-
}
|
|
43
|
-
let val;
|
|
44
|
-
if (picked !== undefined && ft !== undefined)
|
|
45
|
-
val = `${picked} — ${ft}`;
|
|
46
|
-
else if (picked !== undefined)
|
|
47
|
-
val = picked;
|
|
48
|
-
else if (ft !== undefined)
|
|
49
|
-
val = ft;
|
|
50
|
-
else
|
|
51
|
-
val = '(skipped)';
|
|
52
|
-
lines.push(`${it.title}: ${val}`);
|
|
53
|
-
}
|
|
54
|
-
return lines.join('\n');
|
|
55
|
-
}
|
|
56
15
|
/**
|
|
57
16
|
* Resolve a deck against an interaction directory and return the resolution
|
|
58
17
|
* envelope. Writes `<dir>/deck.json` (the request, per the convention) and,
|
package/dist/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { display } from './surfaces/display.js';
|
|
|
12
12
|
import { renderMarkdown, checkMarkdown } from './render/termrender.js';
|
|
13
13
|
import { scanInbox } from './inbox/scan.js';
|
|
14
14
|
import { deckPath, atomicWriteJson, readJson, responsePath, stampCanvasNode, } from './inbox/convention.js';
|
|
15
|
+
import { buildSummary } from './summary.js';
|
|
15
16
|
// ── Version ───────────────────────────────────────────────────────────────────
|
|
16
17
|
const HL_VERSION = '0.2.1';
|
|
17
18
|
function emitError(err, exitCode = 1) {
|
|
@@ -898,8 +899,15 @@ function tryReadJobResult(dir, kind) {
|
|
|
898
899
|
if (!raw)
|
|
899
900
|
return null;
|
|
900
901
|
const dk = readJson(deckPath(dir));
|
|
902
|
+
// Prefer the summary persisted at write time. Legacy response.json files
|
|
903
|
+
// (written before the summary field existed) lack it — rebuild it from the
|
|
904
|
+
// deck if it's still on disk, else fall back to ''.
|
|
905
|
+
let summary = typeof raw.summary === 'string' ? raw.summary : '';
|
|
906
|
+
if (summary === '' && dk) {
|
|
907
|
+
summary = buildSummary(dk, raw.responses);
|
|
908
|
+
}
|
|
901
909
|
return {
|
|
902
|
-
summary
|
|
910
|
+
summary,
|
|
903
911
|
responsePath: rp,
|
|
904
912
|
schema: 'humanloop.response/v2',
|
|
905
913
|
responses: raw.responses,
|
|
@@ -21,6 +21,6 @@ export declare function isClaimed(dir: string): boolean;
|
|
|
21
21
|
export declare function stampCanvasNode(deck: Deck): void;
|
|
22
22
|
export declare function atomicWriteJson(path: string, value: unknown): void;
|
|
23
23
|
export declare function readJson<T>(path: string): T | null;
|
|
24
|
-
export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string): string;
|
|
24
|
+
export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
|
|
25
25
|
export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
|
|
26
26
|
export declare function clearProgress(dir: string): void;
|
package/dist/inbox/convention.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, statSync, writeFileSync, renameSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
|
|
2
2
|
import { dirname } from 'path';
|
|
3
|
+
import { buildSummary } from '../summary.js';
|
|
3
4
|
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
4
5
|
export function deckPath(dir) {
|
|
5
6
|
return `${dir}/deck.json`;
|
|
@@ -81,9 +82,15 @@ export function readJson(path) {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
// ── High-level write helpers ──────────────────────────────────────────────────
|
|
84
|
-
export function writeResponse(dir, responses, completedAt) {
|
|
85
|
+
export function writeResponse(dir, responses, completedAt, deck) {
|
|
85
86
|
const p = responsePath(dir);
|
|
86
|
-
|
|
87
|
+
// Persist the deterministic summary alongside the raw responses so
|
|
88
|
+
// `hl job result` can return a populated summary without re-deriving it (or
|
|
89
|
+
// silently emitting ''). When the deck is known at write time we compute it
|
|
90
|
+
// here; the deck is deterministic input so this never diverges from ask()'s
|
|
91
|
+
// envelope summary.
|
|
92
|
+
const summary = deck !== undefined ? buildSummary(deck, responses) : '';
|
|
93
|
+
atomicWriteJson(p, { responses, completedAt, summary });
|
|
87
94
|
return p;
|
|
88
95
|
}
|
|
89
96
|
export function writeProgress(dir, responses) {
|
package/dist/inbox/tui.js
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
import stringWidth from 'string-width';
|
|
2
1
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize, } from '../tui/terminal.js';
|
|
3
2
|
import { diffFrame } from '../tui/render.js';
|
|
3
|
+
import { RESET, BOLD, DIM, ITALIC, CYAN, RED, GRAY, YELLOW, truncateRow } from '../tui/ansi.js';
|
|
4
4
|
// ── ANSI helpers (local to this module) ──────────────────────────────────────
|
|
5
|
-
const ESC = '\x1b[';
|
|
6
|
-
const RESET = `${ESC}0m`;
|
|
7
|
-
const BOLD = `${ESC}1m`;
|
|
8
|
-
const DIM = `${ESC}2m`;
|
|
9
|
-
const ITALIC = `${ESC}3m`;
|
|
10
|
-
const CYAN = `${ESC}36m`;
|
|
11
|
-
const RED = `${ESC}31m`;
|
|
12
|
-
const GRAY = `${ESC}90m`;
|
|
13
|
-
const YELLOW = `${ESC}33m`;
|
|
14
5
|
function ansiColor(text, color) {
|
|
15
6
|
switch (color) {
|
|
16
7
|
case 'gray': return `${GRAY}${text}${RESET}`;
|
|
@@ -44,26 +35,6 @@ export function formatTimeAgo(iso) {
|
|
|
44
35
|
return `${minutes}m ago`;
|
|
45
36
|
return 'just now';
|
|
46
37
|
}
|
|
47
|
-
// ── truncate (ported from sisyphus src/tui/lib/format.ts:19-37) ──────────────
|
|
48
|
-
function truncate(text, max) {
|
|
49
|
-
const clean = text.replace(/\n/g, ' ').replace(/✅/g, '✓').replace(/❌/g, '✗').replace(/\p{Emoji_Presentation}/gu, '');
|
|
50
|
-
if (max < 4)
|
|
51
|
-
return clean.slice(0, max);
|
|
52
|
-
const w = stringWidth(clean);
|
|
53
|
-
if (w <= max)
|
|
54
|
-
return clean;
|
|
55
|
-
let result = clean;
|
|
56
|
-
while (stringWidth(result) > max - 1 && result.length > 0) {
|
|
57
|
-
const cut = result.lastIndexOf(' ', result.length - 2);
|
|
58
|
-
if (cut > max * 0.4) {
|
|
59
|
-
result = result.slice(0, cut);
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
result = result.slice(0, result.length - 1);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return result + '…';
|
|
66
|
-
}
|
|
67
38
|
// ── buildInboxLines ───────────────────────────────────────────────────────────
|
|
68
39
|
export function buildInboxLines(items, width, selectedIndex) {
|
|
69
40
|
const lines = [];
|
|
@@ -93,11 +64,11 @@ export function buildInboxLines(items, width, selectedIndex) {
|
|
|
93
64
|
else {
|
|
94
65
|
row += ' ';
|
|
95
66
|
}
|
|
96
|
-
row += `${BOLD}${
|
|
67
|
+
row += `${BOLD}${truncateRow(titleText, maxTitle)}${RESET}`;
|
|
97
68
|
row += ` ${DIM}${blocked}${RESET}`;
|
|
98
69
|
lines.push(row);
|
|
99
70
|
if (item.subtitle) {
|
|
100
|
-
lines.push(` ${DIM}${
|
|
71
|
+
lines.push(` ${DIM}${truncateRow(item.subtitle, contentWidth - 6)}${RESET}`);
|
|
101
72
|
}
|
|
102
73
|
}
|
|
103
74
|
return lines;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Deck, InteractionResponse } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic, no-LLM resolution summary — one line per answered
|
|
4
|
+
* interaction: `"<title>: <option label>[ — <freetext>]"`. Shared by `ask()`
|
|
5
|
+
* (envelope summary), `writeResponse` (persisted into response.json at write
|
|
6
|
+
* time), and `hl job result` (fallback rebuild for legacy response.json files
|
|
7
|
+
* written before the summary field existed).
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildSummary(deck: Deck, responses: InteractionResponse[]): string;
|
package/dist/summary.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, no-LLM resolution summary — one line per answered
|
|
3
|
+
* interaction: `"<title>: <option label>[ — <freetext>]"`. Shared by `ask()`
|
|
4
|
+
* (envelope summary), `writeResponse` (persisted into response.json at write
|
|
5
|
+
* time), and `hl job result` (fallback rebuild for legacy response.json files
|
|
6
|
+
* written before the summary field existed).
|
|
7
|
+
*/
|
|
8
|
+
export function buildSummary(deck, responses) {
|
|
9
|
+
const byId = new Map(responses.map((r) => [r.id, r]));
|
|
10
|
+
const lines = [];
|
|
11
|
+
for (const it of deck.interactions) {
|
|
12
|
+
const r = byId.get(it.id);
|
|
13
|
+
if (r === undefined)
|
|
14
|
+
continue;
|
|
15
|
+
const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
|
|
16
|
+
let picked;
|
|
17
|
+
if (r.selectedOptionIds !== undefined) {
|
|
18
|
+
const oc = r.optionComments;
|
|
19
|
+
const parts = r.selectedOptionIds
|
|
20
|
+
.map((id) => it.options.find((o) => o.id === id))
|
|
21
|
+
.filter((o) => o !== undefined)
|
|
22
|
+
.map((o) => {
|
|
23
|
+
const note = oc !== undefined ? oc[o.id] : undefined;
|
|
24
|
+
return typeof note === 'string' && note.length > 0
|
|
25
|
+
? `${o.label} ("${note}")`
|
|
26
|
+
: o.label;
|
|
27
|
+
});
|
|
28
|
+
picked = parts.length > 0 ? parts.join(', ') : undefined;
|
|
29
|
+
}
|
|
30
|
+
else if (r.selectedOptionId !== undefined) {
|
|
31
|
+
picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
|
|
32
|
+
}
|
|
33
|
+
let val;
|
|
34
|
+
if (picked !== undefined && ft !== undefined)
|
|
35
|
+
val = `${picked} — ${ft}`;
|
|
36
|
+
else if (picked !== undefined)
|
|
37
|
+
val = picked;
|
|
38
|
+
else if (ft !== undefined)
|
|
39
|
+
val = ft;
|
|
40
|
+
else
|
|
41
|
+
val = '(skipped)';
|
|
42
|
+
lines.push(`${it.title}: ${val}`);
|
|
43
|
+
}
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export declare const ESC = "\u001B[";
|
|
2
|
+
export declare const RESET = "\u001B[0m";
|
|
3
|
+
export declare const BOLD = "\u001B[1m";
|
|
4
|
+
export declare const DIM = "\u001B[2m";
|
|
5
|
+
export declare const ITALIC = "\u001B[3m";
|
|
6
|
+
export declare const RED = "\u001B[31m";
|
|
7
|
+
export declare const GREEN = "\u001B[32m";
|
|
8
|
+
export declare const YELLOW = "\u001B[33m";
|
|
9
|
+
export declare const CYAN = "\u001B[36m";
|
|
10
|
+
export declare const GRAY = "\u001B[90m";
|
|
11
|
+
export declare const REVERSE = "\u001B[7m";
|
|
12
|
+
export declare function sanitize(text: string): string;
|
|
13
|
+
export declare function singleLine(text: string): string;
|
|
14
|
+
export declare function hline(width: number, char?: string): string;
|
|
15
|
+
export declare function padRight(text: string, width: number): string;
|
|
16
|
+
/** Char-based width truncation with a trailing ellipsis. The canonical deck
|
|
17
|
+
* renderer variant. */
|
|
18
|
+
export declare function truncate(text: string, maxWidth: number): string;
|
|
19
|
+
/** Word-aware truncation that also flattens newlines and strips decorative
|
|
20
|
+
* emoji — tuned for dense inbox rows (ported from sisyphus format.ts). Kept
|
|
21
|
+
* distinct from `truncate` because those two surfaces want different behavior. */
|
|
22
|
+
export declare function truncateRow(text: string, max: number): string;
|
|
23
|
+
/** Word-wrap on whitespace; splits on `\n` into separate paragraphs. */
|
|
24
|
+
export declare function wrap(text: string, maxWidth: number): string[];
|
|
25
|
+
/** Hard character wrap that ignores word boundaries; splits on `\n`. Used for
|
|
26
|
+
* free-form input buffers where every character (including runs without
|
|
27
|
+
* spaces) must stay on screen. */
|
|
28
|
+
export declare function hardWrap(text: string, maxWidth: number): string[];
|
|
29
|
+
/**
|
|
30
|
+
* Pad each non-empty line with leading spaces to horizontally center the
|
|
31
|
+
* `contentWidth`-wide block within `cols`. Wide terminals get breathing room;
|
|
32
|
+
* narrow panes skip centering. Empty lines stay empty so frame diffing keeps
|
|
33
|
+
* them as cheap no-ops.
|
|
34
|
+
*/
|
|
35
|
+
export declare function centerHorizontal(lines: string[], cols: number, contentWidth: number): string[];
|
|
36
|
+
/**
|
|
37
|
+
* ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
|
|
38
|
+
* escape sequences through untouched. Every line written to the terminal must
|
|
39
|
+
* fit within the columns, or it physically wraps onto the next row — which
|
|
40
|
+
* breaks diffFrame's one-logical-line-per-row model and strands spillover text
|
|
41
|
+
* on rows the differ believes are empty (so it never erases them).
|
|
42
|
+
*/
|
|
43
|
+
export declare function clipLine(line: string, maxWidth: number): string;
|
package/dist/tui/ansi.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
// ── ANSI escape constants ─────────────────────────────────────────────────────
|
|
3
|
+
// The single home for these — both the deck renderer (render.ts) and the inbox
|
|
4
|
+
// picker (inbox/tui.ts) import from here rather than keeping their own copies.
|
|
5
|
+
export const ESC = '\x1b[';
|
|
6
|
+
export const RESET = `${ESC}0m`;
|
|
7
|
+
export const BOLD = `${ESC}1m`;
|
|
8
|
+
export const DIM = `${ESC}2m`;
|
|
9
|
+
export const ITALIC = `${ESC}3m`;
|
|
10
|
+
export const RED = `${ESC}31m`;
|
|
11
|
+
export const GREEN = `${ESC}32m`;
|
|
12
|
+
export const YELLOW = `${ESC}33m`;
|
|
13
|
+
export const CYAN = `${ESC}36m`;
|
|
14
|
+
export const GRAY = `${ESC}90m`;
|
|
15
|
+
export const REVERSE = `${ESC}7m`;
|
|
16
|
+
// ── Text helpers ──────────────────────────────────────────────────────────────
|
|
17
|
+
const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
|
|
18
|
+
export function sanitize(text) {
|
|
19
|
+
if (typeof text !== 'string')
|
|
20
|
+
return '';
|
|
21
|
+
return text.replace(CONTROL_CHARS_RE, '');
|
|
22
|
+
}
|
|
23
|
+
export function singleLine(text) {
|
|
24
|
+
return sanitize(text).replace(/\s+/g, ' ').trim();
|
|
25
|
+
}
|
|
26
|
+
export function hline(width, char = '─') {
|
|
27
|
+
if (width < 1)
|
|
28
|
+
return '';
|
|
29
|
+
return char.repeat(width);
|
|
30
|
+
}
|
|
31
|
+
export function padRight(text, width) {
|
|
32
|
+
const w = stringWidth(text);
|
|
33
|
+
if (w >= width)
|
|
34
|
+
return text;
|
|
35
|
+
return text + ' '.repeat(width - w);
|
|
36
|
+
}
|
|
37
|
+
/** Char-based width truncation with a trailing ellipsis. The canonical deck
|
|
38
|
+
* renderer variant. */
|
|
39
|
+
export function truncate(text, maxWidth) {
|
|
40
|
+
if (maxWidth < 1)
|
|
41
|
+
return '';
|
|
42
|
+
if (stringWidth(text) <= maxWidth)
|
|
43
|
+
return text;
|
|
44
|
+
const chars = [...text];
|
|
45
|
+
let w = 0;
|
|
46
|
+
let out = '';
|
|
47
|
+
for (const ch of chars) {
|
|
48
|
+
const cw = stringWidth(ch);
|
|
49
|
+
if (w + cw + 1 > maxWidth)
|
|
50
|
+
break;
|
|
51
|
+
out += ch;
|
|
52
|
+
w += cw;
|
|
53
|
+
}
|
|
54
|
+
return out + '…';
|
|
55
|
+
}
|
|
56
|
+
/** Word-aware truncation that also flattens newlines and strips decorative
|
|
57
|
+
* emoji — tuned for dense inbox rows (ported from sisyphus format.ts). Kept
|
|
58
|
+
* distinct from `truncate` because those two surfaces want different behavior. */
|
|
59
|
+
export function truncateRow(text, max) {
|
|
60
|
+
const clean = text.replace(/\n/g, ' ').replace(/✅/g, '✓').replace(/❌/g, '✗').replace(/\p{Emoji_Presentation}/gu, '');
|
|
61
|
+
if (max < 4)
|
|
62
|
+
return clean.slice(0, max);
|
|
63
|
+
const w = stringWidth(clean);
|
|
64
|
+
if (w <= max)
|
|
65
|
+
return clean;
|
|
66
|
+
let result = clean;
|
|
67
|
+
while (stringWidth(result) > max - 1 && result.length > 0) {
|
|
68
|
+
const cut = result.lastIndexOf(' ', result.length - 2);
|
|
69
|
+
if (cut > max * 0.4) {
|
|
70
|
+
result = result.slice(0, cut);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
result = result.slice(0, result.length - 1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result + '…';
|
|
77
|
+
}
|
|
78
|
+
function sliceByWidth(s, maxWidth) {
|
|
79
|
+
let w = 0;
|
|
80
|
+
let out = '';
|
|
81
|
+
for (const ch of s) {
|
|
82
|
+
const cw = stringWidth(ch);
|
|
83
|
+
if (w + cw > maxWidth)
|
|
84
|
+
break;
|
|
85
|
+
out += ch;
|
|
86
|
+
w += cw;
|
|
87
|
+
}
|
|
88
|
+
if (out === '' && s.length > 0)
|
|
89
|
+
out = [...s][0];
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** Word-wrap on whitespace; splits on `\n` into separate paragraphs. */
|
|
93
|
+
export function wrap(text, maxWidth) {
|
|
94
|
+
if (maxWidth < 1)
|
|
95
|
+
return [text];
|
|
96
|
+
const out = [];
|
|
97
|
+
const paragraphs = text.split('\n');
|
|
98
|
+
for (let p = 0; p < paragraphs.length; p++) {
|
|
99
|
+
const para = paragraphs[p];
|
|
100
|
+
if (para === '') {
|
|
101
|
+
out.push('');
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const words = para.split(/[ \t]+/).filter(Boolean);
|
|
105
|
+
let current = '';
|
|
106
|
+
for (let word of words) {
|
|
107
|
+
while (stringWidth(word) > maxWidth) {
|
|
108
|
+
if (current) {
|
|
109
|
+
out.push(current);
|
|
110
|
+
current = '';
|
|
111
|
+
}
|
|
112
|
+
const piece = sliceByWidth(word, maxWidth);
|
|
113
|
+
out.push(piece);
|
|
114
|
+
word = word.slice(piece.length);
|
|
115
|
+
}
|
|
116
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
117
|
+
if (stringWidth(candidate) <= maxWidth) {
|
|
118
|
+
current = candidate;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
if (current)
|
|
122
|
+
out.push(current);
|
|
123
|
+
current = word;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (current)
|
|
127
|
+
out.push(current);
|
|
128
|
+
}
|
|
129
|
+
return out.length > 0 ? out : [''];
|
|
130
|
+
}
|
|
131
|
+
/** Hard character wrap that ignores word boundaries; splits on `\n`. Used for
|
|
132
|
+
* free-form input buffers where every character (including runs without
|
|
133
|
+
* spaces) must stay on screen. */
|
|
134
|
+
export function hardWrap(text, maxWidth) {
|
|
135
|
+
if (maxWidth < 1)
|
|
136
|
+
return [text];
|
|
137
|
+
const segments = text.split('\n');
|
|
138
|
+
const out = [];
|
|
139
|
+
for (const seg of segments) {
|
|
140
|
+
if (seg.length === 0) {
|
|
141
|
+
out.push('');
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
let current = '';
|
|
145
|
+
let currentW = 0;
|
|
146
|
+
for (const ch of [...seg]) {
|
|
147
|
+
const cw = stringWidth(ch);
|
|
148
|
+
if (currentW + cw > maxWidth) {
|
|
149
|
+
out.push(current);
|
|
150
|
+
current = ch;
|
|
151
|
+
currentW = cw;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
current += ch;
|
|
155
|
+
currentW += cw;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
out.push(current);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Pad each non-empty line with leading spaces to horizontally center the
|
|
164
|
+
* `contentWidth`-wide block within `cols`. Wide terminals get breathing room;
|
|
165
|
+
* narrow panes skip centering. Empty lines stay empty so frame diffing keeps
|
|
166
|
+
* them as cheap no-ops.
|
|
167
|
+
*/
|
|
168
|
+
export function centerHorizontal(lines, cols, contentWidth) {
|
|
169
|
+
const extraPad = Math.max(0, Math.floor((cols - contentWidth) / 2));
|
|
170
|
+
if (extraPad === 0)
|
|
171
|
+
return lines;
|
|
172
|
+
const pad = ' '.repeat(extraPad);
|
|
173
|
+
return lines.map((line) => (line === '' ? '' : pad + line));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* ANSI-aware clip: truncate a line's *visible* width to `maxWidth`, passing
|
|
177
|
+
* escape sequences through untouched. Every line written to the terminal must
|
|
178
|
+
* fit within the columns, or it physically wraps onto the next row — which
|
|
179
|
+
* breaks diffFrame's one-logical-line-per-row model and strands spillover text
|
|
180
|
+
* on rows the differ believes are empty (so it never erases them).
|
|
181
|
+
*/
|
|
182
|
+
export function clipLine(line, maxWidth) {
|
|
183
|
+
if (maxWidth < 1)
|
|
184
|
+
return '';
|
|
185
|
+
if (stringWidth(line) <= maxWidth)
|
|
186
|
+
return line; // string-width ignores ANSI
|
|
187
|
+
let out = '';
|
|
188
|
+
let w = 0;
|
|
189
|
+
let i = 0;
|
|
190
|
+
let sawAnsi = false;
|
|
191
|
+
while (i < line.length) {
|
|
192
|
+
if (line[i] === '\x1b') {
|
|
193
|
+
const m = /^\x1b\[[0-9;?]*[a-zA-Z]|^\x1b[@-_]/.exec(line.slice(i));
|
|
194
|
+
if (m !== null) {
|
|
195
|
+
out += m[0];
|
|
196
|
+
i += m[0].length;
|
|
197
|
+
sawAnsi = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const ch = String.fromCodePoint(line.codePointAt(i));
|
|
202
|
+
const cw = stringWidth(ch);
|
|
203
|
+
if (w + cw > maxWidth)
|
|
204
|
+
break;
|
|
205
|
+
out += ch;
|
|
206
|
+
w += cw;
|
|
207
|
+
i += ch.length;
|
|
208
|
+
}
|
|
209
|
+
return sawAnsi ? out + RESET : out;
|
|
210
|
+
}
|
package/dist/tui/app.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync } from 'fs';
|
|
2
|
-
import { dirname, resolve as resolvePath } from 'node:path';
|
|
2
|
+
import { dirname, resolve as resolvePath, join } from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
3
6
|
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize } from './terminal.js';
|
|
4
|
-
import { diffFrame, renderOverview, renderItemReview, renderFinal } from './render.js';
|
|
7
|
+
import { diffFrame, renderOverview, renderItemReview, renderFinal, clampItemReviewScroll } from './render.js';
|
|
5
8
|
import { handleKeypress, assignShortcuts } from './input.js';
|
|
6
9
|
import { readConversation } from '../conversation/reader.js';
|
|
7
10
|
import { defaultGenerateVisual } from '../visuals/generate.js';
|
|
@@ -113,12 +116,14 @@ function fireVisuals(internals, interactions) {
|
|
|
113
116
|
internals.state.visuals.set(interaction.id, r.ok
|
|
114
117
|
? { questionId: interaction.id, content: r.ansi, status: 'ready' }
|
|
115
118
|
: { questionId: interaction.id, content: '', status: 'error' });
|
|
119
|
+
internals.callbacks.onDirty?.();
|
|
116
120
|
}).catch(() => {
|
|
117
121
|
if (!internals.mounted)
|
|
118
122
|
return;
|
|
119
123
|
if (!internals.state.interactions.some((x) => x.id === interaction.id))
|
|
120
124
|
return;
|
|
121
125
|
internals.state.visuals.set(interaction.id, { questionId: interaction.id, content: '', status: 'error' });
|
|
126
|
+
internals.callbacks.onDirty?.();
|
|
122
127
|
});
|
|
123
128
|
}
|
|
124
129
|
}
|
|
@@ -130,7 +135,7 @@ export function mountPanel(opts) {
|
|
|
130
135
|
mounted: true,
|
|
131
136
|
generateVisual: opts.generateVisual,
|
|
132
137
|
progressPath: opts.progressPath,
|
|
133
|
-
callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit },
|
|
138
|
+
callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit, onDirty: opts.onDirty },
|
|
134
139
|
};
|
|
135
140
|
assignShortcuts(internals.state.interactions);
|
|
136
141
|
rebindPersist(internals);
|
|
@@ -168,6 +173,11 @@ export function mountPanel(opts) {
|
|
|
168
173
|
internals.callbacks.onExit?.();
|
|
169
174
|
}
|
|
170
175
|
});
|
|
176
|
+
// Pre-render clamp (input layer): keep scrollOffset within the current
|
|
177
|
+
// body's bounds so u/d stay responsive. The renderer itself is pure.
|
|
178
|
+
if (internals.state.phase === 'item-review') {
|
|
179
|
+
clampItemReviewScroll(internals.state, internals.cols, internals.rows);
|
|
180
|
+
}
|
|
171
181
|
},
|
|
172
182
|
render() {
|
|
173
183
|
if (!internals.mounted)
|
|
@@ -177,6 +187,10 @@ export function mountPanel(opts) {
|
|
|
177
187
|
handleResize(cols, rows) {
|
|
178
188
|
internals.cols = cols;
|
|
179
189
|
internals.rows = rows;
|
|
190
|
+
// New dimensions change the scroll bounds — re-clamp before laying out.
|
|
191
|
+
if (internals.state.phase === 'item-review') {
|
|
192
|
+
clampItemReviewScroll(internals.state, cols, rows);
|
|
193
|
+
}
|
|
180
194
|
return renderLines();
|
|
181
195
|
},
|
|
182
196
|
unmount() {
|
|
@@ -208,6 +222,17 @@ export function mountPanel(opts) {
|
|
|
208
222
|
return true;
|
|
209
223
|
return internals.state.phase === 'overview' && internals.state.inputMode === null;
|
|
210
224
|
},
|
|
225
|
+
getInputBuffer() {
|
|
226
|
+
if (!internals.mounted || internals.state.inputMode === null)
|
|
227
|
+
return undefined;
|
|
228
|
+
return internals.state.inputMode.buffer;
|
|
229
|
+
},
|
|
230
|
+
setInputBuffer(text) {
|
|
231
|
+
if (!internals.mounted || internals.state.inputMode === null)
|
|
232
|
+
return;
|
|
233
|
+
internals.state.inputMode.buffer = text;
|
|
234
|
+
internals.state.inputMode.cursor = [...text].length;
|
|
235
|
+
},
|
|
211
236
|
};
|
|
212
237
|
}
|
|
213
238
|
/**
|
|
@@ -285,7 +310,7 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
285
310
|
panel?.unmount();
|
|
286
311
|
const completedAt = new Date().toISOString();
|
|
287
312
|
// Resolved supersedes in-progress: write response.json, drop progress.json.
|
|
288
|
-
const rp = writeResponse(dir, responses, completedAt);
|
|
313
|
+
const rp = writeResponse(dir, responses, completedAt, currentDeck);
|
|
289
314
|
clearProgress(dir);
|
|
290
315
|
resolve({ responses, completedAt, responsePath: rp, deck: currentDeck });
|
|
291
316
|
};
|
|
@@ -304,6 +329,12 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
304
329
|
onExit: () => {
|
|
305
330
|
finalize(lastResponses);
|
|
306
331
|
},
|
|
332
|
+
// Async visual finished loading between keystrokes — repaint so the
|
|
333
|
+
// "loading context..." placeholder is replaced immediately.
|
|
334
|
+
onDirty: () => {
|
|
335
|
+
if (panel !== null)
|
|
336
|
+
flushHost(panel.render());
|
|
337
|
+
},
|
|
307
338
|
});
|
|
308
339
|
flushHost(panel.render());
|
|
309
340
|
// ── Live deck reload ──────────────────────────────────────────────────
|
|
@@ -349,8 +380,85 @@ export async function resolveInteractionDir(dir, deck, opts = {}) {
|
|
|
349
380
|
panel.loadDeck(nextDeck, { progressPath: progressPathFor(dir) });
|
|
350
381
|
flushHost(panel.render());
|
|
351
382
|
}, 500);
|
|
383
|
+
// $EDITOR escape hatch (ctrl+o): the host owns the stdin listener + raw
|
|
384
|
+
// mode, so it — not handleInputMode — is where the editor round-trip has
|
|
385
|
+
// to live. Detected here before delegating to the panel; only acts while
|
|
386
|
+
// an input-mode buffer exists (panel.getInputBuffer() undefined otherwise).
|
|
387
|
+
const runEditorEscapeHatch = (buffer) => {
|
|
388
|
+
if (panel === null)
|
|
389
|
+
return;
|
|
390
|
+
process.stdin.removeListener('data', onData);
|
|
391
|
+
process.stdout.removeListener('resize', onResize);
|
|
392
|
+
const tmpFile = join(tmpdir(), `hl-input-${randomUUID()}.txt`);
|
|
393
|
+
const editor = process.env.EDITOR || 'vi';
|
|
394
|
+
let next = buffer;
|
|
395
|
+
let errorMessage = null;
|
|
396
|
+
// Everything from suspending the TUI through the editor round-trip can
|
|
397
|
+
// throw (tmpfile write, spawn, readback) — a tmpdir write failure alone
|
|
398
|
+
// (e.g. /tmp full) used to escape with the listeners detached and the
|
|
399
|
+
// real terminal never restored. The try/finally below guarantees the
|
|
400
|
+
// finally branch — tmpfile cleanup, TUI restore, listener re-attach,
|
|
401
|
+
// resize-aware redraw — runs on every path, success or failure.
|
|
402
|
+
try {
|
|
403
|
+
restoreTerminal();
|
|
404
|
+
writeFileSync(tmpFile, buffer);
|
|
405
|
+
const result = spawnSync(editor, [tmpFile], { stdio: 'inherit' });
|
|
406
|
+
if (result.error) {
|
|
407
|
+
errorMessage = `$EDITOR ("${editor}") failed to launch: ${result.error.message}`;
|
|
408
|
+
}
|
|
409
|
+
else if (result.signal !== null) {
|
|
410
|
+
errorMessage = `$EDITOR ("${editor}") was killed by signal ${result.signal}`;
|
|
411
|
+
}
|
|
412
|
+
else if (result.status !== 0) {
|
|
413
|
+
errorMessage = `$EDITOR ("${editor}") exited with status ${result.status}`;
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
let read = readFileSync(tmpFile, 'utf8');
|
|
417
|
+
// Editors conventionally append a trailing newline on save; strip
|
|
418
|
+
// exactly one so a round-trip that added nothing doesn't grow the
|
|
419
|
+
// buffer by a blank line.
|
|
420
|
+
if (read.endsWith('\n') && !buffer.endsWith('\n'))
|
|
421
|
+
read = read.slice(0, -1);
|
|
422
|
+
next = read;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
errorMessage = `$EDITOR round-trip failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
try {
|
|
430
|
+
unlinkSync(tmpFile);
|
|
431
|
+
}
|
|
432
|
+
catch { /* best-effort cleanup — may never have been created */ }
|
|
433
|
+
setupTerminal();
|
|
434
|
+
process.stdin.on('data', onData);
|
|
435
|
+
process.stdout.on('resize', onResize);
|
|
436
|
+
// On any failure the original buffer is kept untouched. Re-sample the
|
|
437
|
+
// terminal size (it may have changed while the editor had it) so the
|
|
438
|
+
// post-editor redraw lays out for the CURRENT dimensions rather than
|
|
439
|
+
// the panel's stale cols/rows, same as the live resize listener does.
|
|
440
|
+
panel.setInputBuffer(errorMessage === null ? next : buffer);
|
|
441
|
+
const { cols: c, rows: r } = getTerminalSize();
|
|
442
|
+
const lines = panel.handleResize(c, r);
|
|
443
|
+
if (errorMessage !== null) {
|
|
444
|
+
while (lines.length < r)
|
|
445
|
+
lines.push('');
|
|
446
|
+
lines[r - 1] = ` ${errorMessage}`.slice(0, c);
|
|
447
|
+
}
|
|
448
|
+
prevFrameLocal = [];
|
|
449
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
450
|
+
flushHost(lines);
|
|
451
|
+
}
|
|
452
|
+
};
|
|
352
453
|
onData = (data) => {
|
|
353
454
|
const { input: inp, key } = parseKeypress(data);
|
|
455
|
+
if (key.ctrl && inp === 'o') {
|
|
456
|
+
const buf = panel.getInputBuffer();
|
|
457
|
+
if (buf !== undefined) {
|
|
458
|
+
runEditorEscapeHatch(buf);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
354
462
|
panel.handleKey(inp, key);
|
|
355
463
|
flushHost(panel.render());
|
|
356
464
|
};
|