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 +21 -0
- package/README.md +135 -0
- package/package.json +55 -0
- package/src/options.ts +60 -0
- package/src/text.ts +34 -0
- package/src/tui.tsx +274 -0
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
|
+
[](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
|
+
}
|