opencode-ghost 1.0.7 → 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,12 @@ 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
+
8
14
  ## [1.0.7] - 2026-10-08
9
15
 
10
16
  ### Fixed
@@ -228,6 +234,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
228
234
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
229
235
  `recentMessages` and `system`.
230
236
 
237
+ [1.1.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.7...v1.1.0
231
238
  [1.0.7]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.6...v1.0.7
232
239
  [1.0.6]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.5...v1.0.6
233
240
  [1.0.5]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.4...v1.0.5
package/README.md CHANGED
@@ -99,6 +99,12 @@ copy, use `/absolute/path/to/config/plugins/opencode-ghost`.
99
99
  - `/suggest model openai/gpt-6-luna-fast` — set a persisted runtime model override.
100
100
  - `/suggest model clear` — clear the override and use the configured `model`,
101
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.
102
108
 
103
109
  Model precedence is the persisted `/suggest model` override, then the `model`
104
110
  option, then the effective title agent's model from OpenCode's public agent API.
@@ -141,6 +147,17 @@ When enabling, a suggestion is generated immediately for the current session.
141
147
  Acceptance, a new turn, session changes, disabling, model changes and unloading
142
148
  discard it.
143
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
+
144
161
  ## Caveats
145
162
 
146
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.7",
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",
@@ -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
@@ -81,9 +81,20 @@ export function resolveOptions(options: Readonly<Record<string, unknown>> | unde
81
81
  }
82
82
  }
83
83
 
84
- 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 {
85
92
  const raw = input?.trim() ?? ""
86
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" }
87
98
  const match = /^model\s+(\S+)$/.exec(raw)
88
99
  if (!match || (match[1] !== "clear" && !parseModel(match[1]))) return { type: "invalid" }
89
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
 
@@ -169,8 +222,23 @@ export default Plugin.define({
169
222
  context.ui.toast.show({ message: `Suggestion model: ${state.model ?? opts.model ?? "OpenCode small default"}` })
170
223
  return
171
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
+ }
172
240
  if (command.type === "invalid") {
173
- 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" })
174
242
  return
175
243
  }
176
244
  cancelSuggestion()