opencode-ghost 0.5.1 → 1.0.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,7 +5,17 @@ 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
- ## [Unreleased]
8
+ ## [1.0.0] - 2026-10-08
9
+
10
+ ### Breaking changes
11
+
12
+ - Migrate inline prompt ghosts to opencode v2 _(breaking)_
13
+
14
+ ## [0.6.0] - 2026-09-30
15
+
16
+ ### Added
17
+
18
+ - **release:** Generate changelogs from released commit ranges
9
19
 
10
20
  ## [0.5.1] - 2026-09-30
11
21
 
@@ -151,7 +161,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
151
161
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
152
162
  `recentMessages` and `system`.
153
163
 
154
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.1...HEAD
164
+ [1.0.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.6.0...v1.0.0
165
+ [0.6.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.1...v0.6.0
155
166
  [0.5.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.0...v0.5.1
156
167
  [0.5.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.4.0...v0.5.0
157
168
  [0.4.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.3.1...v0.4.0
package/README.md CHANGED
@@ -2,44 +2,32 @@
2
2
 
3
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
4
 
5
- **Next-prompt suggestions** for the [opencode](https://opencode.ai) TUI, in the
5
+ An **OpenCode V2 TUI plugin** providing next-prompt suggestions, in the
6
6
  style of Claude Code: after each turn a small model reads the recent
7
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>).
8
+ a dimmed inline placeholder in the empty composer with
9
+ <kbd>Tab</kbd>/<kbd>→</kbd> to accept it.
9
10
 
10
11
  > Why "ghost": it writes the line you were about to type, then waits for you to
11
12
  > take it.
12
13
 
13
14
  ## What it does
14
15
 
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
- - **Slash-command argument ghosts while typing.** After `/name `, the accepted
21
- argument options (from `argHints`) are listed as `[a | b | c]` inside the
22
- prompt input box, right after the caret. Once you start typing an argument,
23
- the list gives way to a dimmed inline completion of the matching option (e.g.
24
- `/keepwarm alw` ghosts `ays`). Running out of options or naming a command
25
- with no configured hints shows no ghost — command and skill descriptions are
26
- never suggested as if they were arguments. Tab completes the next argument.
27
- While the command name is still being typed, opencode's own slash menu stays
28
- in charge. Purely local — no model calls.
29
- - **History ghost while typing.** If the line you are typing is a prefix of a
30
- message you already sent in the same session, the rest of it is ghosted in
31
- the box as well; Tab pulls the full line back. Also purely local.
32
- - **Grey, dimmed ghost styling.** Every ghost is grey and dimmed so it cannot
33
- be mistaken for typed text. A next-message suggestion that is wider than the
34
- prompt wraps onto extra rows and grows the prompt box (up to five rows) so it
35
- stays fully readable; other ghosts are clipped to the box and never spill
36
- past it.
37
- - **`/suggest` toggles it.** State is stored in the plugin KV.
16
+ - **Suggests your next message.** After a turn finishes, it debounces, builds a
17
+ short transcript of the recent turns, and asks a model for one line that
18
+ sounds like you.
19
+ - **Inline empty-composer ghost.** The suggestion renders as the textarea's
20
+ dimmed placeholder only while the normal composer is empty and focused in an
21
+ idle session. `Tab` or `→` inserts it as editable text; Enter does not accept
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.
25
+ - **`/suggest` toggles it.** State is stored durably via the plugin's storage.
38
26
 
39
27
  ## Requirements
40
28
 
41
- - [opencode](https://opencode.ai) (TUI plugins; tested on 1.18.x).
42
- - A cheap/fast model for `small_model` — or set `model` explicitly.
29
+ - [opencode](https://opencode.ai) v2 (TUI plugins; tested against 2.0.25).
30
+ - A provider/model that supports the stateless generation endpoint, or set `model` explicitly.
43
31
 
44
32
  ## Install
45
33
 
@@ -53,102 +41,126 @@ cd opencode-ghost
53
41
 
54
42
  Then **restart opencode** — plugins are loaded at startup only. `install.sh`
55
43
  copies the plugin into `<config>/plugins/opencode-ghost/` and registers it in
56
- `<config>/tui.json` (backing the file up first).
44
+ `<config>/cli.json`, migrating an existing v1 `tui.json` registration (and its
45
+ options) if one is found.
46
+ Removed options (`apiKey`, `endpoint`, `internalSessionMarkerDir`, `argHints`)
47
+ are stripped from Ghost registrations. Other configuration is preserved.
48
+ The installer accepts JSON objects only; JSONC or invalid configuration is
49
+ left intact along with the installed sources and requires manual registration
50
+ using the snippet below. Node is required for the installer.
57
51
 
58
52
  ### Manually
59
53
 
60
- Add the checkout to your `tui.json` (global at `~/.config/opencode/tui.json`, or
61
- a project's `.opencode/`). Plugins are loaded as source:
54
+ Add the plugin to your `cli.json` (normally `~/.config/opencode/cli.json`).
55
+ For an isolated test, set `OPENCODE_CONFIG_DIR` to a separate configuration
56
+ directory containing this file; a project's `.opencode/cli.json` is not loaded.
62
57
 
63
58
  ```jsonc
64
59
  {
65
- "$schema": "https://opencode.ai/tui.json",
66
- "plugin": ["/absolute/path/to/opencode-ghost/src/tui.tsx"],
60
+ "plugins": ["/absolute/path/to/opencode-ghost/src"]
67
61
  }
68
62
  ```
69
63
 
70
- ### From npm
71
-
72
- ```sh
73
- opencode plugin opencode-ghost # current project
74
- opencode plugin -g opencode-ghost # global
75
- ```
76
-
77
- Restart opencode after changing `tui.json`.
78
-
79
- ## Options
80
-
81
- Pass an options object as the second element of the plugin tuple:
64
+ Pass options with the object form:
82
65
 
83
66
  ```jsonc
84
67
  {
85
- "plugin": [
86
- ["opencode-ghost", {
87
- "enabled": true,
88
- "model": "anthropic/claude-haiku-4-5",
89
- "acceptKeys": ["tab", "right"],
90
- "maxChars": 120,
91
- "idleDelayMs": 500,
92
- "recentMessages": 10
93
- }]
68
+ "plugins": [
69
+ {
70
+ "package": "/absolute/path/to/opencode-ghost/src",
71
+ "options": { "model": "openai/gpt-6-luna-fast" }
72
+ }
94
73
  ]
95
74
  }
96
75
  ```
97
76
 
98
- | Option | Type | Default | Notes |
99
- | ---------------- | ---------- | --------------------- | --------------------------------------------------------------------- |
100
- | `enabled` | `boolean` | `true` | Initial state; `/suggest` toggles it at runtime (persisted). |
101
- | `model` | `string` | session `small_model` | `provider/model`. Falls back to `small_model`, then the session model. |
102
- | `endpoint` | `string` | unset | Custom OpenAI-compatible or gateway endpoint (`baseURL` or `/chat/completions`). Enables stateless direct generation without creating OpenCode sessions. |
103
- | `apiKey` | `string` | unset | API key for `endpoint`, supporting `{file:...}`, `{env:...}`, or raw token. |
104
- | `acceptKeys` | `string[]` | `["tab", "right"]` | Key names as reported by the terminal (`tab`, `right`, ...). |
105
- | `maxChars` | `number` | `120` | Maximum suggestion length. |
106
- | `idleDelayMs` | `number` | `500` | Debounce after a turn finishes before generating. |
107
- | `recentMessages` | `number` | `10` | How many recent messages feed the suggestion prompt. |
108
- | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
109
- | `backOnEmptyLeft` | `boolean` | `true` | Return to the home screen with Left when the prompt is empty. |
110
- | `internalSessionMarkerDir` | `string` | unset | Optional extra crash-recovery tracking. A configuration-free sweep already reaps leftover hidden sessions, so this is only an additional safety net. |
111
- | `argHints` | `Record<string, string[]>` | `{}` | Argument option lists per slash command, shown as `[a \| b \| c]` after a complete `/name ` and completed with Tab, e.g. `"/keepwarm": ["6h", "always", "off", "status"]`. Commands without an entry suggest no arguments. |
77
+ Restart opencode after changing `cli.json`.
78
+
79
+ Register a directory containing `tui.tsx`, not the file itself. For an installed
80
+ copy, use `/absolute/path/to/config/plugins/opencode-ghost`.
81
+
82
+ ## Options
83
+
84
+ | Option | Type | Default | Notes |
85
+ | ---------------- | ---------- | ------------------- | ------------------------------------------------------------- |
86
+ | `enabled` | `boolean` | `true` | Initial state; `/suggest` toggles it at runtime (persisted). |
87
+ | `model` | `string` | OpenCode small default | Explicit `provider/model` override. Otherwise uses the effective title-agent model or OpenCode's small-model selection policy. |
88
+ | `acceptKeys` | `string[]` | `["tab", "right"]` | Key names as reported by the terminal (`tab`, `right`, ...). |
89
+ | `maxChars` | `number` | `120` | Maximum suggestion length. |
90
+ | `idleDelayMs` | `number` | `500` | Debounce after a turn finishes before generating. |
91
+ | `recentMessages` | `number` | `10` | How many recent messages feed the suggestion prompt. |
92
+ | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
93
+ | `backOnEmptyLeft` | `boolean` | `true` | Return to the home screen with Left when the composer is empty. |
112
94
 
113
95
  ## Commands
114
96
 
115
- - `/suggest` — toggle suggestions on/off (state is stored in the plugin KV).
97
+ - `/suggest` — toggle suggestions on/off (persisted).
98
+ - `/suggest model openai/gpt-6-luna-fast` — set a persisted runtime model override.
99
+ - `/suggest model clear` — clear the override and use the configured `model`,
100
+ or the OpenCode small default, never the main model.
101
+
102
+ Model precedence is the persisted `/suggest model` override, then the `model`
103
+ option, then the effective title agent's model from OpenCode's public agent API.
104
+ OpenCode resolves inherited `agents.title.model`, migrated legacy `small_model`,
105
+ and directory definitions such as `agents/title.md` and `agent/title.md`, including
106
+ agent deletion/recreation. Explicitly configured variants are preserved.
107
+ Without a configured title model, or when the title agent is disabled, Ghost
108
+ 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
+ the first enabled, active text-input/text-output catalog model in family order
111
+ `gpt-luna`, `gemini-flash-lite`, `gemini-flash`, `claude-haiku`.
112
+ 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.
116
114
 
117
115
  When enabling, a suggestion is generated immediately for the current session.
118
116
 
119
117
  ## How it works
120
118
 
121
- - Listens for `session.idle`, debounces, and builds a short transcript from the
122
- session's recent messages.
123
- - Asks a model (your `small_model` by default) for one short next user message.
124
- When provider credentials or an `endpoint` are available, it generates suggestions
125
- statelessly via direct HTTP fetch (zero server sessions created, zero cmux/session
126
- hooks emitted). Otherwise, falls back to a throwaway hidden session with tools
127
- disabled that is deleted immediately afterwards. Every hidden-session call is scoped
128
- to the current session's project directory, hidden sessions carry a metadata tag that
129
- survives the server's automatic title generation, and a periodic sweep (plus
130
- shutdown cleanup) reaps any leftovers — including sessions orphaned by a
131
- crash — with no configuration required.
132
- - Renders the result through the `session_prompt` host slot, ghosted inside
133
- the prompt box at the caret, and accepts it into the input via the prompt
134
- ref.
119
+ - Listens for `session.execution.succeeded`, debounces, and builds a short
120
+ transcript from the session's recent messages.
121
+ - Generates the suggestion statelessly through OpenCode's `generate.text` API —
122
+ no session is created, and the call is cancelled if a newer turn finishes,
123
+ the session is left, suggestions are disabled, or the plugin unloads.
124
+ - A zero-size prompt-footer marker identifies the normal composer through the
125
+ public renderer tree. The textarea's public placeholder setter displays the
126
+ suggestion without changing its buffer, caret, history or undo state.
127
+ - Solid effects and a public pre-render callback synchronize placeholder
128
+ ownership. Host hint/color updates are retained while the ghost is visible;
129
+ dismissal restores only values still owned by Ghost, including rich hints.
130
+ - Accepting uses the composer editor's `insertText` directly, without submitting.
131
+ - Typing, pasting, navigation, a new execution, disabling and unloading cancel
132
+ pending generation. Duplicate completion events do not generate again.
135
133
 
136
134
  ## Caveats
137
135
 
138
- - **Owns the `session_prompt` slot.** opencode only exposes the prompt ref
139
- through that slot, so this plugin replaces the default prompt component. It
140
- forwards `on_submit`, `ref`, `right`, `visible`, and `disabled`, and cannot be
141
- combined with another plugin that also renders `session_prompt`.
142
- - **Ghost placement.** All ghosts (next-message suggestion, typing
143
- completions) render inside the prompt box right after the caret; opencode
144
- exposes no inline-completion API, so the plugin overlays the prompt's
145
- editor renderable and never touches the prompt's hint row.
146
- - **Cost.** One small-model call per turn while enabled. Point `model` at a
147
- cheap or free model.
148
- - **`acceptKeys`.** `tab` is also opencode's agent-cycle key; this plugin only
149
- captures it while a suggestion is visible and the input is empty.
150
- - Suggestions fail silently (no ghost text) if the model is unavailable, the
151
- provider is out of balance, or the reply is `NONE`.
136
+ - **No inline typing completions.** V1's slash-argument and history ghosts
137
+ are not supported. This ghost is a next-prompt placeholder for an empty
138
+ composer, not a completion after typed text.
139
+ - **Focus safety.** Modal/form and shell editors are never acceptance targets.
140
+ Non-base keymap modes, autocomplete capture, renderer/editor selections and
141
+ composer extmarks suppress the ghost. The textarea must be the first of exactly
142
+ two children in its body (editor and metadata); additional UI, including image
143
+ previews and failed-preview boxes, suppresses display and acceptance.
144
+ - **Attachment visibility.** V2 does not expose draft attachments to plugins.
145
+ Mentionless files without a rendered preview (non-image files or images with
146
+ previews disabled) cannot be detected, so a ghost may appear in those drafts.
147
+ Disable suggestions with `/suggest` when using such attachments.
148
+ - **Renderer integration.** Composer discovery depends on the host renderer
149
+ hierarchy and its composer identity guard. Placeholder synchronization uses
150
+ OpenTUI's public pre-render callback; host renderer changes need re-verification.
151
+ - **Key fallthrough.** Without an eligible ghost, Tab/Right fall through to
152
+ the host. OpenCode V2's default agent-cycle shortcut is Shift+Tab.
153
+ - **Cost.** One model call per turn while enabled. Point `model` at a cheap or
154
+ free model.
155
+ - **Provider compatibility.** OpenAI and google-vertex generation worked in
156
+ verification. Anthropic's stateless endpoint failed with plugin-auth OAuth
157
+ in verification; choose a supported provider/model. Luna 6.1 is not available.
158
+ - Generation errors show one warning toast per plugin load with the model
159
+ override command. Aborts and `NONE` replies produce no warning or preview.
160
+ - Effective agents and catalogs are read per generation for the session's
161
+ location. The stateless endpoint has no location argument; project-only models
162
+ may fail server-global resolution. Invalid overrides and provider failures warn
163
+ without retrying through another provider or a session.
152
164
 
153
165
  ## Development
154
166
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "0.5.1",
4
- "description": "Claude Code-style next-prompt suggestions in the opencode TUI, shown as dimmed ghost text and accepted with Tab. An opencode plugin.",
3
+ "version": "1.0.0",
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",
7
7
  "author": "Ozan Dogrultan",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "packageManager": "bun@1.4.2",
38
38
  "engines": {
39
- "opencode": ">=1.18.0"
39
+ "opencode": ">=2.0.0"
40
40
  },
41
41
  "scripts": {
42
42
  "prepare": "husky",
@@ -51,16 +51,15 @@
51
51
  "solid-js": "^1.9.12"
52
52
  },
53
53
  "peerDependencies": {
54
- "@opencode-ai/plugin": "*",
54
+ "@opencode/plugin": "*",
55
55
  "solid-js": "*"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@commitlint/cli": "^21.2.2",
59
59
  "@commitlint/config-conventional": "^21.2.2",
60
- "@opencode-ai/plugin": "1.18.25",
61
- "@opentui/core": "0.4.5",
62
- "@opentui/keymap": "0.4.5",
63
- "@opentui/solid": "0.4.5",
60
+ "@opencode/plugin": "2.0.25",
61
+ "@opentui/core": "^0.5.12",
62
+ "@opentui/solid": "^0.5.12",
64
63
  "@types/node": "24.12.2",
65
64
  "husky": "^9.1.7",
66
65
  "typescript": "7.0.2"
@@ -0,0 +1,68 @@
1
+ import type { KeyEvent, KeyHandler, TextareaRenderable } from "@opentui/core"
2
+
3
+ export function observeComposerInput(input: KeyHandler, preserve: (event: KeyEvent) => boolean, cancel: () => void): () => void {
4
+ const onKeypress = (event: KeyEvent) => {
5
+ if (!preserve(event)) cancel()
6
+ }
7
+ input.prependListener("keypress", onKeypress)
8
+ input.prependListener("paste", cancel)
9
+ return () => {
10
+ input.off("keypress", onKeypress)
11
+ input.off("paste", cancel)
12
+ }
13
+ }
14
+
15
+ export type Editor = {
16
+ plainText: string
17
+ focused: boolean
18
+ isDestroyed: boolean
19
+ insertText(text: string): void
20
+ }
21
+
22
+ export type EditorNode = {
23
+ parent: EditorNode | null
24
+ isDestroyed: boolean
25
+ getChildren(): EditorNode[]
26
+ }
27
+
28
+ export function isEmptyComposer(editor: TextareaRenderable | undefined, focused: unknown, mode: string, normal: boolean, selected: boolean): boolean {
29
+ if (!editor?.parent || editor.parent.isDestroyed) return false
30
+ const siblings = editor.parent.getChildren().filter((node) =>
31
+ !(node.id.startsWith("slot-layout-") && node.getChildren().length === 0),
32
+ )
33
+ 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
35
+ }
36
+
37
+ export function findComposerEditor<T extends EditorNode>(marker: EditorNode | undefined, isEditor: (node: EditorNode) => node is T, isComposer: (editor: T) => boolean): T | undefined {
38
+ if (!marker || marker.isDestroyed) return
39
+ const editors = (node: EditorNode): T[] => isEditor(node) ? [node] : node.getChildren().flatMap(editors)
40
+ for (let node = marker.parent; node && node.parent; node = node.parent) {
41
+ if (node.isDestroyed) return
42
+ const found = editors(node)
43
+ if (found.length > 1) return
44
+ if (found.length === 1) return isComposer(found[0]) ? found[0] : undefined
45
+ }
46
+ }
47
+
48
+ export function composerAction(editor: Editor | undefined, focused: () => unknown, mode: string, normal: boolean, allowed: boolean, text: string | undefined): boolean {
49
+ if (!allowed || !text || !editor || editor !== focused() || !editor.focused || editor.isDestroyed || mode !== "base" || !normal) return false
50
+ if (editor.plainText.length !== 0) return false
51
+ try {
52
+ editor.insertText(text)
53
+ if (editor.plainText !== text) return false
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ export function keyName(event: { name: string; ctrl: boolean; meta: boolean; shift: boolean; option?: boolean; super?: boolean; hyper?: boolean }): string {
61
+ return [event.ctrl && "ctrl", (event.meta || event.option) && "alt", event.shift && "shift", event.super && "super", event.hyper && "hyper", event.name].filter(Boolean).join("+")
62
+ }
63
+
64
+ export function canonicalKey(key: string): string {
65
+ const parts = key.trim().toLowerCase().split("+")
66
+ const name = parts.pop() ?? ""
67
+ return [...["ctrl", "alt", "shift", "super", "hyper"].filter((modifier) => parts.includes(modifier)), name].join("+")
68
+ }
@@ -0,0 +1,41 @@
1
+ export function createLifecycle(delay: number, allowed: (sessionID: string) => boolean, generate: (sessionID: string, signal: AbortSignal) => Promise<void>, clear: () => void) {
2
+ let timer: ReturnType<typeof setTimeout> | undefined
3
+ let controller: AbortController | undefined
4
+ let disposed = false
5
+ let warned = false
6
+ const completed = new Set<string>()
7
+ const cancel = () => {
8
+ clearTimeout(timer)
9
+ timer = undefined
10
+ controller?.abort()
11
+ controller = undefined
12
+ clear()
13
+ }
14
+ const run = async (sessionID: string) => {
15
+ cancel()
16
+ if (disposed || !allowed(sessionID)) return
17
+ const current = new AbortController()
18
+ controller = current
19
+ try {
20
+ await generate(sessionID, current.signal)
21
+ } finally {
22
+ if (controller === current) controller = undefined
23
+ }
24
+ }
25
+ return {
26
+ cancel,
27
+ run,
28
+ succeeded(id: string, sessionID: string) {
29
+ if (disposed || !allowed(sessionID) || completed.has(id)) return
30
+ completed.add(id)
31
+ cancel()
32
+ timer = setTimeout(() => { timer = undefined; void run(sessionID) }, delay)
33
+ },
34
+ warn(show: () => void) {
35
+ if (disposed || warned) return
36
+ warned = true
37
+ show()
38
+ },
39
+ dispose() { disposed = true; cancel() },
40
+ }
41
+ }
package/src/options.ts CHANGED
@@ -1,31 +1,26 @@
1
- export type PromptSuggestOptions = {
1
+ import { parseModel } from "./text"
2
+ import { canonicalKey } from "./composer"
3
+
4
+ export type GhostOptions = {
2
5
  enabled?: boolean
3
6
  model?: string
4
- endpoint?: string
5
- apiKey?: string
6
7
  acceptKeys?: string[]
7
8
  backOnEmptyLeft?: boolean
8
- internalSessionMarkerDir?: string
9
9
  maxChars?: number
10
10
  idleDelayMs?: number
11
11
  recentMessages?: number
12
12
  system?: string
13
- argHints?: Record<string, string[]>
14
13
  }
15
14
 
16
15
  export type ResolvedOptions = {
17
16
  enabled: boolean
18
17
  model: string | undefined
19
- endpoint: string | undefined
20
- apiKey: string | undefined
21
18
  acceptKeys: string[]
22
19
  backOnEmptyLeft: boolean
23
- internalSessionMarkerDir: string | undefined
24
20
  maxChars: number
25
21
  idleDelayMs: number
26
22
  recentMessages: number
27
23
  system: string
28
- argHints: Record<string, readonly string[]>
29
24
  }
30
25
 
31
26
  export const DEFAULT_SYSTEM = [
@@ -59,27 +54,26 @@ const DEFAULTS = {
59
54
  recentMessages: 10,
60
55
  }
61
56
 
62
- export function resolveOptions(options: PromptSuggestOptions | undefined): ResolvedOptions {
63
- const input = options ?? {}
57
+ const REMOVED_OPTION_KEYS = ["endpoint", "apiKey", "internalSessionMarkerDir", "argHints"] as const
58
+
59
+ export function removedOptionKeys(raw: Readonly<Record<string, unknown>> | undefined): string[] {
60
+ if (!raw) return []
61
+ return REMOVED_OPTION_KEYS.filter((key) => key in raw)
62
+ }
63
+
64
+ export function resolveOptions(options: Readonly<Record<string, unknown>> | undefined): ResolvedOptions {
65
+ const input = (options ?? {}) as GhostOptions
64
66
  const maxChars = input.maxChars
65
67
  const idleDelayMs = input.idleDelayMs
66
68
  const recentMessages = input.recentMessages
67
69
  return {
68
70
  enabled: input.enabled ?? DEFAULTS.enabled,
69
71
  model: typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined,
70
- endpoint:
71
- typeof input.endpoint === "string" && input.endpoint.trim() ? input.endpoint.trim() : undefined,
72
- apiKey:
73
- typeof input.apiKey === "string" && input.apiKey.trim() ? input.apiKey.trim() : undefined,
74
72
  acceptKeys:
75
73
  Array.isArray(input.acceptKeys) && input.acceptKeys.length > 0
76
- ? input.acceptKeys
74
+ ? input.acceptKeys.filter((key) => typeof key === "string" && key.trim()).map(canonicalKey)
77
75
  : [...DEFAULTS.acceptKeys],
78
76
  backOnEmptyLeft: input.backOnEmptyLeft ?? DEFAULTS.backOnEmptyLeft,
79
- internalSessionMarkerDir:
80
- typeof input.internalSessionMarkerDir === "string" && input.internalSessionMarkerDir.trim()
81
- ? input.internalSessionMarkerDir.trim()
82
- : undefined,
83
77
  maxChars: typeof maxChars === "number" && maxChars > 0 ? maxChars : DEFAULTS.maxChars,
84
78
  idleDelayMs:
85
79
  typeof idleDelayMs === "number" && idleDelayMs >= 0 ? idleDelayMs : DEFAULTS.idleDelayMs,
@@ -88,13 +82,21 @@ export function resolveOptions(options: PromptSuggestOptions | undefined): Resol
88
82
  ? recentMessages
89
83
  : DEFAULTS.recentMessages,
90
84
  system: typeof input.system === "string" && input.system.trim() ? input.system : DEFAULT_SYSTEM,
91
- argHints:
92
- input.argHints && typeof input.argHints === "object" && !Array.isArray(input.argHints)
93
- ? Object.fromEntries(
94
- Object.entries(input.argHints)
95
- .filter(([, value]) => Array.isArray(value) && value.length > 0)
96
- .map(([key, value]) => [key.toLowerCase(), [...value]]),
97
- )
98
- : {},
99
85
  }
100
86
  }
87
+
88
+ export function parseSuggestCommand(input: string | undefined): { type: "toggle" } | { type: "model"; model: string | undefined } | { type: "invalid" } {
89
+ const raw = input?.trim() ?? ""
90
+ if (!raw) return { type: "toggle" }
91
+ const match = /^model\s+(\S+)$/.exec(raw)
92
+ if (!match || (match[1] !== "clear" && !parseModel(match[1]))) return { type: "invalid" }
93
+ return { type: "model", model: match[1] === "clear" ? undefined : match[1] }
94
+ }
95
+
96
+ export function suggestionModel(runtime: string | undefined, configured: string | undefined) {
97
+ const spec = runtime ?? configured
98
+ if (spec === undefined) return undefined
99
+ const model = parseModel(spec)
100
+ if (!model) throw new Error("Invalid suggestion model; set /suggest model provider/model")
101
+ return model
102
+ }
@@ -0,0 +1,46 @@
1
+ import type { TextareaRenderable } from "@opentui/core"
2
+
3
+ export type PlaceholderEditor = Pick<TextareaRenderable, "placeholder" | "placeholderColor" | "plainText" | "isDestroyed">
4
+
5
+ export function createInlinePlaceholder() {
6
+ let owned: {
7
+ editor: PlaceholderEditor
8
+ original: PlaceholderEditor["placeholder"]
9
+ originalColor: PlaceholderEditor["placeholderColor"]
10
+ text: string
11
+ color: PlaceholderEditor["placeholderColor"]
12
+ } | undefined
13
+
14
+ const clear = () => {
15
+ if (!owned) return
16
+ const { editor, original, originalColor, text, color } = owned
17
+ owned = undefined
18
+ if (editor.isDestroyed) return
19
+ if (editor.placeholder === text) editor.placeholder = original
20
+ if (editor.placeholderColor === color) editor.placeholderColor = originalColor
21
+ }
22
+
23
+ return {
24
+ clear,
25
+ visible(editor: PlaceholderEditor | undefined, text: string) {
26
+ return !!owned && owned.editor === editor && !editor?.isDestroyed && editor?.plainText === "" && editor.placeholder === text && owned.text === text
27
+ },
28
+ sync(editor: PlaceholderEditor | undefined, text: string | undefined, color: PlaceholderEditor["placeholderColor"], eligible: boolean) {
29
+ if (owned && owned.editor !== editor) clear()
30
+ if (!editor || editor.isDestroyed || editor.plainText !== "" || !text || !eligible) {
31
+ clear()
32
+ return
33
+ }
34
+ if (!owned) {
35
+ owned = { editor, original: editor.placeholder, originalColor: editor.placeholderColor, text, color }
36
+ } else {
37
+ if (editor.placeholder !== owned.text) owned.original = editor.placeholder
38
+ if (editor.placeholderColor !== owned.color) owned.originalColor = editor.placeholderColor
39
+ owned.text = text
40
+ }
41
+ if (editor.placeholderColor !== color) editor.placeholderColor = color
42
+ owned.color = editor.placeholderColor
43
+ if (editor.placeholder !== text) editor.placeholder = text
44
+ },
45
+ }
46
+ }