opencode-ghost 0.2.1 → 0.2.3
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 +19 -1
- package/README.md +7 -9
- package/package.json +1 -1
- package/src/tui.tsx +72 -72
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.3] - 2026-09-25
|
|
11
|
+
|
|
12
|
+
### Removed
|
|
13
|
+
|
|
14
|
+
- The temporary `GHOST_DEBUG` diagnostic toasts introduced while localizing
|
|
15
|
+
the in-box ghost overlay.
|
|
16
|
+
## [0.2.2] - 2026-09-25
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- All ghosts (next-message suggestion and typing completions) render inside
|
|
21
|
+
the prompt box at the caret; the prompt's hint row (status line) is left
|
|
22
|
+
alone in every state.
|
|
23
|
+
- Typing ghosts are positioned from absolute screen coordinates (the prompt
|
|
24
|
+
editor's `screenX/screenY` plus the caret's visual column/row) instead of a
|
|
25
|
+
relative slot offset, and clear reliably when the input no longer matches.
|
|
10
26
|
## [0.2.1] - 2026-09-24
|
|
11
27
|
|
|
12
28
|
### Changed
|
|
@@ -66,7 +82,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
66
82
|
- Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
|
|
67
83
|
`recentMessages` and `system`.
|
|
68
84
|
|
|
69
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.
|
|
85
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.3...HEAD
|
|
86
|
+
[0.2.3]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.2...v0.2.3
|
|
87
|
+
[0.2.2]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.1...v0.2.2
|
|
70
88
|
[0.2.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...v0.2.1
|
|
71
89
|
[0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
|
|
72
90
|
[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
|
@@ -336,29 +336,15 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
336
336
|
return undefined
|
|
337
337
|
}
|
|
338
338
|
|
|
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
|
-
|
|
339
|
+
// In-box ghost: an absolutely positioned text node rendered in the slot's
|
|
340
|
+
// own container, at the caret of the prompt's editor renderable (found by
|
|
341
|
+
// walking the layout tree). Uses the normal render pipeline, so it is visible
|
|
342
|
+
// wherever the prompt is. When the editor or container cannot be located
|
|
343
|
+
// (host layout change) completion falls back to the hint row.
|
|
344
|
+
const [inBox, setInBox] = createSignal<
|
|
345
|
+
{ sessionID: string; x: number; y: number; text: string } | undefined
|
|
346
|
+
>()
|
|
347
|
+
let slotNode: { screenX?: number; screenY?: number } | undefined
|
|
362
348
|
// Locate the prompt's editor renderable inside the layout tree.
|
|
363
349
|
const findTextarea = (): unknown => {
|
|
364
350
|
const root = (api.renderer as unknown as { root?: unknown }).root as
|
|
@@ -385,8 +371,8 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
385
371
|
return undefined
|
|
386
372
|
}
|
|
387
373
|
|
|
388
|
-
const placeInBoxGhost = (text: string): boolean => {
|
|
389
|
-
|
|
374
|
+
const placeInBoxGhost = (sessionID: string, text: string): boolean => {
|
|
375
|
+
setInBox(undefined)
|
|
390
376
|
const textarea = findTextarea() as
|
|
391
377
|
| { screenX?: unknown; screenY?: unknown; visualCursor?: { visualRow?: number; visualCol?: number } }
|
|
392
378
|
| undefined
|
|
@@ -398,22 +384,31 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
398
384
|
return false
|
|
399
385
|
}
|
|
400
386
|
const cursor = textarea.visualCursor ?? {}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
387
|
+
// Overlay coordinates are relative to the slot container (it anchors the
|
|
388
|
+
// absolute overlay), so subtract its screen origin from the editor's.
|
|
389
|
+
if (
|
|
390
|
+
!slotNode ||
|
|
391
|
+
typeof slotNode.screenX !== "number" ||
|
|
392
|
+
typeof slotNode.screenY !== "number"
|
|
393
|
+
) {
|
|
394
|
+
return false
|
|
405
395
|
}
|
|
396
|
+
const x = textarea.screenX - slotNode.screenX + (typeof cursor.visualCol === "number" ? cursor.visualCol : 0)
|
|
397
|
+
const y = textarea.screenY - slotNode.screenY + (typeof cursor.visualRow === "number" ? cursor.visualRow : 0)
|
|
398
|
+
setInBox({ sessionID, x, y, text })
|
|
406
399
|
return true
|
|
407
400
|
}
|
|
408
401
|
|
|
409
402
|
const POLL_INTERVAL_MS = 100
|
|
410
403
|
const POOL_TTL_MS = 60_000
|
|
411
404
|
let completionKey = ""
|
|
412
|
-
|
|
405
|
+
const pollTimer = setInterval(() => {
|
|
413
406
|
const sessionID = currentSessionID()
|
|
414
407
|
if (!sessionID) {
|
|
415
|
-
|
|
416
|
-
|
|
408
|
+
if (completion() || inBox()) {
|
|
409
|
+
setCompletion(undefined)
|
|
410
|
+
setInBox(undefined)
|
|
411
|
+
}
|
|
417
412
|
return
|
|
418
413
|
}
|
|
419
414
|
const input = promptRef?.current.input ?? ""
|
|
@@ -425,18 +420,26 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
425
420
|
: input.includes(" ")
|
|
426
421
|
? completeCommand(input, commandPool, opts.argHints)
|
|
427
422
|
: 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
423
|
let display = ""
|
|
424
|
+
let insert = ""
|
|
433
425
|
if (found) {
|
|
434
426
|
const args = "args" in found ? found.args : undefined
|
|
435
427
|
const hint = "hint" in found ? found.hint : undefined
|
|
428
|
+
insert = found.insert ?? ""
|
|
436
429
|
display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ?? hint ?? "")
|
|
430
|
+
} else if (!input.trim()) {
|
|
431
|
+
// Empty prompt: the model's next-message suggestion is ghosted in the
|
|
432
|
+
// box as well (the accept layer's `accept()` handles Tab for it).
|
|
433
|
+
const next = ghost()
|
|
434
|
+
if (next && next.sessionID === sessionID) display = next.text
|
|
437
435
|
}
|
|
438
|
-
|
|
439
|
-
|
|
436
|
+
// Include the display in the key: a fresh suggestion for an unchanged
|
|
437
|
+
// (empty) input must still be re-placed.
|
|
438
|
+
const key = `${sessionID}\u0000${input}\u0000${display}`
|
|
439
|
+
if (completionKey === key) return
|
|
440
|
+
completionKey = key
|
|
441
|
+
const drawn = display ? placeInBoxGhost(sessionID, display) : false
|
|
442
|
+
if (!drawn) setInBox(undefined)
|
|
440
443
|
setCompletion(
|
|
441
444
|
found
|
|
442
445
|
? {
|
|
@@ -696,42 +699,39 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
696
699
|
order: 60,
|
|
697
700
|
slots: {
|
|
698
701
|
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
|
|
702
|
+
const overlay = () => {
|
|
703
|
+
const current = inBox()
|
|
704
|
+
return current && current.sessionID === props.session_id ? current : undefined
|
|
709
705
|
}
|
|
710
706
|
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)
|
|
707
|
+
<box
|
|
708
|
+
position="relative"
|
|
709
|
+
ref={(ref: { screenX?: number; screenY?: number }) => {
|
|
710
|
+
slotNode = ref
|
|
733
711
|
}}
|
|
734
|
-
|
|
712
|
+
>
|
|
713
|
+
<api.ui.Prompt
|
|
714
|
+
sessionID={props.session_id}
|
|
715
|
+
visible={props.visible}
|
|
716
|
+
disabled={props.disabled}
|
|
717
|
+
showPlaceholder={false}
|
|
718
|
+
right={<api.ui.Slot name="session_prompt_right" session_id={props.session_id} />}
|
|
719
|
+
onSubmit={() => {
|
|
720
|
+
showGhost(undefined)
|
|
721
|
+
setInBox(undefined)
|
|
722
|
+
props.on_submit?.()
|
|
723
|
+
}}
|
|
724
|
+
ref={(ref) => {
|
|
725
|
+
promptRef = ref
|
|
726
|
+
props.ref?.(ref)
|
|
727
|
+
}}
|
|
728
|
+
/>
|
|
729
|
+
{overlay() ? (
|
|
730
|
+
<box position="absolute" left={overlay()!.x} top={overlay()!.y} zIndex={100}>
|
|
731
|
+
<text fg={api.theme.current.textMuted}>{overlay()!.text}</text>
|
|
732
|
+
</box>
|
|
733
|
+
) : undefined}
|
|
734
|
+
</box>
|
|
735
735
|
)
|
|
736
736
|
},
|
|
737
737
|
},
|
|
@@ -769,7 +769,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
769
769
|
if (timer) clearTimeout(timer)
|
|
770
770
|
clearInterval(sweepTimer)
|
|
771
771
|
clearInterval(pollTimer)
|
|
772
|
-
|
|
772
|
+
setInBox(undefined)
|
|
773
773
|
offIdle()
|
|
774
774
|
offStatus()
|
|
775
775
|
backLayer()
|