opencode-ghost 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 opencode-ghost contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # opencode-ghost
2
+
3
+ [![CI](https://github.com/ozandogrultan/opencode-ghost/actions/workflows/ci.yml/badge.svg)](https://github.com/ozandogrultan/opencode-ghost/actions/workflows/ci.yml)
4
+
5
+ **Next-prompt suggestions** for the [opencode](https://opencode.ai) TUI, in the
6
+ style of Claude Code: after each turn a small model reads the recent
7
+ conversation and writes the message you would most likely send next, shown as
8
+ dimmed ghost text you accept with <kbd>Tab</kbd> (or <kbd>→</kbd>).
9
+
10
+ > Why "ghost": it writes the line you were about to type, then waits for you to
11
+ > take it.
12
+
13
+ ## What it does
14
+
15
+ - **Suggests your next message.** On `session.idle` it debounces, builds a short
16
+ transcript of the recent turns, and asks a model (your `small_model` by
17
+ default) for one line that sounds like you.
18
+ - **Ghost text, `Tab` to accept.** The suggestion renders under the prompt;
19
+ `Tab` or `→` drops it into the input so you can edit and send.
20
+ - **`/suggest` toggles it.** State is stored in the plugin KV.
21
+
22
+ ## Requirements
23
+
24
+ - [opencode](https://opencode.ai) (TUI plugins; tested on 1.18.x).
25
+ - A cheap/fast model for `small_model` — or set `model` explicitly.
26
+
27
+ ## Install
28
+
29
+ ### From this repo (recommended)
30
+
31
+ ```sh
32
+ git clone https://github.com/ozandogrultan/opencode-ghost.git
33
+ cd opencode-ghost
34
+ ./install.sh
35
+ ```
36
+
37
+ Then **restart opencode** — plugins are loaded at startup only. `install.sh`
38
+ copies the plugin into `<config>/plugins/opencode-ghost/` and registers it in
39
+ `<config>/tui.json` (backing the file up first).
40
+
41
+ ### Manually
42
+
43
+ Add the checkout to your `tui.json` (global at `~/.config/opencode/tui.json`, or
44
+ a project's `.opencode/`). Plugins are loaded as source:
45
+
46
+ ```jsonc
47
+ {
48
+ "$schema": "https://opencode.ai/tui.json",
49
+ "plugin": ["/absolute/path/to/opencode-ghost/src/tui.tsx"],
50
+ }
51
+ ```
52
+
53
+ ### From npm
54
+
55
+ ```sh
56
+ opencode plugin opencode-ghost # current project
57
+ opencode plugin -g opencode-ghost # global
58
+ ```
59
+
60
+ Restart opencode after changing `tui.json`.
61
+
62
+ ## Options
63
+
64
+ Pass an options object as the second element of the plugin tuple:
65
+
66
+ ```jsonc
67
+ {
68
+ "plugin": [
69
+ ["opencode-ghost", {
70
+ "enabled": true,
71
+ "model": "anthropic/claude-haiku-4-5",
72
+ "acceptKeys": ["tab", "right"],
73
+ "maxChars": 120,
74
+ "idleDelayMs": 500,
75
+ "recentMessages": 10
76
+ }]
77
+ ]
78
+ }
79
+ ```
80
+
81
+ | Option | Type | Default | Notes |
82
+ | ---------------- | ---------- | --------------------- | --------------------------------------------------------------------- |
83
+ | `enabled` | `boolean` | `true` | Initial state; `/suggest` toggles it at runtime (persisted). |
84
+ | `model` | `string` | session `small_model` | `provider/model`. Falls back to `small_model`, then the session model. |
85
+ | `acceptKeys` | `string[]` | `["tab", "right"]` | Key names as reported by the terminal (`tab`, `right`, ...). |
86
+ | `maxChars` | `number` | `120` | Maximum suggestion length. |
87
+ | `idleDelayMs` | `number` | `500` | Debounce after a turn finishes before generating. |
88
+ | `recentMessages` | `number` | `10` | How many recent messages feed the suggestion prompt. |
89
+ | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
90
+
91
+ ## Commands
92
+
93
+ - `/suggest` — toggle suggestions on/off (state is stored in the plugin KV).
94
+
95
+ When enabling, a suggestion is generated immediately for the current session.
96
+
97
+ ## How it works
98
+
99
+ - Listens for `session.idle`, debounces, and builds a short transcript from the
100
+ session's recent messages.
101
+ - Asks a model (your `small_model` by default) for one short next user message,
102
+ in a throwaway hidden session with tools disabled; the session is deleted
103
+ immediately afterwards.
104
+ - Renders the result through the `session_prompt` host slot into the prompt's
105
+ `hint` row, and accepts it into the input via the prompt ref.
106
+
107
+ ## Caveats
108
+
109
+ - **Owns the `session_prompt` slot.** opencode only exposes the prompt ref
110
+ through that slot, so this plugin replaces the default prompt component. It
111
+ forwards `on_submit`, `ref`, `right`, `visible`, and `disabled`, and cannot be
112
+ combined with another plugin that also renders `session_prompt`.
113
+ - **`hint` row.** While a suggestion is showing it occupies the prompt's hint
114
+ row, so the working-directory label is hidden until you accept or type.
115
+ - **Cost.** One small-model call per turn while enabled. Point `model` at a
116
+ cheap or free model.
117
+ - **`acceptKeys`.** `tab` is also opencode's agent-cycle key; this plugin only
118
+ captures it while a suggestion is visible and the input is empty.
119
+ - Suggestions fail silently (no ghost text) if the model is unavailable, the
120
+ provider is out of balance, or the reply is `NONE`.
121
+
122
+ ## Development
123
+
124
+ ```sh
125
+ bun install
126
+ bun run typecheck
127
+ bun test
128
+ ```
129
+
130
+ No build step: like other opencode TUI plugins, the package ships TSX source and
131
+ opencode's runtime transpiles it.
132
+
133
+ ## License
134
+
135
+ MIT
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "opencode-ghost",
3
+ "version": "0.1.0",
4
+ "description": "Claude Code-style next-prompt suggestions in the opencode TUI, shown as dimmed ghost text and accepted with Tab. An opencode plugin.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Ozan Dogrultan",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ozandogrultan/opencode-ghost.git"
11
+ },
12
+ "homepage": "https://github.com/ozandogrultan/opencode-ghost#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/ozandogrultan/opencode-ghost/issues"
15
+ },
16
+ "exports": {
17
+ "./tui": "./src/tui.tsx"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ },
28
+ "engines": {
29
+ "opencode": ">=1.18.0"
30
+ },
31
+ "keywords": [
32
+ "opencode",
33
+ "opencode-plugin",
34
+ "tui",
35
+ "ghost-text",
36
+ "autosuggest",
37
+ "prompt",
38
+ "suggestion"
39
+ ],
40
+ "dependencies": {
41
+ "solid-js": "^1.9.12"
42
+ },
43
+ "peerDependencies": {
44
+ "@opencode-ai/plugin": "*",
45
+ "solid-js": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@opencode-ai/plugin": "1.18.25",
49
+ "@opentui/core": "0.4.5",
50
+ "@opentui/keymap": "0.4.5",
51
+ "@opentui/solid": "0.4.5",
52
+ "@types/node": "24.12.2",
53
+ "typescript": "5.8.2"
54
+ }
55
+ }
package/src/options.ts ADDED
@@ -0,0 +1,60 @@
1
+ export type PromptSuggestOptions = {
2
+ enabled?: boolean
3
+ model?: string
4
+ acceptKeys?: string[]
5
+ maxChars?: number
6
+ idleDelayMs?: number
7
+ recentMessages?: number
8
+ system?: string
9
+ }
10
+
11
+ export type ResolvedOptions = {
12
+ enabled: boolean
13
+ model: string | undefined
14
+ acceptKeys: string[]
15
+ maxChars: number
16
+ idleDelayMs: number
17
+ recentMessages: number
18
+ system: string
19
+ }
20
+
21
+ export const DEFAULT_SYSTEM = [
22
+ "You write the next message that the USER of a coding agent would send.",
23
+ "It must sound like the user, not the assistant: short and direct.",
24
+ "Never restate or summarize the assistant's reply.",
25
+ 'If the assistant proposed a next step, a brief affirmation such as "Yes." or "Go ahead." is best.',
26
+ "If the assistant asked a question, answer it briefly.",
27
+ "Output one line, plain text only: no markdown, no quotes, no backticks, no preamble.",
28
+ "If no reply makes sense, output exactly: NONE",
29
+ ].join("\n")
30
+
31
+ const DEFAULTS = {
32
+ enabled: true,
33
+ acceptKeys: ["tab", "right"],
34
+ maxChars: 120,
35
+ idleDelayMs: 500,
36
+ recentMessages: 10,
37
+ }
38
+
39
+ export function resolveOptions(options: PromptSuggestOptions | undefined): ResolvedOptions {
40
+ const input = options ?? {}
41
+ const maxChars = input.maxChars
42
+ const idleDelayMs = input.idleDelayMs
43
+ const recentMessages = input.recentMessages
44
+ return {
45
+ enabled: input.enabled ?? DEFAULTS.enabled,
46
+ model: typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined,
47
+ acceptKeys:
48
+ Array.isArray(input.acceptKeys) && input.acceptKeys.length > 0
49
+ ? input.acceptKeys
50
+ : [...DEFAULTS.acceptKeys],
51
+ maxChars: typeof maxChars === "number" && maxChars > 0 ? maxChars : DEFAULTS.maxChars,
52
+ idleDelayMs:
53
+ typeof idleDelayMs === "number" && idleDelayMs >= 0 ? idleDelayMs : DEFAULTS.idleDelayMs,
54
+ recentMessages:
55
+ typeof recentMessages === "number" && recentMessages > 0
56
+ ? recentMessages
57
+ : DEFAULTS.recentMessages,
58
+ system: typeof input.system === "string" && input.system.trim() ? input.system : DEFAULT_SYSTEM,
59
+ }
60
+ }
package/src/text.ts ADDED
@@ -0,0 +1,34 @@
1
+ export function parseModel(
2
+ spec: string | undefined,
3
+ ): { providerID: string; modelID: string } | undefined {
4
+ if (!spec) return undefined
5
+ const index = spec.indexOf("/")
6
+ if (index <= 0 || index === spec.length - 1) return undefined
7
+ return { providerID: spec.slice(0, index), modelID: spec.slice(index + 1) }
8
+ }
9
+
10
+ export function collapse(text: string): string {
11
+ return text.replace(/\s+/g, " ").trim()
12
+ }
13
+
14
+ export function clip(text: string, max: number): string {
15
+ const value = collapse(text)
16
+ if (value.length <= max) return value
17
+ return value.slice(0, max - 1).trimEnd() + "…"
18
+ }
19
+
20
+ export function normalize(raw: string, maxChars: number): string | undefined {
21
+ const first = raw
22
+ .split(/\r?\n/)
23
+ .map((line) => line.trim())
24
+ .find((line) => line.length > 0)
25
+ if (!first) return undefined
26
+ const cleaned = first
27
+ .replace(/^[`"'*\-_#\s]+/, "")
28
+ .replace(/[`"'*_#\s]+$/, "")
29
+ .replace(/[*`_]+/g, "")
30
+ .trim()
31
+ if (!cleaned || /^none\.?$/i.test(cleaned)) return undefined
32
+ if (cleaned.length <= maxChars) return cleaned
33
+ return cleaned.slice(0, maxChars - 1).trimEnd() + "…"
34
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,274 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPromptRef } from "@opencode-ai/plugin/tui"
3
+ import { createSignal } from "solid-js"
4
+ import { resolveOptions, type PromptSuggestOptions } from "./options"
5
+ import { clip, normalize, parseModel } from "./text"
6
+
7
+ const HIDDEN_TITLE = "ghost-hidden"
8
+ const MAX_MESSAGE_CHARS = 800
9
+ const MAX_TRANSCRIPT_CHARS = 6000
10
+ const ENABLED_KEY = "ghost.enabled"
11
+ const MODEL_KEY = "ghost.model"
12
+
13
+ const HIDDEN_TOOLS = {
14
+ bash: false,
15
+ edit: false,
16
+ write: false,
17
+ read: false,
18
+ grep: false,
19
+ glob: false,
20
+ list: false,
21
+ task: false,
22
+ todowrite: false,
23
+ todoread: false,
24
+ webfetch: false,
25
+ websearch: false,
26
+ patch: false,
27
+ multiedit: false,
28
+ question: false,
29
+ skill: false,
30
+ lsp: false,
31
+ }
32
+
33
+ const tui: TuiPlugin = async (api, rawOptions) => {
34
+ const opts = resolveOptions(rawOptions as PromptSuggestOptions | undefined)
35
+ const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
36
+ let promptRef: TuiPromptRef | undefined
37
+ let timer: ReturnType<typeof setTimeout> | undefined
38
+ let generating = false
39
+ let acceptLayer: (() => void) | undefined
40
+
41
+ const currentSessionID = () => {
42
+ const route = api.route.current
43
+ if (route.name !== "session") return undefined
44
+ const id = route.params?.sessionID
45
+ return typeof id === "string" ? id : undefined
46
+ }
47
+
48
+ const currentInput = () => promptRef?.current.input ?? ""
49
+
50
+ const isEnabled = () => {
51
+ try {
52
+ return api.kv.get<boolean>(ENABLED_KEY, opts.enabled) !== false
53
+ } catch {
54
+ return opts.enabled
55
+ }
56
+ }
57
+
58
+ const setEnabled = (value: boolean) => {
59
+ try {
60
+ api.kv.set(ENABLED_KEY, value)
61
+ } catch {
62
+ // ignore
63
+ }
64
+ }
65
+
66
+ const resolveModel = () => {
67
+ try {
68
+ const override = api.kv.get<string>(MODEL_KEY)
69
+ if (typeof override === "string" && override.trim()) return parseModel(override.trim())
70
+ } catch {
71
+ // ignore
72
+ }
73
+ return (
74
+ parseModel(opts.model) ??
75
+ parseModel(api.state.config.small_model) ??
76
+ parseModel(api.state.config.model)
77
+ )
78
+ }
79
+
80
+ const buildTranscript = (sessionID: string) => {
81
+ const messages = api.state.session.messages(sessionID)
82
+ const lines: string[] = []
83
+ for (const message of messages.slice(-opts.recentMessages)) {
84
+ if (message.role !== "user" && message.role !== "assistant") continue
85
+ const text = api.state
86
+ .part(message.id)
87
+ .filter((part) => part.type === "text")
88
+ .map((part) => (part.type === "text" ? part.text : ""))
89
+ .join(" ")
90
+ if (!text.trim()) continue
91
+ lines.push(
92
+ `${message.role === "user" ? "User" : "Assistant"}: ${clip(text, MAX_MESSAGE_CHARS)}`,
93
+ )
94
+ }
95
+ if (lines.length === 0) return ""
96
+ return `${lines.join("\n").slice(-MAX_TRANSCRIPT_CHARS)}\n\nTask: write the user's next message.`
97
+ }
98
+
99
+ const generate = async (sessionID: string): Promise<string | undefined> => {
100
+ const transcript = buildTranscript(sessionID)
101
+ if (!transcript) return undefined
102
+
103
+ const created = await api.client.session.create({ title: HIDDEN_TITLE })
104
+ const tempID = created.data?.id
105
+ if (!tempID) return undefined
106
+
107
+ try {
108
+ const result = await api.client.session.prompt({
109
+ sessionID: tempID,
110
+ system: opts.system,
111
+ tools: { ...HIDDEN_TOOLS },
112
+ model: resolveModel(),
113
+ parts: [{ type: "text", text: transcript }],
114
+ })
115
+ const parts = result.data?.parts ?? []
116
+ const raw = parts
117
+ .filter((part) => part.type === "text")
118
+ .map((part) => (part.type === "text" ? part.text : ""))
119
+ .join("\n")
120
+ return normalize(raw, opts.maxChars)
121
+ } finally {
122
+ await api.client.session.delete({ sessionID: tempID }).catch(() => undefined)
123
+ }
124
+ }
125
+
126
+ const enableAccept = () => {
127
+ if (acceptLayer) return
128
+ acceptLayer = api.keymap.registerLayer({
129
+ mode: "base",
130
+ priority: 100,
131
+ bindings: opts.acceptKeys.map((key) => ({
132
+ key,
133
+ desc: "Accept suggested prompt",
134
+ preventDefault: true,
135
+ cmd: () => {
136
+ accept()
137
+ return true
138
+ },
139
+ })),
140
+ })
141
+ }
142
+
143
+ const disableAccept = () => {
144
+ acceptLayer?.()
145
+ acceptLayer = undefined
146
+ }
147
+
148
+ const showGhost = (value: { sessionID: string; text: string } | undefined) => {
149
+ setGhost(value)
150
+ if (value) enableAccept()
151
+ else disableAccept()
152
+ }
153
+
154
+ const run = async (sessionID: string) => {
155
+ if (generating) return
156
+ if (!isEnabled()) return
157
+ if (currentSessionID() !== sessionID) return
158
+ if (currentInput().trim()) return
159
+ generating = true
160
+ try {
161
+ const text = await generate(sessionID)
162
+ if (currentSessionID() !== sessionID) return
163
+ if (currentInput().trim()) return
164
+ if (!isEnabled()) return
165
+ showGhost(text ? { sessionID, text } : undefined)
166
+ } catch {
167
+ // ignore background failures
168
+ } finally {
169
+ generating = false
170
+ }
171
+ }
172
+
173
+ const schedule = (sessionID: string) => {
174
+ if (!isEnabled()) return
175
+ if (timer) clearTimeout(timer)
176
+ timer = setTimeout(() => void run(sessionID), opts.idleDelayMs)
177
+ }
178
+
179
+ const accept = () => {
180
+ const current = ghost()
181
+ if (!current) return false
182
+ if (currentSessionID() !== current.sessionID) {
183
+ showGhost(undefined)
184
+ return false
185
+ }
186
+ if (!promptRef) return false
187
+ if (currentInput().trim()) return false
188
+ promptRef.set({ input: current.text, parts: [] })
189
+ promptRef.focus()
190
+ showGhost(undefined)
191
+ return true
192
+ }
193
+
194
+ const offIdle = api.event.on("session.idle", (event) => {
195
+ const id = event.properties?.sessionID
196
+ if (!id) return
197
+ schedule(id)
198
+ })
199
+
200
+ api.slots.register({
201
+ order: 60,
202
+ slots: {
203
+ session_prompt(_ctx, props) {
204
+ const suggestion = () => {
205
+ const current = ghost()
206
+ return current && current.sessionID === props.session_id ? current.text : ""
207
+ }
208
+ return (
209
+ <api.ui.Prompt
210
+ sessionID={props.session_id}
211
+ visible={props.visible}
212
+ disabled={props.disabled}
213
+ showPlaceholder={false}
214
+ hint={
215
+ suggestion() ? (
216
+ <box marginLeft={1}>
217
+ <text fg={api.theme.current.textMuted}>{suggestion()}</text>
218
+ </box>
219
+ ) : undefined
220
+ }
221
+ right={<api.ui.Slot name="session_prompt_right" session_id={props.session_id} />}
222
+ onSubmit={() => {
223
+ showGhost(undefined)
224
+ props.on_submit?.()
225
+ }}
226
+ ref={(ref) => {
227
+ promptRef = ref
228
+ props.ref?.(ref)
229
+ }}
230
+ />
231
+ )
232
+ },
233
+ },
234
+ })
235
+
236
+ api.command?.register(() => [
237
+ {
238
+ title: `Prompt suggestions: ${isEnabled() ? "on" : "off"}`,
239
+ value: "ghost",
240
+ description: "Toggle the suggested next prompt",
241
+ category: "Prompt",
242
+ slash: { name: "suggest" },
243
+ onSelect: async () => {
244
+ const next = !isEnabled()
245
+ setEnabled(next)
246
+ if (!next) {
247
+ showGhost(undefined)
248
+ api.ui.toast({ title: "Prompt suggestions", message: "Off" })
249
+ return
250
+ }
251
+ api.ui.toast({ title: "Prompt suggestions", message: "On" })
252
+ const sessionID = currentSessionID()
253
+ if (!sessionID) return
254
+ try {
255
+ const text = await generate(sessionID)
256
+ showGhost(text ? { sessionID, text } : undefined)
257
+ } catch {
258
+ // ignore
259
+ }
260
+ },
261
+ },
262
+ ])
263
+
264
+ api.lifecycle.onDispose(() => {
265
+ if (timer) clearTimeout(timer)
266
+ offIdle()
267
+ disableAccept()
268
+ })
269
+ }
270
+
271
+ export default {
272
+ id: "ghost",
273
+ tui,
274
+ }