opencode-ghost 0.1.1 → 0.2.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 CHANGED
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-09-24
11
+
12
+ ### Changed
13
+
14
+ - Slash-command completion now stays out of the way of opencode's native slash
15
+ menu (the plugin only ghosts argument options after a complete `/name `).
16
+ - Typing ghosts (argument options, prompt history) render inside the prompt
17
+ input box right after the caret instead of the hint row, falling back to the
18
+ hint row when the prompt's editor renderable cannot be located.
19
+ ## [0.2.0] - 2026-09-24
20
+
21
+ ### Added
22
+
23
+ - Slash-command autocomplete while typing: the remaining command name is
24
+ ghosted from configured commands, skills, and builtin TUI commands, with
25
+ known argument options shown as `[a | b]` hints and completed with `Tab`
26
+ (staged: command name, then arguments). Purely local — no model calls.
27
+ - Prompt-history ghost while typing: if the line being typed is a prefix of a
28
+ message already sent in the same session, the rest is ghosted and `Tab`
29
+ pulls the full line back.
30
+ - New option `argHints` (per-command argument option lists for the completion
31
+ hints).
32
+
10
33
  ## [0.1.1] - 2026-09-24
11
34
 
12
35
  ### Added
@@ -43,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
43
66
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
44
67
  `recentMessages` and `system`.
45
68
 
46
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...HEAD
69
+ [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.1...HEAD
70
+ [0.2.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...v0.2.1
71
+ [0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
47
72
  [0.1.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.0...v0.1.1
48
73
  [0.1.0]: https://github.com/ozandogrultan/opencode-ghost/releases/tag/v0.1.0
package/README.md CHANGED
@@ -17,6 +17,14 @@ dimmed ghost text you accept with <kbd>Tab</kbd> (or <kbd>→</kbd>).
17
17
  default) for one line that sounds like you.
18
18
  - **Ghost text, `Tab` to accept.** The suggestion renders under the prompt;
19
19
  `Tab` or `→` drops it into the input so you can edit and send.
20
+ - **Slash-command argument ghosts while typing.** After `/name `, argument
21
+ option hints (from `argHints` or learned from builtins) render inside the
22
+ prompt input box, right after the caret, like Claude Code's inline ghosts.
23
+ Tab completes the next argument. While the command name is still being typed,
24
+ opencode's own slash menu stays in charge. Purely local — no model calls.
25
+ - **History ghost while typing.** If the line you are typing is a prefix of a
26
+ message you already sent in the same session, the rest of it is ghosted in
27
+ the box as well; Tab pulls the full line back. Also purely local.
20
28
  - **`/suggest` toggles it.** State is stored in the plugin KV.
21
29
 
22
30
  ## Requirements
@@ -89,6 +97,7 @@ Pass an options object as the second element of the plugin tuple:
89
97
  | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
90
98
  | `backOnEmptyLeft` | `boolean` | `false` | Return to the home screen with Left when the prompt is empty. |
91
99
  | `internalSessionMarkerDir` | `string` | unset | Optional extra crash-recovery tracking. A configuration-free sweep already reaps leftover hidden sessions, so this is only an additional safety net. |
100
+ | `argHints` | `Record<string, string[]>` | `{}` | Argument option lists per slash command, shown as `[a \| b \| c]` after the command name and completed with Tab, e.g. `"/keepwarm": ["6h", "always", "off", "status"]`. |
92
101
 
93
102
  ## Commands
94
103
 
@@ -116,6 +125,11 @@ When enabling, a suggestion is generated immediately for the current session.
116
125
  through that slot, so this plugin replaces the default prompt component. It
117
126
  forwards `on_submit`, `ref`, `right`, `visible`, and `disabled`, and cannot be
118
127
  combined with another plugin that also renders `session_prompt`.
128
+ - **Ghost placement.** Typing ghosts are drawn directly into the prompt
129
+ buffer after the caret (opencode exposes no inline-completion API, so the
130
+ plugin overlays the prompt's editor renderable). When the layout cannot be
131
+ located, they fall back to the prompt's hint row. The next-prompt
132
+ suggestion always uses the hint row.
119
133
  - **`hint` row.** While a suggestion is showing it occupies the prompt's hint
120
134
  row, so the working-directory label is hidden until you accept or type.
121
135
  - **Cost.** One small-model call per turn while enabled. Point `model` at a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Builtin TUI slash commands. The plugin API does not expose the TUI's own
3
+ * command palette, so the completable subset we observed in the shipped binary
4
+ * is curated here. Names are fixed; descriptions are ours and only used for the
5
+ * completion display.
6
+ */
7
+ export type BuiltinCommand = {
8
+ name: string
9
+ description: string
10
+ }
11
+
12
+ export const BUILTIN_COMMANDS: BuiltinCommand[] = [
13
+ { name: "new", description: "New session" },
14
+ { name: "agent", description: "Cycle agent" },
15
+ { name: "model", description: "Choose model" },
16
+ { name: "mcp", description: "Toggle MCP servers" },
17
+ { name: "open", description: "Command palette" },
18
+ { name: "share", description: "Share session" },
19
+ { name: "unshare", description: "Unshare session" },
20
+ { name: "compact", description: "Compact session" },
21
+ { name: "export", description: "Export session" },
22
+ { name: "fork", description: "Fork session" },
23
+ { name: "undo", description: "Undo messages" },
24
+ { name: "redo", description: "Redo messages" },
25
+ { name: "terminal", description: "Toggle terminal" },
26
+ { name: "workspace", description: "Workspace controls" },
27
+ { name: "suggest", description: "Toggle prompt suggestions" },
28
+ ]
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure slash-command completion: given the current prompt input, the pool of
3
+ * known command names (configured commands, skills, and builtins) and optional
4
+ * per-command argument hints, decide what ghost text to render and what Tab
5
+ * should insert.
6
+ */
7
+ export type CommandPool = {
8
+ readonly name: string
9
+ readonly description?: string
10
+ }
11
+
12
+ export type Completion = {
13
+ /** Dimmed text rendered after the input, e.g. `warm` for `/keep`+`warm`. */
14
+ ghost?: string
15
+ /** What Tab places into the input when accepting. */
16
+ insert?: string
17
+ /** Argument options shown as `[a | b]`, filtered by the word in progress. */
18
+ args?: string[]
19
+ /** Non-insertable hint text shown when nothing can be completed. */
20
+ hint?: string
21
+ }
22
+
23
+ /** Parse leading `/name args...` from the input; `args` is "" until a space exists. */
24
+ function parseSlash(input: string): { name: string; args: string; nameComplete: boolean } | undefined {
25
+ if (!input.startsWith("/")) return undefined
26
+ const line = input.split("\n")[0]
27
+ const space = line.indexOf(" ")
28
+ if (space === -1) return { name: line.slice(1), args: "", nameComplete: false }
29
+ return { name: line.slice(1, space), args: line.slice(space + 1), nameComplete: true }
30
+ }
31
+
32
+ /** Options that still match the word being typed, excluding an exact hit. */
33
+ function matchOptions(typed: string, options: readonly string[]): string[] | undefined {
34
+ const lower = typed.toLowerCase()
35
+ const matches = options.filter(
36
+ (option) => option.toLowerCase() !== lower && option.toLowerCase().startsWith(lower),
37
+ )
38
+ return matches.length > 0 ? matches : undefined
39
+ }
40
+
41
+ export function completeCommand(
42
+ input: string,
43
+ pool: readonly CommandPool[],
44
+ argHints: Readonly<Record<string, readonly string[]>> = {},
45
+ ): Completion | undefined {
46
+ const parsed = parseSlash(input)
47
+ if (!parsed) return undefined
48
+ const lower = parsed.name.toLowerCase()
49
+ const exact = pool.find((command) => command.name.toLowerCase() === lower)
50
+
51
+ // Still typing the command name (no space yet).
52
+ if (!parsed.nameComplete) {
53
+ if (exact) {
54
+ const hints = argHints[lower]
55
+ return {
56
+ args: hints ? [...hints] : undefined,
57
+ hint: hints ? undefined : exact.description,
58
+ insert: `/${exact.name} `,
59
+ }
60
+ }
61
+ const matches = pool.filter((command) => command.name.toLowerCase().startsWith(lower))
62
+ if (matches.length === 0) return undefined
63
+ const next = matches[0]
64
+ return { ghost: next.name.slice(parsed.name.length), insert: `/${next.name} ` }
65
+ }
66
+
67
+ // `/name args...`: complete the in-progress argument word against hints.
68
+ const hintList = argHints[lower]
69
+ if (hintList) {
70
+ const matches = matchOptions(parsed.args, hintList)
71
+ if (matches) {
72
+ const completed = parsed.args + matches[0].slice(parsed.args.length)
73
+ return {
74
+ args: matches,
75
+ ghost: matches[0].slice(parsed.args.length),
76
+ insert: `/${parsed.name} ${completed} `,
77
+ }
78
+ }
79
+ return undefined
80
+ }
81
+ if (exact && parsed.args === "") return { hint: exact.description }
82
+ return undefined
83
+ }
package/src/options.ts CHANGED
@@ -8,6 +8,7 @@ export type PromptSuggestOptions = {
8
8
  idleDelayMs?: number
9
9
  recentMessages?: number
10
10
  system?: string
11
+ argHints?: Record<string, string[]>
11
12
  }
12
13
 
13
14
  export type ResolvedOptions = {
@@ -20,6 +21,7 @@ export type ResolvedOptions = {
20
21
  idleDelayMs: number
21
22
  recentMessages: number
22
23
  system: string
24
+ argHints: Record<string, readonly string[]>
23
25
  }
24
26
 
25
27
  export const DEFAULT_SYSTEM = [
@@ -77,5 +79,13 @@ export function resolveOptions(options: PromptSuggestOptions | undefined): Resol
77
79
  ? recentMessages
78
80
  : DEFAULTS.recentMessages,
79
81
  system: typeof input.system === "string" && input.system.trim() ? input.system : DEFAULT_SYSTEM,
82
+ argHints:
83
+ input.argHints && typeof input.argHints === "object" && !Array.isArray(input.argHints)
84
+ ? Object.fromEntries(
85
+ Object.entries(input.argHints)
86
+ .filter(([, value]) => Array.isArray(value) && value.length > 0)
87
+ .map(([key, value]) => [key.toLowerCase(), [...value]]),
88
+ )
89
+ : {},
80
90
  }
81
91
  }
package/src/tui.tsx CHANGED
@@ -5,6 +5,8 @@ import * as fs from "node:fs"
5
5
  import * as path from "node:path"
6
6
  import { resolveOptions, type PromptSuggestOptions } from "./options"
7
7
  import { clip, isEcho, normalize, parseModel } from "./text"
8
+ import { BUILTIN_COMMANDS } from "./builtins"
9
+ import { completeCommand, type CommandPool } from "./completion"
8
10
 
9
11
  const HIDDEN_TITLE = "ghost-hidden"
10
12
  // Survives the server's automatic title generation, which renames the hidden
@@ -259,6 +261,9 @@ const tui: TuiPlugin = async (api, rawOptions) => {
259
261
  sweepTimer.unref?.()
260
262
 
261
263
  const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
264
+ const [completion, setCompletion] = createSignal<
265
+ { sessionID: string; display: string; insert?: string; drawn: boolean } | undefined
266
+ >()
262
267
  let promptRef: TuiPromptRef | undefined
263
268
  let timer: ReturnType<typeof setTimeout> | undefined
264
269
  let generating = false
@@ -273,6 +278,179 @@ const tui: TuiPlugin = async (api, rawOptions) => {
273
278
 
274
279
  const currentInput = () => promptRef?.current.input ?? ""
275
280
 
281
+ // Completable commands: configured commands/skills from the server layered
282
+ // under the builtin TUI slash commands. One config read, refreshed lazily; no
283
+ // model calls are involved anywhere in completion.
284
+ let commandPool: CommandPool[] = BUILTIN_COMMANDS
285
+ let poolFetching = false
286
+ let poolFetchedAt = 0
287
+ const refreshPool = async () => {
288
+ if (poolFetching) return
289
+ poolFetching = true
290
+ try {
291
+ const result = await api.client.command.list()
292
+ const rows = (result as { data?: unknown }).data
293
+ if (Array.isArray(rows)) {
294
+ const configured: CommandPool[] = rows
295
+ .map((row) => row as { name?: unknown; description?: unknown })
296
+ .filter((row) => typeof row.name === "string" && row.name.length > 0)
297
+ .map((row) => ({
298
+ name: row.name as string,
299
+ description: typeof row.description === "string" ? row.description : undefined,
300
+ }))
301
+ const seen = new Set(configured.map((command) => `${command.name.toLowerCase()}`))
302
+ const merged = [
303
+ ...configured,
304
+ ...BUILTIN_COMMANDS.filter((command) => !seen.has(command.name.toLowerCase())),
305
+ ]
306
+ commandPool = merged.length > 0 ? merged : BUILTIN_COMMANDS
307
+ poolFetchedAt = Date.now()
308
+ }
309
+ } catch {
310
+ // Keep the current pool; the poll loop retries on the next TTL window.
311
+ } finally {
312
+ poolFetching = false
313
+ }
314
+ }
315
+ void refreshPool()
316
+
317
+ // History ghost while typing: if the input is a prefix of a message the user
318
+ // already sent in this session, offer the rest of it. No model involved.
319
+ const historyCandidate = (sessionID: string, input: string) => {
320
+ const wants = input.replace(/\s+/g, " ").trimEnd()
321
+ if (wants.length < 2 || /\n/.test(input)) return undefined
322
+ const collapsed = wants.toLowerCase()
323
+ const messages = api.state.session.messages(sessionID).filter((m) => m.role === "user")
324
+ for (let i = messages.length - 1; i >= 0; i--) {
325
+ const text = api.state
326
+ .part(messages[i].id)
327
+ .filter((part) => part.type === "text")
328
+ .map((part) => (part.type === "text" ? part.text : ""))
329
+ .join(" ")
330
+ .replace(/\s+/g, " ")
331
+ .trim()
332
+ if (text.length <= collapsed.length) continue
333
+ if (!text.toLowerCase().startsWith(collapsed)) continue
334
+ return { ghost: text.slice(collapsed.length), insert: text }
335
+ }
336
+ return undefined
337
+ }
338
+
339
+ // In-box ghost: the suggestion is drawn directly into the prompt's text area
340
+ // right after the caret, refreshed each frame. When the text area cannot be
341
+ // located (host layout change), completion falls back to the hint row.
342
+ let inBoxGhost: { x: number; y: number; text: string } | undefined
343
+ const drawGhost = (buffer: unknown) => {
344
+ const target = inBoxGhost
345
+ if (!target) return
346
+ try {
347
+ const drawn = buffer as { drawText?: (text: string, x: number, y: number, fg: unknown) => void }
348
+ drawn.drawText?.(target.text, target.x, target.y, api.theme.current.textMuted)
349
+ } catch {
350
+ // Rendering must never fail because of the overlay.
351
+ }
352
+ }
353
+ const postProcesses = (api.renderer as unknown as { postProcessFns?: unknown[] } | undefined)
354
+ ?.postProcessFns
355
+ if (Array.isArray(postProcesses)) postProcesses.push(drawGhost)
356
+ const removeOverlay = () => {
357
+ if (!postProcesses) return
358
+ const index = postProcesses.indexOf(drawGhost)
359
+ if (index >= 0) postProcesses.splice(index, 1)
360
+ }
361
+
362
+ // Locate the prompt's editor renderable inside the layout tree.
363
+ const findTextarea = (): unknown => {
364
+ const root = (api.renderer as unknown as { root?: unknown }).root as
365
+ | { getChildren?: () => unknown[] }
366
+ | undefined
367
+ if (!root) return undefined
368
+ const stack: unknown[] = [root]
369
+ while (stack.length) {
370
+ const node = stack.pop() as
371
+ | {
372
+ editBuffer?: unknown
373
+ editorView?: unknown
374
+ visualCursor?: { visualRow?: number; visualCol?: number }
375
+ screenX?: number
376
+ screenY?: number
377
+ getChildren?: () => unknown[]
378
+ }
379
+ | undefined
380
+ if (!node) continue
381
+ if (node.editBuffer && node.editorView && node.visualCursor) return node
382
+ const children = typeof node.getChildren === "function" ? node.getChildren() : []
383
+ stack.push(...children)
384
+ }
385
+ return undefined
386
+ }
387
+
388
+ const placeInBoxGhost = (text: string): boolean => {
389
+ inBoxGhost = undefined
390
+ const textarea = findTextarea() as
391
+ | { screenX?: unknown; screenY?: unknown; visualCursor?: { visualRow?: number; visualCol?: number } }
392
+ | undefined
393
+ if (
394
+ !textarea ||
395
+ typeof textarea.screenX !== "number" ||
396
+ typeof textarea.screenY !== "number"
397
+ ) {
398
+ return false
399
+ }
400
+ const cursor = textarea.visualCursor ?? {}
401
+ inBoxGhost = {
402
+ x: textarea.screenX + (typeof cursor.visualCol === "number" ? cursor.visualCol : 0),
403
+ y: textarea.screenY + (typeof cursor.visualRow === "number" ? cursor.visualRow : 0),
404
+ text,
405
+ }
406
+ return true
407
+ }
408
+
409
+ const POLL_INTERVAL_MS = 100
410
+ const POOL_TTL_MS = 60_000
411
+ let completionKey = ""
412
+ const pollTimer = setInterval(() => {
413
+ const sessionID = currentSessionID()
414
+ if (!sessionID) {
415
+ inBoxGhost = undefined
416
+ if (completion()) setCompletion(undefined)
417
+ return
418
+ }
419
+ const input = promptRef?.current.input ?? ""
420
+ if (poolFetchedAt && Date.now() - poolFetchedAt > POOL_TTL_MS) void refreshPool()
421
+ // `/name` with no space yet is opencode's native slash menu; the plugin
422
+ // stays out of its way. Our slash completion only covers `/name args...`.
423
+ const found = !input.startsWith("/")
424
+ ? historyCandidate(sessionID, input)
425
+ : input.includes(" ")
426
+ ? completeCommand(input, commandPool, opts.argHints)
427
+ : undefined
428
+ const key = `${sessionID}\u0000${input}`
429
+ const insert = found?.insert ?? ""
430
+ if (completionKey === `${key}\u0000${insert}`) return
431
+ completionKey = `${key}\u0000${insert}`
432
+ let display = ""
433
+ if (found) {
434
+ const args = "args" in found ? found.args : undefined
435
+ const hint = "hint" in found ? found.hint : undefined
436
+ display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ?? hint ?? "")
437
+ }
438
+ inBoxGhost = undefined
439
+ const drawn = display ? placeInBoxGhost(display) : false
440
+ setCompletion(
441
+ found
442
+ ? {
443
+ sessionID,
444
+ display,
445
+ insert,
446
+ drawn,
447
+ }
448
+ : undefined,
449
+ )
450
+ setAcceptVisible(Boolean(insert) || Boolean(ghost()))
451
+ }, POLL_INTERVAL_MS)
452
+ pollTimer.unref?.()
453
+
276
454
  const isEnabled = () => {
277
455
  try {
278
456
  return api.kv.get<boolean>(ENABLED_KEY, opts.enabled) !== false
@@ -400,11 +578,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
400
578
  priority: 100,
401
579
  bindings: opts.acceptKeys.map((key) => ({
402
580
  key,
403
- desc: "Accept suggested prompt",
581
+ desc: "Accept suggested prompt or completion",
404
582
  preventDefault: true,
405
583
  cmd: () => {
406
- accept()
407
- return true
584
+ if (completionAccept()) return true
585
+ return accept()
408
586
  },
409
587
  })),
410
588
  })
@@ -415,12 +593,30 @@ const tui: TuiPlugin = async (api, rawOptions) => {
415
593
  acceptLayer = undefined
416
594
  }
417
595
 
418
- const showGhost = (value: { sessionID: string; text: string } | undefined) => {
419
- setGhost(value)
596
+ let acceptVisible = false
597
+ const setAcceptVisible = (value: boolean) => {
598
+ if (acceptVisible === value) return
599
+ acceptVisible = value
420
600
  if (value) enableAccept()
421
601
  else disableAccept()
422
602
  }
423
603
 
604
+ const showGhost = (value: { sessionID: string; text: string } | undefined) => {
605
+ setGhost(value)
606
+ setAcceptVisible(Boolean(value) || Boolean(completion()?.insert))
607
+ }
608
+
609
+ const completionAccept = () => {
610
+ const current = completion()
611
+ if (!current || currentSessionID() !== current.sessionID) return false
612
+ if (!promptRef || !current.insert) return false
613
+ completionKey = ""
614
+ promptRef.set({ input: current.insert, parts: [] })
615
+ promptRef.focus()
616
+ setCompletion(undefined)
617
+ return true
618
+ }
619
+
424
620
  const run = async (sessionID: string) => {
425
621
  if (generating) return
426
622
  if (!isEnabled()) return
@@ -504,6 +700,13 @@ const tui: TuiPlugin = async (api, rawOptions) => {
504
700
  const current = ghost()
505
701
  return current && current.sessionID === props.session_id ? current.text : ""
506
702
  }
703
+ const completionText = () => {
704
+ const current = completion()
705
+ if (!current || current.sessionID !== props.session_id) return ""
706
+ // In in-box mode `drawGhost` handles the text; the hint row then stays free.
707
+ if (current.drawn) return ""
708
+ return current.display
709
+ }
507
710
  return (
508
711
  <api.ui.Prompt
509
712
  sessionID={props.session_id}
@@ -511,9 +714,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
511
714
  disabled={props.disabled}
512
715
  showPlaceholder={false}
513
716
  hint={
514
- suggestion() ? (
717
+ suggestion() || completionText() ? (
515
718
  <box marginLeft={1}>
516
- <text fg={api.theme.current.textMuted}>{suggestion()}</text>
719
+ <text fg={api.theme.current.textMuted}>
720
+ {completionText() || suggestion()}
721
+ </text>
517
722
  </box>
518
723
  ) : undefined
519
724
  }
@@ -563,6 +768,8 @@ const tui: TuiPlugin = async (api, rawOptions) => {
563
768
  api.lifecycle.onDispose(() => {
564
769
  if (timer) clearTimeout(timer)
565
770
  clearInterval(sweepTimer)
771
+ clearInterval(pollTimer)
772
+ removeOverlay()
566
773
  offIdle()
567
774
  offStatus()
568
775
  backLayer()