opencode-localhost 0.1.0 → 0.7.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/README.md CHANGED
@@ -26,14 +26,27 @@ opencode loads server-side and TUI plugins separately, so it goes in two files.
26
26
 
27
27
  ```jsonc
28
28
  // ~/.config/opencode/opencode.jsonc
29
- { "plugin": ["opencode-localhost"] }
29
+ { "plugin": ["/absolute/path/to/opencode-localhost"] }
30
30
  ```
31
31
 
32
32
  ```jsonc
33
33
  // ~/.config/opencode/tui.jsonc
34
- { "plugin": ["opencode-localhost"] }
34
+ { "plugin": ["/absolute/path/to/opencode-localhost"] }
35
35
  ```
36
36
 
37
+ ```sh
38
+ git clone https://github.com/dushyant30suthar/opencode-localhost
39
+ cd opencode-localhost && bun install
40
+ ```
41
+
42
+ **Why a path and not the package name?** On opencode 1.18.5, a TUI plugin
43
+ installed by name never loads. opencode resolves an npm plugin against the
44
+ wrapper `package.json` it generates in `~/.cache/opencode/packages/<name>@latest/`,
45
+ which has no `exports` field — so `exports["./tui"]` is never found and the TUI
46
+ half is skipped silently. The server half survives on a fallback path, which
47
+ makes it look like the provider works but the panel is broken. Referencing the
48
+ package by path avoids that resolution entirely.
49
+
37
50
  Start opencode. The panel appears under the prompt and tells you what is still
38
51
  missing.
39
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-localhost",
3
- "version": "0.1.0",
3
+ "version": "0.7.0",
4
4
  "description": "Run local models in opencode. llama.cpp as a provider, with live GPU, VRAM and CPU telemetry in the sidebar.",
5
5
  "keywords": [
6
6
  "opencode",
@@ -32,26 +32,16 @@
32
32
  "README.md",
33
33
  "LICENSE"
34
34
  ],
35
- "peerDependencies": {
36
- "@opentui/solid": ">=0.4.5",
37
- "solid-js": ">=1.9.0"
38
- },
39
- "peerDependenciesMeta": {
40
- "@opentui/solid": {
41
- "optional": true
42
- },
43
- "solid-js": {
44
- "optional": true
45
- }
46
- },
47
35
  "devDependencies": {
48
36
  "@opencode-ai/plugin": "^1.18.4",
49
- "@opentui/solid": "^0.4.5",
50
37
  "@types/node": "^26.1.1",
51
- "solid-js": "^1.9.0",
52
38
  "typescript": "^7.0.2"
53
39
  },
54
40
  "engines": {
55
41
  "node": ">=22"
42
+ },
43
+ "dependencies": {
44
+ "@opentui/solid": "^0.4.5",
45
+ "solid-js": "^1.9.0"
56
46
  }
57
47
  }
@@ -1,4 +1,4 @@
1
- import type { ProviderStatus } from "../shared/types.ts"
1
+ import type { LoadEvent, LoadedModel, ProviderStatus } from "../shared/types.ts"
2
2
 
3
3
  /**
4
4
  * What every backend implements. llama.cpp today; vLLM and OpenVINO are new
@@ -22,8 +22,16 @@ export type DiscoveredModel = {
22
22
 
23
23
  export interface Backend {
24
24
  readonly id: string
25
+
26
+ /** Short, for the panel — everything there is local, so no prefix. */
25
27
  readonly name: string
26
28
 
29
+ /**
30
+ * How it appears in opencode's provider list, where it sits beside cloud
31
+ * providers and several engines should group together.
32
+ */
33
+ readonly providerName: string
34
+
27
35
  /** Never throws. "unconfigured" is a normal answer on a fresh install. */
28
36
  status(): Promise<ProviderStatus>
29
37
 
@@ -33,6 +41,19 @@ export interface Backend {
33
41
  /** Idempotent: starts the server only if it is not already answering. */
34
42
  start(): Promise<ProviderStatus>
35
43
 
44
+ /** Stops a server we started. Never touches one we did not spawn. */
45
+ stop(): Promise<boolean>
46
+
47
+ /** What the server currently holds, if anything. Undefined when it is down. */
48
+ loaded(): Promise<LoadedModel | undefined>
49
+
50
+ /**
51
+ * Live load progress. Polling cannot see this: a model streaming into VRAM
52
+ * takes tens of seconds and the REST listing only flips from unloaded to
53
+ * loaded at the end. Returns an unsubscribe.
54
+ */
55
+ watch(onEvent: (event: LoadEvent) => void): () => void
56
+
36
57
  baseURL(): string
37
58
  apiKey(): string | undefined
38
59
  }
@@ -22,7 +22,7 @@ const sampling = new Map<string, Record<string, number>>()
22
22
 
23
23
  function providerEntry(backend: Backend, models: DiscoveredModel[]) {
24
24
  return {
25
- name: `${backend.name} (local)`,
25
+ name: backend.providerName,
26
26
  api: backend.baseURL(),
27
27
  npm: "@ai-sdk/openai-compatible",
28
28
  options: {
@@ -50,17 +50,12 @@ async function register(input: any) {
50
50
  // unconfigured or missing binary: contribute nothing. The panel explains why.
51
51
  if (!status || status.state === "unconfigured" || status.state === "failed") continue
52
52
 
53
- // Not up yet: start it in the background and register nothing this run.
53
+ // Configured is enough. The models exist on disk whether or not a server
54
+ // happens to be up, and a picker that stays empty until you find and press
55
+ // [start] is indistinguishable from the plugin not working at all.
54
56
  //
55
- // Awaiting here would stall opencode's startup for as long as the server
56
- // takes to answer — once on a fresh setup, and on *every* launch if the
57
- // server is failing to start. opencode reads its config once per instance
58
- // and exposes no way to re-read it, so the provider genuinely cannot appear
59
- // until the next launch; the panel says so rather than leaving it silent.
60
- if (status.state !== "running") {
61
- void backend.start().catch(() => {})
62
- continue
63
- }
57
+ // Nothing is spawned here: the server comes up on the first request, in
58
+ // chat.params below.
64
59
 
65
60
  const models = await backend.models().catch(() => [])
66
61
  if (models.length === 0) continue
@@ -83,7 +78,14 @@ const server = async () => ({
83
78
 
84
79
  "chat.params": async (input: any, output: any) => {
85
80
  const providerID = input?.model?.providerID
86
- if (!BACKENDS.some((backend) => backend.id === providerID)) return
81
+ const backend = BACKENDS.find((item) => item.id === providerID)
82
+ if (!backend) return
83
+
84
+ // Bring the engine up if this is the first request against it. Sending a
85
+ // message is a clear enough signal of intent — the objection was to
86
+ // spawning a server at launch, not to starting one you are about to use.
87
+ const status = await backend.status().catch(() => undefined)
88
+ if (status && status.state === "stopped") await backend.start().catch(() => {})
87
89
  const values = sampling.get(`${providerID}/${input?.model?.id}`)
88
90
  if (!values) return
89
91
  // opencode names three of these directly and passes the rest through options
@@ -2,7 +2,7 @@ import fs from "fs/promises"
2
2
  import path from "path"
3
3
  import { spawn } from "child_process"
4
4
  import { stateDir, collapseHome } from "../../shared/paths.ts"
5
- import type { ProviderStatus } from "../../shared/types.ts"
5
+ import type { LoadEvent, LoadedModel, ProviderStatus } from "../../shared/types.ts"
6
6
  import type { Backend, DiscoveredModel } from "../backend.ts"
7
7
  import * as Server from "./server-ini.ts"
8
8
  import * as Models from "./models-ini.ts"
@@ -176,12 +176,171 @@ export function create(): Backend {
176
176
  }
177
177
  }
178
178
 
179
+ /**
180
+ * Stop the server we spawned. Checks /proc before signalling so a recycled
181
+ * pid is never killed, and only kills a process that is actually
182
+ * llama-server — the pidfile outlives crashes and reboots.
183
+ */
184
+ async function stop(): Promise<boolean> {
185
+ const raw = await fs.readFile(PID_FILE, "utf8").catch(() => "")
186
+ const pid = Number.parseInt(raw.trim(), 10)
187
+ if (!Number.isFinite(pid) || pid <= 0) return false
188
+ const exe = await fs.readlink(`/proc/${pid}/exe`).catch(() => "")
189
+ if (!exe.includes("llama-server")) {
190
+ await fs.rm(PID_FILE, { force: true }).catch(() => {})
191
+ return false
192
+ }
193
+ try {
194
+ process.kill(pid, "SIGTERM")
195
+ } catch {
196
+ return false
197
+ }
198
+ for (let i = 0; i < 40; i++) {
199
+ try {
200
+ process.kill(pid, 0)
201
+ } catch {
202
+ break
203
+ }
204
+ await new Promise((resolve) => setTimeout(resolve, 250))
205
+ }
206
+ try {
207
+ process.kill(pid, "SIGKILL")
208
+ } catch {
209
+ // already gone
210
+ }
211
+ await fs.rm(PID_FILE, { force: true }).catch(() => {})
212
+ return true
213
+ }
214
+
215
+ /** llama-server reports its launch flags as a flat argv array. */
216
+ function argsOf(argv: unknown): Record<string, string> {
217
+ if (!Array.isArray(argv)) return {}
218
+ const out: Record<string, string> = {}
219
+ for (let i = 0; i < argv.length; i++) {
220
+ const token = String(argv[i])
221
+ if (!token.startsWith("--")) continue
222
+ const next = argv[i + 1]
223
+ if (next === undefined || String(next).startsWith("--")) continue
224
+ out[token.slice(2)] = String(next)
225
+ }
226
+ return out
227
+ }
228
+
229
+ async function loaded(): Promise<LoadedModel | undefined> {
230
+ const cfg = await config()
231
+ try {
232
+ const res = await fetch(`${baseURL()}/models`, {
233
+ signal: AbortSignal.timeout(PROBE_TIMEOUT),
234
+ headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
235
+ })
236
+ if (!res.ok) return undefined
237
+ const body: any = await res.json()
238
+ const entries: any[] = Array.isArray(body?.data) ? body.data : []
239
+ // llama-server reports state as status.value: "loaded" | "unloaded" |
240
+ // "loading". Every preset carries args, so matching on args alone picked
241
+ // whichever model sorted first rather than the one actually loaded.
242
+ const active =
243
+ entries.find((entry) => entry?.status?.value === "loading") ??
244
+ entries.find((entry) => entry?.status?.value === "loaded")
245
+ if (!active) return undefined
246
+ // llama-server reports load progress as {stages, current, value} while
247
+ // weights stream in; value is 0-1 for the stage named by `current`
248
+ return {
249
+ id: String(active.id ?? ""),
250
+ args: argsOf(active?.status?.args),
251
+ loading: active?.status?.value === "loading",
252
+ }
253
+ } catch {
254
+ return undefined
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Stream model state from /models/sse. The REST listing only flips from
260
+ * unloaded to loaded at the very end, so a thirty-second load looks like a
261
+ * hang unless we read this.
262
+ *
263
+ * Captured from a real load: one "model_status" frame announces the load,
264
+ * then ~20 "status_change" frames carry the progress, ending with
265
+ * status "loaded". The outgoing model gets its own "unloaded" frame.
266
+ *
267
+ * data: {"model":"...","event":"status_change",
268
+ * "data":{"status":"loading",
269
+ * "progress":{"stages":["text_model"],"current":"text_model","value":0.19}}}
270
+ */
271
+ function watch(onEvent: (event: LoadEvent) => void): () => void {
272
+ const controller = new AbortController()
273
+ let stopped = false
274
+
275
+ const connect = async () => {
276
+ while (!stopped) {
277
+ try {
278
+ const cfg = await config()
279
+ const host = cfg.host === "0.0.0.0" ? "127.0.0.1" : cfg.host
280
+ const res = await fetch(`http://${host}:${cfg.port}/models/sse`, {
281
+ signal: controller.signal,
282
+ headers: cfg.apiKey ? { Authorization: `Bearer ${cfg.apiKey}` } : {},
283
+ })
284
+ if (!res.ok || !res.body) throw new Error("no stream")
285
+ const reader = res.body.getReader()
286
+ const decoder = new TextDecoder()
287
+ let buffer = ""
288
+ while (!stopped) {
289
+ const { done, value } = await reader.read()
290
+ if (done) break
291
+ buffer += decoder.decode(value, { stream: true })
292
+ // frames are separated by a blank line; keep any partial tail
293
+ const frames = buffer.split("\n\n")
294
+ buffer = frames.pop() ?? ""
295
+ for (const frame of frames) {
296
+ const line = frame.split("\n").find((item) => item.startsWith("data:"))
297
+ if (!line) continue
298
+ try {
299
+ const payload = JSON.parse(line.slice(5).trim())
300
+ // progress rides on status_change; model_status only announces
301
+ // the start. Filtering to model_status dropped every update.
302
+ const kind = payload?.event
303
+ if (kind && kind !== "model_status" && kind !== "status_change") continue
304
+ const body = payload?.data ?? {}
305
+ const progress = body?.progress
306
+ onEvent({
307
+ model: String(payload?.model ?? ""),
308
+ loading: body?.status === "loading",
309
+ failed: body?.status === "failed" || body?.status === "error",
310
+ loaded: body?.status === "loaded",
311
+ progress: typeof progress?.value === "number" ? progress.value : undefined,
312
+ stage: typeof progress?.current === "string" ? progress.current : undefined,
313
+ })
314
+ } catch {
315
+ // a malformed frame is not worth tearing the stream down for
316
+ }
317
+ }
318
+ }
319
+ } catch {
320
+ // server down or restarted; wait before reconnecting
321
+ }
322
+ if (stopped) return
323
+ await new Promise((resolve) => setTimeout(resolve, 2_000))
324
+ }
325
+ }
326
+
327
+ void connect()
328
+ return () => {
329
+ stopped = true
330
+ controller.abort()
331
+ }
332
+ }
333
+
179
334
  return {
180
335
  id: Server.BACKEND,
181
336
  name: "llama.cpp",
337
+ providerName: "Localhost-llama.cpp",
182
338
  status,
183
339
  models,
184
340
  start,
341
+ stop,
342
+ loaded,
343
+ watch,
185
344
  baseURL,
186
345
  apiKey: () => settings?.apiKey || undefined,
187
346
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "fs/promises"
2
2
  import path from "path"
3
3
  import * as Ini from "../../shared/ini.ts"
4
- import { configDir } from "../../shared/paths.ts"
4
+ import { configDir, expandHome } from "../../shared/paths.ts"
5
5
  import type { LocalModel } from "./discover.ts"
6
6
 
7
7
  /**
@@ -86,12 +86,29 @@ export type SyncResult = {
86
86
  export async function sync(models: LocalModel[]): Promise<SyncResult> {
87
87
  const existing = await fs.readFile(FILE, "utf8").catch(() => "")
88
88
  const doc = Ini.parse(existing)
89
+ // llama-server opens these paths literally — it does not expand `~`, so a
90
+ // hand-written `model = ~/...` fails with "failed to open GGUF file". This is
91
+ // the one case where an existing section is rewritten, because leaving it
92
+ // alone means the model silently never loads.
93
+ let rewrote = false
94
+ for (const item of doc.sections) {
95
+ for (const key of ["model", "mmproj"] as const) {
96
+ const raw = Ini.get(item, key)?.trim()
97
+ if (!raw || !raw.startsWith("~")) continue
98
+ Ini.set(item, key, expandHome(raw))
99
+ rewrote = true
100
+ }
101
+ }
89
102
  const names = new Set(doc.sections.map((item) => item.name))
90
103
 
104
+ // Compare expanded paths. Hand-written sections routinely use `~/...` while
105
+ // discovery always produces absolute paths; comparing the raw strings makes
106
+ // an already-tuned model look new, and appends a default 32k section beside
107
+ // it that then wins — silently replacing tuned settings.
91
108
  const byFile = new Map<string, string>()
92
109
  for (const item of doc.sections) {
93
110
  const file = Ini.get(item, "model")?.trim()
94
- if (file) byFile.set(file, item.name)
111
+ if (file) byFile.set(expandHome(file), item.name)
95
112
  }
96
113
 
97
114
  const added: string[] = []
@@ -110,7 +127,8 @@ export async function sync(models: LocalModel[]): Promise<SyncResult> {
110
127
  added.push(section({ ...model, id }))
111
128
  }
112
129
 
113
- const body = existing.trim().length > 0 ? existing.replace(/\s+$/, "") : HEADER
130
+ const source = rewrote ? Ini.serialize(doc) : existing
131
+ const body = source.trim().length > 0 ? source.replace(/\s+$/, "") : HEADER
114
132
  const next = added.length > 0 ? [body, "", added.join("\n\n"), ""].join("\n") : body + "\n"
115
133
 
116
134
  await fs.mkdir(path.dirname(FILE), { recursive: true }).catch(() => {})
@@ -21,6 +21,19 @@ export type LoadedModel = {
21
21
  loading?: boolean
22
22
  /** 0-1 while weights stream in. */
23
23
  progress?: number
24
+ /** Which stage is streaming, e.g. "text_model" or "mmproj". */
25
+ stage?: string
26
+ }
27
+
28
+ /** A model changing state, streamed while it loads. */
29
+ export type LoadEvent = {
30
+ model: string
31
+ loading: boolean
32
+ loaded?: boolean
33
+ failed?: boolean
34
+ /** 0-1 for the stage named below. */
35
+ progress?: number
36
+ stage?: string
24
37
  }
25
38
 
26
39
  /**
@@ -34,18 +47,26 @@ export type ProviderStatus =
34
47
  | { state: "running"; endpoint: string; loaded?: LoadedModel }
35
48
  | { state: "failed"; message: string; hint?: string }
36
49
 
37
- export type PanelData = {
38
- backend: { id: string; name: string }
50
+ /** One engine. A machine can have several, each its own opencode provider. */
51
+ export type BackendPanel = {
52
+ id: string
53
+ name: string
39
54
  status: ProviderStatus
55
+ loaded?: LoadedModel
56
+ }
57
+
58
+ export type PanelData = {
40
59
  /**
41
- * Whether opencode has actually picked up the provider. False while the
42
- * server is up but opencode has not re-read its config the one case where
43
- * a restart is genuinely required, so the panel has to say so.
60
+ * Only backends the user has actually configured. Listing every engine we
61
+ * know about would put three dead rows on screen forever; /localhost is
62
+ * where the full list with install state lives.
44
63
  */
64
+ backends: BackendPanel[]
65
+ /** Whether opencode has picked any of these up as providers yet. */
45
66
  registered?: boolean
67
+ /** Shared: one GPU pool no matter how many engines are installed. */
46
68
  gpus: GpuStat[]
47
69
  memory?: SystemStat
48
- cpu?: SystemStat
49
70
  /** Tokens per second, while generating. */
50
71
  throughput?: number
51
72
  }
@@ -0,0 +1,14 @@
1
+ import { create as llamacpp } from "../server/llamacpp/index.ts"
2
+ import type { Backend } from "../server/backend.ts"
3
+
4
+ /**
5
+ * The engines this TUI half talks to, shared so the panel, the setup screen
6
+ * and the commands all see the same instances and the same busy state.
7
+ *
8
+ * A machine can have several; each is its own opencode provider.
9
+ */
10
+ export const BACKENDS: Backend[] = [llamacpp()]
11
+
12
+ export function backendById(id: string): Backend | undefined {
13
+ return BACKENDS.find((backend) => backend.id === id)
14
+ }
package/src/tui/index.tsx CHANGED
@@ -1,6 +1,42 @@
1
- import { createEffect, For, Show } from "solid-js"
1
+ import { createEffect, createSignal, For, Show } from "solid-js"
2
2
  import { Hardware, Provider, Model, hardwareRows, usePanelData } from "./panel.tsx"
3
3
  import { openSetup } from "./setup.tsx"
4
+ import { BACKENDS, backendById } from "./backends.ts"
5
+
6
+ /**
7
+ * Shared so the strip, the sidebar and the command all show the same busy
8
+ * state — start can take twenty seconds and three views disagreeing about
9
+ * whether it is running looks broken.
10
+ */
11
+ const [busy, setBusy] = createSignal<string | undefined>(undefined)
12
+
13
+ /**
14
+ * opencode's own model picker, rather than a second one of ours: selecting a
15
+ * model is TUI-local state a plugin cannot write, so a custom list could show
16
+ * models but never switch to one.
17
+ */
18
+ function openModelPicker(api: any) {
19
+ try {
20
+ api.keymap.dispatchCommand("model.list")
21
+ } catch {
22
+ api.ui.toast({ message: "could not open the model picker", variant: "error" })
23
+ }
24
+ }
25
+
26
+ async function toggleServer(id: string) {
27
+ if (busy()) return
28
+ const backend = backendById(id)
29
+ if (!backend) return
30
+ setBusy(id)
31
+ try {
32
+ const status = await backend.status()
33
+ await (status.state === "running" ? backend.stop() : backend.start())
34
+ } catch {
35
+ // status reflects whatever actually happened on the next poll
36
+ } finally {
37
+ setBusy(undefined)
38
+ }
39
+ }
4
40
 
5
41
  /**
6
42
  * The TUI half.
@@ -15,13 +51,17 @@ import { openSetup } from "./setup.tsx"
15
51
  */
16
52
 
17
53
  /**
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.
54
+ * Hardware and provider are fixed and cannot shrink without that the row
55
+ * collapses under its own content and the sections overprint each other.
56
+ *
57
+ * MODEL takes whatever is left rather than a fixed width, and wraps: model ids
58
+ * carry a publisher prefix ("lmstudio-community/Qwen3.6-35B-A3B-GGUF"), which a
59
+ * fixed column truncated at exactly the identifying part. The strip keeps the
60
+ * prompt's 75-column max so it stays centred with everything else on the home
61
+ * screen — full width made it hug the left edge while the prompt stayed centred.
21
62
  */
22
- const HARDWARE_WIDTH = 30
23
- const PROVIDER_WIDTH = 18
24
- const MODEL_WIDTH = 23
63
+ const HARDWARE_WIDTH = 28
64
+ const PROVIDER_WIDTH = 17
25
65
 
26
66
  /** One `│` per row, so the rule spans the whole block rather than its first line. */
27
67
  function Divider(props: { color: string; rows: number }) {
@@ -35,7 +75,8 @@ function Divider(props: { color: string; rows: number }) {
35
75
 
36
76
  /** opencode exposes the providers it loaded, which is how we detect the gap. */
37
77
  function registered(api: any) {
38
- return () => (api.state?.provider ?? []).some((item: any) => item?.id === "llamacpp")
78
+ const known = new Set(BACKENDS.map((backend) => backend.id))
79
+ return () => (api.state?.provider ?? []).some((item: any) => known.has(item?.id))
39
80
  }
40
81
 
41
82
  /**
@@ -64,10 +105,10 @@ function requestReload(api: any): boolean {
64
105
  * Guarded because the reload is asynchronous: without it, every poll before
65
106
  * the provider list refreshes would fire another signal.
66
107
  */
67
- function useAutoReload(api: any, data: () => { status: { state: string }; registered?: boolean }) {
108
+ function useAutoReload(api: any, data: () => { backends: { status: { state: string } }[]; registered?: boolean }) {
68
109
  let fired = false
69
110
  createEffect(() => {
70
- const ready = data().status.state === "running"
111
+ const ready = data().backends.some((backend) => backend.status.state === "running")
71
112
  if (!ready || data().registered !== false) {
72
113
  // reset once opencode has caught up, so a later restart of the backend
73
114
  // can trigger exactly one more reload
@@ -90,11 +131,11 @@ function HomePanel(props: { api: any }) {
90
131
  </box>
91
132
  <Divider color={theme().border} rows={hardwareRows(data()) + 1} />
92
133
  <box width={PROVIDER_WIDTH} flexShrink={0} flexDirection="column">
93
- <Provider theme={theme()} data={data()} />
134
+ <Provider theme={theme()} data={data()} busy={busy()} onToggle={(id) => void toggleServer(id)} />
94
135
  </box>
95
136
  <Divider color={theme().border} rows={hardwareRows(data()) + 1} />
96
- <box width={MODEL_WIDTH} flexShrink={0} flexDirection="column">
97
- <Model theme={theme()} data={data()} />
137
+ <box flexGrow={1} minWidth={20} flexDirection="column">
138
+ <Model theme={theme()} data={data()} onChange={() => openModelPicker(props.api)} />
98
139
  </box>
99
140
  </box>
100
141
  )
@@ -107,11 +148,9 @@ function SidebarPanel(props: { api: any }) {
107
148
  <box flexDirection="column">
108
149
  <Hardware theme={theme()} data={data()} />
109
150
  <box height={1} />
110
- <Provider theme={theme()} data={data()} stacked />
151
+ <Provider theme={theme()} data={data()} stacked busy={busy()} onToggle={(id) => void toggleServer(id)} />
111
152
  <box height={1} />
112
- <Show when={data().status.state === "running"}>
113
- <Model theme={theme()} data={data()} stacked />
114
- </Show>
153
+ <Model theme={theme()} data={data()} stacked onChange={() => openModelPicker(props.api)} />
115
154
  </box>
116
155
  )
117
156
  }
@@ -127,6 +166,19 @@ const tui = async (api: any) => {
127
166
  slash: { name: "localhost" },
128
167
  onSelect: () => openSetup(api),
129
168
  },
169
+ {
170
+ // keyboard route to the same action: tab is opencode's agent cycler, so
171
+ // the panel cannot be tabbed into. This is bindable and in ctrl+p.
172
+ title: "Local models: start/stop server",
173
+ value: "localhost.server.toggle",
174
+ category: "Provider",
175
+ onSelect: () => {
176
+ // with one engine this is unambiguous; with several the setup screen
177
+ // is where you pick which, so this acts on the first configured one
178
+ const first = BACKENDS[0]
179
+ if (first) void toggleServer(first.id)
180
+ },
181
+ },
130
182
  ])
131
183
 
132
184
  api.slots.register({
package/src/tui/panel.tsx CHANGED
@@ -1,19 +1,24 @@
1
- import { createMemo, createSignal, createEffect, onCleanup, For, Show } from "solid-js"
2
- import type { GpuStat, PanelData, ProviderStatus, SystemStat } from "../shared/types.ts"
1
+ import { createMemo, createSignal, createEffect, onCleanup, untrack, For, Show } from "solid-js"
2
+ import type { BackendPanel, GpuStat, LoadEvent, PanelData, ProviderStatus, SystemStat } from "../shared/types.ts"
3
3
  import { gpus } from "./hardware/nvidia.ts"
4
4
  import { system } from "./hardware/system.ts"
5
- import * as Server from "../server/llamacpp/server-ini.ts"
6
- import { collapseHome } from "../shared/paths.ts"
5
+ import { BACKENDS } from "./backends.ts"
7
6
 
8
7
  /**
9
- * The panel: hardware, then provider, then model.
8
+ * Three sections: hardware, provider, model.
10
9
  *
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.
10
+ * Hardware is one fixed block because the GPUs are shared no matter how many
11
+ * engines are installed. PROVIDER is the section that multiplies one entry
12
+ * per configured engine. MODEL describes what is actually loaded, naming the
13
+ * engine only when more than one holds something.
14
+ *
15
+ * Rows are padded explicitly rather than laid out with flex: in a monospace
16
+ * grid padStart/padEnd is what actually makes the columns line up.
14
17
  */
15
18
 
16
19
  const POLL_MS = 2_000
20
+ /** While weights stream in, 2s makes the bar jump in visible steps. */
21
+ const POLL_LOADING_MS = 500
17
22
  const MIB_PER_GIB = 1024
18
23
 
19
24
  type Theme = Record<string, any>
@@ -62,30 +67,21 @@ function statusWord(status: ProviderStatus) {
62
67
  }
63
68
  }
64
69
 
65
- function DeviceRow(props: {
66
- theme: Theme
67
- label: string
68
- stat: GpuStat | SystemStat
69
- labelWidth: number
70
- memWidth: number
71
- pctWidth: number
72
- }) {
70
+ function DeviceRow(props: { theme: Theme; label: string; stat: GpuStat | SystemStat }) {
73
71
  const used = () => props.stat.usedMiB
74
72
  const total = () => props.stat.totalMiB
75
73
  return (
76
74
  <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>
75
+ <text fg={props.theme.textMuted}>{props.label.padEnd(5)}</text>
76
+ <text fg={memoryColor(props.theme, used(), total())}>{`${gib(used())}/${gib(total())}G`.padStart(14)}</text>
81
77
  <text fg={props.theme.text}>
82
- {(props.stat.utilization === undefined ? "—" : `${props.stat.utilization}%`).padStart(props.pctWidth)}
78
+ {(props.stat.utilization === undefined ? "—" : `${props.stat.utilization}%`).padStart(9)}
83
79
  </text>
84
80
  </box>
85
81
  )
86
82
  }
87
83
 
88
- /** Rows the hardware grid will render, so the divider can match its height. */
84
+ /** Rows the hardware grid renders, so the divider can match its height. */
89
85
  export function hardwareRows(data: PanelData) {
90
86
  return data.gpus.length + (data.memory ? 1 : 0)
91
87
  }
@@ -101,175 +97,259 @@ export function Hardware(props: { theme: Theme; data: PanelData }) {
101
97
  <Show when={rows().length > 0}>
102
98
  <box flexDirection="column">
103
99
  <box flexDirection="row">
104
- <text fg={props.theme.textMuted}>{"HARDWARE".padEnd(9)}</text>
100
+ <text fg={props.theme.textMuted}>{"HARDWARE".padEnd(8)}</text>
105
101
  <text fg={props.theme.textMuted}>{"memory".padStart(11)}</text>
106
- <text fg={props.theme.textMuted}>{"compute".padStart(10)}</text>
102
+ <text fg={props.theme.textMuted}>{"compute".padStart(9)}</text>
107
103
  </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>
104
+ <For each={rows()}>{(row) => <DeviceRow theme={props.theme} label={row.label} stat={row.stat} />}</For>
113
105
  </box>
114
106
  </Show>
115
107
  )
116
108
  }
117
109
 
118
- export function Provider(props: { theme: Theme; data: PanelData; stacked?: boolean }) {
119
- const status = () => props.data.status
110
+ function endpointOf(status: ProviderStatus) {
111
+ if (status.state !== "running") return undefined
112
+ return status.endpoint.replace(/^https?:\/\//, "").replace(/\/v1$/, "")
113
+ }
114
+
115
+ /** Two lines per engine: name, then state or endpoint, then the control. */
116
+ function ProviderEntry(props: { theme: Theme; backend: BackendPanel; busy?: boolean; onToggle?: (id: string) => void }) {
117
+ const status = () => props.backend.status
118
+ const actionable = () => status().state === "running" || status().state === "stopped"
120
119
  return (
121
120
  <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}
121
+ <text fg={props.theme.text} wrapMode="word">
122
+ {props.backend.name}
127
123
  </text>
128
124
  <box flexDirection="row">
129
125
  <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
126
  <text fg={props.theme.textMuted} wrapMode="none">
136
- {(status() as { endpoint: string }).endpoint.replace(/^https?:\/\//, "").replace(/\/v1$/, "")}
127
+ {endpointOf(status()) ?? statusWord(status())}
137
128
  </text>
138
- </Show>
129
+ </box>
139
130
  <Show when={"message" in status()}>
140
- <text fg={props.theme.textMuted} wrapMode="none">
131
+ <text fg={props.theme.textMuted} wrapMode="word">
141
132
  {(status() as { message: string }).message}
142
133
  </text>
143
134
  </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
135
+ <Show when={props.onToggle && actionable()}>
136
+ <text
137
+ fg={props.busy ? props.theme.textMuted : props.theme.primary}
138
+ wrapMode="none"
139
+ onMouseUp={() => !props.busy && props.onToggle?.(props.backend.id)}
140
+ >
141
+ {props.busy ? "…working" : status().state === "running" ? "[stop]" : "[start]"}
148
142
  </text>
149
143
  </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>
144
+ </box>
145
+ )
146
+ }
147
+
148
+ export function Provider(props: {
149
+ theme: Theme
150
+ data: PanelData
151
+ stacked?: boolean
152
+ busy?: string
153
+ onToggle?: (id: string) => void
154
+ }) {
155
+ return (
156
+ <box flexDirection="column">
157
+ <Show when={props.stacked}>
158
+ <text fg={props.theme.textMuted}>PROVIDER</text>
159
+ </Show>
160
+ <Show
161
+ when={props.data.backends.length > 0}
162
+ fallback={
163
+ <text fg={props.theme.textMuted} wrapMode="word">
164
+ nothing configured — /localhost
165
+ </text>
166
+ }
167
+ >
168
+ <For each={props.data.backends}>
169
+ {(backend) => (
170
+ <ProviderEntry
171
+ theme={props.theme}
172
+ backend={backend}
173
+ busy={props.busy === backend.id}
174
+ onToggle={props.onToggle}
175
+ />
176
+ )}
177
+ </For>
154
178
  </Show>
155
179
  </box>
156
180
  )
157
181
  }
158
182
 
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
- })
183
+ /** A tenth-width bar: enough to read at a glance, cheap in a 25-column column. */
184
+ function bar(fraction: number) {
185
+ const filled = Math.max(0, Math.min(10, Math.round(fraction * 10)))
186
+ return "█".repeat(filled) + "░".repeat(10 - filled)
187
+ }
188
+
189
+ function detailOf(backend: BackendPanel, throughput?: number) {
190
+ const model = backend.loaded
191
+ if (!model) return []
192
+ // while weights stream in there are no launch flags to report yet, and the
193
+ // thing worth showing is how far along it is
194
+ if (model.loading) {
195
+ // Measured: llama-server reports 0% for ~9s while it allocates, then runs
196
+ // 0->100% in about 3s. A bar pinned at 0% for nine seconds reads as hung,
197
+ // so say what it is actually doing until there is progress to show.
198
+ const pct = model.progress === undefined ? undefined : Math.round(model.progress * 100)
199
+ const line = !pct ? "allocating…" : `${bar(model.progress!)} ${pct}%`
200
+ return [line, model.stage ?? ""].filter(Boolean)
201
+ }
202
+ const args = model.args
203
+ const first: string[] = []
204
+ if (args["ctx-size"]) first.push(`${args["ctx-size"]} ctx`)
205
+ if (args["cache-type-k"]) first.push(`${args["cache-type-k"]} KV`)
206
+ const second: string[] = []
207
+ if (args["gpu-layers"]) second.push(`ngl ${args["gpu-layers"]}`)
208
+ if (args["tensor-split"]) second.push(`split ${args["tensor-split"]}`)
209
+ if (throughput) second.push(`${throughput.toFixed(1)} tok/s`)
210
+ return [first.join(" · "), second.join(" · ")].filter(Boolean)
211
+ }
212
+
213
+ export function Model(props: { theme: Theme; data: PanelData; stacked?: boolean; onChange?: () => void }) {
214
+ const withModel = createMemo(() => props.data.backends.filter((backend) => backend.loaded))
215
+ // the engine name is only worth repeating when more than one holds something
216
+ const grouped = createMemo(() => withModel().length > 1)
174
217
  return (
175
218
  <box flexDirection="column">
176
219
  <Show when={props.stacked}>
177
220
  <text fg={props.theme.textMuted}>MODEL</text>
178
221
  </Show>
179
- <text fg={props.theme.text} wrapMode="none">
180
- {loaded()?.id ?? "no model loaded"}
181
- </text>
182
- <For each={detail()}>
183
- {(line) => (
222
+ <Show
223
+ when={withModel().length > 0}
224
+ fallback={
184
225
  <text fg={props.theme.textMuted} wrapMode="none">
185
- {line}
226
+ no model loaded
186
227
  </text>
187
- )}
188
- </For>
228
+ }
229
+ >
230
+ <For each={withModel()}>
231
+ {(backend) => (
232
+ <box flexDirection="column">
233
+ <Show when={grouped()}>
234
+ <text fg={props.theme.textMuted} wrapMode="none">
235
+ {backend.name}
236
+ </text>
237
+ </Show>
238
+ {/* ids carry a publisher prefix; wrap rather than truncate */}
239
+ <text
240
+ fg={backend.loaded!.loading ? props.theme.warning : props.theme.text}
241
+ wrapMode="word"
242
+ onMouseUp={() => props.onChange?.()}
243
+ >
244
+ {backend.loaded!.loading ? `◐ ${backend.loaded!.id}` : backend.loaded!.id}
245
+ </text>
246
+ <For each={detailOf(backend, props.data.throughput)}>
247
+ {(line) => (
248
+ <text fg={props.theme.textMuted} wrapMode="word">
249
+ {line}
250
+ </text>
251
+ )}
252
+ </For>
253
+ </box>
254
+ )}
255
+ </For>
256
+ </Show>
257
+ <Show when={props.onChange}>
258
+ <text fg={props.theme.primary} wrapMode="none" onMouseUp={() => props.onChange?.()}>
259
+ [change]
260
+ </text>
261
+ </Show>
189
262
  </box>
190
263
  )
191
264
  }
192
265
 
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: [],
266
+ // One source of truth for every mount. The home strip and the session sidebar
267
+ // are separate components, so a per-component hook meant two polling loops, two
268
+ // SSE subscriptions and two nvidia-smi calls whenever both were alive.
269
+ const [data, setData] = createSignal<PanelData>({ backends: [], gpus: [] })
270
+ // live load progress, keyed by backend id. Polling cannot see this: the REST
271
+ // listing only flips unloaded -> loaded once the model is already up.
272
+ const [live, setLive] = createSignal<Record<string, LoadEvent>>({})
273
+ let mounted = 0
274
+ let stopPolling: (() => void) | undefined
275
+ let stopWatching: (() => void) | undefined
276
+ let registeredCheck: (() => boolean) | undefined
277
+
278
+ async function refresh() {
279
+ const [gpuStats, sysStat] = await Promise.all([gpus().catch(() => []), system().catch(() => undefined)])
280
+ const backends = await Promise.all(
281
+ BACKENDS.map(async (backend) => {
282
+ const status = await backend.status().catch<ProviderStatus>(() => ({ state: "stopped" }))
283
+ const polled = status.state === "running" ? await backend.loaded().catch(() => undefined) : undefined
284
+ const event = live()[backend.id]
285
+ // a load in flight is only visible on the stream, and it names the
286
+ // incoming model before the listing has caught up
287
+ const loaded =
288
+ event?.loading === true
289
+ ? { id: event.model || polled?.id || "", args: polled?.args ?? {}, ...event }
290
+ : polled
291
+ return { id: backend.id, name: backend.name, status, loaded }
292
+ }),
293
+ )
294
+ // an engine with no binary or no models directory is not configured;
295
+ // carrying it on screen forever is noise, and /localhost lists them all
296
+ setData({
297
+ backends: backends.filter((backend) => backend.status.state !== "unconfigured"),
298
+ gpus: gpuStats,
299
+ memory: sysStat,
300
+ registered: registeredCheck?.() ?? true,
199
301
  })
302
+ }
200
303
 
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
- }))
304
+ function startPolling() {
305
+ let stopped = false
306
+ let timer: ReturnType<typeof setTimeout> | undefined
307
+ const tick = async () => {
308
+ await refresh()
309
+ if (stopped) return
310
+ const loading =
311
+ Object.values(untrack(live)).some((event) => event.loading) ||
312
+ untrack(data).backends.some((backend) => backend.loaded?.loading)
313
+ timer = setTimeout(tick, loading ? POLL_LOADING_MS : POLL_MS)
211
314
  }
315
+ void tick()
316
+ return () => {
317
+ stopped = true
318
+ if (timer) clearTimeout(timer)
319
+ }
320
+ }
212
321
 
213
- createEffect(() => {
214
- void refresh()
215
- const timer = setInterval(() => void refresh(), POLL_MS)
216
- onCleanup(() => clearInterval(timer))
217
- })
218
-
219
- return data
322
+ function startWatching() {
323
+ const stops = BACKENDS.map((backend) =>
324
+ backend.watch((event) => {
325
+ setLive((current) => ({ ...current, [backend.id]: event }))
326
+ // the stream is the only timely signal; fold it in immediately rather
327
+ // than waiting for the next poll tick
328
+ void refresh()
329
+ }),
330
+ )
331
+ return () => stops.forEach((stop) => stop())
220
332
  }
221
333
 
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)}`,
334
+ /** Shared across mounts: the first mount starts the work, the last stops it. */
335
+ export function usePanelData(isRegistered?: () => boolean) {
336
+ if (isRegistered) registeredCheck = isRegistered
337
+
338
+ createEffect(() => {
339
+ mounted++
340
+ if (mounted === 1) {
341
+ stopPolling = startPolling()
342
+ stopWatching = startWatching()
235
343
  }
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}` } : {},
344
+ onCleanup(() => {
345
+ mounted--
346
+ if (mounted > 0) return
347
+ stopPolling?.()
348
+ stopWatching?.()
349
+ stopPolling = undefined
350
+ stopWatching = undefined
243
351
  })
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
- }
352
+ })
262
353
 
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
354
+ return data
275
355
  }
package/src/tui/setup.tsx CHANGED
@@ -2,6 +2,9 @@ import fs from "fs/promises"
2
2
  import { BACKENDS, allOnPath, expand, supported, type BackendSpec } from "../shared/backends.ts"
3
3
  import { collapseHome } from "../shared/paths.ts"
4
4
  import * as Server from "../server/llamacpp/server-ini.ts"
5
+ import { BACKENDS as ENGINES } from "./backends.ts"
6
+
7
+ const backend = ENGINES[0]!
5
8
 
6
9
  /**
7
10
  * Setup, kept separate from the status strip.
@@ -119,12 +122,22 @@ async function rows(api: any, reopen: () => void): Promise<Row[]> {
119
122
  run: () => promptPath(api, "Models directory", dir, "models-dir", reopen),
120
123
  })
121
124
 
125
+ const status = await backend.status().catch(() => undefined)
126
+ const running = status?.state === "running"
127
+
128
+ // the action is on the row itself — selecting it starts or stops, rather
129
+ // than opening a submenu to find the one obvious thing to do
122
130
  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" }),
131
+ title: running ? "Server running [stop]" : "○ Server stopped [start]",
132
+ description: `${settings.host}:${settings.port}`,
133
+ run: async () => {
134
+ api.ui.toast({ message: running ? "stopping…" : "starting…", variant: "info" })
135
+ await (running ? backend.stop() : backend.start()).catch(() => {})
136
+ reopen()
137
+ },
126
138
  })
127
139
 
140
+
128
141
  return out
129
142
  }
130
143