@yusukeshib/pi-babysit 0.6.14 → 0.6.15
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/README.md +19 -14
- package/index.ts +117 -57
- package/log-viewer.ts +346 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -76,6 +76,7 @@ backgrounding (`… &`, `nohup`, `setsid`, `disown`). Set
|
|
|
76
76
|
| Command | What it does |
|
|
77
77
|
| ------- | ------------ |
|
|
78
78
|
| `/babysit` | Arrow-key picker over all sessions. Renders an **inline snapshot** (no tmux): running **process** → current rendered screen + recent output + a copy-paste `babysit attach` take-over hint (detach `Ctrl-\ Ctrl-\`); running **subagent** → read-only progress (RPC stdin stays untouchable); finished → summary. Re-run `/babysit` to refresh |
|
|
79
|
+
| `/babysit view` | Keyboard picker to open a session in the widget log viewer (Pi TUI) |
|
|
79
80
|
| `/babysit gc [days]` | Preview and confirm deletion of old Pi-session roots (default 14 days). Active leases, live supervisor/child PIDs, unknown states, the current root, and recent roots are retained; old empty roots are eligible once their lease is gone. Deletion uses a GC lock and atomic rename |
|
|
80
81
|
|
|
81
82
|
A widget below the editor separates session **kind** from task **state** at a glance:
|
|
@@ -85,20 +86,24 @@ summary counts use `RUNNING` / `IDLE`, and every row is labeled
|
|
|
85
86
|
Click the header to toggle to `babysits(n all)`, which also shows finished and failed
|
|
86
87
|
sessions, and click again to return to running-only. Both modes show newest first,
|
|
87
88
|
ten per page; `< 1/4 >` shows the current/total page when needed. Click the
|
|
88
|
-
arrows to page.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
89
|
+
arrows to page. Rows occupy one line, previewing the command for processes and
|
|
90
|
+
the task for agents (with `…` when clipped). Click an AGENT or PROCESS row to
|
|
91
|
+
replace the list with a tall **log viewer in the same widget area**, not an
|
|
92
|
+
overlay or separate screen. `Esc` or the right-hand **Close** button restores
|
|
93
|
+
the list, preserving its mode and page. The viewer uses Pi's `ScrollView` for
|
|
94
|
+
scroll position and follow behavior, with a widget-local scrollbar styled using
|
|
95
|
+
Pi's scrollbar theme. Wheel, scrollbar drag/track clicks, and ↑/↓ navigate the
|
|
96
|
+
log without requiring any Pi core changes. Home/End and PageUp/PageDown remain
|
|
97
|
+
Pi's transcript shortcuts. At the bottom it follows new output; scrolling up
|
|
98
|
+
pauses following until you return to the bottom. The viewer resizes with the
|
|
99
|
+
terminal, leaving room for the editor/status/footer. Agent logs separate
|
|
100
|
+
assistant text, tool calls, and results; process logs show terminal output.
|
|
101
|
+
Only the selected log is loaded, bounded to the latest 1 MiB / 2,000 display
|
|
102
|
+
lines with an omission notice; the full log file is unchanged. Empty logs show
|
|
103
|
+
a placeholder. Works in the released Pi 1.0 widget host. Mouse interaction
|
|
104
|
+
requires fullscreen mode; `/babysit view` opens the viewer using a keyboard
|
|
105
|
+
picker, and `/babysit` still provides a snapshot. The displayed `age` is total
|
|
106
|
+
session age, not time spent in the current state. Transcript details
|
|
102
107
|
from tool results, snapshots, and completion notifications collapse or expand
|
|
103
108
|
with Pi's tool-output toggle (`Ctrl+O` by default).
|
|
104
109
|
|
package/index.ts
CHANGED
|
@@ -43,7 +43,8 @@ import type {
|
|
|
43
43
|
ToolDefinition,
|
|
44
44
|
} from "@earendil-works/pi-coding-agent";
|
|
45
45
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
46
|
-
import { Box, Markdown, Text, truncateToWidth, visibleWidth,
|
|
46
|
+
import { Box, Markdown, Text, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
47
|
+
import { createLogViewer } from "./log-viewer";
|
|
47
48
|
import { Type, type TSchema } from "typebox";
|
|
48
49
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
49
50
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
|
|
@@ -2401,7 +2402,6 @@ function renderWidgetSessionHeader(
|
|
|
2401
2402
|
|
|
2402
2403
|
// How many trailing output lines to show per session in the widget.
|
|
2403
2404
|
const WIDGET_COLLAPSED_TAIL_LINES = 1;
|
|
2404
|
-
const WIDGET_EXPANDED_TAIL_LINES = 50;
|
|
2405
2405
|
const WIDGET_SESSION_PAGE_SIZE = 10;
|
|
2406
2406
|
|
|
2407
2407
|
export interface WidgetSessionListState {
|
|
@@ -2413,7 +2413,7 @@ export interface WidgetSessionDisplay {
|
|
|
2413
2413
|
id: string;
|
|
2414
2414
|
header: string;
|
|
2415
2415
|
tail: string[];
|
|
2416
|
-
|
|
2416
|
+
viewable: boolean;
|
|
2417
2417
|
active?: boolean;
|
|
2418
2418
|
details?: string[];
|
|
2419
2419
|
}
|
|
@@ -2426,14 +2426,12 @@ interface WidgetMouseEvent {
|
|
|
2426
2426
|
}
|
|
2427
2427
|
|
|
2428
2428
|
/**
|
|
2429
|
-
*
|
|
2430
|
-
* pi-tui mouse classes. Older hosts still render it normally; fullscreen hosts
|
|
2431
|
-
* dispatch component-local click coordinates to handleMouse.
|
|
2429
|
+
* Compact session list. Item presses open a viewer; rows never expand inline.
|
|
2432
2430
|
*/
|
|
2433
2431
|
export function createWidgetComponent(
|
|
2434
2432
|
summaryLines: string[],
|
|
2435
2433
|
sessions: WidgetSessionDisplay[],
|
|
2436
|
-
|
|
2434
|
+
onOpen: (id: string) => void,
|
|
2437
2435
|
listState?: WidgetSessionListState,
|
|
2438
2436
|
) {
|
|
2439
2437
|
type Target = { type: "session"; id: string } | { type: "sessions"; endX: number; prevX?: number; nextX?: number };
|
|
@@ -2448,31 +2446,10 @@ export function createWidgetComponent(
|
|
|
2448
2446
|
targetByLine.push(target);
|
|
2449
2447
|
};
|
|
2450
2448
|
const pushSession = (session: WidgetSessionDisplay) => {
|
|
2451
|
-
const
|
|
2452
|
-
const
|
|
2453
|
-
|
|
2454
|
-
);
|
|
2455
|
-
const target = session.expandable ? { type: "session" as const, id: session.id } : undefined;
|
|
2456
|
-
const details = session.details ?? [];
|
|
2457
|
-
const pushWrapped = (text: string) => {
|
|
2458
|
-
const indent = width > 6 ? " " : "";
|
|
2459
|
-
for (const line of wrapTextWithAnsi(text, Math.max(1, width - indent.length))) {
|
|
2460
|
-
push(`${indent}${line}`, target);
|
|
2461
|
-
}
|
|
2462
|
-
};
|
|
2463
|
-
const pushDetail = (detail: string) =>
|
|
2464
|
-
pushWrapped(detail.startsWith("task: ") ? detail.slice(6) : detail);
|
|
2465
|
-
if (!expanded && details.length) {
|
|
2466
|
-
const preview = details[0].startsWith("task: ") ? details[0].slice(6) : details[0];
|
|
2467
|
-
push(`${session.header} ${preview}`, target, "…");
|
|
2468
|
-
} else if (!expanded && tail.length === 1) {
|
|
2469
|
-
push(`${session.header} ${tail[0]}`, target);
|
|
2470
|
-
} else {
|
|
2471
|
-
push(session.header, target);
|
|
2472
|
-
for (const detail of details) pushDetail(detail);
|
|
2473
|
-
for (const line of tail) pushWrapped(line);
|
|
2474
|
-
if (expanded && tail.length === 0) push(" (no output available)", target);
|
|
2475
|
-
}
|
|
2449
|
+
const target = session.viewable ? { type: "session" as const, id: session.id } : undefined;
|
|
2450
|
+
const detail = session.details?.[0];
|
|
2451
|
+
const preview = detail?.startsWith("task: ") ? detail.slice(6) : detail ?? session.tail.at(-1);
|
|
2452
|
+
push(`${session.header}${preview ? ` ${preview}` : ""}`, target, "…");
|
|
2476
2453
|
};
|
|
2477
2454
|
|
|
2478
2455
|
for (const line of summaryLines.slice(0, -1)) push(line);
|
|
@@ -2515,11 +2492,7 @@ export function createWidgetComponent(
|
|
|
2515
2492
|
// Toggle on press so a terminal that omits release still works.
|
|
2516
2493
|
if (event.type === "press") {
|
|
2517
2494
|
if (target.type === "session") {
|
|
2518
|
-
|
|
2519
|
-
else {
|
|
2520
|
-
expandedSessionIds.clear();
|
|
2521
|
-
expandedSessionIds.add(target.id);
|
|
2522
|
-
}
|
|
2495
|
+
onOpen(target.id);
|
|
2523
2496
|
} else if (listState) {
|
|
2524
2497
|
const count = listState.mode === "running" ? sessions.filter((session) => session.active).length : sessions.length;
|
|
2525
2498
|
const pageCount = Math.max(1, Math.ceil(count / WIDGET_SESSION_PAGE_SIZE));
|
|
@@ -2544,6 +2517,78 @@ export function createWidgetComponent(
|
|
|
2544
2517
|
};
|
|
2545
2518
|
}
|
|
2546
2519
|
|
|
2520
|
+
export function createSessionWidget(options: {
|
|
2521
|
+
getHeight: () => number;
|
|
2522
|
+
requestRender: () => void;
|
|
2523
|
+
getTheme: () => Theme;
|
|
2524
|
+
readLog: (id: string) => { text: string; isAgent: boolean; clipped?: boolean };
|
|
2525
|
+
}) {
|
|
2526
|
+
const listState: WidgetSessionListState = { mode: "running", page: 0 };
|
|
2527
|
+
let selectedId: string | undefined;
|
|
2528
|
+
let displays: WidgetSessionDisplay[] = [];
|
|
2529
|
+
let viewer: ReturnType<typeof createLogViewer> | undefined;
|
|
2530
|
+
let list = createWidgetComponent([], [], open, listState);
|
|
2531
|
+
function refreshViewer() {
|
|
2532
|
+
if (!selectedId || !viewer) return;
|
|
2533
|
+
const log = options.readLog(selectedId);
|
|
2534
|
+
const session = displays.find((session) => session.id === selectedId);
|
|
2535
|
+
const header = session?.header ?? selectedId;
|
|
2536
|
+
const detail = session?.details?.[0] ?? "";
|
|
2537
|
+
viewer.update(
|
|
2538
|
+
`${log.clipped ? "[older log omitted: 1 MiB limit] " : ""}${header}`,
|
|
2539
|
+
log.text,
|
|
2540
|
+
log.isAgent,
|
|
2541
|
+
detail.startsWith("task: ") ? detail.slice(6) : detail,
|
|
2542
|
+
);
|
|
2543
|
+
}
|
|
2544
|
+
function close() {
|
|
2545
|
+
selectedId = undefined;
|
|
2546
|
+
viewer = undefined;
|
|
2547
|
+
options.requestRender();
|
|
2548
|
+
}
|
|
2549
|
+
function open(id: string) {
|
|
2550
|
+
selectedId = id;
|
|
2551
|
+
viewer = createLogViewer({ ...options, onClose: close });
|
|
2552
|
+
refreshViewer();
|
|
2553
|
+
options.requestRender();
|
|
2554
|
+
}
|
|
2555
|
+
return {
|
|
2556
|
+
open,
|
|
2557
|
+
update(summary: string[], sessions: WidgetSessionDisplay[]) {
|
|
2558
|
+
displays = sessions;
|
|
2559
|
+
list = createWidgetComponent(summary, sessions, open, listState);
|
|
2560
|
+
refreshViewer();
|
|
2561
|
+
options.requestRender();
|
|
2562
|
+
},
|
|
2563
|
+
render(width: number) { return (viewer ?? list).render(width); },
|
|
2564
|
+
handleMouse(event: TuiMouseEvent) {
|
|
2565
|
+
// Stock Pi widget hosts forward mouse input to this component, not
|
|
2566
|
+
// to a nested native layout tree. Keep the full wheel/drag event.
|
|
2567
|
+
return (viewer ?? list).handleMouse(event);
|
|
2568
|
+
},
|
|
2569
|
+
handleKey(data: string) { return viewer?.handleKey(data) ?? false; },
|
|
2570
|
+
invalidate() { list.invalidate(); viewer?.invalidate(); },
|
|
2571
|
+
};
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
/** Only the selected session is read; bounded snapshots never affect worker logs. */
|
|
2575
|
+
export function readViewerLog(file: string): { text: string; clipped: boolean } {
|
|
2576
|
+
try {
|
|
2577
|
+
const fd = fs.openSync(file, "r");
|
|
2578
|
+
try {
|
|
2579
|
+
const size = fs.fstatSync(fd).size;
|
|
2580
|
+
const start = Math.max(0, size - 1024 * 1024);
|
|
2581
|
+
const buffer = Buffer.alloc(size - start);
|
|
2582
|
+
const bytes = fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
2583
|
+
let text = buffer.subarray(0, bytes).toString("utf8");
|
|
2584
|
+
if (start > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
2585
|
+
return { text, clipped: start > 0 };
|
|
2586
|
+
} finally { fs.closeSync(fd); }
|
|
2587
|
+
} catch {
|
|
2588
|
+
return { text: "", clipped: false };
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2547
2592
|
// Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
|
|
2548
2593
|
// corrupt the widget area.
|
|
2549
2594
|
function sanitizeTailLine(s: string): string {
|
|
@@ -3498,8 +3543,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
3498
3543
|
}
|
|
3499
3544
|
}
|
|
3500
3545
|
|
|
3501
|
-
|
|
3502
|
-
|
|
3546
|
+
let sessionWidget: ReturnType<typeof createSessionWidget> | undefined;
|
|
3547
|
+
let unsubscribeWidgetInput: (() => void) | undefined;
|
|
3503
3548
|
|
|
3504
3549
|
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
|
|
3505
3550
|
if (!ctx.hasUI) return;
|
|
@@ -3516,10 +3561,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3516
3561
|
}
|
|
3517
3562
|
}
|
|
3518
3563
|
const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
|
|
3519
|
-
const sessionIds = new Set(all.map((session) => session.id));
|
|
3520
|
-
for (const id of expandedWidgetSessions) {
|
|
3521
|
-
if (!sessionIds.has(id)) expandedWidgetSessions.delete(id);
|
|
3522
|
-
}
|
|
3523
3564
|
|
|
3524
3565
|
const theme = ctx.ui.theme;
|
|
3525
3566
|
const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
|
|
@@ -3530,7 +3571,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3530
3571
|
session.id,
|
|
3531
3572
|
isSubagent,
|
|
3532
3573
|
isSubagent ? progressById.get(session.id) : undefined,
|
|
3533
|
-
|
|
3574
|
+
WIDGET_COLLAPSED_TAIL_LINES,
|
|
3534
3575
|
);
|
|
3535
3576
|
}),
|
|
3536
3577
|
);
|
|
@@ -3551,7 +3592,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3551
3592
|
id,
|
|
3552
3593
|
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
|
|
3553
3594
|
tail: activeTails.get(id) ?? [],
|
|
3554
|
-
|
|
3595
|
+
viewable: true,
|
|
3555
3596
|
active: true,
|
|
3556
3597
|
details,
|
|
3557
3598
|
};
|
|
@@ -3566,27 +3607,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
3566
3607
|
theme,
|
|
3567
3608
|
),
|
|
3568
3609
|
details,
|
|
3569
|
-
// Only read/parse
|
|
3610
|
+
// Only read/parse visible sessions on demand.
|
|
3570
3611
|
get tail() {
|
|
3571
3612
|
try {
|
|
3572
|
-
return widgetTail(id, isSubagent, undefined,
|
|
3613
|
+
return widgetTail(id, isSubagent, undefined, WIDGET_COLLAPSED_TAIL_LINES);
|
|
3573
3614
|
} catch {
|
|
3574
3615
|
return [];
|
|
3575
3616
|
}
|
|
3576
3617
|
},
|
|
3577
|
-
|
|
3618
|
+
viewable: true,
|
|
3578
3619
|
};
|
|
3579
3620
|
});
|
|
3580
3621
|
|
|
3581
3622
|
if (ctx.mode === "tui") {
|
|
3582
|
-
|
|
3583
|
-
"pi-babysit",
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3623
|
+
if (!sessionWidget) {
|
|
3624
|
+
ctx.ui.setWidget("pi-babysit", (tui) => {
|
|
3625
|
+
sessionWidget = createSessionWidget({
|
|
3626
|
+
// Keep the viewer bounded while leaving room for editor/status/footer.
|
|
3627
|
+
getHeight: () => Math.max(3, tui.terminal.rows - 8),
|
|
3628
|
+
requestRender: () => tui.requestRender(),
|
|
3629
|
+
getTheme: () => ctx.ui.theme,
|
|
3630
|
+
readLog: (id) => ({ ...readViewerLog(logPath(id)), isAgent: kindOf(id) === "subagent" }),
|
|
3631
|
+
});
|
|
3632
|
+
return sessionWidget;
|
|
3633
|
+
}, { placement: "belowEditor" });
|
|
3634
|
+
unsubscribeWidgetInput = ctx.ui.onTerminalInput((data) =>
|
|
3635
|
+
sessionWidget?.handleKey(data) ? { consume: true } : undefined);
|
|
3636
|
+
}
|
|
3637
|
+
sessionWidget?.update(summaryLines, displays);
|
|
3587
3638
|
} else {
|
|
3588
3639
|
const activeIds = new Set(active.map((session) => session.id));
|
|
3589
|
-
const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)),
|
|
3640
|
+
const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), () => {});
|
|
3590
3641
|
ctx.ui.setWidget("pi-babysit", component.render(10_000), {
|
|
3591
3642
|
placement: "belowEditor",
|
|
3592
3643
|
});
|
|
@@ -3768,6 +3819,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3768
3819
|
});
|
|
3769
3820
|
|
|
3770
3821
|
pi.on("session_shutdown", async (event) => {
|
|
3822
|
+
unsubscribeWidgetInput?.();
|
|
3823
|
+
unsubscribeWidgetInput = undefined;
|
|
3824
|
+
sessionWidget = undefined;
|
|
3771
3825
|
if (pollTimer) clearInterval(pollTimer);
|
|
3772
3826
|
pollTimer = undefined;
|
|
3773
3827
|
// Pi also emits session_shutdown while hot-reloading and switching sessions.
|
|
@@ -5050,9 +5104,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
5050
5104
|
// output + a copy-paste `babysit attach` take-over hint; running subagent →
|
|
5051
5105
|
// read-only progress; finished → summary. Re-run to refresh.
|
|
5052
5106
|
pi.registerCommand("babysit", {
|
|
5053
|
-
description: "Pick a session to inspect
|
|
5107
|
+
description: "Pick a session to inspect; /babysit view opens its log viewer; /babysit gc [days] removes old terminal roots",
|
|
5054
5108
|
handler: async (args, ctx) => {
|
|
5055
|
-
const
|
|
5109
|
+
const view = args.trim() === "view";
|
|
5110
|
+
const command = view ? "" : args.trim();
|
|
5056
5111
|
if (command === "gc" || command.startsWith("gc ")) {
|
|
5057
5112
|
const daysText = command.slice(2).trim();
|
|
5058
5113
|
const days = daysText ? Number(daysText) : 14;
|
|
@@ -5087,7 +5142,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
5087
5142
|
return;
|
|
5088
5143
|
}
|
|
5089
5144
|
if (command) {
|
|
5090
|
-
ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
|
|
5145
|
+
ctx.ui.notify("Usage: /babysit, /babysit view, or /babysit gc [days]", "error");
|
|
5091
5146
|
return;
|
|
5092
5147
|
}
|
|
5093
5148
|
if (!(await babysitAvailable())) {
|
|
@@ -5124,6 +5179,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
5124
5179
|
if (!choice) return;
|
|
5125
5180
|
const picked = sessions[labels.indexOf(choice)];
|
|
5126
5181
|
if (!picked) return;
|
|
5182
|
+
if (view && ctx.mode === "tui") {
|
|
5183
|
+
await refreshWidget(ctx);
|
|
5184
|
+
sessionWidget?.open(picked.id);
|
|
5185
|
+
return;
|
|
5186
|
+
}
|
|
5127
5187
|
const kind = kindOf(picked.id);
|
|
5128
5188
|
const elapsed = picked.state === "running" ? elapsedOf(picked.id) : null;
|
|
5129
5189
|
const elapsedSuffix = elapsed ? ` ${elapsed}` : "";
|
package/log-viewer.ts
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Key,
|
|
4
|
+
Markdown,
|
|
5
|
+
ScrollView,
|
|
6
|
+
Text,
|
|
7
|
+
matchesKey,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
type Component,
|
|
11
|
+
type TuiMouseEvent,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
14
|
+
export interface LogViewerOptions {
|
|
15
|
+
getHeight: () => number;
|
|
16
|
+
requestRender: () => void;
|
|
17
|
+
getTheme: () => Theme;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Only numeric SGR escapes are allowed; never forward cursor, hyperlink or image controls. */
|
|
22
|
+
function sanitize(text: string, sgr = false): string {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\|$)/g, "")
|
|
25
|
+
.replace(/\x1b[P_^X][\s\S]*?(?:\x1b\\|$)/g, "")
|
|
26
|
+
.replace(/\r\n/g, "\n")
|
|
27
|
+
.replace(/\r/g, "\n")
|
|
28
|
+
.replace(/\t/g, " ")
|
|
29
|
+
.replace(
|
|
30
|
+
/\x1b\[[0-?]*[ -/]*[@-~]|\x1b[^\[]?|[\x00-\x08\x0b-\x1f\x7f-\x9f]/g,
|
|
31
|
+
(s) => (sgr && /^\x1b\[[\d;]*m$/.test(s) ? s : ""),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
type Block = { kind: "assistant" | "tool" | "result" | "error"; text: string };
|
|
35
|
+
function contentText(content: unknown): string {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
return Array.isArray(content)
|
|
38
|
+
? content
|
|
39
|
+
.filter((c) => c?.type === "text")
|
|
40
|
+
.map((c) => c.text ?? "")
|
|
41
|
+
.join("")
|
|
42
|
+
: "";
|
|
43
|
+
}
|
|
44
|
+
function agentBlocks(raw: string): Block[] {
|
|
45
|
+
const blocks: Block[] = [];
|
|
46
|
+
let current: Block | undefined;
|
|
47
|
+
const toolResults = new Map<string, Block>();
|
|
48
|
+
const assistant = () =>
|
|
49
|
+
(current ??=
|
|
50
|
+
(blocks.push({ kind: "assistant", text: "" }),
|
|
51
|
+
blocks[blocks.length - 1]));
|
|
52
|
+
for (const line of raw.split("\n")) {
|
|
53
|
+
let e: any;
|
|
54
|
+
try {
|
|
55
|
+
e = JSON.parse(line);
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!e || typeof e !== "object") continue;
|
|
60
|
+
if (e.type === "message_start" && e.message?.role === "assistant")
|
|
61
|
+
current = undefined;
|
|
62
|
+
if (["message_start", "message_update", "message_end"].includes(e.type)) {
|
|
63
|
+
if (e.message?.role === "assistant") {
|
|
64
|
+
const text = contentText(e.message.content);
|
|
65
|
+
const b = assistant();
|
|
66
|
+
if (text) b.text = text; // cumulative/final snapshots replace streamed deltas
|
|
67
|
+
if (e.message.errorMessage)
|
|
68
|
+
blocks.push({ kind: "error", text: String(e.message.errorMessage) });
|
|
69
|
+
if (e.type === "message_end") current = undefined;
|
|
70
|
+
} else if (
|
|
71
|
+
!e.message &&
|
|
72
|
+
e.assistantMessageEvent?.type === "text_delta" &&
|
|
73
|
+
typeof e.assistantMessageEvent.delta === "string"
|
|
74
|
+
) {
|
|
75
|
+
assistant().text += e.assistantMessageEvent.delta;
|
|
76
|
+
} else if (e.type === "message_end" && e.message?.role === "toolResult") {
|
|
77
|
+
const id = e.message.toolCallId;
|
|
78
|
+
const text = contentText(e.message.content);
|
|
79
|
+
if (id && toolResults.has(id)) {
|
|
80
|
+
const b = toolResults.get(id)!;
|
|
81
|
+
b.text = text;
|
|
82
|
+
b.kind = e.message.isError ? "error" : b.kind;
|
|
83
|
+
} else {
|
|
84
|
+
const b: Block = {
|
|
85
|
+
kind: e.message.isError ? "error" : "result",
|
|
86
|
+
text,
|
|
87
|
+
};
|
|
88
|
+
blocks.push(b);
|
|
89
|
+
if (id) toolResults.set(id, b);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (e.type === "tool_execution_start") {
|
|
93
|
+
blocks.push({
|
|
94
|
+
kind: "tool",
|
|
95
|
+
text: `${e.toolName ?? "tool"} ${JSON.stringify(e.args ?? {})}`,
|
|
96
|
+
});
|
|
97
|
+
} else if (e.type === "tool_execution_end") {
|
|
98
|
+
const b: Block = {
|
|
99
|
+
kind: e.isError ? "error" : "result",
|
|
100
|
+
text:
|
|
101
|
+
contentText(e.result?.content) ||
|
|
102
|
+
String(e.error?.message ?? e.error ?? ""),
|
|
103
|
+
};
|
|
104
|
+
if (b.text) {
|
|
105
|
+
blocks.push(b);
|
|
106
|
+
if (e.toolCallId) toolResults.set(e.toolCallId, b);
|
|
107
|
+
}
|
|
108
|
+
} else if (e.type === "error")
|
|
109
|
+
blocks.push({
|
|
110
|
+
kind: "error",
|
|
111
|
+
text: String(
|
|
112
|
+
e.message ?? e.error?.message ?? e.error ?? "Unknown error",
|
|
113
|
+
),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return blocks
|
|
117
|
+
.filter((b) => b.text)
|
|
118
|
+
.map((b) => ({ ...b, text: sanitize(b.text) }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createLogViewer(options: LogViewerOptions) {
|
|
122
|
+
let header = "",
|
|
123
|
+
raw = "",
|
|
124
|
+
description = "",
|
|
125
|
+
isAgent = false;
|
|
126
|
+
let cache: string[] | undefined,
|
|
127
|
+
cacheWidth = -1,
|
|
128
|
+
cacheTheme: Theme | undefined;
|
|
129
|
+
const body: Component = {
|
|
130
|
+
invalidate() {
|
|
131
|
+
cache = undefined;
|
|
132
|
+
},
|
|
133
|
+
render(width) {
|
|
134
|
+
const theme = options.getTheme();
|
|
135
|
+
if (cache && cacheWidth === width && cacheTheme === theme) return cache;
|
|
136
|
+
const blocks = isAgent
|
|
137
|
+
? agentBlocks(raw)
|
|
138
|
+
: [{ kind: "result" as const, text: sanitize(raw, true) }];
|
|
139
|
+
const lines: string[] = description
|
|
140
|
+
? [
|
|
141
|
+
theme.fg("muted", isAgent ? "[task]" : "[command]"),
|
|
142
|
+
...new Text(sanitize(description), 0, 0).render(width),
|
|
143
|
+
"",
|
|
144
|
+
]
|
|
145
|
+
: [];
|
|
146
|
+
for (const b of blocks) {
|
|
147
|
+
if (isAgent)
|
|
148
|
+
lines.push(
|
|
149
|
+
theme.fg(
|
|
150
|
+
b.kind === "error"
|
|
151
|
+
? "error"
|
|
152
|
+
: b.kind === "assistant"
|
|
153
|
+
? "accent"
|
|
154
|
+
: "toolTitle",
|
|
155
|
+
`[${b.kind}]`,
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
lines.push(
|
|
159
|
+
...(b.kind === "assistant"
|
|
160
|
+
? new Markdown(b.text, 0, 0, getMarkdownTheme()).render(width)
|
|
161
|
+
: new Text(b.text, 0, 0).render(width)),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (!blocks.some((block) => block.text.trim()))
|
|
165
|
+
lines.push(theme.fg("muted", "(no output available)"));
|
|
166
|
+
cache =
|
|
167
|
+
lines.length > 2000
|
|
168
|
+
? [
|
|
169
|
+
theme.fg("warning", "[Earlier log lines clipped]"),
|
|
170
|
+
...lines.slice(-1999),
|
|
171
|
+
]
|
|
172
|
+
: lines;
|
|
173
|
+
cacheWidth = width;
|
|
174
|
+
cacheTheme = theme;
|
|
175
|
+
return cache;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const scrollView = new ScrollView(body, {
|
|
179
|
+
follow: "end",
|
|
180
|
+
overscroll: "contain",
|
|
181
|
+
scrollbar: "always",
|
|
182
|
+
scrollbarTrackStyle: (s) => options.getTheme().fg("scrollbarTrack", s),
|
|
183
|
+
scrollbarThumbStyle: (s) => options.getTheme().fg("scrollbarThumb", s),
|
|
184
|
+
});
|
|
185
|
+
const heading = {
|
|
186
|
+
invalidate() {},
|
|
187
|
+
render(width: number) {
|
|
188
|
+
const close = truncateToWidth("[Close]", width);
|
|
189
|
+
const room = Math.max(0, width - visibleWidth(close) - 1);
|
|
190
|
+
const title = truncateToWidth(sanitize(header).replace(/\n/g, " "), room);
|
|
191
|
+
return [
|
|
192
|
+
options.getTheme().fg("accent", title) +
|
|
193
|
+
" ".repeat(
|
|
194
|
+
Math.max(0, width - visibleWidth(title) - visibleWidth(close)),
|
|
195
|
+
) +
|
|
196
|
+
options.getTheme().fg("muted", close),
|
|
197
|
+
];
|
|
198
|
+
},
|
|
199
|
+
handleMouse(e: TuiMouseEvent) {
|
|
200
|
+
if (
|
|
201
|
+
e.button !== "left" ||
|
|
202
|
+
e.x < Math.max(0, e.width - 7) ||
|
|
203
|
+
e.x >= e.width
|
|
204
|
+
)
|
|
205
|
+
return;
|
|
206
|
+
if (e.type === "press") {
|
|
207
|
+
options.onClose();
|
|
208
|
+
return { handled: true };
|
|
209
|
+
}
|
|
210
|
+
if (e.type === "click" || e.type === "release") return { handled: true };
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
const footer: Component = {
|
|
214
|
+
invalidate() {},
|
|
215
|
+
render: (width) => [
|
|
216
|
+
options.getTheme().fg("dim", truncateToWidth("↑↓ · Esc close", width)),
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
let geometry = {
|
|
220
|
+
width: 0,
|
|
221
|
+
height: 0,
|
|
222
|
+
thumbTop: 0,
|
|
223
|
+
thumbHeight: 0,
|
|
224
|
+
maxTop: 0,
|
|
225
|
+
};
|
|
226
|
+
let dragOffset: number | undefined;
|
|
227
|
+
// Stock widget Containers are opaque to native layout. Draw and route this
|
|
228
|
+
// viewport locally, retaining the public ScrollView's follow/scroll state.
|
|
229
|
+
return {
|
|
230
|
+
scrollView,
|
|
231
|
+
render(width: number) {
|
|
232
|
+
width = Math.max(0, Math.floor(width));
|
|
233
|
+
const height = Math.max(0, Math.floor(options.getHeight()) - 2);
|
|
234
|
+
const contentWidth = Math.max(0, width - 1);
|
|
235
|
+
const lines = body.render(Math.max(1, contentWidth));
|
|
236
|
+
scrollView.updateLayout(lines.length, height, options.requestRender);
|
|
237
|
+
const visible = lines.slice(
|
|
238
|
+
scrollView.scrollTop,
|
|
239
|
+
scrollView.scrollTop + height,
|
|
240
|
+
);
|
|
241
|
+
const thumbHeight =
|
|
242
|
+
height === 0
|
|
243
|
+
? 0
|
|
244
|
+
: Math.max(
|
|
245
|
+
1,
|
|
246
|
+
Math.min(
|
|
247
|
+
height,
|
|
248
|
+
Math.round((height * height) / Math.max(1, lines.length)),
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
const maxTop = Math.max(0, lines.length - height);
|
|
252
|
+
const thumbTop =
|
|
253
|
+
maxTop === 0
|
|
254
|
+
? 0
|
|
255
|
+
: Math.round(
|
|
256
|
+
(scrollView.scrollTop / maxTop) * (height - thumbHeight),
|
|
257
|
+
);
|
|
258
|
+
geometry = { width, height, thumbTop, thumbHeight, maxTop };
|
|
259
|
+
return [
|
|
260
|
+
...heading.render(width),
|
|
261
|
+
...Array.from({ length: height }, (_, row) => {
|
|
262
|
+
if (width === 0) return "";
|
|
263
|
+
const text = truncateToWidth(visible[row] ?? "", contentWidth);
|
|
264
|
+
const thumb = row >= thumbTop && row < thumbTop + thumbHeight;
|
|
265
|
+
return (
|
|
266
|
+
text +
|
|
267
|
+
" ".repeat(Math.max(0, contentWidth - visibleWidth(text))) +
|
|
268
|
+
options
|
|
269
|
+
.getTheme()
|
|
270
|
+
.fg(
|
|
271
|
+
thumb ? "scrollbarThumb" : "scrollbarTrack",
|
|
272
|
+
thumb ? (dragOffset === undefined ? "┃" : "█") : "│",
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
}),
|
|
276
|
+
...footer.render(width),
|
|
277
|
+
];
|
|
278
|
+
},
|
|
279
|
+
handleMouse(e: TuiMouseEvent) {
|
|
280
|
+
if (dragOffset !== undefined) {
|
|
281
|
+
if (e.type === "release") {
|
|
282
|
+
dragOffset = undefined;
|
|
283
|
+
return { handled: true, render: true };
|
|
284
|
+
}
|
|
285
|
+
if (e.type === "drag" || e.type === "move") {
|
|
286
|
+
const travel = geometry.height - geometry.thumbHeight;
|
|
287
|
+
const offset = Math.max(0, Math.min(travel, e.y - 1 - dragOffset));
|
|
288
|
+
scrollView.scrollTo(
|
|
289
|
+
travel > 0
|
|
290
|
+
? Math.round((offset / travel) * geometry.maxTop)
|
|
291
|
+
: scrollView.scrollTop,
|
|
292
|
+
);
|
|
293
|
+
return { handled: true, render: true };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (e.y === 0) return heading.handleMouse(e);
|
|
297
|
+
if (e.y < 1 || e.y > geometry.height || e.x < 0 || e.x >= geometry.width)
|
|
298
|
+
return;
|
|
299
|
+
if (e.type === "wheel") {
|
|
300
|
+
scrollView.scrollBy(e.wheelDelta ?? 0);
|
|
301
|
+
return { handled: true, render: true };
|
|
302
|
+
}
|
|
303
|
+
if (e.x === geometry.width - 1 && e.button === "left") {
|
|
304
|
+
if (e.type === "press") {
|
|
305
|
+
const row = e.y - 1;
|
|
306
|
+
if (
|
|
307
|
+
row >= geometry.thumbTop &&
|
|
308
|
+
row < geometry.thumbTop + geometry.thumbHeight
|
|
309
|
+
) {
|
|
310
|
+
dragOffset = row - geometry.thumbTop;
|
|
311
|
+
return { handled: true, capture: true, render: true };
|
|
312
|
+
}
|
|
313
|
+
scrollView.scrollBy(
|
|
314
|
+
row < geometry.thumbTop ? -geometry.height : geometry.height,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
if (["press", "click", "release"].includes(e.type))
|
|
318
|
+
return { handled: true, render: true };
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
invalidate() {
|
|
322
|
+
body.invalidate();
|
|
323
|
+
},
|
|
324
|
+
update(nextHeader: string, logText: string, agent: boolean, context = "") {
|
|
325
|
+
header = nextHeader;
|
|
326
|
+
if (raw !== logText || isAgent !== agent || description !== context) {
|
|
327
|
+
raw = logText;
|
|
328
|
+
isAgent = agent;
|
|
329
|
+
description = context;
|
|
330
|
+
body.invalidate();
|
|
331
|
+
}
|
|
332
|
+
options.requestRender();
|
|
333
|
+
},
|
|
334
|
+
handleKey(data: string): boolean {
|
|
335
|
+
if (matchesKey(data, Key.escape)) {
|
|
336
|
+
options.onClose();
|
|
337
|
+
return true;
|
|
338
|
+
}
|
|
339
|
+
if (matchesKey(data, Key.up)) scrollView.scrollBy(-1);
|
|
340
|
+
else if (matchesKey(data, Key.down)) scrollView.scrollBy(1);
|
|
341
|
+
else return false;
|
|
342
|
+
options.requestRender();
|
|
343
|
+
return true;
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yusukeshib/pi-babysit",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.15",
|
|
4
4
|
"description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"type": "module",
|
|
21
21
|
"files": [
|
|
22
22
|
"index.ts",
|
|
23
|
+
"log-viewer.ts",
|
|
23
24
|
"agents.ts",
|
|
24
25
|
"self-reap.ts",
|
|
25
26
|
"format-stream.mjs",
|
|
@@ -39,8 +40,8 @@
|
|
|
39
40
|
},
|
|
40
41
|
"peerDependencies": {
|
|
41
42
|
"@earendil-works/pi-ai": "*",
|
|
42
|
-
"@earendil-works/pi-coding-agent": "
|
|
43
|
-
"@earendil-works/pi-tui": "
|
|
43
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
44
|
+
"@earendil-works/pi-tui": ">=1.0.0",
|
|
44
45
|
"typebox": "*"
|
|
45
46
|
}
|
|
46
47
|
}
|