Skip to content

Commit c6a672d

Browse files
mguttmannclaudeVasyaYovbak
committed
feat(workflow): TUI (PR-3) — workflow dialogs, autocomplete, fullscreen dialog size
Brings the TUI workflow surface: dialog-workflow* components, prompt workflow-autocomplete + ultracode, session route/permission rendering, keybind/theme/renderer changes, and the plugin TuiDialog "fullscreen" size. Reconcile (PR-5 -> PR-3): command/index.ts is pulled forward because the TUI prompt code (prompt/index.tsx, prompt/workflow-autocomplete.ts) has an UNCAST dependency on the SDK Command.source "workflow" variant, which is derived from command/index.ts's source schema. test/fixture/tui-plugin.ts is pulled forward to satisfy the new "fullscreen" TuiDialogStack size. SDK regenerated so Command.source includes "workflow". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: VasyaYovbak <87126061+VasyaYovbak@users.noreply.github.com>
1 parent 5bd2aaa commit c6a672d

39 files changed

Lines changed: 7398 additions & 1606 deletions

packages/opencode/src/command/index.ts

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Effect, Layer, Context, Schema } from "effect"
77
import { Config } from "@/config/config"
88
import { MCP } from "../mcp"
99
import { Skill } from "../skill"
10+
import { Workflow } from "@/workflow/workflow"
1011
import { EventV2 } from "@opencode-ai/core/event"
1112
import PROMPT_INITIALIZE from "./template/initialize.txt"
1213
import PROMPT_REVIEW from "./template/review.txt"
@@ -32,7 +33,7 @@ export const Info = Schema.Struct({
3233
description: Schema.optional(Schema.String),
3334
agent: Schema.optional(Schema.String),
3435
model: Schema.optional(Schema.String),
35-
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
36+
source: Schema.optional(Schema.Literals(["command", "mcp", "skill", "workflow"])),
3637
// Some command templates are lazy promises from MCP prompt resolution.
3738
template: Schema.Unknown,
3839
subtask: Schema.optional(Schema.Boolean),
@@ -69,18 +70,41 @@ export const layer = Layer.effect(
6970
const config = yield* Config.Service
7071
const mcp = yield* MCP.Service
7172
const skill = yield* Skill.Service
73+
const workflow = yield* Workflow.Service
7274

7375
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) {
7476
const cfg = yield* config.get()
7577
const bridge = yield* EffectBridge.make()
7678
const commands: Record<string, Info> = {}
7779

80+
// T5 (/init): surface discovered workflows in the generated AGENTS.md prompt
81+
// so the init pass documents them. Names + descriptions (falling back to
82+
// whenToUse). Only VALID workflows are listed; the section is omitted
83+
// entirely when none remain, so a repo with no workflows gets the unchanged
84+
// prompt. list() is the static (never-executed) reader — safe at build time.
85+
const allWorkflows = (yield* workflow.list()).filter((wf) => wf.valid !== false)
86+
// The init section documents REPO/PROJECT-defined workflows ("This repository
87+
// defines …"); the builtins that ship inside opencode (source_kind "builtin",
88+
// e.g. deep-research) are not repo-specific, so they are excluded from the
89+
// prompt section. They still register as Command.Info entries via the loop below.
90+
const initWorkflows = allWorkflows.filter((wf) => wf.source_kind !== "builtin")
91+
const workflowsSection =
92+
initWorkflows.length === 0
93+
? ""
94+
: "\n\n## Available workflows\n\nThis repository defines OpenCode workflows. Mention them in `AGENTS.md` so future sessions know they exist:\n\n" +
95+
initWorkflows
96+
.map((wf) => {
97+
const desc = wf.meta.description ?? wf.meta.whenToUse
98+
return desc ? `- \`${wf.name}\` — ${desc}` : `- \`${wf.name}\``
99+
})
100+
.join("\n")
101+
78102
commands[Default.INIT] = {
79103
name: Default.INIT,
80104
description: "guided AGENTS.md setup",
81105
source: "command",
82106
get template() {
83-
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
107+
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) + workflowsSection
84108
},
85109
hints: hints(PROMPT_INITIALIZE),
86110
}
@@ -152,6 +176,33 @@ export const layer = Layer.effect(
152176
}
153177
}
154178

179+
// Spec §5.2 (3) / Delta 4+5: discovered workflows become real Command.Info
180+
// entries (source "workflow") so they appear in /help and Command.list()
181+
// (the TUI's sync.data.command), in parity with the autocomplete /<name>
182+
// path. Runs LAST so any real command/mcp/skill of the same name wins
183+
// (collision skip via `if (commands[wf.name]) continue`); invalid (broken)
184+
// workflow files cannot be started, so they are skipped too.
185+
// DELTA: source "workflow" is DISCOVERY-ONLY — the TUI dispatch starts these
186+
// via the /workflow path (see AUTOCOMPLETE), NOT via session.command, so the
187+
// empty `template` is never executed as a prompt.
188+
// Reuses `allWorkflows` (already filtered to valid, INCLUDING builtins so
189+
// deep-research still registers as a command) instead of a second
190+
// workflow.list() call. The `valid === false` guard is now redundant but kept
191+
// for clarity; the collision skip below still applies.
192+
for (const wf of allWorkflows) {
193+
if (wf.valid === false) continue
194+
if (commands[wf.name]) continue
195+
commands[wf.name] = {
196+
name: wf.name,
197+
description: wf.meta.description ?? wf.meta.whenToUse,
198+
source: "workflow",
199+
get template() {
200+
return ""
201+
},
202+
hints: [],
203+
}
204+
}
205+
155206
return {
156207
commands,
157208
}
@@ -177,8 +228,9 @@ export const defaultLayer = layer.pipe(
177228
Layer.provide(Config.defaultLayer),
178229
Layer.provide(MCP.defaultLayer),
179230
Layer.provide(Skill.defaultLayer),
231+
Layer.provide(Workflow.defaultLayer),
180232
)
181233

182-
export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node])
234+
export const node = LayerNode.make(layer, [Config.node, MCP.node, Skill.node, Workflow.node])
183235

184236
export * as Command from "."

packages/opencode/test/fixture/tui-plugin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
139139
: fallback
140140
const client = () => read()
141141
let depth = 0
142-
let size: "medium" | "large" | "xlarge" = "medium"
142+
let size: "medium" | "large" | "xlarge" | "fullscreen" = "medium"
143143
const has = opts.theme?.has ?? (() => false)
144144
let selected = opts.theme?.selected ?? "opencode"
145145
const set =

packages/plugin/src/tui.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,16 @@ export type TuiCommandApi = {
120120
}
121121

122122
export type TuiDialogProps = {
123-
size?: "medium" | "large" | "xlarge"
123+
size?: "medium" | "large" | "xlarge" | "fullscreen"
124124
onClose: () => void
125125
children?: JSX.Element
126126
}
127127

128128
export type TuiDialogStack = {
129129
replace: (render: () => JSX.Element, onClose?: () => void) => void
130130
clear: () => void
131-
setSize: (size: "medium" | "large" | "xlarge") => void
132-
readonly size: "medium" | "large" | "xlarge"
131+
setSize: (size: "medium" | "large" | "xlarge" | "fullscreen") => void
132+
readonly size: "medium" | "large" | "xlarge" | "fullscreen"
133133
readonly depth: number
134134
readonly open: boolean
135135
}

0 commit comments

Comments
 (0)