opencode-ghost 0.6.0 → 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 +7 -0
- package/README.md +108 -96
- package/package.json +7 -8
- package/src/composer.ts +68 -0
- package/src/lifecycle.ts +41 -0
- package/src/options.ts +30 -28
- package/src/placeholder.ts +46 -0
- package/src/stateless.ts +22 -187
- package/src/text.ts +47 -51
- package/src/transcript.ts +44 -0
- package/src/tui.tsx +217 -914
- package/src/builtins.ts +0 -28
- package/src/completion.ts +0 -78
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.0.0] - 2026-10-08
|
|
9
|
+
|
|
10
|
+
### Breaking changes
|
|
11
|
+
|
|
12
|
+
- Migrate inline prompt ghosts to opencode v2 _(breaking)_
|
|
13
|
+
|
|
8
14
|
## [0.6.0] - 2026-09-30
|
|
9
15
|
|
|
10
16
|
### Added
|
|
@@ -155,6 +161,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
155
161
|
- Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
|
|
156
162
|
`recentMessages` and `system`.
|
|
157
163
|
|
|
164
|
+
[1.0.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.6.0...v1.0.0
|
|
158
165
|
[0.6.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.1...v0.6.0
|
|
159
166
|
[0.5.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.0...v0.5.1
|
|
160
167
|
[0.5.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.4.0...v0.5.0
|
package/README.md
CHANGED
|
@@ -2,44 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ozandogrultan/opencode-ghost/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
**
|
|
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
|
|
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.**
|
|
16
|
-
transcript of the recent turns, and asks a model
|
|
17
|
-
|
|
18
|
-
- **
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
the
|
|
24
|
-
|
|
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
|
|
42
|
-
- A
|
|
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>/
|
|
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
|
|
61
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
"
|
|
86
|
-
|
|
87
|
-
"
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
|
106
|
-
|
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
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 (
|
|
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.
|
|
122
|
-
session's recent messages.
|
|
123
|
-
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
- **
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
- **
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
-
|
|
151
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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": ">=
|
|
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
|
|
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
|
|
61
|
-
"@opentui/core": "0.
|
|
62
|
-
"@opentui/
|
|
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"
|
package/src/composer.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
+
}
|