@crouton-kit/humanloop 0.3.36 → 0.3.38
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/inbox/controller.d.ts +11 -0
- package/dist/inbox/controller.js +76 -0
- package/dist/render/termrender.js +25 -26
- package/dist/tui/render.js +3 -1
- package/dist/tui/tmux.d.ts +1 -2
- package/dist/tui/tmux.js +1 -2
- package/package.json +1 -1
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { InteractionResponse, TicketSummary } from '../types.js';
|
|
2
2
|
import type { Key } from '../tui/terminal.js';
|
|
3
|
+
import { startWebServer } from '../browser/server.js';
|
|
3
4
|
export interface InboxControllerOptions {
|
|
4
5
|
roots?: string[];
|
|
5
6
|
cols?: number;
|
|
6
7
|
rows?: number;
|
|
7
8
|
scan?: (roots?: string[]) => TicketSummary[];
|
|
8
9
|
completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
|
|
10
|
+
startDeckBrowser?: typeof startWebServer;
|
|
11
|
+
openBrowser?: (url: string) => void;
|
|
9
12
|
}
|
|
10
13
|
type Screen = 'list' | 'detail';
|
|
11
14
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
@@ -19,6 +22,8 @@ export declare class InboxController {
|
|
|
19
22
|
private screen;
|
|
20
23
|
private adapter;
|
|
21
24
|
private reviewAdapter;
|
|
25
|
+
private deckBrowser;
|
|
26
|
+
private deckBrowserStarting;
|
|
22
27
|
private claim;
|
|
23
28
|
private reconciling;
|
|
24
29
|
private suspended;
|
|
@@ -51,6 +56,12 @@ export declare class InboxController {
|
|
|
51
56
|
* in ReviewAdapter; the controller only owns the terminal handoff. */
|
|
52
57
|
private activateReview;
|
|
53
58
|
reloadSelectedDeck(): void;
|
|
59
|
+
/** Hand the selected deck to its browser surface while retaining this ticket's claim. */
|
|
60
|
+
private openDeckBrowser;
|
|
61
|
+
/** The browser has atomically published the response; reconcile its owner delivery. */
|
|
62
|
+
private finishDeckBrowser;
|
|
63
|
+
/** Return browser authority to the terminal deck without changing its draft. */
|
|
64
|
+
private takeBackDeckBrowser;
|
|
54
65
|
close(): void;
|
|
55
66
|
run(): Promise<void>;
|
|
56
67
|
private complete;
|
package/dist/inbox/controller.js
CHANGED
|
@@ -2,6 +2,9 @@ import { readFileSync, watch } from 'node:fs';
|
|
|
2
2
|
import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
|
|
3
3
|
import { diffFrame } from '../tui/render.js';
|
|
4
4
|
import { renderMarkdown } from '../render/termrender.js';
|
|
5
|
+
import { startWebServer } from '../browser/server.js';
|
|
6
|
+
import { openBrowser } from '../browser/open.js';
|
|
7
|
+
import { renderHandoff } from '../tui/render.js';
|
|
5
8
|
import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from '../tui/ansi.js';
|
|
6
9
|
import { buildInboxLines } from './tui.js';
|
|
7
10
|
import { inboxLayout } from './layout.js';
|
|
@@ -24,6 +27,8 @@ export class InboxController {
|
|
|
24
27
|
screen = 'list';
|
|
25
28
|
adapter;
|
|
26
29
|
reviewAdapter;
|
|
30
|
+
deckBrowser;
|
|
31
|
+
deckBrowserStarting = false;
|
|
27
32
|
claim;
|
|
28
33
|
reconciling = false;
|
|
29
34
|
suspended = false;
|
|
@@ -121,7 +126,16 @@ export class InboxController {
|
|
|
121
126
|
this.close();
|
|
122
127
|
return;
|
|
123
128
|
}
|
|
129
|
+
if (this.deckBrowser !== undefined) {
|
|
130
|
+
if (input === 'w' || input === 'W')
|
|
131
|
+
void this.takeBackDeckBrowser();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
124
134
|
if (this.screen === 'detail' && this.adapter !== undefined) {
|
|
135
|
+
if ((input === 'w' || input === 'W') && this.adapter.canAcceptHostKeys()) {
|
|
136
|
+
void this.openDeckBrowser();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
125
139
|
this.adapter.handleKey(input, key);
|
|
126
140
|
this.repaint();
|
|
127
141
|
return;
|
|
@@ -221,11 +235,69 @@ export class InboxController {
|
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
237
|
reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
|
|
238
|
+
/** Hand the selected deck to its browser surface while retaining this ticket's claim. */
|
|
239
|
+
async openDeckBrowser() {
|
|
240
|
+
if (this.deckBrowser !== undefined || this.deckBrowserStarting || this.adapter === undefined || this.claim === undefined)
|
|
241
|
+
return;
|
|
242
|
+
const item = this.items[this.selectedIndex];
|
|
243
|
+
if (item?.kind !== 'deck' || item.dir !== this.claim.dir)
|
|
244
|
+
return;
|
|
245
|
+
const deck = readJson(deckPath(item.dir));
|
|
246
|
+
if (deck === null)
|
|
247
|
+
return;
|
|
248
|
+
this.deckBrowserStarting = true;
|
|
249
|
+
try {
|
|
250
|
+
const start = this.options.startDeckBrowser ?? startWebServer;
|
|
251
|
+
let browser;
|
|
252
|
+
browser = await start({
|
|
253
|
+
dir: item.dir,
|
|
254
|
+
deck,
|
|
255
|
+
onSubmit: () => { void this.finishDeckBrowser(item.dir, browser); },
|
|
256
|
+
});
|
|
257
|
+
this.deckBrowser = browser;
|
|
258
|
+
(this.options.openBrowser ?? openBrowser)(browser.url);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.status = error instanceof Error ? error.message : String(error);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
this.deckBrowserStarting = false;
|
|
265
|
+
this.repaint();
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** The browser has atomically published the response; reconcile its owner delivery. */
|
|
269
|
+
async finishDeckBrowser(dir, browser) {
|
|
270
|
+
if (this.deckBrowser === browser)
|
|
271
|
+
this.deckBrowser = undefined;
|
|
272
|
+
await browser.stop();
|
|
273
|
+
if (this.claim?.dir === dir)
|
|
274
|
+
this.leaveDetail();
|
|
275
|
+
this.rescan();
|
|
276
|
+
this.reconcileRoots();
|
|
277
|
+
this.repaint();
|
|
278
|
+
}
|
|
279
|
+
/** Return browser authority to the terminal deck without changing its draft. */
|
|
280
|
+
async takeBackDeckBrowser() {
|
|
281
|
+
const browser = this.deckBrowser;
|
|
282
|
+
if (browser === undefined)
|
|
283
|
+
return;
|
|
284
|
+
this.deckBrowser = undefined;
|
|
285
|
+
try {
|
|
286
|
+
await browser.requestTakeBack();
|
|
287
|
+
await browser.stop();
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
this.repaint(true);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
224
293
|
close() {
|
|
225
294
|
if (this.closed)
|
|
226
295
|
return;
|
|
227
296
|
this.closed = true;
|
|
228
297
|
void this.reviewAdapter?.stop();
|
|
298
|
+
const browser = this.deckBrowser;
|
|
299
|
+
this.deckBrowser = undefined;
|
|
300
|
+
void browser?.stop();
|
|
229
301
|
this.leaveDetail();
|
|
230
302
|
for (const watcher of this.watchers)
|
|
231
303
|
watcher.close();
|
|
@@ -355,6 +427,10 @@ export class InboxController {
|
|
|
355
427
|
return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
|
|
356
428
|
}
|
|
357
429
|
detailLines(width, rows) {
|
|
430
|
+
if (this.deckBrowser !== undefined)
|
|
431
|
+
return renderHandoff(this.deckBrowser.url, width, rows);
|
|
432
|
+
if (this.deckBrowserStarting)
|
|
433
|
+
return [` ${DIM}Opening browser review…${RESET}`];
|
|
358
434
|
if (this.adapter !== undefined)
|
|
359
435
|
return this.adapter.render();
|
|
360
436
|
const selected = this.items[this.selectedIndex];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, statSync, openSync, closeSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, statSync, mkdirSync, openSync, closeSync, unlinkSync, renameSync, accessSync, realpathSync, constants, } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import stringWidth from 'string-width';
|
|
6
6
|
import { TERMRENDER_VERSION } from './version.js';
|
|
7
7
|
// ── The sole org-wide termrender binding ─────────────────────────────────────
|
|
@@ -10,23 +10,10 @@ import { TERMRENDER_VERSION } from './version.js';
|
|
|
10
10
|
// TERMRENDER_VERSION, installed into a venv humanloop owns. The binary is
|
|
11
11
|
// resolved by ABSOLUTE PATH inside that venv — never `$PATH` — so a user's
|
|
12
12
|
// own `pip install termrender` can never shadow or break the pin.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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');
|
|
13
|
+
const RENDERER_CACHE_DIR = join(resolve(process.env.XDG_CACHE_HOME || join(homedir(), '.cache')), 'humanloop', 'termrender', TERMRENDER_VERSION);
|
|
14
|
+
const VENV_DIR = join(RENDERER_CACHE_DIR, 'venv');
|
|
15
|
+
const VENV_BIN = join(VENV_DIR, 'bin/termrender');
|
|
16
|
+
const VENV_PYTHON = join(VENV_DIR, 'bin/python');
|
|
30
17
|
// Readiness marker written by the single authoritative provisioning transition
|
|
31
18
|
// after a verified install. It fingerprints the ACTUAL verified environment —
|
|
32
19
|
// launcher + interpreter (mtime, size, mode) and the interpreter's realpath —
|
|
@@ -38,11 +25,12 @@ const VENV_PYTHON = resolve(PKG_ROOT, '.venv/bin/python');
|
|
|
38
25
|
// marker (see invalidateRenderer), so the next process repairs. Together these
|
|
39
26
|
// remove the ~149ms `termrender -h` + `importlib.metadata` spawn tax from the
|
|
40
27
|
// steady path without letting a stale marker trust a broken renderer forever.
|
|
41
|
-
const VENV_STAMP =
|
|
28
|
+
const VENV_STAMP = join(VENV_DIR, '.hl-termrender-stamp.json');
|
|
42
29
|
// Provisioning lock — lives OUTSIDE .venv (which `uv venv --clear` wipes) so it
|
|
43
|
-
// survives a reinstall.
|
|
44
|
-
//
|
|
45
|
-
|
|
30
|
+
// survives a reinstall. The user cache stays writable when humanloop is installed
|
|
31
|
+
// in a read-only runtime image, and serializes venv mutation + stamp publication
|
|
32
|
+
// across processes: a stamp can never certify a concurrently-changing venv.
|
|
33
|
+
const VENV_LOCK = join(RENDERER_CACHE_DIR, '.hl-termrender.lock');
|
|
46
34
|
// A lock older than this is from a crashed process and may be stolen. Set
|
|
47
35
|
// comfortably above the worst-case held path (uv probe 5s + venv 60s + install
|
|
48
36
|
// 120s + re-verify ~10s ≈ 195s) so a slow-but-alive holder is never judged
|
|
@@ -220,6 +208,14 @@ function stealStaleLock() {
|
|
|
220
208
|
catch { /* best effort */ }
|
|
221
209
|
}
|
|
222
210
|
function withProvisionLock(provision) {
|
|
211
|
+
try {
|
|
212
|
+
mkdirSync(RENDERER_CACHE_DIR, { recursive: true });
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
rendererState = 'unavailable';
|
|
216
|
+
process.stderr.write(`[hl] termrender unavailable — renderer cache cannot be created (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
223
219
|
const giveUpAt = Date.now() + LOCK_GIVE_UP_MS;
|
|
224
220
|
for (;;) {
|
|
225
221
|
let fd;
|
|
@@ -227,8 +223,11 @@ function withProvisionLock(provision) {
|
|
|
227
223
|
fd = openSync(VENV_LOCK, 'wx'); // O_CREAT | O_EXCL — atomic acquire
|
|
228
224
|
}
|
|
229
225
|
catch (err) {
|
|
230
|
-
if (err.code !== 'EEXIST')
|
|
231
|
-
|
|
226
|
+
if (err.code !== 'EEXIST') {
|
|
227
|
+
rendererState = 'unavailable';
|
|
228
|
+
process.stderr.write(`[hl] termrender unavailable — renderer cache cannot be locked (${err instanceof Error ? err.message : String(err)}); using plaintext fallback\n`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
232
231
|
if (lockIsStale()) {
|
|
233
232
|
stealStaleLock();
|
|
234
233
|
continue;
|
package/dist/tui/render.js
CHANGED
|
@@ -74,7 +74,7 @@ export function renderOverview(state, cols, rows) {
|
|
|
74
74
|
lines.push('');
|
|
75
75
|
}
|
|
76
76
|
lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
|
|
77
|
-
lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
|
|
77
|
+
lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}w${RESET} browser ${DIM}q${RESET} finish`);
|
|
78
78
|
while (lines.length < rows)
|
|
79
79
|
lines.push('');
|
|
80
80
|
// Overview content extends roughly cols-16 wide for option labels; center
|
|
@@ -272,6 +272,8 @@ export function renderItemReview(state, cols, rows) {
|
|
|
272
272
|
if (overflows) {
|
|
273
273
|
footerParts.unshift(state.inputMode ? `${DIM}pgup/pgdn${RESET} scroll` : `${DIM}u/d${RESET} scroll`);
|
|
274
274
|
}
|
|
275
|
+
if (state.inputMode === null)
|
|
276
|
+
footerParts.push(`${DIM}w${RESET} browser`);
|
|
275
277
|
const footer = ` ${footerParts.join(' ')}`;
|
|
276
278
|
// Assemble — pad to fill rows so post-body sits at the bottom
|
|
277
279
|
const lines = [...preLines, ...visibleBody, ...postLines];
|
package/dist/tui/tmux.d.ts
CHANGED
|
@@ -10,8 +10,7 @@ export declare function popupPaths(target: TmuxPopupTarget): {
|
|
|
10
10
|
};
|
|
11
11
|
/** The argv crouter's tmux-binding installer binds on the root table to toggle the inbox popup.
|
|
12
12
|
* Humanloop owns the command text; crouter owns which key(s) run it (via its keybindings catalog
|
|
13
|
-
* and the single `installTmuxBindings()` manifest sweep).
|
|
14
|
-
* reconciles any key itself. */
|
|
13
|
+
* and the single `installTmuxBindings()` manifest sweep). */
|
|
15
14
|
export declare function inboxToggleTmuxCommand(): string[];
|
|
16
15
|
export declare function tmuxSocketFromEnvironment(): string | undefined;
|
|
17
16
|
export declare function inferTmuxClient(socket: string, pane?: string | undefined): string | undefined | 'ambiguous';
|
package/dist/tui/tmux.js
CHANGED
|
@@ -28,8 +28,7 @@ function bindingCommand() {
|
|
|
28
28
|
}
|
|
29
29
|
/** The argv crouter's tmux-binding installer binds on the root table to toggle the inbox popup.
|
|
30
30
|
* Humanloop owns the command text; crouter owns which key(s) run it (via its keybindings catalog
|
|
31
|
-
* and the single `installTmuxBindings()` manifest sweep).
|
|
32
|
-
* reconciles any key itself. */
|
|
31
|
+
* and the single `installTmuxBindings()` manifest sweep). */
|
|
33
32
|
export function inboxToggleTmuxCommand() {
|
|
34
33
|
return ['run-shell', '-b', bindingCommand()];
|
|
35
34
|
}
|