@crouton-kit/humanloop 0.1.4 → 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/api.d.ts +35 -0
- package/dist/api.js +119 -0
- package/dist/cli.js +348 -97
- package/dist/editor/review.d.ts +24 -0
- package/dist/editor/review.js +425 -0
- package/dist/inbox/convention.d.ts +17 -0
- package/dist/inbox/convention.js +87 -0
- package/dist/inbox/deck-schema.d.ts +41 -0
- package/dist/inbox/deck-schema.js +109 -0
- package/dist/inbox/scan.d.ts +2 -0
- package/dist/inbox/scan.js +62 -0
- package/dist/inbox/tui.d.ts +9 -0
- package/dist/inbox/tui.js +158 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +13 -0
- package/dist/render/termrender.d.ts +36 -0
- package/dist/render/termrender.js +236 -0
- package/dist/render/version.d.ts +1 -0
- package/dist/render/version.js +1 -0
- package/dist/scripts/install-renderer.d.ts +2 -0
- package/dist/scripts/install-renderer.js +16 -0
- package/dist/surfaces/display.d.ts +5 -0
- package/dist/surfaces/display.js +19 -0
- package/dist/tui/app.d.ts +24 -1
- package/dist/tui/app.js +48 -113
- package/dist/tui/render.js +2 -42
- package/dist/tui/tmux.d.ts +4 -6
- package/dist/tui/tmux.js +6 -4
- package/dist/types.d.ts +65 -0
- package/dist/visuals/generate.js +2 -27
- package/package.json +4 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, basename } from 'path';
|
|
3
|
+
import { deckPath, isResolved, isClaimed, readJson } from './convention.js';
|
|
4
|
+
// ── scanInbox ─────────────────────────────────────────────────────────────────
|
|
5
|
+
export function scanInbox(roots) {
|
|
6
|
+
const items = [];
|
|
7
|
+
for (const root of roots) {
|
|
8
|
+
let entries;
|
|
9
|
+
try {
|
|
10
|
+
entries = readdirSync(root);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// root doesn't exist or isn't readable — skip silently
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const dir = resolve(root, entry);
|
|
18
|
+
try {
|
|
19
|
+
const stat = statSync(dir);
|
|
20
|
+
if (!stat.isDirectory())
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// Skip resolved or actively claimed dirs
|
|
27
|
+
if (isResolved(dir) || isClaimed(dir))
|
|
28
|
+
continue;
|
|
29
|
+
const dp = deckPath(dir);
|
|
30
|
+
const deck = readJson(dp);
|
|
31
|
+
if (deck === null)
|
|
32
|
+
continue;
|
|
33
|
+
// Derive blockedSince: prefer deck.source.blockedSince, fall back to mtime
|
|
34
|
+
let blockedSince;
|
|
35
|
+
if (deck.source?.blockedSince !== undefined) {
|
|
36
|
+
blockedSince = deck.source.blockedSince;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
try {
|
|
40
|
+
blockedSince = new Date(statSync(dp).mtime).toISOString();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
blockedSince = new Date().toISOString();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const firstInteraction = deck.interactions[0];
|
|
47
|
+
const item = {
|
|
48
|
+
dir,
|
|
49
|
+
id: firstInteraction?.id ?? basename(dir),
|
|
50
|
+
title: deck.title ?? firstInteraction?.title,
|
|
51
|
+
subtitle: firstInteraction?.subtitle,
|
|
52
|
+
kind: firstInteraction?.kind,
|
|
53
|
+
source: deck.source,
|
|
54
|
+
blockedSince,
|
|
55
|
+
};
|
|
56
|
+
items.push(item);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Sort ascending by blockedSince (ISO string compare is monotonic)
|
|
60
|
+
items.sort((a, b) => (a.blockedSince < b.blockedSince ? -1 : a.blockedSince > b.blockedSince ? 1 : 0));
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { InboxItem } from '../types.js';
|
|
2
|
+
export declare const KIND_ICON: Record<string, string>;
|
|
3
|
+
export declare const KIND_COLOR: Record<string, string>;
|
|
4
|
+
export declare function formatTimeAgo(iso: string): string;
|
|
5
|
+
export declare function buildInboxLines(items: InboxItem[], width: number, selectedIndex: number): string[];
|
|
6
|
+
export declare function pickFromInbox(items: InboxItem[], opts: {
|
|
7
|
+
cols: number;
|
|
8
|
+
rows: number;
|
|
9
|
+
}): Promise<InboxItem | null>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
import { setupTerminal, restoreTerminal, parseKeypress, getTerminalSize, } from '../tui/terminal.js';
|
|
3
|
+
import { diffFrame } from '../tui/render.js';
|
|
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
|
+
function ansiColor(text, color) {
|
|
15
|
+
switch (color) {
|
|
16
|
+
case 'gray': return `${GRAY}${text}${RESET}`;
|
|
17
|
+
case 'cyan': return `${CYAN}${text}${RESET}`;
|
|
18
|
+
case 'red': return `${RED}${text}${RESET}`;
|
|
19
|
+
case 'yellow': return `${YELLOW}${text}${RESET}`;
|
|
20
|
+
default: return text;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
// ── Row model (ported verbatim from sisyphus cross-session-inbox.ts:8-21) ────
|
|
24
|
+
export const KIND_ICON = {
|
|
25
|
+
notify: '✉',
|
|
26
|
+
validation: '✓',
|
|
27
|
+
decision: '◆',
|
|
28
|
+
context: '✎',
|
|
29
|
+
error: '⚠',
|
|
30
|
+
};
|
|
31
|
+
export const KIND_COLOR = {
|
|
32
|
+
notify: 'gray',
|
|
33
|
+
validation: 'cyan',
|
|
34
|
+
decision: 'cyan',
|
|
35
|
+
context: 'cyan',
|
|
36
|
+
error: 'red',
|
|
37
|
+
};
|
|
38
|
+
// ── formatTimeAgo (ported from sisyphus src/tui/lib/format.ts:5-12) ──────────
|
|
39
|
+
export function formatTimeAgo(iso) {
|
|
40
|
+
const diff = Date.now() - new Date(iso).getTime();
|
|
41
|
+
const minutes = Math.floor(diff / 60000);
|
|
42
|
+
const hours = Math.floor(minutes / 60);
|
|
43
|
+
if (hours > 0)
|
|
44
|
+
return `${hours}h ago`;
|
|
45
|
+
if (minutes > 0)
|
|
46
|
+
return `${minutes}m ago`;
|
|
47
|
+
return 'just now';
|
|
48
|
+
}
|
|
49
|
+
// ── truncate (ported from sisyphus src/tui/lib/format.ts:19-37) ──────────────
|
|
50
|
+
function truncate(text, max) {
|
|
51
|
+
const clean = text.replace(/\n/g, ' ').replace(/✅/g, '✓').replace(/❌/g, '✗').replace(/\p{Emoji_Presentation}/gu, '');
|
|
52
|
+
if (max < 4)
|
|
53
|
+
return clean.slice(0, max);
|
|
54
|
+
const w = stringWidth(clean);
|
|
55
|
+
if (w <= max)
|
|
56
|
+
return clean;
|
|
57
|
+
let result = clean;
|
|
58
|
+
while (stringWidth(result) > max - 1 && result.length > 0) {
|
|
59
|
+
const cut = result.lastIndexOf(' ', result.length - 2);
|
|
60
|
+
if (cut > max * 0.4) {
|
|
61
|
+
result = result.slice(0, cut);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
result = result.slice(0, result.length - 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result + '…';
|
|
68
|
+
}
|
|
69
|
+
// ── buildInboxLines ───────────────────────────────────────────────────────────
|
|
70
|
+
export function buildInboxLines(items, width, selectedIndex) {
|
|
71
|
+
const lines = [];
|
|
72
|
+
if (items.length === 0) {
|
|
73
|
+
lines.push(` ${DIM}${ITALIC}No pending interactions${RESET}`);
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
lines.push(` ${BOLD}${items.length} pending${RESET}`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
const contentWidth = width - 4;
|
|
79
|
+
for (let i = 0; i < items.length; i++) {
|
|
80
|
+
const item = items[i];
|
|
81
|
+
const kindKey = item.kind ?? '';
|
|
82
|
+
const icon = kindKey in KIND_ICON ? KIND_ICON[kindKey] : '·';
|
|
83
|
+
const iconColor = kindKey in KIND_COLOR ? KIND_COLOR[kindKey] : 'cyan';
|
|
84
|
+
const sourceLabel = item.source?.sessionName ?? item.source?.askedBy ?? '';
|
|
85
|
+
const titleText = item.title ?? `(${item.id.slice(0, 8)})`;
|
|
86
|
+
const blocked = formatTimeAgo(item.blockedSince);
|
|
87
|
+
const cursor = i === selectedIndex ? `${CYAN}▸${RESET} ` : ' ';
|
|
88
|
+
const maxTitle = Math.max(10, contentWidth - sourceLabel.length - blocked.length - 8);
|
|
89
|
+
let row = cursor;
|
|
90
|
+
row += ansiColor(icon, iconColor);
|
|
91
|
+
if (sourceLabel) {
|
|
92
|
+
row += ` ${ansiColor(sourceLabel, 'yellow')}`;
|
|
93
|
+
row += ` ${DIM}·${RESET} `;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
row += ' ';
|
|
97
|
+
}
|
|
98
|
+
row += `${BOLD}${truncate(titleText, maxTitle)}${RESET}`;
|
|
99
|
+
row += ` ${DIM}${blocked}${RESET}`;
|
|
100
|
+
lines.push(row);
|
|
101
|
+
if (item.subtitle) {
|
|
102
|
+
lines.push(` ${DIM}${truncate(item.subtitle, contentWidth - 6)}${RESET}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
// ── pickFromInbox ─────────────────────────────────────────────────────────────
|
|
108
|
+
export function pickFromInbox(items, opts) {
|
|
109
|
+
if (items.length === 0)
|
|
110
|
+
return Promise.resolve(null);
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let selectedIndex = 0;
|
|
113
|
+
let prevFrame = [];
|
|
114
|
+
let onData;
|
|
115
|
+
const flush = () => {
|
|
116
|
+
const { cols: currentCols, rows: currentRows } = getTerminalSize();
|
|
117
|
+
const lines = buildInboxLines(items, currentCols, selectedIndex);
|
|
118
|
+
const { writes, nextPrevFrame } = diffFrame(prevFrame, lines, currentRows);
|
|
119
|
+
process.stdout.write('\x1b[?2026h');
|
|
120
|
+
for (const w of writes)
|
|
121
|
+
process.stdout.write(w);
|
|
122
|
+
process.stdout.write('\x1b[?2026l');
|
|
123
|
+
prevFrame = nextPrevFrame;
|
|
124
|
+
};
|
|
125
|
+
const done = (result) => {
|
|
126
|
+
restoreTerminal();
|
|
127
|
+
process.stdin.removeListener('data', onData);
|
|
128
|
+
process.stdout.removeListener('resize', flush);
|
|
129
|
+
resolve(result);
|
|
130
|
+
};
|
|
131
|
+
setupTerminal();
|
|
132
|
+
flush();
|
|
133
|
+
onData = (data) => {
|
|
134
|
+
const { input, key } = parseKeypress(data);
|
|
135
|
+
if (key.downArrow || input === 'j') {
|
|
136
|
+
selectedIndex = Math.min(selectedIndex + 1, items.length - 1);
|
|
137
|
+
flush();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (key.upArrow || input === 'k') {
|
|
141
|
+
selectedIndex = Math.max(selectedIndex - 1, 0);
|
|
142
|
+
flush();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (key.return) {
|
|
146
|
+
const selected = items[selectedIndex];
|
|
147
|
+
done(selected ?? null);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (key.escape || (key.ctrl && input === 'c') || input === 'q') {
|
|
151
|
+
done(null);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
process.stdin.on('data', onData);
|
|
156
|
+
process.stdout.on('resize', flush);
|
|
157
|
+
});
|
|
158
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
export { mountPanel, validateInput, launchTui } from './tui/app.js';
|
|
2
2
|
export { defaultGenerateVisual } from './visuals/generate.js';
|
|
3
|
-
export
|
|
3
|
+
export { launchReview } from './editor/review.js';
|
|
4
|
+
export { launchReview as review } from './editor/review.js';
|
|
5
|
+
export type { ReviewOptions } from './editor/review.js';
|
|
6
|
+
export { ask, approve, notify, inbox } from './api.js';
|
|
7
|
+
export { display } from './surfaces/display.js';
|
|
8
|
+
export { scanInbox } from './inbox/scan.js';
|
|
9
|
+
export { renderMarkdown, checkMarkdown, ensureRenderer, isRendererReady, } from './render/termrender.js';
|
|
10
|
+
export { parseDeck, validateDeck, deckSchema } from './inbox/deck-schema.js';
|
|
11
|
+
export { deckPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
|
|
12
|
+
export type { InteractionState } from './inbox/convention.js';
|
|
13
|
+
export type { Interaction, InteractionOption, InteractionResponse, InteractionKind, Deck, DeckSource, MountedPanel, MountedPanelOpts, GenerateVisual, VisualBlock, FeedbackComment, FeedbackResult, ResolutionEnvelope, InboxItem, DisplayOpts, } from './types.js';
|
|
4
14
|
export type { Key } from './tui/terminal.js';
|
|
5
15
|
export type { ConversationMessage } from './conversation/reader.js';
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,15 @@
|
|
|
1
1
|
export { mountPanel, validateInput, launchTui } from './tui/app.js';
|
|
2
2
|
export { defaultGenerateVisual } from './visuals/generate.js';
|
|
3
|
+
export { launchReview } from './editor/review.js';
|
|
4
|
+
export { launchReview as review } from './editor/review.js';
|
|
5
|
+
// Interaction-layer surface (SDK).
|
|
6
|
+
export { ask, approve, notify, inbox } from './api.js';
|
|
7
|
+
export { display } from './surfaces/display.js';
|
|
8
|
+
export { scanInbox } from './inbox/scan.js';
|
|
9
|
+
// Renderer binding — the sole org-wide termrender caller. Consumers
|
|
10
|
+
// (sisyphus md-render / ask-schema) route markdown through these.
|
|
11
|
+
export { renderMarkdown, checkMarkdown, ensureRenderer, isRendererReady, } from './render/termrender.js';
|
|
12
|
+
// Canonical deck schema + parsing/validation (consumers stop forking it).
|
|
13
|
+
export { parseDeck, validateDeck, deckSchema } from './inbox/deck-schema.js';
|
|
14
|
+
// Interaction-directory convention helpers (§B) — names humanloop owns.
|
|
15
|
+
export { deckPath, responsePath, progressPath, visualsDir, interactionState, isResolved, isClaimed, atomicWriteJson, readJson, writeResponse, writeProgress, clearProgress, } from './inbox/convention.js';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memoized, self-healing. Ensures the pinned termrender binary exists inside
|
|
3
|
+
* the humanloop-managed venv; (re)provisions it via `uv` when missing or the
|
|
4
|
+
* version drifts from the pin. Runs at most once per process. Single
|
|
5
|
+
* degradation path: `uv` absent → one stderr remediation line + plaintext
|
|
6
|
+
* fallback. win32 → plaintext (no renderer).
|
|
7
|
+
*
|
|
8
|
+
* Invoked at postinstall AND lazily on the first render/check/display call,
|
|
9
|
+
* so `npm ci --ignore-scripts` consumers still self-heal on first use.
|
|
10
|
+
*/
|
|
11
|
+
export declare function ensureRenderer(): void;
|
|
12
|
+
/** Cheap predicate — true when the pinned managed binary is present and correct. Does not install. */
|
|
13
|
+
export declare function isRendererReady(): boolean;
|
|
14
|
+
/** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
|
|
15
|
+
export declare function renderMarkdown(md: string, width: number): string[];
|
|
16
|
+
/** Validate markdown via `termrender --check` (exit 0 ok / non-zero error). */
|
|
17
|
+
export declare function checkMarkdown(md: string): {
|
|
18
|
+
ok: true;
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
error: string;
|
|
22
|
+
};
|
|
23
|
+
export interface DisplayInPaneOpts {
|
|
24
|
+
/** Pass `--watch` so the pane live-updates on file edits. Default true. */
|
|
25
|
+
watch?: boolean;
|
|
26
|
+
/** Open in a new tmux window instead of splitting the current one. */
|
|
27
|
+
newWindow?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Spawn termrender into a live tmux pane. The pane-budget policy (whether to
|
|
31
|
+
* split vs open a new window) is decided by the caller (`src/surfaces/
|
|
32
|
+
* display.ts`); this is the thin managed-binary spawn it delegates to.
|
|
33
|
+
*/
|
|
34
|
+
export declare function displayInPane(path: string, opts?: DisplayInPaneOpts): {
|
|
35
|
+
paneId?: string;
|
|
36
|
+
};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import stringWidth from 'string-width';
|
|
6
|
+
import { TERMRENDER_VERSION } from './version.js';
|
|
7
|
+
// ── The sole org-wide termrender binding ─────────────────────────────────────
|
|
8
|
+
//
|
|
9
|
+
// termrender is a humanloop-managed dependency: a pure-Python tool pinned to
|
|
10
|
+
// TERMRENDER_VERSION, installed into a venv humanloop owns. The binary is
|
|
11
|
+
// resolved by ABSOLUTE PATH inside that venv — never `$PATH` — so a user's
|
|
12
|
+
// own `pip install termrender` can never shadow or break the pin.
|
|
13
|
+
function findPkgRoot() {
|
|
14
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
for (let i = 0; i < 12; i++) {
|
|
16
|
+
if (existsSync(join(dir, 'package.json')))
|
|
17
|
+
return dir;
|
|
18
|
+
const parent = dirname(dir);
|
|
19
|
+
if (parent === dir)
|
|
20
|
+
break;
|
|
21
|
+
dir = parent;
|
|
22
|
+
}
|
|
23
|
+
// dist/render/termrender.js or src/render/termrender.ts → two up is pkgRoot.
|
|
24
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
25
|
+
}
|
|
26
|
+
const PKG_ROOT = findPkgRoot();
|
|
27
|
+
const VENV_DIR = resolve(PKG_ROOT, '.venv');
|
|
28
|
+
const VENV_BIN = resolve(PKG_ROOT, '.venv/bin/termrender');
|
|
29
|
+
const VENV_PYTHON = resolve(PKG_ROOT, '.venv/bin/python');
|
|
30
|
+
let rendererState = 'unchecked';
|
|
31
|
+
function binaryOk() {
|
|
32
|
+
if (!existsSync(VENV_BIN))
|
|
33
|
+
return false;
|
|
34
|
+
try {
|
|
35
|
+
const out = execFileSync(VENV_BIN, ['--version'], {
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
38
|
+
timeout: 5000,
|
|
39
|
+
});
|
|
40
|
+
const m = out.match(/\d+\.\d+\.\d+/);
|
|
41
|
+
return m?.[0] === TERMRENDER_VERSION;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function uvAvailable() {
|
|
48
|
+
try {
|
|
49
|
+
execFileSync('uv', ['--version'], { stdio: 'pipe', timeout: 5000 });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Memoized, self-healing. Ensures the pinned termrender binary exists inside
|
|
58
|
+
* the humanloop-managed venv; (re)provisions it via `uv` when missing or the
|
|
59
|
+
* version drifts from the pin. Runs at most once per process. Single
|
|
60
|
+
* degradation path: `uv` absent → one stderr remediation line + plaintext
|
|
61
|
+
* fallback. win32 → plaintext (no renderer).
|
|
62
|
+
*
|
|
63
|
+
* Invoked at postinstall AND lazily on the first render/check/display call,
|
|
64
|
+
* so `npm ci --ignore-scripts` consumers still self-heal on first use.
|
|
65
|
+
*/
|
|
66
|
+
export function ensureRenderer() {
|
|
67
|
+
if (rendererState !== 'unchecked')
|
|
68
|
+
return;
|
|
69
|
+
if (process.platform === 'win32') {
|
|
70
|
+
rendererState = 'unavailable';
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (binaryOk()) {
|
|
74
|
+
rendererState = 'ready';
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!uvAvailable()) {
|
|
78
|
+
process.stderr.write('[hl] termrender unavailable — install uv to enable rich rendering:\n' +
|
|
79
|
+
' curl -LsSf https://astral.sh/uv/install.sh | sh\n');
|
|
80
|
+
rendererState = 'unavailable';
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
execFileSync('uv', ['venv', VENV_DIR], { stdio: 'pipe', timeout: 60000 });
|
|
85
|
+
execFileSync('uv', ['pip', 'install', '--python', VENV_PYTHON, `termrender==${TERMRENDER_VERSION}`], { stdio: 'pipe', timeout: 120000 });
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
process.stderr.write(`[hl] termrender install failed (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
89
|
+
rendererState = 'unavailable';
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
rendererState = binaryOk() ? 'ready' : 'unavailable';
|
|
93
|
+
if (rendererState === 'unavailable') {
|
|
94
|
+
process.stderr.write('[hl] termrender install completed but version check failed; using plaintext fallback\n');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Cheap predicate — true when the pinned managed binary is present and correct. Does not install. */
|
|
98
|
+
export function isRendererReady() {
|
|
99
|
+
if (rendererState === 'ready')
|
|
100
|
+
return true;
|
|
101
|
+
if (rendererState === 'unavailable')
|
|
102
|
+
return false;
|
|
103
|
+
return process.platform !== 'win32' && binaryOk();
|
|
104
|
+
}
|
|
105
|
+
// ── Plaintext fallback helpers (kept here so this is the only termrender site) ─
|
|
106
|
+
const CONTROL_CHARS_RE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b[@-_]|[\x00-\x08\x0B\x0E-\x1F\x7F-\x9F]/g;
|
|
107
|
+
function sanitize(text) {
|
|
108
|
+
if (typeof text !== 'string')
|
|
109
|
+
return '';
|
|
110
|
+
return text.replace(CONTROL_CHARS_RE, '');
|
|
111
|
+
}
|
|
112
|
+
function sliceByWidth(s, maxWidth) {
|
|
113
|
+
let w = 0;
|
|
114
|
+
let out = '';
|
|
115
|
+
for (const ch of s) {
|
|
116
|
+
const cw = stringWidth(ch);
|
|
117
|
+
if (w + cw > maxWidth)
|
|
118
|
+
break;
|
|
119
|
+
out += ch;
|
|
120
|
+
w += cw;
|
|
121
|
+
}
|
|
122
|
+
if (out === '' && s.length > 0)
|
|
123
|
+
out = [...s][0];
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
function wrap(text, maxWidth) {
|
|
127
|
+
if (maxWidth < 1)
|
|
128
|
+
return [text];
|
|
129
|
+
const out = [];
|
|
130
|
+
const paragraphs = text.split('\n');
|
|
131
|
+
for (let p = 0; p < paragraphs.length; p++) {
|
|
132
|
+
const para = paragraphs[p];
|
|
133
|
+
if (para === '') {
|
|
134
|
+
out.push('');
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const words = para.split(/[ \t]+/).filter(Boolean);
|
|
138
|
+
let current = '';
|
|
139
|
+
for (let word of words) {
|
|
140
|
+
while (stringWidth(word) > maxWidth) {
|
|
141
|
+
if (current) {
|
|
142
|
+
out.push(current);
|
|
143
|
+
current = '';
|
|
144
|
+
}
|
|
145
|
+
const piece = sliceByWidth(word, maxWidth);
|
|
146
|
+
out.push(piece);
|
|
147
|
+
word = word.slice(piece.length);
|
|
148
|
+
}
|
|
149
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
150
|
+
if (stringWidth(candidate) <= maxWidth) {
|
|
151
|
+
current = candidate;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
if (current)
|
|
155
|
+
out.push(current);
|
|
156
|
+
current = word;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (current)
|
|
160
|
+
out.push(current);
|
|
161
|
+
}
|
|
162
|
+
return out.length > 0 ? out : [''];
|
|
163
|
+
}
|
|
164
|
+
// ── Render surface ───────────────────────────────────────────────────────────
|
|
165
|
+
const _bodyCache = new Map();
|
|
166
|
+
/** Render markdown to terminal lines via the pinned binary; plaintext fallback. */
|
|
167
|
+
export function renderMarkdown(md, width) {
|
|
168
|
+
const key = `${md}\0${width}`;
|
|
169
|
+
const cached = _bodyCache.get(key);
|
|
170
|
+
if (cached)
|
|
171
|
+
return cached;
|
|
172
|
+
ensureRenderer();
|
|
173
|
+
if (rendererState === 'ready') {
|
|
174
|
+
try {
|
|
175
|
+
const out = execFileSync(VENV_BIN, ['--width', String(width)], {
|
|
176
|
+
input: md,
|
|
177
|
+
encoding: 'utf-8',
|
|
178
|
+
timeout: 5000,
|
|
179
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
180
|
+
env: { ...process.env, TERMRENDER_COLOR: '1' },
|
|
181
|
+
});
|
|
182
|
+
const lines = out.split('\n');
|
|
183
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
184
|
+
lines.pop();
|
|
185
|
+
_bodyCache.set(key, lines);
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
/* fall through to plaintext */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const fallback = wrap(sanitize(md), width);
|
|
193
|
+
_bodyCache.set(key, fallback);
|
|
194
|
+
return fallback;
|
|
195
|
+
}
|
|
196
|
+
/** Validate markdown via `termrender --check` (exit 0 ok / non-zero error). */
|
|
197
|
+
export function checkMarkdown(md) {
|
|
198
|
+
ensureRenderer();
|
|
199
|
+
// Renderer unavailable → don't block validation; the body just renders as
|
|
200
|
+
// plaintext later. Bricking deck validation here would be the wrong default.
|
|
201
|
+
if (rendererState !== 'ready')
|
|
202
|
+
return { ok: true };
|
|
203
|
+
const result = spawnSync(VENV_BIN, ['--check'], {
|
|
204
|
+
input: md,
|
|
205
|
+
encoding: 'utf-8',
|
|
206
|
+
timeout: 5000,
|
|
207
|
+
});
|
|
208
|
+
if (result.error) {
|
|
209
|
+
return { ok: false, error: `termrender invocation failed: ${result.error.message}` };
|
|
210
|
+
}
|
|
211
|
+
if (result.status !== 0) {
|
|
212
|
+
return { ok: false, error: `termrender --check exited ${result.status}: ${(result.stderr || '').toString().trim()}` };
|
|
213
|
+
}
|
|
214
|
+
return { ok: true };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Spawn termrender into a live tmux pane. The pane-budget policy (whether to
|
|
218
|
+
* split vs open a new window) is decided by the caller (`src/surfaces/
|
|
219
|
+
* display.ts`); this is the thin managed-binary spawn it delegates to.
|
|
220
|
+
*/
|
|
221
|
+
export function displayInPane(path, opts = {}) {
|
|
222
|
+
ensureRenderer();
|
|
223
|
+
if (rendererState !== 'ready')
|
|
224
|
+
return {};
|
|
225
|
+
const args = ['--tmux'];
|
|
226
|
+
if (opts.watch !== false)
|
|
227
|
+
args.push('--watch');
|
|
228
|
+
if (opts.newWindow)
|
|
229
|
+
args.push('--tmux-new-window');
|
|
230
|
+
args.push(path);
|
|
231
|
+
const result = spawnSync(VENV_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
232
|
+
if (result.error || result.status !== 0)
|
|
233
|
+
return {};
|
|
234
|
+
const paneId = (result.stdout?.toString() || '').trim();
|
|
235
|
+
return paneId ? { paneId } : {};
|
|
236
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const TERMRENDER_VERSION = "1.0.0";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const TERMRENDER_VERSION = '1.0.0';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureRenderer, isRendererReady } from '../render/termrender.js';
|
|
3
|
+
// postinstall hook. Best-effort: provision the pinned termrender venv now so
|
|
4
|
+
// the first render is fast. NEVER fail — a renderer hiccup must not brick a
|
|
5
|
+
// consumer's `npm install`. The lazy ensureRenderer() on first render covers
|
|
6
|
+
// the `npm ci --ignore-scripts` case.
|
|
7
|
+
try {
|
|
8
|
+
ensureRenderer();
|
|
9
|
+
if (!isRendererReady()) {
|
|
10
|
+
process.stderr.write('[hl] termrender not provisioned at install time; will retry lazily on first render.\n');
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
process.stderr.write(`[hl] termrender postinstall skipped: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
15
|
+
}
|
|
16
|
+
process.exit(0);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { displayInPane } from '../render/termrender.js';
|
|
3
|
+
export function countPanesInCurrentWindow() {
|
|
4
|
+
// -t '' targets the current window of the current session.
|
|
5
|
+
const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
|
|
6
|
+
encoding: 'utf8',
|
|
7
|
+
});
|
|
8
|
+
if (result.status !== 0)
|
|
9
|
+
return 0;
|
|
10
|
+
return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
|
|
11
|
+
}
|
|
12
|
+
export function display(path, opts) {
|
|
13
|
+
const watch = opts?.watch !== false;
|
|
14
|
+
const window = (opts?.window === 'split' || opts?.window === 'new') ? opts.window : 'auto';
|
|
15
|
+
const maxPanes = (opts?.maxPanes !== undefined && opts.maxPanes > 0) ? opts.maxPanes : 3;
|
|
16
|
+
const newWindow = window === 'new' ||
|
|
17
|
+
(window === 'auto' && countPanesInCurrentWindow() >= maxPanes);
|
|
18
|
+
return displayInPane(path, { watch, newWindow });
|
|
19
|
+
}
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
|
-
import type { Deck, InteractionResponse, MountedPanel, MountedPanelOpts } from '../types.js';
|
|
1
|
+
import type { Deck, InteractionResponse, MountedPanel, MountedPanelOpts, GenerateVisual } from '../types.js';
|
|
2
|
+
/** Validate an arbitrary parsed value as a Deck. Delegates to the canonical
|
|
3
|
+
* Zod validator in `inbox/deck-schema.ts` (the single source of truth shared
|
|
4
|
+
* with sisyphus). Kept exported for back-compat. */
|
|
2
5
|
export declare function validateInput(parsed: unknown): Deck;
|
|
3
6
|
export declare function mountPanel(opts: MountedPanelOpts): MountedPanel;
|
|
7
|
+
export interface ResolveDirOpts {
|
|
8
|
+
/** Claude session id → per-interaction visual context from history. */
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
/** Explicit visual generator; overrides the sessionId default. */
|
|
11
|
+
generateVisual?: GenerateVisual;
|
|
12
|
+
cols?: number;
|
|
13
|
+
rows?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve an interaction directory in place: mount the panel TUI keyed off
|
|
17
|
+
* `<dir>/progress.json`, and on finish (full completion OR human-finished
|
|
18
|
+
* with skips) write `<dir>/response.json` atomically and drop the progress
|
|
19
|
+
* file. A hard process kill leaves `progress.json` for a later resume —
|
|
20
|
+
* `tryResume` (unchanged logic) reads the new dir-derived path.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveInteractionDir(dir: string, deck: Deck, opts?: ResolveDirOpts): Promise<{
|
|
23
|
+
responses: InteractionResponse[];
|
|
24
|
+
completedAt: string;
|
|
25
|
+
responsePath: string;
|
|
26
|
+
}>;
|
|
4
27
|
export declare function launchTui(decisionsPath: string, sessionId?: string): Promise<{
|
|
5
28
|
responses: InteractionResponse[];
|
|
6
29
|
completedAt: string;
|