opencode-localhost 0.1.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/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +57 -0
- package/src/server/backend.ts +38 -0
- package/src/server/index.ts +103 -0
- package/src/server/llamacpp/discover.ts +92 -0
- package/src/server/llamacpp/index.ts +188 -0
- package/src/server/llamacpp/models-ini.ts +160 -0
- package/src/server/llamacpp/server-ini.ts +110 -0
- package/src/shared/backends.ts +97 -0
- package/src/shared/ini.ts +97 -0
- package/src/shared/paths.ts +37 -0
- package/src/shared/types.ts +51 -0
- package/src/tui/hardware/nvidia.ts +52 -0
- package/src/tui/hardware/system.ts +68 -0
- package/src/tui/index.tsx +145 -0
- package/src/tui/panel.tsx +275 -0
- package/src/tui/setup.tsx +150 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createEffect, For, Show } from "solid-js"
|
|
2
|
+
import { Hardware, Provider, Model, hardwareRows, usePanelData } from "./panel.tsx"
|
|
3
|
+
import { openSetup } from "./setup.tsx"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The TUI half.
|
|
7
|
+
*
|
|
8
|
+
* Two slots, one component, two arrangements:
|
|
9
|
+
* home_bottom always on screen, so it is also where a fresh install is
|
|
10
|
+
* told what is missing
|
|
11
|
+
* sidebar_content stacked, during a session
|
|
12
|
+
*
|
|
13
|
+
* The hardware grid is the same width in both, so it is genuinely one
|
|
14
|
+
* component rather than two layouts to keep in sync.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Columns are fixed and flexShrink is off. Without this the row collapses
|
|
19
|
+
* under its own content in a narrow terminal and the three sections overprint
|
|
20
|
+
* each other. Total is 74, matching opencode's default prompt width of 75.
|
|
21
|
+
*/
|
|
22
|
+
const HARDWARE_WIDTH = 30
|
|
23
|
+
const PROVIDER_WIDTH = 18
|
|
24
|
+
const MODEL_WIDTH = 23
|
|
25
|
+
|
|
26
|
+
/** One `│` per row, so the rule spans the whole block rather than its first line. */
|
|
27
|
+
function Divider(props: { color: string; rows: number }) {
|
|
28
|
+
const lines = () => Array.from({ length: Math.max(1, props.rows) })
|
|
29
|
+
return (
|
|
30
|
+
<box width={3} flexShrink={0} flexDirection="column">
|
|
31
|
+
<For each={lines()}>{() => <text fg={props.color}> │ </text>}</For>
|
|
32
|
+
</box>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** opencode exposes the providers it loaded, which is how we detect the gap. */
|
|
37
|
+
function registered(api: any) {
|
|
38
|
+
return () => (api.state?.provider ?? []).some((item: any) => item?.id === "llamacpp")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Ask opencode to re-read its config, so a provider that was not ready at
|
|
43
|
+
* startup appears without restarting.
|
|
44
|
+
*
|
|
45
|
+
* opencode's TUI binds SIGUSR2 to a config invalidate + instance dispose, and
|
|
46
|
+
* a TUI plugin runs inside that same process — so it can signal itself. This
|
|
47
|
+
* is the only reload path opencode exposes; there is no endpoint for it.
|
|
48
|
+
*
|
|
49
|
+
* Only fired from the home screen. The reload disposes live instances, which
|
|
50
|
+
* is fine before you start working and rude in the middle of a session.
|
|
51
|
+
*/
|
|
52
|
+
function requestReload(api: any): boolean {
|
|
53
|
+
if (api.route?.current?.name !== "home") return false
|
|
54
|
+
try {
|
|
55
|
+
process.kill(process.pid, "SIGUSR2")
|
|
56
|
+
return true
|
|
57
|
+
} catch {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Once the backend is up but opencode has not picked it up, reload — once.
|
|
64
|
+
* Guarded because the reload is asynchronous: without it, every poll before
|
|
65
|
+
* the provider list refreshes would fire another signal.
|
|
66
|
+
*/
|
|
67
|
+
function useAutoReload(api: any, data: () => { status: { state: string }; registered?: boolean }) {
|
|
68
|
+
let fired = false
|
|
69
|
+
createEffect(() => {
|
|
70
|
+
const ready = data().status.state === "running"
|
|
71
|
+
if (!ready || data().registered !== false) {
|
|
72
|
+
// reset once opencode has caught up, so a later restart of the backend
|
|
73
|
+
// can trigger exactly one more reload
|
|
74
|
+
if (data().registered) fired = false
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (fired) return
|
|
78
|
+
fired = requestReload(api)
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function HomePanel(props: { api: any }) {
|
|
83
|
+
const theme = () => props.api.theme.current
|
|
84
|
+
const data = usePanelData(registered(props.api))
|
|
85
|
+
useAutoReload(props.api, data)
|
|
86
|
+
return (
|
|
87
|
+
<box width="100%" maxWidth={75} flexDirection="row" flexShrink={0} paddingTop={1}>
|
|
88
|
+
<box width={HARDWARE_WIDTH} flexShrink={0} flexDirection="column">
|
|
89
|
+
<Hardware theme={theme()} data={data()} />
|
|
90
|
+
</box>
|
|
91
|
+
<Divider color={theme().border} rows={hardwareRows(data()) + 1} />
|
|
92
|
+
<box width={PROVIDER_WIDTH} flexShrink={0} flexDirection="column">
|
|
93
|
+
<Provider theme={theme()} data={data()} />
|
|
94
|
+
</box>
|
|
95
|
+
<Divider color={theme().border} rows={hardwareRows(data()) + 1} />
|
|
96
|
+
<box width={MODEL_WIDTH} flexShrink={0} flexDirection="column">
|
|
97
|
+
<Model theme={theme()} data={data()} />
|
|
98
|
+
</box>
|
|
99
|
+
</box>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function SidebarPanel(props: { api: any }) {
|
|
104
|
+
const theme = () => props.api.theme.current
|
|
105
|
+
const data = usePanelData(registered(props.api))
|
|
106
|
+
return (
|
|
107
|
+
<box flexDirection="column">
|
|
108
|
+
<Hardware theme={theme()} data={data()} />
|
|
109
|
+
<box height={1} />
|
|
110
|
+
<Provider theme={theme()} data={data()} stacked />
|
|
111
|
+
<box height={1} />
|
|
112
|
+
<Show when={data().status.state === "running"}>
|
|
113
|
+
<Model theme={theme()} data={data()} stacked />
|
|
114
|
+
</Show>
|
|
115
|
+
</box>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const tui = async (api: any) => {
|
|
120
|
+
// Setup lives behind a command rather than in the strip: the strip answers
|
|
121
|
+
// "is it working", this answers "where is everything".
|
|
122
|
+
api.command?.register(() => [
|
|
123
|
+
{
|
|
124
|
+
title: "Local models: setup",
|
|
125
|
+
value: "localhost.setup",
|
|
126
|
+
category: "Provider",
|
|
127
|
+
slash: { name: "localhost" },
|
|
128
|
+
onSelect: () => openSetup(api),
|
|
129
|
+
},
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
api.slots.register({
|
|
133
|
+
order: 350,
|
|
134
|
+
slots: {
|
|
135
|
+
home_bottom() {
|
|
136
|
+
return <HomePanel api={api} />
|
|
137
|
+
},
|
|
138
|
+
sidebar_content() {
|
|
139
|
+
return <SidebarPanel api={api} />
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default { id: "opencode-localhost-tui", tui }
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { createMemo, createSignal, createEffect, onCleanup, For, Show } from "solid-js"
|
|
2
|
+
import type { GpuStat, PanelData, ProviderStatus, SystemStat } from "../shared/types.ts"
|
|
3
|
+
import { gpus } from "./hardware/nvidia.ts"
|
|
4
|
+
import { system } from "./hardware/system.ts"
|
|
5
|
+
import * as Server from "../server/llamacpp/server-ini.ts"
|
|
6
|
+
import { collapseHome } from "../shared/paths.ts"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The panel: hardware, then provider, then model.
|
|
10
|
+
*
|
|
11
|
+
* Rows are built as fixed-width cells and padded explicitly rather than laid
|
|
12
|
+
* out with flex, because in a monospace grid padStart/padEnd is what actually
|
|
13
|
+
* guarantees the columns line up.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const POLL_MS = 2_000
|
|
17
|
+
const MIB_PER_GIB = 1024
|
|
18
|
+
|
|
19
|
+
type Theme = Record<string, any>
|
|
20
|
+
|
|
21
|
+
function gib(mib: number) {
|
|
22
|
+
return (mib / MIB_PER_GIB).toFixed(1)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Memory is an alarm when it is nearly full; compute never is. */
|
|
26
|
+
function memoryColor(theme: Theme, used: number, total: number) {
|
|
27
|
+
if (total <= 0) return theme.text
|
|
28
|
+
const ratio = used / total
|
|
29
|
+
if (ratio >= 0.97) return theme.error
|
|
30
|
+
if (ratio >= 0.9) return theme.warning
|
|
31
|
+
return theme.text
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const GLYPH: Record<ProviderStatus["state"], string> = {
|
|
35
|
+
running: "●",
|
|
36
|
+
starting: "◐",
|
|
37
|
+
stopped: "○",
|
|
38
|
+
failed: "✕",
|
|
39
|
+
unconfigured: "✕",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function statusColor(theme: Theme, state: ProviderStatus["state"]) {
|
|
43
|
+
if (state === "running") return theme.success
|
|
44
|
+
if (state === "starting") return theme.warning
|
|
45
|
+
if (state === "failed" || state === "unconfigured") return theme.error
|
|
46
|
+
return theme.textMuted
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The word always accompanies the glyph, so state never depends on colour. */
|
|
50
|
+
function statusWord(status: ProviderStatus) {
|
|
51
|
+
switch (status.state) {
|
|
52
|
+
case "running":
|
|
53
|
+
return "running"
|
|
54
|
+
case "starting":
|
|
55
|
+
return "starting"
|
|
56
|
+
case "stopped":
|
|
57
|
+
return "stopped"
|
|
58
|
+
case "failed":
|
|
59
|
+
return "failed"
|
|
60
|
+
case "unconfigured":
|
|
61
|
+
return status.missing === "binary" ? "no binary" : "not set up"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function DeviceRow(props: {
|
|
66
|
+
theme: Theme
|
|
67
|
+
label: string
|
|
68
|
+
stat: GpuStat | SystemStat
|
|
69
|
+
labelWidth: number
|
|
70
|
+
memWidth: number
|
|
71
|
+
pctWidth: number
|
|
72
|
+
}) {
|
|
73
|
+
const used = () => props.stat.usedMiB
|
|
74
|
+
const total = () => props.stat.totalMiB
|
|
75
|
+
return (
|
|
76
|
+
<box flexDirection="row">
|
|
77
|
+
<text fg={props.theme.textMuted}>{props.label.padEnd(props.labelWidth)}</text>
|
|
78
|
+
<text fg={memoryColor(props.theme, used(), total())}>
|
|
79
|
+
{`${gib(used())}/${gib(total())}G`.padStart(props.memWidth)}
|
|
80
|
+
</text>
|
|
81
|
+
<text fg={props.theme.text}>
|
|
82
|
+
{(props.stat.utilization === undefined ? "—" : `${props.stat.utilization}%`).padStart(props.pctWidth)}
|
|
83
|
+
</text>
|
|
84
|
+
</box>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Rows the hardware grid will render, so the divider can match its height. */
|
|
89
|
+
export function hardwareRows(data: PanelData) {
|
|
90
|
+
return data.gpus.length + (data.memory ? 1 : 0)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function Hardware(props: { theme: Theme; data: PanelData }) {
|
|
94
|
+
const rows = createMemo(() => {
|
|
95
|
+
const out: { label: string; stat: GpuStat | SystemStat }[] = []
|
|
96
|
+
for (const gpu of props.data.gpus) out.push({ label: `GPU${gpu.index}`, stat: gpu })
|
|
97
|
+
if (props.data.memory) out.push({ label: "CPU", stat: props.data.memory })
|
|
98
|
+
return out
|
|
99
|
+
})
|
|
100
|
+
return (
|
|
101
|
+
<Show when={rows().length > 0}>
|
|
102
|
+
<box flexDirection="column">
|
|
103
|
+
<box flexDirection="row">
|
|
104
|
+
<text fg={props.theme.textMuted}>{"HARDWARE".padEnd(9)}</text>
|
|
105
|
+
<text fg={props.theme.textMuted}>{"memory".padStart(11)}</text>
|
|
106
|
+
<text fg={props.theme.textMuted}>{"compute".padStart(10)}</text>
|
|
107
|
+
</box>
|
|
108
|
+
<For each={rows()}>
|
|
109
|
+
{(row) => (
|
|
110
|
+
<DeviceRow theme={props.theme} label={row.label} stat={row.stat} labelWidth={6} memWidth={14} pctWidth={10} />
|
|
111
|
+
)}
|
|
112
|
+
</For>
|
|
113
|
+
</box>
|
|
114
|
+
</Show>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function Provider(props: { theme: Theme; data: PanelData; stacked?: boolean }) {
|
|
119
|
+
const status = () => props.data.status
|
|
120
|
+
return (
|
|
121
|
+
<box flexDirection="column">
|
|
122
|
+
<Show when={props.stacked}>
|
|
123
|
+
<text fg={props.theme.textMuted}>PROVIDER</text>
|
|
124
|
+
</Show>
|
|
125
|
+
<text fg={props.theme.text} wrapMode="none">
|
|
126
|
+
{props.data.backend.name}
|
|
127
|
+
</text>
|
|
128
|
+
<box flexDirection="row">
|
|
129
|
+
<text fg={statusColor(props.theme, status().state)}>{`${GLYPH[status().state]} `}</text>
|
|
130
|
+
<text fg={props.theme.text} wrapMode="none">
|
|
131
|
+
{statusWord(status())}
|
|
132
|
+
</text>
|
|
133
|
+
</box>
|
|
134
|
+
<Show when={status().state === "running" && "endpoint" in status()}>
|
|
135
|
+
<text fg={props.theme.textMuted} wrapMode="none">
|
|
136
|
+
{(status() as { endpoint: string }).endpoint.replace(/^https?:\/\//, "").replace(/\/v1$/, "")}
|
|
137
|
+
</text>
|
|
138
|
+
</Show>
|
|
139
|
+
<Show when={"message" in status()}>
|
|
140
|
+
<text fg={props.theme.textMuted} wrapMode="none">
|
|
141
|
+
{(status() as { message: string }).message}
|
|
142
|
+
</text>
|
|
143
|
+
</Show>
|
|
144
|
+
{/* the file path is only useful where there is room to read it */}
|
|
145
|
+
<Show when={status().state === "running" && props.data.registered === false}>
|
|
146
|
+
<text fg={props.theme.warning} wrapMode="none">
|
|
147
|
+
restart opencode
|
|
148
|
+
</text>
|
|
149
|
+
</Show>
|
|
150
|
+
<Show when={props.stacked && "hint" in status() && (status() as { hint?: string }).hint}>
|
|
151
|
+
<text fg={props.theme.textMuted} wrapMode="word">
|
|
152
|
+
{(status() as { hint: string }).hint}
|
|
153
|
+
</text>
|
|
154
|
+
</Show>
|
|
155
|
+
</box>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function Model(props: { theme: Theme; data: PanelData; stacked?: boolean }) {
|
|
160
|
+
const loaded = () => (props.data.status.state === "running" ? props.data.status.loaded : undefined)
|
|
161
|
+
const detail = createMemo(() => {
|
|
162
|
+
const model = loaded()
|
|
163
|
+
if (!model) return []
|
|
164
|
+
const args = model.args
|
|
165
|
+
const parts: string[] = []
|
|
166
|
+
if (args["ctx-size"]) parts.push(`${args["ctx-size"]} ctx`)
|
|
167
|
+
if (args["cache-type-k"]) parts.push(`${args["cache-type-k"]} KV`)
|
|
168
|
+
const second: string[] = []
|
|
169
|
+
if (args["gpu-layers"]) second.push(`ngl ${args["gpu-layers"]}`)
|
|
170
|
+
if (args["tensor-split"]) second.push(`split ${args["tensor-split"]}`)
|
|
171
|
+
if (props.data.throughput) second.push(`${props.data.throughput.toFixed(1)} tok/s`)
|
|
172
|
+
return [parts.join(" · "), second.join(" · ")].filter(Boolean)
|
|
173
|
+
})
|
|
174
|
+
return (
|
|
175
|
+
<box flexDirection="column">
|
|
176
|
+
<Show when={props.stacked}>
|
|
177
|
+
<text fg={props.theme.textMuted}>MODEL</text>
|
|
178
|
+
</Show>
|
|
179
|
+
<text fg={props.theme.text} wrapMode="none">
|
|
180
|
+
{loaded()?.id ?? "no model loaded"}
|
|
181
|
+
</text>
|
|
182
|
+
<For each={detail()}>
|
|
183
|
+
{(line) => (
|
|
184
|
+
<text fg={props.theme.textMuted} wrapMode="none">
|
|
185
|
+
{line}
|
|
186
|
+
</text>
|
|
187
|
+
)}
|
|
188
|
+
</For>
|
|
189
|
+
</box>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Polls only while mounted, so a hidden panel costs nothing. */
|
|
194
|
+
export function usePanelData(isRegistered?: () => boolean) {
|
|
195
|
+
const [data, setData] = createSignal<PanelData>({
|
|
196
|
+
backend: { id: "llamacpp", name: "llama.cpp" },
|
|
197
|
+
status: { state: "stopped" },
|
|
198
|
+
gpus: [],
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
async function refresh() {
|
|
202
|
+
const [gpuStats, sysStat] = await Promise.all([gpus().catch(() => []), system().catch(() => undefined)])
|
|
203
|
+
const status = await probe().catch<ProviderStatus>(() => ({ state: "stopped" }))
|
|
204
|
+
setData((current) => ({
|
|
205
|
+
...current,
|
|
206
|
+
gpus: gpuStats,
|
|
207
|
+
memory: sysStat,
|
|
208
|
+
status,
|
|
209
|
+
registered: isRegistered?.() ?? true,
|
|
210
|
+
}))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
createEffect(() => {
|
|
214
|
+
void refresh()
|
|
215
|
+
const timer = setInterval(() => void refresh(), POLL_MS)
|
|
216
|
+
onCleanup(() => clearInterval(timer))
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
return data
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The TUI half reads the backend directly rather than asking opencode, because
|
|
224
|
+
* a server plugin cannot expose endpoints for it to ask. Documented limitation:
|
|
225
|
+
* this breaks if the TUI ever runs on a different machine from the server.
|
|
226
|
+
*/
|
|
227
|
+
async function probe(): Promise<ProviderStatus> {
|
|
228
|
+
const cfg = await Server.load()
|
|
229
|
+
if (!cfg.modelsDir) {
|
|
230
|
+
return {
|
|
231
|
+
state: "unconfigured",
|
|
232
|
+
missing: "models-dir",
|
|
233
|
+
message: "models-dir not set",
|
|
234
|
+
hint: `set it in ${collapseHome(Server.FILE)}`,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const host = cfg.host === "0.0.0.0" ? "127.0.0.1" : cfg.host
|
|
238
|
+
const baseURL = `http://${host}:${cfg.port}/v1`
|
|
239
|
+
try {
|
|
240
|
+
const res = await fetch(`${baseURL}/models`, {
|
|
241
|
+
signal: AbortSignal.timeout(1_500),
|
|
242
|
+
headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
|
|
243
|
+
})
|
|
244
|
+
if (!res.ok) return { state: "stopped" }
|
|
245
|
+
const body: any = await res.json()
|
|
246
|
+
const entries: any[] = Array.isArray(body?.data) ? body.data : []
|
|
247
|
+
const active = entries.find((entry) => entry?.status?.args || entry?.status?.status === "loading")
|
|
248
|
+
if (!active) return { state: "running", endpoint: baseURL }
|
|
249
|
+
return {
|
|
250
|
+
state: "running",
|
|
251
|
+
endpoint: baseURL,
|
|
252
|
+
loaded: {
|
|
253
|
+
id: String(active.id ?? ""),
|
|
254
|
+
args: argsOf(active?.status?.args),
|
|
255
|
+
loading: active?.status?.status === "loading",
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
return { state: "stopped" }
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** llama-server reports its launch flags as a flat argv array. */
|
|
264
|
+
function argsOf(argv: unknown): Record<string, string> {
|
|
265
|
+
if (!Array.isArray(argv)) return {}
|
|
266
|
+
const out: Record<string, string> = {}
|
|
267
|
+
for (let i = 0; i < argv.length; i++) {
|
|
268
|
+
const token = String(argv[i])
|
|
269
|
+
if (!token.startsWith("--")) continue
|
|
270
|
+
const next = argv[i + 1]
|
|
271
|
+
if (next === undefined || String(next).startsWith("--")) continue
|
|
272
|
+
out[token.slice(2)] = String(next)
|
|
273
|
+
}
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import { BACKENDS, allOnPath, expand, supported, type BackendSpec } from "../shared/backends.ts"
|
|
3
|
+
import { collapseHome } from "../shared/paths.ts"
|
|
4
|
+
import * as Server from "../server/llamacpp/server-ini.ts"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Setup, kept separate from the status strip.
|
|
8
|
+
*
|
|
9
|
+
* The strip answers "is it working right now". This answers "where is
|
|
10
|
+
* everything" — a different question, asked at a different time, and cramming
|
|
11
|
+
* both into three narrow columns is what made the unconfigured state unreadable.
|
|
12
|
+
*
|
|
13
|
+
* Built from DialogSelect and DialogPrompt rather than a hand-rolled screen, so
|
|
14
|
+
* filtering, keyboard navigation and theming come from opencode itself.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Row = {
|
|
18
|
+
title: string
|
|
19
|
+
description?: string
|
|
20
|
+
run?: () => void | Promise<void>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const OK = "✓"
|
|
24
|
+
const MISSING = "✗"
|
|
25
|
+
|
|
26
|
+
async function isFile(file: string): Promise<boolean> {
|
|
27
|
+
const stat = await fs.stat(file).catch(() => undefined)
|
|
28
|
+
return !!stat?.isFile()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function backendRow(spec: BackendSpec, api: any, reopen: () => void): Promise<Row> {
|
|
32
|
+
if (!supported(spec)) {
|
|
33
|
+
return {
|
|
34
|
+
title: `${MISSING} ${spec.name}`,
|
|
35
|
+
description: "Apple Silicon only",
|
|
36
|
+
run: () => api.ui.toast({ message: `${spec.name} requires macOS on Apple Silicon`, variant: "info" }),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!spec.implemented) {
|
|
40
|
+
const found = await allOnPath(spec.binary)
|
|
41
|
+
return {
|
|
42
|
+
title: `${found.length > 0 ? OK : MISSING} ${spec.name}`,
|
|
43
|
+
description: found.length > 0 ? `installed, not supported yet` : `not installed — ${spec.install}`,
|
|
44
|
+
run: () =>
|
|
45
|
+
api.ui.toast({
|
|
46
|
+
message: found.length > 0 ? `${spec.name} support is not implemented yet` : spec.install,
|
|
47
|
+
variant: "info",
|
|
48
|
+
}),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// llama.cpp: the configured path wins, otherwise whatever is on $PATH
|
|
53
|
+
const settings = await Server.load()
|
|
54
|
+
const configured = settings.bin && (await isFile(settings.bin)) ? settings.bin : undefined
|
|
55
|
+
const found = await allOnPath(spec.binary)
|
|
56
|
+
const active = configured ?? found[0]
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
title: `${active ? OK : MISSING} ${spec.name}`,
|
|
60
|
+
description: active ? collapseHome(active) : `not found — ${spec.install}`,
|
|
61
|
+
run: () => chooseBinary(api, spec, found, active, reopen),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Several llama.cpp builds commonly coexist — a tuned local build plus a
|
|
67
|
+
* release binary — so this offers the ones it found and always allows typing
|
|
68
|
+
* a path, rather than assuming the first hit on $PATH is the wanted one.
|
|
69
|
+
*/
|
|
70
|
+
function chooseBinary(api: any, spec: BackendSpec, found: string[], active: string | undefined, reopen: () => void) {
|
|
71
|
+
const options = [
|
|
72
|
+
...found.map((file) => ({
|
|
73
|
+
title: collapseHome(file),
|
|
74
|
+
value: file,
|
|
75
|
+
description: file === active ? "in use" : undefined,
|
|
76
|
+
})),
|
|
77
|
+
{ title: "Enter a path…", value: "__custom__", description: `e.g. a local build of ${spec.binary}` },
|
|
78
|
+
]
|
|
79
|
+
api.ui.dialog.replace(() => (
|
|
80
|
+
<api.ui.DialogSelect
|
|
81
|
+
title={`${spec.name} binary`}
|
|
82
|
+
options={options}
|
|
83
|
+
current={active}
|
|
84
|
+
onSelect={(option: { value: string }) => {
|
|
85
|
+
if (option.value === "__custom__") return promptPath(api, "Path to " + spec.binary, active ?? "", "bin", reopen)
|
|
86
|
+
void Server.update("bin", option.value).then(reopen)
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function promptPath(api: any, title: string, value: string, key: "bin" | "models-dir", reopen: () => void) {
|
|
93
|
+
api.ui.dialog.replace(() => (
|
|
94
|
+
<api.ui.DialogPrompt
|
|
95
|
+
title={title}
|
|
96
|
+
value={collapseHome(value)}
|
|
97
|
+
placeholder="~/..."
|
|
98
|
+
onConfirm={(next: string) => {
|
|
99
|
+
const resolved = expand(next)
|
|
100
|
+
if (!resolved) return reopen()
|
|
101
|
+
void Server.update(key, resolved).then(reopen)
|
|
102
|
+
}}
|
|
103
|
+
onCancel={reopen}
|
|
104
|
+
/>
|
|
105
|
+
))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function rows(api: any, reopen: () => void): Promise<Row[]> {
|
|
109
|
+
const settings = await Server.load()
|
|
110
|
+
const out: Row[] = []
|
|
111
|
+
|
|
112
|
+
for (const spec of BACKENDS) out.push(await backendRow(spec, api, reopen))
|
|
113
|
+
|
|
114
|
+
const dir = settings.modelsDir
|
|
115
|
+
const exists = dir ? await fs.stat(dir).then((s) => s.isDirectory()).catch(() => false) : false
|
|
116
|
+
out.push({
|
|
117
|
+
title: `${exists ? OK : MISSING} Models directory`,
|
|
118
|
+
description: dir ? `${collapseHome(dir)}${exists ? "" : " — not found"}` : "not set — required",
|
|
119
|
+
run: () => promptPath(api, "Models directory", dir, "models-dir", reopen),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
out.push({
|
|
123
|
+
title: " Server",
|
|
124
|
+
description: `${settings.host}:${settings.port} · ${settings.apiKey ? "api-key set" : "no api-key"}`,
|
|
125
|
+
run: () => api.ui.toast({ message: `Edit ${collapseHome(Server.FILE)}`, variant: "info" }),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return out
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function openSetup(api: any) {
|
|
132
|
+
const reopen = () => openSetup(api)
|
|
133
|
+
void rows(api, reopen).then((list) => {
|
|
134
|
+
api.ui.dialog.replace(() => (
|
|
135
|
+
<api.ui.DialogSelect
|
|
136
|
+
title="Local models — setup"
|
|
137
|
+
options={list.map((row) => ({
|
|
138
|
+
title: row.title,
|
|
139
|
+
value: row.title,
|
|
140
|
+
description: row.description,
|
|
141
|
+
}))}
|
|
142
|
+
onSelect={(option: { value: string }) => {
|
|
143
|
+
const row = list.find((item) => item.title === option.value)
|
|
144
|
+
if (!row?.run) return
|
|
145
|
+
void row.run()
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
))
|
|
149
|
+
})
|
|
150
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "Preserve",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"lib": ["ESNext"],
|
|
8
|
+
"jsx": "preserve",
|
|
9
|
+
"jsxImportSource": "@opentui/solid",
|
|
10
|
+
"types": ["node"],
|
|
11
|
+
"strict": true,
|
|
12
|
+
"noUncheckedIndexedAccess": false,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"allowImportingTsExtensions": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
"verbatimModuleSyntax": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src"]
|
|
19
|
+
}
|