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 +7 -0
- package/README.md +17 -0
- package/package.json +1 -1
- package/src/diagnostics.ts +76 -0
- package/src/options.ts +12 -1
- package/src/tui.tsx +79 -11
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
|
@@ -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
|
|
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
|
-
|
|
79
|
-
? resolveExplicitModel(context.client, location, signal, explicit)
|
|
80
|
-
: resolveSmallModel(context.client, location, signal, session)
|
|
81
|
-
|
|
82
|
-
if (
|
|
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)
|
|
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))
|
|
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 (
|
|
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,
|
|
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()
|