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 +12 -1
- package/README.md +12 -7
- package/package.json +1 -1
- package/src/tui.tsx +98 -14
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.
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Tab completes
|
|
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
|
|
27
|
-
well
|
|
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
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;
|
|
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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
361
|
-
insert
|
|
362
|
-
|
|
363
|
-
hint: "hint" in found ? found.hint : undefined,
|
|
444
|
+
display,
|
|
445
|
+
insert,
|
|
446
|
+
drawn,
|
|
364
447
|
}
|
|
365
448
|
: undefined,
|
|
366
449
|
)
|
|
367
|
-
setAcceptVisible(Boolean(
|
|
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
|
-
|
|
624
|
-
if (current.
|
|
625
|
-
return current.
|
|
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()
|