Skip to content

Commit ff54f71

Browse files
committed
feat(workflow): add headless UI mode and workflow UI components
Introduce headless UI mode for workflow execution when CODEMACHINE_HEADLESS_UI is set. Create new UI components for workflow visualization including timeline and output panels. Refactor workflow UI types to support both interactive and headless modes. - Add HeadlessWorkflowUI class as a no-op implementation - Create new UI components (TimelinePanel, OutputPanel) - Update workflow execution to support headless mode - Modify CLI to spawn headless workflow process
1 parent 0efd997 commit ff54f71

File tree

14 files changed

+277
-51
lines changed

14 files changed

+277
-51
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { Show } from "solid-js"
3+
import { useTheme } from "@tui/context/theme"
4+
import { useUIState } from "@tui/context/ui-state"
5+
6+
export function OutputPanel() {
7+
const { theme } = useTheme()
8+
const ui = useUIState()
9+
const state = ui.state()
10+
11+
const selectedAgent =
12+
state.selectedSubAgentId &&
13+
Array.from(state.subAgents.values())
14+
.flat()
15+
.find((sa) => sa.id === state.selectedSubAgentId) ||
16+
(state.selectedAgentId && state.agents.find((a) => a.id === state.selectedAgentId)) ||
17+
state.agents[0]
18+
19+
return (
20+
<box
21+
flexDirection="column"
22+
padding={1}
23+
border
24+
borderColor={theme.borderSubtle}
25+
backgroundColor={theme.backgroundPanel}
26+
flexGrow={1}
27+
>
28+
<text attributes={1} fg={theme.text}>Output</text>
29+
<Show when={selectedAgent} fallback={<text fg={theme.textMuted}>No agent selected yet.</text>}>
30+
{(agent) => (
31+
<>
32+
<text>
33+
<span style={{ bold: true }}>{agent().name}</span>{" "}
34+
<span style={{ fg: theme.textMuted }}>{`(${agent().engine})`}</span>
35+
</text>
36+
<text fg={theme.textMuted}>Status: {agent().status}</text>
37+
<text fg={theme.textMuted}>Tokens: {agent().telemetry.tokensIn} in / {agent().telemetry.tokensOut} out</text>
38+
<text fg={theme.textMuted}>Logs will appear here once hooked to monitoring.</text>
39+
</>
40+
)}
41+
</Show>
42+
</box>
43+
)
44+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { For, Show } from "solid-js"
3+
import { useTheme } from "@tui/context/theme"
4+
import { useUIState } from "@tui/context/ui-state"
5+
import type { AgentStatus, SubAgentState } from "@tui/state/types"
6+
7+
type StatusVisual = { icon: string; color: string }
8+
9+
function getStatusVisual(status: AgentStatus, theme: ReturnType<typeof useTheme>["theme"]): StatusVisual {
10+
switch (status) {
11+
case "pending":
12+
return { icon: "○", color: theme.textMuted.toString() }
13+
case "running":
14+
return { icon: "⠋", color: theme.primary.toString() }
15+
case "completed":
16+
return { icon: "●", color: theme.success.toString() }
17+
case "skipped":
18+
return { icon: "●", color: theme.textMuted.toString() }
19+
case "retrying":
20+
return { icon: "⟳", color: theme.warning.toString() }
21+
case "failed":
22+
default:
23+
return { icon: "✗", color: theme.error.toString() }
24+
}
25+
}
26+
27+
export function TimelinePanel() {
28+
const { theme } = useTheme()
29+
const ui = useUIState()
30+
const state = ui.state()
31+
32+
const renderSubAgents = (subs: SubAgentState[]) => (
33+
<For each={subs}>
34+
{(sub) => {
35+
const visual = getStatusVisual(sub.status, theme)
36+
const isSelected = state.selectedItemType === "sub" && state.selectedSubAgentId === sub.id
37+
return (
38+
<box paddingLeft={2}>
39+
<text>
40+
{isSelected ? "> " : " "}
41+
<span style={{ fg: visual.color }}>{visual.icon}</span>{" "}
42+
<span style={{ bold: true }}>{sub.name}</span>{" "}
43+
<span style={{ fg: theme.textMuted }}>{`(${sub.engine})`}</span>
44+
</text>
45+
</box>
46+
)
47+
}}
48+
</For>
49+
)
50+
51+
return (
52+
<box flexDirection="column" padding={1} border borderColor={theme.borderSubtle} backgroundColor={theme.backgroundPanel} flexGrow={1}>
53+
<text attributes={1} fg={theme.text}>Timeline</text>
54+
<Show when={state.agents.length === 0}>
55+
<text fg={theme.textMuted}>Awaiting agents...</text>
56+
</Show>
57+
58+
<For each={state.agents}>
59+
{(agent) => {
60+
const visual = getStatusVisual(agent.status, theme)
61+
const isSelected = state.selectedItemType === "main" && state.selectedAgentId === agent.id
62+
const subAgents = state.subAgents.get(agent.id) || []
63+
const isExpanded = state.expandedNodes.has(agent.id)
64+
const hasSubs = subAgents.length > 0
65+
const summarySelected = state.selectedItemType === "summary" && state.selectedAgentId === agent.id
66+
67+
return (
68+
<box flexDirection="column" gap={0}>
69+
<text>
70+
{isSelected ? "> " : " "}
71+
<span style={{ fg: visual.color }}>{visual.icon}</span>{" "}
72+
<span style={{ bold: true }}>{agent.name}</span>{" "}
73+
<span style={{ fg: theme.textMuted }}>{`(${agent.engine})`}</span>
74+
{agent.loopRound && agent.loopRound > 0 && (
75+
<span style={{ fg: theme.primary }}>{` • Loop ${agent.loopRound}`}</span>
76+
)}
77+
</text>
78+
79+
{hasSubs && (
80+
<text paddingLeft={1}>
81+
{summarySelected ? "> " : " "}
82+
<span style={{ fg: theme.textMuted }}>{isExpanded ? "▼" : "▶"} Sub-agents: {subAgents.length}</span>
83+
</text>
84+
)}
85+
86+
{hasSubs && isExpanded && renderSubAgents(subAgents)}
87+
</box>
88+
)
89+
}}
90+
</For>
91+
</box>
92+
)
93+
}

src/cli/tui/routes/home.tsx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useRenderer } from "@opentui/solid"
1313
import { TextAttributes } from "@opentui/core"
1414
import { createRequire } from "node:module"
1515
import { resolvePackageJson } from "../../../shared/runtime/pkg.js"
16+
import { spawnProcess } from "../../../infra/process/spawn.js"
1617
import { onMount } from "solid-js"
1718
import * as path from "node:path"
1819
import type { InitialToast } from "../app"
@@ -80,14 +81,46 @@ export function Home(props: HomeProps) {
8081
return // Don't destroy TUI or spawn workflow
8182
}
8283

84+
// Kick off new workflow view and launch headless runner
8385
if (props.onStartWorkflow) {
8486
props.onStartWorkflow()
85-
return
8687
}
8788

88-
// TODO: legacy Ink workflow spawn is temporarily disabled during TUI refactor.
89-
// Validation passed; workflow execution will be handled by the new OpenTUI flow.
90-
// Previously: destroy renderer → reset stdin → clear terminal → spawn runner-process/binary → exit with code.
89+
const isDev = import.meta.url.includes('/src/')
90+
let command: string
91+
let args: string[]
92+
93+
if (isDev) {
94+
const runnerPath = path.join(process.cwd(), "src", "workflows", "runner-process.ts")
95+
command = "bun"
96+
args = [runnerPath, cwd, ""]
97+
} else {
98+
const { resolveWorkflowBinary } = await import("../../../shared/utils/resolve-workflow-binary.js")
99+
command = resolveWorkflowBinary()
100+
args = [cwd, ""]
101+
}
102+
103+
void spawnProcess({
104+
command,
105+
args,
106+
env: {
107+
...process.env,
108+
CODEMACHINE_HEADLESS_UI: "1",
109+
...(process.env.CODEMACHINE_INSTALL_DIR ? { CODEMACHINE_INSTALL_DIR: process.env.CODEMACHINE_INSTALL_DIR } : {}),
110+
},
111+
stdioMode: "pipe",
112+
}).catch((error: unknown) => {
113+
toast.show({
114+
variant: "error",
115+
message: `Workflow process failed: ${error instanceof Error ? error.message : String(error)}`,
116+
})
117+
})
118+
119+
toast.show({
120+
variant: "info",
121+
message: "Workflow started headlessly. The new UI will reflect progress.",
122+
duration: 5000,
123+
})
91124
return
92125
}
93126

src/cli/tui/routes/workflow.tsx

Lines changed: 19 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { resolvePackageJson } from "../../../shared/runtime/pkg.js"
55
import { BrandingHeader } from "@tui/component/layout/branding-header"
66
import { useTheme } from "@tui/context/theme"
77
import { UIStateProvider, useUIState } from "@tui/context/ui-state"
8-
import { Show } from "solid-js"
8+
import { useKeyboard } from "@opentui/solid"
9+
import { TimelinePanel } from "@tui/component/workflow/timeline-panel"
10+
import { OutputPanel } from "@tui/component/workflow/output-panel"
911

1012
export function Workflow() {
1113
const getVersion = () => {
@@ -34,41 +36,29 @@ function WorkflowShell(props: { version: string; currentDir: string }) {
3436
const { theme } = useTheme()
3537
const ui = useUIState()
3638
const state = ui.state()
39+
const actions = ui.actions
40+
41+
useKeyboard((evt) => {
42+
if (evt.name === "up") {
43+
actions.navigateUp()
44+
} else if (evt.name === "down") {
45+
actions.navigateDown()
46+
} else if (evt.name === "return" && state.selectedAgentId) {
47+
actions.toggleExpand(state.selectedAgentId)
48+
}
49+
})
3750

3851
return (
3952
<box flexDirection="column" gap={1} paddingLeft={1} paddingRight={1} paddingTop={1}>
4053
<BrandingHeader version={props.version} currentDir={props.currentDir} />
4154

4255
<box flexDirection="row" gap={1}>
43-
<box
44-
flexDirection="column"
45-
padding={1}
46-
border
47-
borderColor={theme.borderSubtle}
48-
backgroundColor={theme.backgroundPanel}
49-
width="50%"
50-
>
51-
<text fg={theme.text} attributes={1}>Timeline</text>
52-
<text fg={theme.textMuted}>Agents: {state.agents.length} • Subagents: {Array.from(state.subAgents.values()).reduce((sum, list) => sum + list.length, 0)}</text>
53-
<text fg={theme.textMuted}>Status: {state.workflowStatus}</text>
54-
<text fg={theme.textMuted}>Scroll offset: {state.scrollOffset}</text>
55-
<text fg={theme.textMuted}>Visible rows: {state.visibleItemCount}</text>
56-
<Show when={state.loopState?.active}>
57-
<text fg={theme.primary}>Loop {state.loopState?.iteration}/{state.loopState?.maxIterations}</text>
58-
</Show>
59-
</box>
56+
<TimelinePanel />
57+
<OutputPanel />
58+
</box>
6059

61-
<box
62-
flexDirection="column"
63-
padding={1}
64-
border
65-
borderColor={theme.borderSubtle}
66-
backgroundColor={theme.backgroundPanel}
67-
width="50%"
68-
>
69-
<text fg={theme.text} attributes={1}>Output</text>
70-
<text fg={theme.textMuted}>Awaiting agent output. Hook log stream here.</text>
71-
</box>
60+
<box paddingLeft={1}>
61+
<text fg={theme.textMuted}>Keys: ↑/↓ navigate • Enter toggles sub-agents • Ctrl+C exits.</text>
7262
</box>
7363
</box>
7464
)

src/workflows/behaviors/checkpoint/controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { WorkflowStep } from '../../templates/index.js';
22
import { isModuleStep } from '../../templates/types.js';
33
import { evaluateCheckpointBehavior } from './evaluator.js';
44
import { formatAgentLog } from '../../../shared/logging/index.js';
5-
import type { WorkflowUIManager } from '../../../ui/index.js';
5+
import type { WorkflowUI } from '../../execution/ui-types.js';
66

77
export interface CheckpointDecision {
88
shouldStopWorkflow: boolean;
@@ -13,7 +13,7 @@ export async function handleCheckpointLogic(
1313
step: WorkflowStep,
1414
output: string,
1515
cwd: string,
16-
ui?: WorkflowUIManager,
16+
ui?: WorkflowUI,
1717
): Promise<CheckpointDecision | null> {
1818
// Only module steps can have checkpoint behavior
1919
if (!isModuleStep(step)) {

src/workflows/behaviors/loop/controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { isModuleStep } from '../../templates/types.js';
33
import { evaluateLoopBehavior } from './evaluator.js';
44
import { formatAgentLog } from '../../../shared/logging/index.js';
55
import type { ActiveLoop } from '../skip.js';
6-
import type { WorkflowUIManager } from '../../../ui/index.js';
6+
import type { WorkflowUI } from '../../execution/ui-types.js';
77
import { debug } from '../../../shared/logging/logger.js';
88

99
export interface LoopDecision {
@@ -19,7 +19,7 @@ export async function handleLoopLogic(
1919
output: string,
2020
loopCounters: Map<string, number>,
2121
cwd: string,
22-
ui?: WorkflowUIManager,
22+
ui?: WorkflowUI,
2323
): Promise<{ decision: LoopDecision | null; newIndex: number }> {
2424
// Only module steps can have loop behavior
2525
if (!isModuleStep(step)) {

src/workflows/behaviors/skip.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { WorkflowStep } from '../templates/index.js';
22
import { isModuleStep } from '../templates/types.js';
3-
import type { WorkflowUIManager } from '../../ui/index.js';
3+
import type { WorkflowUI } from '../execution/ui-types.js';
44
import { debug } from '../../shared/logging/logger.js';
55

66
export interface ActiveLoop {
@@ -12,7 +12,7 @@ export function shouldSkipStep(
1212
index: number,
1313
completedSteps: number[],
1414
activeLoop: ActiveLoop | null,
15-
ui?: WorkflowUIManager,
15+
ui?: WorkflowUI,
1616
uniqueAgentId?: string,
1717
): { skip: boolean; reason?: string } {
1818
// UI steps can't be skipped

src/workflows/behaviors/trigger/controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { WorkflowStep } from '../../templates/index.js';
22
import { isModuleStep } from '../../templates/types.js';
33
import { evaluateTriggerBehavior } from './evaluator.js';
44
import { formatAgentLog } from '../../../shared/logging/index.js';
5-
import type { WorkflowUIManager } from '../../../ui/index.js';
5+
import type { WorkflowUI } from '../../execution/ui-types.js';
66

77
export interface TriggerDecision {
88
shouldTrigger: boolean;
@@ -14,7 +14,7 @@ export async function handleTriggerLogic(
1414
step: WorkflowStep,
1515
output: string,
1616
cwd: string,
17-
ui?: WorkflowUIManager,
17+
ui?: WorkflowUI,
1818
): Promise<TriggerDecision | null> {
1919
// Only module steps can have trigger behavior
2020
if (!isModuleStep(step)) {

src/workflows/execution/fallback.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import type { WorkflowStep } from '../templates/index.js';
22
import { isModuleStep } from '../templates/types.js';
33
import { executeStep } from './step.js';
44
import { mainAgents } from '../utils/config.js';
5-
import type { WorkflowUIManager } from '../../ui/index.js';
5+
import type { WorkflowUI } from './ui-types.js';
66

77
export interface FallbackExecutionOptions {
88
logger: (message: string) => void;
99
stderrLogger: (message: string) => void;
10-
ui?: WorkflowUIManager;
10+
ui?: WorkflowUI;
1111
abortSignal?: AbortSignal;
1212
}
1313

@@ -32,7 +32,7 @@ export async function executeFallbackStep(
3232
cwd: string,
3333
workflowStartTime: number,
3434
engineType: string,
35-
ui?: WorkflowUIManager,
35+
ui?: WorkflowUI,
3636
uniqueParentAgentId?: string,
3737
abortSignal?: AbortSignal,
3838
): Promise<void> {

0 commit comments

Comments
 (0)