opencode-localhost 0.1.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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