Skip to content

Commit bdb01df

Browse files
committed
feat: Add OpenCode TUI sidebar plugin showing workflow phase and name
- New package `@codemcp/workflows-opencode-tui` in packages/opencode-tui-plugin/ - Single-file TUI plugin (workflows-phase.tsx) consumed raw by the OpenCode Bun TUI runtime - Hard-codes tool names for both usage modes: bare opencode-plugin names and MCP-prefixed names - Reads .vibe/conversations/*/state.json on mount (eager) and on every workflow tool call - Add tui.json at repo root pointing to the local package for immediate use by contributors - Add publish step to release.yml CI for the new package
1 parent 1246918 commit bdb01df

7 files changed

Lines changed: 1700 additions & 18 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,5 +166,6 @@ jobs:
166166
# Using pnpm publish with --filter to exclude private packages
167167
pnpm --filter '@codemcp/workflows-server' publish --access public --no-git-checks
168168
pnpm --filter '@codemcp/workflows' publish --access public --no-git-checks
169+
pnpm --filter '@codemcp/workflows-opencode-tui' publish --access public --no-git-checks
169170
env:
170171
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# @codemcp/workflows-opencode-tui
2+
3+
OpenCode TUI sidebar plugin that displays the current [responsible-vibe](https://mrsimpson.github.io/responsible-vibe-mcp/) workflow phase and name.
4+
5+
## Installation
6+
7+
Add the plugin to your OpenCode TUI config. Create or edit `~/.config/opencode/tui.json`:
8+
9+
```json
10+
{
11+
"$schema": "https://opencode.ai/tui.json",
12+
"plugin": ["@codemcp/workflows-opencode-tui"]
13+
}
14+
```
15+
16+
OpenCode will install the package automatically via Bun on next startup — no manual `npm install` needed.
17+
18+
## What it shows
19+
20+
When a workflow is active, the sidebar displays:
21+
22+
```
23+
Workflow
24+
epcc: code
25+
```
26+
27+
The plugin reads state from `.vibe/conversations/*/state.json` in your project directory and updates whenever any responsible-vibe tool is invoked.
28+
29+
## Supported tool modes
30+
31+
The plugin works with both integration modes:
32+
33+
| Mode | Tool names |
34+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
35+
| **opencode-plugin** (direct) | `start_development`, `proceed_to_phase`, `conduct_review`, `reset_development`, `setup_project_docs` |
36+
| **MCP server** | `workflows_start_development`, `workflows_proceed_to_phase`, `workflows_conduct_review`, `workflows_reset_development`, `workflows_setup_project_docs` |
37+
38+
## Local development
39+
40+
To test the plugin locally before publishing, point `tui.json` at the absolute path to this package:
41+
42+
```json
43+
{
44+
"$schema": "https://opencode.ai/tui.json",
45+
"plugin": ["/path/to/responsible-vibe-mcp/packages/opencode-tui-plugin"]
46+
}
47+
```
48+
49+
## License
50+
51+
MIT
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@codemcp/workflows-opencode-tui",
3+
"version": "6.4.0",
4+
"description": "OpenCode TUI sidebar plugin that displays the current responsible-vibe workflow phase and name",
5+
"main": "workflows-phase.tsx",
6+
"exports": {
7+
"./tui": "./workflows-phase.tsx"
8+
},
9+
"files": [
10+
"workflows-phase.tsx"
11+
],
12+
"publishConfig": {
13+
"access": "public"
14+
},
15+
"scripts": {
16+
"typecheck": "tsc --noEmit",
17+
"lint": "oxlint .",
18+
"lint:fix": "oxlint --fix .",
19+
"format:check": "prettier --check .",
20+
"format": "prettier --write ."
21+
},
22+
"peerDependencies": {
23+
"@opencode-ai/plugin": "*",
24+
"@opentui/solid": "*",
25+
"solid-js": "*"
26+
},
27+
"devDependencies": {
28+
"@opencode-ai/plugin": "*",
29+
"@opentui/solid": "*",
30+
"@types/node": "^22.0.0",
31+
"solid-js": "*",
32+
"typescript": "^5.9.3"
33+
},
34+
"keywords": [
35+
"opencode",
36+
"opencode-plugin",
37+
"opencode-tui-plugin",
38+
"workflows",
39+
"responsible-vibe"
40+
],
41+
"license": "MIT",
42+
"repository": {
43+
"type": "git",
44+
"url": "https://github.com/mrsimpson/responsible-vibe-mcp"
45+
}
46+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2022",
4+
"module": "ESNext",
5+
"moduleResolution": "bundler",
6+
"jsx": "preserve",
7+
"jsxImportSource": "@opentui/solid",
8+
"strict": true,
9+
"skipLibCheck": true,
10+
"noEmit": true,
11+
"allowJs": false,
12+
"esModuleInterop": true,
13+
"forceConsistentCasingInFileNames": true,
14+
"resolveJsonModule": true,
15+
"types": ["node"]
16+
},
17+
"include": ["workflows-phase.tsx"],
18+
"exclude": ["node_modules"]
19+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import { createSignal, onCleanup } from 'solid-js';
3+
import type { TuiPlugin, TuiPluginModule } from '@opencode-ai/plugin/tui';
4+
import type fs from 'node:fs';
5+
import type path from 'node:path';
6+
7+
/**
8+
* Tool names that trigger a state refresh.
9+
*
10+
* Covers both usage modes:
11+
* - opencode-plugin (bare names, no prefix)
12+
* - MCP server (tools namespaced with "workflows_" prefix)
13+
*/
14+
const WORKFLOW_TOOLS = new Set([
15+
// opencode-plugin direct tool names
16+
'start_development',
17+
'proceed_to_phase',
18+
'conduct_review',
19+
'reset_development',
20+
'setup_project_docs',
21+
// MCP server tool names (workflows_ namespace prefix)
22+
'workflows_start_development',
23+
'workflows_proceed_to_phase',
24+
'workflows_conduct_review',
25+
'workflows_reset_development',
26+
'workflows_setup_project_docs',
27+
]);
28+
29+
interface StateJson {
30+
currentPhase?: string;
31+
workflowName?: string;
32+
}
33+
34+
interface MessagePartUpdatedEvent {
35+
properties?: {
36+
part?: {
37+
sessionID?: string;
38+
type?: string;
39+
tool?: string;
40+
};
41+
};
42+
}
43+
44+
function readLatestState(
45+
sessionDir: string
46+
): { phase: string; workflow: string } | null {
47+
try {
48+
// require() is intentional: top-level ESM imports of Node built-ins are not
49+
// supported in the Bun plugin runtime.
50+
// eslint-disable-next-line @typescript-eslint/no-require-imports
51+
const fsSync = require('node:fs') as typeof fs;
52+
// eslint-disable-next-line @typescript-eslint/no-require-imports
53+
const pathSync = require('node:path') as typeof path;
54+
const vibeDir = pathSync.join(sessionDir, '.vibe', 'conversations');
55+
const dirs = fsSync.readdirSync(vibeDir);
56+
let latest: { mtime: number; file: string } | null = null;
57+
for (const dir of dirs) {
58+
const file = pathSync.join(vibeDir, dir, 'state.json');
59+
try {
60+
const stat = fsSync.statSync(file);
61+
if (!latest || stat.mtimeMs > latest.mtime) {
62+
latest = { mtime: stat.mtimeMs, file };
63+
}
64+
} catch {
65+
// unreadable entry — skip silently
66+
}
67+
}
68+
if (!latest) return null;
69+
const state = JSON.parse(
70+
fsSync.readFileSync(latest.file, 'utf8')
71+
) as StateJson;
72+
if (!state.currentPhase && !state.workflowName) return null;
73+
return {
74+
phase: state.currentPhase ?? '—',
75+
workflow: state.workflowName ?? '—',
76+
};
77+
} catch {
78+
return null;
79+
}
80+
}
81+
82+
// eslint-disable-next-line @typescript-eslint/require-await -- TuiPlugin signature requires Promise<void>; plugin body is synchronous
83+
const tui: TuiPlugin = async api => {
84+
api.slots.register({
85+
order: 5,
86+
slots: {
87+
sidebar_content(_ctx, props) {
88+
const theme = () => api.theme.current;
89+
const [state, setState] = createSignal<{
90+
phase: string;
91+
workflow: string;
92+
} | null>(null);
93+
94+
// Read state eagerly on mount so it's visible immediately on reload,
95+
// not only after the first tool call.
96+
const dir = api.state.path.directory;
97+
if (dir) {
98+
setState(readLatestState(dir));
99+
}
100+
101+
const offPart = api.event.on('message.part.updated', e => {
102+
const ev = e as MessagePartUpdatedEvent;
103+
const part = ev.properties?.part;
104+
if (!part) return;
105+
if (part.sessionID !== props.session_id) return;
106+
if (part.type !== 'tool') return;
107+
if (!part.tool || !WORKFLOW_TOOLS.has(part.tool)) return;
108+
if (!dir) return;
109+
setState(readLatestState(dir));
110+
});
111+
onCleanup(offPart);
112+
113+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSX element typed as `error` by @opentui/solid's JSX types; safe at runtime
114+
return (
115+
<box flexDirection="column" visible={!!state()}>
116+
<text fg={theme().text}>
117+
<b>Workflow</b>
118+
</text>
119+
<text fg={theme().textMuted}>
120+
{state()?.workflow}:{' '}
121+
{/* eslint-disable-next-line solid/style-prop -- `fg` is an OpenTUI-specific style prop, not a standard CSS property */}
122+
<span style={{ fg: theme().text }}>{state()?.phase}</span>
123+
</text>
124+
</box>
125+
);
126+
},
127+
},
128+
});
129+
};
130+
131+
const plugin: TuiPluginModule & { id: string } = {
132+
id: 'workflows-phase',
133+
tui,
134+
};
135+
136+
export default plugin;

0 commit comments

Comments
 (0)