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 +25 -1
- package/README.md +14 -11
- package/package.json +1 -1
- package/src/tui.tsx +143 -47
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.
|
|
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
|
|
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
|
|
@@ -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
|
|
120
|
-
|
|
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
|
-
-
|
|
129
|
-
|
|
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
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;
|
|
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
|
-
|
|
406
|
+
const pollTimer = setInterval(() => {
|
|
343
407
|
const sessionID = currentSessionID()
|
|
344
408
|
if (!sessionID) {
|
|
345
|
-
if (completion()
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
361
|
-
insert
|
|
362
|
-
|
|
363
|
-
hint: "hint" in found ? found.hint : undefined,
|
|
459
|
+
display,
|
|
460
|
+
insert,
|
|
461
|
+
drawn,
|
|
364
462
|
}
|
|
365
463
|
: undefined,
|
|
366
464
|
)
|
|
367
|
-
setAcceptVisible(Boolean(
|
|
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
|
|
617
|
-
const current =
|
|
618
|
-
return current && current.sessionID === props.session_id ? current
|
|
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
|
-
<
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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()
|