@mtayfur/opencode-prompt-enhancer 0.0.4 → 0.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtayfur/opencode-prompt-enhancer",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "OpenCode plugin that rewrites rough drafts into stronger prompts.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -7,50 +7,46 @@ import type {
7
7
  TuiPromptInfo,
8
8
  TuiPromptRef,
9
9
  TuiRouteCurrent,
10
- TuiSidebarFileItem,
11
- TuiSidebarTodoItem,
12
10
  TuiSlotPlugin,
13
11
  } from "@opencode-ai/plugin/tui"
14
12
 
15
- const MAX_RECENT_MESSAGES = 6
16
- const MAX_CHANGED_FILES = 30
17
- const MAX_PROMPT_PREVIEW_LENGTH = 300
18
- const MAX_TODOS = 10
13
+ const MAX_RECENT_MESSAGES = 3
14
+ const MAX_CHANGED_FILES = 25
15
+ const MAX_PROMPT_PREVIEW_LENGTH = 250
19
16
  const DIALOG_TITLE = "Enhance Prompt"
20
17
  const TOAST_TITLE = "Prompt enhancer"
21
18
  const TEMP_SESSION_TITLE = "Prompt Enhancer"
22
- const HOME_PROMPT_PLACEHOLDERS = {
23
- normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
24
- shell: ["ls -la", "git status", "pwd"],
19
+
20
+ function makeTempSessionTitle(): string {
21
+ return `${TEMP_SESSION_TITLE} ${Math.random().toString(36).slice(2, 8)}`
25
22
  }
26
23
 
27
- const ENHANCER_SYSTEM_PROMPT = `You rewrite rough user drafts into strong prompts for OpenCode, an AI coding assistant.
24
+ const ENHANCER_SYSTEM_PROMPT = `You are a prompt editor for OpenCode, an AI coding assistant.
28
25
 
29
26
  Goal:
30
- 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.
31
-
32
- OpenCode context:
33
- 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.
34
-
35
- Rules:
36
- - Preserve the original request type.
37
- - If the draft is a question, return a better question.
38
- - If the draft is a bug fix, return a better bug-fix request.
39
- - If the draft is a review, return a better review request.
40
- - Preserve explicit constraints, file names, commands, error messages, acceptance criteria, and user wording when they matter.
41
- - Use workspace context only when it clearly helps disambiguate or narrow the task.
42
- - Reference specific files, paths, branches, recent prompts, changed files, or todos only when they are directly relevant.
43
- - Resolve vague references like "this", "that bug", or "the plugin" from context when clear.
44
- - If context does not clearly resolve a reference, do not invent details.
45
- - 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.
46
- - Prefer wording that helps OpenCode start work immediately.
47
- - If the draft is already precise, return it with minimal cleanup.
48
-
49
- Do not:
27
+ Rewrite the user's draft into the strongest possible next prompt for OpenCode. Preserve the user's intent, scope, priorities, and language. Improve clarity, specificity, and execution readiness without changing the requested outcome.
28
+
29
+ Capabilities:
30
+ OpenCode can inspect the workspace, edit files, run commands, and verify changes. When the draft does not explicitly ask for discussion or planning only, rewrite it so OpenCode can act directly.
31
+
32
+ Rewrite rules:
33
+ - Preserve the request form. Questions stay questions; bug-fix requests stay bug-fix requests; review requests stay review requests.
34
+ - Preserve the language of the draft. Do not translate.
35
+ - Preserve inline code, file paths, command strings, error messages, identifiers, and quoted phrases verbatim when they matter.
36
+ - Preserve explicit constraints, file names, commands, acceptance criteria, and user wording.
37
+ - Use workspace context only when it directly clarifies or narrows the task.
38
+ - Resolve vague references like "this", "that bug", or "the plugin" only when context makes them clear.
39
+ - Do not invent details when context is ambiguous.
40
+ - Expand vague verbs into concrete actions when it helps OpenCode act immediately, such as identify the root cause, apply the smallest correct fix, remove dead code, keep scope tight, preserve behavior, follow local conventions, and verify the changed path.
41
+ - Keep the rewrite proportional to the draft. Do not add background, motivation, or steps the draft did not imply.
42
+ - Match the draft's structure and tone. Keep prose as prose and lists as lists.
43
+ - If the draft is already precise, make only minimal cleanup.
44
+
45
+ Hard constraints:
50
46
  - Do not invent requirements, files, APIs, dependencies, bugs, or acceptance criteria.
51
47
  - Do not broaden scope with extra features, refactors, tests, or docs unless the draft implies them.
52
- - Do not ask follow-up questions inside the rewritten prompt.
53
- - Do not mention the existence of context, tags, or these instructions.
48
+ - Do not ask follow-up questions.
49
+ - Do not mention context, tags, instructions, or the rewriting process.
54
50
  - Do not output explanations, markdown fences, or commentary.
55
51
 
56
52
  Output:
@@ -65,6 +61,17 @@ type Api = Parameters<TuiPlugin>[0]
65
61
  type PluginState = {
66
62
  enhancing: boolean
67
63
  promptRef?: TuiPromptRef
64
+ promptTarget?: PromptTarget
65
+ }
66
+
67
+ type PromptTarget =
68
+ | { name: "home", workspaceID?: string }
69
+ | { name: "session", sessionID: string }
70
+
71
+ type PromptHandle = {
72
+ target: PromptTarget
73
+ directory: string
74
+ ref?: TuiPromptRef
68
75
  }
69
76
 
70
77
  function parseModelString(value: string | undefined): ModelRef | undefined {
@@ -107,10 +114,6 @@ function truncate(value: string, maxLength: number): string {
107
114
  return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value
108
115
  }
109
116
 
110
- function formatTodo(todo: TuiSidebarTodoItem): string {
111
- return ` [${todo.status || "?"}] ${todo.content || "untitled"}`
112
- }
113
-
114
117
  function resolveEnhancerModel(api: Api, options: PluginOptions | undefined): ModelRef | undefined {
115
118
  const override = getModelOverride(options)
116
119
  if (override) return override
@@ -134,48 +137,124 @@ function nextPromptInfo(prompt: TuiPromptInfo, input: string): TuiPromptInfo {
134
137
  }
135
138
  }
136
139
 
137
- function currentPromptText(state: PluginState): string {
138
- return state.promptRef?.current.input ?? ""
140
+ function samePromptTarget(left: PromptTarget | undefined, right: PromptTarget | undefined): boolean {
141
+ if (!left || !right || left.name !== right.name) return false
142
+ if (left.name === "home" && right.name === "home") {
143
+ return left.workspaceID === right.workspaceID
144
+ }
145
+ if (left.name === "session" && right.name === "session") {
146
+ return left.sessionID === right.sessionID
147
+ }
148
+ return false
139
149
  }
140
150
 
141
- function bindPromptRef(state: PluginState, forwarded: ((ref: TuiPromptRef | undefined) => void) | undefined, ref: TuiPromptRef | undefined): void {
142
- state.promptRef = ref
151
+ function resolvePromptTarget(route: TuiRouteCurrent, workspaceID?: string): PromptTarget | undefined {
152
+ if (route.name === "home") return { name: "home", workspaceID }
153
+ if (isSessionRoute(route)) {
154
+ return { name: "session", sessionID: route.params.sessionID }
155
+ }
156
+ return undefined
157
+ }
158
+
159
+ function isPromptTargetActive(route: TuiRouteCurrent, target: PromptTarget): boolean {
160
+ if (target.name === "home") return route.name === "home"
161
+ return isSessionRoute(route) && route.params.sessionID === target.sessionID
162
+ }
163
+
164
+ function currentPromptTarget(api: Api, state: PluginState): PromptTarget | undefined {
165
+ return state.promptTarget ?? resolvePromptTarget(api.route.current)
166
+ }
167
+
168
+ function isPromptHandleActive(api: Api, state: PluginState, handle: PromptHandle): boolean {
169
+ if (!isPromptTargetActive(api.route.current, handle.target)) return false
170
+ if (api.state.path.directory !== handle.directory) return false
171
+
172
+ if (handle.ref) {
173
+ return state.promptRef === handle.ref && samePromptTarget(state.promptTarget, handle.target)
174
+ }
175
+
176
+ return true
177
+ }
178
+
179
+ function bindPromptRef(
180
+ state: PluginState,
181
+ target: PromptTarget,
182
+ forwarded: ((ref: TuiPromptRef | undefined) => void) | undefined,
183
+ ref: TuiPromptRef | undefined,
184
+ ): void {
185
+ if (ref) {
186
+ state.promptRef = ref
187
+ state.promptTarget = target
188
+ } else if (samePromptTarget(state.promptTarget, target)) {
189
+ state.promptRef = undefined
190
+ state.promptTarget = undefined
191
+ }
192
+
143
193
  forwarded?.(ref)
144
194
  }
145
195
 
146
- async function writePrompt(api: Api, state: PluginState, input: string, signal: AbortSignal, template?: TuiPromptInfo): Promise<void> {
147
- const promptRef = state.promptRef
196
+ async function clearPrompt(api: Api, state: PluginState, handle: PromptHandle, signal: AbortSignal, template?: TuiPromptInfo): Promise<boolean> {
197
+ if (!isPromptHandleActive(api, state, handle)) return false
198
+
199
+ const promptRef = handle.ref
200
+ if (promptRef) {
201
+ promptRef.set(nextPromptInfo(template ?? promptRef.current, ""))
202
+ return true
203
+ }
204
+
205
+ await api.client.tui.clearPrompt({ directory: handle.directory }, { signal, throwOnError: true } as const)
206
+ return true
207
+ }
208
+
209
+ async function writePrompt(
210
+ api: Api,
211
+ state: PluginState,
212
+ handle: PromptHandle,
213
+ input: string,
214
+ signal: AbortSignal,
215
+ template?: TuiPromptInfo,
216
+ ): Promise<boolean> {
217
+ if (!isPromptHandleActive(api, state, handle)) return false
218
+
219
+ const promptRef = handle.ref
148
220
  if (promptRef) {
149
221
  promptRef.set(nextPromptInfo(template ?? promptRef.current, input))
150
222
  promptRef.focus()
151
- return
223
+ return true
152
224
  }
153
225
 
154
- const directory = api.state.path.directory
155
226
  const requestOptions = { signal, throwOnError: true } as const
156
- await api.client.tui.clearPrompt({ directory }, requestOptions)
227
+ await api.client.tui.clearPrompt({ directory: handle.directory }, requestOptions)
157
228
  if (input) {
158
- await api.client.tui.appendPrompt({ directory, text: input }, requestOptions)
229
+ await api.client.tui.appendPrompt({ directory: handle.directory, text: input }, requestOptions)
159
230
  }
231
+ return true
160
232
  }
161
233
 
162
- async function restorePrompt(api: Api, state: PluginState, prompt: TuiPromptInfo, signal: AbortSignal): Promise<void> {
163
- const promptRef = state.promptRef
234
+ async function restorePrompt(
235
+ api: Api,
236
+ state: PluginState,
237
+ handle: PromptHandle,
238
+ prompt: TuiPromptInfo,
239
+ signal: AbortSignal,
240
+ ): Promise<boolean> {
241
+ if (!isPromptHandleActive(api, state, handle)) return false
242
+
243
+ const promptRef = handle.ref
164
244
  if (promptRef) {
165
245
  promptRef.set(clonePromptInfo(prompt))
166
246
  promptRef.focus()
167
- return
247
+ return true
168
248
  }
169
249
 
170
- await writePrompt(api, state, prompt.input, signal, prompt)
250
+ return writePrompt(api, state, handle, prompt.input, signal, prompt)
171
251
  }
172
252
 
173
253
  function gatherContext(api: Api): string {
174
254
  const sections: string[] = []
175
255
 
176
256
  const dir = api.state.path.directory
177
- const branch = api.state.vcs?.branch
178
- sections.push(`Working directory: ${dir}${branch ? ` (branch: ${branch})` : ""}`)
257
+ sections.push(`Working directory: ${dir}`)
179
258
 
180
259
  const route = api.route.current
181
260
  if (isSessionRoute(route)) {
@@ -183,7 +262,7 @@ function gatherContext(api: Api): string {
183
262
  const messages = api.state.session.messages(sessionID)
184
263
 
185
264
  const userMessages = messages.filter(isUserMessage)
186
- const recent = userMessages.slice(-MAX_RECENT_MESSAGES)
265
+ const recent = userMessages.slice(-MAX_RECENT_MESSAGES).reverse()
187
266
  if (recent.length > 0) {
188
267
  const prompts: string[] = []
189
268
  for (const msg of recent) {
@@ -193,26 +272,16 @@ function gatherContext(api: Api): string {
193
272
  }
194
273
  }
195
274
  if (prompts.length > 0) {
196
- sections.push(`Recent user prompts in this session (oldest first):\n${prompts.map((p, i) => `${i + 1}. ${p}`).join("\n")}`)
275
+ sections.push(`Recent user prompts in this session (newest first):\n${prompts.map((p, i) => `${i + 1}. ${p}`).join("\n")}`)
197
276
  }
198
277
  }
199
278
 
200
279
  const diff = api.state.session.diff(sessionID)
201
280
  if (diff.length > 0) {
202
- const files = diff.slice(0, MAX_CHANGED_FILES).map((f: TuiSidebarFileItem) =>
203
- ` ${f.file} (+${f.additions} -${f.deletions})`
204
- )
205
- const label = diff.length > MAX_CHANGED_FILES
206
- ? `Files changed in session (showing ${MAX_CHANGED_FILES} of ${diff.length}):`
207
- : `Files changed in session:`
208
- sections.push(`${label}\n${files.join("\n")}`)
281
+ const files = diff.slice(0, MAX_CHANGED_FILES).map((f) => ` ${f.file}`)
282
+ sections.push(`Files changed in session:\n${files.join("\n")}`)
209
283
  }
210
284
 
211
- const todos = api.state.session.todo(sessionID)
212
- if (todos.length > 0) {
213
- const todoLines = todos.slice(0, MAX_TODOS).map(formatTodo)
214
- sections.push(`Active todos:\n${todoLines.join("\n")}`)
215
- }
216
285
  }
217
286
 
218
287
  return sections.join("\n\n")
@@ -244,13 +313,13 @@ async function enhanceWithModel(
244
313
  const created = await api.client.session.create(
245
314
  {
246
315
  directory,
247
- title: TEMP_SESSION_TITLE,
316
+ title: makeTempSessionTitle(),
248
317
  },
249
318
  { signal, throwOnError: true },
250
319
  )
251
320
 
252
321
  const tempSessionID = created.data?.id
253
- if (!tempSessionID) throw new Error("Prompt enhancer session creation failed.")
322
+ if (!tempSessionID) throw new Error("Failed to start prompt enhancer.")
254
323
 
255
324
  try {
256
325
  const response = await api.client.session.prompt(
@@ -270,10 +339,10 @@ async function enhanceWithModel(
270
339
  )
271
340
 
272
341
  const parts = response.data?.parts
273
- if (!parts) throw new Error("Enhancer model returned no response.")
342
+ if (!parts) throw new Error("Enhancer returned no response.")
274
343
 
275
344
  const enhanced = extractVisibleText(parts)
276
- if (!enhanced) throw new Error("Enhancer model returned no text.")
345
+ if (!enhanced) throw new Error("Enhancer returned no text.")
277
346
  return enhanced
278
347
  } finally {
279
348
  try {
@@ -291,33 +360,47 @@ function openEnhanceDialog(
291
360
  signal: AbortSignal,
292
361
  ): void {
293
362
  if (state.enhancing) {
294
- api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Enhancement already in progress." })
363
+ api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Enhancement in progress." })
295
364
  return
296
365
  }
297
366
 
298
367
  if (signal.aborted) return
299
368
 
300
- const initialValue = currentPromptText(state)
369
+ const target = currentPromptTarget(api, state)
370
+ if (!target) {
371
+ api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Enhancement only works from a prompt." })
372
+ return
373
+ }
374
+
375
+ const handle: PromptHandle = {
376
+ target,
377
+ directory: api.state.path.directory,
378
+ ref: state.promptRef,
379
+ }
380
+ const originalPrompt = handle.ref ? clonePromptInfo(handle.ref.current) : undefined
381
+ const initialValue = originalPrompt?.input ?? ""
301
382
 
302
383
  api.ui.dialog.replace(() => (
303
384
  <api.ui.DialogPrompt
304
385
  title={DIALOG_TITLE}
305
- placeholder="Describe what you want to do..."
386
+ placeholder="Describe the task..."
306
387
  value={initialValue}
307
388
  onCancel={() => api.ui.dialog.clear()}
308
389
  onConfirm={(value) => {
309
390
  const input = value.trim()
310
391
  if (!input) {
311
- api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Prompt is empty." })
392
+ api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Enter a prompt first." })
312
393
  api.ui.dialog.clear()
313
394
  return
314
395
  }
315
396
 
316
- const originalPrompt = state.promptRef ? clonePromptInfo(state.promptRef.current) : undefined
317
- state.enhancing = true
318
- if (originalPrompt) {
319
- state.promptRef?.set(nextPromptInfo(originalPrompt, ""))
397
+ if (!isPromptHandleActive(api, state, handle)) {
398
+ api.ui.toast({ variant: "warning", title: TOAST_TITLE, message: "Prompt changed while dialog was open." })
399
+ api.ui.dialog.clear()
400
+ return
320
401
  }
402
+
403
+ state.enhancing = true
321
404
  api.ui.dialog.clear()
322
405
  api.ui.toast({
323
406
  variant: "info",
@@ -328,24 +411,58 @@ function openEnhanceDialog(
328
411
 
329
412
  void (async () => {
330
413
  try {
414
+ const cleared = await clearPrompt(api, state, handle, signal, originalPrompt)
415
+ if (!cleared) {
416
+ api.ui.toast({
417
+ variant: "warning",
418
+ title: TOAST_TITLE,
419
+ message: "Prompt changed before enhancement started.",
420
+ })
421
+ return
422
+ }
423
+
331
424
  const enhanced = await enhanceWithModel(api, options, input, signal)
332
425
  if (signal.aborted) return
333
426
 
334
- await writePrompt(api, state, enhanced, signal, originalPrompt)
427
+ const wrote = await writePrompt(api, state, handle, enhanced, signal, originalPrompt)
428
+ if (!wrote) {
429
+ api.ui.toast({
430
+ variant: "warning",
431
+ title: TOAST_TITLE,
432
+ message: "Enhanced prompt is ready, but that prompt is no longer active.",
433
+ })
434
+ return
435
+ }
436
+
335
437
  api.ui.toast({
336
438
  variant: "success",
337
439
  title: "Prompt enhanced",
338
- message: "Enhanced prompt written to input.",
440
+ message: "Enhanced prompt added to input.",
339
441
  duration: 3000,
340
442
  })
341
443
  } catch (error) {
342
444
  if (signal.aborted) return
343
445
 
446
+ let restored = true
344
447
  if (originalPrompt) {
345
- await restorePrompt(api, state, originalPrompt, signal)
448
+ try {
449
+ restored = await restorePrompt(api, state, handle, originalPrompt, signal)
450
+ } catch {
451
+ // Best-effort restore; do not suppress the error toast.
452
+ restored = false
453
+ }
454
+ } else {
455
+ try {
456
+ restored = await writePrompt(api, state, handle, input, signal)
457
+ } catch {
458
+ restored = false
459
+ }
346
460
  }
347
461
 
348
- const message = error instanceof Error ? error.message : "Model enhancement failed."
462
+ const baseMessage = error instanceof Error ? error.message : "Prompt enhancement failed."
463
+ const message = restored
464
+ ? baseMessage
465
+ : `${baseMessage} Original prompt could not be restored because the prompt changed.`
349
466
  api.ui.toast({ variant: "error", title: TOAST_TITLE, message })
350
467
  } finally {
351
468
  state.enhancing = false
@@ -364,17 +481,16 @@ const tui: TuiPlugin = async (api, options) => {
364
481
  home_prompt(_ctx, props) {
365
482
  return (
366
483
  <api.ui.Prompt
367
- ref={(ref) => bindPromptRef(state, props.ref, ref)}
484
+ ref={(ref) => bindPromptRef(state, { name: "home", workspaceID: props.workspace_id }, props.ref, ref)}
368
485
  workspaceID={props.workspace_id}
369
486
  right={<api.ui.Slot name="home_prompt_right" workspace_id={props.workspace_id} />}
370
- placeholders={HOME_PROMPT_PLACEHOLDERS}
371
487
  />
372
488
  )
373
489
  },
374
490
  session_prompt(_ctx, props) {
375
491
  return (
376
492
  <api.ui.Prompt
377
- ref={(ref) => bindPromptRef(state, props.ref, ref)}
493
+ ref={(ref) => bindPromptRef(state, { name: "session", sessionID: props.session_id }, props.ref, ref)}
378
494
  sessionID={props.session_id}
379
495
  visible={props.visible}
380
496
  disabled={props.disabled}
@@ -392,7 +508,7 @@ const tui: TuiPlugin = async (api, options) => {
392
508
  {
393
509
  title: DIALOG_TITLE,
394
510
  value: "prompt-enhancer.enhance",
395
- description: "Enhance prompt with project context (Ctrl+E)",
511
+ description: "Enhance current prompt (Ctrl+E)",
396
512
  category: "Prompt",
397
513
  keybind: "ctrl+e",
398
514
  suggested: true,
@@ -409,6 +525,8 @@ const tui: TuiPlugin = async (api, options) => {
409
525
  api.lifecycle.onDispose(() => {
410
526
  unregister()
411
527
  state.enhancing = false
528
+ state.promptRef = undefined
529
+ state.promptTarget = undefined
412
530
  })
413
531
  }
414
532