opencode-ghost 0.2.2 → 0.2.4
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 -3
- package/LICENSE +1 -1
- package/README.md +20 -6
- package/package.json +20 -14
- package/src/completion.ts +8 -16
- package/src/text.ts +28 -0
- package/src/tui.tsx +122 -32
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.4] - 2026-09-25
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Long next-message suggestions now wrap onto extra rows and grow the prompt
|
|
15
|
+
box instead of being clipped at its right edge, so the whole suggestion stays
|
|
16
|
+
readable.
|
|
17
|
+
- Slash commands and skills only suggest their accepted argument flags (from
|
|
18
|
+
`argHints`); command and skill descriptions are no longer echoed as if they
|
|
19
|
+
were arguments. Commands without hints show no ghost.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Ghost styling: slash-command ghosts use the theme accent color, and every
|
|
24
|
+
other ghost is dimmed and clipped to the prompt box so it cannot be mistaken
|
|
25
|
+
for typed text.
|
|
26
|
+
## [0.2.3] - 2026-09-25
|
|
27
|
+
|
|
28
|
+
### Removed
|
|
29
|
+
|
|
30
|
+
- The temporary `GHOST_DEBUG` diagnostic toasts introduced while localizing
|
|
31
|
+
the in-box ghost overlay.
|
|
10
32
|
## [0.2.2] - 2026-09-25
|
|
11
33
|
|
|
12
34
|
### Changed
|
|
@@ -17,8 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
17
39
|
- Typing ghosts are positioned from absolute screen coordinates (the prompt
|
|
18
40
|
editor's `screenX/screenY` plus the caret's visual column/row) instead of a
|
|
19
41
|
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
42
|
## [0.2.1] - 2026-09-24
|
|
23
43
|
|
|
24
44
|
### Changed
|
|
@@ -78,7 +98,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
78
98
|
- Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
|
|
79
99
|
`recentMessages` and `system`.
|
|
80
100
|
|
|
81
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.
|
|
101
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.4...HEAD
|
|
102
|
+
[0.2.4]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.3...v0.2.4
|
|
103
|
+
[0.2.3]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.2...v0.2.3
|
|
82
104
|
[0.2.2]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.1...v0.2.2
|
|
83
105
|
[0.2.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.0...v0.2.1
|
|
84
106
|
[0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
|
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -17,14 +17,22 @@ 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 argument ghosts while typing.** After `/name `,
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
- **Slash-command argument ghosts while typing.** After `/name `, accepted
|
|
21
|
+
argument options (from `argHints`) render inside the prompt input box, right
|
|
22
|
+
after the caret, as `[a | b | c]` like Claude Code. Running out of options or
|
|
23
|
+
naming a command with no configured hints shows no ghost — command and skill
|
|
24
|
+
descriptions are never suggested as if they were arguments. Tab completes the
|
|
25
|
+
next argument. While the command name is still being typed, opencode's own
|
|
26
|
+
slash menu stays in charge. Purely local — no model calls.
|
|
25
27
|
- **History ghost while typing.** If the line you are typing is a prefix of a
|
|
26
28
|
message you already sent in the same session, the rest of it is ghosted in
|
|
27
29
|
the box as well; Tab pulls the full line back. Also purely local.
|
|
30
|
+
- **Distinct ghost styling.** Slash-command ghosts use the theme accent color;
|
|
31
|
+
all other ghosts (history completion, next-message suggestion) are dimmed so
|
|
32
|
+
they cannot be mistaken for typed text. A next-message suggestion that is
|
|
33
|
+
wider than the prompt wraps onto extra rows and grows the prompt box (up to
|
|
34
|
+
five rows) so it stays fully readable; other ghosts are clipped to the box and
|
|
35
|
+
never spill past it.
|
|
28
36
|
- **`/suggest` toggles it.** State is stored in the plugin KV.
|
|
29
37
|
|
|
30
38
|
## Requirements
|
|
@@ -97,7 +105,7 @@ Pass an options object as the second element of the plugin tuple:
|
|
|
97
105
|
| `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
|
|
98
106
|
| `backOnEmptyLeft` | `boolean` | `false` | Return to the home screen with Left when the prompt is empty. |
|
|
99
107
|
| `internalSessionMarkerDir` | `string` | unset | Optional extra crash-recovery tracking. A configuration-free sweep already reaps leftover hidden sessions, so this is only an additional safety net. |
|
|
100
|
-
| `argHints` | `Record<string, string[]>` | `{}` | Argument option lists per slash command, shown as `[a \| b \| c]` after
|
|
108
|
+
| `argHints` | `Record<string, string[]>` | `{}` | Argument option lists per slash command, shown as `[a \| b \| c]` after a complete `/name ` and completed with Tab, e.g. `"/keepwarm": ["6h", "always", "off", "status"]`. Commands without an entry suggest no arguments. |
|
|
101
109
|
|
|
102
110
|
## Commands
|
|
103
111
|
|
|
@@ -148,6 +156,12 @@ bun test
|
|
|
148
156
|
No build step: like other opencode TUI plugins, the package ships TSX source and
|
|
149
157
|
opencode's runtime transpiles it.
|
|
150
158
|
|
|
159
|
+
## Contributing
|
|
160
|
+
|
|
161
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the check suite and commit
|
|
162
|
+
conventions, [AGENTS.md](AGENTS.md) for the design rules, and
|
|
163
|
+
[CHANGELOG.md](CHANGELOG.md) for what changed.
|
|
164
|
+
|
|
151
165
|
## License
|
|
152
166
|
|
|
153
167
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ghost",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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",
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
"bugs": {
|
|
14
14
|
"url": "https://github.com/ozandogrultan/opencode-ghost/issues"
|
|
15
15
|
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"tui",
|
|
20
|
+
"ghost-text",
|
|
21
|
+
"autosuggest",
|
|
22
|
+
"prompt",
|
|
23
|
+
"suggestion"
|
|
24
|
+
],
|
|
16
25
|
"exports": {
|
|
17
26
|
"./tui": "./src/tui.tsx"
|
|
18
27
|
},
|
|
@@ -25,25 +34,19 @@
|
|
|
25
34
|
"publishConfig": {
|
|
26
35
|
"access": "public"
|
|
27
36
|
},
|
|
37
|
+
"packageManager": "bun@1.4.2",
|
|
38
|
+
"engines": {
|
|
39
|
+
"opencode": ">=1.18.0"
|
|
40
|
+
},
|
|
28
41
|
"scripts": {
|
|
29
|
-
"
|
|
42
|
+
"prepare": "husky",
|
|
30
43
|
"test": "bun test && bash tests/changelog.sh",
|
|
31
44
|
"test:unit": "bun test",
|
|
32
45
|
"test:changelog": "bash tests/changelog.sh",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint:sh": "for script in install.sh tests/*.sh scripts/*.sh; do bash -n \"$script\" || exit; done",
|
|
33
48
|
"prepublishOnly": "bun run test && bun run typecheck"
|
|
34
49
|
},
|
|
35
|
-
"engines": {
|
|
36
|
-
"opencode": ">=1.18.0"
|
|
37
|
-
},
|
|
38
|
-
"keywords": [
|
|
39
|
-
"opencode",
|
|
40
|
-
"opencode-plugin",
|
|
41
|
-
"tui",
|
|
42
|
-
"ghost-text",
|
|
43
|
-
"autosuggest",
|
|
44
|
-
"prompt",
|
|
45
|
-
"suggestion"
|
|
46
|
-
],
|
|
47
50
|
"dependencies": {
|
|
48
51
|
"solid-js": "^1.9.12"
|
|
49
52
|
},
|
|
@@ -52,11 +55,14 @@
|
|
|
52
55
|
"solid-js": "*"
|
|
53
56
|
},
|
|
54
57
|
"devDependencies": {
|
|
58
|
+
"@commitlint/cli": "^21.2.2",
|
|
59
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
55
60
|
"@opencode-ai/plugin": "1.18.25",
|
|
56
61
|
"@opentui/core": "0.4.5",
|
|
57
62
|
"@opentui/keymap": "0.4.5",
|
|
58
63
|
"@opentui/solid": "0.4.5",
|
|
59
64
|
"@types/node": "24.12.2",
|
|
65
|
+
"husky": "^9.1.7",
|
|
60
66
|
"typescript": "5.8.2"
|
|
61
67
|
}
|
|
62
68
|
}
|
package/src/completion.ts
CHANGED
|
@@ -16,8 +16,6 @@ export type Completion = {
|
|
|
16
16
|
insert?: string
|
|
17
17
|
/** Argument options shown as `[a | b]`, filtered by the word in progress. */
|
|
18
18
|
args?: string[]
|
|
19
|
-
/** Non-insertable hint text shown when nothing can be completed. */
|
|
20
|
-
hint?: string
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
/** Parse leading `/name args...` from the input; `args` is "" until a space exists. */
|
|
@@ -54,7 +52,6 @@ export function completeCommand(
|
|
|
54
52
|
const hints = argHints[lower]
|
|
55
53
|
return {
|
|
56
54
|
args: hints ? [...hints] : undefined,
|
|
57
|
-
hint: hints ? undefined : exact.description,
|
|
58
55
|
insert: `/${exact.name} `,
|
|
59
56
|
}
|
|
60
57
|
}
|
|
@@ -66,18 +63,13 @@ export function completeCommand(
|
|
|
66
63
|
|
|
67
64
|
// `/name args...`: complete the in-progress argument word against hints.
|
|
68
65
|
const hintList = argHints[lower]
|
|
69
|
-
if (hintList)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return undefined
|
|
66
|
+
if (!hintList) return undefined
|
|
67
|
+
const matches = matchOptions(parsed.args, hintList)
|
|
68
|
+
if (!matches) return undefined
|
|
69
|
+
const completed = parsed.args + matches[0].slice(parsed.args.length)
|
|
70
|
+
return {
|
|
71
|
+
args: matches,
|
|
72
|
+
ghost: matches[0].slice(parsed.args.length),
|
|
73
|
+
insert: `/${parsed.name} ${completed} `,
|
|
80
74
|
}
|
|
81
|
-
if (exact && parsed.args === "") return { hint: exact.description }
|
|
82
|
-
return undefined
|
|
83
75
|
}
|
package/src/text.ts
CHANGED
|
@@ -33,6 +33,34 @@ export function normalize(raw: string, maxChars: number): string | undefined {
|
|
|
33
33
|
return cleaned.slice(0, maxChars - 1).trimEnd() + "…"
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Number of visual rows `text` occupies when word-wrapped to `width` columns.
|
|
38
|
+
* Mirrors the greedy word wrap the prompt renderer uses, so a ghost can reserve
|
|
39
|
+
* exactly as many rows as it needs. Long words break at the column boundary.
|
|
40
|
+
*/
|
|
41
|
+
export function wrapCount(text: string, width: number): number {
|
|
42
|
+
if (width <= 0) return 1
|
|
43
|
+
let lines = 1
|
|
44
|
+
let col = 0
|
|
45
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
46
|
+
let length = word.length
|
|
47
|
+
if (col > 0) {
|
|
48
|
+
if (col + 1 + length <= width) {
|
|
49
|
+
col += 1 + length
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
lines += 1
|
|
53
|
+
col = 0
|
|
54
|
+
}
|
|
55
|
+
while (length > width) {
|
|
56
|
+
lines += 1
|
|
57
|
+
length -= width
|
|
58
|
+
}
|
|
59
|
+
col = length
|
|
60
|
+
}
|
|
61
|
+
return lines
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
export function isEcho(suggestion: string, previous: string): boolean {
|
|
37
65
|
const strip = (value: string) =>
|
|
38
66
|
value
|
package/src/tui.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { createSignal } from "solid-js"
|
|
|
4
4
|
import * as fs from "node:fs"
|
|
5
5
|
import * as path from "node:path"
|
|
6
6
|
import { resolveOptions, type PromptSuggestOptions } from "./options"
|
|
7
|
-
import { clip, isEcho, normalize, parseModel } from "./text"
|
|
7
|
+
import { clip, isEcho, normalize, parseModel, wrapCount } from "./text"
|
|
8
8
|
import { BUILTIN_COMMANDS } from "./builtins"
|
|
9
9
|
import { completeCommand, type CommandPool } from "./completion"
|
|
10
10
|
|
|
@@ -17,9 +17,12 @@ const ORPHAN_SWEEP_INTERVAL_MS = 30_000
|
|
|
17
17
|
const ORPHAN_MIN_AGE_MS = 15_000
|
|
18
18
|
const MAX_MESSAGE_CHARS = 800
|
|
19
19
|
const MAX_TRANSCRIPT_CHARS = 6000
|
|
20
|
+
// The prompt editor grows to fit a wrapped next-message suggestion only up to
|
|
21
|
+
// this many rows; longer text is clipped. The host caps its editor at >= 6 rows
|
|
22
|
+
// by default, so 5 stays inside that ceiling.
|
|
23
|
+
const MAX_GHOST_LINES = 5
|
|
20
24
|
const ENABLED_KEY = "ghost.enabled"
|
|
21
25
|
const MODEL_KEY = "ghost.model"
|
|
22
|
-
const GHOST_DEBUG = Boolean(process.env.GHOST_DEBUG)
|
|
23
26
|
|
|
24
27
|
const HIDDEN_TOOLS = {
|
|
25
28
|
bash: false,
|
|
@@ -342,10 +345,21 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
342
345
|
// walking the layout tree). Uses the normal render pipeline, so it is visible
|
|
343
346
|
// wherever the prompt is. When the editor or container cannot be located
|
|
344
347
|
// (host layout change) completion falls back to the hint row.
|
|
348
|
+
type GhostColor = typeof api.theme.current.textMuted
|
|
345
349
|
const [inBox, setInBox] = createSignal<
|
|
346
|
-
{
|
|
350
|
+
{
|
|
351
|
+
sessionID: string
|
|
352
|
+
x: number
|
|
353
|
+
y: number
|
|
354
|
+
text: string
|
|
355
|
+
width: number
|
|
356
|
+
height: number
|
|
357
|
+
wrap: boolean
|
|
358
|
+
color: GhostColor
|
|
359
|
+
opacity: number
|
|
360
|
+
} | undefined
|
|
347
361
|
>()
|
|
348
|
-
let slotNode: { screenX?: number; screenY?: number } | undefined
|
|
362
|
+
let slotNode: { screenX?: number; screenY?: number; width?: number } | undefined
|
|
349
363
|
// Locate the prompt's editor renderable inside the layout tree.
|
|
350
364
|
const findTextarea = (): unknown => {
|
|
351
365
|
const root = (api.renderer as unknown as { root?: unknown }).root as
|
|
@@ -361,6 +375,8 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
361
375
|
visualCursor?: { visualRow?: number; visualCol?: number }
|
|
362
376
|
screenX?: number
|
|
363
377
|
screenY?: number
|
|
378
|
+
width?: number
|
|
379
|
+
minHeight?: number
|
|
364
380
|
getChildren?: () => unknown[]
|
|
365
381
|
}
|
|
366
382
|
| undefined
|
|
@@ -372,10 +388,38 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
372
388
|
return undefined
|
|
373
389
|
}
|
|
374
390
|
|
|
375
|
-
|
|
391
|
+
// A wrapped next-message suggestion is shown by growing the host editor's
|
|
392
|
+
// min height to the number of rows it needs, so the prompt box grows instead
|
|
393
|
+
// of clipping the text. The host sets minHeight once at creation (not
|
|
394
|
+
// reactively), so the value we set survives until we reset it.
|
|
395
|
+
let heightManaged: { minHeight?: number } | undefined
|
|
396
|
+
const resetEditorHeight = () => {
|
|
397
|
+
if (!heightManaged) return
|
|
398
|
+
try {
|
|
399
|
+
heightManaged.minHeight = 1
|
|
400
|
+
} catch {
|
|
401
|
+
// The editor may have been recreated or destroyed; best effort.
|
|
402
|
+
}
|
|
403
|
+
heightManaged = undefined
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const placeInBoxGhost = (
|
|
407
|
+
sessionID: string,
|
|
408
|
+
text: string,
|
|
409
|
+
color: GhostColor,
|
|
410
|
+
opacity: number,
|
|
411
|
+
wrap: boolean,
|
|
412
|
+
): boolean => {
|
|
413
|
+
resetEditorHeight()
|
|
376
414
|
setInBox(undefined)
|
|
377
415
|
const textarea = findTextarea() as
|
|
378
|
-
| {
|
|
416
|
+
| {
|
|
417
|
+
screenX?: unknown
|
|
418
|
+
screenY?: unknown
|
|
419
|
+
width?: unknown
|
|
420
|
+
minHeight?: number
|
|
421
|
+
visualCursor?: { visualRow?: number; visualCol?: number }
|
|
422
|
+
}
|
|
379
423
|
| undefined
|
|
380
424
|
if (
|
|
381
425
|
!textarea ||
|
|
@@ -385,6 +429,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
385
429
|
return false
|
|
386
430
|
}
|
|
387
431
|
const cursor = textarea.visualCursor ?? {}
|
|
432
|
+
const visualCol = typeof cursor.visualCol === "number" ? cursor.visualCol : 0
|
|
388
433
|
// Overlay coordinates are relative to the slot container (it anchors the
|
|
389
434
|
// absolute overlay), so subtract its screen origin from the editor's.
|
|
390
435
|
if (
|
|
@@ -394,9 +439,30 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
394
439
|
) {
|
|
395
440
|
return false
|
|
396
441
|
}
|
|
397
|
-
const x = textarea.screenX - slotNode.screenX +
|
|
442
|
+
const x = textarea.screenX - slotNode.screenX + visualCol
|
|
398
443
|
const y = textarea.screenY - slotNode.screenY + (typeof cursor.visualRow === "number" ? cursor.visualRow : 0)
|
|
399
|
-
|
|
444
|
+
// Keep the ghost inside the prompt box: clip it to the editor's remaining
|
|
445
|
+
// width so it never spills past the box's right edge.
|
|
446
|
+
const editorWidth = typeof textarea.width === "number" ? textarea.width : undefined
|
|
447
|
+
const width =
|
|
448
|
+
editorWidth !== undefined
|
|
449
|
+
? editorWidth - visualCol
|
|
450
|
+
: typeof slotNode.width === "number"
|
|
451
|
+
? slotNode.width - x
|
|
452
|
+
: undefined
|
|
453
|
+
if (width === undefined || width <= 0) return false
|
|
454
|
+
let height = 1
|
|
455
|
+
if (wrap) {
|
|
456
|
+
height = Math.max(1, Math.min(MAX_GHOST_LINES, wrapCount(text, width)))
|
|
457
|
+
try {
|
|
458
|
+
textarea.minHeight = height
|
|
459
|
+
heightManaged = textarea
|
|
460
|
+
} catch {
|
|
461
|
+
// If the host editor cannot be resized, fall back to a one-row clip.
|
|
462
|
+
height = 1
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
setInBox({ sessionID, x, y, text, width, height, wrap: wrap && height > 1, color, opacity })
|
|
400
466
|
return true
|
|
401
467
|
}
|
|
402
468
|
|
|
@@ -410,47 +476,52 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
410
476
|
setCompletion(undefined)
|
|
411
477
|
setInBox(undefined)
|
|
412
478
|
}
|
|
479
|
+
resetEditorHeight()
|
|
413
480
|
return
|
|
414
481
|
}
|
|
415
482
|
const input = promptRef?.current.input ?? ""
|
|
416
483
|
if (poolFetchedAt && Date.now() - poolFetchedAt > POOL_TTL_MS) void refreshPool()
|
|
484
|
+
const isSlash = input.startsWith("/")
|
|
485
|
+
const startsEmpty = !input.trim()
|
|
417
486
|
// `/name` with no space yet is opencode's native slash menu; the plugin
|
|
418
487
|
// stays out of its way. Our slash completion only covers `/name args...`.
|
|
419
|
-
const found = !
|
|
488
|
+
const found = !isSlash
|
|
420
489
|
? historyCandidate(sessionID, input)
|
|
421
490
|
: input.includes(" ")
|
|
422
491
|
? completeCommand(input, commandPool, opts.argHints)
|
|
423
492
|
: undefined
|
|
424
493
|
let display = ""
|
|
425
494
|
let insert = ""
|
|
495
|
+
let wrap = false
|
|
496
|
+
// Slash-command completions are tinted like commands and kept at full
|
|
497
|
+
// strength; every other ghost (history, next-message suggestion) is dimmed
|
|
498
|
+
// so it cannot be mistaken for typed text.
|
|
499
|
+
const color = isSlash ? api.theme.current.accent : api.theme.current.textMuted
|
|
500
|
+
const opacity = isSlash ? 1 : 0.6
|
|
426
501
|
if (found) {
|
|
427
502
|
const args = "args" in found ? found.args : undefined
|
|
428
|
-
const hint = "hint" in found ? found.hint : undefined
|
|
429
503
|
insert = found.insert ?? ""
|
|
430
|
-
display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ??
|
|
431
|
-
} else if (
|
|
504
|
+
display = args && args.length > 0 ? `[${args.join(" | ")}]` : (found.ghost ?? "")
|
|
505
|
+
} else if (startsEmpty) {
|
|
432
506
|
// 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).
|
|
507
|
+
// box as well (the accept layer's `accept()` handles Tab for it). It is
|
|
508
|
+
// the only ghost allowed to wrap and grow the prompt box, since it can be
|
|
509
|
+
// longer than the visible width and there is no typed text to disturb.
|
|
434
510
|
const next = ghost()
|
|
435
|
-
if (next && next.sessionID === sessionID)
|
|
511
|
+
if (next && next.sessionID === sessionID) {
|
|
512
|
+
display = next.text
|
|
513
|
+
wrap = true
|
|
514
|
+
}
|
|
436
515
|
}
|
|
437
|
-
// Include the display in the key: a fresh suggestion for
|
|
438
|
-
// (empty) input must
|
|
439
|
-
const key = `${sessionID}\u0000${input}\u0000${display}`
|
|
516
|
+
// Include the display and renderer width in the key: a fresh suggestion for
|
|
517
|
+
// an unchanged (empty) input must be re-placed, and a resize must re-wrap.
|
|
518
|
+
const key = `${sessionID}\u0000${input}\u0000${display}\u0000${api.renderer.width}`
|
|
440
519
|
if (completionKey === key) return
|
|
441
520
|
completionKey = key
|
|
442
|
-
const drawn = display ? placeInBoxGhost(sessionID, display) : false
|
|
443
|
-
if (!drawn)
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
}
|
|
521
|
+
const drawn = display ? placeInBoxGhost(sessionID, display, color, opacity, wrap) : false
|
|
522
|
+
if (!drawn) {
|
|
523
|
+
setInBox(undefined)
|
|
524
|
+
resetEditorHeight()
|
|
454
525
|
}
|
|
455
526
|
setCompletion(
|
|
456
527
|
found
|
|
@@ -668,6 +739,9 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
668
739
|
if (currentInput().trim()) return false
|
|
669
740
|
promptRef.set({ input: current.text, parts: [] })
|
|
670
741
|
promptRef.focus()
|
|
742
|
+
completionKey = ""
|
|
743
|
+
setInBox(undefined)
|
|
744
|
+
resetEditorHeight()
|
|
671
745
|
showGhost(undefined)
|
|
672
746
|
return true
|
|
673
747
|
}
|
|
@@ -718,7 +792,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
718
792
|
return (
|
|
719
793
|
<box
|
|
720
794
|
position="relative"
|
|
721
|
-
ref={(ref: { screenX?: number; screenY?: number }) => {
|
|
795
|
+
ref={(ref: { screenX?: number; screenY?: number; width?: number }) => {
|
|
722
796
|
slotNode = ref
|
|
723
797
|
}}
|
|
724
798
|
>
|
|
@@ -739,8 +813,23 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
739
813
|
}}
|
|
740
814
|
/>
|
|
741
815
|
{overlay() ? (
|
|
742
|
-
<box
|
|
743
|
-
|
|
816
|
+
<box
|
|
817
|
+
position="absolute"
|
|
818
|
+
left={overlay()!.x}
|
|
819
|
+
top={overlay()!.y}
|
|
820
|
+
width={overlay()!.width}
|
|
821
|
+
height={overlay()!.height}
|
|
822
|
+
overflow="hidden"
|
|
823
|
+
opacity={overlay()!.opacity}
|
|
824
|
+
zIndex={100}
|
|
825
|
+
>
|
|
826
|
+
<text
|
|
827
|
+
fg={overlay()!.color}
|
|
828
|
+
wrapMode={overlay()!.wrap ? "word" : "none"}
|
|
829
|
+
truncate={!overlay()!.wrap}
|
|
830
|
+
>
|
|
831
|
+
{overlay()!.text}
|
|
832
|
+
</text>
|
|
744
833
|
</box>
|
|
745
834
|
) : undefined}
|
|
746
835
|
</box>
|
|
@@ -782,6 +871,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
782
871
|
clearInterval(sweepTimer)
|
|
783
872
|
clearInterval(pollTimer)
|
|
784
873
|
setInBox(undefined)
|
|
874
|
+
resetEditorHeight()
|
|
785
875
|
offIdle()
|
|
786
876
|
offStatus()
|
|
787
877
|
backLayer()
|