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/src/tui.tsx CHANGED
@@ -1,950 +1,243 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import type { TuiPlugin, TuiPromptRef } from "@opencode-ai/plugin/tui"
3
- import { createSignal } from "solid-js"
4
- import * as fs from "node:fs"
5
- import * as path from "node:path"
6
- import { resolveOptions, type PromptSuggestOptions } from "./options"
7
- import { clip, isEcho, normalize, parseModel, wrapCount } from "./text"
8
- import { BUILTIN_COMMANDS } from "./builtins"
9
- import { completeCommand, type CommandPool } from "./completion"
10
- import { fetchStatelessSuggestion } from "./stateless"
11
-
12
- const HIDDEN_TITLE = "ghost-hidden"
13
- // Survives the server's automatic title generation, which renames the hidden
14
- // session after the first prompt and would otherwise make it invisible to a
15
- // title-based sweep.
16
- const GHOST_METADATA = { ghost: "opencode-ghost" } as const
17
- const ORPHAN_SWEEP_INTERVAL_MS = 30_000
18
- const ORPHAN_MIN_AGE_MS = 15_000
19
- const MAX_MESSAGE_CHARS = 800
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
- function processIsRunning(pid: number): boolean {
97
- try {
98
- process.kill(pid, 0)
99
- return true
100
- } catch (error) {
101
- return (error as NodeJS.ErrnoException).code !== "ESRCH"
102
- }
103
- }
104
-
105
- function isNotFound(error: unknown): boolean {
106
- const candidate = error as { status?: number; response?: { status?: number }; message?: string }
107
- const status = candidate?.status ?? candidate?.response?.status
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
- const markerPaths = () => {
140
- const dir = opts.internalSessionMarkerDir
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
- const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
272
- const [completion, setCompletion] = createSignal<
273
- { sessionID: string; display: string; insert?: string; drawn: boolean } | undefined
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
- const currentSessionID = () => {
281
- const route = api.route.current
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
- const currentInput = () => promptRef?.current.input ?? ""
288
-
289
- // Completable commands: configured commands/skills from the server layered
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
- // In-box ghost: an absolutely positioned text node rendered in the slot's
348
- // own container, at the caret of the prompt's editor renderable (found by
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
- // A wrapped next-message suggestion is shown by growing the host editor's
396
- // min height to the number of rows it needs, so the prompt box grows instead
397
- // of clipping the text. The host sets minHeight once at creation (not
398
- // reactively), so the value we set survives until we reset it.
399
- let heightManaged: { minHeight?: number } | undefined
400
- const resetEditorHeight = () => {
401
- if (!heightManaged) return
402
- try {
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
- const placeInBoxGhost = (
411
- sessionID: string,
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
- textarea.minHeight = height
463
- heightManaged = textarea
464
- } catch {
465
- // If the host editor cannot be resized, fall back to a one-row clip.
466
- height = 1
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
- const POLL_INTERVAL_MS = 100
474
- const POOL_TTL_MS = 60_000
475
- let completionKey = ""
476
- const pollTimer = setInterval(() => {
477
- const sessionID = currentSessionID()
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
- const setEnabled = (value: boolean) => {
551
- try {
552
- api.kv.set(ENABLED_KEY, value)
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
- const resolveModel = () => {
559
- try {
560
- const override = api.kv.get<string>(MODEL_KEY)
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
- const buildTranscript = (sessionID: string) => {
574
- const messages = api.state.session.messages(sessionID)
575
- const lines: string[] = []
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
- // HIDDEN_TOOLS only names built-in tools; anything else (MCP servers,
603
- // other dynamically registered tools) stays enabled unless listed here too.
604
- // Ask the server for the full set so a hidden session never gets to call
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 model = resolveModel()
629
-
630
- const stateless = await fetchStatelessSuggestion(
631
- transcript,
632
- opts,
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 markerDir = opts.internalSessionMarkerDir
646
- const pending = markerDir ? path.join(markerDir, "pending") : undefined
647
- if (markerDir && pending) markPending(markerDir, pending)
648
-
649
- let tempID: string | undefined
650
- let createdDirectory: string | undefined
651
- // Scope every hidden-session call to the visible session's project: the
652
- // server resolves sessions per directory, and unscoped calls left orphans
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
- const parts = result.data?.parts ?? []
683
- const raw = parts
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
- const disableAccept = () => {
723
- acceptLayer?.()
724
- acceptLayer = undefined
725
- }
726
-
727
- let acceptVisible = false
728
- const setAcceptVisible = (value: boolean) => {
729
- if (acceptVisible === value) return
730
- acceptVisible = value
731
- if (value) enableAccept()
732
- else disableAccept()
733
- }
734
-
735
- const showGhost = (value: { sessionID: string; text: string } | undefined) => {
736
- setGhost(value)
737
- setAcceptVisible(Boolean(value) || Boolean(completion()?.insert))
738
- }
739
-
740
- const completionAccept = () => {
741
- const current = completion()
742
- if (!current || currentSessionID() !== current.sessionID) return false
743
- if (!promptRef || !current.insert) return false
744
- completionKey = ""
745
- promptRef.set({ input: current.insert, parts: [] })
746
- promptRef.focus()
747
- setCompletion(undefined)
748
- return true
749
- }
750
-
751
- const run = async (sessionID: string) => {
752
- if (generating) return
753
- if (!isEnabled()) return
754
- if (currentSessionID() !== sessionID) return
755
- if (currentInput().trim()) return
756
- generating = true
757
- try {
758
- const text = await generate(sessionID)
759
- if (currentSessionID() !== sessionID) return
760
- if (currentInput().trim()) return
761
- if (!isEnabled()) return
762
- showGhost(text ? { sessionID, text } : undefined)
763
- } catch {
764
- // ignore background failures
765
- } finally {
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
- const plugin = {
941
- id: "ghost",
942
- tui,
943
- setup(ctx: any) {
944
- if (ctx && typeof ctx.slots?.register === "function") {
945
- return tui(ctx, ctx.options, ctx.meta)
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
+ })