opencode-ghost 0.3.1 → 0.5.0
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 +17 -1
- package/package.json +2 -2
- package/src/stateless.ts +92 -11
- package/src/text.ts +21 -1
- package/src/tui.tsx +12 -9
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Support `--all` and explicit directory targets in `install.sh` to install across both global OpenCode v1 (`~/.config/opencode`) and sandbox/v2 configurations.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Support OpenCode v1 and v2 plugin loading conventions (named and default `tui`/`id` exports, snake_case and camelCase slot props, object model specs).
|
|
19
|
+
## [0.4.0] - 2026-09-30
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Expanded direct stateless suggestion generation: resolve API keys directly from OpenCode provider state (`api.state.provider`), add default endpoints for well-known providers (OpenAI, DeepSeek, Groq, Mistral, OpenRouter, Together, Ollama, Perplexity, xAI, Fireworks, Cerebras), and support keyless local completions without creating temporary OpenCode sessions.
|
|
10
24
|
## [0.3.1] - 2026-09-28
|
|
11
25
|
|
|
12
26
|
### Fixed
|
|
@@ -132,7 +146,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
132
146
|
- Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
|
|
133
147
|
`recentMessages` and `system`.
|
|
134
148
|
|
|
135
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.
|
|
149
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.5.0...HEAD
|
|
150
|
+
[0.5.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.4.0...v0.5.0
|
|
151
|
+
[0.4.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.3.1...v0.4.0
|
|
136
152
|
[0.3.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.3.0...v0.3.1
|
|
137
153
|
[0.3.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.7...v0.3.0
|
|
138
154
|
[0.2.7]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.6...v0.2.7
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ghost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -63,6 +63,6 @@
|
|
|
63
63
|
"@opentui/solid": "0.4.5",
|
|
64
64
|
"@types/node": "24.12.2",
|
|
65
65
|
"husky": "^9.1.7",
|
|
66
|
-
"typescript": "
|
|
66
|
+
"typescript": "7.0.2"
|
|
67
67
|
}
|
|
68
68
|
}
|
package/src/stateless.ts
CHANGED
|
@@ -3,6 +3,34 @@ import * as os from "node:os"
|
|
|
3
3
|
import * as path from "node:path"
|
|
4
4
|
import type { ResolvedOptions } from "./options"
|
|
5
5
|
|
|
6
|
+
const WELL_KNOWN_BASE_URLS: Record<string, string> = {
|
|
7
|
+
openai: "https://api.openai.com/v1",
|
|
8
|
+
deepseek: "https://api.deepseek.com",
|
|
9
|
+
groq: "https://api.groq.com/openai/v1",
|
|
10
|
+
mistral: "https://api.mistral.com/v1",
|
|
11
|
+
openrouter: "https://openrouter.ai/api/v1",
|
|
12
|
+
together: "https://api.together.xyz/v1",
|
|
13
|
+
ollama: "http://127.0.0.1:11434/v1",
|
|
14
|
+
perplexity: "https://api.perplexity.ai",
|
|
15
|
+
xai: "https://api.x.ai/v1",
|
|
16
|
+
fireworks: "https://api.fireworks.ai/inference/v1",
|
|
17
|
+
cerebras: "https://api.cerebras.ai/v1",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const WELL_KNOWN_ENV_KEYS: Record<string, string[]> = {
|
|
21
|
+
openai: ["OPENAI_API_KEY"],
|
|
22
|
+
deepseek: ["DEEPSEEK_API_KEY"],
|
|
23
|
+
groq: ["GROQ_API_KEY"],
|
|
24
|
+
mistral: ["MISTRAL_API_KEY"],
|
|
25
|
+
openrouter: ["OPENROUTER_API_KEY"],
|
|
26
|
+
together: ["TOGETHER_API_KEY"],
|
|
27
|
+
perplexity: ["PERPLEXITY_API_KEY"],
|
|
28
|
+
xai: ["XAI_API_KEY"],
|
|
29
|
+
fireworks: ["FIREWORKS_API_KEY"],
|
|
30
|
+
cerebras: ["CEREBRAS_API_KEY"],
|
|
31
|
+
anthropic: ["ANTHROPIC_API_KEY"],
|
|
32
|
+
}
|
|
33
|
+
|
|
6
34
|
export function resolveApiKey(raw: string | undefined): string | undefined {
|
|
7
35
|
if (!raw) return undefined
|
|
8
36
|
const trimmed = raw.trim()
|
|
@@ -24,24 +52,70 @@ export function resolveApiKey(raw: string | undefined): string | undefined {
|
|
|
24
52
|
return trimmed
|
|
25
53
|
}
|
|
26
54
|
|
|
55
|
+
function resolveProviderApiKey(
|
|
56
|
+
opts: ResolvedOptions,
|
|
57
|
+
providerID: string | undefined,
|
|
58
|
+
providerConfig: any,
|
|
59
|
+
providerState: any,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
if (opts.apiKey) return resolveApiKey(opts.apiKey)
|
|
62
|
+
|
|
63
|
+
const configKey = resolveApiKey(providerConfig?.options?.apiKey)
|
|
64
|
+
if (configKey) return configKey
|
|
65
|
+
|
|
66
|
+
const stateOptionKey = resolveApiKey(providerState?.options?.apiKey)
|
|
67
|
+
if (stateOptionKey) return stateOptionKey
|
|
68
|
+
|
|
69
|
+
const stateKey = resolveApiKey(providerState?.key)
|
|
70
|
+
if (stateKey) return stateKey
|
|
71
|
+
|
|
72
|
+
if (Array.isArray(providerState?.env)) {
|
|
73
|
+
for (const envName of providerState.env) {
|
|
74
|
+
if (typeof envName === "string" && process.env[envName]) {
|
|
75
|
+
return process.env[envName]
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (providerID && WELL_KNOWN_ENV_KEYS[providerID]) {
|
|
81
|
+
for (const envName of WELL_KNOWN_ENV_KEYS[providerID]) {
|
|
82
|
+
if (process.env[envName]) {
|
|
83
|
+
return process.env[envName]
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (process.env.LITELLM_API_KEY) return process.env.LITELLM_API_KEY
|
|
89
|
+
if (!providerID || providerID === "openai" || providerID === "custom") {
|
|
90
|
+
if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
27
96
|
export async function fetchStatelessSuggestion(
|
|
28
97
|
transcript: string,
|
|
29
98
|
opts: ResolvedOptions,
|
|
30
99
|
modelRef: { providerID: string; modelID: string } | undefined,
|
|
31
100
|
config: any,
|
|
101
|
+
providers?: readonly any[],
|
|
32
102
|
): Promise<string | undefined> {
|
|
33
|
-
const
|
|
103
|
+
const providerID = modelRef?.providerID
|
|
104
|
+
const providerConfig = providerID ? config?.provider?.[providerID] : undefined
|
|
105
|
+
const providerState = providerID && Array.isArray(providers)
|
|
106
|
+
? providers.find((p) => p?.id === providerID)
|
|
107
|
+
: undefined
|
|
108
|
+
|
|
109
|
+
const apiKey = resolveProviderApiKey(opts, providerID, providerConfig, providerState)
|
|
110
|
+
const isAnthropic = providerID === "anthropic"
|
|
111
|
+
|
|
34
112
|
const baseURL =
|
|
35
113
|
opts.endpoint ||
|
|
36
|
-
|
|
114
|
+
providerConfig?.options?.baseURL ||
|
|
115
|
+
providerState?.options?.baseURL ||
|
|
116
|
+
(!isAnthropic && providerID ? WELL_KNOWN_BASE_URLS[providerID] : undefined) ||
|
|
37
117
|
process.env.LITELLM_BASE_URL ||
|
|
38
118
|
process.env.OPENAI_BASE_URL
|
|
39
|
-
const rawKey =
|
|
40
|
-
opts.apiKey ||
|
|
41
|
-
provider?.options?.apiKey ||
|
|
42
|
-
process.env.LITELLM_API_KEY ||
|
|
43
|
-
process.env.OPENAI_API_KEY
|
|
44
|
-
const apiKey = resolveApiKey(rawKey)
|
|
45
119
|
|
|
46
120
|
const controller = new AbortController()
|
|
47
121
|
const timeoutMs = 4000
|
|
@@ -54,7 +128,8 @@ export async function fetchStatelessSuggestion(
|
|
|
54
128
|
: `${baseURL.replace(/\/+$/, "")}/chat/completions`
|
|
55
129
|
|
|
56
130
|
const rawModel = modelRef?.modelID || "default"
|
|
57
|
-
const
|
|
131
|
+
const keepSlash = providerID === "openrouter" || providerID === "together"
|
|
132
|
+
const model = !keepSlash && rawModel.includes("/")
|
|
58
133
|
? rawModel.slice(rawModel.lastIndexOf("/") + 1)
|
|
59
134
|
: rawModel
|
|
60
135
|
|
|
@@ -82,9 +157,15 @@ export async function fetchStatelessSuggestion(
|
|
|
82
157
|
}
|
|
83
158
|
|
|
84
159
|
if (modelRef?.providerID === "anthropic") {
|
|
85
|
-
const antKey =
|
|
160
|
+
const antKey = apiKey || process.env.ANTHROPIC_API_KEY
|
|
86
161
|
if (!antKey) return undefined
|
|
87
|
-
const
|
|
162
|
+
const antBase = (
|
|
163
|
+
providerConfig?.options?.baseURL ||
|
|
164
|
+
providerState?.options?.baseURL ||
|
|
165
|
+
"https://api.anthropic.com"
|
|
166
|
+
).replace(/\/+$/, "")
|
|
167
|
+
const url = antBase.endsWith("/v1/messages") ? antBase : `${antBase}/v1/messages`
|
|
168
|
+
const res = await fetch(url, {
|
|
88
169
|
method: "POST",
|
|
89
170
|
headers: {
|
|
90
171
|
"Content-Type": "application/json",
|
package/src/text.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
export function parseModel(
|
|
2
|
-
spec:
|
|
2
|
+
spec: unknown,
|
|
3
3
|
): { providerID: string; modelID: string } | undefined {
|
|
4
4
|
if (!spec) return undefined
|
|
5
|
+
if (typeof spec === "object" && spec !== null) {
|
|
6
|
+
const obj = spec as Record<string, unknown>
|
|
7
|
+
const providerID =
|
|
8
|
+
typeof obj.providerID === "string"
|
|
9
|
+
? obj.providerID
|
|
10
|
+
: typeof obj.provider === "string"
|
|
11
|
+
? obj.provider
|
|
12
|
+
: undefined
|
|
13
|
+
const modelID =
|
|
14
|
+
typeof obj.modelID === "string"
|
|
15
|
+
? obj.modelID
|
|
16
|
+
: typeof obj.id === "string"
|
|
17
|
+
? obj.id
|
|
18
|
+
: typeof obj.model === "string"
|
|
19
|
+
? obj.model
|
|
20
|
+
: undefined
|
|
21
|
+
if (providerID && modelID) return { providerID, modelID }
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
if (typeof spec !== "string") return undefined
|
|
5
25
|
const index = spec.indexOf("/")
|
|
6
26
|
if (index <= 0 || index === spec.length - 1) return undefined
|
|
7
27
|
return { providerID: spec.slice(0, index), modelID: spec.slice(index + 1) }
|
package/src/tui.tsx
CHANGED
|
@@ -562,10 +562,11 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
562
562
|
} catch {
|
|
563
563
|
// ignore
|
|
564
564
|
}
|
|
565
|
+
const config = api.state.config as Record<string, unknown> | undefined
|
|
565
566
|
return (
|
|
566
567
|
parseModel(opts.model) ??
|
|
567
|
-
parseModel(
|
|
568
|
-
parseModel(
|
|
568
|
+
parseModel(config?.small_model ?? config?.smallModel) ??
|
|
569
|
+
parseModel(config?.model)
|
|
569
570
|
)
|
|
570
571
|
}
|
|
571
572
|
|
|
@@ -631,6 +632,7 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
631
632
|
opts,
|
|
632
633
|
model,
|
|
633
634
|
api.state.config,
|
|
635
|
+
api.state.provider,
|
|
634
636
|
)
|
|
635
637
|
if (stateless) {
|
|
636
638
|
const suggestion = normalize(stateless, opts.maxChars)
|
|
@@ -828,9 +830,10 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
828
830
|
order: 60,
|
|
829
831
|
slots: {
|
|
830
832
|
session_prompt(_ctx, props) {
|
|
833
|
+
const sid = props.session_id ?? (props as any).sessionID
|
|
831
834
|
const overlay = () => {
|
|
832
835
|
const current = inBox()
|
|
833
|
-
return current && current.sessionID ===
|
|
836
|
+
return current && current.sessionID === sid ? current : undefined
|
|
834
837
|
}
|
|
835
838
|
return (
|
|
836
839
|
<box
|
|
@@ -840,15 +843,16 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
840
843
|
}}
|
|
841
844
|
>
|
|
842
845
|
<api.ui.Prompt
|
|
843
|
-
sessionID={
|
|
846
|
+
sessionID={sid}
|
|
844
847
|
visible={props.visible}
|
|
845
848
|
disabled={props.disabled}
|
|
846
849
|
showPlaceholder={false}
|
|
847
|
-
right={<api.ui.Slot name="session_prompt_right" session_id={
|
|
850
|
+
right={<api.ui.Slot name="session_prompt_right" session_id={sid} />}
|
|
848
851
|
onSubmit={() => {
|
|
849
852
|
showGhost(undefined)
|
|
850
853
|
setInBox(undefined)
|
|
851
854
|
props.on_submit?.()
|
|
855
|
+
;(props as any).onSubmit?.()
|
|
852
856
|
}}
|
|
853
857
|
ref={(ref) => {
|
|
854
858
|
promptRef = ref
|
|
@@ -930,7 +934,6 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
930
934
|
})
|
|
931
935
|
}
|
|
932
936
|
|
|
933
|
-
export
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}
|
|
937
|
+
export const id = "ghost"
|
|
938
|
+
export { tui }
|
|
939
|
+
export default Object.assign(tui, { id: "ghost", tui })
|