@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.
Files changed (4) hide show
  1. package/README.md +19 -14
  2. package/index.ts +117 -57
  3. package/log-viewer.ts +346 -0
  4. 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. Collapsed rows occupy one line, previewing the command for
89
- processes and the task for agents (with `…` when clipped). Click a row for the
90
- complete wrapped command or task; agent rows also show their RPC worker launch
91
- argv (bold, readable text rather than a copy-paste shell command), and up to 50
92
- output lines. Agent output retains recent RPC text, tool calls, and tool results
93
- in chronological order instead of showing only the latest message. Blank lines
94
- and surrounding whitespace are preserved. Expanded output lines wrap without a
95
- 100-character cutoff.
96
- Opening another row closes the previous
97
- one. A session with no available output shows an explicit placeholder when
98
- expanded. Mouse clicks
99
- require fullscreen mode; `/babysit` works in regular mode. The displayed `age`
100
- is total session age, not time spent
101
- in the current state. Transcript details
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, wrapTextWithAnsi } from "@earendil-works/pi-tui";
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
- expandable: boolean;
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
- * Build the live widget as a mouse-aware component without depending on newer
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
- expandedSessionIds: Set<string>,
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 expanded = session.expandable && expandedSessionIds.has(session.id);
2452
- const tail = session.tail.slice(
2453
- expanded ? -WIDGET_EXPANDED_TAIL_LINES : -WIDGET_COLLAPSED_TAIL_LINES,
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
- if (expandedSessionIds.has(target.id)) expandedSessionIds.clear();
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
- const expandedWidgetSessions = new Set<string>();
3502
- const widgetListState: WidgetSessionListState = { mode: "running", page: 0 };
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
- WIDGET_EXPANDED_TAIL_LINES,
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
- expandable: true,
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 the five visible sessions on demand.
3610
+ // Only read/parse visible sessions on demand.
3570
3611
  get tail() {
3571
3612
  try {
3572
- return widgetTail(id, isSubagent, undefined, WIDGET_EXPANDED_TAIL_LINES);
3613
+ return widgetTail(id, isSubagent, undefined, WIDGET_COLLAPSED_TAIL_LINES);
3573
3614
  } catch {
3574
3615
  return [];
3575
3616
  }
3576
3617
  },
3577
- expandable: true,
3618
+ viewable: true,
3578
3619
  };
3579
3620
  });
3580
3621
 
3581
3622
  if (ctx.mode === "tui") {
3582
- ctx.ui.setWidget(
3583
- "pi-babysit",
3584
- () => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, widgetListState),
3585
- { placement: "belowEditor" },
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)), new Set());
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, or `/babysit gc [days]` to remove old terminal roots",
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 command = args.trim();
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.14",
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
  }