From 2c42d83c9a678b0ef30a9b5c0b4d3ec1a0d671ef Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 12 Mar 2026 07:19:47 +0900 Subject: [PATCH 1/2] 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 --- apps/perstack/bin/cli.ts | 37 +++++++++++++++- apps/perstack/package.json | 1 + bun.lock | 3 +- packages/filesystem/src/event.ts | 8 +++- packages/log/src/data-fetcher.ts | 32 +++++++++++++- .../execution/components/interface-panel.tsx | 28 ++---------- .../execution/hooks/use-delegation-tree.ts | 44 +++++++++++++------ .../tui-components/src/log-viewer/app.tsx | 2 +- 8 files changed, 111 insertions(+), 44 deletions(-) diff --git a/apps/perstack/bin/cli.ts b/apps/perstack/bin/cli.ts index 38a34236..9fd4cc11 100755 --- a/apps/perstack/bin/cli.ts +++ b/apps/perstack/bin/cli.ts @@ -2,7 +2,12 @@ import { PerstackError } from "@perstack/core" import { installHandler } from "@perstack/installer" -import { logHandler, parsePositiveInt } from "@perstack/log" +import { + createLogDataFetcher, + createStorageAdapter, + logHandler, + parsePositiveInt, +} from "@perstack/log" import { findConfigPath, findLockfile, @@ -24,6 +29,7 @@ import { expertYankHandler, } from "@perstack/studio" import { runHandler, startHandler } from "@perstack/tui" +import { renderLogViewer } from "@perstack/tui-components" import { Command } from "commander" import packageJson from "../package.json" with { type: "json" } @@ -151,7 +157,34 @@ program ) .option("--messages", "Show message history for checkpoint") .option("--summary", "Show summarized view") - .action((options) => logHandler(options)) + .option("--text", "Force text output mode (skip interactive TUI)") + .action(async (options) => { + const hasOutputFlags = + options.json || options.pretty || options.summary || options.messages || options.text + const hasFilterFlags = + options.step || + options.type || + options.errors || + options.tools || + options.delegations || + options.filter || + options.verbose || + options.take !== undefined || + options.offset !== undefined || + options.context !== undefined + if (hasOutputFlags || hasFilterFlags) { + await logHandler(options) + } else { + const storagePath = process.env.PERSTACK_STORAGE_PATH ?? `${process.cwd()}/perstack` + const adapter = createStorageAdapter(storagePath) + const fetcher = createLogDataFetcher(adapter) + await renderLogViewer({ + fetcher, + initialJobId: options.job, + initialRunId: options.run, + }) + } + }) program .command("install") diff --git a/apps/perstack/package.json b/apps/perstack/package.json index 1861450d..31347fe3 100644 --- a/apps/perstack/package.json +++ b/apps/perstack/package.json @@ -29,6 +29,7 @@ "@perstack/perstack-toml": "workspace:*", "@perstack/studio": "workspace:*", "@perstack/tui": "workspace:*", + "@perstack/tui-components": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.3.0", "typescript": "^5.9.3" diff --git a/bun.lock b/bun.lock index 0477c403..d454f8a3 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,7 @@ "@perstack/perstack-toml": "workspace:*", "@perstack/studio": "workspace:*", "@perstack/tui": "workspace:*", + "@perstack/tui-components": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.3.0", "typescript": "^5.9.3", @@ -317,7 +318,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@paralleldrive/cuid2": "^3.3.0", "@perstack/api-client": "^0.0.58", - "@perstack/base": "0.0.78", + "@perstack/base": "0.0.79", "@perstack/core": "0.0.64", "ai": "^6.0.86", "ollama-ai-provider-v2": "^3.3.0", diff --git a/packages/filesystem/src/event.ts b/packages/filesystem/src/event.ts index a00cfde1..9ffe7dbd 100644 --- a/packages/filesystem/src/event.ts +++ b/packages/filesystem/src/event.ts @@ -30,7 +30,12 @@ function getEventsByRun( .sort((a, b) => a.stepNumber - b.stepNumber) } -export function getEventContents(jobId: string, runId: string, maxStepNumber?: number): RunEvent[] { +export function getEventContents( + jobId: string, + runId: string, + maxStepNumber?: number, + typeFilter?: Set, +): RunEvent[] { const runDir = getRunDir(jobId, runId) if (!existsSync(runDir)) { return [] @@ -42,6 +47,7 @@ export function getEventContents(jobId: string, runId: string, maxStepNumber?: n return { file, timestamp: Number(timestamp), stepNumber: Number(step), type } }) .filter((e) => maxStepNumber === undefined || e.stepNumber <= maxStepNumber) + .filter((e) => !typeFilter || typeFilter.has(e.type)) .sort((a, b) => a.timestamp - b.timestamp) const events: RunEvent[] = [] for (const { file } of eventFiles) { diff --git a/packages/log/src/data-fetcher.ts b/packages/log/src/data-fetcher.ts index ca9a7ac8..dcb23407 100644 --- a/packages/log/src/data-fetcher.ts +++ b/packages/log/src/data-fetcher.ts @@ -18,6 +18,7 @@ export interface LogDataFetcher { getCheckpoint(jobId: string, checkpointId: string): Promise getEvents(jobId: string, runId: string): Promise getAllEventsForJob(jobId: string): Promise + getTreeEventsForJob(jobId: string): Promise } export interface StorageAdapter { @@ -25,7 +26,12 @@ export interface StorageAdapter { retrieveJob(jobId: string): Promise getCheckpointsByJobId(jobId: string): Promise retrieveCheckpoint(jobId: string, checkpointId: string): Promise - getEventContents(jobId: string, runId: string, maxStep?: number): Promise + getEventContents( + jobId: string, + runId: string, + maxStep?: number, + typeFilter?: Set, + ): Promise getAllRuns(): Promise getJobIds(): string[] getBasePath(): string @@ -114,6 +120,27 @@ export function createLogDataFetcher(storage: StorageAdapter): LogDataFetcher { } return allEvents.sort((a, b) => a.timestamp - b.timestamp) }, + + async getTreeEventsForJob(jobId: string): Promise { + const treeEventTypes = new Set([ + "startRun", + "resumeFromStop", + "stopRunByDelegate", + "callTools", + "completeRun", + "stopRunByError", + "retry", + "continueToNextStep", + "resolveToolResults", + ]) + const runs = await this.getRuns(jobId) + const allEvents: RunEvent[] = [] + for (const run of runs) { + const events = await storage.getEventContents(jobId, run.runId, undefined, treeEventTypes) + allEvents.push(...events) + } + return allEvents.sort((a, b) => a.timestamp - b.timestamp) + }, } } @@ -137,7 +164,8 @@ export function createStorageAdapter(basePath: string): StorageAdapter { getCheckpointsByJobId: async (jobId) => getCheckpointsByJobId(jobId), retrieveCheckpoint: async (jobId, checkpointId) => defaultRetrieveCheckpoint(jobId, checkpointId), - getEventContents: async (jobId, runId, maxStep) => getEventContents(jobId, runId, maxStep), + getEventContents: async (jobId, runId, maxStep, typeFilter) => + getEventContents(jobId, runId, maxStep, typeFilter), getAllRuns: async () => getAllRuns(), getJobIds: () => { const jobsDir = path.join(basePath, "jobs") diff --git a/packages/tui-components/src/execution/components/interface-panel.tsx b/packages/tui-components/src/execution/components/interface-panel.tsx index 28c5f374..407f73d0 100644 --- a/packages/tui-components/src/execution/components/interface-panel.tsx +++ b/packages/tui-components/src/execution/components/interface-panel.tsx @@ -1,7 +1,7 @@ -import { Box, Text, useInput } from "ink" +import { Text } from "ink" import type React from "react" import { colors } from "../../colors.js" -import { useTextInput } from "../../hooks/use-text-input.js" +import { BottomPanel } from "../../components/bottom-panel.js" import type { DelegationTreeState } from "../hooks/use-delegation-tree.js" import type { RunStatus } from "../hooks/use-execution-state.js" import { DelegationTree } from "./delegation-tree.js" @@ -38,25 +38,10 @@ export const InterfacePanel = ({ cacheHitRate, elapsedTime, }: InterfacePanelProps): React.ReactNode => { - const { input, handleInput } = useTextInput({ - onSubmit, - canSubmit: runStatus !== "running", - }) - - useInput(handleInput) - const isWaiting = runStatus === "waiting" return ( - + {isWaiting ? ( Waiting for query... ) : ( @@ -87,11 +72,6 @@ export const InterfacePanel = ({ )} - - > - {input} - _ - - + ) } diff --git a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts index 9fcfd503..783f94f9 100644 --- a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts +++ b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts @@ -133,28 +133,37 @@ export function getStatusCounts(state: DelegationTreeState): { return { running, waiting } } -/** - * Flatten the entire tree without pruning — includes all nodes regardless of status. - * Useful for testing and debugging where the full tree structure matters. - */ +/** Flatten tree without pruning - shows all nodes including completed/error. For log viewer. */ export function flattenTreeAll(state: DelegationTreeState): FlatTreeNode[] { - if (!state.rootRunId) return [] - const root = state.nodes.get(state.rootRunId) - if (!root) return [] - const result: FlatTreeNode[] = [] + const visited = new Set() function dfs(nodeId: string, depth: number, isLast: boolean, ancestorIsLast: boolean[]) { const node = state.nodes.get(nodeId) - if (!node) return + if (!node || visited.has(nodeId)) return + + visited.add(nodeId) result.push({ node, depth, isLast, ancestorIsLast: [...ancestorIsLast] }) - for (let i = 0; i < node.childRunIds.length; i++) { - const childIsLast = i === node.childRunIds.length - 1 - dfs(node.childRunIds[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast]) + + const children = node.childRunIds + for (let i = 0; i < children.length; i++) { + const childIsLast = i === children.length - 1 + dfs(children[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast]) + } + } + + if (state.rootRunId) { + dfs(state.rootRunId, 0, true, []) + } + + // Show orphaned nodes (no parent in tree) at root level + for (const [nodeId, node] of state.nodes) { + if (!visited.has(nodeId)) { + visited.add(nodeId) + result.push({ node, depth: 0, isLast: true, ancestorIsLast: [] }) } } - dfs(state.rootRunId, 0, true, []) return result } @@ -350,6 +359,9 @@ export function processDelegationTreeEvent( node.actionLabel = label node.actionFileArg = fileArg node.totalTokens += event.usage.totalTokens + node.inputTokens += event.usage.inputTokens + node.outputTokens += event.usage.outputTokens + node.cachedInputTokens += event.usage.cachedInputTokens state.jobTotalTokens += event.usage.totalTokens state.jobReasoningTokens += event.usage.reasoningTokens state.jobInputTokens += event.usage.inputTokens @@ -381,6 +393,9 @@ export function processDelegationTreeEvent( node.actionLabel = "Completed" node.actionFileArg = undefined node.totalTokens += event.usage.totalTokens + node.inputTokens += event.usage.inputTokens + node.outputTokens += event.usage.outputTokens + node.cachedInputTokens += event.usage.cachedInputTokens state.jobTotalTokens += event.usage.totalTokens state.jobReasoningTokens += event.usage.reasoningTokens state.jobInputTokens += event.usage.inputTokens @@ -425,6 +440,9 @@ export function processDelegationTreeEvent( const node = state.nodes.get(nodeId) if (node) { node.totalTokens += event.usage.totalTokens + node.inputTokens += event.usage.inputTokens + node.outputTokens += event.usage.outputTokens + node.cachedInputTokens += event.usage.cachedInputTokens state.jobTotalTokens += event.usage.totalTokens state.jobReasoningTokens += event.usage.reasoningTokens state.jobInputTokens += event.usage.inputTokens diff --git a/packages/tui-components/src/log-viewer/app.tsx b/packages/tui-components/src/log-viewer/app.tsx index 38ce000b..4dd1f3f8 100644 --- a/packages/tui-components/src/log-viewer/app.tsx +++ b/packages/tui-components/src/log-viewer/app.tsx @@ -42,7 +42,7 @@ async function extractJobQuery(fetcher: LogDataFetcher, job: Job): Promise Date: Thu, 12 Mar 2026 07:20:09 +0900 Subject: [PATCH 2/2] chore: add changeset for log viewer optimization Co-Authored-By: Claude Opus 4.6 --- .changeset/log-tui-enhancements.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/log-tui-enhancements.md diff --git a/.changeset/log-tui-enhancements.md b/.changeset/log-tui-enhancements.md new file mode 100644 index 00000000..d8d8de06 --- /dev/null +++ b/.changeset/log-tui-enhancements.md @@ -0,0 +1,8 @@ +--- +"@perstack/filesystem-storage": patch +"@perstack/log": patch +"@perstack/tui-components": patch +"perstack": patch +--- + +Optimize log viewer performance with filename-level event type filtering and add CLI TUI routing