opencode-ghost 0.2.0 → 0.2.2

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,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.2] - 2026-09-25
11
+
12
+ ### Changed
13
+
14
+ - All ghosts (next-message suggestion and typing completions) render inside
15
+ the prompt box at the caret; the prompt's hint row (status line) is left
16
+ alone in every state.
17
+ - Typing ghosts are positioned from absolute screen coordinates (the prompt
18
+ editor's `screenX/screenY` plus the caret's visual column/row) instead of a
19
+ relative slot offset, and clear reliably when the input no longer matches.
20
+ - `GHOST_DEBUG=1` makes the plugin emit diagnostic toasts about ghost
21
+ placement (temporary, for localization feedback).
22
+ ## [0.2.1] - 2026-09-24
23
+
24
+ ### Changed
25
+
26
+ - Slash-command completion now stays out of the way of opencode's native slash
27
+ menu (the plugin only ghosts argument options after a complete `/name `).
28
+ - Typing ghosts (argument options, prompt history) render inside the prompt
29
+ input box right after the caret instead of the hint row, falling back to the
30
+ hint row when the prompt's editor renderable cannot be located.
10
31
  ## [0.2.0] - 2026-09-24
11
32
 
12
33
  ### Added
@@ -20,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20
41
  pulls the full line back.
21
42
  - New option `argHints` (per-command argument option lists for the completion
22
43
  hints).
44
+
23
45
  ## [0.1.1] - 2026-09-24
24
46
 
25
47
  ### Added
@@ -56,7 +78,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
56
78
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
57
79
  `recentMessages` and `system`.
58
80
 
59
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...HEAD
81
+ [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.2...HEAD
82
+ [0.2.2]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.1...v0.2.2
83
+ [0.2.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...v0.2.1
60
84
  [0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
61
85
  [0.1.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.0...v0.1.1
62
86
  [0.1.0]: https://github.com/ozandogrultan/opencode-ghost/releases/tag/v0.1.0
package/README.md CHANGED
@@ -17,14 +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 autocomplete while typing.** When the input starts with `/`,
21
- the remaining command name is ghosted from configured commands, skills, and
22
- the builtin TUI commands; known argument options render as `[a | b]` hints.
23
- Tab completes in stages (command name, then each argument). Purely local — no
24
- model calls.
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
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 as
27
- well, Tab pulls the full line back. Also purely local.
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.
28
28
  - **`/suggest` toggles it.** State is stored in the plugin KV.
29
29
 
30
30
  ## Requirements
@@ -116,8 +116,9 @@ When enabling, a suggestion is generated immediately for the current session.
116
116
  survives the server's automatic title generation, and a periodic sweep (plus
117
117
  shutdown cleanup) reaps any leftovers — including sessions orphaned by a
118
118
  crash — with no configuration required.
119
- - Renders the result through the `session_prompt` host slot into the prompt's
120
- `hint` row, and accepts it into the input via the prompt ref.
119
+ - Renders the result through the `session_prompt` host slot, ghosted inside
120
+ the prompt box at the caret, and accepts it into the input via the prompt
121
+ ref.
121
122
 
122
123
  ## Caveats
123
124
 
@@ -125,8 +126,10 @@ When enabling, a suggestion is generated immediately for the current session.
125
126
  through that slot, so this plugin replaces the default prompt component. It
126
127
  forwards `on_submit`, `ref`, `right`, `visible`, and `disabled`, and cannot be
127
128
  combined with another plugin that also renders `session_prompt`.
128
- - **`hint` row.** While a suggestion is showing it occupies the prompt's hint
129
- row, so the working-directory label is hidden until you accept or type.
129
+ - **Ghost placement.** All ghosts (next-message suggestion, typing
130
+ completions) render inside the prompt box right after the caret; opencode
131
+ exposes no inline-completion API, so the plugin overlays the prompt's
132
+ editor renderable and never touches the prompt's hint row.
130
133
  - **Cost.** One small-model call per turn while enabled. Point `model` at a
131
134
  cheap or free model.
132
135
  - **`acceptKeys`.** `tab` is also opencode's agent-cycle key; this plugin only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
package/src/tui.tsx CHANGED
@@ -19,6 +19,7 @@ const MAX_MESSAGE_CHARS = 800
19
19
  const MAX_TRANSCRIPT_CHARS = 6000
20
20
  const ENABLED_KEY = "ghost.enabled"
21
21
  const MODEL_KEY = "ghost.model"
22
+ const GHOST_DEBUG = Boolean(process.env.GHOST_DEBUG)
22
23
 
23
24
  const HIDDEN_TOOLS = {
24
25
  bash: false,
@@ -262,7 +263,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
262
263
 
263
264
  const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
264
265
  const [completion, setCompletion] = createSignal<
265
- { sessionID: string; ghost?: string; insert?: string; args?: string[]; hint?: string } | undefined
266
+ { sessionID: string; display: string; insert?: string; drawn: boolean } | undefined
266
267
  >()
267
268
  let promptRef: TuiPromptRef | undefined
268
269
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -336,35 +337,132 @@ const tui: TuiPlugin = async (api, rawOptions) => {
336
337
  return undefined
337
338
  }
338
339
 
340
+ // In-box ghost: an absolutely positioned text node rendered in the slot's
341
+ // own container, at the caret of the prompt's editor renderable (found by
342
+ // walking the layout tree). Uses the normal render pipeline, so it is visible
343
+ // wherever the prompt is. When the editor or container cannot be located
344
+ // (host layout change) completion falls back to the hint row.
345
+ const [inBox, setInBox] = createSignal<
346
+ { sessionID: string; x: number; y: number; text: string } | undefined
347
+ >()
348
+ let slotNode: { screenX?: number; screenY?: number } | undefined
349
+ // Locate the prompt's editor renderable inside the layout tree.
350
+ const findTextarea = (): unknown => {
351
+ const root = (api.renderer as unknown as { root?: unknown }).root as
352
+ | { getChildren?: () => unknown[] }
353
+ | undefined
354
+ if (!root) return undefined
355
+ const stack: unknown[] = [root]
356
+ while (stack.length) {
357
+ const node = stack.pop() as
358
+ | {
359
+ editBuffer?: unknown
360
+ editorView?: unknown
361
+ visualCursor?: { visualRow?: number; visualCol?: number }
362
+ screenX?: number
363
+ screenY?: number
364
+ getChildren?: () => unknown[]
365
+ }
366
+ | undefined
367
+ if (!node) continue
368
+ if (node.editBuffer && node.editorView && node.visualCursor) return node
369
+ const children = typeof node.getChildren === "function" ? node.getChildren() : []
370
+ stack.push(...children)
371
+ }
372
+ return undefined
373
+ }
374
+
375
+ const placeInBoxGhost = (sessionID: string, text: string): boolean => {
376
+ setInBox(undefined)
377
+ const textarea = findTextarea() as
378
+ | { screenX?: unknown; screenY?: unknown; visualCursor?: { visualRow?: number; visualCol?: number } }
379
+ | undefined
380
+ if (
381
+ !textarea ||
382
+ typeof textarea.screenX !== "number" ||
383
+ typeof textarea.screenY !== "number"
384
+ ) {
385
+ return false
386
+ }
387
+ const cursor = textarea.visualCursor ?? {}
388
+ // Overlay coordinates are relative to the slot container (it anchors the
389
+ // absolute overlay), so subtract its screen origin from the editor's.
390
+ if (
391
+ !slotNode ||
392
+ typeof slotNode.screenX !== "number" ||
393
+ typeof slotNode.screenY !== "number"
394
+ ) {
395
+ return false
396
+ }
397
+ const x = textarea.screenX - slotNode.screenX + (typeof cursor.visualCol === "number" ? cursor.visualCol : 0)
398
+ const y = textarea.screenY - slotNode.screenY + (typeof cursor.visualRow === "number" ? cursor.visualRow : 0)
399
+ setInBox({ sessionID, x, y, text })
400
+ return true
401
+ }
402
+
339
403
  const POLL_INTERVAL_MS = 100
340
404
  const POOL_TTL_MS = 60_000
341
405
  let completionKey = ""
342
- const pollTimer = setInterval(() => {
406
+ const pollTimer = setInterval(() => {
343
407
  const sessionID = currentSessionID()
344
408
  if (!sessionID) {
345
- if (completion()) setCompletion(undefined)
409
+ if (completion() || inBox()) {
410
+ setCompletion(undefined)
411
+ setInBox(undefined)
412
+ }
346
413
  return
347
414
  }
348
415
  const input = promptRef?.current.input ?? ""
349
416
  if (poolFetchedAt && Date.now() - poolFetchedAt > POOL_TTL_MS) void refreshPool()
350
- const key = `${sessionID}\u0000${input}`
351
- const found = input.startsWith("/")
352
- ? completeCommand(input, commandPool, opts.argHints)
353
- : historyCandidate(sessionID, input)
354
- if (completionKey === `${key}\u0000${found?.insert ?? ""}`) return
355
- completionKey = `${key}\u0000${found?.insert ?? ""}`
417
+ // `/name` with no space yet is opencode's native slash menu; the plugin
418
+ // stays out of its way. Our slash completion only covers `/name args...`.
419
+ const found = !input.startsWith("/")
420
+ ? historyCandidate(sessionID, input)
421
+ : input.includes(" ")
422
+ ? completeCommand(input, commandPool, opts.argHints)
423
+ : undefined
424
+ let display = ""
425
+ let insert = ""
426
+ if (found) {
427
+ const args = "args" in found ? found.args : undefined
428
+ const hint = "hint" in found ? found.hint : undefined
429
+ insert = found.insert ?? ""
430
+ display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ?? hint ?? "")
431
+ } else if (!input.trim()) {
432
+ // Empty prompt: the model's next-message suggestion is ghosted in the
433
+ // box as well (the accept layer's `accept()` handles Tab for it).
434
+ const next = ghost()
435
+ if (next && next.sessionID === sessionID) display = next.text
436
+ }
437
+ // Include the display in the key: a fresh suggestion for an unchanged
438
+ // (empty) input must still be re-placed.
439
+ const key = `${sessionID}\u0000${input}\u0000${display}`
440
+ if (completionKey === key) return
441
+ completionKey = key
442
+ const drawn = display ? placeInBoxGhost(sessionID, display) : false
443
+ if (!drawn) setInBox(undefined)
444
+ if (GHOST_DEBUG && (display || input.startsWith("/"))) {
445
+ const overlay = inBox()
446
+ try {
447
+ api.ui.toast({
448
+ title: "ghost-debug",
449
+ message: `display=${display.slice(0, 20) || "∅"} drawn=${drawn} slot=${slotNode ? `${slotNode.screenX}/${slotNode.screenY}` : "n"} box=${overlay ? `${overlay.x}/${overlay.y}` : "∅"}`,
450
+ })
451
+ } catch {
452
+ // ignore
453
+ }
454
+ }
356
455
  setCompletion(
357
456
  found
358
457
  ? {
359
458
  sessionID,
360
- ghost: found.ghost,
361
- insert: found.insert,
362
- args: "args" in found ? found.args : undefined,
363
- hint: "hint" in found ? found.hint : undefined,
459
+ display,
460
+ insert,
461
+ drawn,
364
462
  }
365
463
  : undefined,
366
464
  )
367
- setAcceptVisible(Boolean(found?.insert) || Boolean(ghost()))
465
+ setAcceptVisible(Boolean(insert) || Boolean(ghost()))
368
466
  }, POLL_INTERVAL_MS)
369
467
  pollTimer.unref?.()
370
468
 
@@ -613,42 +711,39 @@ const tui: TuiPlugin = async (api, rawOptions) => {
613
711
  order: 60,
614
712
  slots: {
615
713
  session_prompt(_ctx, props) {
616
- const suggestion = () => {
617
- const current = ghost()
618
- return current && current.sessionID === props.session_id ? current.text : ""
619
- }
620
- const completionText = () => {
621
- const current = completion()
622
- if (!current || current.sessionID !== props.session_id) return ""
623
- if (current.args && current.args.length > 0) return `[${current.args.join(" | ")}]`
624
- if (current.hint) return current.hint
625
- return current.ghost ?? ""
714
+ const overlay = () => {
715
+ const current = inBox()
716
+ return current && current.sessionID === props.session_id ? current : undefined
626
717
  }
627
718
  return (
628
- <api.ui.Prompt
629
- sessionID={props.session_id}
630
- visible={props.visible}
631
- disabled={props.disabled}
632
- showPlaceholder={false}
633
- hint={
634
- suggestion() || completionText() ? (
635
- <box marginLeft={1}>
636
- <text fg={api.theme.current.textMuted}>
637
- {completionText() || suggestion()}
638
- </text>
639
- </box>
640
- ) : undefined
641
- }
642
- right={<api.ui.Slot name="session_prompt_right" session_id={props.session_id} />}
643
- onSubmit={() => {
644
- showGhost(undefined)
645
- props.on_submit?.()
646
- }}
647
- ref={(ref) => {
648
- promptRef = ref
649
- props.ref?.(ref)
719
+ <box
720
+ position="relative"
721
+ ref={(ref: { screenX?: number; screenY?: number }) => {
722
+ slotNode = ref
650
723
  }}
651
- />
724
+ >
725
+ <api.ui.Prompt
726
+ sessionID={props.session_id}
727
+ visible={props.visible}
728
+ disabled={props.disabled}
729
+ showPlaceholder={false}
730
+ right={<api.ui.Slot name="session_prompt_right" session_id={props.session_id} />}
731
+ onSubmit={() => {
732
+ showGhost(undefined)
733
+ setInBox(undefined)
734
+ props.on_submit?.()
735
+ }}
736
+ ref={(ref) => {
737
+ promptRef = ref
738
+ props.ref?.(ref)
739
+ }}
740
+ />
741
+ {overlay() ? (
742
+ <box position="absolute" left={overlay()!.x} top={overlay()!.y} zIndex={100}>
743
+ <text fg={api.theme.current.textMuted}>{overlay()!.text}</text>
744
+ </box>
745
+ ) : undefined}
746
+ </box>
652
747
  )
653
748
  },
654
749
  },
@@ -686,6 +781,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
686
781
  if (timer) clearTimeout(timer)
687
782
  clearInterval(sweepTimer)
688
783
  clearInterval(pollTimer)
784
+ setInBox(undefined)
689
785
  offIdle()
690
786
  offStatus()
691
787
  backLayer()