opencode-ghost 0.6.0 → 1.0.1
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 +14 -0
- package/README.md +108 -96
- package/package.json +7 -8
- package/src/composer.ts +68 -0
- package/src/keymap.ts +23 -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 +208 -915
- package/src/builtins.ts +0 -28
- package/src/completion.ts +0 -78
package/src/tui.tsx
CHANGED
|
@@ -1,950 +1,243 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const MAX_TRANSCRIPT_CHARS = 6000
|
|
21
|
-
// The prompt editor grows to fit a wrapped next-message suggestion only up to
|
|
22
|
-
// this many rows; longer text is clipped. The host caps its editor at >= 6 rows
|
|
23
|
-
// by default, so 5 stays inside that ceiling.
|
|
24
|
-
const MAX_GHOST_LINES = 5
|
|
25
|
-
// Every ghost is drawn at this opacity so it always reads as a suggestion, not
|
|
26
|
-
// typed text. Slash-command ghosts keep the accent hue but are dimmed too.
|
|
27
|
-
const GHOST_OPACITY = 0.6
|
|
28
|
-
const ENABLED_KEY = "ghost.enabled"
|
|
29
|
-
const MODEL_KEY = "ghost.model"
|
|
30
|
-
|
|
31
|
-
const HIDDEN_TOOLS = {
|
|
32
|
-
bash: false,
|
|
33
|
-
edit: false,
|
|
34
|
-
write: false,
|
|
35
|
-
read: false,
|
|
36
|
-
grep: false,
|
|
37
|
-
glob: false,
|
|
38
|
-
list: false,
|
|
39
|
-
task: false,
|
|
40
|
-
todowrite: false,
|
|
41
|
-
todoread: false,
|
|
42
|
-
webfetch: false,
|
|
43
|
-
websearch: false,
|
|
44
|
-
patch: false,
|
|
45
|
-
multiedit: false,
|
|
46
|
-
question: false,
|
|
47
|
-
skill: false,
|
|
48
|
-
lsp: false,
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function markPending(dir: string, pending: string) {
|
|
52
|
-
try {
|
|
53
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
54
|
-
fs.writeFileSync(pending, "")
|
|
55
|
-
} catch {
|
|
56
|
-
// best effort
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function markSession(dir: string, pending: string, id: string, directory: string | undefined) {
|
|
61
|
-
try {
|
|
62
|
-
fs.writeFileSync(
|
|
63
|
-
path.join(dir, `id-${id}`),
|
|
64
|
-
JSON.stringify(directory ? { directory, pid: process.pid } : { pid: process.pid }),
|
|
65
|
-
)
|
|
66
|
-
fs.rmSync(pending, { force: true })
|
|
67
|
-
} catch {
|
|
68
|
-
// best effort
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function unmarkSession(dir: string, pending: string, id: string | undefined, deleted: boolean) {
|
|
73
|
-
try {
|
|
74
|
-
fs.rmSync(pending, { force: true })
|
|
75
|
-
} catch {
|
|
76
|
-
// best effort
|
|
77
|
-
}
|
|
78
|
-
if (!id || !deleted) return
|
|
79
|
-
try {
|
|
80
|
-
fs.rmSync(path.join(dir, `id-${id}`), { force: true })
|
|
81
|
-
} catch {
|
|
82
|
-
// best effort
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function isGhostSession(session: { title?: unknown; metadata?: unknown }): boolean {
|
|
87
|
-
if ((session as { title?: unknown }).title === HIDDEN_TITLE) return true
|
|
88
|
-
const metadata = (session as { metadata?: Record<string, unknown> }).metadata
|
|
89
|
-
return (
|
|
90
|
-
typeof metadata === "object" &&
|
|
91
|
-
metadata !== null &&
|
|
92
|
-
(metadata as Record<string, unknown>).ghost === GHOST_METADATA.ghost
|
|
2
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
3
|
+
import { TextareaRenderable, InputRenderable, type BoxRenderable, type Renderable } from "@opentui/core"
|
|
4
|
+
import { createEffect, createRoot, createSignal, on, onCleanup } from "solid-js"
|
|
5
|
+
import { composerAction, findComposerEditor, isEmptyComposer, keyName, observeComposerInput } from "./composer"
|
|
6
|
+
import { createLifecycle } from "./lifecycle"
|
|
7
|
+
import { ghostKeymapLayers } from "./keymap"
|
|
8
|
+
import { parseSuggestCommand, removedOptionKeys, resolveOptions, suggestionModel } from "./options"
|
|
9
|
+
import { createInlinePlaceholder } from "./placeholder"
|
|
10
|
+
import { generateSuggestion } from "./stateless"
|
|
11
|
+
import { isEcho, normalize, resolveSmallModel } from "./text"
|
|
12
|
+
import { buildTranscript, lastUserText, suggestionPrompt, type TranscriptMessage } from "./transcript"
|
|
13
|
+
|
|
14
|
+
type Suggestion = { sessionID: string; text: string }
|
|
15
|
+
|
|
16
|
+
function composerEditor(marker: Renderable | undefined): TextareaRenderable | undefined {
|
|
17
|
+
return findComposerEditor(marker,
|
|
18
|
+
(node): node is TextareaRenderable => node instanceof TextareaRenderable && !(node instanceof InputRenderable),
|
|
19
|
+
(editor) => typeof (editor as unknown as { getClipboardText?: unknown }).getClipboardText === "function",
|
|
93
20
|
)
|
|
94
21
|
}
|
|
95
22
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (status === 404) return true
|
|
109
|
-
return typeof candidate?.message === "string" && /not found/i.test(candidate.message)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const tui: TuiPlugin = async (api, rawOptions) => {
|
|
113
|
-
const opts = resolveOptions(rawOptions as PromptSuggestOptions | undefined)
|
|
114
|
-
// In-flight hidden sessions (id -> directory). A Map so dispose and the
|
|
115
|
-
// sweep can scope get/delete calls even while a generation is running.
|
|
116
|
-
const activeHidden = new Map<string, string | undefined>()
|
|
117
|
-
|
|
118
|
-
// Directory of the visible session the suggestion belongs to, falling back
|
|
119
|
-
// to the TUI working directory. Every hidden-session call is scoped with it:
|
|
120
|
-
// the server resolves sessions per directory, and an unscoped call is the
|
|
121
|
-
// historical reason failed generations left sessions behind.
|
|
122
|
-
const resolveParentDirectory = (visibleSessionID: string): string | undefined => {
|
|
123
|
-
try {
|
|
124
|
-
const local = api.state.session.get(visibleSessionID) as { directory?: unknown } | undefined
|
|
125
|
-
if (typeof local?.directory === "string" && local.directory) return local.directory
|
|
126
|
-
} catch {
|
|
127
|
-
// fall through to the cwd
|
|
128
|
-
}
|
|
129
|
-
try {
|
|
130
|
-
const cwd = (api.state as { path?: { directory?: unknown } }).path?.directory
|
|
131
|
-
if (typeof cwd === "string" && cwd) return cwd
|
|
132
|
-
} catch {
|
|
133
|
-
// unavailable in some test harnesses; callers fall back to the
|
|
134
|
-
// server-reported directory instead.
|
|
23
|
+
export default Plugin.define({
|
|
24
|
+
id: "ghost",
|
|
25
|
+
setup(context) {
|
|
26
|
+
const opts = resolveOptions(context.options)
|
|
27
|
+
|
|
28
|
+
const removed = removedOptionKeys(context.options)
|
|
29
|
+
if (removed.length > 0) {
|
|
30
|
+
context.ui.toast.show({
|
|
31
|
+
title: "Ghost",
|
|
32
|
+
message: `Unsupported options ignored: ${removed.join(", ")}`,
|
|
33
|
+
variant: "warning",
|
|
34
|
+
})
|
|
135
35
|
}
|
|
136
|
-
return undefined
|
|
137
|
-
}
|
|
138
36
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (!dir) return [] as { entry: string; marker: string; ageMs: number }[]
|
|
142
|
-
let names: string[]
|
|
143
|
-
try {
|
|
144
|
-
names = fs.readdirSync(dir).filter((entry) => /^id-ses_[\w-]+$/.test(entry))
|
|
145
|
-
} catch {
|
|
146
|
-
return [] as { entry: string; marker: string; ageMs: number }[]
|
|
147
|
-
}
|
|
148
|
-
const now = Date.now()
|
|
149
|
-
return names.map((entry) => {
|
|
150
|
-
const marker = path.join(dir, entry)
|
|
151
|
-
let ageMs = Number.POSITIVE_INFINITY
|
|
152
|
-
try {
|
|
153
|
-
ageMs = now - fs.statSync(marker).mtimeMs
|
|
154
|
-
} catch {
|
|
155
|
-
// Unreadable mtime: treat as old so it gets swept.
|
|
156
|
-
}
|
|
157
|
-
return { entry, marker, ageMs }
|
|
37
|
+
const [state, updateState] = context.storage.store("state", {
|
|
38
|
+
initial: { enabled: opts.enabled, model: undefined as string | undefined },
|
|
158
39
|
})
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Deletes a ghost session and reports whether it is confirmed gone. A
|
|
162
|
-
// `directory` is passed whenever known: the server scopes deletion by it, and
|
|
163
|
-
// omitting it is why failed generations left sessions behind. A non-true
|
|
164
|
-
// delete response is re-checked by existence rather than assumed to have
|
|
165
|
-
// failed, so a session already removed (or removed concurrently) still counts
|
|
166
|
-
// as gone and its marker is not kept forever.
|
|
167
|
-
const removeHiddenSession = async (sessionID: string, directory?: string): Promise<boolean> => {
|
|
168
|
-
const target = directory ? { sessionID, directory } : { sessionID }
|
|
169
|
-
try {
|
|
170
|
-
const result = await api.client.session.delete(target)
|
|
171
|
-
if (result.data === true) return true
|
|
172
|
-
} catch (error) {
|
|
173
|
-
// Only a definitive "not found" means it is gone; anything else (server
|
|
174
|
-
// down, permissions) must keep the marker so a later sweep retries.
|
|
175
|
-
return isNotFound(error)
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
await api.client.session.get(target)
|
|
179
|
-
return false
|
|
180
|
-
} catch (error) {
|
|
181
|
-
return isNotFound(error)
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Reaps abandoned ghost sessions in this process and from crashed ones. Two
|
|
186
|
-
// phases:
|
|
187
|
-
//
|
|
188
|
-
// - Marker files (when `internalSessionMarkerDir` is configured) are
|
|
189
|
-
// authoritative proof this plugin created the session, so they are deleted
|
|
190
|
-
// regardless of title: the server auto-titles the hidden session after the
|
|
191
|
-
// first prompt, and the old title check let every retitled orphan live
|
|
192
|
-
// forever. It deliberately does not skip markers by owner pid anymore: a
|
|
193
|
-
// failed delete in a still-running TUI left its own markers behind forever,
|
|
194
|
-
// because the old startup-only recovery treated a live pid as
|
|
195
|
-
// "not orphaned". Fresh markers (younger than ORPHAN_MIN_AGE_MS) whose
|
|
196
|
-
// owner is still running are left alone so an in-flight generation in
|
|
197
|
-
// another process is not swept mid-write.
|
|
198
|
-
// - A list-based sweep needs no configuration and catches everything the
|
|
199
|
-
// markers miss (default setup, older versions, deleted marker dirs). Only
|
|
200
|
-
// sessions still identifiable as ghost (title or metadata tag) and quiet
|
|
201
|
-
// past the grace window are deleted, and never in-flight generations of
|
|
202
|
-
// this process.
|
|
203
|
-
const sweepOrphans = async () => {
|
|
204
|
-
for (const { entry, marker, ageMs } of markerPaths()) {
|
|
205
|
-
const sessionID = entry.slice(3)
|
|
206
|
-
if (activeHidden.has(sessionID)) continue
|
|
207
|
-
let directory: string | undefined
|
|
208
|
-
let pid: number | undefined
|
|
209
|
-
try {
|
|
210
|
-
const parsed = JSON.parse(fs.readFileSync(marker, "utf8")) as {
|
|
211
|
-
directory?: string
|
|
212
|
-
pid?: number
|
|
213
|
-
}
|
|
214
|
-
if (typeof parsed?.directory === "string" && parsed.directory) directory = parsed.directory
|
|
215
|
-
if (Number.isInteger(parsed?.pid) && (parsed.pid as number) > 0) pid = parsed.pid as number
|
|
216
|
-
} catch {
|
|
217
|
-
// Corrupt/empty marker: no directory or pid; still attempt cleanup.
|
|
218
|
-
}
|
|
219
|
-
// A fresh marker whose owner is still running may be an in-flight
|
|
220
|
-
// generation in another TUI. A dead owner, or one quiet past the grace
|
|
221
|
-
// window, is an orphan.
|
|
222
|
-
if (pid !== undefined && ageMs < ORPHAN_MIN_AGE_MS && processIsRunning(pid)) continue
|
|
223
|
-
|
|
224
|
-
if (await removeHiddenSession(sessionID, directory)) {
|
|
225
|
-
try {
|
|
226
|
-
fs.rmSync(marker, { force: true })
|
|
227
|
-
} catch {
|
|
228
|
-
// best effort
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
const listed = await api.client.session.list()
|
|
235
|
-
const sessions = (listed as { data?: unknown }).data
|
|
236
|
-
if (!Array.isArray(sessions)) return
|
|
237
|
-
const now = Date.now()
|
|
238
|
-
for (const session of sessions) {
|
|
239
|
-
const candidate = session as {
|
|
240
|
-
id?: unknown
|
|
241
|
-
title?: unknown
|
|
242
|
-
metadata?: unknown
|
|
243
|
-
directory?: unknown
|
|
244
|
-
time?: { created?: unknown; updated?: unknown }
|
|
245
|
-
}
|
|
246
|
-
if (typeof candidate.id !== "string" || !candidate.id) continue
|
|
247
|
-
if (activeHidden.has(candidate.id)) continue
|
|
248
|
-
if (!isGhostSession(candidate)) continue
|
|
249
|
-
const updated =
|
|
250
|
-
typeof candidate.time?.updated === "number"
|
|
251
|
-
? candidate.time.updated
|
|
252
|
-
: typeof candidate.time?.created === "number"
|
|
253
|
-
? candidate.time.created
|
|
254
|
-
: undefined
|
|
255
|
-
if (updated !== undefined && now - updated < ORPHAN_MIN_AGE_MS) continue
|
|
256
|
-
const directory =
|
|
257
|
-
typeof candidate.directory === "string" && candidate.directory
|
|
258
|
-
? candidate.directory
|
|
259
|
-
: undefined
|
|
260
|
-
await removeHiddenSession(candidate.id, directory)
|
|
261
|
-
}
|
|
262
|
-
} catch {
|
|
263
|
-
// Listing is best effort (server down, older SDK): markers and the
|
|
264
|
-
// generation finally-block still cover the common cases.
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
void sweepOrphans()
|
|
268
|
-
const sweepTimer = setInterval(() => void sweepOrphans(), ORPHAN_SWEEP_INTERVAL_MS)
|
|
269
|
-
sweepTimer.unref?.()
|
|
270
40
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
>()
|
|
275
|
-
let promptRef: TuiPromptRef | undefined
|
|
276
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
277
|
-
let generating = false
|
|
278
|
-
let acceptLayer: (() => void) | undefined
|
|
41
|
+
const [suggestion, setSuggestion] = createSignal<Suggestion | undefined>()
|
|
42
|
+
const [composerRevision, setComposerRevision] = createSignal(0)
|
|
43
|
+
const inline = createInlinePlaceholder()
|
|
279
44
|
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
if (route.name !== "session") return undefined
|
|
283
|
-
const id = route.params?.sessionID
|
|
284
|
-
return typeof id === "string" ? id : undefined
|
|
285
|
-
}
|
|
45
|
+
let disposed = false
|
|
46
|
+
const composers = new Map<string, { marker: BoxRenderable; normal: () => boolean }>()
|
|
286
47
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// under the builtin TUI slash commands. One config read, refreshed lazily; no
|
|
291
|
-
// model calls are involved anywhere in completion.
|
|
292
|
-
let commandPool: CommandPool[] = BUILTIN_COMMANDS
|
|
293
|
-
let poolFetching = false
|
|
294
|
-
let poolFetchedAt = 0
|
|
295
|
-
const refreshPool = async () => {
|
|
296
|
-
if (poolFetching) return
|
|
297
|
-
poolFetching = true
|
|
298
|
-
try {
|
|
299
|
-
const result = await api.client.command.list()
|
|
300
|
-
const rows = (result as { data?: unknown }).data
|
|
301
|
-
if (Array.isArray(rows)) {
|
|
302
|
-
const configured: CommandPool[] = rows
|
|
303
|
-
.map((row) => row as { name?: unknown; description?: unknown })
|
|
304
|
-
.filter((row) => typeof row.name === "string" && row.name.length > 0)
|
|
305
|
-
.map((row) => ({
|
|
306
|
-
name: row.name as string,
|
|
307
|
-
description: typeof row.description === "string" ? row.description : undefined,
|
|
308
|
-
}))
|
|
309
|
-
const seen = new Set(configured.map((command) => `${command.name.toLowerCase()}`))
|
|
310
|
-
const merged = [
|
|
311
|
-
...configured,
|
|
312
|
-
...BUILTIN_COMMANDS.filter((command) => !seen.has(command.name.toLowerCase())),
|
|
313
|
-
]
|
|
314
|
-
commandPool = merged.length > 0 ? merged : BUILTIN_COMMANDS
|
|
315
|
-
poolFetchedAt = Date.now()
|
|
316
|
-
}
|
|
317
|
-
} catch {
|
|
318
|
-
// Keep the current pool; the poll loop retries on the next TTL window.
|
|
319
|
-
} finally {
|
|
320
|
-
poolFetching = false
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
void refreshPool()
|
|
324
|
-
|
|
325
|
-
// History ghost while typing: if the input is a prefix of a message the user
|
|
326
|
-
// already sent in this session, offer the rest of it. No model involved.
|
|
327
|
-
const historyCandidate = (sessionID: string, input: string) => {
|
|
328
|
-
const wants = input.replace(/\s+/g, " ").trimEnd()
|
|
329
|
-
if (wants.length < 2 || /\n/.test(input)) return undefined
|
|
330
|
-
const collapsed = wants.toLowerCase()
|
|
331
|
-
const messages = api.state.session.messages(sessionID).filter((m) => m.role === "user")
|
|
332
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
333
|
-
const text = api.state
|
|
334
|
-
.part(messages[i].id)
|
|
335
|
-
.filter((part) => part.type === "text")
|
|
336
|
-
.map((part) => (part.type === "text" ? part.text : ""))
|
|
337
|
-
.join(" ")
|
|
338
|
-
.replace(/\s+/g, " ")
|
|
339
|
-
.trim()
|
|
340
|
-
if (text.length <= collapsed.length) continue
|
|
341
|
-
if (!text.toLowerCase().startsWith(collapsed)) continue
|
|
342
|
-
return { ghost: text.slice(collapsed.length), insert: text }
|
|
48
|
+
const isCurrentSession = (sessionID: string) => {
|
|
49
|
+
const route = context.ui.router.current()
|
|
50
|
+
return route.type === "session" && route.sessionID === sessionID
|
|
343
51
|
}
|
|
344
|
-
return undefined
|
|
345
|
-
}
|
|
346
52
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// walking the layout tree). Uses the normal render pipeline, so it is visible
|
|
350
|
-
// wherever the prompt is. When the editor or container cannot be located
|
|
351
|
-
// (host layout change) completion falls back to the hint row.
|
|
352
|
-
type GhostColor = typeof api.theme.current.textMuted
|
|
353
|
-
const [inBox, setInBox] = createSignal<
|
|
354
|
-
{
|
|
355
|
-
sessionID: string
|
|
356
|
-
x: number
|
|
357
|
-
y: number
|
|
358
|
-
text: string
|
|
359
|
-
width: number
|
|
360
|
-
height: number
|
|
361
|
-
wrap: boolean
|
|
362
|
-
color: GhostColor
|
|
363
|
-
opacity: number
|
|
364
|
-
} | undefined
|
|
365
|
-
>()
|
|
366
|
-
let slotNode: { screenX?: number; screenY?: number; width?: number } | undefined
|
|
367
|
-
// Locate the prompt's editor renderable inside the layout tree.
|
|
368
|
-
const findTextarea = (): unknown => {
|
|
369
|
-
const root = (api.renderer as unknown as { root?: unknown }).root as
|
|
370
|
-
| { getChildren?: () => unknown[] }
|
|
371
|
-
| undefined
|
|
372
|
-
if (!root) return undefined
|
|
373
|
-
const stack: unknown[] = [root]
|
|
374
|
-
while (stack.length) {
|
|
375
|
-
const node = stack.pop() as
|
|
376
|
-
| {
|
|
377
|
-
editBuffer?: unknown
|
|
378
|
-
editorView?: unknown
|
|
379
|
-
visualCursor?: { visualRow?: number; visualCol?: number }
|
|
380
|
-
screenX?: number
|
|
381
|
-
screenY?: number
|
|
382
|
-
width?: number
|
|
383
|
-
minHeight?: number
|
|
384
|
-
getChildren?: () => unknown[]
|
|
385
|
-
}
|
|
386
|
-
| undefined
|
|
387
|
-
if (!node) continue
|
|
388
|
-
if (node.editBuffer && node.editorView && node.visualCursor) return node
|
|
389
|
-
const children = typeof node.getChildren === "function" ? node.getChildren() : []
|
|
390
|
-
stack.push(...children)
|
|
391
|
-
}
|
|
392
|
-
return undefined
|
|
393
|
-
}
|
|
53
|
+
const canGenerate = (sessionID: string) =>
|
|
54
|
+
!disposed && state.enabled && isCurrentSession(sessionID) && context.data.session.status(sessionID) === "idle"
|
|
394
55
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
heightManaged.minHeight = 1
|
|
404
|
-
} catch {
|
|
405
|
-
// The editor may have been recreated or destroyed; best effort.
|
|
56
|
+
const warnGeneration = (error: unknown) => {
|
|
57
|
+
lifecycle.warn(() => context.ui.toast.show({
|
|
58
|
+
title: "Ghost",
|
|
59
|
+
message: error instanceof Error && /^(No default small model|Invalid suggestion model)/.test(error.message)
|
|
60
|
+
? error.message
|
|
61
|
+
: "Suggestions unavailable. Use /suggest model provider/model to select a supported generation model.",
|
|
62
|
+
variant: "warning",
|
|
63
|
+
}))
|
|
406
64
|
}
|
|
407
|
-
heightManaged = undefined
|
|
408
|
-
}
|
|
409
65
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
text: string,
|
|
413
|
-
color: GhostColor,
|
|
414
|
-
opacity: number,
|
|
415
|
-
wrap: boolean,
|
|
416
|
-
): boolean => {
|
|
417
|
-
resetEditorHeight()
|
|
418
|
-
setInBox(undefined)
|
|
419
|
-
const textarea = findTextarea() as
|
|
420
|
-
| {
|
|
421
|
-
screenX?: unknown
|
|
422
|
-
screenY?: unknown
|
|
423
|
-
width?: unknown
|
|
424
|
-
minHeight?: number
|
|
425
|
-
visualCursor?: { visualRow?: number; visualCol?: number }
|
|
426
|
-
}
|
|
427
|
-
| undefined
|
|
428
|
-
if (
|
|
429
|
-
!textarea ||
|
|
430
|
-
typeof textarea.screenX !== "number" ||
|
|
431
|
-
typeof textarea.screenY !== "number"
|
|
432
|
-
) {
|
|
433
|
-
return false
|
|
434
|
-
}
|
|
435
|
-
const cursor = textarea.visualCursor ?? {}
|
|
436
|
-
const visualCol = typeof cursor.visualCol === "number" ? cursor.visualCol : 0
|
|
437
|
-
// Overlay coordinates are relative to the slot container (it anchors the
|
|
438
|
-
// absolute overlay), so subtract its screen origin from the editor's.
|
|
439
|
-
if (
|
|
440
|
-
!slotNode ||
|
|
441
|
-
typeof slotNode.screenX !== "number" ||
|
|
442
|
-
typeof slotNode.screenY !== "number"
|
|
443
|
-
) {
|
|
444
|
-
return false
|
|
445
|
-
}
|
|
446
|
-
const x = textarea.screenX - slotNode.screenX + visualCol
|
|
447
|
-
const y = textarea.screenY - slotNode.screenY + (typeof cursor.visualRow === "number" ? cursor.visualRow : 0)
|
|
448
|
-
// Keep the ghost inside the prompt box: clip it to the editor's remaining
|
|
449
|
-
// width so it never spills past the box's right edge.
|
|
450
|
-
const editorWidth = typeof textarea.width === "number" ? textarea.width : undefined
|
|
451
|
-
const width =
|
|
452
|
-
editorWidth !== undefined
|
|
453
|
-
? editorWidth - visualCol
|
|
454
|
-
: typeof slotNode.width === "number"
|
|
455
|
-
? slotNode.width - x
|
|
456
|
-
: undefined
|
|
457
|
-
if (width === undefined || width <= 0) return false
|
|
458
|
-
let height = 1
|
|
459
|
-
if (wrap) {
|
|
460
|
-
height = Math.max(1, Math.min(MAX_GHOST_LINES, wrapCount(text, width)))
|
|
66
|
+
const generate = async (sessionID: string, signal: AbortSignal) => {
|
|
67
|
+
if (!canGenerate(sessionID)) return
|
|
461
68
|
try {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
69
|
+
await context.data.session.message.sync(sessionID)
|
|
70
|
+
if (signal.aborted || !canGenerate(sessionID)) return
|
|
71
|
+
const messages = context.data.session.message.list(sessionID) as readonly TranscriptMessage[]
|
|
72
|
+
const transcript = buildTranscript(messages, opts.recentMessages)
|
|
73
|
+
if (!transcript) return
|
|
74
|
+
const session = context.data.session.get(sessionID)
|
|
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)
|
|
80
|
+
if (signal.aborted || !raw) return
|
|
81
|
+
const clean = normalize(raw, opts.maxChars)
|
|
82
|
+
if (!clean) return
|
|
83
|
+
const previous = lastUserText(messages)
|
|
84
|
+
if (previous && isEcho(clean, previous)) return
|
|
85
|
+
if (!canGenerate(sessionID)) return
|
|
86
|
+
setSuggestion({ sessionID, text: clean })
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!signal.aborted) warnGeneration(error)
|
|
467
89
|
}
|
|
468
90
|
}
|
|
469
|
-
setInBox({ sessionID, x, y, text, width, height, wrap: wrap && height > 1, color, opacity })
|
|
470
|
-
return true
|
|
471
|
-
}
|
|
472
91
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
if (!sessionID) {
|
|
479
|
-
if (completion() || inBox()) {
|
|
480
|
-
setCompletion(undefined)
|
|
481
|
-
setInBox(undefined)
|
|
482
|
-
}
|
|
483
|
-
resetEditorHeight()
|
|
484
|
-
return
|
|
485
|
-
}
|
|
486
|
-
const input = promptRef?.current.input ?? ""
|
|
487
|
-
if (poolFetchedAt && Date.now() - poolFetchedAt > POOL_TTL_MS) void refreshPool()
|
|
488
|
-
const isSlash = input.startsWith("/")
|
|
489
|
-
const startsEmpty = !input.trim()
|
|
490
|
-
// `/name` with no space yet is opencode's native slash menu; the plugin
|
|
491
|
-
// stays out of its way. Our slash completion only covers `/name args...`.
|
|
492
|
-
const found = !isSlash
|
|
493
|
-
? historyCandidate(sessionID, input)
|
|
494
|
-
: input.includes(" ")
|
|
495
|
-
? completeCommand(input, commandPool, opts.argHints)
|
|
496
|
-
: undefined
|
|
497
|
-
let display = ""
|
|
498
|
-
let insert = ""
|
|
499
|
-
let wrap = false
|
|
500
|
-
// Every ghost is grey and dimmed so it cannot be mistaken for typed text.
|
|
501
|
-
const color = api.theme.current.textMuted
|
|
502
|
-
const opacity = GHOST_OPACITY
|
|
503
|
-
if (found) {
|
|
504
|
-
const args = "args" in found ? found.args : undefined
|
|
505
|
-
insert = found.insert ?? ""
|
|
506
|
-
display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ?? "")
|
|
507
|
-
} else if (startsEmpty) {
|
|
508
|
-
// Empty prompt: the model's next-message suggestion is ghosted in the
|
|
509
|
-
// box as well (the accept layer's `accept()` handles Tab for it). It is
|
|
510
|
-
// the only ghost allowed to wrap and grow the prompt box, since it can be
|
|
511
|
-
// longer than the visible width and there is no typed text to disturb.
|
|
512
|
-
const next = ghost()
|
|
513
|
-
if (next && next.sessionID === sessionID) {
|
|
514
|
-
display = next.text
|
|
515
|
-
wrap = true
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
// Include the display and renderer width in the key: a fresh suggestion for
|
|
519
|
-
// an unchanged (empty) input must be re-placed, and a resize must re-wrap.
|
|
520
|
-
const key = `${sessionID}\u0000${input}\u0000${display}\u0000${api.renderer.width}`
|
|
521
|
-
if (completionKey === key) return
|
|
522
|
-
completionKey = key
|
|
523
|
-
const drawn = display ? placeInBoxGhost(sessionID, display, color, opacity, wrap) : false
|
|
524
|
-
if (!drawn) {
|
|
525
|
-
setInBox(undefined)
|
|
526
|
-
resetEditorHeight()
|
|
527
|
-
}
|
|
528
|
-
setCompletion(
|
|
529
|
-
found
|
|
530
|
-
? {
|
|
531
|
-
sessionID,
|
|
532
|
-
display,
|
|
533
|
-
insert,
|
|
534
|
-
drawn,
|
|
535
|
-
}
|
|
536
|
-
: undefined,
|
|
537
|
-
)
|
|
538
|
-
setAcceptVisible(Boolean(insert) || Boolean(ghost()))
|
|
539
|
-
}, POLL_INTERVAL_MS)
|
|
540
|
-
pollTimer.unref?.()
|
|
541
|
-
|
|
542
|
-
const isEnabled = () => {
|
|
543
|
-
try {
|
|
544
|
-
return api.kv.get<boolean>(ENABLED_KEY, opts.enabled) !== false
|
|
545
|
-
} catch {
|
|
546
|
-
return opts.enabled
|
|
547
|
-
}
|
|
548
|
-
}
|
|
92
|
+
const lifecycle = createLifecycle(opts.idleDelayMs, canGenerate, generate, () => {
|
|
93
|
+
inline.clear()
|
|
94
|
+
setSuggestion(undefined)
|
|
95
|
+
})
|
|
96
|
+
const cancelSuggestion = lifecycle.cancel
|
|
549
97
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
} catch {
|
|
554
|
-
// ignore
|
|
555
|
-
}
|
|
556
|
-
}
|
|
98
|
+
const offSucceeded = context.data.on("session.execution.succeeded", (event) => {
|
|
99
|
+
lifecycle.succeeded(event.id, event.data.sessionID)
|
|
100
|
+
})
|
|
557
101
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (typeof override === "string" && override.trim()) return parseModel(override.trim())
|
|
562
|
-
} catch {
|
|
563
|
-
// ignore
|
|
564
|
-
}
|
|
565
|
-
const config = api.state.config as Record<string, unknown> | undefined
|
|
566
|
-
return (
|
|
567
|
-
parseModel(opts.model) ??
|
|
568
|
-
parseModel(config?.small_model ?? config?.smallModel) ??
|
|
569
|
-
parseModel(config?.model)
|
|
570
|
-
)
|
|
571
|
-
}
|
|
102
|
+
const offStarted = context.data.on("session.execution.started", (event) => {
|
|
103
|
+
if (isCurrentSession(event.data.sessionID)) cancelSuggestion()
|
|
104
|
+
})
|
|
572
105
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
for (const message of messages.slice(-opts.recentMessages)) {
|
|
577
|
-
if (message.role !== "user" && message.role !== "assistant") continue
|
|
578
|
-
const text = api.state
|
|
579
|
-
.part(message.id)
|
|
580
|
-
.filter((part) => part.type === "text")
|
|
581
|
-
.map((part) => (part.type === "text" ? part.text : ""))
|
|
582
|
-
.join(" ")
|
|
583
|
-
if (!text.trim()) continue
|
|
584
|
-
lines.push(
|
|
585
|
-
`${message.role === "user" ? "User" : "Assistant"}: ${clip(text, MAX_MESSAGE_CHARS)}`,
|
|
586
|
-
)
|
|
106
|
+
const getComposer = (sessionID: string) => {
|
|
107
|
+
const entry = composers.get(sessionID)
|
|
108
|
+
return { editor: composerEditor(entry?.marker), normal: entry?.normal() === true }
|
|
587
109
|
}
|
|
588
|
-
if (lines.length === 0) return ""
|
|
589
|
-
return `${lines.join("\n").slice(-MAX_TRANSCRIPT_CHARS)}\n\nTask: write the user's next message.`
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const lastUserText = (sessionID: string) => {
|
|
593
|
-
const message = api.state.session.messages(sessionID).filter((m) => m.role === "user").at(-1)
|
|
594
|
-
if (!message) return ""
|
|
595
|
-
return api.state
|
|
596
|
-
.part(message.id)
|
|
597
|
-
.filter((part) => part.type === "text")
|
|
598
|
-
.map((part) => (part.type === "text" ? part.text : ""))
|
|
599
|
-
.join(" ")
|
|
600
|
-
}
|
|
601
110
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
// unrelated tools (e.g. session search) and answer like a real agent
|
|
606
|
-
// instead of writing a suggested next message.
|
|
607
|
-
const fetchDisabledTools = async (
|
|
608
|
-
directory: string | undefined,
|
|
609
|
-
): Promise<Record<string, boolean>> => {
|
|
610
|
-
const disabled: Record<string, boolean> = { ...HIDDEN_TOOLS }
|
|
611
|
-
try {
|
|
612
|
-
const result = await api.client.tool.ids(directory ? { directory } : undefined)
|
|
613
|
-
const ids = Array.isArray(result?.data) ? result.data : []
|
|
614
|
-
for (const id of ids) {
|
|
615
|
-
if (typeof id === "string" && id) disabled[id] = false
|
|
616
|
-
}
|
|
617
|
-
} catch {
|
|
618
|
-
// Best effort: the static list above still covers every built-in tool
|
|
619
|
-
// if the dynamic lookup fails.
|
|
111
|
+
const isComposerEmpty = (sessionID: string) => {
|
|
112
|
+
const { editor, normal } = getComposer(sessionID)
|
|
113
|
+
return isEmptyComposer(editor, context.renderer.currentFocusedEditor, context.keymap.mode.current(), normal, context.renderer.hasSelection)
|
|
620
114
|
}
|
|
621
|
-
return disabled
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
const generate = async (sessionID: string): Promise<string | undefined> => {
|
|
625
|
-
const transcript = buildTranscript(sessionID)
|
|
626
|
-
if (!transcript) return undefined
|
|
627
115
|
|
|
628
|
-
const
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
model,
|
|
634
|
-
api.state.config,
|
|
635
|
-
api.state.provider,
|
|
636
|
-
)
|
|
637
|
-
if (stateless) {
|
|
638
|
-
const suggestion = normalize(stateless, opts.maxChars)
|
|
639
|
-
if (!suggestion) return undefined
|
|
640
|
-
const previous = lastUserText(sessionID)
|
|
641
|
-
if (previous && isEcho(suggestion, previous)) return undefined
|
|
642
|
-
return suggestion
|
|
116
|
+
const syncInline = () => {
|
|
117
|
+
composerRevision()
|
|
118
|
+
const current = suggestion()
|
|
119
|
+
const editor = current ? getComposer(current.sessionID).editor : undefined
|
|
120
|
+
inline.sync(editor, current?.text, context.theme.text.muted, !!current && canGenerate(current.sessionID) && isComposerEmpty(current.sessionID))
|
|
643
121
|
}
|
|
644
122
|
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
// behind whenever the default differed.
|
|
654
|
-
const parentDirectory = resolveParentDirectory(sessionID)
|
|
655
|
-
try {
|
|
656
|
-
const [created, disabledTools] = await Promise.all([
|
|
657
|
-
api.client.session.create({
|
|
658
|
-
title: HIDDEN_TITLE,
|
|
659
|
-
metadata: { ...GHOST_METADATA },
|
|
660
|
-
...(parentDirectory ? { directory: parentDirectory } : {}),
|
|
661
|
-
}),
|
|
662
|
-
fetchDisabledTools(parentDirectory),
|
|
663
|
-
])
|
|
664
|
-
tempID = created.data?.id
|
|
665
|
-
if (!tempID) return undefined
|
|
666
|
-
createdDirectory =
|
|
667
|
-
typeof created.data?.directory === "string" && created.data.directory
|
|
668
|
-
? created.data.directory
|
|
669
|
-
: parentDirectory
|
|
670
|
-
activeHidden.set(tempID, createdDirectory)
|
|
671
|
-
if (markerDir && pending)
|
|
672
|
-
markSession(markerDir, pending, tempID, createdDirectory ?? parentDirectory ?? "")
|
|
673
|
-
|
|
674
|
-
const result = await api.client.session.prompt({
|
|
675
|
-
sessionID: tempID,
|
|
676
|
-
...(createdDirectory ? { directory: createdDirectory } : {}),
|
|
677
|
-
system: opts.system,
|
|
678
|
-
tools: disabledTools,
|
|
679
|
-
model,
|
|
680
|
-
parts: [{ type: "text", text: transcript }],
|
|
123
|
+
const disposeRouteWatcher = createRoot((dispose) => {
|
|
124
|
+
createEffect(on(() => {
|
|
125
|
+
const route = context.ui.router.current()
|
|
126
|
+
return route.type === "session" ? route.sessionID : route.type
|
|
127
|
+
}, cancelSuggestion, { defer: true }))
|
|
128
|
+
createEffect(() => {
|
|
129
|
+
const route = context.ui.router.current()
|
|
130
|
+
if (!state.enabled || (route.type === "session" && context.data.session.status(route.sessionID) !== "idle")) cancelSuggestion()
|
|
681
131
|
})
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
.filter((part) => part.type === "text")
|
|
685
|
-
.map((part) => (part.type === "text" ? part.text : ""))
|
|
686
|
-
.join("\n")
|
|
687
|
-
const suggestion = normalize(raw, opts.maxChars)
|
|
688
|
-
if (!suggestion) return undefined
|
|
689
|
-
const previous = lastUserText(sessionID)
|
|
690
|
-
if (previous && isEcho(suggestion, previous)) return undefined
|
|
691
|
-
return suggestion
|
|
692
|
-
} finally {
|
|
693
|
-
let deleted = false
|
|
694
|
-
if (tempID) {
|
|
695
|
-
activeHidden.delete(tempID)
|
|
696
|
-
// Pass the directory and retry-based confirmation so a failed
|
|
697
|
-
// generation still removes its hidden session. If the session truly
|
|
698
|
-
// cannot be removed yet, the marker is kept and sweepOrphans retries.
|
|
699
|
-
deleted = await removeHiddenSession(tempID, createdDirectory)
|
|
700
|
-
}
|
|
701
|
-
if (markerDir && pending) unmarkSession(markerDir, pending, tempID, deleted)
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
const enableAccept = () => {
|
|
706
|
-
if (acceptLayer) return
|
|
707
|
-
acceptLayer = api.keymap.registerLayer({
|
|
708
|
-
mode: "base",
|
|
709
|
-
priority: 100,
|
|
710
|
-
bindings: opts.acceptKeys.map((key) => ({
|
|
711
|
-
key,
|
|
712
|
-
desc: "Accept suggested prompt or completion",
|
|
713
|
-
preventDefault: true,
|
|
714
|
-
cmd: () => {
|
|
715
|
-
if (completionAccept()) return true
|
|
716
|
-
return accept()
|
|
717
|
-
},
|
|
718
|
-
})),
|
|
132
|
+
createEffect(syncInline)
|
|
133
|
+
return dispose
|
|
719
134
|
})
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
generating = false
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
const schedule = (sessionID: string) => {
|
|
771
|
-
if (!isEnabled()) return
|
|
772
|
-
if (timer) clearTimeout(timer)
|
|
773
|
-
timer = setTimeout(() => void run(sessionID), opts.idleDelayMs)
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
const accept = () => {
|
|
777
|
-
const current = ghost()
|
|
778
|
-
if (!current) return false
|
|
779
|
-
if (currentSessionID() !== current.sessionID) {
|
|
780
|
-
showGhost(undefined)
|
|
781
|
-
return false
|
|
782
|
-
}
|
|
783
|
-
if (!promptRef) return false
|
|
784
|
-
if (currentInput().trim()) return false
|
|
785
|
-
promptRef.set({ input: current.text, parts: [] })
|
|
786
|
-
promptRef.focus()
|
|
787
|
-
completionKey = ""
|
|
788
|
-
setInBox(undefined)
|
|
789
|
-
resetEditorHeight()
|
|
790
|
-
showGhost(undefined)
|
|
791
|
-
return true
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
const offIdle = api.event.on("session.idle", (event) => {
|
|
795
|
-
const id = event.properties?.sessionID
|
|
796
|
-
if (!id) return
|
|
797
|
-
schedule(id)
|
|
798
|
-
})
|
|
799
|
-
|
|
800
|
-
// session.idle is deprecated upstream; the live signal is session.status.
|
|
801
|
-
const offStatus = api.event.on("session.status", (event) => {
|
|
802
|
-
const props = event.properties
|
|
803
|
-
if (props?.status?.type !== "idle") return
|
|
804
|
-
if (!props.sessionID) return
|
|
805
|
-
schedule(props.sessionID)
|
|
806
|
-
})
|
|
807
|
-
|
|
808
|
-
const backLayer = api.keymap.registerLayer({
|
|
809
|
-
mode: "base",
|
|
810
|
-
priority: 40,
|
|
811
|
-
bindings: [
|
|
812
|
-
{
|
|
813
|
-
key: "left",
|
|
814
|
-
desc: "Return to the home screen when the prompt is empty",
|
|
815
|
-
cmd: () => {
|
|
816
|
-
if (!opts.backOnEmptyLeft) return false
|
|
817
|
-
if (api.route.current.name !== "session") return false
|
|
818
|
-
const input = currentInput()
|
|
819
|
-
if (input.trim().length > 0) return false
|
|
820
|
-
showGhost(undefined)
|
|
821
|
-
setInBox(undefined)
|
|
822
|
-
api.route.navigate("home")
|
|
823
|
-
return true
|
|
135
|
+
const beforeRender = async () => { syncInline() }
|
|
136
|
+
context.renderer.setFrameCallback(beforeRender)
|
|
137
|
+
|
|
138
|
+
const act = (sessionID: string) => {
|
|
139
|
+
syncInline()
|
|
140
|
+
const current = suggestion()
|
|
141
|
+
if (!current || current.sessionID !== sessionID) return false
|
|
142
|
+
const { editor, normal } = getComposer(sessionID)
|
|
143
|
+
if (!inline.visible(editor, current.text) || !isComposerEmpty(sessionID)) return false
|
|
144
|
+
if (!composerAction(editor, () => context.renderer.currentFocusedEditor, context.keymap.mode.current(), normal, canGenerate(sessionID), current.text)) return false
|
|
145
|
+
inline.clear()
|
|
146
|
+
setSuggestion(undefined)
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const offInput = observeComposerInput(context.renderer.keyInput, (event) => {
|
|
151
|
+
syncInline()
|
|
152
|
+
const key = keyName(event)
|
|
153
|
+
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
|
+
}, cancelSuggestion)
|
|
156
|
+
|
|
157
|
+
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
|
+
})),
|
|
170
|
+
{
|
|
171
|
+
id: "ghost.home",
|
|
172
|
+
bind: "left",
|
|
173
|
+
enabled: () => opts.backOnEmptyLeft,
|
|
174
|
+
run: () => {
|
|
175
|
+
const route = context.ui.router.current()
|
|
176
|
+
if (route.type !== "session") return false
|
|
177
|
+
if (!isComposerEmpty(route.sessionID)) return false
|
|
178
|
+
cancelSuggestion()
|
|
179
|
+
context.ui.router.navigate({ type: "home" })
|
|
180
|
+
},
|
|
824
181
|
},
|
|
182
|
+
], state.enabled, async (input) => {
|
|
183
|
+
const command = parseSuggestCommand(input)
|
|
184
|
+
if (command.type === "model") {
|
|
185
|
+
cancelSuggestion()
|
|
186
|
+
await updateState((draft) => { draft.model = command.model })
|
|
187
|
+
context.ui.toast.show({ message: `Suggestion model: ${state.model ?? opts.model ?? "OpenCode small default"}` })
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
if (command.type === "invalid") {
|
|
191
|
+
context.ui.toast.show({ message: "Use /suggest, /suggest model provider/model, or /suggest model clear", variant: "warning" })
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
cancelSuggestion()
|
|
195
|
+
const next = !state.enabled
|
|
196
|
+
await updateState((draft) => {
|
|
197
|
+
draft.enabled = next
|
|
198
|
+
})
|
|
199
|
+
if (!next) {
|
|
200
|
+
context.ui.toast.show({ message: "Prompt suggestions: off" })
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
context.ui.toast.show({ message: "Prompt suggestions: on" })
|
|
204
|
+
const route = context.ui.router.current()
|
|
205
|
+
if (route.type === "session") void lifecycle.run(route.sessionID)
|
|
206
|
+
})
|
|
207
|
+
context.keymap.layer(() => commandLayers()[0]!)
|
|
208
|
+
context.keymap.layer(() => commandLayers()[1]!)
|
|
209
|
+
|
|
210
|
+
const offComposer = context.ui.slot({
|
|
211
|
+
append: "prompt.footer",
|
|
212
|
+
render: (input) => {
|
|
213
|
+
const [marker, setMarker] = createSignal<BoxRenderable>()
|
|
214
|
+
createEffect(() => {
|
|
215
|
+
const sessionID = input.sessionID
|
|
216
|
+
const node = marker()
|
|
217
|
+
if (!sessionID || !node) return
|
|
218
|
+
composers.set(sessionID, { marker: node, normal: () => input.mode === "normal" })
|
|
219
|
+
setComposerRevision((value) => value + 1)
|
|
220
|
+
onCleanup(() => {
|
|
221
|
+
if (composers.get(sessionID)?.marker === node) {
|
|
222
|
+
composers.delete(sessionID)
|
|
223
|
+
setComposerRevision((value) => value + 1)
|
|
224
|
+
syncInline()
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
return <box width={0} height={0} ref={setMarker} />
|
|
825
229
|
},
|
|
826
|
-
|
|
827
|
-
})
|
|
828
|
-
|
|
829
|
-
api.slots.register({
|
|
830
|
-
order: 60,
|
|
831
|
-
slots: {
|
|
832
|
-
session_prompt(_ctx, props) {
|
|
833
|
-
const sid = props.session_id ?? (props as any).sessionID
|
|
834
|
-
const overlay = () => {
|
|
835
|
-
const current = inBox()
|
|
836
|
-
return current && current.sessionID === sid ? current : undefined
|
|
837
|
-
}
|
|
838
|
-
return (
|
|
839
|
-
<box
|
|
840
|
-
position="relative"
|
|
841
|
-
ref={(ref: { screenX?: number; screenY?: number; width?: number }) => {
|
|
842
|
-
slotNode = ref
|
|
843
|
-
}}
|
|
844
|
-
>
|
|
845
|
-
<api.ui.Prompt
|
|
846
|
-
sessionID={sid}
|
|
847
|
-
visible={props.visible}
|
|
848
|
-
disabled={props.disabled}
|
|
849
|
-
showPlaceholder={false}
|
|
850
|
-
right={<api.ui.Slot name="session_prompt_right" session_id={sid} />}
|
|
851
|
-
onSubmit={() => {
|
|
852
|
-
showGhost(undefined)
|
|
853
|
-
setInBox(undefined)
|
|
854
|
-
props.on_submit?.()
|
|
855
|
-
;(props as any).onSubmit?.()
|
|
856
|
-
}}
|
|
857
|
-
ref={(ref) => {
|
|
858
|
-
promptRef = ref
|
|
859
|
-
props.ref?.(ref)
|
|
860
|
-
}}
|
|
861
|
-
/>
|
|
862
|
-
{overlay() ? (
|
|
863
|
-
<box
|
|
864
|
-
position="absolute"
|
|
865
|
-
left={overlay()!.x}
|
|
866
|
-
top={overlay()!.y}
|
|
867
|
-
width={overlay()!.width}
|
|
868
|
-
height={overlay()!.height}
|
|
869
|
-
overflow="hidden"
|
|
870
|
-
opacity={overlay()!.opacity}
|
|
871
|
-
zIndex={100}
|
|
872
|
-
>
|
|
873
|
-
<text
|
|
874
|
-
fg={overlay()!.color}
|
|
875
|
-
wrapMode={overlay()!.wrap ? "word" : "none"}
|
|
876
|
-
truncate={!overlay()!.wrap}
|
|
877
|
-
>
|
|
878
|
-
{overlay()!.text}
|
|
879
|
-
</text>
|
|
880
|
-
</box>
|
|
881
|
-
) : undefined}
|
|
882
|
-
</box>
|
|
883
|
-
)
|
|
884
|
-
},
|
|
885
|
-
},
|
|
886
|
-
})
|
|
887
|
-
|
|
888
|
-
api.command?.register(() => [
|
|
889
|
-
{
|
|
890
|
-
title: `Prompt suggestions: ${isEnabled() ? "on" : "off"}`,
|
|
891
|
-
value: "ghost",
|
|
892
|
-
description: "Toggle the suggested next prompt",
|
|
893
|
-
category: "Prompt",
|
|
894
|
-
slash: { name: "suggest" },
|
|
895
|
-
onSelect: async () => {
|
|
896
|
-
const next = !isEnabled()
|
|
897
|
-
setEnabled(next)
|
|
898
|
-
if (!next) {
|
|
899
|
-
showGhost(undefined)
|
|
900
|
-
api.ui.toast({ title: "Prompt suggestions", message: "Off" })
|
|
901
|
-
return
|
|
902
|
-
}
|
|
903
|
-
api.ui.toast({ title: "Prompt suggestions", message: "On" })
|
|
904
|
-
const sessionID = currentSessionID()
|
|
905
|
-
if (!sessionID) return
|
|
906
|
-
try {
|
|
907
|
-
const text = await generate(sessionID)
|
|
908
|
-
showGhost(text ? { sessionID, text } : undefined)
|
|
909
|
-
} catch {
|
|
910
|
-
// ignore
|
|
911
|
-
}
|
|
912
|
-
},
|
|
913
|
-
},
|
|
914
|
-
])
|
|
915
|
-
|
|
916
|
-
api.lifecycle.onDispose(() => {
|
|
917
|
-
if (timer) clearTimeout(timer)
|
|
918
|
-
clearInterval(sweepTimer)
|
|
919
|
-
clearInterval(pollTimer)
|
|
920
|
-
setInBox(undefined)
|
|
921
|
-
resetEditorHeight()
|
|
922
|
-
offIdle()
|
|
923
|
-
offStatus()
|
|
924
|
-
backLayer()
|
|
925
|
-
disableAccept()
|
|
926
|
-
// A generation in flight at shutdown would otherwise leak its hidden
|
|
927
|
-
// session: the finally-block still runs for the awaiting caller, but if
|
|
928
|
-
// the host drops pending work, fire one last scoped delete per session.
|
|
929
|
-
// The periodic sweep (markers + list) retries anything this misses.
|
|
930
|
-
for (const [sessionID, directory] of activeHidden) {
|
|
931
|
-
void removeHiddenSession(sessionID, directory).catch(() => {})
|
|
932
|
-
}
|
|
933
|
-
activeHidden.clear()
|
|
934
|
-
})
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
export const id = "ghost"
|
|
938
|
-
export { tui }
|
|
230
|
+
})
|
|
939
231
|
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
232
|
+
return () => {
|
|
233
|
+
disposed = true
|
|
234
|
+
lifecycle.dispose()
|
|
235
|
+
context.renderer.removeFrameCallback(beforeRender)
|
|
236
|
+
disposeRouteWatcher()
|
|
237
|
+
offSucceeded()
|
|
238
|
+
offStarted()
|
|
239
|
+
offInput()
|
|
240
|
+
offComposer()
|
|
946
241
|
}
|
|
947
242
|
},
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
export default plugin
|
|
243
|
+
})
|