opencode-ghost 0.2.0 → 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,15 @@ 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.
10
19
  ## [0.2.0] - 2026-09-24
11
20
 
12
21
  ### Added
@@ -20,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
20
29
  pulls the full line back.
21
30
  - New option `argHints` (per-command argument option lists for the completion
22
31
  hints).
32
+
23
33
  ## [0.1.1] - 2026-09-24
24
34
 
25
35
  ### Added
@@ -56,7 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
56
66
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
57
67
  `recentMessages` and `system`.
58
68
 
59
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...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
60
71
  [0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
61
72
  [0.1.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.0...v0.1.1
62
73
  [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
@@ -125,6 +125,11 @@ When enabling, a suggestion is generated immediately for the current session.
125
125
  through that slot, so this plugin replaces the default prompt component. It
126
126
  forwards `on_submit`, `ref`, `right`, `visible`, and `disabled`, and cannot be
127
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.
128
133
  - **`hint` row.** While a suggestion is showing it occupies the prompt's hint
129
134
  row, so the working-directory label is hidden until you accept or type.
130
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.2.0",
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",
package/src/tui.tsx CHANGED
@@ -262,7 +262,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
262
262
 
263
263
  const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
264
264
  const [completion, setCompletion] = createSignal<
265
- { sessionID: string; ghost?: string; insert?: string; args?: string[]; hint?: string } | undefined
265
+ { sessionID: string; display: string; insert?: string; drawn: boolean } | undefined
266
266
  >()
267
267
  let promptRef: TuiPromptRef | undefined
268
268
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -336,35 +336,118 @@ const tui: TuiPlugin = async (api, rawOptions) => {
336
336
  return undefined
337
337
  }
338
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
+
339
409
  const POLL_INTERVAL_MS = 100
340
410
  const POOL_TTL_MS = 60_000
341
411
  let completionKey = ""
342
412
  const pollTimer = setInterval(() => {
343
413
  const sessionID = currentSessionID()
344
414
  if (!sessionID) {
415
+ inBoxGhost = undefined
345
416
  if (completion()) setCompletion(undefined)
346
417
  return
347
418
  }
348
419
  const input = promptRef?.current.input ?? ""
349
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
350
428
  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 ?? ""}`
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
356
440
  setCompletion(
357
441
  found
358
442
  ? {
359
443
  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,
444
+ display,
445
+ insert,
446
+ drawn,
364
447
  }
365
448
  : undefined,
366
449
  )
367
- setAcceptVisible(Boolean(found?.insert) || Boolean(ghost()))
450
+ setAcceptVisible(Boolean(insert) || Boolean(ghost()))
368
451
  }, POLL_INTERVAL_MS)
369
452
  pollTimer.unref?.()
370
453
 
@@ -620,9 +703,9 @@ const tui: TuiPlugin = async (api, rawOptions) => {
620
703
  const completionText = () => {
621
704
  const current = completion()
622
705
  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 ?? ""
706
+ // In in-box mode `drawGhost` handles the text; the hint row then stays free.
707
+ if (current.drawn) return ""
708
+ return current.display
626
709
  }
627
710
  return (
628
711
  <api.ui.Prompt
@@ -686,6 +769,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
686
769
  if (timer) clearTimeout(timer)
687
770
  clearInterval(sweepTimer)
688
771
  clearInterval(pollTimer)
772
+ removeOverlay()
689
773
  offIdle()
690
774
  offStatus()
691
775
  backLayer()