opencode-ghost 1.0.6 → 1.1.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2026-10-08
9
+
10
+ ### Added
11
+
12
+ - **tui:** Add /suggest debug and log diagnostics
13
+
14
+ ## [1.0.7] - 2026-10-08
15
+
16
+ ### Fixed
17
+
18
+ - **tui:** Remove left-arrow home navigation
19
+ - **tui:** Ignore all slot wrappers when detecting the composer
20
+
8
21
  ## [1.0.6] - 2026-10-08
9
22
 
10
23
  ### Fixed
@@ -221,6 +234,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
221
234
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
222
235
  `recentMessages` and `system`.
223
236
 
237
+ [1.1.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.7...v1.1.0
238
+ [1.0.7]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.6...v1.0.7
224
239
  [1.0.6]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.5...v1.0.6
225
240
  [1.0.5]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.4...v1.0.5
226
241
  [1.0.4]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.3...v1.0.4
package/README.md CHANGED
@@ -24,8 +24,6 @@ a dimmed inline placeholder in the empty composer with
24
24
  - **Hides as you type.** Typing and pasting abort pending generation and hide the
25
25
  preview. Emptying the composer restores the same suggestion without another
26
26
  API call; focus changes also reuse it. Navigation keys leave it intact.
27
- Left on an empty eligible focused composer returns
28
- home, even when suggestions are off (`backOnEmptyLeft`).
29
27
  - **`/suggest` toggles it.** State is stored durably via the plugin's storage.
30
28
 
31
29
  ## Requirements
@@ -94,7 +92,6 @@ copy, use `/absolute/path/to/config/plugins/opencode-ghost`.
94
92
  | `idleDelayMs` | `number` | `500` | Debounce after a turn finishes before generating. |
95
93
  | `recentMessages` | `number` | `10` | How many recent messages feed the suggestion prompt. |
96
94
  | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
97
- | `backOnEmptyLeft` | `boolean` | `true` | Return to the home screen with Left when the composer is empty. |
98
95
 
99
96
  ## Commands
100
97
 
@@ -102,6 +99,12 @@ copy, use `/absolute/path/to/config/plugins/opencode-ghost`.
102
99
  - `/suggest model openai/gpt-6-luna-fast` — set a persisted runtime model override.
103
100
  - `/suggest model clear` — clear the override and use the configured `model`,
104
101
  or the OpenCode small default, never the main model.
102
+ - `/suggest debug` — toggle debug mode (persisted). While on, each generation
103
+ attempt shows a toast with its outcome, resolved model, latency and length, or
104
+ the error.
105
+ - `/suggest debug on` / `/suggest debug off` — set debug mode explicitly.
106
+ - `/suggest log` — show the last 20 generation attempts (outcome, model,
107
+ latency, length and error detail) in a dialog, regardless of debug mode.
105
108
 
106
109
  Model precedence is the persisted `/suggest model` override, then the `model`
107
110
  option, then the effective title agent's model from OpenCode's public agent API.
@@ -144,6 +147,17 @@ When enabling, a suggestion is generated immediately for the current session.
144
147
  Acceptance, a new turn, session changes, disabling, model changes and unloading
145
148
  discard it.
146
149
 
150
+ ## Debugging
151
+
152
+ Ghost records every generation attempt to a bounded in-memory history (the last
153
+ 20): outcome (`ok`, `empty`, `echo`, `no model`, `error`, `aborted`), the resolved
154
+ model with its variant, latency, suggestion length and error detail. `/suggest log`
155
+ shows it in a dialog at any time; `aborted` covers attempts cancelled by typing,
156
+ a newer turn, leaving the session, disabling or unload, so a missing suggestion can
157
+ be traced to a debounce, an empty or `NONE` reply, an echo filter, or a provider
158
+ error. `/suggest debug` toggles the same data as a toast on each attempt (aborts
159
+ are logged but not toasted). The history is not written to disk.
160
+
147
161
  ## Caveats
148
162
 
149
163
  - **No inline typing completions.** V1's slash-argument and history ghosts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Stateless inline next-prompt ghosts for the empty OpenCode V2 composer, accepted with Tab or Right.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/composer.ts CHANGED
@@ -39,9 +39,9 @@ export function isEmptyComposer(editor: TextareaRenderable | undefined, focused:
39
39
  export function isEligibleComposer(editor: TextareaRenderable | undefined, mode: string, normal: boolean, selected: boolean): boolean {
40
40
  if (!editor?.parent || editor.parent.isDestroyed) return false
41
41
  const siblings = editor.parent.getChildren().filter((node) =>
42
- !(node.id.startsWith("slot-layout-") && node.getChildren().length === 0),
42
+ !node.id.startsWith("slot-layout-"),
43
43
  )
44
- if (siblings.length !== 2 || siblings[0] !== editor) return false
44
+ if (siblings.length !== 1 || siblings[0] !== editor) return false
45
45
  return normal && mode === "base" && !selected && !editor.isDestroyed && editor.plainText === "" && !editor.hasSelection() && !editor.traits.capture?.includes("navigate") && editor.extmarks.getAll().length === 0
46
46
  }
47
47
 
@@ -0,0 +1,76 @@
1
+ export type DiagnosticOutcome = "ok" | "empty" | "echo" | "unavailable" | "error" | "aborted"
2
+
3
+ export type DiagnosticEntry = {
4
+ readonly at: number
5
+ readonly sessionID: string
6
+ readonly outcome: DiagnosticOutcome
7
+ readonly model?: string
8
+ readonly durationMs?: number
9
+ readonly chars?: number
10
+ readonly detail?: string
11
+ }
12
+
13
+ export const DIAGNOSTIC_HISTORY_LIMIT = 20
14
+
15
+ export type Diagnostics = {
16
+ record(entry: Omit<DiagnosticEntry, "at">): DiagnosticEntry
17
+ list(): readonly DiagnosticEntry[]
18
+ }
19
+
20
+ export function createDiagnostics(limit = DIAGNOSTIC_HISTORY_LIMIT, now: () => number = Date.now): Diagnostics {
21
+ const entries: DiagnosticEntry[] = []
22
+ return {
23
+ record(input) {
24
+ const entry: DiagnosticEntry = { ...input, at: now() }
25
+ entries.push(entry)
26
+ if (entries.length > limit) entries.splice(0, entries.length - limit)
27
+ return entry
28
+ },
29
+ list() {
30
+ return [...entries].reverse()
31
+ },
32
+ }
33
+ }
34
+
35
+ export function modelLabel(model: { providerID: string; modelID: string; variant?: string }): string {
36
+ return `${model.providerID}/${model.modelID}${model.variant ? `@${model.variant}` : ""}`
37
+ }
38
+
39
+ export function errorDetail(error: unknown): string {
40
+ const text = error instanceof Error
41
+ ? error.message
42
+ : typeof error === "string"
43
+ ? error
44
+ : error === undefined || error === null
45
+ ? ""
46
+ : String(error)
47
+ return text.replace(/\s+/g, " ").trim() || "unknown error"
48
+ }
49
+
50
+ const OUTCOME_LABELS: Record<DiagnosticOutcome, string> = {
51
+ ok: "ok",
52
+ empty: "empty",
53
+ echo: "echo",
54
+ unavailable: "no model",
55
+ error: "error",
56
+ aborted: "aborted",
57
+ }
58
+
59
+ function clockTime(at: number): string {
60
+ const date = new Date(at)
61
+ const pad = (value: number) => String(value).padStart(2, "0")
62
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
63
+ }
64
+
65
+ export function formatDiagnostic(entry: DiagnosticEntry): string {
66
+ const parts = [clockTime(entry.at), OUTCOME_LABELS[entry.outcome], entry.model ?? "—"]
67
+ if (entry.durationMs !== undefined) parts.push(`${entry.durationMs}ms`)
68
+ if (entry.chars !== undefined) parts.push(`${entry.chars} chars`)
69
+ const line = parts.join(" ")
70
+ return entry.detail ? `${line}\n ${entry.detail}` : line
71
+ }
72
+
73
+ export function formatDiagnostics(entries: readonly DiagnosticEntry[]): string {
74
+ if (entries.length === 0) return "No generation attempts recorded yet."
75
+ return entries.map(formatDiagnostic).join("\n")
76
+ }
package/src/options.ts CHANGED
@@ -5,7 +5,6 @@ export type GhostOptions = {
5
5
  enabled?: boolean
6
6
  model?: string
7
7
  acceptKeys?: string[]
8
- backOnEmptyLeft?: boolean
9
8
  maxChars?: number
10
9
  idleDelayMs?: number
11
10
  recentMessages?: number
@@ -16,7 +15,6 @@ export type ResolvedOptions = {
16
15
  enabled: boolean
17
16
  model: string | undefined
18
17
  acceptKeys: string[]
19
- backOnEmptyLeft: boolean
20
18
  maxChars: number
21
19
  idleDelayMs: number
22
20
  recentMessages: number
@@ -48,13 +46,12 @@ export const DEFAULT_SYSTEM = [
48
46
  const DEFAULTS = {
49
47
  enabled: true,
50
48
  acceptKeys: ["tab", "right"],
51
- backOnEmptyLeft: true,
52
49
  maxChars: 120,
53
50
  idleDelayMs: 500,
54
51
  recentMessages: 10,
55
52
  }
56
53
 
57
- const REMOVED_OPTION_KEYS = ["endpoint", "apiKey", "internalSessionMarkerDir", "argHints"] as const
54
+ const REMOVED_OPTION_KEYS = ["endpoint", "apiKey", "internalSessionMarkerDir", "argHints", "backOnEmptyLeft"] as const
58
55
 
59
56
  export function removedOptionKeys(raw: Readonly<Record<string, unknown>> | undefined): string[] {
60
57
  if (!raw) return []
@@ -73,7 +70,6 @@ export function resolveOptions(options: Readonly<Record<string, unknown>> | unde
73
70
  enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULTS.enabled,
74
71
  model: typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined,
75
72
  acceptKeys: acceptKeys.length > 0 ? acceptKeys : [...DEFAULTS.acceptKeys],
76
- backOnEmptyLeft: typeof input.backOnEmptyLeft === "boolean" ? input.backOnEmptyLeft : DEFAULTS.backOnEmptyLeft,
77
73
  maxChars: typeof maxChars === "number" && maxChars > 0 ? maxChars : DEFAULTS.maxChars,
78
74
  idleDelayMs:
79
75
  typeof idleDelayMs === "number" && idleDelayMs >= 0 ? idleDelayMs : DEFAULTS.idleDelayMs,
@@ -85,9 +81,20 @@ export function resolveOptions(options: Readonly<Record<string, unknown>> | unde
85
81
  }
86
82
  }
87
83
 
88
- export function parseSuggestCommand(input: string | undefined): { type: "toggle" } | { type: "model"; model: string | undefined } | { type: "invalid" } {
84
+ export type SuggestCommand =
85
+ | { type: "toggle" }
86
+ | { type: "model"; model: string | undefined }
87
+ | { type: "debug"; value: boolean | undefined }
88
+ | { type: "log" }
89
+ | { type: "invalid" }
90
+
91
+ export function parseSuggestCommand(input: string | undefined): SuggestCommand {
89
92
  const raw = input?.trim() ?? ""
90
93
  if (!raw) return { type: "toggle" }
94
+ if (raw === "log") return { type: "log" }
95
+ if (raw === "debug") return { type: "debug", value: undefined }
96
+ const debug = /^debug\s+(on|off)$/.exec(raw)
97
+ if (debug) return { type: "debug", value: debug[1] === "on" }
91
98
  const match = /^model\s+(\S+)$/.exec(raw)
92
99
  if (!match || (match[1] !== "clear" && !parseModel(match[1]))) return { type: "invalid" }
93
100
  return { type: "model", model: match[1] === "clear" ? undefined : match[1] }
package/src/tui.tsx CHANGED
@@ -3,12 +3,13 @@ import { Plugin } from "@opencode/plugin/tui"
3
3
  import { TextareaRenderable, InputRenderable, type BoxRenderable, type Renderable } from "@opentui/core"
4
4
  import { createEffect, createMemo, createRoot, createSignal, on, onCleanup } from "solid-js"
5
5
  import { composerAction, findComposerEditor, isEligibleComposer, isEmptyComposer, keyName, observeComposerInput } from "./composer"
6
+ import { createDiagnostics, errorDetail, formatDiagnostic, formatDiagnostics, modelLabel, type DiagnosticEntry } from "./diagnostics"
6
7
  import { createLifecycle } from "./lifecycle"
7
8
  import { acceptCommand, acceptShortcuts, ghostKeymapLayers } from "./keymap"
8
9
  import { parseSuggestCommand, removedOptionKeys, resolveOptions, suggestionModel } from "./options"
9
10
  import { createInlinePlaceholder, dimPlaceholderColor } from "./placeholder"
10
11
  import { generateSuggestion } from "./stateless"
11
- import { isEcho, normalize, resolveExplicitModel, resolveSmallModel } from "./text"
12
+ import { isEcho, normalize, resolveExplicitModel, resolveSmallModel, type SuggestionModel } from "./text"
12
13
  import { buildTranscript, lastUserText, suggestionPrompt, type TranscriptMessage } from "./transcript"
13
14
 
14
15
  type Suggestion = { sessionID: string; text: string }
@@ -35,13 +36,22 @@ export default Plugin.define({
35
36
  }
36
37
 
37
38
  const [state, updateState] = context.storage.store("state", {
38
- initial: { enabled: opts.enabled, model: undefined as string | undefined },
39
+ initial: { enabled: opts.enabled, model: undefined as string | undefined, debug: false },
39
40
  })
40
41
 
41
42
  const [suggestion, setSuggestion] = createSignal<Suggestion | undefined>()
42
43
  const [composerRevision, setComposerRevision] = createSignal(0)
43
44
  const inline = createInlinePlaceholder()
44
45
  const ghostColor = createMemo(() => dimPlaceholderColor(context.theme.text.muted))
46
+ const diagnostics = createDiagnostics()
47
+
48
+ const note = (entry: Omit<DiagnosticEntry, "at">) => {
49
+ const recorded = diagnostics.record(entry)
50
+ if (state.debug === true && recorded.outcome !== "aborted") {
51
+ context.ui.toast.show({ title: "Ghost", message: formatDiagnostic(recorded) })
52
+ }
53
+ return recorded
54
+ }
45
55
 
46
56
  let disposed = false
47
57
  const composers = new Map<string, { marker: BoxRenderable; normal: () => boolean }>()
@@ -66,6 +76,10 @@ export default Plugin.define({
66
76
 
67
77
  const generate = async (sessionID: string, signal: AbortSignal) => {
68
78
  if (!canGenerate(sessionID)) return
79
+ const started = Date.now()
80
+ const elapsed = () => Date.now() - started
81
+ let model: SuggestionModel | undefined
82
+ let failure: unknown
69
83
  try {
70
84
  await context.data.session.message.sync(sessionID)
71
85
  if (signal.aborted || !canGenerate(sessionID)) return
@@ -75,19 +89,58 @@ export default Plugin.define({
75
89
  const session = context.data.session.get(sessionID)
76
90
  const location = session?.location ?? context.location ?? context.data.location.default()
77
91
  const explicit = suggestionModel(state.model, opts.model)
78
- const model = explicit
79
- ? resolveExplicitModel(context.client, location, signal, explicit)
80
- : resolveSmallModel(context.client, location, signal, session)
81
- const raw = await generateSuggestion(context.client, suggestionPrompt(opts.system, transcript), model, signal, warnGeneration, () => canGenerate(sessionID))
82
- if (signal.aborted || !raw) return
92
+ model = explicit
93
+ ? await resolveExplicitModel(context.client, location, signal, explicit)
94
+ : await resolveSmallModel(context.client, location, signal, session)
95
+ if (signal.aborted || !canGenerate(sessionID)) return
96
+ if (!model) {
97
+ note({ sessionID, outcome: "unavailable", durationMs: elapsed() })
98
+ return
99
+ }
100
+ const raw = await generateSuggestion(
101
+ context.client,
102
+ suggestionPrompt(opts.system, transcript),
103
+ model,
104
+ signal,
105
+ (error) => { failure = error; warnGeneration(error) },
106
+ () => canGenerate(sessionID),
107
+ )
108
+ const label = modelLabel(model)
109
+ if (signal.aborted) {
110
+ note({ sessionID, outcome: "aborted", model: label, durationMs: elapsed() })
111
+ return
112
+ }
113
+ if (failure) {
114
+ note({ sessionID, outcome: "error", model: label, durationMs: elapsed(), detail: errorDetail(failure) })
115
+ return
116
+ }
117
+ if (!raw) {
118
+ note({ sessionID, outcome: "empty", model: label, durationMs: elapsed() })
119
+ return
120
+ }
83
121
  const clean = normalize(raw, opts.maxChars)
84
- if (!clean) return
122
+ if (!clean) {
123
+ note({ sessionID, outcome: "empty", model: label, durationMs: elapsed(), chars: raw.length })
124
+ return
125
+ }
85
126
  const previous = lastUserText(messages)
86
- if (previous && isEcho(clean, previous)) return
127
+ if (previous && isEcho(clean, previous)) {
128
+ note({ sessionID, outcome: "echo", model: label, durationMs: elapsed(), chars: clean.length })
129
+ return
130
+ }
87
131
  if (!canGenerate(sessionID)) return
88
132
  setSuggestion({ sessionID, text: clean })
133
+ note({ sessionID, outcome: "ok", model: label, durationMs: elapsed(), chars: clean.length })
89
134
  } catch (error) {
90
- if (!signal.aborted) warnGeneration(error)
135
+ if (signal.aborted) return
136
+ warnGeneration(error)
137
+ note({
138
+ sessionID,
139
+ outcome: "error",
140
+ ...(model ? { model: modelLabel(model) } : {}),
141
+ durationMs: elapsed(),
142
+ detail: errorDetail(error),
143
+ })
91
144
  }
92
145
  }
93
146
 
@@ -161,18 +214,6 @@ export default Plugin.define({
161
214
  const route = context.ui.router.current()
162
215
  return route.type === "session" ? route.sessionID : undefined
163
216
  }, () => suggestion()?.sessionID, act)),
164
- {
165
- id: "ghost.home",
166
- bind: "left",
167
- enabled: () => opts.backOnEmptyLeft,
168
- run: () => {
169
- const route = context.ui.router.current()
170
- if (route.type !== "session") return false
171
- if (!isComposerEmpty(route.sessionID)) return false
172
- cancelSuggestion()
173
- context.ui.router.navigate({ type: "home" })
174
- },
175
- },
176
217
  ], state.enabled, async (input) => {
177
218
  const command = parseSuggestCommand(input)
178
219
  if (command.type === "model") {
@@ -181,8 +222,23 @@ export default Plugin.define({
181
222
  context.ui.toast.show({ message: `Suggestion model: ${state.model ?? opts.model ?? "OpenCode small default"}` })
182
223
  return
183
224
  }
225
+ if (command.type === "debug") {
226
+ const next = command.value ?? (state.debug !== true)
227
+ await updateState((draft) => { draft.debug = next })
228
+ context.ui.toast.show({ message: `Ghost debug: ${next ? "on" : "off"}` })
229
+ return
230
+ }
231
+ if (command.type === "log") {
232
+ const override = state.model ?? opts.model
233
+ const summary = `suggestions ${state.enabled ? "on" : "off"}, debug ${state.debug === true ? "on" : "off"}, model ${override ?? "OpenCode small default"}`
234
+ void context.ui.dialog.alert({
235
+ title: "Ghost — recent generations",
236
+ message: `${summary}\n\n${formatDiagnostics(diagnostics.list())}`,
237
+ })
238
+ return
239
+ }
184
240
  if (command.type === "invalid") {
185
- context.ui.toast.show({ message: "Use /suggest, /suggest model provider/model, or /suggest model clear", variant: "warning" })
241
+ context.ui.toast.show({ message: "Use /suggest, /suggest model provider/model, /suggest model clear, /suggest debug [on|off], or /suggest log", variant: "warning" })
186
242
  return
187
243
  }
188
244
  cancelSuggestion()