From bb9a7d616393234694a678080d2ed915ee2a019b Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Mon, 16 Feb 2026 18:05:11 +0000 Subject: [PATCH] refactor: move query input from selection to execution phase - Remove query from SelectionResult/SelectionParams; selection now returns only expertKey + checkpoint - Add "waiting" RunStatus to execution; InterfacePanel collects first query when no query provided - Add queryReady promise to renderExecution; start-handler awaits it before calling perstackRun - Render log events (initializeRuntime, skillConnected) with ActionRow bullet format - Clean up terminal buffer on phase transitions by rendering null before exit - Remove tree indicator from ActionRow, add gap between log events Co-Authored-By: Claude Opus 4.6 --- .changeset/move-query-to-execution.md | 6 + .../src/components/action-row.tsx | 17 +-- .../tui-components/src/components/index.ts | 2 - .../src/components/run-setting.tsx | 81 ------------ .../src/components/streaming-display.tsx | 99 -------------- packages/tui-components/src/constants.ts | 5 +- packages/tui-components/src/execution/app.tsx | 67 +++++++--- .../components/activity-log-panel.tsx | 121 +++-------------- .../components/continue-input-panel.tsx | 46 ------- .../src/execution/components/index.ts | 5 +- .../execution/components/interface-panel.tsx | 89 +++++++++++++ .../src/execution/components/status-panel.tsx | 22 ---- .../src/execution/hooks/index.ts | 10 +- .../execution/hooks/use-execution-state.ts | 124 ++++++++++++++---- .../src/execution/hooks/use-spinner.ts | 31 +++++ .../execution/hooks/use-streaming-phase.ts | 16 +++ .../tui-components/src/execution/render.tsx | 25 +++- .../tui-components/src/execution/types.ts | 4 +- .../src/hooks/use-text-input.ts | 6 +- packages/tui-components/src/selection/app.tsx | 118 ++++++----------- .../tui-components/src/selection/render.tsx | 11 +- .../tui-components/src/selection/types.ts | 7 +- packages/tui/src/start-handler.ts | 32 +++-- 23 files changed, 421 insertions(+), 523 deletions(-) create mode 100644 .changeset/move-query-to-execution.md delete mode 100644 packages/tui-components/src/components/run-setting.tsx delete mode 100644 packages/tui-components/src/components/streaming-display.tsx delete mode 100644 packages/tui-components/src/execution/components/continue-input-panel.tsx create mode 100644 packages/tui-components/src/execution/components/interface-panel.tsx delete mode 100644 packages/tui-components/src/execution/components/status-panel.tsx create mode 100644 packages/tui-components/src/execution/hooks/use-spinner.ts create mode 100644 packages/tui-components/src/execution/hooks/use-streaming-phase.ts diff --git a/.changeset/move-query-to-execution.md b/.changeset/move-query-to-execution.md new file mode 100644 index 00000000..360f71d1 --- /dev/null +++ b/.changeset/move-query-to-execution.md @@ -0,0 +1,6 @@ +--- +"@perstack/tui-components": patch +"@perstack/tui": patch +--- + +refactor: move query input from selection to execution phase diff --git a/packages/tui-components/src/components/action-row.tsx b/packages/tui-components/src/components/action-row.tsx index 3752e33d..1b8230e8 100644 --- a/packages/tui-components/src/components/action-row.tsx +++ b/packages/tui-components/src/components/action-row.tsx @@ -13,11 +13,9 @@ export const ActionRowSimple = ({ text, textDimColor = false, }: ActionRowSimpleProps) => ( - - - - {INDICATOR.BULLET} - + + + {INDICATOR.BULLET} {text} @@ -32,7 +30,7 @@ type ActionRowProps = { children: React.ReactNode } export const ActionRow = ({ indicatorColor, label, summary, children }: ActionRowProps) => ( - + {INDICATOR.BULLET} {label} @@ -42,11 +40,6 @@ export const ActionRow = ({ indicatorColor, label, summary, children }: ActionRo )} - - - {INDICATOR.TREE} - - {children} - + {children} ) diff --git a/packages/tui-components/src/components/index.ts b/packages/tui-components/src/components/index.ts index 5b680271..9548e2fc 100644 --- a/packages/tui-components/src/components/index.ts +++ b/packages/tui-components/src/components/index.ts @@ -3,5 +3,3 @@ export { CheckpointActionRow } from "./checkpoint-action-row.js" export { ExpertList, type ExpertListProps } from "./expert-list.js" export { ExpertSelectorBase, type ExpertSelectorBaseProps } from "./expert-selector-base.js" export { ListBrowser, type ListBrowserProps } from "./list-browser.js" -export { RunSetting, type RunSettingProps } from "./run-setting.js" -export { StreamingDisplay } from "./streaming-display.js" diff --git a/packages/tui-components/src/components/run-setting.tsx b/packages/tui-components/src/components/run-setting.tsx deleted file mode 100644 index 1df66be9..00000000 --- a/packages/tui-components/src/components/run-setting.tsx +++ /dev/null @@ -1,81 +0,0 @@ -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 - eventCount: number - isEditing: boolean - expertName?: string - onQuerySubmit?: (query: string) => void -} -export const RunSetting = ({ - info, - eventCount, - isEditing, - expertName, - onQuerySubmit, -}: RunSettingProps) => { - const { input, handleInput } = useTextInput({ - onSubmit: onQuerySubmit ?? (() => {}), - }) - useInput(handleInput, { isActive: isEditing }) - const displayExpertName = expertName ?? info.expertName - const skills = info.activeSkills.length > 0 ? info.activeSkills.join(", ") : "" - const step = info.currentStep !== undefined ? String(info.currentStep) : "" - const usagePercent = (info.contextWindowUsage * 100).toFixed(1) - return ( - - - - Perstack - - {info.runtimeVersion && (v{info.runtimeVersion})} - - - Expert: - {displayExpertName} - / Skills: - {skills} - - - Status: - - {info.status} - - / Step: - {step} - / Events: - {eventCount} - / Usage: - {usagePercent}% - - - Model: - {info.model} - - - Query: - {isEditing ? ( - <> - {input} - _ - - ) : ( - {info.query} - )} - - - ) -} diff --git a/packages/tui-components/src/components/streaming-display.tsx b/packages/tui-components/src/components/streaming-display.tsx deleted file mode 100644 index 672ae03a..00000000 --- a/packages/tui-components/src/components/streaming-display.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { PerRunStreamingState, StreamingState } from "@perstack/react" -import { Box, Text } from "ink" -import type React from "react" -import { ActionRow } from "./action-row.js" - -type StreamingDisplayProps = { - streaming: StreamingState -} - -/** - * Renders currently active streaming content. - * This component is sandwiched between logs and input fields. - * Content is only shown while actively streaming, and moves to - * logs once complete. - * - * Streaming content is organized by run ID to support parallel execution. - * Each run's streaming content is displayed in a separate section with - * the expert key as a label. - */ -export const StreamingDisplay = ({ streaming }: StreamingDisplayProps): React.ReactNode => { - const activeRuns = Object.entries(streaming.runs).filter( - ([, run]) => run.isReasoningActive || run.isRunResultActive, - ) - - if (activeRuns.length === 0) return null - - return ( - - {activeRuns.map(([runId, run]) => ( - - ))} - - ) -} - -function StreamingRunSection({ run }: { run: PerRunStreamingState }): React.ReactNode { - return ( - - {run.isReasoningActive && run.reasoning !== undefined && ( - - )} - {run.isRunResultActive && run.runResult !== undefined && ( - - )} - - ) -} - -function StreamingReasoning({ - expertKey, - text, -}: { - expertKey: string - text: string -}): React.ReactNode { - const lines = text.split("\n") - const label = `[${formatExpertKey(expertKey)}] Reasoning...` - return ( - - - {lines.map((line, idx) => ( - - {line} - - ))} - - - ) -} - -function StreamingRunResult({ - expertKey, - text, -}: { - expertKey: string - text: string -}): React.ReactNode { - const lines = text.split("\n") - const label = `[${formatExpertKey(expertKey)}] Generating...` - return ( - - - {lines.map((line, idx) => ( - - {line} - - ))} - - - ) -} - -function formatExpertKey(expertKey: string): string { - const atIndex = expertKey.lastIndexOf("@") - if (atIndex > 0) { - return expertKey.substring(0, atIndex) - } - return expertKey -} diff --git a/packages/tui-components/src/constants.ts b/packages/tui-components/src/constants.ts index a06830a2..ff92a5df 100644 --- a/packages/tui-components/src/constants.ts +++ b/packages/tui-components/src/constants.ts @@ -21,7 +21,6 @@ export const RENDER_CONSTANTS = { export const INDICATOR = { CHEVRON_RIGHT: ">", BULLET: "●", - TREE: "└", ELLIPSIS: "...", } as const @@ -47,6 +46,10 @@ export const KEY_BINDINGS = { CTRL_QUIT: "Ctrl+q", } as const +export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const + +export const USAGE_INDICATORS = { LOW: "◔", MEDIUM: "◑", HIGH: "◕", FULL: "●" } as const + export const KEY_HINTS = { NAVIGATE: `${KEY_BINDINGS.NAVIGATE_UP}${KEY_BINDINGS.NAVIGATE_DOWN}:Navigate`, SELECT: `${KEY_BINDINGS.SELECT}:Select`, diff --git a/packages/tui-components/src/execution/app.tsx b/packages/tui-components/src/execution/app.tsx index fd10cd1d..d3d67bfb 100644 --- a/packages/tui-components/src/execution/app.tsx +++ b/packages/tui-components/src/execution/app.tsx @@ -1,21 +1,45 @@ import type { PerstackEvent } from "@perstack/core" -import { Box, useApp, useInput } from "ink" -import { StreamingDisplay } from "../components/index.js" -import { ActivityLogPanel, ContinueInputPanel, StatusPanel } from "./components/index.js" +import { Box, Static, Text, useApp, useInput } from "ink" +import { useCallback, useEffect, useState } from "react" +import { ActionRow, ActionRowSimple } from "../components/action-row.js" +import { ActivityLogItem, InterfacePanel } from "./components/index.js" import { useExecutionState } from "./hooks/index.js" import type { ExecutionParams, ExecutionResult } from "./types.js" type ExecutionAppProps = ExecutionParams & { onReady: (addEvent: (event: PerstackEvent) => void) => void onComplete: (result: ExecutionResult) => void + onQueryReady?: (query: string) => void } export const ExecutionApp = (props: ExecutionAppProps) => { - const { expertKey, query, config, continueTimeoutMs, historicalEvents, onReady, onComplete } = - props + const { + expertKey, + query, + config, + continueTimeoutMs, + historicalEvents, + onReady, + onComplete, + onQueryReady, + } = props const { exit } = useApp() + // Deferred exit: set result → render null → useEffect fires onComplete + exit + const [exitResult, setExitResult] = useState(null) + + const handleComplete = useCallback((result: ExecutionResult) => { + setExitResult(result) + }, []) + + useEffect(() => { + if (exitResult) { + onComplete(exitResult) + exit() + } + }, [exitResult, onComplete, exit]) + const state = useExecutionState({ expertKey, query, @@ -23,7 +47,8 @@ export const ExecutionApp = (props: ExecutionAppProps) => { continueTimeoutMs, historicalEvents, onReady, - onComplete, + onComplete: handleComplete, + onQueryReady, }) useInput((input, key) => { @@ -33,19 +58,31 @@ export const ExecutionApp = (props: ExecutionAppProps) => { } }) + // After exitResult is set, render null so Ink's final frame is empty + if (exitResult) return null + return ( - - - + {(item, index) => { + if (item.type === "logEntry") { + if (item.detail) { + return ( + + {item.detail} + + ) + } + return + } + return + }} + + - ) diff --git a/packages/tui-components/src/execution/components/activity-log-panel.tsx b/packages/tui-components/src/execution/components/activity-log-panel.tsx index 70efe147..f99bdf5a 100644 --- a/packages/tui-components/src/execution/components/activity-log-panel.tsx +++ b/packages/tui-components/src/execution/components/activity-log-panel.tsx @@ -1,53 +1,10 @@ import type { Activity, ActivityOrGroup, ParallelActivitiesGroup } from "@perstack/core" import { Box, Text } from "ink" import type React from "react" -import { useMemo } from "react" import { CheckpointActionRow } from "../../components/index.js" -type RunNode = { - runId: string - expertKey: string - activities: ActivityOrGroup[] - children: RunNode[] -} - -type ActivityLogPanelProps = { - activities: ActivityOrGroup[] -} - -function getActivityKey(activityOrGroup: ActivityOrGroup, index: number): string { - return activityOrGroup.id || `activity-${index}` -} - -const RunBox = ({ node, isRoot }: { node: RunNode; isRoot: boolean }): React.ReactNode => { - if (isRoot) { - return ( - - {node.activities.map((activity, index) => ( - - ))} - {node.children.map((child) => ( - - ))} - - ) - } - - // Show abbreviated runId (first 8 chars) to help identify different runs - const shortRunId = node.runId.slice(0, 8) - return ( - - - [{node.expertKey}] ({shortRunId}) - - {node.activities.map((activity, index) => ( - - ))} - {node.children.map((child) => ( - - ))} - - ) +type ActivityLogItemProps = { + activity: ActivityOrGroup } /** @@ -60,7 +17,6 @@ function getActivityProps( ): Pick { if (activityOrGroup.type === "parallelGroup") { const group = activityOrGroup as ParallelActivitiesGroup - // For groups, check the first activity for delegatedBy info const firstActivity = group.activities[0] return { runId: group.runId, @@ -71,66 +27,19 @@ function getActivityProps( return activityOrGroup } -export const ActivityLogPanel = ({ activities }: ActivityLogPanelProps): React.ReactNode => { - const rootNodes = useMemo(() => { - const nodeMap = new Map() - const roots: RunNode[] = [] - // Track orphan nodes that need parent lookup (parentRunId -> childNode[]) - const orphanChildren = new Map() - - for (const activityOrGroup of activities) { - const { runId, expertKey, delegatedBy } = getActivityProps(activityOrGroup) - - let node = nodeMap.get(runId) - if (!node) { - node = { - runId, - expertKey, - activities: [], - children: [], - } - nodeMap.set(runId, node) - - // Check if any orphan children were waiting for this node - const waitingChildren = orphanChildren.get(runId) - if (waitingChildren) { - for (const child of waitingChildren) { - node.children.push(child) - // Remove from roots if it was added there - const rootIndex = roots.indexOf(child) - if (rootIndex !== -1) { - roots.splice(rootIndex, 1) - } - } - orphanChildren.delete(runId) - } - - if (delegatedBy) { - const parentNode = nodeMap.get(delegatedBy.runId) - if (parentNode) { - parentNode.children.push(node) - } else { - // Parent doesn't exist yet - track as orphan and add to roots temporarily - const orphans = orphanChildren.get(delegatedBy.runId) ?? [] - orphans.push(node) - orphanChildren.set(delegatedBy.runId, orphans) - roots.push(node) - } - } else { - roots.push(node) - } - } - node.activities.push(activityOrGroup) - } +export const ActivityLogItem = ({ activity }: ActivityLogItemProps): React.ReactNode => { + const { delegatedBy, expertKey } = getActivityProps(activity) - return roots - }, [activities]) + if (delegatedBy) { + return ( + + + [{expertKey}] + + + + ) + } - return ( - - {rootNodes.map((node) => ( - - ))} - - ) + return } diff --git a/packages/tui-components/src/execution/components/continue-input-panel.tsx b/packages/tui-components/src/execution/components/continue-input-panel.tsx deleted file mode 100644 index 147126fb..00000000 --- a/packages/tui-components/src/execution/components/continue-input-panel.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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 = { - isActive: boolean - runStatus: RunStatus - onSubmit: (query: string) => void -} - -export const ContinueInputPanel = ({ - isActive, - runStatus, - onSubmit, -}: ContinueInputPanelProps): React.ReactNode => { - const { input, handleInput } = useTextInput({ - onSubmit: (newQuery) => { - if (isActive && newQuery.trim()) { - onSubmit(newQuery.trim()) - } - }, - }) - - useInput(handleInput, { isActive }) - - if (runStatus === "running") { - return null - } - - return ( - - - - {runStatus === "completed" ? "Completed" : "Stopped"} - - - Enter a follow-up query or wait to exit - - - Continue: - {input} - _ - - - ) -} diff --git a/packages/tui-components/src/execution/components/index.ts b/packages/tui-components/src/execution/components/index.ts index c7714f88..7c4807b9 100644 --- a/packages/tui-components/src/execution/components/index.ts +++ b/packages/tui-components/src/execution/components/index.ts @@ -1,3 +1,2 @@ -export { ActivityLogPanel } from "./activity-log-panel.js" -export { ContinueInputPanel } from "./continue-input-panel.js" -export { StatusPanel } from "./status-panel.js" +export { ActivityLogItem } from "./activity-log-panel.js" +export { InterfacePanel } from "./interface-panel.js" diff --git a/packages/tui-components/src/execution/components/interface-panel.tsx b/packages/tui-components/src/execution/components/interface-panel.tsx new file mode 100644 index 00000000..e209906c --- /dev/null +++ b/packages/tui-components/src/execution/components/interface-panel.tsx @@ -0,0 +1,89 @@ +import type { StreamingState } from "@perstack/react" +import { Box, Text, useInput } from "ink" +import type React from "react" +import { USAGE_INDICATORS } from "../../constants.js" +import { useTextInput } from "../../hooks/use-text-input.js" +import type { RuntimeInfo } from "../../types/index.js" +import type { RunStatus } from "../hooks/use-execution-state.js" +import { useSpinner } from "../hooks/use-spinner.js" +import { useStreamingPhase } from "../hooks/use-streaming-phase.js" + +type InterfacePanelProps = { + runtimeInfo: RuntimeInfo + runStatus: RunStatus + streaming: StreamingState + onSubmit: (query: string) => void +} + +function getUsageIcon(percent: number): string { + if (percent <= 25) return USAGE_INDICATORS.LOW + if (percent <= 50) return USAGE_INDICATORS.MEDIUM + if (percent <= 75) return USAGE_INDICATORS.HIGH + return USAGE_INDICATORS.FULL +} + +export const InterfacePanel = ({ + runtimeInfo, + runStatus, + streaming, + onSubmit, +}: InterfacePanelProps): React.ReactNode => { + const streamingPhase = useStreamingPhase(streaming) + const isSpinnerActive = runStatus === "running" + const spinner = useSpinner({ isActive: isSpinnerActive }) + + const { input, handleInput } = useTextInput({ + onSubmit, + canSubmit: runStatus !== "running", + }) + + useInput(handleInput) + + // Derive status label + let statusLabel: React.ReactNode + if (runStatus === "waiting") { + statusLabel = Waiting for query... + } else if (runStatus === "completed") { + statusLabel = Completed + } else if (runStatus === "stopped") { + statusLabel = Stopped + } else if (streamingPhase === "reasoning") { + statusLabel = Streaming Reasoning... + } else if (streamingPhase === "generating") { + statusLabel = Streaming Generation... + } else { + statusLabel = Running... + } + + const step = runtimeInfo.currentStep !== undefined ? String(runtimeInfo.currentStep) : "–" + const usagePercent = (runtimeInfo.contextWindowUsage * 100).toFixed(1) + const usageIcon = getUsageIcon(runtimeInfo.contextWindowUsage * 100) + + return ( + + + {spinner ? {spinner} : null} + {statusLabel} + + + {step} + + {usageIcon} + {usagePercent}% + + + > + {input} + _ + + + ) +} diff --git a/packages/tui-components/src/execution/components/status-panel.tsx b/packages/tui-components/src/execution/components/status-panel.tsx deleted file mode 100644 index 7bc7938e..00000000 --- a/packages/tui-components/src/execution/components/status-panel.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type React from "react" -import { RunSetting } from "../../components/index.js" -import type { RuntimeInfo } from "../../types/index.js" -import type { RunStatus } from "../hooks/index.js" - -type StatusPanelProps = { - runtimeInfo: RuntimeInfo - eventCount: number - runStatus: RunStatus -} - -export const StatusPanel = ({ - runtimeInfo, - eventCount, - runStatus, -}: StatusPanelProps): React.ReactNode => { - if (runStatus !== "running") { - return null - } - - return -} diff --git a/packages/tui-components/src/execution/hooks/index.ts b/packages/tui-components/src/execution/hooks/index.ts index fb538ad4..3b283eb1 100644 --- a/packages/tui-components/src/execution/hooks/index.ts +++ b/packages/tui-components/src/execution/hooks/index.ts @@ -1 +1,9 @@ -export { type ExecutionState, type RunStatus, useExecutionState } from "./use-execution-state.js" +export { + type ExecutionState, + type LogEntry, + type RunStatus, + type StaticItem, + useExecutionState, +} from "./use-execution-state.js" +export { useSpinner } from "./use-spinner.js" +export { type StreamingPhase, useStreamingPhase } from "./use-streaming-phase.js" diff --git a/packages/tui-components/src/execution/hooks/use-execution-state.ts b/packages/tui-components/src/execution/hooks/use-execution-state.ts index d0a3c256..007ba68b 100644 --- a/packages/tui-components/src/execution/hooks/use-execution-state.ts +++ b/packages/tui-components/src/execution/hooks/use-execution-state.ts @@ -1,38 +1,78 @@ -import type { PerstackEvent } from "@perstack/core" +import type { ActivityOrGroup, PerstackEvent } from "@perstack/core" import { useRun } from "@perstack/react" -import { useApp } from "ink" import { useCallback, useEffect, useRef, useState } from "react" import { useRuntimeInfo } from "../../hooks/index.js" import type { InitialRuntimeConfig } from "../../types/index.js" -export type RunStatus = "running" | "completed" | "stopped" +export type RunStatus = "waiting" | "running" | "completed" | "stopped" + +export type LogEntry = { + id: string + type: "logEntry" + label: string + detail?: string + color: "green" | "yellow" | "gray" +} + +export type StaticItem = ActivityOrGroup | LogEntry type UseExecutionStateOptions = { expertKey: string - query: string + query?: string config: InitialRuntimeConfig continueTimeoutMs: number historicalEvents?: PerstackEvent[] onReady: (addEvent: (event: PerstackEvent) => void) => void onComplete: (result: { nextQuery: string | null }) => void + onQueryReady?: (query: string) => void } export type ExecutionState = { - activities: ReturnType["activities"] + staticItems: StaticItem[] streaming: ReturnType["streaming"] - eventCount: number runtimeInfo: ReturnType["runtimeInfo"] runStatus: RunStatus - isAcceptingContinue: boolean - handleContinueSubmit: (query: string) => void + handleSubmit: (query: string) => void clearTimeout: () => void } +let logEntryCounter = 0 + +function createLogEntry(label: string, color: LogEntry["color"], detail?: string): LogEntry { + logEntryCounter++ + return { id: `log-${logEntryCounter}`, type: "logEntry", label, color, detail } +} + +function extractLogEntriesFromEvent(event: PerstackEvent): LogEntry[] { + const entries: LogEntry[] = [] + if (event.type === "initializeRuntime") { + entries.push( + createLogEntry( + "Initialize Runtime", + "green", + `Perstack v${event.runtimeVersion} · ${event.expertName} · ${event.model}`, + ), + ) + } else if (event.type === "skillConnected") { + entries.push(createLogEntry(`Skill Connected: ${event.skillName}`, "green")) + } else if (event.type === "skillDisconnected") { + entries.push(createLogEntry(`Skill Disconnected: ${event.skillName}`, "yellow")) + } + return entries +} + export const useExecutionState = (options: UseExecutionStateOptions): ExecutionState => { - const { expertKey, query, config, continueTimeoutMs, historicalEvents, onReady, onComplete } = - options + const { + expertKey, + query, + config, + continueTimeoutMs, + historicalEvents, + onReady, + onComplete, + onQueryReady, + } = options - const { exit } = useApp() const runState = useRun() const { runtimeInfo, handleEvent, setQuery } = useRuntimeInfo({ @@ -40,9 +80,10 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS initialConfig: config, }) - const [runStatus, setRunStatus] = useState("running") - const [isAcceptingContinue, setIsAcceptingContinue] = useState(false) + const [runStatus, setRunStatus] = useState(query ? "running" : "waiting") + const [staticItems, setStaticItems] = useState([]) const timeoutRef = useRef(null) + const lastSyncedCountRef = useRef(0) const clearTimeoutIfExists = useCallback(() => { if (timeoutRef.current) { @@ -55,32 +96,56 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS clearTimeoutIfExists() timeoutRef.current = setTimeout(() => { onComplete({ nextQuery: null }) - exit() }, continueTimeoutMs) - }, [clearTimeoutIfExists, continueTimeoutMs, onComplete, exit]) + }, [clearTimeoutIfExists, continueTimeoutMs, onComplete]) useEffect(() => { - setQuery(query) + if (query) { + setQuery(query) + } }, [query, setQuery]) + // Sync historical events: extract log entries for init/skill events useEffect(() => { if (historicalEvents && historicalEvents.length > 0) { runState.appendHistoricalEvents(historicalEvents) + + const logEntries: LogEntry[] = [] + for (const event of historicalEvents) { + logEntries.push(...extractLogEntriesFromEvent(event)) + } + if (logEntries.length > 0) { + setStaticItems((prev) => [...prev, ...logEntries]) + } } }, [historicalEvents, runState.appendHistoricalEvents]) + // Sync new activities from runState into staticItems + useEffect(() => { + const currentActivities = runState.activities + if (currentActivities.length > lastSyncedCountRef.current) { + const newItems = currentActivities.slice(lastSyncedCountRef.current) + lastSyncedCountRef.current = currentActivities.length + setStaticItems((prev) => [...prev, ...newItems]) + } + }, [runState.activities]) + useEffect(() => { onReady((event: PerstackEvent) => { runState.addEvent(event) const result = handleEvent(event) + // Extract log entries from runtime events + const logEntries = extractLogEntriesFromEvent(event) + if (logEntries.length > 0) { + setStaticItems((prev) => [...prev, ...logEntries]) + } + if (result?.completed) { setRunStatus("completed") - setIsAcceptingContinue(true) startExitTimeout() } else if (result?.stopped) { setRunStatus("stopped") - setIsAcceptingContinue(true) startExitTimeout() } }) @@ -92,25 +157,32 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS } }, [clearTimeoutIfExists]) - const handleContinueSubmit = useCallback( + const handleSubmit = useCallback( (newQuery: string) => { - if (isAcceptingContinue && newQuery.trim()) { + if (!newQuery.trim()) return + + if (runStatus === "waiting") { + const trimmed = newQuery.trim() + setQuery(trimmed) + setRunStatus("running") + onQueryReady?.(trimmed) + return + } + + if (runStatus !== "running") { clearTimeoutIfExists() onComplete({ nextQuery: newQuery.trim() }) - exit() } }, - [isAcceptingContinue, clearTimeoutIfExists, onComplete, exit], + [runStatus, clearTimeoutIfExists, onComplete, setQuery, onQueryReady], ) return { - activities: runState.activities, + staticItems, streaming: runState.streaming, - eventCount: runState.eventCount, runtimeInfo, runStatus, - isAcceptingContinue, - handleContinueSubmit, + handleSubmit, clearTimeout: clearTimeoutIfExists, } } diff --git a/packages/tui-components/src/execution/hooks/use-spinner.ts b/packages/tui-components/src/execution/hooks/use-spinner.ts new file mode 100644 index 00000000..899f4b88 --- /dev/null +++ b/packages/tui-components/src/execution/hooks/use-spinner.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from "react" +import { SPINNER_FRAMES } from "../../constants.js" + +export const useSpinner = ({ isActive }: { isActive: boolean }): string => { + const [index, setIndex] = useState(0) + const intervalRef = useRef(null) + + useEffect(() => { + if (isActive) { + intervalRef.current = setInterval(() => { + setIndex((prev) => (prev + 1) % SPINNER_FRAMES.length) + }, 80) + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + setIndex(0) + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [isActive]) + + if (!isActive) return "" + return SPINNER_FRAMES[index] +} diff --git a/packages/tui-components/src/execution/hooks/use-streaming-phase.ts b/packages/tui-components/src/execution/hooks/use-streaming-phase.ts new file mode 100644 index 00000000..eefdf2e2 --- /dev/null +++ b/packages/tui-components/src/execution/hooks/use-streaming-phase.ts @@ -0,0 +1,16 @@ +import type { StreamingState } from "@perstack/react" +import { useMemo } from "react" + +export type StreamingPhase = "idle" | "reasoning" | "generating" + +export function deriveStreamingPhase(streaming: StreamingState): StreamingPhase { + for (const run of Object.values(streaming.runs)) { + if (run.isReasoningActive) return "reasoning" + if (run.isRunResultActive) return "generating" + } + return "idle" +} + +export const useStreamingPhase = (streaming: StreamingState): StreamingPhase => { + return useMemo(() => deriveStreamingPhase(streaming), [streaming]) +} diff --git a/packages/tui-components/src/execution/render.tsx b/packages/tui-components/src/execution/render.tsx index cba49713..55268895 100644 --- a/packages/tui-components/src/execution/render.tsx +++ b/packages/tui-components/src/execution/render.tsx @@ -7,18 +7,30 @@ import type { ExecutionParams, ExecutionResult } from "./types.js" type RenderExecutionResult = { result: Promise eventListener: (event: PerstackEvent) => void + queryReady: Promise } /** * Renders the execution TUI phase. * Returns a promise that resolves with the execution result (next query or null). * Also returns an event listener to feed events into the TUI. + * Also returns a queryReady promise that resolves when the user submits their first query. */ export function renderExecution(params: ExecutionParams): RenderExecutionResult { const eventQueue = new EventQueue() + let resolveQuery: (q: string) => void + const queryReady = new Promise((resolve) => { + resolveQuery = resolve + }) + + // If query is already provided, resolve immediately + if (params.query) { + resolveQuery!(params.query) + } + const result = new Promise((resolve, reject) => { - let resolved = false + let executionResult: ExecutionResult | undefined const { waitUntilExit } = render( { - resolved = true - resolve(result) + executionResult = result + }} + onQueryReady={(query) => { + resolveQuery!(query) }} />, ) waitUntilExit() .then(() => { - if (!resolved) { + if (executionResult) { + resolve(executionResult) + } else { reject(new PerstackError("Execution cancelled")) } }) @@ -47,5 +63,6 @@ export function renderExecution(params: ExecutionParams): RenderExecutionResult eventListener: (event: PerstackEvent) => { eventQueue.emit(event) }, + queryReady, } } diff --git a/packages/tui-components/src/execution/types.ts b/packages/tui-components/src/execution/types.ts index 78ab62f0..5bec05fb 100644 --- a/packages/tui-components/src/execution/types.ts +++ b/packages/tui-components/src/execution/types.ts @@ -15,8 +15,8 @@ export type ExecutionResult = { export type ExecutionParams = { /** Expert key being executed */ expertKey: string - /** Initial query */ - query: string + /** Initial query (undefined = waiting for user input) */ + query?: string /** Runtime configuration */ config: InitialRuntimeConfig /** Timeout for continue input in milliseconds */ diff --git a/packages/tui-components/src/hooks/use-text-input.ts b/packages/tui-components/src/hooks/use-text-input.ts index d9118ac4..550ab291 100644 --- a/packages/tui-components/src/hooks/use-text-input.ts +++ b/packages/tui-components/src/hooks/use-text-input.ts @@ -5,12 +5,14 @@ import { useLatestRef } from "./use-latest-ref.js" type UseTextInputOptions = { onSubmit: (value: string) => void onCancel?: () => void + canSubmit?: boolean } export const useTextInput = (options: UseTextInputOptions) => { const [input, setInput] = useState("") const inputRef = useLatestRef(input) const onSubmitRef = useLatestRef(options.onSubmit) const onCancelRef = useLatestRef(options.onCancel) + const canSubmitRef = useLatestRef(options.canSubmit ?? true) const handleInput = useCallback( (inputChar: string, key: Key): void => { if (key.escape) { @@ -18,7 +20,7 @@ export const useTextInput = (options: UseTextInputOptions) => { onCancelRef.current?.() return } - if (key.return && inputRef.current.trim()) { + if (key.return && inputRef.current.trim() && canSubmitRef.current) { onSubmitRef.current(inputRef.current.trim()) setInput("") return @@ -31,7 +33,7 @@ export const useTextInput = (options: UseTextInputOptions) => { setInput((prev) => prev + inputChar) } }, - [inputRef, onSubmitRef, onCancelRef], + [inputRef, onSubmitRef, onCancelRef, canSubmitRef], ) const reset = useCallback(() => { setInput("") diff --git a/packages/tui-components/src/selection/app.tsx b/packages/tui-components/src/selection/app.tsx index a362c963..fa728915 100644 --- a/packages/tui-components/src/selection/app.tsx +++ b/packages/tui-components/src/selection/app.tsx @@ -1,9 +1,8 @@ -import { Box, Text, useApp, useInput } from "ink" +import { Box, 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" @@ -12,12 +11,10 @@ type SelectionState = | { type: "browsingHistory"; jobs: JobHistoryItem[] } | { type: "browsingExperts"; experts: ExpertOption[] } | { type: "browsingCheckpoints"; job: JobHistoryItem; checkpoints: CheckpointHistoryItem[] } - | { type: "enteringQuery"; expertKey: string } type SelectionAction = | { type: "BROWSE_HISTORY"; jobs: JobHistoryItem[] } | { type: "BROWSE_EXPERTS"; experts: ExpertOption[] } - | { type: "SELECT_EXPERT"; expertKey: string } | { type: "SELECT_JOB"; job: JobHistoryItem; checkpoints: CheckpointHistoryItem[] } | { type: "GO_BACK_FROM_CHECKPOINTS"; jobs: JobHistoryItem[] } @@ -27,8 +24,6 @@ const selectionReducer = (_state: SelectionState, action: SelectionAction): Sele return { type: "browsingHistory", jobs: action.jobs } case "BROWSE_EXPERTS": return { type: "browsingExperts", experts: action.experts } - case "SELECT_EXPERT": - return { type: "enteringQuery", expertKey: action.expertKey } case "SELECT_JOB": return { type: "browsingCheckpoints", job: action.job, checkpoints: action.checkpoints } case "GO_BACK_FROM_CHECKPOINTS": @@ -46,7 +41,6 @@ export const SelectionApp = (props: SelectionAppProps) => { const { showHistory, initialExpertKey, - initialQuery, initialCheckpoint, configuredExperts, recentExperts, @@ -68,10 +62,6 @@ export const SelectionApp = (props: SelectionAppProps) => { // Determine initial state const getInitialState = (): SelectionState => { - // If expert and query are both provided, we'll complete immediately (handled in useEffect) - if (initialExpertKey && !initialQuery) { - return { type: "enteringQuery", expertKey: initialExpertKey } - } if (showHistory && historyJobs.length > 0) { return { type: "browsingHistory", jobs: historyJobs } } @@ -79,42 +69,42 @@ export const SelectionApp = (props: SelectionAppProps) => { } const [state, dispatch] = useReducer(selectionReducer, undefined, getInitialState) - const [selectedCheckpoint, setSelectedCheckpoint] = useState( - initialCheckpoint, - ) - // If both expert and query are provided, complete immediately + // Deferred exit: set result → render null → useEffect fires onComplete + exit + const [exitResult, setExitResult] = useState(null) + + useEffect(() => { + if (exitResult) { + onComplete(exitResult) + exit() + } + }, [exitResult, onComplete, exit]) + + // If expert key is provided, complete immediately (never rendered anything) useEffect(() => { - if (initialExpertKey && initialQuery) { + if (initialExpertKey) { onComplete({ expertKey: initialExpertKey, - query: initialQuery, checkpoint: initialCheckpoint, }) exit() } - }, [initialExpertKey, initialQuery, initialCheckpoint, onComplete, exit]) - - // Text input for query - const { input: queryInput, handleInput: handleQueryInput } = useTextInput({ - onSubmit: (query) => { - if (state.type === "enteringQuery" && query.trim()) { - onComplete({ - expertKey: state.expertKey, - query: query.trim(), - checkpoint: selectedCheckpoint, - }) - exit() - } - }, - }) - - useInput(handleQueryInput, { isActive: state.type === "enteringQuery" }) + }, [initialExpertKey, initialCheckpoint, onComplete, exit]) // Handlers - const handleExpertSelect = useCallback((expertKey: string) => { - dispatch({ type: "SELECT_EXPERT", expertKey }) - }, []) + const completeWithExpert = useCallback( + (expertKey: string, checkpoint?: CheckpointHistoryItem) => { + setExitResult({ expertKey, checkpoint }) + }, + [], + ) + + const handleExpertSelect = useCallback( + (expertKey: string) => { + completeWithExpert(expertKey) + }, + [completeWithExpert], + ) const handleJobSelect = useCallback( async (job: JobHistoryItem) => { @@ -135,27 +125,25 @@ export const SelectionApp = (props: SelectionAppProps) => { const checkpoints = await onLoadCheckpoints(job) const latestCheckpoint = checkpoints[0] if (latestCheckpoint) { - setSelectedCheckpoint(latestCheckpoint) + completeWithExpert(job.expertKey, latestCheckpoint) + } else { + completeWithExpert(job.expertKey) } - // Enter query mode regardless of whether checkpoint exists - dispatch({ type: "SELECT_EXPERT", expertKey: job.expertKey }) } catch { - // Failed to load checkpoints, just enter query mode without checkpoint - dispatch({ type: "SELECT_EXPERT", expertKey: job.expertKey }) + // Failed to load checkpoints, just complete without checkpoint + completeWithExpert(job.expertKey) } }, - [onLoadCheckpoints], + [onLoadCheckpoints, completeWithExpert], ) const handleCheckpointResume = useCallback( (checkpoint: CheckpointHistoryItem) => { - setSelectedCheckpoint(checkpoint) - // Need to get expertKey from the job - for now use empty and let parent handle if (state.type === "browsingCheckpoints") { - dispatch({ type: "SELECT_EXPERT", expertKey: state.job.expertKey }) + completeWithExpert(state.job.expertKey, checkpoint) } }, - [state], + [state, completeWithExpert], ) const handleBack = useCallback(() => { @@ -204,43 +192,19 @@ export const SelectionApp = (props: SelectionAppProps) => { ], ) - // If already completed via useEffect, don't render - if (initialExpertKey && initialQuery) { + // After exitResult is set or initialExpertKey shortcut, render null + if (exitResult || initialExpertKey) { return null } return ( - {(state.type === "browsingHistory" || - state.type === "browsingExperts" || - state.type === "browsingCheckpoints") && ( - [0]["inputState"]} - showEventsHint={false} - /> - )} + [0]["inputState"]} + showEventsHint={false} + /> - - {state.type === "enteringQuery" && ( - - - - Expert: - {" "} - {state.expertKey} - {selectedCheckpoint && ( - (resuming from step {selectedCheckpoint.stepNumber}) - )} - - - Query: - {queryInput} - _ - - Press Enter to start - - )} ) } diff --git a/packages/tui-components/src/selection/render.tsx b/packages/tui-components/src/selection/render.tsx index f9f7cb9f..af86df6f 100644 --- a/packages/tui-components/src/selection/render.tsx +++ b/packages/tui-components/src/selection/render.tsx @@ -5,25 +5,26 @@ import type { SelectionParams, SelectionResult } from "./types.js" /** * Renders the selection TUI phase. - * Returns a promise that resolves with the selection result (expert, query, checkpoint). + * Returns a promise that resolves with the selection result (expert and checkpoint). */ export async function renderSelection(params: SelectionParams): Promise { return new Promise((resolve, reject) => { - let resolved = false + let selectionResult: SelectionResult | undefined const { waitUntilExit } = render( { - resolved = true - resolve(result) + selectionResult = result }} />, ) waitUntilExit() .then(() => { - if (!resolved) { + if (selectionResult) { + resolve(selectionResult) + } else { reject(new PerstackError("Selection cancelled")) } }) diff --git a/packages/tui-components/src/selection/types.ts b/packages/tui-components/src/selection/types.ts index 091ec379..c8f08635 100644 --- a/packages/tui-components/src/selection/types.ts +++ b/packages/tui-components/src/selection/types.ts @@ -2,11 +2,10 @@ import type { CheckpointHistoryItem, ExpertOption, JobHistoryItem } from "../typ /** * Result returned by the selection TUI phase. - * Contains all information needed to start a run. + * Contains the expert key and optional checkpoint to start a run. */ export type SelectionResult = { expertKey: string - query: string checkpoint: CheckpointHistoryItem | undefined } @@ -16,10 +15,8 @@ export type SelectionResult = { export type SelectionParams = { /** Whether to show history browser */ showHistory: boolean - /** Pre-selected expert key (skip expert selection if provided with query) */ + /** Pre-selected expert key (skip selection if provided) */ initialExpertKey: string | undefined - /** Pre-filled query (skip query input if provided with expert) */ - initialQuery: string | undefined /** Pre-selected checkpoint for resume */ initialCheckpoint: CheckpointHistoryItem | undefined /** Experts from perstack.toml */ diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts index 6be45ea1..07a40d10 100644 --- a/packages/tui/src/start-handler.ts +++ b/packages/tui/src/start-handler.ts @@ -90,11 +90,10 @@ export async function startHandler( })) : [] - // Phase 2: Selection - get expert, query, and optional checkpoint + // Phase 2: Selection - get expert and optional checkpoint (no query) const selection = await renderSelection({ showHistory, initialExpertKey: input.expertKey, - initialQuery: input.query, initialCheckpoint: checkpoint ? { id: checkpoint.id, @@ -118,10 +117,6 @@ export async function startHandler( 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 @@ -138,7 +133,7 @@ export async function startHandler( const lockfile = handlerOptions.lockfile // Phase 3: Execution loop - let currentQuery: string | null = selection.query + let currentQuery: string | null = input.query ?? null 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 @@ -151,7 +146,9 @@ export async function startHandler( ? getAllEventContentsForJob(currentCheckpoint.jobId, currentCheckpoint.stepNumber) : undefined - while (currentQuery !== null) { + // First iteration: if no query from CLI, the execution TUI will collect it + // Subsequent iterations: query is always known from the previous TUI's continue input + while (true) { // Only pass historical events on first iteration // Subsequent iterations: previous TUI output remains on screen const historicalEvents = isFirstIteration ? initialHistoricalEvents : undefined @@ -159,10 +156,14 @@ export async function startHandler( // Generate a new runId for each iteration const runId = createId() - // Start execution TUI - const { result: executionResult, eventListener } = renderExecution({ + // Start execution TUI (query may be undefined on first iteration) + const { + result: executionResult, + eventListener, + queryReady, + } = renderExecution({ expertKey: selection.expertKey, - query: currentQuery, + query: currentQuery ?? undefined, config: { runtimeVersion, model, @@ -175,6 +176,9 @@ export async function startHandler( historicalEvents, }) + // Wait for query to be ready (resolves immediately if provided) + const resolvedQuery = await queryReady + // Run the expert const runResult = await perstackRun( { @@ -184,8 +188,8 @@ export async function startHandler( expertKey: selection.expertKey, input: isNextQueryInteractiveToolResult && currentCheckpoint - ? parseInteractiveToolCallResult(currentQuery, currentCheckpoint) - : { text: currentQuery }, + ? parseInteractiveToolCallResult(resolvedQuery, currentCheckpoint) + : { text: resolvedQuery }, experts, model, providerConfig, @@ -234,7 +238,7 @@ export async function startHandler( // Mark first iteration as complete (subsequent TUIs won't show historical events) isFirstIteration = false } else { - currentQuery = null + break } } }