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.
@@ -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
+ }