opencode-ghost 0.1.1 → 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 +26 -1
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/builtins.ts +28 -0
- package/src/completion.ts +83 -0
- package/src/options.ts +10 -0
- package/src/tui.tsx +214 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ 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.
|
|
19
|
+
## [0.2.0] - 2026-09-24
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Slash-command autocomplete while typing: the remaining command name is
|
|
24
|
+
ghosted from configured commands, skills, and builtin TUI commands, with
|
|
25
|
+
known argument options shown as `[a | b]` hints and completed with `Tab`
|
|
26
|
+
(staged: command name, then arguments). Purely local — no model calls.
|
|
27
|
+
- Prompt-history ghost while typing: if the line being typed is a prefix of a
|
|
28
|
+
message already sent in the same session, the rest is ghosted and `Tab`
|
|
29
|
+
pulls the full line back.
|
|
30
|
+
- New option `argHints` (per-command argument option lists for the completion
|
|
31
|
+
hints).
|
|
32
|
+
|
|
10
33
|
## [0.1.1] - 2026-09-24
|
|
11
34
|
|
|
12
35
|
### Added
|
|
@@ -43,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
43
66
|
- Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
|
|
44
67
|
`recentMessages` and `system`.
|
|
45
68
|
|
|
46
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.
|
|
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
|
|
71
|
+
[0.2.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...v0.2.0
|
|
47
72
|
[0.1.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.0...v0.1.1
|
|
48
73
|
[0.1.0]: https://github.com/ozandogrultan/opencode-ghost/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -17,6 +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 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
|
+
- **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 in
|
|
27
|
+
the box as well; Tab pulls the full line back. Also purely local.
|
|
20
28
|
- **`/suggest` toggles it.** State is stored in the plugin KV.
|
|
21
29
|
|
|
22
30
|
## Requirements
|
|
@@ -89,6 +97,7 @@ Pass an options object as the second element of the plugin tuple:
|
|
|
89
97
|
| `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
|
|
90
98
|
| `backOnEmptyLeft` | `boolean` | `false` | Return to the home screen with Left when the prompt is empty. |
|
|
91
99
|
| `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 the command name and completed with Tab, e.g. `"/keepwarm": ["6h", "always", "off", "status"]`. |
|
|
92
101
|
|
|
93
102
|
## Commands
|
|
94
103
|
|
|
@@ -116,6 +125,11 @@ When enabling, a suggestion is generated immediately for the current session.
|
|
|
116
125
|
through that slot, so this plugin replaces the default prompt component. It
|
|
117
126
|
forwards `on_submit`, `ref`, `right`, `visible`, and `disabled`, and cannot be
|
|
118
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.
|
|
119
133
|
- **`hint` row.** While a suggestion is showing it occupies the prompt's hint
|
|
120
134
|
row, so the working-directory label is hidden until you accept or type.
|
|
121
135
|
- **Cost.** One small-model call per turn while enabled. Point `model` at a
|
package/package.json
CHANGED
package/src/builtins.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builtin TUI slash commands. The plugin API does not expose the TUI's own
|
|
3
|
+
* command palette, so the completable subset we observed in the shipped binary
|
|
4
|
+
* is curated here. Names are fixed; descriptions are ours and only used for the
|
|
5
|
+
* completion display.
|
|
6
|
+
*/
|
|
7
|
+
export type BuiltinCommand = {
|
|
8
|
+
name: string
|
|
9
|
+
description: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const BUILTIN_COMMANDS: BuiltinCommand[] = [
|
|
13
|
+
{ name: "new", description: "New session" },
|
|
14
|
+
{ name: "agent", description: "Cycle agent" },
|
|
15
|
+
{ name: "model", description: "Choose model" },
|
|
16
|
+
{ name: "mcp", description: "Toggle MCP servers" },
|
|
17
|
+
{ name: "open", description: "Command palette" },
|
|
18
|
+
{ name: "share", description: "Share session" },
|
|
19
|
+
{ name: "unshare", description: "Unshare session" },
|
|
20
|
+
{ name: "compact", description: "Compact session" },
|
|
21
|
+
{ name: "export", description: "Export session" },
|
|
22
|
+
{ name: "fork", description: "Fork session" },
|
|
23
|
+
{ name: "undo", description: "Undo messages" },
|
|
24
|
+
{ name: "redo", description: "Redo messages" },
|
|
25
|
+
{ name: "terminal", description: "Toggle terminal" },
|
|
26
|
+
{ name: "workspace", description: "Workspace controls" },
|
|
27
|
+
{ name: "suggest", description: "Toggle prompt suggestions" },
|
|
28
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure slash-command completion: given the current prompt input, the pool of
|
|
3
|
+
* known command names (configured commands, skills, and builtins) and optional
|
|
4
|
+
* per-command argument hints, decide what ghost text to render and what Tab
|
|
5
|
+
* should insert.
|
|
6
|
+
*/
|
|
7
|
+
export type CommandPool = {
|
|
8
|
+
readonly name: string
|
|
9
|
+
readonly description?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Completion = {
|
|
13
|
+
/** Dimmed text rendered after the input, e.g. `warm` for `/keep`+`warm`. */
|
|
14
|
+
ghost?: string
|
|
15
|
+
/** What Tab places into the input when accepting. */
|
|
16
|
+
insert?: string
|
|
17
|
+
/** Argument options shown as `[a | b]`, filtered by the word in progress. */
|
|
18
|
+
args?: string[]
|
|
19
|
+
/** Non-insertable hint text shown when nothing can be completed. */
|
|
20
|
+
hint?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse leading `/name args...` from the input; `args` is "" until a space exists. */
|
|
24
|
+
function parseSlash(input: string): { name: string; args: string; nameComplete: boolean } | undefined {
|
|
25
|
+
if (!input.startsWith("/")) return undefined
|
|
26
|
+
const line = input.split("\n")[0]
|
|
27
|
+
const space = line.indexOf(" ")
|
|
28
|
+
if (space === -1) return { name: line.slice(1), args: "", nameComplete: false }
|
|
29
|
+
return { name: line.slice(1, space), args: line.slice(space + 1), nameComplete: true }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Options that still match the word being typed, excluding an exact hit. */
|
|
33
|
+
function matchOptions(typed: string, options: readonly string[]): string[] | undefined {
|
|
34
|
+
const lower = typed.toLowerCase()
|
|
35
|
+
const matches = options.filter(
|
|
36
|
+
(option) => option.toLowerCase() !== lower && option.toLowerCase().startsWith(lower),
|
|
37
|
+
)
|
|
38
|
+
return matches.length > 0 ? matches : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function completeCommand(
|
|
42
|
+
input: string,
|
|
43
|
+
pool: readonly CommandPool[],
|
|
44
|
+
argHints: Readonly<Record<string, readonly string[]>> = {},
|
|
45
|
+
): Completion | undefined {
|
|
46
|
+
const parsed = parseSlash(input)
|
|
47
|
+
if (!parsed) return undefined
|
|
48
|
+
const lower = parsed.name.toLowerCase()
|
|
49
|
+
const exact = pool.find((command) => command.name.toLowerCase() === lower)
|
|
50
|
+
|
|
51
|
+
// Still typing the command name (no space yet).
|
|
52
|
+
if (!parsed.nameComplete) {
|
|
53
|
+
if (exact) {
|
|
54
|
+
const hints = argHints[lower]
|
|
55
|
+
return {
|
|
56
|
+
args: hints ? [...hints] : undefined,
|
|
57
|
+
hint: hints ? undefined : exact.description,
|
|
58
|
+
insert: `/${exact.name} `,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const matches = pool.filter((command) => command.name.toLowerCase().startsWith(lower))
|
|
62
|
+
if (matches.length === 0) return undefined
|
|
63
|
+
const next = matches[0]
|
|
64
|
+
return { ghost: next.name.slice(parsed.name.length), insert: `/${next.name} ` }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `/name args...`: complete the in-progress argument word against hints.
|
|
68
|
+
const hintList = argHints[lower]
|
|
69
|
+
if (hintList) {
|
|
70
|
+
const matches = matchOptions(parsed.args, hintList)
|
|
71
|
+
if (matches) {
|
|
72
|
+
const completed = parsed.args + matches[0].slice(parsed.args.length)
|
|
73
|
+
return {
|
|
74
|
+
args: matches,
|
|
75
|
+
ghost: matches[0].slice(parsed.args.length),
|
|
76
|
+
insert: `/${parsed.name} ${completed} `,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
if (exact && parsed.args === "") return { hint: exact.description }
|
|
82
|
+
return undefined
|
|
83
|
+
}
|
package/src/options.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type PromptSuggestOptions = {
|
|
|
8
8
|
idleDelayMs?: number
|
|
9
9
|
recentMessages?: number
|
|
10
10
|
system?: string
|
|
11
|
+
argHints?: Record<string, string[]>
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export type ResolvedOptions = {
|
|
@@ -20,6 +21,7 @@ export type ResolvedOptions = {
|
|
|
20
21
|
idleDelayMs: number
|
|
21
22
|
recentMessages: number
|
|
22
23
|
system: string
|
|
24
|
+
argHints: Record<string, readonly string[]>
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export const DEFAULT_SYSTEM = [
|
|
@@ -77,5 +79,13 @@ export function resolveOptions(options: PromptSuggestOptions | undefined): Resol
|
|
|
77
79
|
? recentMessages
|
|
78
80
|
: DEFAULTS.recentMessages,
|
|
79
81
|
system: typeof input.system === "string" && input.system.trim() ? input.system : DEFAULT_SYSTEM,
|
|
82
|
+
argHints:
|
|
83
|
+
input.argHints && typeof input.argHints === "object" && !Array.isArray(input.argHints)
|
|
84
|
+
? Object.fromEntries(
|
|
85
|
+
Object.entries(input.argHints)
|
|
86
|
+
.filter(([, value]) => Array.isArray(value) && value.length > 0)
|
|
87
|
+
.map(([key, value]) => [key.toLowerCase(), [...value]]),
|
|
88
|
+
)
|
|
89
|
+
: {},
|
|
80
90
|
}
|
|
81
91
|
}
|
package/src/tui.tsx
CHANGED
|
@@ -5,6 +5,8 @@ import * as fs from "node:fs"
|
|
|
5
5
|
import * as path from "node:path"
|
|
6
6
|
import { resolveOptions, type PromptSuggestOptions } from "./options"
|
|
7
7
|
import { clip, isEcho, normalize, parseModel } from "./text"
|
|
8
|
+
import { BUILTIN_COMMANDS } from "./builtins"
|
|
9
|
+
import { completeCommand, type CommandPool } from "./completion"
|
|
8
10
|
|
|
9
11
|
const HIDDEN_TITLE = "ghost-hidden"
|
|
10
12
|
// Survives the server's automatic title generation, which renames the hidden
|
|
@@ -259,6 +261,9 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
259
261
|
sweepTimer.unref?.()
|
|
260
262
|
|
|
261
263
|
const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
|
|
264
|
+
const [completion, setCompletion] = createSignal<
|
|
265
|
+
{ sessionID: string; display: string; insert?: string; drawn: boolean } | undefined
|
|
266
|
+
>()
|
|
262
267
|
let promptRef: TuiPromptRef | undefined
|
|
263
268
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
264
269
|
let generating = false
|
|
@@ -273,6 +278,179 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
273
278
|
|
|
274
279
|
const currentInput = () => promptRef?.current.input ?? ""
|
|
275
280
|
|
|
281
|
+
// Completable commands: configured commands/skills from the server layered
|
|
282
|
+
// under the builtin TUI slash commands. One config read, refreshed lazily; no
|
|
283
|
+
// model calls are involved anywhere in completion.
|
|
284
|
+
let commandPool: CommandPool[] = BUILTIN_COMMANDS
|
|
285
|
+
let poolFetching = false
|
|
286
|
+
let poolFetchedAt = 0
|
|
287
|
+
const refreshPool = async () => {
|
|
288
|
+
if (poolFetching) return
|
|
289
|
+
poolFetching = true
|
|
290
|
+
try {
|
|
291
|
+
const result = await api.client.command.list()
|
|
292
|
+
const rows = (result as { data?: unknown }).data
|
|
293
|
+
if (Array.isArray(rows)) {
|
|
294
|
+
const configured: CommandPool[] = rows
|
|
295
|
+
.map((row) => row as { name?: unknown; description?: unknown })
|
|
296
|
+
.filter((row) => typeof row.name === "string" && row.name.length > 0)
|
|
297
|
+
.map((row) => ({
|
|
298
|
+
name: row.name as string,
|
|
299
|
+
description: typeof row.description === "string" ? row.description : undefined,
|
|
300
|
+
}))
|
|
301
|
+
const seen = new Set(configured.map((command) => `${command.name.toLowerCase()}`))
|
|
302
|
+
const merged = [
|
|
303
|
+
...configured,
|
|
304
|
+
...BUILTIN_COMMANDS.filter((command) => !seen.has(command.name.toLowerCase())),
|
|
305
|
+
]
|
|
306
|
+
commandPool = merged.length > 0 ? merged : BUILTIN_COMMANDS
|
|
307
|
+
poolFetchedAt = Date.now()
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Keep the current pool; the poll loop retries on the next TTL window.
|
|
311
|
+
} finally {
|
|
312
|
+
poolFetching = false
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
void refreshPool()
|
|
316
|
+
|
|
317
|
+
// History ghost while typing: if the input is a prefix of a message the user
|
|
318
|
+
// already sent in this session, offer the rest of it. No model involved.
|
|
319
|
+
const historyCandidate = (sessionID: string, input: string) => {
|
|
320
|
+
const wants = input.replace(/\s+/g, " ").trimEnd()
|
|
321
|
+
if (wants.length < 2 || /\n/.test(input)) return undefined
|
|
322
|
+
const collapsed = wants.toLowerCase()
|
|
323
|
+
const messages = api.state.session.messages(sessionID).filter((m) => m.role === "user")
|
|
324
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
325
|
+
const text = api.state
|
|
326
|
+
.part(messages[i].id)
|
|
327
|
+
.filter((part) => part.type === "text")
|
|
328
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
329
|
+
.join(" ")
|
|
330
|
+
.replace(/\s+/g, " ")
|
|
331
|
+
.trim()
|
|
332
|
+
if (text.length <= collapsed.length) continue
|
|
333
|
+
if (!text.toLowerCase().startsWith(collapsed)) continue
|
|
334
|
+
return { ghost: text.slice(collapsed.length), insert: text }
|
|
335
|
+
}
|
|
336
|
+
return undefined
|
|
337
|
+
}
|
|
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
|
+
|
|
409
|
+
const POLL_INTERVAL_MS = 100
|
|
410
|
+
const POOL_TTL_MS = 60_000
|
|
411
|
+
let completionKey = ""
|
|
412
|
+
const pollTimer = setInterval(() => {
|
|
413
|
+
const sessionID = currentSessionID()
|
|
414
|
+
if (!sessionID) {
|
|
415
|
+
inBoxGhost = undefined
|
|
416
|
+
if (completion()) setCompletion(undefined)
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
const input = promptRef?.current.input ?? ""
|
|
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
|
|
428
|
+
const key = `${sessionID}\u0000${input}`
|
|
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
|
|
440
|
+
setCompletion(
|
|
441
|
+
found
|
|
442
|
+
? {
|
|
443
|
+
sessionID,
|
|
444
|
+
display,
|
|
445
|
+
insert,
|
|
446
|
+
drawn,
|
|
447
|
+
}
|
|
448
|
+
: undefined,
|
|
449
|
+
)
|
|
450
|
+
setAcceptVisible(Boolean(insert) || Boolean(ghost()))
|
|
451
|
+
}, POLL_INTERVAL_MS)
|
|
452
|
+
pollTimer.unref?.()
|
|
453
|
+
|
|
276
454
|
const isEnabled = () => {
|
|
277
455
|
try {
|
|
278
456
|
return api.kv.get<boolean>(ENABLED_KEY, opts.enabled) !== false
|
|
@@ -400,11 +578,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
400
578
|
priority: 100,
|
|
401
579
|
bindings: opts.acceptKeys.map((key) => ({
|
|
402
580
|
key,
|
|
403
|
-
desc: "Accept suggested prompt",
|
|
581
|
+
desc: "Accept suggested prompt or completion",
|
|
404
582
|
preventDefault: true,
|
|
405
583
|
cmd: () => {
|
|
406
|
-
|
|
407
|
-
return
|
|
584
|
+
if (completionAccept()) return true
|
|
585
|
+
return accept()
|
|
408
586
|
},
|
|
409
587
|
})),
|
|
410
588
|
})
|
|
@@ -415,12 +593,30 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
415
593
|
acceptLayer = undefined
|
|
416
594
|
}
|
|
417
595
|
|
|
418
|
-
|
|
419
|
-
|
|
596
|
+
let acceptVisible = false
|
|
597
|
+
const setAcceptVisible = (value: boolean) => {
|
|
598
|
+
if (acceptVisible === value) return
|
|
599
|
+
acceptVisible = value
|
|
420
600
|
if (value) enableAccept()
|
|
421
601
|
else disableAccept()
|
|
422
602
|
}
|
|
423
603
|
|
|
604
|
+
const showGhost = (value: { sessionID: string; text: string } | undefined) => {
|
|
605
|
+
setGhost(value)
|
|
606
|
+
setAcceptVisible(Boolean(value) || Boolean(completion()?.insert))
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const completionAccept = () => {
|
|
610
|
+
const current = completion()
|
|
611
|
+
if (!current || currentSessionID() !== current.sessionID) return false
|
|
612
|
+
if (!promptRef || !current.insert) return false
|
|
613
|
+
completionKey = ""
|
|
614
|
+
promptRef.set({ input: current.insert, parts: [] })
|
|
615
|
+
promptRef.focus()
|
|
616
|
+
setCompletion(undefined)
|
|
617
|
+
return true
|
|
618
|
+
}
|
|
619
|
+
|
|
424
620
|
const run = async (sessionID: string) => {
|
|
425
621
|
if (generating) return
|
|
426
622
|
if (!isEnabled()) return
|
|
@@ -504,6 +700,13 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
504
700
|
const current = ghost()
|
|
505
701
|
return current && current.sessionID === props.session_id ? current.text : ""
|
|
506
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
|
|
709
|
+
}
|
|
507
710
|
return (
|
|
508
711
|
<api.ui.Prompt
|
|
509
712
|
sessionID={props.session_id}
|
|
@@ -511,9 +714,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
511
714
|
disabled={props.disabled}
|
|
512
715
|
showPlaceholder={false}
|
|
513
716
|
hint={
|
|
514
|
-
suggestion() ? (
|
|
717
|
+
suggestion() || completionText() ? (
|
|
515
718
|
<box marginLeft={1}>
|
|
516
|
-
<text fg={api.theme.current.textMuted}>
|
|
719
|
+
<text fg={api.theme.current.textMuted}>
|
|
720
|
+
{completionText() || suggestion()}
|
|
721
|
+
</text>
|
|
517
722
|
</box>
|
|
518
723
|
) : undefined
|
|
519
724
|
}
|
|
@@ -563,6 +768,8 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
563
768
|
api.lifecycle.onDispose(() => {
|
|
564
769
|
if (timer) clearTimeout(timer)
|
|
565
770
|
clearInterval(sweepTimer)
|
|
771
|
+
clearInterval(pollTimer)
|
|
772
|
+
removeOverlay()
|
|
566
773
|
offIdle()
|
|
567
774
|
offStatus()
|
|
568
775
|
backLayer()
|