Skip to content

Commit 4094da5

Browse files
udondanmrsimpson
authored andcommitted
feat(tui-plugin): show all workflow phases as a list with current phase highlighted
1 parent 3a4e6f4 commit 4094da5

3 files changed

Lines changed: 264 additions & 12 deletions

File tree

packages/opencode-tui-plugin/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
"format": "prettier --write ."
2121
},
2222
"peerDependencies": {
23+
"@codemcp/workflows-core": "*",
2324
"@opencode-ai/plugin": "*",
2425
"@opentui/solid": "*",
2526
"solid-js": "*"
2627
},
2728
"devDependencies": {
29+
"@codemcp/workflows-core": "workspace:*",
2830
"@opencode-ai/plugin": "*",
2931
"@opentui/solid": "*",
3032
"@types/node": "^22.0.0",

packages/opencode-tui-plugin/workflows-phase.tsx

Lines changed: 259 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
/** @jsxImportSource @opentui/solid */
2-
import { createSignal, onCleanup } from 'solid-js';
2+
import {
3+
Index,
4+
createEffect,
5+
createMemo,
6+
createSignal,
7+
onCleanup,
8+
} from 'solid-js';
39
import type { TuiPlugin, TuiPluginModule } from '@opencode-ai/plugin/tui';
410
import type fs from 'node:fs';
511
import type path from 'node:path';
@@ -45,10 +51,143 @@ interface MessagePartUpdatedEvent {
4551
};
4652
}
4753

54+
/**
55+
* Extract ordered phase names from a workflow YAML file without a YAML parser.
56+
*
57+
* The workflow YAML format is consistent: phase names are top-level keys under
58+
* the `states:` map, each indented with exactly two spaces. We use a simple
59+
* line-based scan rather than js-yaml because js-yaml is not a built-in Node
60+
* module and is not available in the TUI plugin's node_modules — it is a
61+
* dependency of @codemcp/workflows-core but is not hoisted into this package's
62+
* scope.
63+
*/
64+
function parsePhasesFromYaml(content: string): string[] {
65+
const phases: string[] = [];
66+
let inStates = false;
67+
for (const line of content.split('\n')) {
68+
if (line.startsWith('states:')) {
69+
inStates = true;
70+
continue;
71+
}
72+
if (inStates) {
73+
// A new top-level key (no leading spaces) ends the states block
74+
if (/^\S/.test(line) && line.trim() !== '') {
75+
break;
76+
}
77+
// A key at exactly two-space indent is a phase name
78+
const match = /^ ([\w-]+):/.exec(line);
79+
if (match?.[1]) {
80+
phases.push(match[1]);
81+
}
82+
}
83+
}
84+
return phases;
85+
}
86+
87+
/**
88+
* Look up the ordered phase list for a workflow name.
89+
*
90+
* Checks project-local workflows (.vibe/workflows/) first, then falls back to
91+
* the built-in workflows bundled in @codemcp/workflows-core.
92+
*
93+
* We read YAML files directly rather than delegating to WorkflowManager
94+
* because WorkflowManager is ESM-only: it uses `import.meta.url` to locate
95+
* the bundled resources/workflows/ directory at runtime. When loaded via
96+
* require() — which is required in the Bun TUI plugin runtime because
97+
* top-level ESM imports of Node built-ins are not supported there —
98+
* `import.meta` is undefined and the module throws on load, before the
99+
* constructor is even reached.
100+
*/
101+
function getWorkflowPhases(projectDir: string, workflowName: string): string[] {
102+
// Guard against path traversal — workflow names must be simple identifiers
103+
if (!/^[\w-]+$/.test(workflowName)) return [];
104+
try {
105+
// eslint-disable-next-line @typescript-eslint/no-require-imports
106+
const fsSync = require('node:fs') as typeof fs;
107+
// eslint-disable-next-line @typescript-eslint/no-require-imports
108+
const pathSync = require('node:path') as typeof path;
109+
110+
// 1. Project-local workflows: scan `.vibe/workflows` for a YAML whose `name:` matches workflowName
111+
const workflowsDir = pathSync.join(projectDir, '.vibe', 'workflows');
112+
if (
113+
fsSync.existsSync(workflowsDir) &&
114+
fsSync.statSync(workflowsDir).isDirectory()
115+
) {
116+
for (const entry of fsSync.readdirSync(workflowsDir)) {
117+
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
118+
const fullPath = pathSync.join(workflowsDir, entry);
119+
try {
120+
const contents = fsSync.readFileSync(fullPath, 'utf8');
121+
// Extract the `name:` field from the YAML without a parser.
122+
// Strip surrounding single/double quotes (e.g. name: 'minor').
123+
const nameMatch = /^name:\s*(.+)/m.exec(contents);
124+
const rawName = nameMatch?.[1]?.trim();
125+
const parsedName =
126+
rawName !== undefined
127+
? rawName.replace(/^(['"])(.*)\1$/, '$2')
128+
: undefined;
129+
if (parsedName === workflowName) {
130+
return parsePhasesFromYaml(contents);
131+
}
132+
} catch {
133+
// unreadable file — skip
134+
}
135+
}
136+
}
137+
138+
// 2. Built-in workflow bundled with @codemcp/workflows-core (.yaml then .yml)
139+
const corePkgDir = pathSync.dirname(
140+
require.resolve('@codemcp/workflows-core/package.json')
141+
);
142+
const builtinBase = pathSync.join(
143+
corePkgDir,
144+
'resources',
145+
'workflows',
146+
workflowName
147+
);
148+
for (const ext of ['.yaml', '.yml']) {
149+
const builtinPath = builtinBase + ext;
150+
if (fsSync.existsSync(builtinPath)) {
151+
return parsePhasesFromYaml(fsSync.readFileSync(builtinPath, 'utf8'));
152+
}
153+
}
154+
155+
// 3. Additional fallback locations: workspace/dev setups where
156+
// @codemcp/workflows-core/resources/workflows has not been built yet,
157+
// and project-local custom workflows under resources/workflows/.
158+
const additionalRoots = [
159+
pathSync.join(process.cwd(), 'resources', 'workflows'),
160+
pathSync.join(projectDir, 'resources', 'workflows'),
161+
];
162+
for (const root of additionalRoots) {
163+
try {
164+
if (!fsSync.existsSync(root) || !fsSync.statSync(root).isDirectory()) {
165+
continue;
166+
}
167+
} catch {
168+
continue;
169+
}
170+
const candidateBase = pathSync.join(root, workflowName);
171+
for (const ext of ['.yaml', '.yml']) {
172+
const candidatePath = candidateBase + ext;
173+
if (fsSync.existsSync(candidatePath)) {
174+
return parsePhasesFromYaml(
175+
fsSync.readFileSync(candidatePath, 'utf8')
176+
);
177+
}
178+
}
179+
}
180+
181+
return [];
182+
} catch {
183+
return [];
184+
}
185+
}
186+
48187
function readStateBySessionId(
49188
sessionDir: string,
50189
sessionId: string
51-
): { phase: string; workflow: string } | null {
190+
): { phase: string; workflow: string; phases: string[] } | null {
52191
try {
53192
// require() is intentional: top-level ESM imports of Node built-ins are not
54193
// supported in the Bun plugin runtime.
@@ -70,9 +209,13 @@ function readStateBySessionId(
70209
// Check if this state's sessionMetadata matches the current session ID
71210
if (state.sessionMetadata?.referenceId === sessionId) {
72211
if (!state.currentPhase && !state.workflowName) return null;
212+
const phases = state.workflowName
213+
? getWorkflowPhases(sessionDir, state.workflowName)
214+
: [];
73215
return {
74216
phase: state.currentPhase ?? '—',
75217
workflow: state.workflowName ?? '—',
218+
phases,
76219
};
77220
}
78221
} catch {
@@ -88,6 +231,10 @@ function readStateBySessionId(
88231

89232
// eslint-disable-next-line @typescript-eslint/require-await -- TuiPlugin signature requires Promise<void>; plugin body is synchronous
90233
const tui: TuiPlugin = async api => {
234+
// Respect the WORKFLOW env var used by the opencode-plugin.
235+
// Set WORKFLOW=off to disable the TUI sidebar widget.
236+
if (process.env.WORKFLOW?.toLowerCase() === 'off') return;
237+
91238
api.slots.register({
92239
order: 5,
93240
slots: {
@@ -96,7 +243,29 @@ const tui: TuiPlugin = async api => {
96243
const [state, setState] = createSignal<{
97244
phase: string;
98245
workflow: string;
246+
phases: string[];
99247
} | null>(null);
248+
const [collapsed, setCollapsed] = createSignal(false);
249+
250+
// Spinner frames for the current-phase icon
251+
const SPINNER = ['◐', '◓', '◑', '◒'];
252+
const [spinnerFrame, setSpinnerFrame] = createSignal(0);
253+
// Only animate when the phase list is visible (expanded + active workflow)
254+
createEffect(() => {
255+
const s = state();
256+
if (collapsed() || !s || !s.phases || s.phases.length === 0) return;
257+
const id = setInterval(() => {
258+
setSpinnerFrame(f => (f + 1) % SPINNER.length);
259+
}, 150);
260+
onCleanup(() => clearInterval(id));
261+
});
262+
263+
// Precompute current phase index once per state change to avoid O(n²) indexOf in render
264+
const currentPhaseIndex = createMemo(() => {
265+
const s = state();
266+
if (!s) return -1;
267+
return s.phases.indexOf(s.phase);
268+
});
100269

101270
// Read state eagerly on mount so it's visible immediately on reload,
102271
// not only after the first tool call.
@@ -127,18 +296,96 @@ const tui: TuiPlugin = async api => {
127296
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSX element typed as `error` by @opentui/solid's JSX types; safe at runtime
128297
return (
129298
<box flexDirection="column">
130-
<text fg={theme().text}>
131-
<b>Workflow</b>
299+
{/* Header row — clickable to collapse/expand when an active workflow is present */}
300+
<text
301+
fg={theme().text}
302+
onMouseDown={() => state() && setCollapsed(c => !c)}
303+
>
304+
{state() ? (
305+
(state()?.phases ?? []).length === 0 ? (
306+
// Phases unknown
307+
collapsed() ? (
308+
// Collapsed: ▶ workflowName phaseName
309+
<span>
310+
{'▶ '}
311+
<b>{state()?.workflow}</b>
312+
<span style={{ fg: theme().textMuted }}>
313+
{' '}
314+
{state()?.phase}
315+
</span>
316+
</span>
317+
) : (
318+
// Expanded: ▼ Workflow (body shows workflowName phaseName)
319+
<span>
320+
{'▼ '}
321+
<b>Workflow</b>
322+
</span>
323+
)
324+
) : collapsed() ? (
325+
// Collapsed + active: ▶ workflowName phaseName
326+
<span>
327+
{'▶ '}
328+
<b>{state()?.workflow}</b>
329+
<span style={{ fg: theme().textMuted }}>
330+
{' '}
331+
{state()?.phase}
332+
</span>
333+
</span>
334+
) : (
335+
// Expanded + active: ▼ Workflow workflowName
336+
<span>
337+
{'▼ '}
338+
<b>Workflow</b> {state()?.workflow}
339+
</span>
340+
)
341+
) : (
342+
// No active workflow
343+
// eslint-disable-next-line solid/style-prop -- `fg` is an OpenTUI-specific style prop, not a standard CSS property
344+
<b>Workflow</b>
345+
)}
132346
</text>
133-
{state() ? (
134-
<text fg={theme().textMuted}>
135-
{state()?.workflow}:{' '}
136-
{/* eslint-disable-next-line solid/style-prop -- `fg` is an OpenTUI-specific style prop, not a standard CSS property */}
137-
<span style={{ fg: theme().text }}>{state()?.phase}</span>
138-
</text>
139-
) : (
347+
{/* Expanded phase list */}
348+
{!collapsed() && state() ? (
349+
(state()?.phases ?? []).length > 0 ? (
350+
<box flexDirection="column">
351+
<Index each={state()?.phases ?? []}>
352+
{(phase, index) => (
353+
<text
354+
fg={
355+
phase() === state()?.phase
356+
? theme().warning
357+
: currentPhaseIndex() >= 0 &&
358+
index < currentPhaseIndex()
359+
? theme().success
360+
: theme().textMuted
361+
}
362+
>
363+
{phase() === state()?.phase
364+
? `${SPINNER[spinnerFrame()]} `
365+
: currentPhaseIndex() >= 0 &&
366+
index < currentPhaseIndex()
367+
? '● '
368+
: '○ '}
369+
{phase()}
370+
</text>
371+
)}
372+
</Index>
373+
</box>
374+
) : (
375+
// Phases unknown — show workflowName phaseName
376+
<text fg={theme().text}>
377+
<b>{state()?.workflow}</b>
378+
<span style={{ fg: theme().textMuted }}>
379+
{' '}
380+
{state()?.phase}
381+
</span>
382+
</text>
383+
)
384+
) : null}
385+
{/* No active workflow message */}
386+
{!state() ? (
140387
<text fg={theme().textMuted}>No Active Workflow</text>
141-
)}
388+
) : null}
142389
</box>
143390
);
144391
},

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)