opencode-buddy 0.3.1 → 0.3.4

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,169 @@ 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 the plugin in both config files using the same spec. opencode picks the right entrypoint from the package's `exports` field:
38
+
39
+ - `opencode-buddy` (main) → `src/server-plugin.js` (server plugin, no-op since 0.3.x)
40
+ - `opencode-buddy` with kind `tui` → `src/tui-plugin.jsx` (TUI plugin)
41
+
42
+ Both `~/.config/opencode/opencode.json` and `~/.config/opencode/tui.json` get the same `plugin: ["opencode-buddy"]` entry.
43
+
44
+ If you have those config files as JSONC (with comments), the script skips them — add the entries manually:
45
+
46
+ ```jsonc
47
+ // ~/.config/opencode/opencode.json
48
+ {
49
+ "plugin": ["opencode-buddy"]
50
+ }
51
+ ```
38
52
 
39
53
  ```json
54
+ // ~/.config/opencode/tui.json
40
55
  {
56
+ "$schema": "https://opencode.ai/tui.json",
41
57
  "plugin": ["opencode-buddy"]
42
58
  }
43
59
  ```
44
60
 
45
61
  Restart opencode. The buddy appears in the sidebar.
46
62
 
47
- ## How to interact
63
+ ## Usage
48
64
 
49
- The buddy has a `buddy` tool the LLM can call. Just talk to it:
65
+ Once installed, type `/` in the prompt to see the slash commands. The buddy ships with six:
50
66
 
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
- ```
67
+ | Slash command | Effect |
68
+ | --- | --- |
69
+ | `/buddy` | Show the buddy's current stats as a toast |
70
+ | `/buddy-feed` | Feed the buddy (+25 hunger, -5 energy) |
71
+ | `/buddy-play` | Play with the buddy (+20 happiness, +5 xp, -5 hunger) |
72
+ | `/buddy-rest` | Let the buddy rest (+30 energy) |
73
+ | `/buddy-rename` | Open a prompt to rename the buddy (max 20 chars) |
74
+ | `/buddy-switch` | Open a picker to switch to duck, cat, dragon, axolotl, robot, or ghost |
58
75
 
59
- The LLM will call the `buddy` tool with the right action. Available actions:
76
+ The buddy also reacts passively to your sessions:
60
77
 
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 |
78
+ - `session.idle` 4 second **celebrating** animation
79
+ - `session.error` 5 second **scared** animation
80
+ - Energy < 20 automatically transitions to **sleeping**
81
+ - Hunger < 25 transitions to **scared** for 30 seconds
82
+
83
+ 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
84
 
74
85
  ## Six species
75
86
 
76
87
  ```
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
88
+ duck cat dragon
89
+ __ /\_/\ /\_/\
90
+ <(o )___ ( o.o ) ( o o ) ~~
91
+ ( ._> / > ^ < > ^ < /
92
+ `--' /| |\ /| |\
93
+ ~ idle ~ (_| |_) (_| |_)
94
+ meow rawr
95
+
96
+ axolotl robot ghost
97
+ ^___^ [ O . O ] .-"-"-.
98
+ (o . o) /|#####|\ ( o . o )
99
+ \|_|_|/ / |#####| \ | ~ ~ |
100
+ \| |/ | | | |
101
+ ) ( /| | | |\ \uuuuu/
102
+ ~ ambien beep boo
92
103
  ```
93
104
 
94
- Each species has a per-character color palette. Idle state has 3 frames (blink loop) at 600ms per frame.
105
+ Each species has a per-character color palette. The idle state is a 3-frame blink loop that runs at ~1.6 fps.
106
+
107
+ ## Architecture
108
+
109
+ ```mermaid
110
+ flowchart LR
111
+ subgraph User
112
+ U[Opencode TUI user]
113
+ end
114
+
115
+ subgraph OpencodeTUI["opencode TUI process (binary)"]
116
+ direction TB
117
+ Config[("tui.json<br/>plugin: [...]")]
118
+ Runtime[TuiPluginRuntime]
119
+ Sidebar[Sidebar component]
120
+ Prompt[Prompt component]
121
+ Slots{{"slot registry<br/>sidebar_content"}}
122
+ Keymap{{"keymap<br/>(slash commands)"}}
123
+
124
+ Config --> Runtime
125
+ Runtime --> Slots
126
+ Runtime --> Keymap
127
+ Slots --> Sidebar
128
+ Keymap --> Prompt
129
+ end
130
+
131
+ subgraph Buddy["opencode-buddy plugin (jsx)"]
132
+ direction TB
133
+ TUIEntry["tui entry<br/>(id: opencode-buddy)"]
134
+ View[View component<br/>SolidJS]
135
+ AnimTick[setInterval 600ms<br/>frame++]
136
+ RefreshTick[setInterval 1500ms<br/>mtime poll]
137
+ SlashCmds["slash commands<br/>feed / play / rest /<br/>status / rename / switch"]
138
+
139
+ TUIEntry --> View
140
+ TUIEntry --> SlashCmds
141
+ View --> AnimTick
142
+ View --> RefreshTick
143
+ end
144
+
145
+ subgraph State["Persistent state"]
146
+ StateFile[("~/.config/<br/>opencode-buddy/<br/>state.json")]
147
+ end
148
+
149
+ subgraph OpencodeServer["opencode server (same process)"]
150
+ Bus{{"event bus"}}
151
+ Sessions[Session events<br/>session.idle<br/>session.error]
152
+ end
153
+
154
+ U -->|types| Prompt
155
+ Prompt -->|/buddy-feed etc| Keymap
156
+ Keymap --> SlashCmds
157
+ SlashCmds -->|read+write| StateFile
158
+ SlashCmds -->|toast feedback| U
159
+
160
+ Slots -->|invokes renderer| View
161
+ View -->|render frame| Sidebar
162
+ View -->|reads mtime| StateFile
163
+ RefreshTick -->|state changed| View
164
+ Bus -->|session.idle| View
165
+ Bus -->|session.error| View
166
+ View -->|write derived state| StateFile
167
+
168
+ classDef store fill:#1e293b,stroke:#64748b,color:#f1f5f9
169
+ class StateFile store
170
+ ```
95
171
 
96
- ## How it works
172
+ ### Boot flow
97
173
 
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`.
174
+ 1. opencode reads `~/.config/opencode/tui.json` and discovers the buddy entry under `plugin`.
175
+ 2. The TUI runtime loads `tui-plugin.jsx` from `opencode-buddy/tui`, which exports a `{ id, tui }` module.
176
+ 3. The `tui(api)` function runs once: it registers the `sidebar_content` slot and registers 6 slash commands on the keymap.
177
+ 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>`.
178
+ 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.
179
+ 6. Slash commands typed in the prompt hit the keymap → buddy's `run()` → reads/writes `state.json` directly → toast feedback to the user.
180
+ 7. The view's reactive signals propagate state changes back to the sidebar render. No re-render of the TUI shell is needed.
101
181
 
102
- ## Uninstall
182
+ ### Why two config files with the same spec?
103
183
 
104
- ```bash
105
- npm uninstall -g opencode-buddy
106
- ```
184
+ opencode has separate plugin registries for the **server** (LLM tools, file watching) and the **TUI** (sidebar slots, slash commands, keybindings). When the same package spec appears in both, opencode looks at the package's `exports` field to pick the right entrypoint:
107
185
 
108
- Then remove `"opencode-buddy"` from your `opencode.json` `plugin` array.
186
+ - `opencode.json` `kind: "server"` loads `src/server-plugin.js` via `main` (currently a no-op — we no longer expose an LLM tool)
187
+ - `tui.json` → `kind: "tui"` → loads `src/tui-plugin.jsx` via `exports["./tui"]` (the sidebar slot + slash commands)
188
+
189
+ 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
190
 
110
191
  ## Project layout
111
192
 
@@ -115,13 +196,23 @@ opencode-buddy/
115
196
  ├── README.md
116
197
  ├── LICENSE
117
198
  └── 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
199
+ ├── tui-plugin.jsx # TUI plugin: slot + slash commands + event listeners
200
+ ├── server-plugin.js # Server plugin: no-op (LLM tool removed in 0.3.x)
201
+ ├── species.js # ASCII art + per-species color palettes + 3-frame idle loop
202
+ ├── state.js # state machine: tick, feed, play, rest, rename, switchSpecies, deriveState
203
+ └── persistence.js # atomic read/write of state.json
123
204
  ```
124
205
 
206
+ State is held at `~/.config/opencode-buddy/state.json`. `~/.config` resolves via `os.homedir()` so it works on Linux, macOS, and Windows.
207
+
208
+ ## Uninstall
209
+
210
+ ```bash
211
+ npm uninstall -g opencode-buddy
212
+ ```
213
+
214
+ Then remove `"opencode-buddy"` from `opencode.json` and `"opencode-buddy/tui"` from `tui.json` (the npm uninstall doesn't auto-edit user config).
215
+
125
216
  ## License
126
217
 
127
218
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-buddy",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
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": {
@@ -0,0 +1,114 @@
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 PLUGIN_SPEC = "opencode-buddy"
17
+ const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode")
18
+ const SERVER_CONFIG = path.join(CONFIG_DIR, "opencode.json")
19
+ const TUI_CONFIG = path.join(CONFIG_DIR, "tui.json")
20
+
21
+ async function readJSON(filepath) {
22
+ try {
23
+ const raw = await fs.readFile(filepath, "utf8")
24
+ return { raw, data: JSON.parse(raw) }
25
+ } catch (err) {
26
+ if (err.code === "ENOENT") return null
27
+ throw err
28
+ }
29
+ }
30
+
31
+ function hasComments(raw) {
32
+ // JSONC comments: // line comments and /* block */ comments.
33
+ return /\/\/[^\n]*|\/\*[\s\S]*?\*\//.test(raw)
34
+ }
35
+
36
+ function ensurePluginArray(data, spec) {
37
+ if (!data || typeof data !== "object") return false
38
+ const list = Array.isArray(data.plugin) ? data.plugin : []
39
+ if (list.some((entry) => typeof entry === "string" && entry === spec)) return false
40
+ list.push(spec)
41
+ data.plugin = list
42
+ return true
43
+ }
44
+
45
+ async function updateConfig({ filepath, spec, fallback, mergeTopLevel }) {
46
+ let existing = await readJSON(filepath)
47
+
48
+ if (existing && hasComments(existing.raw)) {
49
+ // Don't touch JSONC files with comments — we'd strip them.
50
+ return { changed: false, created: false, skipped: true }
51
+ }
52
+
53
+ let data
54
+ let isNew
55
+
56
+ if (!existing) {
57
+ data = { ...fallback }
58
+ isNew = true
59
+ } else {
60
+ data = existing.data
61
+ isNew = false
62
+ }
63
+
64
+ if (mergeTopLevel) {
65
+ Object.assign(data, mergeTopLevel)
66
+ }
67
+
68
+ const changed = ensurePluginArray(data, spec)
69
+ if (!changed && !isNew) {
70
+ return { changed: false, created: false }
71
+ }
72
+
73
+ await fs.mkdir(path.dirname(filepath), { recursive: true })
74
+ const json = JSON.stringify(data, null, 2) + "\n"
75
+ await fs.writeFile(filepath, json)
76
+
77
+ if (isNew) return { changed: true, created: true }
78
+ return { changed: true, created: false }
79
+ }
80
+
81
+ async function main() {
82
+ const results = []
83
+
84
+ results.push({
85
+ file: SERVER_CONFIG,
86
+ ...(await updateConfig({
87
+ filepath: SERVER_CONFIG,
88
+ spec: PLUGIN_SPEC,
89
+ fallback: { $schema: "https://opencode.ai/config.json" },
90
+ })),
91
+ })
92
+
93
+ results.push({
94
+ file: TUI_CONFIG,
95
+ ...(await updateConfig({
96
+ filepath: TUI_CONFIG,
97
+ spec: PLUGIN_SPEC,
98
+ fallback: { $schema: "https://opencode.ai/tui.json" },
99
+ })),
100
+ })
101
+
102
+ for (const r of results) {
103
+ if (r.changed) {
104
+ const action = r.created ? "created" : "updated"
105
+ console.log(`[opencode-buddy] ${action} ${r.file}`)
106
+ }
107
+ }
108
+ }
109
+
110
+ main().catch((err) => {
111
+ console.error("[opencode-buddy] postinstall: failed to register plugin", err.message)
112
+ // Don't fail the install — the user can configure manually if needed.
113
+ process.exit(0)
114
+ })
@@ -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