opencode-ghost 0.2.1 → 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,18 @@ 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).
10
22
  ## [0.2.1] - 2026-09-24
11
23
 
12
24
  ### Changed
@@ -66,7 +78,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66
78
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
67
79
  `recentMessages` and `system`.
68
80
 
69
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.1...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
70
83
  [0.2.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...v0.2.1
71
84
  [0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
72
85
  [0.1.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -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,13 +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
- - **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.
133
- - **`hint` row.** While a suggestion is showing it occupies the prompt's hint
134
- 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.
135
133
  - **Cost.** One small-model call per turn while enabled. Point `model` at a
136
134
  cheap or free model.
137
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.1",
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,
@@ -336,29 +337,15 @@ const tui: TuiPlugin = async (api, rawOptions) => {
336
337
  return undefined
337
338
  }
338
339
 
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
-
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
362
349
  // Locate the prompt's editor renderable inside the layout tree.
363
350
  const findTextarea = (): unknown => {
364
351
  const root = (api.renderer as unknown as { root?: unknown }).root as
@@ -385,8 +372,8 @@ const tui: TuiPlugin = async (api, rawOptions) => {
385
372
  return undefined
386
373
  }
387
374
 
388
- const placeInBoxGhost = (text: string): boolean => {
389
- inBoxGhost = undefined
375
+ const placeInBoxGhost = (sessionID: string, text: string): boolean => {
376
+ setInBox(undefined)
390
377
  const textarea = findTextarea() as
391
378
  | { screenX?: unknown; screenY?: unknown; visualCursor?: { visualRow?: number; visualCol?: number } }
392
379
  | undefined
@@ -398,22 +385,31 @@ const tui: TuiPlugin = async (api, rawOptions) => {
398
385
  return false
399
386
  }
400
387
  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,
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
405
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 })
406
400
  return true
407
401
  }
408
402
 
409
403
  const POLL_INTERVAL_MS = 100
410
404
  const POOL_TTL_MS = 60_000
411
405
  let completionKey = ""
412
- const pollTimer = setInterval(() => {
406
+ const pollTimer = setInterval(() => {
413
407
  const sessionID = currentSessionID()
414
408
  if (!sessionID) {
415
- inBoxGhost = undefined
416
- if (completion()) setCompletion(undefined)
409
+ if (completion() || inBox()) {
410
+ setCompletion(undefined)
411
+ setInBox(undefined)
412
+ }
417
413
  return
418
414
  }
419
415
  const input = promptRef?.current.input ?? ""
@@ -425,18 +421,37 @@ const tui: TuiPlugin = async (api, rawOptions) => {
425
421
  : input.includes(" ")
426
422
  ? completeCommand(input, commandPool, opts.argHints)
427
423
  : 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
424
  let display = ""
425
+ let insert = ""
433
426
  if (found) {
434
427
  const args = "args" in found ? found.args : undefined
435
428
  const hint = "hint" in found ? found.hint : undefined
429
+ insert = found.insert ?? ""
436
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
+ }
437
454
  }
438
- inBoxGhost = undefined
439
- const drawn = display ? placeInBoxGhost(display) : false
440
455
  setCompletion(
441
456
  found
442
457
  ? {
@@ -696,42 +711,39 @@ const tui: TuiPlugin = async (api, rawOptions) => {
696
711
  order: 60,
697
712
  slots: {
698
713
  session_prompt(_ctx, props) {
699
- const suggestion = () => {
700
- const current = ghost()
701
- return current && current.sessionID === props.session_id ? current.text : ""
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
714
+ const overlay = () => {
715
+ const current = inBox()
716
+ return current && current.sessionID === props.session_id ? current : undefined
709
717
  }
710
718
  return (
711
- <api.ui.Prompt
712
- sessionID={props.session_id}
713
- visible={props.visible}
714
- disabled={props.disabled}
715
- showPlaceholder={false}
716
- hint={
717
- suggestion() || completionText() ? (
718
- <box marginLeft={1}>
719
- <text fg={api.theme.current.textMuted}>
720
- {completionText() || suggestion()}
721
- </text>
722
- </box>
723
- ) : undefined
724
- }
725
- right={<api.ui.Slot name="session_prompt_right" session_id={props.session_id} />}
726
- onSubmit={() => {
727
- showGhost(undefined)
728
- props.on_submit?.()
729
- }}
730
- ref={(ref) => {
731
- promptRef = ref
732
- props.ref?.(ref)
719
+ <box
720
+ position="relative"
721
+ ref={(ref: { screenX?: number; screenY?: number }) => {
722
+ slotNode = ref
733
723
  }}
734
- />
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>
735
747
  )
736
748
  },
737
749
  },
@@ -769,7 +781,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
769
781
  if (timer) clearTimeout(timer)
770
782
  clearInterval(sweepTimer)
771
783
  clearInterval(pollTimer)
772
- removeOverlay()
784
+ setInBox(undefined)
773
785
  offIdle()
774
786
  offStatus()
775
787
  backLayer()