opencode-ghost 1.0.1 → 1.0.3

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,28 @@ 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.0.3] - 2026-10-08
9
+
10
+ ### Fixed
11
+
12
+ - **packaging:** Declare required OpenTUI runtime peers
13
+
14
+ ## [1.0.2] - 2026-10-08
15
+
16
+ ### Changed
17
+
18
+ - **composer:** Remove redundant editor existence guard
19
+
20
+ ### Fixed
21
+
22
+ - **lifecycle:** Bound recent completion deduplication history
23
+ - **options:** Validate booleans and normalize accept bindings
24
+ - **deps:** Align host and renderer compatibility requirements
25
+ - **install:** Tolerate unrelated legacy configs and spaced paths
26
+ - **composer:** Preserve navigation and rebound accept shortcuts
27
+ - **model:** Anchor small-model provider on the session agent model
28
+ - **model:** Match host title-model variant and default-provider fallback
29
+
8
30
  ## [1.0.1] - 2026-10-08
9
31
 
10
32
  ### Fixed
@@ -13,6 +35,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13
35
 
14
36
  ## [1.0.0] - 2026-10-08
15
37
 
38
+ ### Added
39
+
40
+ - `/suggest model provider/model` and `/suggest model clear` for persistent model overrides.
41
+ - Effective title-agent model resolution and same-provider small-model selection.
42
+
43
+ ### Changed
44
+
45
+ - Register TUI plugins by directory in `cli.json` instead of `tui.json`.
46
+
47
+ ### Removed
48
+
49
+ - V1 inline typing ghosts and slash-command argument hints.
50
+ - The `endpoint`, `apiKey`, `internalSessionMarkerDir`, and `argHints` options.
51
+
16
52
  ### Breaking changes
17
53
 
18
54
  - Migrate inline prompt ghosts to opencode v2 _(breaking)_
@@ -167,6 +203,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
167
203
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
168
204
  `recentMessages` and `system`.
169
205
 
206
+ [1.0.3]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.2...v1.0.3
207
+ [1.0.2]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.1...v1.0.2
170
208
  [1.0.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v1.0.0...v1.0.1
171
209
  [1.0.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.6.0...v1.0.0
172
210
  [0.6.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.1...v0.6.0
package/README.md CHANGED
@@ -20,8 +20,9 @@ a dimmed inline placeholder in the empty composer with
20
20
  dimmed placeholder only while the normal composer is empty and focused in an
21
21
  idle session. `Tab` or `→` inserts it as editable text; Enter does not accept
22
22
  or submit the ghost. Acceptance never replaces a draft or touches the clipboard.
23
- - **Clears as you type.** Typing anything other than an accept key dismisses
24
- the suggestion.
23
+ - **Clears as you type.** Text-editing keys and pasting dismiss the suggestion;
24
+ navigation keys leave it intact. Left on an empty eligible composer returns
25
+ home, even when suggestions are off (`backOnEmptyLeft`).
25
26
  - **`/suggest` toggles it.** State is stored durably via the plugin's storage.
26
27
 
27
28
  ## Requirements
@@ -106,11 +107,17 @@ and directory definitions such as `agents/title.md` and `agent/title.md`, includ
106
107
  agent deletion/recreation. Explicitly configured variants are preserved.
107
108
  Without a configured title model, or when the title agent is disabled, Ghost
108
109
  mirrors OpenCode 2.0.25's internal small-model policy until a public API exists:
109
- within the session model's provider (or the location's default provider), choose
110
+ within the session model's provider (or the session agent model's provider,
111
+ then the location's default provider, then the first enabled text model's
112
+ provider when no default exists), choose
110
113
  the first enabled, active text-input/text-output catalog model in family order
111
114
  `gpt-luna`, `gemini-flash-lite`, `gemini-flash`, `claude-haiku`.
115
+ Like OpenCode's title generation, an override, title or small model without a
116
+ configured variant uses its first supported `none`, `minimal` or `low` variant.
112
117
  Catalog IDs are used directly. No matching small model means a warning and no
113
- suggestion; Ghost never falls back to the main model or another provider.
118
+ suggestion. Ghost never uses the main model as a generation fallback or retries
119
+ with another provider. An explicit override or configured title model may use
120
+ a different provider from the session's primary model.
114
121
 
115
122
  When enabling, a suggestion is generated immediately for the current session.
116
123
 
@@ -167,7 +174,7 @@ When enabling, a suggestion is generated immediately for the current session.
167
174
  ```sh
168
175
  bun install
169
176
  bun run typecheck
170
- bun test
177
+ bun run test
171
178
  ```
172
179
 
173
180
  No build step: like other opencode TUI plugins, the package ships TSX source and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "packageManager": "bun@1.4.2",
38
38
  "engines": {
39
- "opencode": ">=2.0.0"
39
+ "opencode": ">=2.0.25"
40
40
  },
41
41
  "scripts": {
42
42
  "prepare": "husky",
@@ -47,21 +47,22 @@
47
47
  "lint:sh": "for script in install.sh tests/*.sh scripts/*.sh; do bash -n \"$script\" || exit; done",
48
48
  "prepublishOnly": "bun run test && bun run typecheck"
49
49
  },
50
- "dependencies": {
51
- "solid-js": "^1.9.12"
52
- },
53
50
  "peerDependencies": {
54
51
  "@opencode/plugin": "*",
52
+ "@opentui/core": ">=0.5.16",
53
+ "@opentui/solid": ">=0.5.16",
55
54
  "solid-js": "*"
56
55
  },
57
56
  "devDependencies": {
58
57
  "@commitlint/cli": "^21.2.2",
59
58
  "@commitlint/config-conventional": "^21.2.2",
60
59
  "@opencode/plugin": "2.0.25",
61
- "@opentui/core": "^0.5.12",
62
- "@opentui/solid": "^0.5.12",
60
+ "@opentui/core": "^0.5.16",
61
+ "@opentui/solid": "^0.5.16",
62
+ "@types/bun": "^1.4.2",
63
63
  "@types/node": "24.12.2",
64
64
  "husky": "^9.1.7",
65
+ "solid-js": "^1.9.12",
65
66
  "typescript": "7.0.2"
66
67
  }
67
68
  }
package/src/composer.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  import type { KeyEvent, KeyHandler, TextareaRenderable } from "@opentui/core"
2
2
 
3
+ export function editsComposer(event: { name: string; sequence: string; ctrl: boolean; meta: boolean; option?: boolean; super?: boolean; hyper?: boolean }): boolean {
4
+ if (event.ctrl && !event.meta && !event.option && !event.super && !event.hyper) return ["v", "h", "d", "k", "u", "w", "j", "m"].includes(event.name)
5
+ if (event.ctrl || event.meta || event.option || event.super || event.hyper) return false
6
+ if (["return", "enter", "linefeed", "backspace", "delete", "space"].includes(event.name)) return true
7
+ return [...event.sequence].length === 1 && !/[\u0000-\u001f\u007f]/.test(event.sequence)
8
+ }
9
+
3
10
  export function observeComposerInput(input: KeyHandler, preserve: (event: KeyEvent) => boolean, cancel: () => void): () => void {
4
11
  const onKeypress = (event: KeyEvent) => {
5
- if (!preserve(event)) cancel()
12
+ if (editsComposer(event) && !preserve(event)) cancel()
6
13
  }
7
14
  input.prependListener("keypress", onKeypress)
8
15
  input.prependListener("paste", cancel)
@@ -31,7 +38,7 @@ export function isEmptyComposer(editor: TextareaRenderable | undefined, focused:
31
38
  !(node.id.startsWith("slot-layout-") && node.getChildren().length === 0),
32
39
  )
33
40
  if (siblings.length !== 2 || siblings[0] !== editor) return false
34
- return !!editor && normal && mode === "base" && !selected && editor === focused && editor.focused && !editor.isDestroyed && editor.plainText === "" && !editor.hasSelection() && !editor.traits.capture?.includes("navigate") && editor.extmarks.getAll().length === 0
41
+ return normal && mode === "base" && !selected && editor === focused && editor.focused && !editor.isDestroyed && editor.plainText === "" && !editor.hasSelection() && !editor.traits.capture?.includes("navigate") && editor.extmarks.getAll().length === 0
35
42
  }
36
43
 
37
44
  export function findComposerEditor<T extends EditorNode>(marker: EditorNode | undefined, isEditor: (node: EditorNode) => node is T, isComposer: (editor: T) => boolean): T | undefined {
package/src/keymap.ts CHANGED
@@ -1,4 +1,26 @@
1
1
  import type { KeymapCommand, KeymapLayer } from "@opencode/plugin/tui/context"
2
+ import { canonicalKey } from "./composer"
3
+
4
+ export const acceptCommandId = (key: string) => `ghost.accept.${key}`
5
+
6
+ export function acceptCommand(key: string, session: () => string | undefined, suggestionSession: () => string | undefined, act: (sessionID: string) => boolean): KeymapCommand {
7
+ return {
8
+ id: acceptCommandId(key),
9
+ bind: key,
10
+ enabled: () => Boolean(suggestionSession()),
11
+ run: () => {
12
+ const sessionID = session()
13
+ if (!sessionID || suggestionSession() !== sessionID || !act(sessionID)) return false
14
+ },
15
+ }
16
+ }
17
+
18
+ export function acceptShortcuts(acceptKeys: readonly string[], shortcuts: (id: string) => readonly string[]): Set<string> {
19
+ return new Set(acceptKeys.flatMap((key) => {
20
+ const bound = shortcuts(acceptCommandId(key))
21
+ return bound.map(canonicalKey)
22
+ }))
23
+ }
2
24
 
3
25
  export function ghostKeymapLayers(
4
26
  composerCommands: KeymapCommand[],
package/src/lifecycle.ts CHANGED
@@ -28,6 +28,7 @@ export function createLifecycle(delay: number, allowed: (sessionID: string) => b
28
28
  succeeded(id: string, sessionID: string) {
29
29
  if (disposed || !allowed(sessionID) || completed.has(id)) return
30
30
  completed.add(id)
31
+ if (completed.size > COMPLETION_HISTORY_LIMIT) completed.delete(completed.values().next().value!)
31
32
  cancel()
32
33
  timer = setTimeout(() => { timer = undefined; void run(sessionID) }, delay)
33
34
  },
@@ -39,3 +40,4 @@ export function createLifecycle(delay: number, allowed: (sessionID: string) => b
39
40
  dispose() { disposed = true; cancel() },
40
41
  }
41
42
  }
43
+ export const COMPLETION_HISTORY_LIMIT = 1024
package/src/options.ts CHANGED
@@ -66,14 +66,14 @@ export function resolveOptions(options: Readonly<Record<string, unknown>> | unde
66
66
  const maxChars = input.maxChars
67
67
  const idleDelayMs = input.idleDelayMs
68
68
  const recentMessages = input.recentMessages
69
+ const acceptKeys = Array.isArray(input.acceptKeys)
70
+ ? [...new Set(input.acceptKeys.filter((key) => typeof key === "string" && key.trim() && key.trim().split("+").every((part) => part.trim())).map(canonicalKey))]
71
+ : []
69
72
  return {
70
- enabled: input.enabled ?? DEFAULTS.enabled,
73
+ enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULTS.enabled,
71
74
  model: typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined,
72
- acceptKeys:
73
- Array.isArray(input.acceptKeys) && input.acceptKeys.length > 0
74
- ? input.acceptKeys.filter((key) => typeof key === "string" && key.trim()).map(canonicalKey)
75
- : [...DEFAULTS.acceptKeys],
76
- backOnEmptyLeft: input.backOnEmptyLeft ?? DEFAULTS.backOnEmptyLeft,
75
+ acceptKeys: acceptKeys.length > 0 ? acceptKeys : [...DEFAULTS.acceptKeys],
76
+ backOnEmptyLeft: typeof input.backOnEmptyLeft === "boolean" ? input.backOnEmptyLeft : DEFAULTS.backOnEmptyLeft,
77
77
  maxChars: typeof maxChars === "number" && maxChars > 0 ? maxChars : DEFAULTS.maxChars,
78
78
  idleDelayMs:
79
79
  typeof idleDelayMs === "number" && idleDelayMs >= 0 ? idleDelayMs : DEFAULTS.idleDelayMs,
package/src/stateless.ts CHANGED
@@ -12,14 +12,16 @@ export type GenerateClient = {
12
12
  export async function generateSuggestion(
13
13
  client: GenerateClient,
14
14
  prompt: string,
15
- model: SuggestionModel | undefined,
15
+ model: SuggestionModel | undefined | Promise<SuggestionModel | undefined>,
16
16
  signal: AbortSignal,
17
17
  onError?: (error: unknown) => void,
18
+ allowed: () => boolean = () => true,
18
19
  ): Promise<string | undefined> {
19
- if (signal.aborted || !model) return undefined
20
20
  try {
21
+ const resolved = await model
22
+ if (signal.aborted || !allowed() || !resolved) return undefined
21
23
  const result = await client.generate.text(
22
- { prompt, model: { id: model.modelID, providerID: model.providerID, ...(model.variant ? { variant: model.variant } : {}) } },
24
+ { prompt, model: { id: resolved.modelID, providerID: resolved.providerID, ...(resolved.variant ? { variant: resolved.variant } : {}) } },
23
25
  { signal },
24
26
  )
25
27
  return result.text
package/src/text.ts CHANGED
@@ -24,24 +24,55 @@ export async function resolveSmallModel(
24
24
  client: SmallModelClient,
25
25
  location: LocationRef,
26
26
  signal: AbortSignal,
27
- providerID?: string,
27
+ session?: { model?: { providerID: string }; agent?: string },
28
28
  ): Promise<SuggestionModel | undefined> {
29
29
  if (signal.aborted) return undefined
30
30
  const agents = await client.agent.list({ location }, { signal })
31
31
  if (signal.aborted) return undefined
32
32
  const configured = agents.data.find((agent) => agent.id === "title")?.model
33
- if (configured) return { providerID: configured.providerID, modelID: configured.id, ...(configured.variant ? { variant: configured.variant } : {}) }
34
- if (!providerID) {
33
+ let providerID = session?.model?.providerID ?? (session?.agent ? agents.data.find((agent) => agent.id === session.agent)?.model?.providerID : undefined)
34
+ if (configured?.variant) return { providerID: configured.providerID, modelID: configured.id, variant: configured.variant }
35
+ if (!configured && !providerID) {
35
36
  const primary = await client.model.default({ location }, { signal })
36
37
  if (signal.aborted) return undefined
37
38
  providerID = primary.data?.providerID
38
39
  }
39
- if (!providerID) throw new Error(NO_SMALL_MODEL)
40
40
  const catalog = await client.model.list({ location }, { signal })
41
41
  if (signal.aborted) return undefined
42
+ if (configured) {
43
+ const info = catalog.data.find((model) => model.providerID === configured.providerID && model.id === configured.id)
44
+ return withLowEffortVariant({ providerID: configured.providerID, modelID: configured.id }, info)
45
+ }
46
+ providerID ??= catalog.data.find((model) => model.enabled && isTextModel(model))?.providerID
47
+ if (!providerID) throw new Error(NO_SMALL_MODEL)
42
48
  const model = selectSmallModel(catalog.data, providerID)
43
49
  if (!model) throw new Error(NO_SMALL_MODEL)
44
- return model
50
+ return withLowEffortVariant(model, catalog.data.find((info) => info.providerID === model.providerID && info.id === model.modelID))
51
+ }
52
+
53
+ export async function resolveExplicitModel(
54
+ client: Pick<SmallModelClient, "model">,
55
+ location: LocationRef,
56
+ signal: AbortSignal,
57
+ model: SuggestionModel,
58
+ ): Promise<SuggestionModel | undefined> {
59
+ if (signal.aborted) return undefined
60
+ if (model.variant) return model
61
+ const catalog = await client.model.list({ location }, { signal })
62
+ if (signal.aborted) return undefined
63
+ return withLowEffortVariant(model, catalog.data.find((info) => info.providerID === model.providerID && info.id === model.modelID))
64
+ }
65
+
66
+ const LOW_EFFORT_VARIANTS = ["none", "minimal", "low"]
67
+
68
+ function withLowEffortVariant(model: SuggestionModel, info: ModelInfo | undefined): SuggestionModel {
69
+ const variant = LOW_EFFORT_VARIANTS.find((id) => info?.variants.some((item) => item.id === id))
70
+ return variant ? { ...model, variant } : model
71
+ }
72
+
73
+ function isTextModel(model: ModelInfo) {
74
+ const text = (items: readonly string[]) => items.length === 0 || items.some((item) => item.startsWith("text"))
75
+ return text(model.capabilities.input) && text(model.capabilities.output)
45
76
  }
46
77
 
47
78
  export function parseModel(spec: string | undefined): { providerID: string; modelID: string } | undefined {
package/src/tui.tsx CHANGED
@@ -4,11 +4,11 @@ import { TextareaRenderable, InputRenderable, type BoxRenderable, type Renderabl
4
4
  import { createEffect, createRoot, createSignal, on, onCleanup } from "solid-js"
5
5
  import { composerAction, findComposerEditor, isEmptyComposer, keyName, observeComposerInput } from "./composer"
6
6
  import { createLifecycle } from "./lifecycle"
7
- import { ghostKeymapLayers } from "./keymap"
7
+ import { acceptCommand, acceptShortcuts, ghostKeymapLayers } from "./keymap"
8
8
  import { parseSuggestCommand, removedOptionKeys, resolveOptions, suggestionModel } from "./options"
9
9
  import { createInlinePlaceholder } from "./placeholder"
10
10
  import { generateSuggestion } from "./stateless"
11
- import { isEcho, normalize, resolveSmallModel } from "./text"
11
+ import { isEcho, normalize, resolveExplicitModel, resolveSmallModel } from "./text"
12
12
  import { buildTranscript, lastUserText, suggestionPrompt, type TranscriptMessage } from "./transcript"
13
13
 
14
14
  type Suggestion = { sessionID: string; text: string }
@@ -73,10 +73,11 @@ export default Plugin.define({
73
73
  if (!transcript) return
74
74
  const session = context.data.session.get(sessionID)
75
75
  const location = session?.location ?? context.location ?? context.data.location.default()
76
- const model = suggestionModel(state.model, opts.model) ??
77
- await resolveSmallModel(context.client, location, signal, session?.model?.providerID)
78
- if (signal.aborted || !canGenerate(sessionID) || !model) return
79
- const raw = await generateSuggestion(context.client, suggestionPrompt(opts.system, transcript), model, signal, warnGeneration)
76
+ const explicit = suggestionModel(state.model, opts.model)
77
+ const model = explicit
78
+ ? resolveExplicitModel(context.client, location, signal, explicit)
79
+ : resolveSmallModel(context.client, location, signal, session)
80
+ const raw = await generateSuggestion(context.client, suggestionPrompt(opts.system, transcript), model, signal, warnGeneration, () => canGenerate(sessionID))
80
81
  if (signal.aborted || !raw) return
81
82
  const clean = normalize(raw, opts.maxChars)
82
83
  if (!clean) return
@@ -151,22 +152,14 @@ export default Plugin.define({
151
152
  syncInline()
152
153
  const key = keyName(event)
153
154
  const current = suggestion()
154
- return Boolean(current && inline.visible(getComposer(current.sessionID).editor, current.text) && opts.acceptKeys.includes(key) && canGenerate(current.sessionID) && isComposerEmpty(current.sessionID))
155
+ return Boolean(current && inline.visible(getComposer(current.sessionID).editor, current.text) && acceptShortcuts(opts.acceptKeys, context.keymap.shortcuts).has(key) && canGenerate(current.sessionID) && isComposerEmpty(current.sessionID))
155
156
  }, cancelSuggestion)
156
157
 
157
158
  const commandLayers = () => ghostKeymapLayers([
158
- ...opts.acceptKeys.map((key) => ({
159
- id: `ghost.accept.${key}`,
160
- bind: key,
161
- enabled: () => Boolean(suggestion()),
162
- run: () => {
163
- const route = context.ui.router.current()
164
- if (route.type !== "session") return false
165
- const current = suggestion()
166
- if (!current || current.sessionID !== route.sessionID) return false
167
- if (!act(route.sessionID)) return false
168
- },
169
- })),
159
+ ...opts.acceptKeys.map((key) => acceptCommand(key, () => {
160
+ const route = context.ui.router.current()
161
+ return route.type === "session" ? route.sessionID : undefined
162
+ }, () => suggestion()?.sessionID, act)),
170
163
  {
171
164
  id: "ghost.home",
172
165
  bind: "left",