@yusukeshib/pi-babysit 0.6.14 → 0.6.16

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 +24 -14
  2. package/index.ts +166 -57
  3. package/log-viewer.ts +371 -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,29 @@ 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
+ When babysit provides `output.timestamps.jsonl`, process lines begin with a muted
102
+ local `[YYYY-MM-DD HH:mm:ss]` capture time, with wrapped text aligned beneath the
103
+ message. This is the time babysit received the first byte of the line, not an
104
+ application event time. Narrow views place the date above the text. Old logs and
105
+ lines without capture metadata remain readable without invented timestamps.
106
+ Only the selected log is loaded, bounded to the latest 1 MiB / 2,000 display
107
+ lines with an omission notice; the full log file is unchanged. Empty logs show
108
+ a placeholder. Works in the released Pi 1.0 widget host. Mouse interaction
109
+ requires fullscreen mode; `/babysit view` opens the viewer using a keyboard
110
+ picker, and `/babysit` still provides a snapshot. The displayed `age` is total
111
+ session age, not time spent in the current state. Transcript details
102
112
  from tool results, snapshots, and completion notifications collapse or expand
103
113
  with Pi's tool-output toggle (`Ctrl+O` by default).
104
114
 
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,124 @@ 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; lineTimestamps?: (number | undefined)[] };
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
+ log.lineTimestamps,
2543
+ );
2544
+ }
2545
+ function close() {
2546
+ selectedId = undefined;
2547
+ viewer = undefined;
2548
+ options.requestRender();
2549
+ }
2550
+ function open(id: string) {
2551
+ selectedId = id;
2552
+ viewer = createLogViewer({ ...options, onClose: close });
2553
+ refreshViewer();
2554
+ options.requestRender();
2555
+ }
2556
+ return {
2557
+ open,
2558
+ update(summary: string[], sessions: WidgetSessionDisplay[]) {
2559
+ displays = sessions;
2560
+ list = createWidgetComponent(summary, sessions, open, listState);
2561
+ refreshViewer();
2562
+ options.requestRender();
2563
+ },
2564
+ render(width: number) { return (viewer ?? list).render(width); },
2565
+ handleMouse(event: TuiMouseEvent) {
2566
+ // Stock Pi widget hosts forward mouse input to this component, not
2567
+ // to a nested native layout tree. Keep the full wheel/drag event.
2568
+ return (viewer ?? list).handleMouse(event);
2569
+ },
2570
+ handleKey(data: string) { return viewer?.handleKey(data) ?? false; },
2571
+ invalidate() { list.invalidate(); viewer?.invalidate(); },
2572
+ };
2573
+ }
2574
+
2575
+ /** Capture times are optional: old logs and incomplete sidecars remain readable. */
2576
+ function readLineTimestamps(file: string, data: Buffer, offset: number): (number | undefined)[] {
2577
+ const metadata = readViewerLog(path.join(path.dirname(file), "output.timestamps.jsonl")).text;
2578
+ const ranges: { start: number; end: number; time: number }[] = [];
2579
+ for (const line of metadata.split("\n")) {
2580
+ try {
2581
+ const record = JSON.parse(line);
2582
+ if (!Array.isArray(record) || record.length !== 3) continue;
2583
+ const [start, length, time] = record;
2584
+ if (!Number.isSafeInteger(start) || start < 0 ||
2585
+ !Number.isSafeInteger(length) || length <= 0 ||
2586
+ !Number.isSafeInteger(start + length) ||
2587
+ !Number.isSafeInteger(time) || time < 0 || time > 8640000000000000 ||
2588
+ start < (ranges.at(-1)?.end ?? 0)) continue;
2589
+ ranges.push({ start, end: start + length, time });
2590
+ } catch { /* A partial or malformed record has no trustworthy time. */ }
2591
+ }
2592
+ let range = 0;
2593
+ const timestamps: (number | undefined)[] = [];
2594
+ const addLine = (position: number) => {
2595
+ const absolute = offset + position;
2596
+ while (range < ranges.length && ranges[range].end <= absolute) range++;
2597
+ const entry = ranges[range];
2598
+ timestamps.push(position < data.length && entry && entry.start <= absolute ? entry.time : undefined);
2599
+ };
2600
+ addLine(0);
2601
+ for (let i = 0; i < data.length; i++) {
2602
+ if (data[i] !== 10 && data[i] !== 13) continue;
2603
+ if (data[i] === 13 && data[i + 1] === 10) i++;
2604
+ addLine(i + 1);
2605
+ }
2606
+ return timestamps;
2607
+ }
2608
+
2609
+ /** Only the selected session is read; bounded snapshots never affect worker logs. */
2610
+ export function readViewerLog(file: string, withTimestamps = false): {
2611
+ text: string; clipped: boolean; lineTimestamps?: (number | undefined)[];
2612
+ } {
2613
+ try {
2614
+ const fd = fs.openSync(file, "r");
2615
+ try {
2616
+ const size = fs.fstatSync(fd).size;
2617
+ const start = Math.max(0, size - 1024 * 1024);
2618
+ const buffer = Buffer.alloc(size - start);
2619
+ const bytes = fs.readSync(fd, buffer, 0, buffer.length, start);
2620
+ let data = buffer.subarray(0, bytes);
2621
+ let offset = start;
2622
+ if (start > 0) {
2623
+ const skip = data.indexOf(10) + 1;
2624
+ data = data.subarray(skip);
2625
+ offset += skip;
2626
+ }
2627
+ const text = data.toString("utf8");
2628
+ return {
2629
+ text, clipped: start > 0,
2630
+ ...(withTimestamps ? { lineTimestamps: readLineTimestamps(file, data, offset) } : {}),
2631
+ };
2632
+ } finally { fs.closeSync(fd); }
2633
+ } catch {
2634
+ return { text: "", clipped: false };
2635
+ }
2636
+ }
2637
+
2547
2638
  // Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
2548
2639
  // corrupt the widget area.
2549
2640
  function sanitizeTailLine(s: string): string {
@@ -3498,8 +3589,8 @@ export default function (pi: ExtensionAPI) {
3498
3589
  }
3499
3590
  }
3500
3591
 
3501
- const expandedWidgetSessions = new Set<string>();
3502
- const widgetListState: WidgetSessionListState = { mode: "running", page: 0 };
3592
+ let sessionWidget: ReturnType<typeof createSessionWidget> | undefined;
3593
+ let unsubscribeWidgetInput: (() => void) | undefined;
3503
3594
 
3504
3595
  const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
3505
3596
  if (!ctx.hasUI) return;
@@ -3516,10 +3607,6 @@ export default function (pi: ExtensionAPI) {
3516
3607
  }
3517
3608
  }
3518
3609
  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
3610
 
3524
3611
  const theme = ctx.ui.theme;
3525
3612
  const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
@@ -3530,7 +3617,7 @@ export default function (pi: ExtensionAPI) {
3530
3617
  session.id,
3531
3618
  isSubagent,
3532
3619
  isSubagent ? progressById.get(session.id) : undefined,
3533
- WIDGET_EXPANDED_TAIL_LINES,
3620
+ WIDGET_COLLAPSED_TAIL_LINES,
3534
3621
  );
3535
3622
  }),
3536
3623
  );
@@ -3551,7 +3638,7 @@ export default function (pi: ExtensionAPI) {
3551
3638
  id,
3552
3639
  header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme),
3553
3640
  tail: activeTails.get(id) ?? [],
3554
- expandable: true,
3641
+ viewable: true,
3555
3642
  active: true,
3556
3643
  details,
3557
3644
  };
@@ -3566,27 +3653,40 @@ export default function (pi: ExtensionAPI) {
3566
3653
  theme,
3567
3654
  ),
3568
3655
  details,
3569
- // Only read/parse the five visible sessions on demand.
3656
+ // Only read/parse visible sessions on demand.
3570
3657
  get tail() {
3571
3658
  try {
3572
- return widgetTail(id, isSubagent, undefined, WIDGET_EXPANDED_TAIL_LINES);
3659
+ return widgetTail(id, isSubagent, undefined, WIDGET_COLLAPSED_TAIL_LINES);
3573
3660
  } catch {
3574
3661
  return [];
3575
3662
  }
3576
3663
  },
3577
- expandable: true,
3664
+ viewable: true,
3578
3665
  };
3579
3666
  });
3580
3667
 
3581
3668
  if (ctx.mode === "tui") {
3582
- ctx.ui.setWidget(
3583
- "pi-babysit",
3584
- () => createWidgetComponent(summaryLines, displays, expandedWidgetSessions, widgetListState),
3585
- { placement: "belowEditor" },
3586
- );
3669
+ if (!sessionWidget) {
3670
+ ctx.ui.setWidget("pi-babysit", (tui) => {
3671
+ sessionWidget = createSessionWidget({
3672
+ // Keep the viewer bounded while leaving room for editor/status/footer.
3673
+ getHeight: () => Math.max(3, tui.terminal.rows - 8),
3674
+ requestRender: () => tui.requestRender(),
3675
+ getTheme: () => ctx.ui.theme,
3676
+ readLog: (id) => {
3677
+ const isAgent = kindOf(id) === "subagent";
3678
+ return { ...readViewerLog(logPath(id), !isAgent), isAgent };
3679
+ },
3680
+ });
3681
+ return sessionWidget;
3682
+ }, { placement: "belowEditor" });
3683
+ unsubscribeWidgetInput = ctx.ui.onTerminalInput((data) =>
3684
+ sessionWidget?.handleKey(data) ? { consume: true } : undefined);
3685
+ }
3686
+ sessionWidget?.update(summaryLines, displays);
3587
3687
  } else {
3588
3688
  const activeIds = new Set(active.map((session) => session.id));
3589
- const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), new Set());
3689
+ const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), () => {});
3590
3690
  ctx.ui.setWidget("pi-babysit", component.render(10_000), {
3591
3691
  placement: "belowEditor",
3592
3692
  });
@@ -3768,6 +3868,9 @@ export default function (pi: ExtensionAPI) {
3768
3868
  });
3769
3869
 
3770
3870
  pi.on("session_shutdown", async (event) => {
3871
+ unsubscribeWidgetInput?.();
3872
+ unsubscribeWidgetInput = undefined;
3873
+ sessionWidget = undefined;
3771
3874
  if (pollTimer) clearInterval(pollTimer);
3772
3875
  pollTimer = undefined;
3773
3876
  // Pi also emits session_shutdown while hot-reloading and switching sessions.
@@ -5050,9 +5153,10 @@ export default function (pi: ExtensionAPI) {
5050
5153
  // output + a copy-paste `babysit attach` take-over hint; running subagent →
5051
5154
  // read-only progress; finished → summary. Re-run to refresh.
5052
5155
  pi.registerCommand("babysit", {
5053
- description: "Pick a session to inspect, or `/babysit gc [days]` to remove old terminal roots",
5156
+ description: "Pick a session to inspect; /babysit view opens its log viewer; /babysit gc [days] removes old terminal roots",
5054
5157
  handler: async (args, ctx) => {
5055
- const command = args.trim();
5158
+ const view = args.trim() === "view";
5159
+ const command = view ? "" : args.trim();
5056
5160
  if (command === "gc" || command.startsWith("gc ")) {
5057
5161
  const daysText = command.slice(2).trim();
5058
5162
  const days = daysText ? Number(daysText) : 14;
@@ -5087,7 +5191,7 @@ export default function (pi: ExtensionAPI) {
5087
5191
  return;
5088
5192
  }
5089
5193
  if (command) {
5090
- ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
5194
+ ctx.ui.notify("Usage: /babysit, /babysit view, or /babysit gc [days]", "error");
5091
5195
  return;
5092
5196
  }
5093
5197
  if (!(await babysitAvailable())) {
@@ -5124,6 +5228,11 @@ export default function (pi: ExtensionAPI) {
5124
5228
  if (!choice) return;
5125
5229
  const picked = sessions[labels.indexOf(choice)];
5126
5230
  if (!picked) return;
5231
+ if (view && ctx.mode === "tui") {
5232
+ await refreshWidget(ctx);
5233
+ sessionWidget?.open(picked.id);
5234
+ return;
5235
+ }
5127
5236
  const kind = kindOf(picked.id);
5128
5237
  const elapsed = picked.state === "running" ? elapsedOf(picked.id) : null;
5129
5238
  const elapsedSuffix = elapsed ? ` ${elapsed}` : "";
package/log-viewer.ts ADDED
@@ -0,0 +1,371 @@
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
+ function dateLabel(timestamp: number): string {
122
+ const date = new Date(timestamp);
123
+ const pad = (n: number) => String(n).padStart(2, "0");
124
+ return `[${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}]`;
125
+ }
126
+
127
+ export function createLogViewer(options: LogViewerOptions) {
128
+ let header = "",
129
+ raw = "",
130
+ description = "",
131
+ isAgent = false;
132
+ let lineTimestamps: (number | undefined)[] | undefined;
133
+ let cache: string[] | undefined,
134
+ cacheWidth = -1,
135
+ cacheTheme: Theme | undefined;
136
+ const body: Component = {
137
+ invalidate() {
138
+ cache = undefined;
139
+ },
140
+ render(width) {
141
+ const theme = options.getTheme();
142
+ if (cache && cacheWidth === width && cacheTheme === theme) return cache;
143
+ const blocks = isAgent
144
+ ? agentBlocks(raw)
145
+ : [{ kind: "result" as const, text: sanitize(raw, true) }];
146
+ const lines: string[] = description
147
+ ? [
148
+ theme.fg("muted", isAgent ? "[task]" : "[command]"),
149
+ ...new Text(sanitize(description), 0, 0).render(width),
150
+ "",
151
+ ]
152
+ : [];
153
+ for (const b of blocks) {
154
+ if (!isAgent && lineTimestamps?.some((time) => time !== undefined)) {
155
+ for (const [index, text] of raw.split(/\r\n|\r|\n/).entries()) {
156
+ const time = lineTimestamps[index];
157
+ const label = time === undefined ? "" : dateLabel(time);
158
+ const indent = label ? visibleWidth(label) + 1 : 0;
159
+ if (indent && width <= indent + 4) {
160
+ lines.push(...new Text(theme.fg("muted", label), 0, 0).render(width));
161
+ lines.push(...new Text(sanitize(text, true), 0, 0).render(width));
162
+ } else {
163
+ const wrapped = new Text(sanitize(text, true), 0, 0).render(width - indent);
164
+ lines.push(...wrapped.map((line, row) =>
165
+ (row === 0 && label ? theme.fg("muted", label) + " " : " ".repeat(indent)) + line));
166
+ }
167
+ }
168
+ continue;
169
+ }
170
+ if (isAgent)
171
+ lines.push(
172
+ theme.fg(
173
+ b.kind === "error"
174
+ ? "error"
175
+ : b.kind === "assistant"
176
+ ? "accent"
177
+ : "toolTitle",
178
+ `[${b.kind}]`,
179
+ ),
180
+ );
181
+ lines.push(
182
+ ...(b.kind === "assistant"
183
+ ? new Markdown(b.text, 0, 0, getMarkdownTheme()).render(width)
184
+ : new Text(b.text, 0, 0).render(width)),
185
+ );
186
+ }
187
+ if (!blocks.some((block) => block.text.trim()))
188
+ lines.push(theme.fg("muted", "(no output available)"));
189
+ cache =
190
+ lines.length > 2000
191
+ ? [
192
+ theme.fg("warning", "[Earlier log lines clipped]"),
193
+ ...lines.slice(-1999),
194
+ ]
195
+ : lines;
196
+ cacheWidth = width;
197
+ cacheTheme = theme;
198
+ return cache;
199
+ },
200
+ };
201
+ const scrollView = new ScrollView(body, {
202
+ follow: "end",
203
+ overscroll: "contain",
204
+ scrollbar: "always",
205
+ scrollbarTrackStyle: (s) => options.getTheme().fg("scrollbarTrack", s),
206
+ scrollbarThumbStyle: (s) => options.getTheme().fg("scrollbarThumb", s),
207
+ });
208
+ const heading = {
209
+ invalidate() {},
210
+ render(width: number) {
211
+ const close = truncateToWidth("[Close]", width);
212
+ const room = Math.max(0, width - visibleWidth(close) - 1);
213
+ const title = truncateToWidth(sanitize(header).replace(/\n/g, " "), room);
214
+ return [
215
+ options.getTheme().fg("accent", title) +
216
+ " ".repeat(
217
+ Math.max(0, width - visibleWidth(title) - visibleWidth(close)),
218
+ ) +
219
+ options.getTheme().fg("muted", close),
220
+ ];
221
+ },
222
+ handleMouse(e: TuiMouseEvent) {
223
+ if (
224
+ e.button !== "left" ||
225
+ e.x < Math.max(0, e.width - 7) ||
226
+ e.x >= e.width
227
+ )
228
+ return;
229
+ if (e.type === "press") {
230
+ options.onClose();
231
+ return { handled: true };
232
+ }
233
+ if (e.type === "click" || e.type === "release") return { handled: true };
234
+ },
235
+ };
236
+ const footer: Component = {
237
+ invalidate() {},
238
+ render: (width) => [
239
+ options.getTheme().fg("dim", truncateToWidth("↑↓ · Esc close", width)),
240
+ ],
241
+ };
242
+ let geometry = {
243
+ width: 0,
244
+ height: 0,
245
+ thumbTop: 0,
246
+ thumbHeight: 0,
247
+ maxTop: 0,
248
+ };
249
+ let dragOffset: number | undefined;
250
+ // Stock widget Containers are opaque to native layout. Draw and route this
251
+ // viewport locally, retaining the public ScrollView's follow/scroll state.
252
+ return {
253
+ scrollView,
254
+ render(width: number) {
255
+ width = Math.max(0, Math.floor(width));
256
+ const height = Math.max(0, Math.floor(options.getHeight()) - 2);
257
+ const contentWidth = Math.max(0, width - 1);
258
+ const lines = body.render(Math.max(1, contentWidth));
259
+ scrollView.updateLayout(lines.length, height, options.requestRender);
260
+ const visible = lines.slice(
261
+ scrollView.scrollTop,
262
+ scrollView.scrollTop + height,
263
+ );
264
+ const thumbHeight =
265
+ height === 0
266
+ ? 0
267
+ : Math.max(
268
+ 1,
269
+ Math.min(
270
+ height,
271
+ Math.round((height * height) / Math.max(1, lines.length)),
272
+ ),
273
+ );
274
+ const maxTop = Math.max(0, lines.length - height);
275
+ const thumbTop =
276
+ maxTop === 0
277
+ ? 0
278
+ : Math.round(
279
+ (scrollView.scrollTop / maxTop) * (height - thumbHeight),
280
+ );
281
+ geometry = { width, height, thumbTop, thumbHeight, maxTop };
282
+ return [
283
+ ...heading.render(width),
284
+ ...Array.from({ length: height }, (_, row) => {
285
+ if (width === 0) return "";
286
+ const text = truncateToWidth(visible[row] ?? "", contentWidth);
287
+ const thumb = row >= thumbTop && row < thumbTop + thumbHeight;
288
+ return (
289
+ text +
290
+ " ".repeat(Math.max(0, contentWidth - visibleWidth(text))) +
291
+ options
292
+ .getTheme()
293
+ .fg(
294
+ thumb ? "scrollbarThumb" : "scrollbarTrack",
295
+ thumb ? (dragOffset === undefined ? "┃" : "█") : "│",
296
+ )
297
+ );
298
+ }),
299
+ ...footer.render(width),
300
+ ];
301
+ },
302
+ handleMouse(e: TuiMouseEvent) {
303
+ if (dragOffset !== undefined) {
304
+ if (e.type === "release") {
305
+ dragOffset = undefined;
306
+ return { handled: true, render: true };
307
+ }
308
+ if (e.type === "drag" || e.type === "move") {
309
+ const travel = geometry.height - geometry.thumbHeight;
310
+ const offset = Math.max(0, Math.min(travel, e.y - 1 - dragOffset));
311
+ scrollView.scrollTo(
312
+ travel > 0
313
+ ? Math.round((offset / travel) * geometry.maxTop)
314
+ : scrollView.scrollTop,
315
+ );
316
+ return { handled: true, render: true };
317
+ }
318
+ }
319
+ if (e.y === 0) return heading.handleMouse(e);
320
+ if (e.y < 1 || e.y > geometry.height || e.x < 0 || e.x >= geometry.width)
321
+ return;
322
+ if (e.type === "wheel") {
323
+ scrollView.scrollBy(e.wheelDelta ?? 0);
324
+ return { handled: true, render: true };
325
+ }
326
+ if (e.x === geometry.width - 1 && e.button === "left") {
327
+ if (e.type === "press") {
328
+ const row = e.y - 1;
329
+ if (
330
+ row >= geometry.thumbTop &&
331
+ row < geometry.thumbTop + geometry.thumbHeight
332
+ ) {
333
+ dragOffset = row - geometry.thumbTop;
334
+ return { handled: true, capture: true, render: true };
335
+ }
336
+ scrollView.scrollBy(
337
+ row < geometry.thumbTop ? -geometry.height : geometry.height,
338
+ );
339
+ }
340
+ if (["press", "click", "release"].includes(e.type))
341
+ return { handled: true, render: true };
342
+ }
343
+ },
344
+ invalidate() {
345
+ body.invalidate();
346
+ },
347
+ update(nextHeader: string, logText: string, agent: boolean, context = "", timestamps?: (number | undefined)[]) {
348
+ header = nextHeader;
349
+ if (raw !== logText || isAgent !== agent || description !== context ||
350
+ JSON.stringify(lineTimestamps) !== JSON.stringify(timestamps)) {
351
+ lineTimestamps = timestamps;
352
+ raw = logText;
353
+ isAgent = agent;
354
+ description = context;
355
+ body.invalidate();
356
+ }
357
+ options.requestRender();
358
+ },
359
+ handleKey(data: string): boolean {
360
+ if (matchesKey(data, Key.escape)) {
361
+ options.onClose();
362
+ return true;
363
+ }
364
+ if (matchesKey(data, Key.up)) scrollView.scrollBy(-1);
365
+ else if (matchesKey(data, Key.down)) scrollView.scrollBy(1);
366
+ else return false;
367
+ options.requestRender();
368
+ return true;
369
+ },
370
+ };
371
+ }
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.16",
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
  }