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 +146 -55
- package/package.json +5 -1
- package/scripts/postinstall.mjs +114 -0
- package/src/tui-plugin.jsx +225 -96
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
|
|
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
|
|
31
|
+
Requires opencode ≥ 1.15.
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
34
|
npm install -g opencode-buddy
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
|
|
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
|
-
##
|
|
63
|
+
## Usage
|
|
48
64
|
|
|
49
|
-
|
|
65
|
+
Once installed, type `/` in the prompt to see the slash commands. The buddy ships with six:
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
76
|
+
The buddy also reacts passively to your sessions:
|
|
60
77
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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
|
-
|
|
172
|
+
### Boot flow
|
|
97
173
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
182
|
+
### Why two config files with the same spec?
|
|
103
183
|
|
|
104
|
-
|
|
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
|
-
|
|
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 #
|
|
119
|
-
├── server-plugin.js #
|
|
120
|
-
├── species.js # ASCII art + per-species color palettes
|
|
121
|
-
├── state.js # state machine
|
|
122
|
-
└── persistence.js #
|
|
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.
|
|
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
|
+
})
|
package/src/tui-plugin.jsx
CHANGED
|
@@ -1,58 +1,69 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import
|
|
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
|
|
17
|
-
const
|
|
18
|
-
const
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
69
|
-
const species = () =>
|
|
70
|
-
|
|
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
|
|
91
|
+
const barStr = (v) => {
|
|
75
92
|
const filled = Math.round((v / 100) * 10)
|
|
76
|
-
return (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
<
|
|
86
|
-
<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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:
|
|
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
|
-
}
|
|
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
|
|
152
|
-
id: "opencode-buddy",
|
|
153
|
-
tui,
|
|
154
|
-
}
|
|
155
|
-
|
|
284
|
+
const plugin = { id: "opencode-buddy", tui }
|
|
156
285
|
export default plugin
|