opencode-ollama-ps 0.0.0-stage → 0.1.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/README.md +113 -2
- package/package.json +32 -4
- package/src/index.tsx +137 -0
- package/src/jsx-dev-runtime.ts +24 -0
- package/src/jsx-runtime/index.ts +24 -0
- package/src/jsx-runtime/jsx-dev-runtime.ts +24 -0
- package/src/jsx-runtime.ts +25 -0
- package/tui.ts +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,114 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-ollama-ps
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode TUI sidebar plugin that displays Ollama running models (model **name**, **size**, **processor**, and **until**) in the right sidebar alongside the built-in context length and todo list.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Context 12,345 tokens 45% used
|
|
7
|
+
Ollama
|
|
8
|
+
devstral-small-2:latest 55.0 GB llamacpp 4 minutes
|
|
9
|
+
Todo
|
|
10
|
+
□ Review pull request
|
|
11
|
+
□ Fix login bug
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Install (OpenCode v2.2.6+)
|
|
15
|
+
|
|
16
|
+
For file-based TUI plugins, OpenCode only needs the single `.tsx` file — do **not** copy `package.json` into the plugins directory (it triggers a failed `npm install`).
|
|
17
|
+
|
|
18
|
+
1. Copy just the plugin file:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
mkdir -p ~/.config/opencode/plugins/opencode-ollama-ps/src
|
|
22
|
+
cp /path/to/opencode-ollama-ps/src/index.tsx ~/.config/opencode/plugins/opencode-ollama-ps/src/index.tsx
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
2. Register it in `~/.config/opencode/tui.json` (use `file://` prefix if the plain path doesn't load):
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
30
|
+
"plugin": [
|
|
31
|
+
"file:///home/eike/.config/opencode/plugins/opencode-ollama-ps/src/index.tsx"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If `file://` doesn't work, try the plain absolute path:
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"plugin": [
|
|
40
|
+
"/home/eike/.config/opencode/plugins/opencode-ollama-ps/src/index.tsx"
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
3. **Do not** add it to `cli.json` — that would try to load it as a server plugin and fail.
|
|
46
|
+
|
|
47
|
+
4. Clear OpenCode's cache and restart:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
rm -rf ~/.cache/opencode/
|
|
51
|
+
export OLLAMA_HOST=http://172.23.192.1:11434
|
|
52
|
+
opencode
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Configuration (optional)
|
|
56
|
+
|
|
57
|
+
Add options under the plugin entry in `tui.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"plugin": [
|
|
62
|
+
["file:///home/eike/.config/opencode/plugins/opencode-ollama-ps/src/index.tsx", {
|
|
63
|
+
"ollamaIntervalMs": 5000
|
|
64
|
+
}]
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| Option | Default | Description |
|
|
70
|
+
|--------|---------|-------------|
|
|
71
|
+
| `ollamaIntervalMs` | `5000` | How often to poll the Ollama API (ms) |
|
|
72
|
+
|
|
73
|
+
## How it works
|
|
74
|
+
|
|
75
|
+
- Registers a `sidebar_content` slot contribution, so the panel appears in the right sidebar next to Context, MCP, LSP, and Todos.
|
|
76
|
+
- Polls the Ollama HTTP API (`GET {host}/api/ps`) via `fetch` on a timer — no `ollama` CLI binary needed.
|
|
77
|
+
- Reads **`$OLLAMA_HOST`** from the environment (default `http://localhost:11434`), so remote Ollama hosts work out of the box.
|
|
78
|
+
- Renders each loaded model's name, size, processor, and time-until-unload.
|
|
79
|
+
- Shows an error line in the sidebar if the API call fails, and a "no models loaded" line when nothing is running.
|
|
80
|
+
|
|
81
|
+
## Important: where the panel appears
|
|
82
|
+
|
|
83
|
+
- **TUI plugins do not show in `opencode plugin list`** — that command only lists server plugins.
|
|
84
|
+
- The sidebar panel only renders when you're in an **active session** (not on the home screen). Start a chat session and look at the right sidebar.
|
|
85
|
+
- If you don't see a sidebar, press `Ctrl+X B` (or `leader b`) to toggle it.
|
|
86
|
+
|
|
87
|
+
## Troubleshooting
|
|
88
|
+
|
|
89
|
+
Panel not showing at all:
|
|
90
|
+
1. Make sure you're in an active chat session (sidebar panels only render during sessions)
|
|
91
|
+
2. Export `OLLAMA_HOST` before launching OpenCode:
|
|
92
|
+
```sh
|
|
93
|
+
export OLLAMA_HOST=http://172.23.192.1:11434
|
|
94
|
+
opencode
|
|
95
|
+
```
|
|
96
|
+
3. Completely quit OpenCode, clear cache, and restart:
|
|
97
|
+
```sh
|
|
98
|
+
rm -rf ~/.cache/opencode/
|
|
99
|
+
```
|
|
100
|
+
4. Try the `file://` prefix in `tui.json` (see Install above)
|
|
101
|
+
5. Verify the Ollama API is reachable: `curl http://172.23.192.1:11434/api/ps`
|
|
102
|
+
|
|
103
|
+
Panel shows `error: ... connection refused` / `host: http://...`:
|
|
104
|
+
- OpenCode can't reach the Ollama host. The host should be the base URL without `/v1` (your Ollama API base is `http://172.23.192.1:11434/v1`, so `OLLAMA_HOST=http://172.23.192.1:11434`).
|
|
105
|
+
|
|
106
|
+
Panel stays empty / `no models loaded`:
|
|
107
|
+
- No Ollama models are currently loaded. Load a model first, then the panel appears.
|
|
108
|
+
|
|
109
|
+
Server plugin error / NpmInstallFailedError:
|
|
110
|
+
- You likely added the plugin to `cli.json` (server plugins) or copied `package.json` into the plugins directory. Remove it from `cli.json` and install only `src/index.tsx` (see Install above).
|
|
111
|
+
|
|
112
|
+
## Uninstall
|
|
113
|
+
|
|
114
|
+
Remove the plugin entry from `tui.json` and restart OpenCode.
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ollama-ps",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "OpenCode TUI sidebar plugin that shows ollama ps output (size, processor, context) alongside context length and todos.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.tsx",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.tsx",
|
|
9
|
+
"./tui": "./src/index.tsx"
|
|
10
|
+
},
|
|
11
|
+
"tui": {
|
|
12
|
+
"plugin": "./src/index.tsx"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@opencode-ai/plugin": "^1.4.0",
|
|
16
|
+
"@opentui/core": "^0.1.97",
|
|
17
|
+
"@opentui/solid": "^0.1.97",
|
|
18
|
+
"solid-js": "^1.9.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^24.0.0",
|
|
22
|
+
"typescript": "^5.0.0"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"tui.ts"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/** @jsxImportSource ./jsx-runtime */
|
|
2
|
+
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { createMemo, createSignal, Show, For } from "solid-js"
|
|
4
|
+
|
|
5
|
+
type OllamaModel = {
|
|
6
|
+
name: string
|
|
7
|
+
id: string
|
|
8
|
+
size: string
|
|
9
|
+
processor: string
|
|
10
|
+
until?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INTERVAL_MS = 5000
|
|
14
|
+
const DEFAULT_OLLAMA_HOST = "http://localhost:11434"
|
|
15
|
+
|
|
16
|
+
function resolveHost(options: Record<string, unknown> | undefined): string {
|
|
17
|
+
return (
|
|
18
|
+
(process.env.OLLAMA_HOST as string | undefined) ??
|
|
19
|
+
DEFAULT_OLLAMA_HOST
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fmtBytes(bytes: number): string {
|
|
24
|
+
if (!bytes) return "—"
|
|
25
|
+
const units = ["B", "KB", "MB", "GB", "TB"]
|
|
26
|
+
let v = bytes
|
|
27
|
+
let u = 0
|
|
28
|
+
while (v >= 1024 && u < units.length - 1) {
|
|
29
|
+
v /= 1024
|
|
30
|
+
u++
|
|
31
|
+
}
|
|
32
|
+
return `${v.toFixed(1)} ${units[u]}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rgbaToHex(rgba: { r: number; g: number; b: number; a: number }): string {
|
|
36
|
+
const toHex = (n: number) => Math.round(n * 255).toString(16).padStart(2, "0")
|
|
37
|
+
return `#${toHex(rgba.r)}${toHex(rgba.g)}${toHex(rgba.b)}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchOllamaPs(host: string): Promise<{ models: OllamaModel[]; error?: string }> {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`${host}/api/ps`, { signal: AbortSignal.timeout(10000) })
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
return { models: [], error: `HTTP ${res.status}` }
|
|
45
|
+
}
|
|
46
|
+
const data = (await res.json()) as { models?: unknown[] }
|
|
47
|
+
const models: OllamaModel[] = (data.models ?? []).map((m: any) => ({
|
|
48
|
+
name: m.name ?? m.model ?? "unknown",
|
|
49
|
+
id: m.id ?? "",
|
|
50
|
+
size: m.size_label ?? fmtBytes(m.size ?? 0),
|
|
51
|
+
processor: m.processor ?? "?",
|
|
52
|
+
until: m.expires_at_label ?? m.expires_at,
|
|
53
|
+
}))
|
|
54
|
+
return { models, error: undefined }
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
models: [],
|
|
58
|
+
error: err instanceof Error ? err.message : String(err),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const tui: TuiPlugin = async (api, options, meta) => {
|
|
64
|
+
const intervalMs =
|
|
65
|
+
(options?.ollamaIntervalMs as number | undefined) ?? DEFAULT_INTERVAL_MS
|
|
66
|
+
const host = resolveHost(options)
|
|
67
|
+
|
|
68
|
+
const [version, setVersion] = createSignal(0)
|
|
69
|
+
const [state, setState] = createSignal<{
|
|
70
|
+
models: OllamaModel[]
|
|
71
|
+
error?: string
|
|
72
|
+
}>({ models: [], error: undefined })
|
|
73
|
+
|
|
74
|
+
const refresh = () => {
|
|
75
|
+
void fetchOllamaPs(host).then((result) => {
|
|
76
|
+
setState({ models: result.models, error: result.error })
|
|
77
|
+
setVersion((v) => v + 1)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const intervalId = setInterval(refresh, intervalMs)
|
|
82
|
+
refresh()
|
|
83
|
+
|
|
84
|
+
api.lifecycle.onDispose(() => {
|
|
85
|
+
clearInterval(intervalId)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const data = createMemo(() => {
|
|
89
|
+
version()
|
|
90
|
+
return state()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
api.slots.register({
|
|
94
|
+
order: 450,
|
|
95
|
+
slots: {
|
|
96
|
+
sidebar_content(_ctx, props) {
|
|
97
|
+
const d = data()
|
|
98
|
+
const theme = api.theme.current
|
|
99
|
+
const rows = d.models
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<text fg={rgbaToHex(theme.text)}>
|
|
104
|
+
<b>Ollama</b>
|
|
105
|
+
</text>
|
|
106
|
+
<Show when={d.error}>
|
|
107
|
+
<text fg={rgbaToHex(theme.textMuted)}>
|
|
108
|
+
error: {d.error} (host: {host})
|
|
109
|
+
</text>
|
|
110
|
+
</Show>
|
|
111
|
+
<Show when={!d.error && rows.length === 0}>
|
|
112
|
+
<text fg={rgbaToHex(theme.textMuted)}>no models loaded</text>
|
|
113
|
+
</Show>
|
|
114
|
+
<For each={rows}>
|
|
115
|
+
{(m) => (
|
|
116
|
+
<>
|
|
117
|
+
<text fg={rgbaToHex(theme.textMuted)}>{m.name}</text>
|
|
118
|
+
<text fg={rgbaToHex(theme.textMuted)}>{m.size}</text>
|
|
119
|
+
<text fg={rgbaToHex(theme.textMuted)}>{m.processor}</text>
|
|
120
|
+
<Show when={m.until}>
|
|
121
|
+
<text fg={rgbaToHex(theme.textMuted)}>{m.until}</text>
|
|
122
|
+
</Show>
|
|
123
|
+
</>
|
|
124
|
+
)}
|
|
125
|
+
</For>
|
|
126
|
+
</>
|
|
127
|
+
)
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
134
|
+
id: "opencode.ollama-ps",
|
|
135
|
+
tui,
|
|
136
|
+
}
|
|
137
|
+
export default plugin
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// JSX runtime for @opentui/solid compatibility
|
|
2
|
+
import { createElement, spread, createComponent } from "@opentui/solid"
|
|
3
|
+
|
|
4
|
+
function Fragment(props: { children?: unknown }): unknown {
|
|
5
|
+
const children = props.children
|
|
6
|
+
return typeof children === "function" ? children() : children
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function jsx(type: any, props: any = {}, ...children: any[]): any {
|
|
10
|
+
if (typeof type === "string") {
|
|
11
|
+
const el = createElement(type)
|
|
12
|
+
if (props) {
|
|
13
|
+
const merged = { ...props }
|
|
14
|
+
if (children.length > 0) {
|
|
15
|
+
merged.children = children.length === 1 ? children[0] : children
|
|
16
|
+
}
|
|
17
|
+
spread(el, merged)
|
|
18
|
+
}
|
|
19
|
+
return el
|
|
20
|
+
}
|
|
21
|
+
return createComponent(type, { ...props, children: children.length === 1 ? children[0] : children })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { jsx, jsx as jsxs, jsx as jsxDEV, Fragment }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// JSX runtime entry for @opentui/solid compatibility
|
|
2
|
+
import { createElement, spread, createComponent } from "@opentui/solid"
|
|
3
|
+
|
|
4
|
+
function Fragment(props: { children?: unknown }): unknown {
|
|
5
|
+
const children = props.children
|
|
6
|
+
return typeof children === "function" ? children() : children
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function jsx(type: any, props: any = {}, ...children: any[]): any {
|
|
10
|
+
if (typeof type === "string") {
|
|
11
|
+
const el = createElement(type)
|
|
12
|
+
if (props) {
|
|
13
|
+
const merged = { ...props }
|
|
14
|
+
if (children.length > 0) {
|
|
15
|
+
merged.children = children.length === 1 ? children[0] : children
|
|
16
|
+
}
|
|
17
|
+
spread(el, merged)
|
|
18
|
+
}
|
|
19
|
+
return el
|
|
20
|
+
}
|
|
21
|
+
return createComponent(type, { ...props, children: children.length === 1 ? children[0] : children })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { jsx, jsx as jsxs, Fragment }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// JSX dev runtime for @opentui/solid compatibility
|
|
2
|
+
import { createElement, spread, createComponent } from "@opentui/solid"
|
|
3
|
+
|
|
4
|
+
function Fragment(props: { children?: unknown }): unknown {
|
|
5
|
+
const children = props.children
|
|
6
|
+
return typeof children === "function" ? children() : children
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function jsxDEV(type: any, props: any = {}, ...children: any[]): any {
|
|
10
|
+
if (typeof type === "string") {
|
|
11
|
+
const el = createElement(type)
|
|
12
|
+
if (props) {
|
|
13
|
+
const merged = { ...props }
|
|
14
|
+
if (children.length > 0) {
|
|
15
|
+
merged.children = children.length === 1 ? children[0] : children
|
|
16
|
+
}
|
|
17
|
+
spread(el, merged)
|
|
18
|
+
}
|
|
19
|
+
return el
|
|
20
|
+
}
|
|
21
|
+
return createComponent(type, { ...props, children: children.length === 1 ? children[0] : children })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export { jsxDEV, Fragment }
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// JSX runtime for @opentui/solid compatibility
|
|
2
|
+
// Re-exports createElement, spread, createComponent with a local Fragment
|
|
3
|
+
import { createElement, spread, createComponent } from "@opentui/solid"
|
|
4
|
+
|
|
5
|
+
function Fragment(props: { children?: unknown }): unknown {
|
|
6
|
+
const children = props.children
|
|
7
|
+
return typeof children === "function" ? children() : children
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function jsx(type: any, props: any = {}, ...children: any[]): any {
|
|
11
|
+
if (typeof type === "string") {
|
|
12
|
+
const el = createElement(type)
|
|
13
|
+
if (props) {
|
|
14
|
+
const merged = { ...props }
|
|
15
|
+
if (children.length > 0) {
|
|
16
|
+
merged.children = children.length === 1 ? children[0] : children
|
|
17
|
+
}
|
|
18
|
+
spread(el, merged)
|
|
19
|
+
}
|
|
20
|
+
return el
|
|
21
|
+
}
|
|
22
|
+
return createComponent(type, { ...props, children: children.length === 1 ? children[0] : children })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { jsx, jsx as jsxs, jsx as jsxDEV, Fragment }
|
package/tui.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./src/index.tsx"
|