opencode-buddy 0.3.0 → 0.3.3

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
@@ -24,88 +24,167 @@ A virtual ASCII pet companion that lives in the opencode TUI sidebar. Hatches, f
24
24
  └──────────────────────────┘
25
25
  ```
26
26
 
27
- The buddy blinks every 600ms. Switch species, watch it cheer when opencode finishes a turn, and get scared when a session errors.
27
+ The buddy blinks every 600ms, animates inside the TUI sidebar, and reacts to your coding sessions.
28
28
 
29
29
  ## Install
30
30
 
31
- Requires Node.js ≥ 18 and opencode ≥ 1.15.
31
+ Requires opencode ≥ 1.15.
32
32
 
33
33
  ```bash
34
34
  npm install -g opencode-buddy
35
35
  ```
36
36
 
37
- Add to your `~/.config/opencode/opencode.json`:
37
+ The `postinstall` script automatically registers both plugin entrypoints in your opencode config:
38
38
 
39
- ```json
39
+ - `opencode-buddy` → `~/.config/opencode/opencode.json` (server plugin)
40
+ - `opencode-buddy/tui` → `~/.config/opencode/tui.json` (TUI plugin)
41
+
42
+ If you have those config files as JSONC (with comments), the script skips them — add the entries manually:
43
+
44
+ ```jsonc
45
+ // ~/.config/opencode/opencode.json
40
46
  {
41
47
  "plugin": ["opencode-buddy"]
42
48
  }
43
49
  ```
44
50
 
51
+ ```json
52
+ // ~/.config/opencode/tui.json
53
+ {
54
+ "$schema": "https://opencode.ai/tui.json",
55
+ "plugin": ["opencode-buddy/tui"]
56
+ }
57
+ ```
58
+
45
59
  Restart opencode. The buddy appears in the sidebar.
46
60
 
47
- ## How to interact
61
+ ## Usage
48
62
 
49
- The buddy has a `buddy` tool the LLM can call. Just talk to it:
63
+ Once installed, type `/` in the prompt to see the slash commands. The buddy ships with six:
50
64
 
51
- ```
52
- /buddy
53
- /feed the buddy
54
- /switch to a dragon
55
- /rename it to Sparky
56
- /can you show me the buddy's ascii art
57
- ```
65
+ | Slash command | Effect |
66
+ | --- | --- |
67
+ | `/buddy` | Show the buddy's current stats as a toast |
68
+ | `/buddy-feed` | Feed the buddy (+25 hunger, -5 energy) |
69
+ | `/buddy-play` | Play with the buddy (+20 happiness, +5 xp, -5 hunger) |
70
+ | `/buddy-rest` | Let the buddy rest (+30 energy) |
71
+ | `/buddy-rename` | Open a prompt to rename the buddy (max 20 chars) |
72
+ | `/buddy-switch` | Open a picker to switch to duck, cat, dragon, axolotl, robot, or ghost |
58
73
 
59
- The LLM will call the `buddy` tool with the right action. Available actions:
74
+ The buddy also reacts passively to your sessions:
60
75
 
61
- | Action | Effect |
62
- | --- | --- |
63
- | `status` | Print current stats |
64
- | `feed` | +25 hunger, -5 energy |
65
- | `play` | +20 happiness, -5 hunger, +5 xp |
66
- | `rest` | +30 energy |
67
- | `switch <species>` | Change to duck, cat, dragon, axolotl, robot, or ghost |
68
- | `rename <name>` | Rename (max 20 chars) |
69
- | `hatch [species] [name]` | Start a brand new buddy |
70
- | `ascii [frame]` | Return rendered ASCII art |
71
- | `path` | Path to the state file |
72
- | `help` | List actions |
76
+ - `session.idle` 4 second **celebrating** animation
77
+ - `session.error` 5 second **scared** animation
78
+ - Energy < 20 automatically transitions to **sleeping**
79
+ - Hunger < 25 transitions to **scared** for 30 seconds
80
+
81
+ State is shared across all opencode sessions via `~/.config/opencode-buddy/state.json`. Open a session in a different terminal and your buddy is still there.
73
82
 
74
83
  ## Six species
75
84
 
76
85
  ```
77
- duck cat dragon
78
- __ /\_/\ /\_/\
79
- <(o )___ ( o.o ) ( o o ) ~~
80
- ( ._> / > ^ < > ^ < /
81
- `--' /| |\ /| |\
82
- ~ idle ~ (_| |_) (_| |_)
83
- meow rawr
84
-
85
- axolotl robot ghost
86
- ^___^ [ O . O ] .-"\"-.
87
- (o . o) /|#####|\ ( o . o )
88
- \|_|_|/ / |#####| \ | ~ ~ |
89
- \| |/ | | | |
90
- ) ( /| | | |\ \uuuuu/
91
- ~ ambien beep boo
86
+ duck cat dragon
87
+ __ /\_/\ /\_/\
88
+ <(o )___ ( o.o ) ( o o ) ~~
89
+ ( ._> / > ^ < > ^ < /
90
+ `--' /| |\ /| |\
91
+ ~ idle ~ (_| |_) (_| |_)
92
+ meow rawr
93
+
94
+ axolotl robot ghost
95
+ ^___^ [ O . O ] .-"-"-.
96
+ (o . o) /|#####|\ ( o . o )
97
+ \|_|_|/ / |#####| \ | ~ ~ |
98
+ \| |/ | | | |
99
+ ) ( /| | | |\ \uuuuu/
100
+ ~ ambien beep boo
92
101
  ```
93
102
 
94
- Each species has a per-character color palette. Idle state has 3 frames (blink loop) at 600ms per frame.
103
+ Each species has a per-character color palette. The idle state is a 3-frame blink loop that runs at ~1.6 fps.
104
+
105
+ ## Architecture
106
+
107
+ ```mermaid
108
+ flowchart LR
109
+ subgraph User
110
+ U[Opencode TUI user]
111
+ end
112
+
113
+ subgraph OpencodeTUI["opencode TUI process (binary)"]
114
+ direction TB
115
+ Config[("tui.json<br/>plugin: [...]")]
116
+ Runtime[TuiPluginRuntime]
117
+ Sidebar[Sidebar component]
118
+ Prompt[Prompt component]
119
+ Slots{{"slot registry<br/>sidebar_content"}}
120
+ Keymap{{"keymap<br/>(slash commands)"}}
121
+
122
+ Config --> Runtime
123
+ Runtime --> Slots
124
+ Runtime --> Keymap
125
+ Slots --> Sidebar
126
+ Keymap --> Prompt
127
+ end
128
+
129
+ subgraph Buddy["opencode-buddy plugin (jsx)"]
130
+ direction TB
131
+ TUIEntry["tui entry<br/>(id: opencode-buddy)"]
132
+ View[View component<br/>SolidJS]
133
+ AnimTick[setInterval 600ms<br/>frame++]
134
+ RefreshTick[setInterval 1500ms<br/>mtime poll]
135
+ SlashCmds["slash commands<br/>feed / play / rest /<br/>status / rename / switch"]
136
+
137
+ TUIEntry --> View
138
+ TUIEntry --> SlashCmds
139
+ View --> AnimTick
140
+ View --> RefreshTick
141
+ end
142
+
143
+ subgraph State["Persistent state"]
144
+ StateFile[("~/.config/<br/>opencode-buddy/<br/>state.json")]
145
+ end
146
+
147
+ subgraph OpencodeServer["opencode server (same process)"]
148
+ Bus{{"event bus"}}
149
+ Sessions[Session events<br/>session.idle<br/>session.error]
150
+ end
151
+
152
+ U -->|types| Prompt
153
+ Prompt -->|/buddy-feed etc| Keymap
154
+ Keymap --> SlashCmds
155
+ SlashCmds -->|read+write| StateFile
156
+ SlashCmds -->|toast feedback| U
157
+
158
+ Slots -->|invokes renderer| View
159
+ View -->|render frame| Sidebar
160
+ View -->|reads mtime| StateFile
161
+ RefreshTick -->|state changed| View
162
+ Bus -->|session.idle| View
163
+ Bus -->|session.error| View
164
+ View -->|write derived state| StateFile
165
+
166
+ classDef store fill:#1e293b,stroke:#64748b,color:#f1f5f9
167
+ class StateFile store
168
+ ```
95
169
 
96
- ## How it works
170
+ ### Boot flow
97
171
 
98
- - **TUI plugin** (`src/tui-plugin.jsx`): registers `sidebar_content` slot, renders a Solid component that polls `~/.config/opencode-buddy/state.json` every 1.5s and animates at 6fps. Pure opencode-native — no tmux, no separate process.
99
- - **Server plugin** (`src/server-plugin.js`): registers a `buddy` tool the LLM can call. The same package exports both entrypoints (`main` for server, `exports["./tui"]` for TUI).
100
- - **State** at `~/.config/opencode-buddy/state.json` (~/.config is cross-platform via os.homedir()). Attributes decay over time. Sessions auto-celebrate on `session.idle` and auto-scare on `session.error`.
172
+ 1. opencode reads `~/.config/opencode/tui.json` and discovers the buddy entry under `plugin`.
173
+ 2. The TUI runtime loads `tui-plugin.jsx` from `opencode-buddy/tui`, which exports a `{ id, tui }` module.
174
+ 3. The `tui(api)` function runs once: it registers the `sidebar_content` slot and registers 6 slash commands on the keymap.
175
+ 4. The TUI's `<Slot name="sidebar_content" />` (in the sidebar component) resolves to the buddy's `View` component. Each render of the sidebar instantiates one `<View>`.
176
+ 5. The view runs two timers: a 1500 ms state-poll timer that reads `state.json` mtime, and a 600 ms animation timer that increments the frame counter.
177
+ 6. Slash commands typed in the prompt hit the keymap → buddy's `run()` → reads/writes `state.json` directly → toast feedback to the user.
178
+ 7. The view's reactive signals propagate state changes back to the sidebar render. No re-render of the TUI shell is needed.
101
179
 
102
- ## Uninstall
180
+ ### Why two config files?
103
181
 
104
- ```bash
105
- npm uninstall -g opencode-buddy
106
- ```
182
+ opencode has separate plugin registries for the **server** (LLM tools, file watching) and the **TUI** (sidebar slots, slash commands, keybindings). The buddy exports both:
107
183
 
108
- Then remove `"opencode-buddy"` from your `opencode.json` `plugin` array.
184
+ - `opencode-buddy` `src/server-plugin.js` (currently a no-op — we no longer expose an LLM tool)
185
+ - `opencode-buddy/tui` → `src/tui-plugin.jsx` (the sidebar slot + slash commands)
186
+
187
+ This split lets slash commands update state instantly without round-tripping through the LLM, which is the right UX for "I want to feed my pet right now" interactions.
109
188
 
110
189
  ## Project layout
111
190
 
@@ -115,13 +194,23 @@ opencode-buddy/
115
194
  ├── README.md
116
195
  ├── LICENSE
117
196
  └── src/
118
- ├── tui-plugin.jsx # v2 TUI plugin, mounts to sidebar_content
119
- ├── server-plugin.js # v1 server plugin, exposes `buddy` tool
120
- ├── species.js # ASCII art + per-species color palettes
121
- ├── state.js # state machine (hunger/happiness/energy decay)
122
- └── persistence.js # state file reader/writer
197
+ ├── tui-plugin.jsx # TUI plugin: slot + slash commands + event listeners
198
+ ├── server-plugin.js # Server plugin: no-op (LLM tool removed in 0.3.x)
199
+ ├── species.js # ASCII art + per-species color palettes + 3-frame idle loop
200
+ ├── state.js # state machine: tick, feed, play, rest, rename, switchSpecies, deriveState
201
+ └── persistence.js # atomic read/write of state.json
123
202
  ```
124
203
 
204
+ State is held at `~/.config/opencode-buddy/state.json`. `~/.config` resolves via `os.homedir()` so it works on Linux, macOS, and Windows.
205
+
206
+ ## Uninstall
207
+
208
+ ```bash
209
+ npm uninstall -g opencode-buddy
210
+ ```
211
+
212
+ Then remove `"opencode-buddy"` from `opencode.json` and `"opencode-buddy/tui"` from `tui.json` (the npm uninstall doesn't auto-edit user config).
213
+
125
214
  ## License
126
215
 
127
216
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-buddy",
3
- "version": "0.3.0",
3
+ "version": "0.3.3",
4
4
  "description": "A virtual ASCII pet companion that lives in the opencode TUI sidebar. Hatches, feeds, plays, and reacts to what you're coding.",
5
5
  "type": "module",
6
6
  "main": "src/server-plugin.js",
@@ -11,6 +11,7 @@
11
11
  },
12
12
  "files": [
13
13
  "src",
14
+ "scripts",
14
15
  "README.md",
15
16
  "LICENSE"
16
17
  ],
@@ -29,6 +30,9 @@
29
30
  "engines": {
30
31
  "node": ">=18"
31
32
  },
33
+ "scripts": {
34
+ "postinstall": "node scripts/postinstall.mjs"
35
+ },
32
36
  "author": "AMark-CS",
33
37
  "license": "MIT",
34
38
  "repository": {
@@ -39,6 +43,9 @@
39
43
  "url": "https://github.com/AMark-CS/opencode-buddy/issues"
40
44
  },
41
45
  "homepage": "https://github.com/AMark-CS/opencode-buddy#readme",
46
+ "dependencies": {
47
+ "@opentui/solid": "^0.3.1"
48
+ },
42
49
  "peerDependencies": {
43
50
  "@opencode-ai/plugin": "*"
44
51
  },
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // Postinstall: register the buddy plugin in opencode config files so the user
3
+ // only needs to run `npm install -g opencode-buddy` and restart opencode.
4
+ //
5
+ // We touch two files:
6
+ // - ~/.config/opencode/opencode.json (server plugin)
7
+ // - ~/.config/opencode/tui.json (TUI plugin, created if missing)
8
+ //
9
+ // We are idempotent: if the entry is already present, we leave it alone.
10
+ // We never overwrite a user's existing config — we only add the `plugin` key.
11
+
12
+ import { promises as fs } from "node:fs"
13
+ import path from "node:path"
14
+ import os from "node:os"
15
+
16
+ const SERVER_SPEC = "opencode-buddy"
17
+ const TUI_SPEC = "opencode-buddy/tui"
18
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode")
19
+ const SERVER_CONFIG = path.join(CONFIG_DIR, "opencode.json")
20
+ const TUI_CONFIG = path.join(CONFIG_DIR, "tui.json")
21
+
22
+ async function readJSON(filepath) {
23
+ try {
24
+ const raw = await fs.readFile(filepath, "utf8")
25
+ return { raw, data: JSON.parse(raw) }
26
+ } catch (err) {
27
+ if (err.code === "ENOENT") return null
28
+ throw err
29
+ }
30
+ }
31
+
32
+ function hasComments(raw) {
33
+ // JSONC comments: // line comments and /* block */ comments.
34
+ return /\/\/[^\n]*|\/\*[\s\S]*?\*\//.test(raw)
35
+ }
36
+
37
+ function ensurePluginArray(data, spec) {
38
+ if (!data || typeof data !== "object") return false
39
+ const list = Array.isArray(data.plugin) ? data.plugin : []
40
+ if (list.some((entry) => typeof entry === "string" && entry === spec)) return false
41
+ list.push(spec)
42
+ data.plugin = list
43
+ return true
44
+ }
45
+
46
+ async function updateConfig({ filepath, spec, fallback, mergeTopLevel }) {
47
+ let existing = await readJSON(filepath)
48
+
49
+ if (existing && hasComments(existing.raw)) {
50
+ // Don't touch JSONC files with comments — we'd strip them.
51
+ return { changed: false, created: false, skipped: true }
52
+ }
53
+
54
+ let data
55
+ let isNew
56
+
57
+ if (!existing) {
58
+ data = { ...fallback }
59
+ isNew = true
60
+ } else {
61
+ data = existing.data
62
+ isNew = false
63
+ }
64
+
65
+ if (mergeTopLevel) {
66
+ Object.assign(data, mergeTopLevel)
67
+ }
68
+
69
+ const changed = ensurePluginArray(data, spec)
70
+ if (!changed && !isNew) {
71
+ return { changed: false, created: false }
72
+ }
73
+
74
+ await fs.mkdir(path.dirname(filepath), { recursive: true })
75
+ const json = JSON.stringify(data, null, 2) + "\n"
76
+ await fs.writeFile(filepath, json)
77
+
78
+ if (isNew) return { changed: true, created: true }
79
+ return { changed: true, created: false }
80
+ }
81
+
82
+ async function main() {
83
+ const results = []
84
+
85
+ results.push({
86
+ file: SERVER_CONFIG,
87
+ ...(await updateConfig({
88
+ filepath: SERVER_CONFIG,
89
+ spec: SERVER_SPEC,
90
+ fallback: { $schema: "https://opencode.ai/config.json" },
91
+ })),
92
+ })
93
+
94
+ results.push({
95
+ file: TUI_CONFIG,
96
+ ...(await updateConfig({
97
+ filepath: TUI_CONFIG,
98
+ spec: TUI_SPEC,
99
+ fallback: { $schema: "https://opencode.ai/tui.json" },
100
+ })),
101
+ })
102
+
103
+ for (const r of results) {
104
+ if (r.changed) {
105
+ const action = r.created ? "created" : "updated"
106
+ console.log(`[opencode-buddy] ${action} ${r.file}`)
107
+ }
108
+ }
109
+ }
110
+
111
+ main().catch((err) => {
112
+ console.error("[opencode-buddy] postinstall: failed to register plugin", err.message)
113
+ // Don't fail the install — the user can configure manually if needed.
114
+ process.exit(0)
115
+ })
@@ -1,58 +1,69 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import type { TuiPlugin, TuiPluginApi, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
3
- import { createSignal, createMemo, onCleanup, Show, For } from "solid-js"
2
+ import { createSignal, createMemo, onCleanup, For } from "solid-js"
4
3
  import * as persistence from "./persistence.js"
5
4
  import {
6
5
  tick as tickState,
7
6
  celebrate as celebrateState,
8
7
  scared as scaredState,
9
8
  deriveState,
9
+ feed,
10
+ play,
11
+ rest,
12
+ rename,
13
+ switchSpecies,
10
14
  } from "./state.js"
11
- import { renderFrame, frameCount, frameIntervalMs } from "./species.js"
15
+ import { renderFrame, frameCount, frameIntervalMs, SPECIES } from "./species.js"
12
16
 
13
17
  const ANIMATION_TICK_MS = frameIntervalMs()
14
18
  const REFRESH_TICK_MS = 1500
15
19
 
16
- function View(props: { api: TuiPluginApi; session_id: string }) {
17
- const theme = () => props.api.theme.current
18
- const [state, setState] = createSignal<any | null>(null)
20
+ function View(props) {
21
+ const api = props.api
22
+ const theme = () => api.theme.current
23
+ const [state, setState] = createSignal(null)
19
24
  const [frame, setFrame] = createSignal(0)
20
25
  const [lastMtime, setLastMtime] = createSignal(0)
26
+ const [tick, setTick] = createSignal(0)
21
27
 
22
28
  const refresh = async () => {
23
- const mt = await persistence.mtime()
24
- if (mt === 0) {
25
- const fresh = {
26
- species: "duck",
27
- name: "Quack",
28
- hunger: 80,
29
- happiness: 80,
30
- energy: 100,
31
- xp: 0,
32
- level: 1,
33
- hatchedAt: Date.now(),
34
- lastFed: Date.now(),
35
- lastPlayed: Date.now(),
36
- lastTick: Date.now(),
37
- state: "idle",
38
- stateUntil: 0,
39
- stateReason: "",
29
+ try {
30
+ const mt = await persistence.mtime()
31
+ if (mt === 0) {
32
+ const fresh = {
33
+ species: "duck",
34
+ name: "Quack",
35
+ hunger: 80,
36
+ happiness: 80,
37
+ energy: 100,
38
+ xp: 0,
39
+ level: 1,
40
+ hatchedAt: Date.now(),
41
+ lastFed: Date.now(),
42
+ lastPlayed: Date.now(),
43
+ lastTick: Date.now(),
44
+ state: "idle",
45
+ stateUntil: 0,
46
+ stateReason: "",
47
+ }
48
+ await persistence.save(fresh)
49
+ setLastMtime(Date.now())
50
+ setState(fresh)
51
+ setTick(tick() + 1)
52
+ return
40
53
  }
41
- await persistence.save(fresh)
42
- setLastMtime(Date.now())
43
- setState(fresh)
44
- return
45
- }
46
- if (mt !== lastMtime()) {
47
- setLastMtime(mt)
48
- const onDisk = await persistence.load()
49
- if (onDisk) {
50
- const ticked = tickState(onDisk)
51
- const derived = deriveState(ticked)
52
- setState(derived)
53
- // Persist derived state (so transient state changes survive)
54
- persistence.save(derived).catch(() => {})
54
+ if (mt !== lastMtime()) {
55
+ setLastMtime(mt)
56
+ const onDisk = await persistence.load()
57
+ if (onDisk) {
58
+ const ticked = tickState(onDisk)
59
+ const derived = deriveState(ticked)
60
+ setState(derived)
61
+ setTick(tick() + 1)
62
+ persistence.save(derived).catch(() => {})
63
+ }
55
64
  }
65
+ } catch (err) {
66
+ console.error("[buddy] refresh error", err)
56
67
  }
57
68
  }
58
69
 
@@ -65,77 +76,199 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
65
76
  }, ANIMATION_TICK_MS)
66
77
  onCleanup(() => clearInterval(animTimer))
67
78
 
68
- const s = () => state()
69
- const species = () => s()?.species ?? "duck"
70
- const buddyState = () => s()?.state ?? "idle"
79
+ const current = state
80
+ const species = () => {
81
+ const v = current()
82
+ return v ? v.species : "duck"
83
+ }
84
+ const buddyState = () => {
85
+ const v = current()
86
+ return v ? v.state : "idle"
87
+ }
71
88
  const fc = createMemo(() => frameCount(species(), buddyState()))
72
89
  const lines = createMemo(() => renderFrame(species(), buddyState(), frame() % Math.max(1, fc())))
73
90
 
74
- const bar = (v: number, color: any) => {
91
+ const barStr = (v) => {
75
92
  const filled = Math.round((v / 100) * 10)
76
- return (
77
- <text>
78
- <span style={{ fg: theme().success }}>{"█".repeat(filled)}</span>
79
- <span style={{ fg: theme().borderSubtle }}>{"░".repeat(10 - filled)}</span>
80
- </text>
81
- )
93
+ return "|" + "\u2588".repeat(filled) + "\u2591".repeat(10 - filled) + "|"
94
+ }
95
+
96
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "")
97
+
98
+ const speciesColor = () => {
99
+ const sp = current()?.species
100
+ if (sp === "cat") return theme().accent
101
+ if (sp === "duck") return theme().success
102
+ if (sp === "dragon") return theme().error
103
+ if (sp === "axolotl") return theme().accent
104
+ if (sp === "robot") return theme().info
105
+ if (sp === "ghost") return theme().text
106
+ return theme().text
82
107
  }
83
108
 
84
109
  return (
85
- <Show when={s()}>
86
- <box flexDirection="column" paddingTop={1} paddingBottom={1}>
87
- <text fg={theme().accent}>
88
- <b> {s()!.name} the {species()}</b>
89
- </text>
90
- <For each={lines()}>
91
- {(line) => (
92
- <text>
93
- {" "}
94
- {line}
95
- </text>
96
- )}
97
- </For>
98
- <text fg={theme().textMuted}> {"─".repeat(20)}</text>
99
- <text>
100
- {" hunger "}
101
- {bar(s()!.hunger, theme().success)}
102
- {" "}
103
- {Math.floor(s()!.hunger)}
104
- </text>
105
- <text>
106
- {" happy "}
107
- {bar(s()!.happiness, theme().accent)}
108
- {" "}
109
- {Math.floor(s()!.happiness)}
110
- </text>
111
- <text>
112
- {" energy "}
113
- {bar(s()!.energy, theme().info)}
114
- {" "}
115
- {Math.floor(s()!.energy)}
116
- </text>
117
- <text fg={theme().textMuted}>
118
- {" "}
119
- {buddyState()} · Lv {s()!.level} · xp {s()!.xp}/{s()!.level * 50}
120
- </text>
121
- </box>
122
- </Show>
110
+ <box>
111
+ <text fg={theme().accent}>{current() ? current().name + " the " + current().species : "BUDDY"}</text>
112
+ <For each={lines()}>
113
+ {(line) => <text fg={speciesColor()}>{stripAnsi(line)}</text>}
114
+ </For>
115
+ <text fg={theme().textMuted}>hunger {barStr(current()?.hunger ?? 0)} {Math.floor(current()?.hunger ?? 0)}</text>
116
+ <text fg={theme().textMuted}>happy {barStr(current()?.happiness ?? 0)} {Math.floor(current()?.happiness ?? 0)}</text>
117
+ <text fg={theme().textMuted}>energy {barStr(current()?.energy ?? 0)} {Math.floor(current()?.energy ?? 0)}</text>
118
+ <text fg={theme().textMuted}>{current()?.state ?? "..."} Lv{current()?.level ?? 1} xp{current()?.xp ?? 0}/{(current()?.level ?? 1) * 50}</text>
119
+ </box>
123
120
  )
124
121
  }
125
122
 
126
- const tui: TuiPlugin = async (api) => {
127
- // Register the slot. `api` is captured in the closure so the slot
128
- // function can pass it to the Solid component.
123
+ const tui = async (api) => {
129
124
  api.slots.register({
130
- order: 500,
125
+ order: 50,
131
126
  slots: {
132
127
  sidebar_content(_ctx, props) {
133
128
  return <View api={api} session_id={props.session_id} />
134
129
  },
135
130
  },
136
- } as TuiSlotPlugin)
131
+ })
132
+
133
+ const feedBuddy = async () => {
134
+ const s = await persistence.load()
135
+ if (!s) return
136
+ const next = feed(s)
137
+ await persistence.save(next)
138
+ api.ui.toast({ variant: "success", title: "Yum!", message: `${next.name} ate a snack.` })
139
+ }
140
+ const playBuddy = async () => {
141
+ const s = await persistence.load()
142
+ if (!s) return
143
+ const next = play(s)
144
+ await persistence.save(next)
145
+ api.ui.toast({ variant: "success", title: "Woohoo!", message: `${next.name} played (+5xp).` })
146
+ }
147
+ const restBuddy = async () => {
148
+ const s = await persistence.load()
149
+ if (!s) return
150
+ const next = rest(s)
151
+ await persistence.save(next)
152
+ api.ui.toast({ variant: "info", title: "Zzz", message: `${next.name} is napping.` })
153
+ }
154
+ const statusBuddy = async () => {
155
+ const s = await persistence.load()
156
+ if (!s) return
157
+ api.ui.toast({
158
+ variant: "info",
159
+ title: `${s.name} the ${s.species}`,
160
+ message: `Lv${s.level} xp${s.xp}/${s.level * 50} - hunger ${Math.floor(s.hunger)} happy ${Math.floor(s.happiness)} energy ${Math.floor(s.energy)}`,
161
+ })
162
+ }
163
+ const renameBuddy = () => {
164
+ api.ui.dialog.replace(() => (
165
+ <api.ui.DialogPrompt
166
+ title="Rename buddy"
167
+ placeholder="New name (max 20 chars)"
168
+ onConfirm={async (value) => {
169
+ const s = await persistence.load()
170
+ if (!s) return
171
+ const next = rename(s, value)
172
+ await persistence.save(next)
173
+ api.ui.toast({ variant: "success", title: "Renamed", message: `Now called ${next.name}.` })
174
+ }}
175
+ onCancel={() => {}}
176
+ />
177
+ ))
178
+ }
179
+ const switchBuddy = () => {
180
+ const options = SPECIES.map((sp) => ({
181
+ title: sp,
182
+ value: sp,
183
+ onSelect: async () => {
184
+ const s = await persistence.load()
185
+ if (!s) return
186
+ const next = switchSpecies(s, sp)
187
+ await persistence.save(next)
188
+ api.ui.toast({ variant: "success", title: "Morphed", message: `Now a ${sp}.` })
189
+ api.ui.dialog.clear()
190
+ },
191
+ }))
192
+ api.ui.dialog.replace(() => (
193
+ <api.ui.DialogSelect
194
+ title="Switch species"
195
+ options={options}
196
+ onSelect={() => {}}
197
+ />
198
+ ))
199
+ }
200
+
201
+ api.keymap.registerLayer({
202
+ commands: [
203
+ {
204
+ namespace: "palette",
205
+ name: "buddy.feed",
206
+ title: "Feed buddy",
207
+ slashName: "buddy-feed",
208
+ category: "Buddy",
209
+ run() {
210
+ api.ui.dialog.clear()
211
+ void feedBuddy()
212
+ },
213
+ },
214
+ {
215
+ namespace: "palette",
216
+ name: "buddy.play",
217
+ title: "Play with buddy",
218
+ slashName: "buddy-play",
219
+ category: "Buddy",
220
+ run() {
221
+ api.ui.dialog.clear()
222
+ void playBuddy()
223
+ },
224
+ },
225
+ {
226
+ namespace: "palette",
227
+ name: "buddy.rest",
228
+ title: "Buddy rests",
229
+ slashName: "buddy-rest",
230
+ category: "Buddy",
231
+ run() {
232
+ api.ui.dialog.clear()
233
+ void restBuddy()
234
+ },
235
+ },
236
+ {
237
+ namespace: "palette",
238
+ name: "buddy.status",
239
+ title: "Show buddy status",
240
+ slashName: "buddy",
241
+ slashAliases: ["buddy-status"],
242
+ category: "Buddy",
243
+ run() {
244
+ api.ui.dialog.clear()
245
+ void statusBuddy()
246
+ },
247
+ },
248
+ {
249
+ namespace: "palette",
250
+ name: "buddy.rename",
251
+ title: "Rename buddy",
252
+ slashName: "buddy-rename",
253
+ category: "Buddy",
254
+ run() {
255
+ renameBuddy()
256
+ },
257
+ },
258
+ {
259
+ namespace: "palette",
260
+ name: "buddy.switch",
261
+ title: "Switch buddy species",
262
+ slashName: "buddy-switch",
263
+ category: "Buddy",
264
+ run() {
265
+ switchBuddy()
266
+ },
267
+ },
268
+ ],
269
+ bindings: [],
270
+ })
137
271
 
138
- // React to opencode session events
139
272
  api.event.on("session.idle", async () => {
140
273
  const s = await persistence.load()
141
274
  if (!s) return
@@ -148,9 +281,5 @@ const tui: TuiPlugin = async (api) => {
148
281
  })
149
282
  }
150
283
 
151
- const plugin: TuiPluginModule & { id: string } = {
152
- id: "opencode-buddy",
153
- tui,
154
- }
155
-
284
+ const plugin = { id: "opencode-buddy", tui }
156
285
  export default plugin