diff --git a/.changeset/extract-tui-packages.md b/.changeset/extract-tui-packages.md new file mode 100644 index 00000000..18251b5b --- /dev/null +++ b/.changeset/extract-tui-packages.md @@ -0,0 +1,4 @@ +--- +--- + +refactor: extract TUI code into @perstack/tui-components and @perstack/tui packages diff --git a/apps/create-expert/bin/cli.ts b/apps/create-expert/bin/cli.ts index 5e86010f..e76e5bc6 100644 --- a/apps/create-expert/bin/cli.ts +++ b/apps/create-expert/bin/cli.ts @@ -2,8 +2,8 @@ import { readFileSync } from "node:fs" import { parseWithFriendlyError, perstackConfigSchema } from "@perstack/core" +import { startHandler } from "@perstack/tui" import { Command } from "commander" -import { startHandler } from "perstack/start" import TOML from "smol-toml" const tomlPath = new URL("../perstack.toml", import.meta.url) diff --git a/apps/create-expert/package.json b/apps/create-expert/package.json index 8015b77f..54f4f4b6 100644 --- a/apps/create-expert/package.json +++ b/apps/create-expert/package.json @@ -24,8 +24,8 @@ }, "dependencies": { "@perstack/core": "workspace:*", + "@perstack/tui": "workspace:*", "commander": "^14.0.2", - "perstack": "workspace:*", "smol-toml": "^1.6.0" }, "devDependencies": { diff --git a/apps/perstack/package.json b/apps/perstack/package.json index a53c44c8..28bafecf 100644 --- a/apps/perstack/package.json +++ b/apps/perstack/package.json @@ -5,14 +5,8 @@ "author": "Wintermute Technologies, Inc.", "license": "Apache-2.0", "type": "module", - "exports": { - "./start": "./src/start.ts" - }, "publishConfig": { "access": "public", - "exports": { - "./start": "./dist/src/start.js" - }, "bin": { "perstack": "dist/bin/cli.js" } @@ -29,20 +23,15 @@ "@paralleldrive/cuid2": "^3.0.6", "@perstack/api-client": "^0.0.54", "@perstack/core": "workspace:*", - "@perstack/react": "workspace:*", "@perstack/runtime": "workspace:*", "commander": "^14.0.2", - "dotenv": "^17.2.3", - "ink": "^6.6.0", - "react": "^19.2.3", "smol-toml": "^1.6.0" }, "devDependencies": { "@perstack/filesystem-storage": "workspace:*", - "@perstack/tui-components": "workspace:*", + "@perstack/tui": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.0.10", - "@types/react": "^19.2.9", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.18" diff --git a/apps/perstack/src/install.ts b/apps/perstack/src/install.ts index 240b83b1..397af168 100644 --- a/apps/perstack/src/install.ts +++ b/apps/perstack/src/install.ts @@ -12,10 +12,10 @@ import { type Skill, } from "@perstack/core" import { collectToolDefinitionsForExpert } from "@perstack/runtime" +import { getEnv } from "@perstack/tui/get-env" +import { getPerstackConfig } from "@perstack/tui/perstack-toml" import { Command } from "commander" import TOML from "smol-toml" -import { getEnv } from "./lib/get-env.js" -import { getPerstackConfig } from "./lib/perstack-toml.js" async function findConfigPath(configPath?: string): Promise { if (configPath) { diff --git a/apps/perstack/src/run.ts b/apps/perstack/src/run.ts index 04c6eca1..0c4d06f4 100644 --- a/apps/perstack/src/run.ts +++ b/apps/perstack/src/run.ts @@ -15,12 +15,12 @@ import { storeJob, } from "@perstack/filesystem-storage" import { findLockfile, loadLockfile, run as perstackRun } from "@perstack/runtime" -import { Command } from "commander" -import { resolveRunContext } from "./lib/context.js" +import { resolveRunContext } from "@perstack/tui/context" import { parseInteractiveToolCallResult, parseInteractiveToolCallResultJson, -} from "./lib/interactive.js" +} from "@perstack/tui/interactive" +import { Command } from "commander" const defaultEventListener = (event: RunEvent | RuntimeEvent) => console.log(JSON.stringify(event)) diff --git a/apps/perstack/src/start.ts b/apps/perstack/src/start.ts index 1ab07b5c..b77383ca 100644 --- a/apps/perstack/src/start.ts +++ b/apps/perstack/src/start.ts @@ -1,251 +1,5 @@ -import { createId } from "@paralleldrive/cuid2" -import { - defaultMaxRetries, - defaultTimeout, - type PerstackConfig, - parseWithFriendlyError, - startCommandInputSchema, -} from "@perstack/core" -import { - createInitialJob, - defaultRetrieveCheckpoint, - defaultStoreCheckpoint, - defaultStoreEvent, - retrieveJob, - storeJob, -} from "@perstack/filesystem-storage" -import { findLockfile, loadLockfile, run as perstackRun, runtimeVersion } from "@perstack/runtime" +import { startHandler } from "@perstack/tui" import { Command } from "commander" -import { resolveRunContext } from "./lib/context.js" -import { parseInteractiveToolCallResult } from "./lib/interactive.js" -import { - getAllEventContentsForJob, - getAllJobs, - getCheckpointById, - getCheckpointsWithDetails, - type getEventContents, - getRecentExperts, -} from "./lib/run-manager.js" -import { renderExecution } from "./tui/execution/index.js" -import { renderSelection } from "./tui/selection/index.js" -import type { CheckpointHistoryItem, JobHistoryItem } from "./tui/types/index.js" - -const CONTINUE_TIMEOUT_MS = 60_000 - -export interface StartHandlerOptions { - perstackConfig?: PerstackConfig - additionalEnv?: (env: Record) => Record -} - -export async function startHandler( - expertKey: string | undefined, - query: string | undefined, - options: Record, - handlerOptions?: StartHandlerOptions, -): Promise { - const input = parseWithFriendlyError(startCommandInputSchema, { expertKey, query, options }) - - try { - // Phase 1: Initialize context - const { perstackConfig, checkpoint, env, providerConfig, model, experts } = - await resolveRunContext({ - configPath: input.options.config, - provider: input.options.provider, - model: input.options.model, - envPath: input.options.envPath, - continue: input.options.continue, - continueJob: input.options.continueJob, - resumeFrom: input.options.resumeFrom, - expertKey: input.expertKey, - perstackConfig: handlerOptions?.perstackConfig, - }) - - if (handlerOptions?.additionalEnv) { - Object.assign(env, handlerOptions.additionalEnv(env)) - } - - const maxSteps = input.options.maxSteps ?? perstackConfig.maxSteps - const maxRetries = input.options.maxRetries ?? perstackConfig.maxRetries ?? defaultMaxRetries - const timeout = input.options.timeout ?? perstackConfig.timeout ?? defaultTimeout - - // Prepare expert lists - const configuredExperts = Object.keys(perstackConfig.experts ?? {}).map((key) => ({ - key, - name: key, - })) - const recentExperts = getRecentExperts(10) - - // Prepare history jobs (only if browsing is needed) - const showHistory = !input.expertKey && !input.query && !checkpoint - const historyJobs: JobHistoryItem[] = showHistory - ? getAllJobs().map((j) => ({ - jobId: j.id, - status: j.status, - expertKey: j.coordinatorExpertKey, - totalSteps: j.totalSteps, - startedAt: j.startedAt, - finishedAt: j.finishedAt, - })) - : [] - - // Phase 2: Selection - get expert, query, and optional checkpoint - const selection = await renderSelection({ - showHistory, - initialExpertKey: input.expertKey, - initialQuery: input.query, - initialCheckpoint: checkpoint - ? { - id: checkpoint.id, - jobId: checkpoint.jobId, - runId: checkpoint.runId, - stepNumber: checkpoint.stepNumber, - contextWindowUsage: checkpoint.contextWindowUsage ?? 0, - } - : undefined, - configuredExperts, - recentExperts, - historyJobs, - onLoadCheckpoints: async (j: JobHistoryItem): Promise => { - const checkpoints = getCheckpointsWithDetails(j.jobId) - return checkpoints.map((cp) => ({ ...cp, jobId: j.jobId })) - }, - }) - - // Validate selection - if (!selection.expertKey) { - console.error("Expert key is required") - return - } - if (!selection.query && !selection.checkpoint) { - console.error("Query is required") - return - } - - // Resolve checkpoint if selected from TUI - let currentCheckpoint = selection.checkpoint - ? getCheckpointById(selection.checkpoint.jobId, selection.checkpoint.id) - : checkpoint - - if (currentCheckpoint && currentCheckpoint.expert.key !== selection.expertKey) { - console.error( - `Checkpoint expert key ${currentCheckpoint.expert.key} does not match input expert key ${selection.expertKey}`, - ) - return - } - - // Load lockfile if present - const lockfilePath = findLockfile() - const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined - - // Phase 3: Execution loop - let currentQuery: string | null = selection.query - let currentJobId = currentCheckpoint?.jobId ?? input.options.jobId ?? createId() - // Track if the next query should be treated as an interactive tool result - let isNextQueryInteractiveToolResult = input.options.interactiveToolCallResult ?? false - - // Track whether this is the first iteration (for historical events display) - // On first iteration, load all events for the job up to the checkpoint - // On subsequent iterations, skip historical events (previous TUI already displayed them) - let isFirstIteration = true - const initialHistoricalEvents: ReturnType | undefined = - currentCheckpoint - ? getAllEventContentsForJob(currentCheckpoint.jobId, currentCheckpoint.stepNumber) - : undefined - - while (currentQuery !== null) { - // Only pass historical events on first iteration - // Subsequent iterations: previous TUI output remains on screen - const historicalEvents = isFirstIteration ? initialHistoricalEvents : undefined - - // Generate a new runId for each iteration - const runId = createId() - - // Start execution TUI - const { result: executionResult, eventListener } = renderExecution({ - expertKey: selection.expertKey, - query: currentQuery, - config: { - runtimeVersion, - model, - maxSteps, - maxRetries, - timeout, - contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, - }, - continueTimeoutMs: CONTINUE_TIMEOUT_MS, - historicalEvents, - }) - - // Run the expert - const runResult = await perstackRun( - { - setting: { - jobId: currentJobId, - runId, - expertKey: selection.expertKey, - input: - isNextQueryInteractiveToolResult && currentCheckpoint - ? parseInteractiveToolCallResult(currentQuery, currentCheckpoint) - : { text: currentQuery }, - experts, - model, - providerConfig, - reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, - maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, - maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, - timeout: input.options.timeout ?? perstackConfig.timeout, - perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, - perstackApiKey: env.PERSTACK_API_KEY, - perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, - env, - verbose: input.options.verbose, - }, - checkpoint: currentCheckpoint, - }, - { - eventListener, - storeCheckpoint: defaultStoreCheckpoint, - storeEvent: defaultStoreEvent, - retrieveCheckpoint: defaultRetrieveCheckpoint, - storeJob, - retrieveJob, - createJob: createInitialJob, - lockfile, - }, - ) - - // Wait for execution TUI to complete (user input or timeout) - const result = await executionResult - - // Check if user wants to continue - const canContinue = - runResult.status === "completed" || - runResult.status === "stoppedByExceededMaxSteps" || - runResult.status === "stoppedByError" || - runResult.status === "stoppedByInteractiveTool" - - if (result.nextQuery && canContinue) { - currentQuery = result.nextQuery - currentCheckpoint = runResult - currentJobId = runResult.jobId - - // If the run stopped for interactive tool, the next query is an interactive tool result - isNextQueryInteractiveToolResult = runResult.status === "stoppedByInteractiveTool" - - // Mark first iteration as complete (subsequent TUIs won't show historical events) - isFirstIteration = false - } else { - currentQuery = null - } - } - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - } -} export const startCommand = new Command() .command("start") diff --git a/apps/perstack/src/tui/utils/error-handling.ts b/apps/perstack/src/tui/utils/error-handling.ts deleted file mode 100644 index 37552c07..00000000 --- a/apps/perstack/src/tui/utils/error-handling.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const createErrorHandler = - (onError?: (error: Error) => void) => - (error: unknown, context: string): void => { - const err = error instanceof Error ? error : new Error(`${context}: ${String(error)}`) - onError?.(err) - } diff --git a/apps/perstack/src/tui/utils/index.ts b/apps/perstack/src/tui/utils/index.ts deleted file mode 100644 index 90396fa4..00000000 --- a/apps/perstack/src/tui/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createErrorHandler } from "./error-handling.js" -export { EventQueue } from "./event-queue.js" diff --git a/apps/perstack/tsconfig.json b/apps/perstack/tsconfig.json index d00bce6b..6b253e9b 100644 --- a/apps/perstack/tsconfig.json +++ b/apps/perstack/tsconfig.json @@ -4,6 +4,6 @@ "resolveJsonModule": true, "jsx": "react-jsx" }, - "include": ["**/*.ts", "**/*.tsx"], + "include": ["**/*.ts"], "exclude": ["node_modules", "dist"] } diff --git a/apps/perstack/tsup.config.ts b/apps/perstack/tsup.config.ts index 4082ca9c..69a987b3 100644 --- a/apps/perstack/tsup.config.ts +++ b/apps/perstack/tsup.config.ts @@ -6,8 +6,8 @@ export const cliConfig: Options = { dts: false, entry: { "bin/cli": "bin/cli.ts", - "src/start": "src/start.ts", }, + external: ["react-devtools-core"], } export default defineConfig(cliConfig) diff --git a/packages/tui-components/package.json b/packages/tui-components/package.json index ed385dad..5abc5de7 100644 --- a/packages/tui-components/package.json +++ b/packages/tui-components/package.json @@ -27,10 +27,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@perstack/core": "workspace:*", + "@perstack/react": "workspace:*", "ink": "^6.6.0", "react": "^19.2.3" }, "peerDependencies": { + "@perstack/core": "*", + "@perstack/react": "*", "ink": ">=6.0.0", "react": ">=18.0.0" }, diff --git a/apps/perstack/src/tui/components/action-row.tsx b/packages/tui-components/src/components/action-row.tsx similarity index 100% rename from apps/perstack/src/tui/components/action-row.tsx rename to packages/tui-components/src/components/action-row.tsx diff --git a/apps/perstack/src/tui/components/browser-router.tsx b/packages/tui-components/src/components/browser-router.tsx similarity index 100% rename from apps/perstack/src/tui/components/browser-router.tsx rename to packages/tui-components/src/components/browser-router.tsx diff --git a/apps/perstack/src/tui/components/checkpoint-action-row.tsx b/packages/tui-components/src/components/checkpoint-action-row.tsx similarity index 100% rename from apps/perstack/src/tui/components/checkpoint-action-row.tsx rename to packages/tui-components/src/components/checkpoint-action-row.tsx diff --git a/apps/perstack/src/tui/components/expert-list.tsx b/packages/tui-components/src/components/expert-list.tsx similarity index 100% rename from apps/perstack/src/tui/components/expert-list.tsx rename to packages/tui-components/src/components/expert-list.tsx diff --git a/apps/perstack/src/tui/components/expert-selector-base.tsx b/packages/tui-components/src/components/expert-selector-base.tsx similarity index 100% rename from apps/perstack/src/tui/components/expert-selector-base.tsx rename to packages/tui-components/src/components/expert-selector-base.tsx diff --git a/apps/perstack/src/tui/components/index.ts b/packages/tui-components/src/components/index.ts similarity index 100% rename from apps/perstack/src/tui/components/index.ts rename to packages/tui-components/src/components/index.ts diff --git a/apps/perstack/src/tui/components/input-areas/browsing-checkpoints.tsx b/packages/tui-components/src/components/input-areas/browsing-checkpoints.tsx similarity index 100% rename from apps/perstack/src/tui/components/input-areas/browsing-checkpoints.tsx rename to packages/tui-components/src/components/input-areas/browsing-checkpoints.tsx diff --git a/apps/perstack/src/tui/components/input-areas/browsing-event-detail.tsx b/packages/tui-components/src/components/input-areas/browsing-event-detail.tsx similarity index 100% rename from apps/perstack/src/tui/components/input-areas/browsing-event-detail.tsx rename to packages/tui-components/src/components/input-areas/browsing-event-detail.tsx diff --git a/apps/perstack/src/tui/components/input-areas/browsing-events.tsx b/packages/tui-components/src/components/input-areas/browsing-events.tsx similarity index 100% rename from apps/perstack/src/tui/components/input-areas/browsing-events.tsx rename to packages/tui-components/src/components/input-areas/browsing-events.tsx diff --git a/apps/perstack/src/tui/components/input-areas/browsing-experts.tsx b/packages/tui-components/src/components/input-areas/browsing-experts.tsx similarity index 100% rename from apps/perstack/src/tui/components/input-areas/browsing-experts.tsx rename to packages/tui-components/src/components/input-areas/browsing-experts.tsx diff --git a/apps/perstack/src/tui/components/input-areas/browsing-history.tsx b/packages/tui-components/src/components/input-areas/browsing-history.tsx similarity index 100% rename from apps/perstack/src/tui/components/input-areas/browsing-history.tsx rename to packages/tui-components/src/components/input-areas/browsing-history.tsx diff --git a/apps/perstack/src/tui/components/input-areas/index.ts b/packages/tui-components/src/components/input-areas/index.ts similarity index 100% rename from apps/perstack/src/tui/components/input-areas/index.ts rename to packages/tui-components/src/components/input-areas/index.ts diff --git a/apps/perstack/src/tui/components/list-browser.tsx b/packages/tui-components/src/components/list-browser.tsx similarity index 97% rename from apps/perstack/src/tui/components/list-browser.tsx rename to packages/tui-components/src/components/list-browser.tsx index 03183f1e..57810fbc 100644 --- a/apps/perstack/src/tui/components/list-browser.tsx +++ b/packages/tui-components/src/components/list-browser.tsx @@ -1,9 +1,9 @@ -import { useListNavigation } from "@perstack/tui-components" import type { Key } from "ink" import { Box, Text, useInput } from "ink" import type React from "react" import { useMemo } from "react" import { INDICATOR, UI_CONSTANTS } from "../constants.js" +import { useListNavigation } from "../hooks/use-list-navigation.js" type ListBrowserProps = { title: string diff --git a/apps/perstack/src/tui/components/run-setting.tsx b/packages/tui-components/src/components/run-setting.tsx similarity index 97% rename from apps/perstack/src/tui/components/run-setting.tsx rename to packages/tui-components/src/components/run-setting.tsx index 8a380e9e..1df66be9 100644 --- a/apps/perstack/src/tui/components/run-setting.tsx +++ b/packages/tui-components/src/components/run-setting.tsx @@ -1,5 +1,5 @@ -import { useTextInput } from "@perstack/tui-components" import { Box, Text, useInput } from "ink" +import { useTextInput } from "../hooks/use-text-input.js" import type { RuntimeInfo } from "../types/index.js" export type RunSettingProps = { info: RuntimeInfo diff --git a/apps/perstack/src/tui/components/streaming-display.tsx b/packages/tui-components/src/components/streaming-display.tsx similarity index 100% rename from apps/perstack/src/tui/components/streaming-display.tsx rename to packages/tui-components/src/components/streaming-display.tsx diff --git a/apps/perstack/src/tui/constants.ts b/packages/tui-components/src/constants.ts similarity index 100% rename from apps/perstack/src/tui/constants.ts rename to packages/tui-components/src/constants.ts diff --git a/apps/perstack/src/tui/context/index.ts b/packages/tui-components/src/context/index.ts similarity index 100% rename from apps/perstack/src/tui/context/index.ts rename to packages/tui-components/src/context/index.ts diff --git a/apps/perstack/src/tui/context/input-area-context.tsx b/packages/tui-components/src/context/input-area-context.tsx similarity index 100% rename from apps/perstack/src/tui/context/input-area-context.tsx rename to packages/tui-components/src/context/input-area-context.tsx diff --git a/apps/perstack/src/tui/execution/app.tsx b/packages/tui-components/src/execution/app.tsx similarity index 100% rename from apps/perstack/src/tui/execution/app.tsx rename to packages/tui-components/src/execution/app.tsx diff --git a/apps/perstack/src/tui/execution/components/activity-log-panel.tsx b/packages/tui-components/src/execution/components/activity-log-panel.tsx similarity index 100% rename from apps/perstack/src/tui/execution/components/activity-log-panel.tsx rename to packages/tui-components/src/execution/components/activity-log-panel.tsx diff --git a/apps/perstack/src/tui/execution/components/continue-input-panel.tsx b/packages/tui-components/src/execution/components/continue-input-panel.tsx similarity index 94% rename from apps/perstack/src/tui/execution/components/continue-input-panel.tsx rename to packages/tui-components/src/execution/components/continue-input-panel.tsx index c1a16c20..147126fb 100644 --- a/apps/perstack/src/tui/execution/components/continue-input-panel.tsx +++ b/packages/tui-components/src/execution/components/continue-input-panel.tsx @@ -1,6 +1,6 @@ -import { useTextInput } from "@perstack/tui-components" import { Box, Text, useInput } from "ink" import type React from "react" +import { useTextInput } from "../../hooks/use-text-input.js" import type { RunStatus } from "../hooks/index.js" type ContinueInputPanelProps = { diff --git a/apps/perstack/src/tui/execution/components/index.ts b/packages/tui-components/src/execution/components/index.ts similarity index 100% rename from apps/perstack/src/tui/execution/components/index.ts rename to packages/tui-components/src/execution/components/index.ts diff --git a/apps/perstack/src/tui/execution/components/status-panel.tsx b/packages/tui-components/src/execution/components/status-panel.tsx similarity index 100% rename from apps/perstack/src/tui/execution/components/status-panel.tsx rename to packages/tui-components/src/execution/components/status-panel.tsx diff --git a/apps/perstack/src/tui/execution/hooks/index.ts b/packages/tui-components/src/execution/hooks/index.ts similarity index 100% rename from apps/perstack/src/tui/execution/hooks/index.ts rename to packages/tui-components/src/execution/hooks/index.ts diff --git a/apps/perstack/src/tui/execution/hooks/use-execution-state.ts b/packages/tui-components/src/execution/hooks/use-execution-state.ts similarity index 100% rename from apps/perstack/src/tui/execution/hooks/use-execution-state.ts rename to packages/tui-components/src/execution/hooks/use-execution-state.ts diff --git a/apps/perstack/src/tui/execution/index.ts b/packages/tui-components/src/execution/index.ts similarity index 100% rename from apps/perstack/src/tui/execution/index.ts rename to packages/tui-components/src/execution/index.ts diff --git a/apps/perstack/src/tui/execution/render.tsx b/packages/tui-components/src/execution/render.tsx similarity index 100% rename from apps/perstack/src/tui/execution/render.tsx rename to packages/tui-components/src/execution/render.tsx diff --git a/apps/perstack/src/tui/execution/types.ts b/packages/tui-components/src/execution/types.ts similarity index 100% rename from apps/perstack/src/tui/execution/types.ts rename to packages/tui-components/src/execution/types.ts diff --git a/apps/perstack/src/tui/helpers.ts b/packages/tui-components/src/helpers.ts similarity index 100% rename from apps/perstack/src/tui/helpers.ts rename to packages/tui-components/src/helpers.ts diff --git a/apps/perstack/src/tui/hooks/index.ts b/packages/tui-components/src/hooks/index.ts similarity index 100% rename from apps/perstack/src/tui/hooks/index.ts rename to packages/tui-components/src/hooks/index.ts diff --git a/apps/perstack/src/tui/hooks/state/index.ts b/packages/tui-components/src/hooks/state/index.ts similarity index 100% rename from apps/perstack/src/tui/hooks/state/index.ts rename to packages/tui-components/src/hooks/state/index.ts diff --git a/apps/perstack/src/tui/hooks/state/use-runtime-info.ts b/packages/tui-components/src/hooks/state/use-runtime-info.ts similarity index 100% rename from apps/perstack/src/tui/hooks/state/use-runtime-info.ts rename to packages/tui-components/src/hooks/state/use-runtime-info.ts diff --git a/apps/perstack/src/tui/hooks/ui/index.ts b/packages/tui-components/src/hooks/ui/index.ts similarity index 100% rename from apps/perstack/src/tui/hooks/ui/index.ts rename to packages/tui-components/src/hooks/ui/index.ts diff --git a/apps/perstack/src/tui/hooks/ui/use-expert-selector.ts b/packages/tui-components/src/hooks/ui/use-expert-selector.ts similarity index 92% rename from apps/perstack/src/tui/hooks/ui/use-expert-selector.ts rename to packages/tui-components/src/hooks/ui/use-expert-selector.ts index 7424bfe6..400afab5 100644 --- a/apps/perstack/src/tui/hooks/ui/use-expert-selector.ts +++ b/packages/tui-components/src/hooks/ui/use-expert-selector.ts @@ -1,7 +1,8 @@ -import { useListNavigation, useTextInput } from "@perstack/tui-components" import type { Key } from "ink" import { useCallback, useState } from "react" import type { ExpertOption } from "../../types/index.js" +import { useListNavigation } from "../use-list-navigation.js" +import { useTextInput } from "../use-text-input.js" type UseExpertSelectorOptions = { experts: ExpertOption[] diff --git a/packages/tui-components/src/index.ts b/packages/tui-components/src/index.ts index fa313a21..72b6d99e 100644 --- a/packages/tui-components/src/index.ts +++ b/packages/tui-components/src/index.ts @@ -1,3 +1,19 @@ +export type { ExecutionParams, ExecutionResult } from "./execution/index.js" +// Execution +export { renderExecution } from "./execution/index.js" export { useLatestRef } from "./hooks/use-latest-ref.js" export { useListNavigation } from "./hooks/use-list-navigation.js" export { useTextInput } from "./hooks/use-text-input.js" +export type { SelectionParams, SelectionResult } from "./selection/index.js" +// Selection +export { renderSelection } from "./selection/index.js" + +// Types +export type { + CheckpointHistoryItem, + EventHistoryItem, + ExpertOption, + InitialRuntimeConfig, + JobHistoryItem, + RuntimeInfo, +} from "./types/index.js" diff --git a/apps/perstack/src/tui/selection/app.tsx b/packages/tui-components/src/selection/app.tsx similarity index 99% rename from apps/perstack/src/tui/selection/app.tsx rename to packages/tui-components/src/selection/app.tsx index 3b6484ba..a362c963 100644 --- a/apps/perstack/src/tui/selection/app.tsx +++ b/packages/tui-components/src/selection/app.tsx @@ -1,9 +1,9 @@ -import { useTextInput } from "@perstack/tui-components" import { Box, Text, useApp, useInput } from "ink" import { useCallback, useEffect, useMemo, useReducer, useState } from "react" import { BrowserRouter } from "../components/index.js" import { type InputAreaContextValue, InputAreaProvider } from "../context/index.js" import { assertNever } from "../helpers.js" +import { useTextInput } from "../hooks/use-text-input.js" import type { CheckpointHistoryItem, ExpertOption, JobHistoryItem } from "../types/index.js" import type { SelectionParams, SelectionResult } from "./types.js" diff --git a/apps/perstack/src/tui/selection/index.ts b/packages/tui-components/src/selection/index.ts similarity index 100% rename from apps/perstack/src/tui/selection/index.ts rename to packages/tui-components/src/selection/index.ts diff --git a/apps/perstack/src/tui/selection/render.tsx b/packages/tui-components/src/selection/render.tsx similarity index 100% rename from apps/perstack/src/tui/selection/render.tsx rename to packages/tui-components/src/selection/render.tsx diff --git a/apps/perstack/src/tui/selection/types.ts b/packages/tui-components/src/selection/types.ts similarity index 100% rename from apps/perstack/src/tui/selection/types.ts rename to packages/tui-components/src/selection/types.ts diff --git a/apps/perstack/src/tui/types/base.ts b/packages/tui-components/src/types/base.ts similarity index 100% rename from apps/perstack/src/tui/types/base.ts rename to packages/tui-components/src/types/base.ts diff --git a/apps/perstack/src/tui/types/index.ts b/packages/tui-components/src/types/index.ts similarity index 100% rename from apps/perstack/src/tui/types/index.ts rename to packages/tui-components/src/types/index.ts diff --git a/apps/perstack/src/tui/types/input-state.ts b/packages/tui-components/src/types/input-state.ts similarity index 100% rename from apps/perstack/src/tui/types/input-state.ts rename to packages/tui-components/src/types/input-state.ts diff --git a/apps/perstack/src/tui/utils/event-queue.ts b/packages/tui-components/src/utils/event-queue.ts similarity index 100% rename from apps/perstack/src/tui/utils/event-queue.ts rename to packages/tui-components/src/utils/event-queue.ts diff --git a/packages/tui-components/src/utils/index.ts b/packages/tui-components/src/utils/index.ts new file mode 100644 index 00000000..c3be0952 --- /dev/null +++ b/packages/tui-components/src/utils/index.ts @@ -0,0 +1 @@ +export { EventQueue } from "./event-queue.js" diff --git a/packages/tui/package.json b/packages/tui/package.json new file mode 100644 index 00000000..6f75cd23 --- /dev/null +++ b/packages/tui/package.json @@ -0,0 +1,34 @@ +{ + "name": "@perstack/tui", + "private": true, + "version": "0.0.1", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./context": "./src/lib/context.ts", + "./interactive": "./src/lib/interactive.ts", + "./get-env": "./src/lib/get-env.ts", + "./perstack-toml": "./src/lib/perstack-toml.ts" + }, + "scripts": { + "clean": "rm -rf dist", + "build": "pnpm run clean && tsup --config ./tsup.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paralleldrive/cuid2": "^3.0.6", + "@perstack/core": "workspace:*", + "@perstack/tui-components": "workspace:*", + "dotenv": "^17.2.3", + "smol-toml": "^1.6.0" + }, + "devDependencies": { + "@perstack/filesystem-storage": "workspace:*", + "@perstack/runtime": "workspace:*", + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.0.10", + "tsup": "^8.5.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts new file mode 100644 index 00000000..e1efa42e --- /dev/null +++ b/packages/tui/src/index.ts @@ -0,0 +1 @@ +export { type StartHandlerOptions, startHandler } from "./start-handler.js" diff --git a/apps/perstack/src/lib/context.ts b/packages/tui/src/lib/context.ts similarity index 100% rename from apps/perstack/src/lib/context.ts rename to packages/tui/src/lib/context.ts diff --git a/apps/perstack/src/lib/get-env.ts b/packages/tui/src/lib/get-env.ts similarity index 100% rename from apps/perstack/src/lib/get-env.ts rename to packages/tui/src/lib/get-env.ts diff --git a/apps/perstack/src/lib/interactive.ts b/packages/tui/src/lib/interactive.ts similarity index 100% rename from apps/perstack/src/lib/interactive.ts rename to packages/tui/src/lib/interactive.ts diff --git a/apps/perstack/src/lib/perstack-toml.test.ts b/packages/tui/src/lib/perstack-toml.test.ts similarity index 100% rename from apps/perstack/src/lib/perstack-toml.test.ts rename to packages/tui/src/lib/perstack-toml.test.ts diff --git a/apps/perstack/src/lib/perstack-toml.ts b/packages/tui/src/lib/perstack-toml.ts similarity index 100% rename from apps/perstack/src/lib/perstack-toml.ts rename to packages/tui/src/lib/perstack-toml.ts diff --git a/apps/perstack/src/lib/provider-config.ts b/packages/tui/src/lib/provider-config.ts similarity index 100% rename from apps/perstack/src/lib/provider-config.ts rename to packages/tui/src/lib/provider-config.ts diff --git a/apps/perstack/src/lib/run-manager.ts b/packages/tui/src/lib/run-manager.ts similarity index 100% rename from apps/perstack/src/lib/run-manager.ts rename to packages/tui/src/lib/run-manager.ts diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts new file mode 100644 index 00000000..b4c756b7 --- /dev/null +++ b/packages/tui/src/start-handler.ts @@ -0,0 +1,250 @@ +import { createId } from "@paralleldrive/cuid2" +import { + defaultMaxRetries, + defaultTimeout, + type PerstackConfig, + parseWithFriendlyError, + startCommandInputSchema, +} from "@perstack/core" +import { + createInitialJob, + defaultRetrieveCheckpoint, + defaultStoreCheckpoint, + defaultStoreEvent, + retrieveJob, + storeJob, +} from "@perstack/filesystem-storage" +import { findLockfile, loadLockfile, run as perstackRun, runtimeVersion } from "@perstack/runtime" +import { + type CheckpointHistoryItem, + type JobHistoryItem, + renderExecution, + renderSelection, +} from "@perstack/tui-components" +import { resolveRunContext } from "./lib/context.js" +import { parseInteractiveToolCallResult } from "./lib/interactive.js" +import { + getAllEventContentsForJob, + getAllJobs, + getCheckpointById, + getCheckpointsWithDetails, + type getEventContents, + getRecentExperts, +} from "./lib/run-manager.js" + +const CONTINUE_TIMEOUT_MS = 60_000 + +export interface StartHandlerOptions { + perstackConfig?: PerstackConfig + additionalEnv?: (env: Record) => Record +} + +export async function startHandler( + expertKey: string | undefined, + query: string | undefined, + options: Record, + handlerOptions?: StartHandlerOptions, +): Promise { + const input = parseWithFriendlyError(startCommandInputSchema, { expertKey, query, options }) + + try { + // Phase 1: Initialize context + const { perstackConfig, checkpoint, env, providerConfig, model, experts } = + await resolveRunContext({ + configPath: input.options.config, + provider: input.options.provider, + model: input.options.model, + envPath: input.options.envPath, + continue: input.options.continue, + continueJob: input.options.continueJob, + resumeFrom: input.options.resumeFrom, + expertKey: input.expertKey, + perstackConfig: handlerOptions?.perstackConfig, + }) + + if (handlerOptions?.additionalEnv) { + Object.assign(env, handlerOptions.additionalEnv(env)) + } + + const maxSteps = input.options.maxSteps ?? perstackConfig.maxSteps + const maxRetries = input.options.maxRetries ?? perstackConfig.maxRetries ?? defaultMaxRetries + const timeout = input.options.timeout ?? perstackConfig.timeout ?? defaultTimeout + + // Prepare expert lists + const configuredExperts = Object.keys(perstackConfig.experts ?? {}).map((key) => ({ + key, + name: key, + })) + const recentExperts = getRecentExperts(10) + + // Prepare history jobs (only if browsing is needed) + const showHistory = !input.expertKey && !input.query && !checkpoint + const historyJobs: JobHistoryItem[] = showHistory + ? getAllJobs().map((j) => ({ + jobId: j.id, + status: j.status, + expertKey: j.coordinatorExpertKey, + totalSteps: j.totalSteps, + startedAt: j.startedAt, + finishedAt: j.finishedAt, + })) + : [] + + // Phase 2: Selection - get expert, query, and optional checkpoint + const selection = await renderSelection({ + showHistory, + initialExpertKey: input.expertKey, + initialQuery: input.query, + initialCheckpoint: checkpoint + ? { + id: checkpoint.id, + jobId: checkpoint.jobId, + runId: checkpoint.runId, + stepNumber: checkpoint.stepNumber, + contextWindowUsage: checkpoint.contextWindowUsage ?? 0, + } + : undefined, + configuredExperts, + recentExperts, + historyJobs, + onLoadCheckpoints: async (j: JobHistoryItem): Promise => { + const checkpoints = getCheckpointsWithDetails(j.jobId) + return checkpoints.map((cp) => ({ ...cp, jobId: j.jobId })) + }, + }) + + // Validate selection + if (!selection.expertKey) { + console.error("Expert key is required") + return + } + if (!selection.query && !selection.checkpoint) { + console.error("Query is required") + return + } + + // Resolve checkpoint if selected from TUI + let currentCheckpoint = selection.checkpoint + ? getCheckpointById(selection.checkpoint.jobId, selection.checkpoint.id) + : checkpoint + + if (currentCheckpoint && currentCheckpoint.expert.key !== selection.expertKey) { + console.error( + `Checkpoint expert key ${currentCheckpoint.expert.key} does not match input expert key ${selection.expertKey}`, + ) + return + } + + // Load lockfile if present + const lockfilePath = findLockfile() + const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined + + // Phase 3: Execution loop + let currentQuery: string | null = selection.query + let currentJobId = currentCheckpoint?.jobId ?? input.options.jobId ?? createId() + // Track if the next query should be treated as an interactive tool result + let isNextQueryInteractiveToolResult = input.options.interactiveToolCallResult ?? false + + // Track whether this is the first iteration (for historical events display) + // On first iteration, load all events for the job up to the checkpoint + // On subsequent iterations, skip historical events (previous TUI already displayed them) + let isFirstIteration = true + const initialHistoricalEvents: ReturnType | undefined = + currentCheckpoint + ? getAllEventContentsForJob(currentCheckpoint.jobId, currentCheckpoint.stepNumber) + : undefined + + while (currentQuery !== null) { + // Only pass historical events on first iteration + // Subsequent iterations: previous TUI output remains on screen + const historicalEvents = isFirstIteration ? initialHistoricalEvents : undefined + + // Generate a new runId for each iteration + const runId = createId() + + // Start execution TUI + const { result: executionResult, eventListener } = renderExecution({ + expertKey: selection.expertKey, + query: currentQuery, + config: { + runtimeVersion, + model, + maxSteps, + maxRetries, + timeout, + contextWindowUsage: currentCheckpoint?.contextWindowUsage ?? 0, + }, + continueTimeoutMs: CONTINUE_TIMEOUT_MS, + historicalEvents, + }) + + // Run the expert + const runResult = await perstackRun( + { + setting: { + jobId: currentJobId, + runId, + expertKey: selection.expertKey, + input: + isNextQueryInteractiveToolResult && currentCheckpoint + ? parseInteractiveToolCallResult(currentQuery, currentCheckpoint) + : { text: currentQuery }, + experts, + model, + providerConfig, + reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, + maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, + maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, + timeout: input.options.timeout ?? perstackConfig.timeout, + perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, + perstackApiKey: env.PERSTACK_API_KEY, + perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, + env, + verbose: input.options.verbose, + }, + checkpoint: currentCheckpoint, + }, + { + eventListener, + storeCheckpoint: defaultStoreCheckpoint, + storeEvent: defaultStoreEvent, + retrieveCheckpoint: defaultRetrieveCheckpoint, + storeJob, + retrieveJob, + createJob: createInitialJob, + lockfile, + }, + ) + + // Wait for execution TUI to complete (user input or timeout) + const result = await executionResult + + // Check if user wants to continue + const canContinue = + runResult.status === "completed" || + runResult.status === "stoppedByExceededMaxSteps" || + runResult.status === "stoppedByError" || + runResult.status === "stoppedByInteractiveTool" + + if (result.nextQuery && canContinue) { + currentQuery = result.nextQuery + currentCheckpoint = runResult + currentJobId = runResult.jobId + + // If the run stopped for interactive tool, the next query is an interactive tool result + isNextQueryInteractiveToolResult = runResult.status === "stoppedByInteractiveTool" + + // Mark first iteration as complete (subsequent TUIs won't show historical events) + isFirstIteration = false + } else { + currentQuery = null + } + } + } catch (error) { + if (error instanceof Error) { + console.error(error.message) + } else { + console.error(error) + } + } +} diff --git a/packages/tui/tsconfig.json b/packages/tui/tsconfig.json new file mode 100644 index 00000000..1fcfc385 --- /dev/null +++ b/packages/tui/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { "resolveJsonModule": true, "jsx": "react-jsx" }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/tui/tsup.config.ts b/packages/tui/tsup.config.ts new file mode 100644 index 00000000..b82dadf5 --- /dev/null +++ b/packages/tui/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup" +import { baseConfig } from "../../tsup.config.js" + +export default defineConfig({ + ...baseConfig, + entry: { + "src/index": "src/index.ts", + "src/lib/context": "src/lib/context.ts", + "src/lib/interactive": "src/lib/interactive.ts", + "src/lib/get-env": "src/lib/get-env.ts", + "src/lib/perstack-toml": "src/lib/perstack-toml.ts", + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8839090..2b882a1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,12 +102,12 @@ importers: '@perstack/core': specifier: workspace:* version: link:../../packages/core + '@perstack/tui': + specifier: workspace:* + version: link:../../packages/tui commander: specifier: ^14.0.2 version: 14.0.2 - perstack: - specifier: workspace:* - version: link:../perstack smol-toml: specifier: ^1.6.0 version: 1.6.0 @@ -167,24 +167,12 @@ importers: '@perstack/core': specifier: workspace:* version: link:../../packages/core - '@perstack/react': - specifier: workspace:* - version: link:../../packages/react '@perstack/runtime': specifier: workspace:* version: link:../runtime commander: specifier: ^14.0.2 version: 14.0.2 - dotenv: - specifier: ^17.2.3 - version: 17.2.3 - ink: - specifier: ^6.6.0 - version: 6.6.0(@types/react@19.2.9)(react@19.2.3) - react: - specifier: ^19.2.3 - version: 19.2.3 smol-toml: specifier: ^1.6.0 version: 1.6.0 @@ -192,18 +180,15 @@ importers: '@perstack/filesystem-storage': specifier: workspace:* version: link:../../packages/filesystem - '@perstack/tui-components': + '@perstack/tui': specifier: workspace:* - version: link:../../packages/tui-components + version: link:../../packages/tui '@tsconfig/node22': specifier: ^22.0.5 version: 22.0.5 '@types/node': specifier: ^25.0.10 version: 25.0.10 - '@types/react': - specifier: ^19.2.9 - version: 19.2.9 tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -686,8 +671,54 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/tui: + dependencies: + '@paralleldrive/cuid2': + specifier: ^3.0.6 + version: 3.0.6 + '@perstack/core': + specifier: workspace:* + version: link:../core + '@perstack/tui-components': + specifier: workspace:* + version: link:../tui-components + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + smol-toml: + specifier: ^1.6.0 + version: 1.6.0 + devDependencies: + '@perstack/filesystem-storage': + specifier: workspace:* + version: link:../filesystem + '@perstack/runtime': + specifier: workspace:* + version: link:../../apps/runtime + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/node': + specifier: ^25.0.10 + version: 25.0.10 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.0.10)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/tui-components: dependencies: + '@perstack/core': + specifier: workspace:* + version: link:../core + '@perstack/react': + specifier: workspace:* + version: link:../react ink: specifier: ^6.6.0 version: 6.6.0(@types/react@19.2.9)(react@19.2.3)