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.
@@ -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({