Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@
An owning checkout's lock file that exists and cannot be parsed no longer
reads as absent: unknown ownership is not permission to clear another
checkout's recovery marker (VST-258).
- pi-agents-tmux: the Agents popup Transcript tab is an event timeline
(paired tool rows, capped previews, `✖`-marked failures, line-boundary tail
with a dropped-events note) instead of raw JSONL, and `e` opens the raw
file in `$VISUAL`/`$EDITOR` (VST-327).

- pi-agents-tmux: Monitor tree task rows show elapsed/total run-time instead
of a jumpy local `HH:MM` clock (`updatedAt` is no longer a time source);
detail-pane timestamps render local human time instead of UTC ISO, and the
Expand Down
4 changes: 4 additions & 0 deletions pi-extensions/pi-agents-tmux/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Consumer-impacting changes

### 2.8.4

- The Agents popup Transcript tab renders an event timeline instead of raw JSONL (vstack VST-327). One row per event — elapsed stamp, kind, capped one-line detail — covering input, assistant text/thinking previews, turn boundaries, and lifecycle records; a tool call collapses into a single row pairing start with result (name, primary argument, status, duration, result size), a tool-call-only assistant message renders as its calls, and errors/aborts/non-zero exits are `✖`-marked rows. No event type falls through to a raw JSONL line: unrecognized types render as their type and size. The tail read grew 24 KB → 256 KB, is byte-based and streaming (only the final window is materialized; the dropped prefix is newline-counted through a fixed buffer), cuts on a line boundary only, and states how many earlier events were dropped. Decoded text is scrubbed of C0/C1 terminal controls before rendering — JSON.parse would otherwise revive escaped OSC/CSI sequences the raw view kept inert. Native pane-session `message` records render as conversation content, and only trouble-shaped lifecycle records (`abort_*`, `*_failed`, escalations) carry the failure tone. New `e` key in the trace viewer opens the item's file in `$VISUAL`/`$EDITOR` (own tmux window), listed in the footer hint.

### 2.8.3

- Agents popup times are readable run-times, not machine stamps (vstack VST-316). Monitor tree task rows show elapsed run-time — `createdAt` → now while active, `createdAt` → `completedAt` once terminal — instead of a bare local `HH:MM` clock that was ambiguous (duration vs wall time) and jumped whenever a registry poll refreshed `updatedAt`; `updatedAt` is no longer a time-of-day source anywhere in the tree. Detail panes (Session Start/Latest, Task Summary Created/Done) render local human time (`Mar 24, 16:59`) instead of UTC ISO, and the Task Summary gains a Duration line once the task is terminal (the Summary text is cached until status changes, so a live elapsed there would freeze). Running elapsed is minute-granular (`<1m` under a minute) and keeps ticking even with spinner animation disabled — the popup timer now re-renders on a slow cadence while any task is live.
Expand Down
1 change: 1 addition & 0 deletions pi-extensions/pi-agents-tmux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Delegate work to specialized agents from a running Pi session. Agents run either
- Monitor groups tasks by session (pane, bg lane, bg one-shot) under expandable Active and Completed sections, with active sessions first and newest invocations first inside each section; repeated same-agent launches get session numbers and task numbers reset per session. Steering/follow-up delivery mode is shown in expanded rows and trace metadata.
- Chat completion rows show actual results, never a repeat of the original request.
- Task detail shows Summary and Completion tabs; Summary contains task metadata, artifacts, and task text, while Completion contains result summary, files changed, and validation.
- The Transcript tab renders an event timeline — one row per event with elapsed stamp, kind, and a capped one-line detail; tool calls collapse into a single row (name, primary argument, status, duration, result size), failures are `✖`-marked, and a budget-cut tail states how many earlier events are not shown. `e` in the trace viewer opens the raw transcript file in `$VISUAL`/`$EDITOR` (own tmux window).
- Bg one-shot transcripts keep enough history to inspect results without creating oversized session files.
- When a task needs manual completion, result views include useful repository context when available.
- Dashboard widget shows live state, turns, input/output/reasoning tokens, and cost for every spawned agent; working agents stay above attention/completed agents, newest invocations lead each bucket, and activity updates or completion polling do not reshuffle rows. Once you hide it, lifecycle updates do not reopen it until you toggle it back in.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
formatUsageStats,
highlightInlinePreview,
} from "../format.js";
import { readTextFileIfExists, recordTraceRef } from "../renderers.js";
import { readTextFileIfExists, readTranscriptTail, recordTraceRef } from "../renderers.js";
import { monitorStatusIsTerminal } from "../task-records.js";
import { formatTranscriptForDisplay, inputDeliveryLabel } from "../transcripts.js";
import { formatTranscriptForDisplay } from "../transcript-timeline.js";
import { inputDeliveryLabel } from "../transcripts.js";
import {
MONITOR_SUBTAB_LABELS,
type AgentBrowserUiState,
Expand Down Expand Up @@ -77,6 +78,17 @@ export function renderTraceContentLine(raw: string, type: TraceViewerItem["type"
const line = raw.replace(/\t/g, " ");
const trimmed = line.trim();
if (!trimmed) return [""];
// Transcript timeline rows: `[elapsed] kind · detail`, `✖`-marked when the
// event is an error/abort so a failed run stands out without scrolling.
const timeline = line.match(/^([ ✖])\[([^\]]+)\] (.*)$/);
if (timeline) {
const [, mark, stamp, rest] = timeline;
if (mark === "✖") return wrapTextWithAnsi(theme.fg("error", `✖ [${stamp}] ${rest}`), width);
const sep = rest!.indexOf(" · ");
const kind = sep === -1 ? rest! : rest!.slice(0, sep);
const detail = sep === -1 ? "" : rest!.slice(sep);
return wrapTextWithAnsi(`${theme.fg("dim", `[${stamp}]`)} ${theme.fg("accent", theme.bold(kind))}${theme.fg("toolOutput", detail)}`, width);
}
if (/^── .+ ──$/.test(trimmed)) return wrapTextWithAnsi(theme.fg("muted", trimmed.replace(/(input|assistant|user|tool call|tool start|tool end|turn start|turn end|agent end|exit)/i, (match) => theme.fg("accent", theme.bold(match)))), width);
if (/^-{3,}$/.test(trimmed)) return [];
if (/^(Overview|Metadata|Summary|Files changed|Validation|Notes|Task|Artifacts|Session|Task list|System Prompt)$/i.test(trimmed)) {
Expand Down Expand Up @@ -258,9 +270,9 @@ export async function traceViewerItems(record: PaneTaskRecord, taskNumber?: numb
...completionJsonSection,
].filter(Boolean).join("\n");
const common = { agent: record.agent, createdAt: record.completedAt ?? record.createdAt, ref, status: record.status, summary: summaryText };
const transcript = await readTextFileIfExists(record.transcriptPath, 24_000);
const transcript = await readTranscriptTail(record.transcriptPath);
const transcriptItem = record.transcriptPath
? [{ ...common, label: "Transcript", path: record.transcriptPath, text: transcript ? formatTranscriptForDisplay(transcript) : "Transcript file could not be read.", type: "transcript" as const }]
? [{ ...common, label: "Transcript", path: record.transcriptPath, text: transcript ? formatTranscriptForDisplay(transcript.text, { droppedEvents: transcript.droppedLines, originTs: transcript.originTs, taskTerminal: monitorStatusIsTerminal(record.status) }) : "Transcript file could not be read.", type: "transcript" as const }]
: [];
return [
{ ...common, label: "Summary", text: summary, type: "summary" as const },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { spawn } from "node:child_process";
import { type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
import { ansiYellow, compactPath, divider, simpleFrame } from "../format.js";
import { shellQuote } from "../names.js";
import {
TRACE_VIEWER_MAX_HEIGHT,
TRACE_VIEWER_WIDTH,
Expand All @@ -13,7 +15,7 @@ function traceViewerLines(state: TraceViewerState, width: number, rows: number,
const innerWidth = Math.max(1, width - 4);
const frameRows = Math.max(8, rows);
const item = state.items[state.selected] ?? state.items[0];
const help = `${ansiYellow("tab/←→")} ${theme.fg("dim", "sections · ")}${ansiYellow("-/=")} ${theme.fg("dim", "page")}`;
const help = `${ansiYellow("tab/←→")} ${theme.fg("dim", "sections · ")}${ansiYellow("-/=")} ${theme.fg("dim", "page")}${item?.path ? `${theme.fg("dim", " · ")}${ansiYellow("e")} ${theme.fg("dim", "editor")}` : ""}`;
Comment thread
bmethod marked this conversation as resolved.
const tabs = renderTraceTabBar(state.items, state.selected, innerWidth, theme);
const meta = [
item?.ref ? theme.fg("accent", item.ref) : "",
Expand Down Expand Up @@ -48,6 +50,27 @@ function traceViewerLines(state: TraceViewerState, width: number, rows: number,
return simpleFrame(innerLines.slice(0, frameRows - 2), width, theme, state.title);
}

/**
* Open the item's backing file in $VISUAL/$EDITOR. The popup owns the
* terminal, so the editor gets its own tmux window; outside tmux (or with no
* editor configured) the path is surfaced instead of fighting over the tty.
*/
export function openTraceItemInEditor(ctx: ExtensionContext, path: string | undefined): void {
if (!path) return;
const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim();
if (!editor) {
ctx.ui.notify(`Set $VISUAL or $EDITOR to open ${path}`, "warning");
return;
}
if (!process.env.TMUX) {
ctx.ui.notify(`Not inside tmux — open manually: ${editor} ${path}`, "warning");
return;
}
const child = spawn("tmux", ["new-window", "-n", "transcript", `${editor} ${shellQuote(path)}`], { detached: true, stdio: "ignore" });
Comment thread
bmethod marked this conversation as resolved.
child.on("error", (error) => ctx.ui.notify(`Could not open editor window: ${error instanceof Error ? error.message : String(error)}`, "error"));
child.unref();
}
Comment thread
bmethod marked this conversation as resolved.

export async function openTraceViewer(ctx: ExtensionContext, title: string, items: TraceViewerItem[]): Promise<void> {
if (!ctx.hasUI) {
ctx.ui.notify(title, "info");
Expand All @@ -63,6 +86,7 @@ export async function openTraceViewer(ctx: ExtensionContext, title: string, item
if (matchesKey(data, "down")) { state.scroll += 1; tui.requestRender(); return; }
if (matchesKey(data, "-") || matchesKey(data, "pageup" as any) || matchesKey(data, "page_up" as any)) { state.scroll = Math.max(0, state.scroll - tracePageRows); tui.requestRender(); return; }
if (matchesKey(data, "=") || matchesKey(data, "pagedown" as any) || matchesKey(data, "page_down" as any)) { state.scroll += tracePageRows; tui.requestRender(); return; }
if (matchesKey(data, "e")) { openTraceItemInEditor(ctx, (state.items[state.selected] ?? state.items[0])?.path); return; }
if (matchesKey(data, "left")) { state.selected = (state.selected + state.items.length - 1) % state.items.length; state.scroll = 0; tui.requestRender(); return; }
if (matchesKey(data, "right") || matchesKey(data, "tab")) { state.selected = (state.selected + 1) % state.items.length; state.scroll = 0; tui.requestRender(); return; }
},
Expand Down
70 changes: 70 additions & 0 deletions pi-extensions/pi-agents-tmux/extensions/subagent/renderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,76 @@ export async function readTextFileIfExists(filePath: string | undefined, maxByte
}
}

/**
* Tail-read a JSONL transcript on a byte budget without ever cutting
* mid-record: only the final window is materialized (the dropped prefix is
* newline-counted through a small reusable buffer, never held in memory),
* the cut lands on a line boundary, and the dropped-event count lets the
* caller say what is not shown instead of rendering a leading fragment.
*/
export async function readTranscriptTail(filePath: string | undefined, maxBytes = 256_000): Promise<{ text: string; droppedLines: number; originTs?: unknown } | undefined> {
if (!filePath) return undefined;
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(filePath, "r");
const { size } = await handle.stat();
if (size <= maxBytes) return { droppedLines: 0, text: (await handle.readFile()).toString("utf-8") };
const start = size - maxBytes;
let droppedLines = 0;
// The first record's stamp survives the cut so elapsed times keep the
// session's real origin instead of restarting at the tail.
let originTs: unknown;
const chunk = Buffer.alloc(64 * 1024);
// The first record is read to ITS OWN newline — which can sit past the
// cut offset when the record (it embeds the full task prompt) is longer
// than the whole dropped prefix — bounded at 1MB.
{
let firstLine = Buffer.alloc(0);
for (let position = 0; position < size && firstLine.length <= 1024 * 1024; ) {
const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
if (bytesRead <= 0) break;
const boundary = chunk.subarray(0, bytesRead).indexOf(10);
firstLine = Buffer.concat([firstLine, chunk.subarray(0, boundary === -1 ? bytesRead : boundary)]);
position += bytesRead;
if (boundary !== -1) break;
}
try {
const parsed = JSON.parse(firstLine.toString("utf-8"));
originTs = parsed?.ts ?? parsed?.timestamp;
} catch {
originTs = undefined;
}
}
for (let position = 0; position < start; ) {
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, start - position), position);
Comment thread
bmethod marked this conversation as resolved.
if (bytesRead <= 0) break;
for (let index = 0; index < bytesRead; index += 1) if (chunk[index] === 10) droppedLines += 1;
position += bytesRead;
}
Comment thread
bmethod marked this conversation as resolved.
const tail = Buffer.alloc(maxBytes);
const { bytesRead } = await handle.read(tail, 0, maxBytes, start);
let text = tail.subarray(0, Math.max(0, bytesRead)).toString("utf-8");
const prev = Buffer.alloc(1);
await handle.read(prev, 0, 1, start - 1);
if (prev[0] !== 10) {
// The window opens mid-record: drop the fragment (counted — its
// newline sits inside the window). A single record larger than the
// whole window has no boundary; it stays as one line for the
// formatter to label rather than vanishing into a blank tab.
const boundary = text.indexOf("\n");
if (boundary !== -1) {
droppedLines += 1;
text = text.slice(boundary + 1);
}
}
return { droppedLines, originTs, text };
} catch {
return undefined;
} finally {
await handle?.close().catch(() => undefined);
}
}

export async function formatTraceView(record: PaneTaskRecord, verbose = false): Promise<string> {
const base = formatTaskRecordResult(record, true);
const transcript = await readTextFileIfExists(record.transcriptPath, verbose ? 80_000 : 24_000);
Expand Down
Loading
Loading