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' ;
39import type { TuiPlugin , TuiPluginModule } from '@opencode-ai/plugin/tui' ;
410import type fs from 'node:fs' ;
511import 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 = / ^ n a m e : \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+
48187function 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
90233const 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 } ,
0 commit comments