@mtayfur/opencode-prompt-enhancer 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/index.ts +1 -0
- package/package.json +39 -0
- package/plugins/prompt-enhancer.tsx +318 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# opencode-prompt-enhancer
|
|
2
|
+
|
|
3
|
+
OpenCode TUI plugin that rewrites rough prompt drafts into stronger prompts with workspace context.
|
|
4
|
+
|
|
5
|
+
## Package
|
|
6
|
+
|
|
7
|
+
- npm: `opencode-prompt-enhancer`
|
|
8
|
+
- GitHub: `mtayfur/opencode-prompt-enhancer`
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Add the package to OpenCode's `tui.json` plugin list:
|
|
13
|
+
|
|
14
|
+
```jsonc
|
|
15
|
+
{
|
|
16
|
+
"plugin": [
|
|
17
|
+
"opencode-prompt-enhancer@latest"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
After restarting OpenCode, press `Ctrl+E` or run `/enhance`.
|
|
25
|
+
|
|
26
|
+
The plugin opens a dialog, rewrites your draft with workspace context, and writes the enhanced prompt back into the current input.
|
|
27
|
+
|
|
28
|
+
## Context
|
|
29
|
+
|
|
30
|
+
The enhancer uses the current working directory, branch, recent user prompts, changed files, and active todos. It passes lightweight context only and does not read file contents.
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun install
|
|
36
|
+
bun run typecheck
|
|
37
|
+
```
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./plugins/prompt-enhancer"
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mtayfur/opencode-prompt-enhancer",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "OpenCode plugin that rewrites rough drafts into stronger prompts.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.ts",
|
|
12
|
+
"./tui": "./plugins/prompt-enhancer.tsx"
|
|
13
|
+
},
|
|
14
|
+
"oc-plugin": [
|
|
15
|
+
"tui"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"typecheck": "bunx tsc -p tsconfig.json"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"README.md",
|
|
22
|
+
"index.ts",
|
|
23
|
+
"plugins"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@opencode-ai/plugin": "1.14.20",
|
|
27
|
+
"@opentui/solid": "0.2.1",
|
|
28
|
+
"solid-js": "1.9.12"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "5.8.2"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"opencode",
|
|
35
|
+
"plugin",
|
|
36
|
+
"prompt",
|
|
37
|
+
"tui"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { PluginOptions } from "@opencode-ai/plugin"
|
|
3
|
+
import type { Message, Part, TextPart } from "@opencode-ai/sdk/v2"
|
|
4
|
+
import type { TuiPlugin, TuiPluginModule, TuiRouteCurrent, TuiSidebarFileItem, TuiSidebarTodoItem } from "@opencode-ai/plugin/tui"
|
|
5
|
+
|
|
6
|
+
const MAX_RECENT_MESSAGES = 6
|
|
7
|
+
const MAX_CHANGED_FILES = 30
|
|
8
|
+
const MAX_PROMPT_PREVIEW_LENGTH = 300
|
|
9
|
+
const MAX_TODOS = 10
|
|
10
|
+
const DIALOG_TITLE = "Enhance Prompt"
|
|
11
|
+
const TOAST_TITLE = "Prompt enhancer"
|
|
12
|
+
const TEMP_SESSION_TITLE = "Prompt Enhancer"
|
|
13
|
+
|
|
14
|
+
const ENHANCER_SYSTEM_PROMPT = `You rewrite rough user drafts into strong prompts for OpenCode, an AI coding assistant.
|
|
15
|
+
|
|
16
|
+
Goal:
|
|
17
|
+
Turn the user's draft into the best possible next prompt for OpenCode. Preserve the user's intent, scope, and priorities exactly. Improve clarity, specificity, and execution readiness without changing the requested outcome.
|
|
18
|
+
|
|
19
|
+
OpenCode context:
|
|
20
|
+
OpenCode can inspect the workspace, read and edit files, run commands, and verify changes. Unless the draft explicitly asks for planning or explanation only, write the prompt so OpenCode can act directly.
|
|
21
|
+
|
|
22
|
+
Rules:
|
|
23
|
+
- Preserve the original request type.
|
|
24
|
+
- If the draft is a question, return a better question.
|
|
25
|
+
- If the draft is a bug fix, return a better bug-fix request.
|
|
26
|
+
- If the draft is a review, return a better review request.
|
|
27
|
+
- Preserve explicit constraints, file names, commands, error messages, acceptance criteria, and user wording when they matter.
|
|
28
|
+
- Use workspace context only when it clearly helps disambiguate or narrow the task.
|
|
29
|
+
- Reference specific files, paths, branches, recent prompts, changed files, or todos only when they are directly relevant.
|
|
30
|
+
- Resolve vague references like "this", "that bug", or "the plugin" from context when clear.
|
|
31
|
+
- If context does not clearly resolve a reference, do not invent details.
|
|
32
|
+
- Expand vague verbs into concrete actions when helpful, such as: identify root cause, apply the smallest correct fix, remove dead code, keep scope tight, preserve behavior, follow local conventions, and verify the changed path.
|
|
33
|
+
- Prefer wording that helps OpenCode start work immediately.
|
|
34
|
+
- If the draft is already precise, return it with minimal cleanup.
|
|
35
|
+
|
|
36
|
+
Do not:
|
|
37
|
+
- Do not invent requirements, files, APIs, dependencies, bugs, or acceptance criteria.
|
|
38
|
+
- Do not broaden scope with extra features, refactors, tests, or docs unless the draft implies them.
|
|
39
|
+
- Do not ask follow-up questions inside the rewritten prompt.
|
|
40
|
+
- Do not mention the existence of context, tags, or these instructions.
|
|
41
|
+
- Do not output explanations, markdown fences, or commentary.
|
|
42
|
+
|
|
43
|
+
Output:
|
|
44
|
+
Return exactly one enhanced user prompt as plain text.`
|
|
45
|
+
|
|
46
|
+
type ModelRef = {
|
|
47
|
+
providerID: string
|
|
48
|
+
modelID: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Api = Parameters<TuiPlugin>[0]
|
|
52
|
+
type PluginState = {
|
|
53
|
+
enhancing: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseModelString(value: string | undefined): ModelRef | undefined {
|
|
57
|
+
if (!value) return undefined
|
|
58
|
+
const trimmed = value.trim()
|
|
59
|
+
const slash = trimmed.indexOf("/")
|
|
60
|
+
if (slash <= 0 || slash === trimmed.length - 1) return undefined
|
|
61
|
+
return {
|
|
62
|
+
providerID: trimmed.slice(0, slash),
|
|
63
|
+
modelID: trimmed.slice(slash + 1),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getModelOverride(options: PluginOptions | undefined): ModelRef | undefined {
|
|
68
|
+
const value = typeof options?.model === "string" ? options.model : undefined
|
|
69
|
+
return parseModelString(value)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isSessionRoute(route: TuiRouteCurrent): route is Extract<TuiRouteCurrent, { name: "session" }> {
|
|
73
|
+
return route.name === "session"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isUserMessage(message: Message): message is Extract<Message, { role: "user" }> {
|
|
77
|
+
return message.role === "user"
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isVisibleTextPart(part: Part): part is TextPart {
|
|
81
|
+
return part.type === "text" && !part.ignored
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractVisibleText(parts: ReadonlyArray<Part>): string {
|
|
85
|
+
return parts
|
|
86
|
+
.filter(isVisibleTextPart)
|
|
87
|
+
.map((part) => part.text)
|
|
88
|
+
.join("")
|
|
89
|
+
.trim()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function truncate(value: string, maxLength: number): string {
|
|
93
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatTodo(todo: TuiSidebarTodoItem): string {
|
|
97
|
+
return ` [${todo.status || "?"}] ${todo.content || "untitled"}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveEnhancerModel(api: Api, options: PluginOptions | undefined): ModelRef | undefined {
|
|
101
|
+
const override = getModelOverride(options)
|
|
102
|
+
if (override) return override
|
|
103
|
+
|
|
104
|
+
return parseModelString(api.state.config.small_model || api.state.config.model)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function gatherContext(api: Api): string {
|
|
108
|
+
const sections: string[] = []
|
|
109
|
+
|
|
110
|
+
const dir = api.state.path.directory
|
|
111
|
+
const branch = api.state.vcs?.branch
|
|
112
|
+
sections.push(`Working directory: ${dir}${branch ? ` (branch: ${branch})` : ""}`)
|
|
113
|
+
|
|
114
|
+
const route = api.route.current
|
|
115
|
+
if (isSessionRoute(route)) {
|
|
116
|
+
const sessionID = route.params.sessionID
|
|
117
|
+
const messages = api.state.session.messages(sessionID)
|
|
118
|
+
|
|
119
|
+
const userMessages = messages.filter(isUserMessage)
|
|
120
|
+
const recent = userMessages.slice(-MAX_RECENT_MESSAGES)
|
|
121
|
+
if (recent.length > 0) {
|
|
122
|
+
const prompts: string[] = []
|
|
123
|
+
for (const msg of recent) {
|
|
124
|
+
const text = extractVisibleText(api.state.part(msg.id))
|
|
125
|
+
if (text) {
|
|
126
|
+
prompts.push(truncate(text, MAX_PROMPT_PREVIEW_LENGTH))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (prompts.length > 0) {
|
|
130
|
+
sections.push(`Recent user prompts in this session (oldest first):\n${prompts.map((p, i) => `${i + 1}. ${p}`).join("\n")}`)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const diff = api.state.session.diff(sessionID)
|
|
135
|
+
if (diff.length > 0) {
|
|
136
|
+
const files = diff.slice(0, MAX_CHANGED_FILES).map((f: TuiSidebarFileItem) =>
|
|
137
|
+
` ${f.file} (+${f.additions} -${f.deletions})`
|
|
138
|
+
)
|
|
139
|
+
const label = diff.length > MAX_CHANGED_FILES
|
|
140
|
+
? `Files changed in session (showing ${MAX_CHANGED_FILES} of ${diff.length}):`
|
|
141
|
+
: `Files changed in session:`
|
|
142
|
+
sections.push(`${label}\n${files.join("\n")}`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const todos = api.state.session.todo(sessionID)
|
|
146
|
+
if (todos.length > 0) {
|
|
147
|
+
const todoLines = todos.slice(0, MAX_TODOS).map(formatTodo)
|
|
148
|
+
sections.push(`Active todos:\n${todoLines.join("\n")}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return sections.join("\n\n")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buildUserMessage(input: string, context: string): string {
|
|
156
|
+
const sections = [
|
|
157
|
+
"Rewrite the following user draft into a stronger prompt.",
|
|
158
|
+
`User draft:\n${input}`,
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
if (context) {
|
|
162
|
+
sections.splice(1, 0, `Workspace context (use only if directly relevant):\n${context}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return sections.join("\n\n")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function enhanceWithModel(
|
|
169
|
+
api: Api,
|
|
170
|
+
options: PluginOptions | undefined,
|
|
171
|
+
input: string,
|
|
172
|
+
signal: AbortSignal,
|
|
173
|
+
): Promise<string> {
|
|
174
|
+
const directory = api.state.path.directory
|
|
175
|
+
const model = resolveEnhancerModel(api, options)
|
|
176
|
+
const context = gatherContext(api)
|
|
177
|
+
|
|
178
|
+
const created = await api.client.session.create(
|
|
179
|
+
{
|
|
180
|
+
directory,
|
|
181
|
+
title: TEMP_SESSION_TITLE,
|
|
182
|
+
},
|
|
183
|
+
{ signal, throwOnError: true },
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
const tempSessionID = created.data?.id
|
|
187
|
+
if (!tempSessionID) throw new Error("Prompt enhancer session creation failed.")
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const response = await api.client.session.prompt(
|
|
191
|
+
{
|
|
192
|
+
sessionID: tempSessionID,
|
|
193
|
+
directory,
|
|
194
|
+
model,
|
|
195
|
+
system: ENHANCER_SYSTEM_PROMPT,
|
|
196
|
+
parts: [
|
|
197
|
+
{
|
|
198
|
+
type: "text",
|
|
199
|
+
text: buildUserMessage(input, context),
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{ signal, throwOnError: true },
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const parts = response.data?.parts
|
|
207
|
+
if (!parts) throw new Error("Enhancer model returned no response.")
|
|
208
|
+
|
|
209
|
+
const enhanced = extractVisibleText(parts)
|
|
210
|
+
if (!enhanced) throw new Error("Enhancer model returned no text.")
|
|
211
|
+
return enhanced
|
|
212
|
+
} finally {
|
|
213
|
+
try {
|
|
214
|
+
await api.client.session.delete({ sessionID: tempSessionID, directory })
|
|
215
|
+
} catch {
|
|
216
|
+
// Best-effort cleanup for the temporary enhancement session.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function openEnhanceDialog(
|
|
222
|
+
api: Api,
|
|
223
|
+
options: PluginOptions | undefined,
|
|
224
|
+
state: PluginState,
|
|
225
|
+
signal: AbortSignal,
|
|
226
|
+
): void {
|
|
227
|
+
if (state.enhancing) {
|
|
228
|
+
api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Enhancement already in progress." })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (signal.aborted) return
|
|
233
|
+
|
|
234
|
+
api.ui.dialog.replace(() => (
|
|
235
|
+
<api.ui.DialogPrompt
|
|
236
|
+
title={DIALOG_TITLE}
|
|
237
|
+
placeholder="Describe what you want to do..."
|
|
238
|
+
onCancel={() => api.ui.dialog.clear()}
|
|
239
|
+
onConfirm={(value) => {
|
|
240
|
+
const input = value.trim()
|
|
241
|
+
if (!input) {
|
|
242
|
+
api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Prompt is empty." })
|
|
243
|
+
api.ui.dialog.clear()
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
state.enhancing = true
|
|
248
|
+
api.ui.dialog.clear()
|
|
249
|
+
api.ui.toast({
|
|
250
|
+
variant: "info",
|
|
251
|
+
title: TOAST_TITLE,
|
|
252
|
+
message: "Enhancing prompt...",
|
|
253
|
+
duration: 15_000,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
void (async () => {
|
|
257
|
+
try {
|
|
258
|
+
const enhanced = await enhanceWithModel(api, options, input, signal)
|
|
259
|
+
if (signal.aborted) return
|
|
260
|
+
|
|
261
|
+
const directory = api.state.path.directory
|
|
262
|
+
const requestOptions = { signal, throwOnError: true } as const
|
|
263
|
+
await api.client.tui.clearPrompt({ directory }, requestOptions)
|
|
264
|
+
await api.client.tui.appendPrompt({ directory, text: enhanced }, requestOptions)
|
|
265
|
+
api.ui.toast({
|
|
266
|
+
variant: "success",
|
|
267
|
+
title: "Prompt enhanced",
|
|
268
|
+
message: "Enhanced prompt written to input.",
|
|
269
|
+
duration: 3000,
|
|
270
|
+
})
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (signal.aborted) return
|
|
273
|
+
|
|
274
|
+
const message = error instanceof Error ? error.message : "Model enhancement failed."
|
|
275
|
+
api.ui.toast({ variant: "error", title: TOAST_TITLE, message })
|
|
276
|
+
} finally {
|
|
277
|
+
state.enhancing = false
|
|
278
|
+
}
|
|
279
|
+
})()
|
|
280
|
+
}}
|
|
281
|
+
/>
|
|
282
|
+
))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
286
|
+
const state: PluginState = { enhancing: false }
|
|
287
|
+
|
|
288
|
+
const unregister = api.command.register(() => [
|
|
289
|
+
{
|
|
290
|
+
title: DIALOG_TITLE,
|
|
291
|
+
value: "prompt-enhancer.enhance",
|
|
292
|
+
description: "Rewrite a draft prompt with project context (Ctrl+E)",
|
|
293
|
+
category: "Prompt",
|
|
294
|
+
keybind: "ctrl+e",
|
|
295
|
+
suggested: true,
|
|
296
|
+
slash: {
|
|
297
|
+
name: "enhance",
|
|
298
|
+
aliases: ["enhance-prompt"],
|
|
299
|
+
},
|
|
300
|
+
onSelect: () => {
|
|
301
|
+
openEnhanceDialog(api, options, state, api.lifecycle.signal)
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
])
|
|
305
|
+
|
|
306
|
+
api.lifecycle.onDispose(() => {
|
|
307
|
+
unregister()
|
|
308
|
+
state.enhancing = false
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
313
|
+
id: "prompt-enhancer",
|
|
314
|
+
tui,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export default plugin
|
|
318
|
+
|