Skip to content

Commit b594132

Browse files
FL4TLiN3claude
andauthored
feat: optimize log viewer performance and add CLI TUI routing (#749)
* feat: optimize log viewer performance and add CLI TUI routing - Add typeFilter to event file reading, filtering by filename before reading file contents (8.6x I/O reduction for tree building) - Add getTreeEventsForJob to LogDataFetcher for selective event loading - Route `perstack log` to interactive TUI by default, with --text flag for legacy text output - Track per-node token breakdown (input/output/cached) in delegation tree - Improve flattenTreeAll with visited set and orphan node handling - Refactor interface-panel to use shared BottomPanel component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add changeset for log viewer optimization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 72c5940 commit b594132

File tree

9 files changed

+119
-44
lines changed

9 files changed

+119
-44
lines changed

.changeset/log-tui-enhancements.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@perstack/filesystem-storage": patch
3+
"@perstack/log": patch
4+
"@perstack/tui-components": patch
5+
"perstack": patch
6+
---
7+
8+
Optimize log viewer performance with filename-level event type filtering and add CLI TUI routing

apps/perstack/bin/cli.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import { PerstackError } from "@perstack/core"
44
import { installHandler } from "@perstack/installer"
5-
import { logHandler, parsePositiveInt } from "@perstack/log"
5+
import {
6+
createLogDataFetcher,
7+
createStorageAdapter,
8+
logHandler,
9+
parsePositiveInt,
10+
} from "@perstack/log"
611
import {
712
findConfigPath,
813
findLockfile,
@@ -24,6 +29,7 @@ import {
2429
expertYankHandler,
2530
} from "@perstack/studio"
2631
import { runHandler, startHandler } from "@perstack/tui"
32+
import { renderLogViewer } from "@perstack/tui-components"
2733
import { Command } from "commander"
2834
import packageJson from "../package.json" with { type: "json" }
2935

@@ -151,7 +157,34 @@ program
151157
)
152158
.option("--messages", "Show message history for checkpoint")
153159
.option("--summary", "Show summarized view")
154-
.action((options) => logHandler(options))
160+
.option("--text", "Force text output mode (skip interactive TUI)")
161+
.action(async (options) => {
162+
const hasOutputFlags =
163+
options.json || options.pretty || options.summary || options.messages || options.text
164+
const hasFilterFlags =
165+
options.step ||
166+
options.type ||
167+
options.errors ||
168+
options.tools ||
169+
options.delegations ||
170+
options.filter ||
171+
options.verbose ||
172+
options.take !== undefined ||
173+
options.offset !== undefined ||
174+
options.context !== undefined
175+
if (hasOutputFlags || hasFilterFlags) {
176+
await logHandler(options)
177+
} else {
178+
const storagePath = process.env.PERSTACK_STORAGE_PATH ?? `${process.cwd()}/perstack`
179+
const adapter = createStorageAdapter(storagePath)
180+
const fetcher = createLogDataFetcher(adapter)
181+
await renderLogViewer({
182+
fetcher,
183+
initialJobId: options.job,
184+
initialRunId: options.run,
185+
})
186+
}
187+
})
155188

156189
program
157190
.command("install")

apps/perstack/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@perstack/perstack-toml": "workspace:*",
3030
"@perstack/studio": "workspace:*",
3131
"@perstack/tui": "workspace:*",
32+
"@perstack/tui-components": "workspace:*",
3233
"@tsconfig/node22": "^22.0.5",
3334
"@types/node": "^25.3.0",
3435
"typescript": "^5.9.3"

bun.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/filesystem/src/event.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ function getEventsByRun(
3030
.sort((a, b) => a.stepNumber - b.stepNumber)
3131
}
3232

33-
export function getEventContents(jobId: string, runId: string, maxStepNumber?: number): RunEvent[] {
33+
export function getEventContents(
34+
jobId: string,
35+
runId: string,
36+
maxStepNumber?: number,
37+
typeFilter?: Set<string>,
38+
): RunEvent[] {
3439
const runDir = getRunDir(jobId, runId)
3540
if (!existsSync(runDir)) {
3641
return []
@@ -42,6 +47,7 @@ export function getEventContents(jobId: string, runId: string, maxStepNumber?: n
4247
return { file, timestamp: Number(timestamp), stepNumber: Number(step), type }
4348
})
4449
.filter((e) => maxStepNumber === undefined || e.stepNumber <= maxStepNumber)
50+
.filter((e) => !typeFilter || typeFilter.has(e.type))
4551
.sort((a, b) => a.timestamp - b.timestamp)
4652
const events: RunEvent[] = []
4753
for (const { file } of eventFiles) {

packages/log/src/data-fetcher.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,20 @@ export interface LogDataFetcher {
1818
getCheckpoint(jobId: string, checkpointId: string): Promise<Checkpoint>
1919
getEvents(jobId: string, runId: string): Promise<RunEvent[]>
2020
getAllEventsForJob(jobId: string): Promise<RunEvent[]>
21+
getTreeEventsForJob(jobId: string): Promise<RunEvent[]>
2122
}
2223

2324
export interface StorageAdapter {
2425
getAllJobs(): Promise<Job[]>
2526
retrieveJob(jobId: string): Promise<Job | undefined>
2627
getCheckpointsByJobId(jobId: string): Promise<Checkpoint[]>
2728
retrieveCheckpoint(jobId: string, checkpointId: string): Promise<Checkpoint>
28-
getEventContents(jobId: string, runId: string, maxStep?: number): Promise<RunEvent[]>
29+
getEventContents(
30+
jobId: string,
31+
runId: string,
32+
maxStep?: number,
33+
typeFilter?: Set<string>,
34+
): Promise<RunEvent[]>
2935
getAllRuns(): Promise<RunSetting[]>
3036
getJobIds(): string[]
3137
getBasePath(): string
@@ -114,6 +120,27 @@ export function createLogDataFetcher(storage: StorageAdapter): LogDataFetcher {
114120
}
115121
return allEvents.sort((a, b) => a.timestamp - b.timestamp)
116122
},
123+
124+
async getTreeEventsForJob(jobId: string): Promise<RunEvent[]> {
125+
const treeEventTypes = new Set([
126+
"startRun",
127+
"resumeFromStop",
128+
"stopRunByDelegate",
129+
"callTools",
130+
"completeRun",
131+
"stopRunByError",
132+
"retry",
133+
"continueToNextStep",
134+
"resolveToolResults",
135+
])
136+
const runs = await this.getRuns(jobId)
137+
const allEvents: RunEvent[] = []
138+
for (const run of runs) {
139+
const events = await storage.getEventContents(jobId, run.runId, undefined, treeEventTypes)
140+
allEvents.push(...events)
141+
}
142+
return allEvents.sort((a, b) => a.timestamp - b.timestamp)
143+
},
117144
}
118145
}
119146

@@ -137,7 +164,8 @@ export function createStorageAdapter(basePath: string): StorageAdapter {
137164
getCheckpointsByJobId: async (jobId) => getCheckpointsByJobId(jobId),
138165
retrieveCheckpoint: async (jobId, checkpointId) =>
139166
defaultRetrieveCheckpoint(jobId, checkpointId),
140-
getEventContents: async (jobId, runId, maxStep) => getEventContents(jobId, runId, maxStep),
167+
getEventContents: async (jobId, runId, maxStep, typeFilter) =>
168+
getEventContents(jobId, runId, maxStep, typeFilter),
141169
getAllRuns: async () => getAllRuns(),
142170
getJobIds: () => {
143171
const jobsDir = path.join(basePath, "jobs")

packages/tui-components/src/execution/components/interface-panel.tsx

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Box, Text, useInput } from "ink"
1+
import { Text } from "ink"
22
import type React from "react"
33
import { colors } from "../../colors.js"
4-
import { useTextInput } from "../../hooks/use-text-input.js"
4+
import { BottomPanel } from "../../components/bottom-panel.js"
55
import type { DelegationTreeState } from "../hooks/use-delegation-tree.js"
66
import type { RunStatus } from "../hooks/use-execution-state.js"
77
import { DelegationTree } from "./delegation-tree.js"
@@ -38,25 +38,10 @@ export const InterfacePanel = ({
3838
cacheHitRate,
3939
elapsedTime,
4040
}: InterfacePanelProps): React.ReactNode => {
41-
const { input, handleInput } = useTextInput({
42-
onSubmit,
43-
canSubmit: runStatus !== "running",
44-
})
45-
46-
useInput(handleInput)
47-
4841
const isWaiting = runStatus === "waiting"
4942

5043
return (
51-
<Box
52-
flexDirection="column"
53-
borderStyle="single"
54-
borderColor={colors.muted}
55-
borderTop={true}
56-
borderBottom={false}
57-
borderLeft={false}
58-
borderRight={false}
59-
>
44+
<BottomPanel onSubmit={onSubmit} canSubmit={runStatus !== "running"}>
6045
{isWaiting ? (
6146
<Text color={colors.accent}>Waiting for query...</Text>
6247
) : (
@@ -87,11 +72,6 @@ export const InterfacePanel = ({
8772
</>
8873
)}
8974
<DelegationTree state={delegationTreeState} />
90-
<Text>
91-
<Text color={colors.muted}>&gt; </Text>
92-
<Text>{input}</Text>
93-
<Text color={colors.accent}>_</Text>
94-
</Text>
95-
</Box>
75+
</BottomPanel>
9676
)
9777
}

packages/tui-components/src/execution/hooks/use-delegation-tree.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,28 +133,37 @@ export function getStatusCounts(state: DelegationTreeState): {
133133
return { running, waiting }
134134
}
135135

136-
/**
137-
* Flatten the entire tree without pruning — includes all nodes regardless of status.
138-
* Useful for testing and debugging where the full tree structure matters.
139-
*/
136+
/** Flatten tree without pruning - shows all nodes including completed/error. For log viewer. */
140137
export function flattenTreeAll(state: DelegationTreeState): FlatTreeNode[] {
141-
if (!state.rootRunId) return []
142-
const root = state.nodes.get(state.rootRunId)
143-
if (!root) return []
144-
145138
const result: FlatTreeNode[] = []
139+
const visited = new Set<string>()
146140

147141
function dfs(nodeId: string, depth: number, isLast: boolean, ancestorIsLast: boolean[]) {
148142
const node = state.nodes.get(nodeId)
149-
if (!node) return
143+
if (!node || visited.has(nodeId)) return
144+
145+
visited.add(nodeId)
150146
result.push({ node, depth, isLast, ancestorIsLast: [...ancestorIsLast] })
151-
for (let i = 0; i < node.childRunIds.length; i++) {
152-
const childIsLast = i === node.childRunIds.length - 1
153-
dfs(node.childRunIds[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast])
147+
148+
const children = node.childRunIds
149+
for (let i = 0; i < children.length; i++) {
150+
const childIsLast = i === children.length - 1
151+
dfs(children[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast])
152+
}
153+
}
154+
155+
if (state.rootRunId) {
156+
dfs(state.rootRunId, 0, true, [])
157+
}
158+
159+
// Show orphaned nodes (no parent in tree) at root level
160+
for (const [nodeId, node] of state.nodes) {
161+
if (!visited.has(nodeId)) {
162+
visited.add(nodeId)
163+
result.push({ node, depth: 0, isLast: true, ancestorIsLast: [] })
154164
}
155165
}
156166

157-
dfs(state.rootRunId, 0, true, [])
158167
return result
159168
}
160169

@@ -350,6 +359,9 @@ export function processDelegationTreeEvent(
350359
node.actionLabel = label
351360
node.actionFileArg = fileArg
352361
node.totalTokens += event.usage.totalTokens
362+
node.inputTokens += event.usage.inputTokens
363+
node.outputTokens += event.usage.outputTokens
364+
node.cachedInputTokens += event.usage.cachedInputTokens
353365
state.jobTotalTokens += event.usage.totalTokens
354366
state.jobReasoningTokens += event.usage.reasoningTokens
355367
state.jobInputTokens += event.usage.inputTokens
@@ -381,6 +393,9 @@ export function processDelegationTreeEvent(
381393
node.actionLabel = "Completed"
382394
node.actionFileArg = undefined
383395
node.totalTokens += event.usage.totalTokens
396+
node.inputTokens += event.usage.inputTokens
397+
node.outputTokens += event.usage.outputTokens
398+
node.cachedInputTokens += event.usage.cachedInputTokens
384399
state.jobTotalTokens += event.usage.totalTokens
385400
state.jobReasoningTokens += event.usage.reasoningTokens
386401
state.jobInputTokens += event.usage.inputTokens
@@ -425,6 +440,9 @@ export function processDelegationTreeEvent(
425440
const node = state.nodes.get(nodeId)
426441
if (node) {
427442
node.totalTokens += event.usage.totalTokens
443+
node.inputTokens += event.usage.inputTokens
444+
node.outputTokens += event.usage.outputTokens
445+
node.cachedInputTokens += event.usage.cachedInputTokens
428446
state.jobTotalTokens += event.usage.totalTokens
429447
state.jobReasoningTokens += event.usage.reasoningTokens
430448
state.jobInputTokens += event.usage.inputTokens

packages/tui-components/src/log-viewer/app.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async function extractJobQuery(fetcher: LogDataFetcher, job: Job): Promise<strin
4242
}
4343

4444
async function buildRunTree(fetcher: LogDataFetcher, jobId: string) {
45-
const allEvents = await fetcher.getAllEventsForJob(jobId)
45+
const allEvents = await fetcher.getTreeEventsForJob(jobId)
4646
return buildRunTreeFromEvents(allEvents)
4747
}
4848

0 commit comments

Comments
 (0)