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 +14 -1
- package/README.md +7 -9
- package/package.json +1 -1
- package/src/tui.tsx +84 -72
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.
|
|
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
|
|
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,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.**
|
|
129
|
-
|
|
130
|
-
plugin overlays the prompt's
|
|
131
|
-
|
|
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
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:
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
406
|
+
const pollTimer = setInterval(() => {
|
|
413
407
|
const sessionID = currentSessionID()
|
|
414
408
|
if (!sessionID) {
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
700
|
-
const current =
|
|
701
|
-
return current && current.sessionID === props.session_id ? current
|
|
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
|
-
<
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
784
|
+
setInBox(undefined)
|
|
773
785
|
offIdle()
|
|
774
786
|
offStatus()
|
|
775
787
|
backLayer()
|