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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ picker to return to input selection, then `Esc` again to close rewind.
/status inspect detailed runtime and session status
/rename <title> rename the current session
/activity inspect every active tool and open task
/workflows inspect workflow progress, artifacts and recovery
/tasks inspect and manage background tasks
/tasks message <id> <text> send guidance to a running background agent
/tasks resume <id> [text] resume a stopped or failed background agent
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
"test:node": "node --test test/node/*.test.cjs",
"test:tui": "bun run test:tui:component && bun run test:tui:e2e",
"test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts",
"test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/http-mock.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts",
"test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts",
"test:tui:host": "bun test test/tui/scenario-mountx.test.ts",
"test:tui:manual": "bun scripts/tui-scenario.ts --manual",
"test:tui-scenario": "bun scripts/tui-scenario.ts",
Expand Down
138 changes: 138 additions & 0 deletions packages/zcode-tui/src/dynamic-workflows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { asString, isRecord, type RuntimeAdapter, type UnknownRecord } from "./types.ts";
import { sanitizeTerminalText } from "./terminal-text.ts";

const maximumRuns = 20;
const maximumBufferedEvents = 2_000;

export class DynamicWorkflows {
private state: unknown;
private summaries: UnknownRecord[] = [];
private epoch = 0;
private loading?: Promise<void>;
private buffered: UnknownRecord[] = [];
private watermarks = new Map<string, number>();
private lastEvents = new Map<string, string>();
private incomplete = false;
error?: string;

constructor(private readonly adapter: Pick<RuntimeAdapter, "listWorkflowRuns" | "replayWorkflowRuns" | "reduceWorkflowRuns">) {}

reset(): void {
this.epoch++;
this.state = undefined;
this.summaries = [];
this.loading = undefined;
this.buffered = [];
this.watermarks.clear();
this.lastEvents.clear();
this.error = undefined;
this.incomplete = false;
}

runs(): UnknownRecord[] {
const runs = isRecord(this.state) && Array.isArray(this.state.runs) ? this.state.runs.filter(isRecord) : [];
const byId = new Map(runs.map((run) => [run.runId, run]));
const summaries = this.summaries.map((summary) => {
const run = byId.get(summary.runId);
byId.delete(summary.runId);
return run ? { ...run, label: summary.label, updatedAt: summary.updatedAt } : summary;
});
return [...summaries, ...byId.values()].slice(0, maximumRuns)
.map((run) => this.incomplete ? { ...run, progressIncomplete: true } : run);
}

accept(event: unknown): boolean {
if (!isRecord(event) || typeof event.runId !== "string" || typeof event.eventType !== "string") return false;
if (this.loading) {
if (this.buffered.length < maximumBufferedEvents) this.buffered.push(event);
else {
this.incomplete = true;
this.error = "Some workflow progress was omitted. Live runs cannot be fully replayed in this process; use /dwf list for runtime status.";
}
return false;
}
return this.apply(event);
}

private apply(event: UnknownRecord): boolean {
if (!this.adapter.reduceWorkflowRuns) return false;
const id = String(event.runId);
const sequence = typeof event.sequence === "number" ? event.sequence : undefined;
const watermark = this.watermarks.get(id) ?? -1;
const fingerprint = JSON.stringify(event);
if (sequence !== undefined && (sequence < watermark
|| sequence === watermark && this.lastEvents.get(id) === fingerprint)) return false;
try {
const state = this.adapter.reduceWorkflowRuns(this.state, event);
if (sequence !== undefined) this.watermarks.set(id, sequence);
this.lastEvents.set(id, fingerprint);
if (!state) return false;
this.state = state;
// The runtime bounds its retained runs; mirror its retention for cursors.
if (isRecord(state) && Array.isArray(state.runs)) {
const ids = new Set(state.runs.filter(isRecord).map((run) => String(run.runId)));
for (const key of this.watermarks.keys()) if (!ids.has(key)) {
this.watermarks.delete(key);
this.lastEvents.delete(key);
}
}
return true;
} catch (error) {
this.error = `Workflow progress could not be read: ${error instanceof Error ? error.message : String(error)}`;
return false;
}
}

hydrate(): Promise<void> {
if (this.loading) return this.loading;
const epoch = this.epoch;
if (!this.incomplete) this.error = undefined;
const operation = Promise.resolve().then(async () => {
// Read replay before summaries. Live events are buffered until replay is
// applied, then sequence watermarks discard duplicate deliveries.
const results = await Promise.allSettled([
this.adapter.replayWorkflowRuns?.({ excludeRunIds: new Set<string>() }),
this.adapter.listWorkflowRuns?.()
]);
if (epoch !== this.epoch) return;
const [replay, summaries] = results;
if (replay.status === "fulfilled" && Array.isArray(replay.value)) {
for (const event of replay.value) if (isRecord(event)) this.apply(event);
}
if (summaries.status === "fulfilled" && Array.isArray(summaries.value)) {
this.summaries = summaries.value.filter((value) => isRecord(value) && typeof value.runId === "string").slice(0, maximumRuns);
}
for (const result of results) if (result.status === "rejected") {
this.error = `Workflow history could not be loaded: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}. Run /workflows to retry.`;
}
}).finally(() => {
if (epoch !== this.epoch) return;
this.loading = undefined;
for (const event of this.buffered) this.apply(event);
this.buffered = [];
});
this.loading = operation;
return operation;
}
}

function safe(value: unknown): string {
return sanitizeTerminalText(typeof value === "string" ? value : "", { preserveSgr: false }).slice(0, 2_000);
}

export function workflowRunDetail(run: UnknownRecord): string {
const nodes = Array.isArray(run.nodes) ? run.nodes.filter(isRecord) : undefined;
const actors = Array.isArray(run.actors) ? run.actors.filter(isRecord) : [];
const artifacts = Array.isArray(run.artifacts) ? run.artifacts.filter(isRecord) : [];
const usage = isRecord(run.usage) ? run.usage : undefined;
const lines = [safe(run.label) || safe(run.runId), `Status: ${safe(run.status) || "unknown"}${run.stopReason ? ` · ${safe(run.stopReason)}` : ""}`];
if (run.progressIncomplete === true) lines.push("Progress is incomplete. Use /dwf list for runtime status.");
if (nodes) lines.push(`Steps: ${nodes.filter((node) => node.phase === "settled").length}/${nodes.length} observed steps settled`);
if (typeof usage?.spentTokens === "number") lines.push(`Tokens: ${usage.spentTokens.toLocaleString()}`);
if (run.resumable === true) lines.push("This run can be resumed.");
if (actors.length) lines.push("", "Agents", ...actors.slice(0, 12).map((actor) => `${safe(actor.name) || safe(actor.sessionId) || "Agent"} · ${safe(actor.status)}`));
if (artifacts.length) lines.push("", "Artifacts", ...artifacts.slice(0, 12).map((artifact) => safe(artifact.title) || safe(artifact.artifactId) || safe(artifact.id)));
if (asString(run.error)) lines.push("", `Error: ${safe(run.error)}`);
if (asString(run.resultPreview)) lines.push("", safe(run.resultPreview));
return lines.join("\n");
}
86 changes: 85 additions & 1 deletion packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ import { isVisibleProtocolPart, ProtocolPartView } from "./protocol-part-view.ts
import { InputQueue, type QueuedSubmission } from "./input-queue.ts";
import { QueuedInputView } from "./queued-input-view.ts";
import { RuntimeActivityView } from "./runtime-activity-view.ts";
import { DynamicWorkflows, workflowRunDetail } from "./dynamic-workflows.ts";
import { RuntimeContextCache } from "./runtime-context-cache.ts";
import {
runtimeActivityActive,
Expand Down Expand Up @@ -679,6 +680,9 @@ class ZCodeTui {
private readonly turnWork = new TurnWorkTracker();
private readonly backgroundCoordinatorMessageIds = new Set<string>();
private workflowPanel?: Record<string, unknown>;
private readonly dynamicWorkflows: DynamicWorkflows;
private dynamicWorkflowView?: Text;
private selectedDynamicWorkflow?: string;
private workflowView?: Markdown;
private workflowRefreshInFlight = false;
private readonly permissionRequests = new PermissionRequestQueue();
Expand Down Expand Up @@ -727,6 +731,7 @@ class ZCodeTui {
private removeStreamErrorGuards?: () => void;

constructor(private readonly options: TuiOptions) {
this.dynamicWorkflows = new DynamicWorkflows(options);
this.animateTurnTimer = turnTimerAnimationEnabled();
this.colorsEnabled = !options.noColor && !process.env.NO_COLOR;
this.themePreference = themePreference(options.theme);
Expand Down Expand Up @@ -904,6 +909,7 @@ class ZCodeTui {
this.onSessionEvent(event);
}) ?? undefined;
}
void this.dynamicWorkflows.hydrate().then(() => this.renderDynamicWorkflow());
if (this.options.subscribeWorkflowEvents) {
this.unsubscribeWorkflow = this.options.subscribeWorkflowEvents((event) => {
this.debugEvent("workflow", event);
Expand Down Expand Up @@ -1379,6 +1385,7 @@ class ZCodeTui {
{ name: "paste-image", description: "Attach an image from the system clipboard" },
{ name: "attachments", description: "Manage or clear pending attachments", argumentHint: "[clear]" },
{ name: "activity", description: "Inspect every active tool and open task" },
{ name: "workflows", description: "Inspect workflow progress, results and recovery" },
{
name: "tasks",
description: "Inspect, message or recover background tasks",
Expand Down Expand Up @@ -1599,6 +1606,7 @@ class ZCodeTui {
if (input === "/cls") {
this.clearTranscriptProjection();
this.workflowView = undefined;
this.dynamicWorkflowView = undefined;
this.ui.requestRender(true);
return;
}
Expand Down Expand Up @@ -1634,6 +1642,10 @@ class ZCodeTui {
await this.showActivityDetails();
return;
}
if ((input === "/workflows" || input === "/workflows list") && this.options.listWorkflowRuns) {
await this.showDynamicWorkflows();
return;
}
if (input === "/tasks" || input === "/tasks list") {
await this.showBackgroundTasks();
return;
Expand Down Expand Up @@ -2159,6 +2171,9 @@ class ZCodeTui {
if (!isRecord(result)) return;
this.runtimeContextCache.invalidate();
if (result.resetSessionProjection === true) {
this.dynamicWorkflows.reset();
this.dynamicWorkflowView = undefined;
this.selectedDynamicWorkflow = undefined;
this.runtimeContextCache.reset();
this.executionStateRevision++;
this.clearTranscriptProjection();
Expand Down Expand Up @@ -2222,6 +2237,7 @@ class ZCodeTui {
this.updateMetadata();
this.ui.requestRender();
if (this.sessionModelIssue) await this.recoverSessionModel();
void this.dynamicWorkflows.hydrate().then(() => this.renderDynamicWorkflow());
}
this.scheduleRuntimeRefresh(0);
}
Expand All @@ -2232,6 +2248,7 @@ class ZCodeTui {
const event = normalizeEvent(value);
if (!event || this.isForeignSessionEvent(event)) return;
if (runtimeContextRefreshNeeded(event)) this.runtimeContextCache.invalidate();
if (this.handleDynamicWorkflowEvent(value, event.type)) return;
const taskScoped = this.backgroundTaskEvents.isTaskScoped(event);
this.applyBackgroundTaskEvent(event);
if (!taskScoped && event.kind && toolLifecycleEventKinds.has(event.kind)) this.turnHadWorkActivity = true;
Expand Down Expand Up @@ -2408,12 +2425,79 @@ class ZCodeTui {
const event = normalizeEvent(value);
if (!event || this.isForeignSessionEvent(event)) return;
if (runtimeContextRefreshNeeded(event)) this.runtimeContextCache.invalidate();
if (this.handleDynamicWorkflowEvent(value, event.type)) return;
this.applyBackgroundTaskEvent(event);
if (runtimeRefreshNeeded(event)) this.scheduleRuntimeRefresh();
}

private isForeignSessionEvent(event: StreamEvent): boolean {
return Boolean(this.sessionId && event.sessionId && event.sessionId !== this.sessionId);
const sessionId = this.options.getMainSessionId?.() ?? this.sessionId;
return Boolean(sessionId && event.sessionId && event.sessionId !== sessionId);
}

private handleDynamicWorkflowEvent(value: unknown, type: string | undefined): boolean {
if (type !== "dynamic_workflow_run_progress" || !isRecord(value)) return false;
if (this.dynamicWorkflows.accept(value.payload)) this.renderDynamicWorkflow();
return true;
}

private renderDynamicWorkflow(): void {
if (!this.dynamicWorkflowView || !this.selectedDynamicWorkflow) return;
const run = this.dynamicWorkflows.runs().find((run) => run.runId === this.selectedDynamicWorkflow);
if (run) this.dynamicWorkflowView.setText(workflowRunDetail(run));
this.ui.requestRender();
}

private async showDynamicWorkflows(): Promise<void> {
if (!this.options.listWorkflowRuns) {
this.addNotice("Workflow inspection is unavailable in this runtime.", "warning");
return;
}
await this.dynamicWorkflows.hydrate();
if (this.dynamicWorkflows.error) this.addNotice(this.dynamicWorkflows.error, "warning");
const runs = this.dynamicWorkflows.runs();
if (runs.length === 0) {
this.addNotice("No workflow runs in this session.", "muted");
return;
}
const choice = await this.showChoice({
title: "Workflow runs", prompt: "Select a run to inspect its progress and results.",
items: runs.map((run) => ({ value: String(run.runId), label: asString(run.label) || String(run.runId),
description: [run.status, run.resumable === true ? "resumable" : undefined].filter(Boolean).join(" · ") }))
});
if (!choice) return;
this.selectedDynamicWorkflow = choice.value;
const run = this.dynamicWorkflows.runs().find((run) => run.runId === choice.value);
if (!run) {
this.addNotice("This run is no longer in the current workflow list. Open /workflows again.", "muted");
return;
}
this.dynamicWorkflowView = new Text(workflowRunDetail(run), 1, 0);
this.transcript.addBlock(this.dynamicWorkflowView);
this.ui.requestRender();
const action = await this.showChoice({
title: "Workflow actions", prompt: "Progress remains visible in the transcript.",
items: [
{ value: "close", label: "Back to prompt" },
...(["pending", "running"].includes(String(run.status)) ? [{ value: "cancel", label: "Stop workflow" }] : []),
...(run.resumable === true ? [{ value: "resume", label: "Resume workflow" }] : [])
]
});
if (!action || action.value === "close") return;
// The upstream command owns cancellation/resume validation and operates on
// this same app. Never spawn another app-server to control the live run.
if (!/^[A-Za-z0-9_.:-]+$/u.test(choice.value)) {
this.addNotice("This workflow ID cannot be passed to the runtime command.", "error");
return;
}
try {
const result = await this.options.submitPrompt(`/dwf ${action.value} ${choice.value}`, {});
await this.handleResult(result);
await this.dynamicWorkflows.hydrate();
this.renderDynamicWorkflow();
} catch (error) {
this.addNotice(error instanceof Error ? error.message : String(error), "error");
}
}

private isBackgroundCoordinatorReasoning(event: StreamEvent): boolean {
Expand Down
4 changes: 4 additions & 0 deletions packages/zcode-tui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export type ListPluginReferences = () => Promise<unknown>;

/** Stable boundary consumed by the local TUI; upstream details stay in the bridge. */
export interface RuntimeAdapter {
listWorkflowRuns?: () => Promise<unknown>;
replayWorkflowRuns?: (input: { excludeRunIds: ReadonlySet<string> }) => Promise<unknown>;
reduceWorkflowRuns?: (state: unknown, event: unknown) => unknown;
getMainSessionId?: () => string | undefined;
loadSessionTranscript?: () => Promise<unknown>;
loadSessionContextMessages?: () => Promise<unknown>;
listPluginReferences?: ListPluginReferences;
Expand Down
16 changes: 16 additions & 0 deletions scripts/sync-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,18 @@ export function patchRuntimeModelCatalogReload(runtime: string): string {
throw new Error("ZCode runtime is incompatible with model catalog reload (registry bridge anchor missing).");
}

/** Use the runtime's own workflow reducer so live and persisted progress agree. */
export function patchRuntimeWorkflowReducer(runtime: string): string {
if (runtime.includes("reduceWorkflowRuns:(")) return runtime;
const reducer = /[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*),"reduceWorkflowRunsState"\)/u.exec(runtime);
const init = reducer && [...runtime.slice(0, reducer.index).matchAll(
/([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu
)].at(-1)?.[1];
const option = /replayWorkflowRuns:([A-Za-z_$][\w$]*)\.replayWorkflowRuns/u.exec(runtime);
if (!reducer || !init || !option) throw new Error("ZCode runtime is incompatible with the workflow reducer bridge.");
return runtime.replace(option[0], `${option[0]},reduceWorkflowRuns:($zState,$zEvent)=>{${init}();return ${reducer[1]}($zState,$zEvent)}`);
}

export function patchRuntimeSharedConfig(runtime: string): string {
if (runtime.includes('ZCODE_CLI_MIGRATE_CONFIG==="1"')) return runtime;
const file = /([A-Za-z_$][\w$]*)="config.json",([A-Za-z_$][\w$]*)="~\/\.zcode\/cli"/u.exec(runtime);
Expand Down Expand Up @@ -1529,6 +1541,10 @@ const terminalProjectionMarkers = [
] as const;

export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [
{
id: "tui-workflow-reducer", requirement: "optional", apply: patchRuntimeWorkflowReducer,
verify: runtime => runtime.includes("reduceWorkflowRuns:(")
},
{
id: "tui-execution-state", requirement: "required", apply: patchRuntimeTuiExecutionState,
verify: runtime => runtime.includes('"readExecutionState"') && runtime.includes('"setPlanEnabled"')
Expand Down
Loading
Loading