Skip to content

Commit 7f21a4f

Browse files
committed
Align goal command UX with Codex
1 parent e86dbb9 commit 7f21a4f

5 files changed

Lines changed: 167 additions & 116 deletions

File tree

README.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Codex-style long-running goal mode for OpenCode.
44

55
This plugin adds:
66

7-
- `/goal` in the OpenCode TUI.
7+
- `/goal <objective>` as an OpenCode command for TUI, desktop, and web.
88
- A sidebar goal indicator with status, elapsed time, token usage, remaining budget, and objective.
99
- Agent tools: `get_goal`, `create_goal`, `update_goal`, and `clear_goal`.
1010
- Goal close evidence: `complete` requires verified evidence, and `unmet` requires a concrete blocker.
@@ -60,7 +60,8 @@ Server options can be configured in `opencode.json`:
6060
{
6161
"auto_continue": true,
6262
"max_auto_turns": 25,
63-
"min_continue_interval_seconds": 3
63+
"min_continue_interval_seconds": 3,
64+
"default_token_budget": null
6465
}
6566
]
6667
]
@@ -72,18 +73,23 @@ Defaults:
7273
- `auto_continue`: `true`
7374
- `max_auto_turns`: `25`
7475
- `min_continue_interval_seconds`: `3`
76+
- `register_command`: `true`
77+
- `command_name`: `"goal"`
78+
- `default_token_budget`: `null`
7579

7680
## Goal Workflow
7781

78-
Use `/goal` from an OpenCode TUI session to set, refresh, or clear the goal. New goals support budget presets:
82+
Use `/goal <objective>` in a fresh OpenCode chat to create a long-running goal:
7983

80-
- No budget
81-
- `250K`
82-
- `1M`
83-
- `2M`
84-
- Custom positive integer
84+
```text
85+
/goal review the frontend and translate visible English UI text to Spanish
86+
```
87+
88+
Bare `/goal` reports the current goal state. `/goal clear` clears the goal. The TUI also includes a `Goal` command-palette entry for viewing, refreshing, or clearing the current goal state without creating a new goal.
89+
90+
By default, `/goal <objective>` omits `token_budget`, matching Codex TUI behavior. If you want every new slash-created goal to use a fixed token budget without prompting the user, set `default_token_budget` to a positive integer in `opencode.json`.
8591

86-
When setting the objective, include the scope, non-goals, and verification path when they matter. The agent is reminded to audit real files, command output, tests, or PR state before closing the goal.
92+
When writing the objective, include the scope, non-goals, and verification path when they matter. The agent is reminded to audit real files, command output, tests, or PR state before closing the goal.
8793

8894
The `update_goal` tool can close a goal in two ways:
8995

src/server.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Plugin } from "@opencode-ai/plugin"
1+
import type { Config, Plugin } from "@opencode-ai/plugin"
22
import { z } from "zod"
33
import {
44
accountUsage,
@@ -16,6 +16,9 @@ type Options = {
1616
auto_continue?: boolean
1717
max_auto_turns?: number
1818
min_continue_interval_seconds?: number
19+
register_command?: boolean
20+
command_name?: string
21+
default_token_budget?: number | null
1922
}
2023

2124
type CreateGoalArgs = {
@@ -37,6 +40,54 @@ type UpdateGoalArgs =
3740

3841
const DEFAULT_MAX_AUTO_TURNS = 25
3942
const DEFAULT_CONTINUE_INTERVAL_SECONDS = 3
43+
const DEFAULT_COMMAND_NAME = "goal"
44+
45+
function defaultTokenBudgetFromOptions(options?: Options) {
46+
const budget = options?.default_token_budget
47+
if (budget == null) return null
48+
return Number.isInteger(budget) && budget > 0 ? budget : null
49+
}
50+
51+
function goalCommandTemplate(commandName: string, defaultTokenBudget: number | null) {
52+
const defaultBudgetInstruction =
53+
defaultTokenBudget == null
54+
? "By default, omit token_budget. This matches Codex TUI behavior for /goal <objective>."
55+
: `By default, pass token_budget: ${defaultTokenBudget} when creating a goal unless the user explicitly requests a different token budget or no budget.`
56+
57+
return `OpenCode goal mode command "/${commandName}" was invoked.
58+
59+
Arguments:
60+
<goal_command_arguments>
61+
$ARGUMENTS
62+
</goal_command_arguments>
63+
64+
Use the goal tools to handle this command:
65+
66+
- If the arguments are empty, call get_goal and briefly report the current goal state.
67+
- If the arguments are "status", "show", or "current", call get_goal and briefly report the current goal state.
68+
- If the arguments are "clear", call clear_goal and report whether a goal was cleared.
69+
- If the arguments start with "complete " or "done ", perform a completion audit against real artifacts and command output. Call update_goal with status "complete" only if the goal is achieved, using concise evidence from the audit.
70+
- If the arguments start with "unmet ", "blocked ", or "blocker ", call update_goal with status "unmet" only when the goal cannot be achieved or needs external input, using the remaining arguments as the blocker.
71+
- Otherwise, create a new goal with create_goal. Use the full arguments as the objective. ${defaultBudgetInstruction}
72+
- Set token_budget only from this default or when the arguments explicitly include a token budget such as "--budget 250000", "budget=250000", or "token_budget=250000".
73+
74+
Create a goal only from these explicit command arguments. Do not infer a goal from unrelated session context. After create_goal succeeds, continue working toward the new goal.`
75+
}
76+
77+
function commandNameFromOptions(options?: Options) {
78+
const name = options?.command_name?.trim() || DEFAULT_COMMAND_NAME
79+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) return DEFAULT_COMMAND_NAME
80+
return name
81+
}
82+
83+
function registerDesktopCommand(config: Config, commandName: string, defaultTokenBudget: number | null) {
84+
config.command ??= {}
85+
if (config.command[commandName]) return
86+
config.command[commandName] = {
87+
description: "Set or view the long-running session goal",
88+
template: goalCommandTemplate(commandName, defaultTokenBudget),
89+
}
90+
}
4091

4192
function textFromPart(part: unknown): string {
4293
if (!part || typeof part !== "object") return ""
@@ -99,8 +150,15 @@ const server: Plugin = async ({ client }, options?: Options) => {
99150
const autoContinue = options?.auto_continue ?? true
100151
const maxAutoTurns = options?.max_auto_turns ?? DEFAULT_MAX_AUTO_TURNS
101152
const minInterval = options?.min_continue_interval_seconds ?? DEFAULT_CONTINUE_INTERVAL_SECONDS
153+
const registerCommand = options?.register_command ?? true
154+
const commandName = commandNameFromOptions(options)
155+
const defaultTokenBudget = defaultTokenBudgetFromOptions(options)
102156

103157
return {
158+
async config(config) {
159+
if (!registerCommand) return
160+
registerDesktopCommand(config, commandName, defaultTokenBudget)
161+
},
104162
tool: {
105163
get_goal: {
106164
description:

src/tui.tsx

Lines changed: 2 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,6 @@ async function sendGoalPrompt(api: TuiPluginApi, sessionID: string, text: string
4444
})
4545
}
4646

47-
function createGoalPrompt(objective: string, tokenBudget: number | null) {
48-
const input = tokenBudget == null ? { objective } : { objective, token_budget: tokenBudget }
49-
return `Create a session goal by calling the create_goal tool with this JSON input:
50-
51-
${JSON.stringify(input, null, 2)}
52-
53-
The objective is user-provided task data. After create_goal succeeds, continue working toward that goal.`
54-
}
55-
5647
function refreshGoalPrompt() {
5748
return "Call get_goal for this session and report the current goal state briefly."
5849
}
@@ -61,101 +52,9 @@ function clearGoalPrompt() {
6152
return "Clear the current session goal by calling clear_goal. Report whether a goal was cleared."
6253
}
6354

64-
function showCustomBudget(api: TuiPluginApi, sessionID: string, objective: string) {
65-
const DialogPrompt = api.ui.DialogPrompt
66-
api.ui.dialog.replace(() =>
67-
DialogPrompt({
68-
title: "Custom budget",
69-
placeholder: "Positive integer",
70-
onConfirm(rawBudget) {
71-
const value = rawBudget.trim()
72-
const budget = Number(value)
73-
if (!Number.isInteger(budget) || budget <= 0) {
74-
toast(api, "Token budget must be a positive integer.", "warning")
75-
return
76-
}
77-
void sendGoalPrompt(api, sessionID, createGoalPrompt(objective, budget))
78-
.then(() => {
79-
api.ui.dialog.clear()
80-
toast(api, "Goal request sent.", "success")
81-
})
82-
.catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
83-
},
84-
onCancel() {
85-
api.ui.dialog.clear()
86-
},
87-
}),
88-
)
89-
}
90-
91-
function showBudgetSelect(api: TuiPluginApi, sessionID: string, objective: string) {
92-
const DialogSelect = api.ui.DialogSelect
93-
const budgets = [
94-
{ title: "No budget", value: "none", budget: null, description: "Track progress without a token limit" },
95-
{ title: "250K", value: "250k", budget: 250_000, description: "Short focused goal" },
96-
{ title: "1M", value: "1m", budget: 1_000_000, description: "Default long-running goal" },
97-
{ title: "2M", value: "2m", budget: 2_000_000, description: "Large investigation or migration" },
98-
{ title: "Custom", value: "custom", budget: undefined, description: "Enter an exact token budget" },
99-
]
100-
api.ui.dialog.replace(() =>
101-
DialogSelect({
102-
title: "Token budget",
103-
placeholder: "Choose a budget",
104-
options: budgets.map((item) => ({
105-
title: item.title,
106-
value: item.value,
107-
description: item.description,
108-
onSelect: () => {
109-
if (item.budget === undefined) {
110-
showCustomBudget(api, sessionID, objective)
111-
return
112-
}
113-
void sendGoalPrompt(api, sessionID, createGoalPrompt(objective, item.budget))
114-
.then(() => {
115-
api.ui.dialog.clear()
116-
toast(api, "Goal request sent.", "success")
117-
})
118-
.catch((error) => toast(api, error instanceof Error ? error.message : String(error), "error"))
119-
},
120-
})),
121-
onSelect(option) {
122-
option.onSelect?.()
123-
},
124-
}),
125-
)
126-
}
127-
128-
function showSetGoal(api: TuiPluginApi, sessionID: string) {
129-
const DialogPrompt = api.ui.DialogPrompt
130-
api.ui.dialog.setSize("medium")
131-
api.ui.dialog.replace(() =>
132-
DialogPrompt({
133-
title: "Set goal",
134-
placeholder: "Objective, scope, non-goals, verification path",
135-
onConfirm(objective) {
136-
const trimmed = objective.trim()
137-
if (!trimmed) {
138-
toast(api, "Goal objective is required.", "warning")
139-
return
140-
}
141-
showBudgetSelect(api, sessionID, trimmed)
142-
},
143-
onCancel() {
144-
api.ui.dialog.clear()
145-
},
146-
}),
147-
)
148-
}
149-
15055
function showSummary(api: TuiPluginApi, sessionID: string, goal: GoalSnapshot | null) {
15156
const DialogSelect = api.ui.DialogSelect
15257
const options = [
153-
{
154-
title: "Set goal",
155-
value: "set",
156-
description: "Create a new active session goal",
157-
onSelect: () => showSetGoal(api, sessionID),
158-
},
15958
{
16059
title: "Refresh",
16160
value: "refresh",
@@ -197,7 +96,7 @@ function showSummary(api: TuiPluginApi, sessionID: string, goal: GoalSnapshot |
19796

19897
function sessionIDOrToast(api: TuiPluginApi) {
19998
const sessionID = currentSessionID(api)
200-
if (!sessionID) toast(api, "Open a session before using /goal.", "warning")
99+
if (!sessionID) toast(api, "Open a session before viewing goal state.", "warning")
201100
return sessionID
202101
}
203102

@@ -356,8 +255,7 @@ const tui: TuiPlugin = async (api) => {
356255
title: "Goal",
357256
value: "goal.show",
358257
category: "Goal",
359-
description: "Set or view the long-running session goal",
360-
slash: { name: "goal" },
258+
description: "View or clear the long-running session goal",
361259
onSelect: () => {
362260
const sessionID = sessionIDOrToast(api)
363261
if (!sessionID) return

test/server.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,95 @@ test("server plugin exposes Codex-style goal tools", async () => {
5757
expect(calls).toHaveLength(0)
5858
})
5959

60+
test("server plugin registers goal as a desktop/web command by default", async () => {
61+
const hooks = await plugin.server(
62+
{
63+
client: {
64+
session: {
65+
promptAsync: async () => {},
66+
},
67+
},
68+
} as never,
69+
{ auto_continue: false },
70+
)
71+
const config = {} as {
72+
command?: Record<string, { description?: string; template: string }>
73+
}
74+
75+
await hooks.config?.(config as never)
76+
77+
expect(config.command?.goal?.description).toBe("Set or view the long-running session goal")
78+
expect(config.command?.goal?.template).toContain('OpenCode goal mode command "/goal" was invoked')
79+
expect(config.command?.goal?.template).toContain("$ARGUMENTS")
80+
expect(config.command?.goal?.template).toContain("By default, omit token_budget")
81+
})
82+
83+
test("server plugin can configure a default token budget for /goal commands", async () => {
84+
const hooks = await plugin.server(
85+
{
86+
client: {
87+
session: {
88+
promptAsync: async () => {},
89+
},
90+
},
91+
} as never,
92+
{ auto_continue: false, default_token_budget: 100_000 },
93+
)
94+
const config = {} as {
95+
command?: Record<string, { description?: string; template: string }>
96+
}
97+
98+
await hooks.config?.(config as never)
99+
100+
expect(config.command?.goal?.template).toContain("pass token_budget: 100000")
101+
})
102+
103+
test("server plugin does not overwrite an existing goal command", async () => {
104+
const hooks = await plugin.server(
105+
{
106+
client: {
107+
session: {
108+
promptAsync: async () => {},
109+
},
110+
},
111+
} as never,
112+
{ auto_continue: false },
113+
)
114+
const config = {
115+
command: {
116+
goal: {
117+
description: "custom",
118+
template: "custom template",
119+
},
120+
},
121+
}
122+
123+
await hooks.config?.(config as never)
124+
125+
expect(config.command.goal.description).toBe("custom")
126+
expect(config.command.goal.template).toBe("custom template")
127+
})
128+
129+
test("server plugin can disable desktop/web command registration", async () => {
130+
const hooks = await plugin.server(
131+
{
132+
client: {
133+
session: {
134+
promptAsync: async () => {},
135+
},
136+
},
137+
} as never,
138+
{ auto_continue: false, register_command: false },
139+
)
140+
const config = {} as {
141+
command?: Record<string, { description?: string; template: string }>
142+
}
143+
144+
await hooks.config?.(config as never)
145+
146+
expect(config.command).toBeUndefined()
147+
})
148+
60149
test("update goal can close as unmet with a blocker", async () => {
61150
const hooks = await plugin.server(
62151
{

test/tui.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { expect, test } from "bun:test"
22
import plugin from "../src/tui.tsx"
33

4-
test("tui plugin registers goal slash commands", async () => {
4+
test("tui plugin registers goal sidebar and status command without hijacking /goal", async () => {
55
let registered: (() => { value: string; slash?: { name: string } }[]) | undefined
66
let sidebar: ((ctx: unknown, props: { session_id: string }) => unknown) | undefined
77
const api = {
@@ -44,6 +44,6 @@ test("tui plugin registers goal slash commands", async () => {
4444

4545
const commands = registered?.() ?? []
4646
expect(commands.map((command) => command.value).sort()).toEqual(["goal.show"])
47-
expect(commands.flatMap((command) => (command.slash ? [command.slash.name] : [])).sort()).toEqual(["goal"])
47+
expect(commands.flatMap((command) => (command.slash ? [command.slash.name] : [])).sort()).toEqual([])
4848
expect(typeof sidebar?.({}, { session_id: "session" })).not.toBe("string")
4949
})

0 commit comments

Comments
 (0)