opencode-buddy 0.3.3 → 0.3.7

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,7 +24,7 @@ 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, animates inside the TUI sidebar, and reacts to your coding sessions.
27
+ The buddy renders inside the TUI sidebar, reacts to your coding sessions (cheering when a turn finishes, getting scared on errors, falling asleep when low on energy), and can be controlled via slash commands typed directly in the prompt.
28
28
 
29
29
  ## Install
30
30
 
@@ -34,12 +34,12 @@ Requires opencode ≥ 1.15.
34
34
  npm install -g opencode-buddy
35
35
  ```
36
36
 
37
- The `postinstall` script automatically registers both plugin entrypoints in your opencode config:
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 based on runtime kind:
38
38
 
39
- - `opencode-buddy` → `~/.config/opencode/opencode.json` (server plugin)
40
- - `opencode-buddy/tui` → `~/.config/opencode/tui.json` (TUI plugin)
39
+ - `opencode.json` (kind: `server`) `src/server-plugin.js` via `main` (no-op since 0.3.x)
40
+ - `tui.json` (kind: `tui`) `src/tui-plugin.jsx` via `exports["./tui"]`
41
41
 
42
- If you have those config files as JSONC (with comments), the script skips them add the entries manually:
42
+ Both `~/.config/opencode/opencode.json` and `~/.config/opencode/tui.json` get the same `plugin: ["opencode-buddy"]` entry. The `postinstall` is JSONC-safe it leaves any file that contains comments alone and asks you to add the entry manually:
43
43
 
44
44
  ```jsonc
45
45
  // ~/.config/opencode/opencode.json
@@ -48,11 +48,11 @@ If you have those config files as JSONC (with comments), the script skips them
48
48
  }
49
49
  ```
50
50
 
51
- ```json
51
+ ```jsonc
52
52
  // ~/.config/opencode/tui.json
53
53
  {
54
54
  "$schema": "https://opencode.ai/tui.json",
55
- "plugin": ["opencode-buddy/tui"]
55
+ "plugin": ["opencode-buddy"]
56
56
  }
57
57
  ```
58
58
 
@@ -73,22 +73,37 @@ Once installed, type `/` in the prompt to see the slash commands. The buddy ship
73
73
 
74
74
  The buddy also reacts passively to your sessions:
75
75
 
76
- - `session.idle` → 4 second **celebrating** animation
77
- - `session.error` → 5 second **scared** animation
78
- - Energy < 20 → automatically transitions to **sleeping**
76
+ - `session.idle` → 4 second **celebrating** state
77
+ - `session.error` → 5 second **scared** state
78
+ - Energy < 20 → automatically transitions to **sleeping** (animation pauses)
79
79
  - Hunger < 25 → transitions to **scared** for 30 seconds
80
80
 
81
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.
82
82
 
83
+ ### What you see in the sidebar
84
+
85
+ The sidebar shows the buddy's species-specific ASCII art, plus four lines under it:
86
+
87
+ ```
88
+ [BDDY] Quack the cat <- species + name (preceded by a rotating status dot)
89
+ <art rows> <- 6 rows of the species art
90
+ hunger |████░░░░░░| 79 <- hunger bar
91
+ happy |███░░░░░░░| 60 <- happiness bar
92
+ energy |███████░░░| 70 <- energy bar
93
+ idle Lv1 xp5/50 <- state label + level + xp progress
94
+ ```
95
+
96
+ The bottom state label reflects the persisted `state` field in `state.json`. It changes on `session.idle` (4 s "celebrating"), `session.error` (5 s "scared"), and on energy/hunger thresholds (auto "sleeping" or "scared"). The art's idle state has 3 frames; the sidebar re-renders the current frame each time `state.json` is reloaded.
97
+
83
98
  ## Six species
84
99
 
85
100
  ```
86
101
  duck cat dragon
87
- __ /\_/\ /\_/\
88
- <(o )___ ( o.o ) ( o o ) ~~
89
- ( ._> / > ^ < > ^ < /
90
- `--' /| |\ /| |\
91
- ~ idle ~ (_| |_) (_| |_)
102
+ __ /\_/\ /^^\
103
+ <(o )___ ( o.o ) (o o) ~~
104
+ ( ._> / > ^ < >w< ~
105
+ `--' /| |\ /| |\
106
+ ~ idle ~ (_| |_) (_| |_)
92
107
  meow rawr
93
108
 
94
109
  axolotl robot ghost
@@ -100,7 +115,7 @@ State is shared across all opencode sessions via `~/.config/opencode-buddy/state
100
115
  ~ ambien beep boo
101
116
  ```
102
117
 
103
- Each species has a per-character color palette. The idle state is a 3-frame blink loop that runs at ~1.6 fps.
118
+ Each species has a per-character color palette. The idle state has 3 frames; `frameCount` is exposed so the View can pick a frame to render. The buddy renders whatever frame `frame() % fc()` returns at the moment of re-render.
104
119
 
105
120
  ## Architecture
106
121
 
@@ -112,7 +127,7 @@ flowchart LR
112
127
 
113
128
  subgraph OpencodeTUI["opencode TUI process (binary)"]
114
129
  direction TB
115
- Config[("tui.json<br/>plugin: [...]")]
130
+ Config[("tui.json<br/>plugin: [opencode-buddy]")]
116
131
  Runtime[TuiPluginRuntime]
117
132
  Sidebar[Sidebar component]
118
133
  Prompt[Prompt component]
@@ -130,13 +145,15 @@ flowchart LR
130
145
  direction TB
131
146
  TUIEntry["tui entry<br/>(id: opencode-buddy)"]
132
147
  View[View component<br/>SolidJS]
133
- AnimTick[setInterval 600ms<br/>frame++]
148
+ FrameSignal[("frame signal<br/>(plugin-level)")]
149
+ AnimTick[setInterval 300ms<br/>frame++ gated by energy]
134
150
  RefreshTick[setInterval 1500ms<br/>mtime poll]
135
151
  SlashCmds["slash commands<br/>feed / play / rest /<br/>status / rename / switch"]
136
152
 
153
+ TUIEntry --> FrameSignal
137
154
  TUIEntry --> View
138
155
  TUIEntry --> SlashCmds
139
- View --> AnimTick
156
+ FrameSignal --> AnimTick
140
157
  View --> RefreshTick
141
158
  end
142
159
 
@@ -162,6 +179,7 @@ flowchart LR
162
179
  Bus -->|session.idle| View
163
180
  Bus -->|session.error| View
164
181
  View -->|write derived state| StateFile
182
+ AnimTick -->|reads energy| StateFile
165
183
 
166
184
  classDef store fill:#1e293b,stroke:#64748b,color:#f1f5f9
167
185
  class StateFile store
@@ -170,21 +188,23 @@ flowchart LR
170
188
  ### Boot flow
171
189
 
172
190
  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.
191
+ 2. The TUI runtime loads `tui-plugin.jsx` from `opencode-buddy` (via `exports["./tui"]` in `package.json`).
192
+ 3. The `tui(api)` function runs once. It does three things:
193
+ - Sets up a **plugin-level frame signal** and a `setInterval` (300 ms) that ticks it. The frame signal is passed into the View as a prop, so the View is purely a consumer — its own lifecycle does not own the timer. This means slot re-renders cannot freeze the animation.
194
+ - Registers the `sidebar_content` slot. The slot renderer is cached on first call so the View instance is reused across re-renders.
195
+ - Registers 6 slash commands on the keymap.
196
+ 4. The TUI's `<Slot name="sidebar_content" />` (in the sidebar component) resolves to the buddy's cached `View`. The View reads the frame signal and re-renders the buddy art on every tick.
197
+ 5. The View also runs a 1500 ms timer that polls `state.json` mtime. When the file changes, the View reloads and re-renders. Slash commands write to `state.json` directly, so the next poll picks up the change.
198
+ 6. The `setInterval` for the frame signal also reads `state.json` each tick. If the buddy is `sleeping` or has energy < 20, the frame does not advance — the animation visually pauses while the buddy rests.
179
199
 
180
- ### Why two config files?
200
+ ### Why two config files with the same spec?
181
201
 
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:
202
+ 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:
183
203
 
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)
204
+ - `opencode.json` → `kind: "server"` → loads `src/server-plugin.js` via `main` (no-op since 0.3.x)
205
+ - `tui.json` → `kind: "tui"` → loads `src/tui-plugin.jsx` via `exports["./tui"]`
186
206
 
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.
207
+ 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.
188
208
 
189
209
  ## Project layout
190
210
 
@@ -193,10 +213,12 @@ opencode-buddy/
193
213
  ├── package.json
194
214
  ├── README.md
195
215
  ├── LICENSE
216
+ ├── scripts/
217
+ │ └── postinstall.mjs # auto-registers plugin in opencode.json + tui.json
196
218
  └── src/
197
- ├── tui-plugin.jsx # TUI plugin: slot + slash commands + event listeners
219
+ ├── tui-plugin.jsx # TUI plugin: slot + slash commands + frame timer
198
220
  ├── 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
221
+ ├── species.js # ASCII art + per-species palettes + 3-frame idle loop
200
222
  ├── state.js # state machine: tick, feed, play, rest, rename, switchSpecies, deriveState
201
223
  └── persistence.js # atomic read/write of state.json
202
224
  ```
@@ -209,7 +231,7 @@ State is held at `~/.config/opencode-buddy/state.json`. `~/.config` resolves via
209
231
  npm uninstall -g opencode-buddy
210
232
  ```
211
233
 
212
- Then remove `"opencode-buddy"` from `opencode.json` and `"opencode-buddy/tui"` from `tui.json` (the npm uninstall doesn't auto-edit user config).
234
+ Then remove `"opencode-buddy"` from `opencode.json` and from `tui.json` (the npm uninstall does not auto-edit user config).
213
235
 
214
236
  ## License
215
237
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-buddy",
3
- "version": "0.3.3",
3
+ "version": "0.3.7",
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",
@@ -13,8 +13,7 @@ import { promises as fs } from "node:fs"
13
13
  import path from "node:path"
14
14
  import os from "node:os"
15
15
 
16
- const SERVER_SPEC = "opencode-buddy"
17
- const TUI_SPEC = "opencode-buddy/tui"
16
+ const PLUGIN_SPEC = "opencode-buddy"
18
17
  const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode")
19
18
  const SERVER_CONFIG = path.join(CONFIG_DIR, "opencode.json")
20
19
  const TUI_CONFIG = path.join(CONFIG_DIR, "tui.json")
@@ -86,7 +85,7 @@ async function main() {
86
85
  file: SERVER_CONFIG,
87
86
  ...(await updateConfig({
88
87
  filepath: SERVER_CONFIG,
89
- spec: SERVER_SPEC,
88
+ spec: PLUGIN_SPEC,
90
89
  fallback: { $schema: "https://opencode.ai/config.json" },
91
90
  })),
92
91
  })
@@ -95,7 +94,7 @@ async function main() {
95
94
  file: TUI_CONFIG,
96
95
  ...(await updateConfig({
97
96
  filepath: TUI_CONFIG,
98
- spec: TUI_SPEC,
97
+ spec: PLUGIN_SPEC,
99
98
  fallback: { $schema: "https://opencode.ai/tui.json" },
100
99
  })),
101
100
  })
package/src/species.js CHANGED
@@ -27,27 +27,27 @@ const C = {
27
27
  const FRAMES = {
28
28
  duck: {
29
29
  idle: [
30
- // frame 0: blink open
30
+ // frame 0: bob up, blink open
31
31
  [
32
- ` __ `,
32
+ ` __ ~ `,
33
33
  ` <(o )___ `,
34
34
  ` ( ._> / `,
35
35
  ` \`--' `,
36
36
  ` `,
37
37
  ` ~ idle ~ `,
38
38
  ],
39
- // frame 1: blink closed
39
+ // frame 1: bob up, blink closed
40
40
  [
41
- ` __ `,
41
+ ` __ ~ `,
42
42
  ` <(- )___ `,
43
43
  ` ( ._> / `,
44
44
  ` \`--' `,
45
45
  ` `,
46
46
  ` ~ idle ~ `,
47
47
  ],
48
- // frame 2: blink open (same as 0, ends loop)
48
+ // frame 2: same as 0
49
49
  [
50
- ` __ `,
50
+ ` __ ~ `,
51
51
  ` <(o )___ `,
52
52
  ` ( ._> / `,
53
53
  ` \`--' `,
@@ -80,19 +80,19 @@ const FRAMES = {
80
80
  ` __ `,
81
81
  ` <(O )___ `,
82
82
  ` ( ._> / `,
83
- ` \`--' !! `,
84
- ` `,
85
- ` !! oh no `,
83
+ ` \`--' `,
84
+ ` !! !! `,
85
+ ` ! quack `,
86
86
  ],
87
87
  ],
88
88
  sleeping: [
89
89
  [
90
90
  ` __ `,
91
- ` <(o )___ `,
92
- ` ( -_> z `,
93
- ` \`--' z `,
94
- ` `,
95
- ` z z z z z `,
91
+ ` <(- )___ `,
92
+ ` ( ._> / `,
93
+ ` \`--' `,
94
+ ` z `,
95
+ ` z z z z `,
96
96
  ],
97
97
  ],
98
98
  },
@@ -101,7 +101,7 @@ const FRAMES = {
101
101
  idle: [
102
102
  [
103
103
  ` /\\_/\\ `,
104
- ` ( o.o ) `,
104
+ ` ( o.o ) ~ `,
105
105
  ` > ^ < `,
106
106
  ` /| |\\ `,
107
107
  ` (_| |_) `,
@@ -109,7 +109,7 @@ const FRAMES = {
109
109
  ],
110
110
  [
111
111
  ` /\\_/\\ `,
112
- ` ( -.o ) `,
112
+ ` ( -.o ) ~ `,
113
113
  ` > ^ < `,
114
114
  ` /| |\\ `,
115
115
  ` (_| |_) `,
@@ -117,7 +117,7 @@ const FRAMES = {
117
117
  ],
118
118
  [
119
119
  ` /\\_/\\ `,
120
- ` ( o.o ) `,
120
+ ` ( o.o ) ~ `,
121
121
  ` > ^ < `,
122
122
  ` /| |\\ `,
123
123
  ` (_| |_) `,
@@ -136,101 +136,104 @@ const FRAMES = {
136
136
  ],
137
137
  celebrating: [
138
138
  [
139
- ` /\\_/\\ `,
140
- ` ( ^.^ ) \\o/ `,
141
- ` > ~ < `,
142
- ` /| |\\ \\o/ `,
139
+ ` /\\_/\\ \\o/ `,
140
+ ` ( ^.^ ) `,
141
+ ` > ~ < \\o/ `,
142
+ ` /| |\\ `,
143
143
  ` (_| |_) `,
144
- ` purrrfect! `,
144
+ ` +5 xp! \\o/ `,
145
145
  ],
146
146
  ],
147
147
  scared: [
148
148
  [
149
- ` /\\_/\\ !! `,
150
- ` ( O.O ) `,
149
+ ` /\\_/\\ !! `,
150
+ ` ( O.O ) !! `,
151
151
  ` > ^ < `,
152
- ` /| |\\ `,
153
- ` (_| |_) /\\ `,
152
+ ` /| |\\ !! `,
153
+ ` (_| |_) `,
154
154
  ` !! hiss `,
155
155
  ],
156
156
  ],
157
157
  sleeping: [
158
158
  [
159
159
  ` /\\_/\\ `,
160
- ` ( -.- ) z `,
160
+ ` ( -.- ) z `,
161
161
  ` > ^ < `,
162
- ` /| |\\ z `,
162
+ ` /| |\\ z `,
163
163
  ` (_| |_) `,
164
- ` z z z z z `,
164
+ ` z z z z `,
165
165
  ],
166
166
  ],
167
167
  },
168
168
 
169
169
  dragon: {
170
170
  idle: [
171
+ // wings trailing right, tail left
171
172
  [
172
- ` /\\_/\\ `,
173
- ` ( o o ) ~~ `,
174
- ` > ^ < / `,
175
- ` /| |\\ `,
176
- ` (_| |_) `,
177
- ` rawr `,
173
+ ` /^^\\ `,
174
+ ` (o o) ~~ `,
175
+ ` >w< ~ `,
176
+ ` /| |\\ `,
177
+ ` (_| |_) ~ `,
178
+ ` rawr! `,
178
179
  ],
180
+ // blink, wings up, tail mid
179
181
  [
180
- ` /\\_/\\ `,
181
- ` ( - - ) ~~ `,
182
- ` > ^ < / `,
183
- ` /| |\\ `,
184
- ` (_| |_) `,
185
- ` rawr `,
182
+ ` /^^\\ `,
183
+ ` (- -) ~~~ `,
184
+ ` >w< ~ `,
185
+ ` /| |\\ `,
186
+ ` (_| |_) ~ `,
187
+ ` rawr! `,
186
188
  ],
189
+ // wings mid, tail right
187
190
  [
188
- ` /\\_/\\ `,
189
- ` ( o o ) ~~ `,
190
- ` > ^ < / `,
191
- ` /| |\\ `,
192
- ` (_| |_) `,
193
- ` rawr `,
191
+ ` /^^\\ `,
192
+ ` (o o) ~~ `,
193
+ ` >w< ~ `,
194
+ ` /| |\\ `,
195
+ ` (_| |_) ~ `,
196
+ ` rawr! `,
194
197
  ],
195
198
  ],
196
199
  working: [
197
200
  [
198
- ` /\\_/\\ ~~~ `,
199
- ` ( o o ) ~~ `,
200
- ` > ^ < `,
201
- ` /| |\\ ~~ `,
202
- ` (_| |_) `,
203
- ` forging... `,
201
+ ` /^^\\ ~~~ `,
202
+ ` (o o) ~~~ `,
203
+ ` >w< ~~ `,
204
+ ` /| |\\ ~~~ `,
205
+ ` (_| |_) `,
206
+ ` forging... `,
204
207
  ],
205
208
  ],
206
209
  celebrating: [
207
210
  [
208
- ` /\\^/\\ \\o/ `,
209
- ` ( ^.^ ) `,
210
- ` > ~ < \\o/ `,
211
- ` /| |\\ `,
212
- ` (_| |_) `,
213
- ` +50 xp!! \\o/ `,
211
+ ` /^^\\ \\o/ `,
212
+ ` (^o^) `,
213
+ ` >w< \\o/ `,
214
+ ` /| |\\ \\o/ `,
215
+ ` (_| |_) `,
216
+ ` ROAR! \\o/ `,
214
217
  ],
215
218
  ],
216
219
  scared: [
217
220
  [
218
- ` /\\_/\\ !! `,
219
- ` ( O O ) `,
220
- ` > ^ < `,
221
- ` /| |\\ !! `,
222
- ` (_| |_) `,
223
- ` !! yikes `,
221
+ ` /^^\\ !! `,
222
+ ` (O O) !! `,
223
+ ` >w< `,
224
+ ` /| |\\ !! `,
225
+ ` (_| |_) `,
226
+ ` ! yikes `,
224
227
  ],
225
228
  ],
226
229
  sleeping: [
227
230
  [
228
- ` /\\_/\\ `,
229
- ` ( - - ) z `,
230
- ` > ^ < `,
231
- ` /| |\\ z `,
232
- ` (_| |_) `,
233
- ` z z z z z `,
231
+ ` /^^\\ z `,
232
+ ` (- -) z `,
233
+ ` >w< `,
234
+ ` /| |\\ z `,
235
+ ` (_| |_) `,
236
+ ` z z z z `,
234
237
  ],
235
238
  ],
236
239
  },
@@ -239,7 +242,7 @@ const FRAMES = {
239
242
  idle: [
240
243
  [
241
244
  ` ^___^ `,
242
- ` (o . o) `,
245
+ ` (o . o)~ `,
243
246
  ` \\|_|_|/ `,
244
247
  ` \\| |/ `,
245
248
  ` ) ( `,
@@ -247,7 +250,7 @@ const FRAMES = {
247
250
  ],
248
251
  [
249
252
  ` ^___^ `,
250
- ` (o - o) `,
253
+ ` (o - o) ~ `,
251
254
  ` \\|_|_|/ `,
252
255
  ` \\| |/ `,
253
256
  ` ) ( `,
@@ -255,7 +258,7 @@ const FRAMES = {
255
258
  ],
256
259
  [
257
260
  ` ^___^ `,
258
- ` (o . o) `,
261
+ ` (o . o) ~ `,
259
262
  ` \\|_|_|/ `,
260
263
  ` \\| |/ `,
261
264
  ` ) ( `,
@@ -315,16 +318,16 @@ const FRAMES = {
315
318
  ` beep `,
316
319
  ],
317
320
  [
318
- ` [ O . O ] `,
321
+ ` [- . - ] . `,
319
322
  ` /|#####|\\ `,
320
323
  ` / |#####| \\ `,
321
324
  ` | | `,
322
325
  ` /| | | |\\ `,
323
- ` beep `,
326
+ ` bzzt `,
324
327
  ],
325
328
  [
326
329
  ` [ O . O ] `,
327
- ` /|#####|\\ `,
330
+ ` /|#####|\\ . `,
328
331
  ` / |#####| \\ `,
329
332
  ` | | `,
330
333
  ` /| | | |\\ `,
@@ -384,15 +387,15 @@ const FRAMES = {
384
387
  ` boo `,
385
388
  ],
386
389
  [
387
- ` .-""-. `,
388
- ` ( o . o ) `,
390
+ ` .-""-. o `,
391
+ ` ( - . - ) `,
389
392
  ` | ~ ~ | `,
390
393
  ` | | `,
391
394
  ` \\uuuuu/ `,
392
395
  ` boo `,
393
396
  ],
394
397
  [
395
- ` .-""-. `,
398
+ ` .-""-. o `,
396
399
  ` ( o . o ) `,
397
400
  ` | ~ ~ | `,
398
401
  ` | | `,
@@ -500,7 +503,7 @@ const PALETTES = {
500
503
  export const SPECIES = ["duck", "cat", "dragon", "axolotl", "robot", "ghost"];
501
504
  export const STATES = ["idle", "working", "celebrating", "scared", "sleeping"];
502
505
 
503
- const FRAME_INTERVAL_MS = 600;
506
+ const FRAME_INTERVAL_MS = 300;
504
507
 
505
508
  export function framesFor(species, state) {
506
509
  const list = FRAMES[species]?.[state] ?? FRAMES.duck.idle;
@@ -529,6 +532,9 @@ export function renderFrame(species, state, frameIdx) {
529
532
  const frames = framesFor(species, state);
530
533
  const i = ((frameIdx % frames.length) + frames.length) % frames.length;
531
534
  const palette = PALETTES[species];
535
+ // Idle motion lives in the art itself (wagging tails, flapping wings,
536
+ // bobbing bodies) so the For reconciliation always sees new content
537
+ // each frame and re-renders. No whitespace trickery required.
532
538
  return frames[i].map((row) => {
533
539
  let out = "";
534
540
  for (const ch of row) out += palette(ch);
@@ -1,5 +1,5 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { createSignal, createMemo, onCleanup, For } from "solid-js"
2
+ import { createSignal, createMemo, onCleanup } from "solid-js"
3
3
  import * as persistence from "./persistence.js"
4
4
  import {
5
5
  tick as tickState,
@@ -20,8 +20,8 @@ const REFRESH_TICK_MS = 1500
20
20
  function View(props) {
21
21
  const api = props.api
22
22
  const theme = () => api.theme.current
23
+ const frame = props.frame
23
24
  const [state, setState] = createSignal(null)
24
- const [frame, setFrame] = createSignal(0)
25
25
  const [lastMtime, setLastMtime] = createSignal(0)
26
26
  const [tick, setTick] = createSignal(0)
27
27
 
@@ -71,16 +71,17 @@ function View(props) {
71
71
  const refreshTimer = setInterval(refresh, REFRESH_TICK_MS)
72
72
  onCleanup(() => clearInterval(refreshTimer))
73
73
 
74
- const animTimer = setInterval(() => {
75
- setFrame((f) => f + 1)
76
- }, ANIMATION_TICK_MS)
77
- onCleanup(() => clearInterval(animTimer))
74
+ // The frame signal lives at the plugin level (see tui() below). It
75
+ // is passed in as a prop so this View can read its current value
76
+ // without owning a timer of its own.
78
77
 
79
78
  const current = state
80
79
  const species = () => {
81
80
  const v = current()
82
81
  return v ? v.species : "duck"
83
82
  }
83
+ // Only animate when the buddy is awake (not sleeping). Sleeping
84
+ // buddies hold a single frame so the user can see they're resting.
84
85
  const buddyState = () => {
85
86
  const v = current()
86
87
  return v ? v.state : "idle"
@@ -106,12 +107,18 @@ function View(props) {
106
107
  return theme().text
107
108
  }
108
109
 
110
+ const SPINNER = ["\u25D0", "\u25D3", "\u25D1", "\u25D2"]
111
+ const spinner = () => SPINNER[frame() % SPINNER.length]
112
+
109
113
  return (
110
114
  <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().accent}>{spinner()} {current() ? current().name + " the " + current().species : "BUDDY"}</text>
116
+ <text fg={speciesColor()}>{stripAnsi(lines()[0] ?? "")}</text>
117
+ <text fg={speciesColor()}>{stripAnsi(lines()[1] ?? "")}</text>
118
+ <text fg={speciesColor()}>{stripAnsi(lines()[2] ?? "")}</text>
119
+ <text fg={speciesColor()}>{stripAnsi(lines()[3] ?? "")}</text>
120
+ <text fg={speciesColor()}>{stripAnsi(lines()[4] ?? "")}</text>
121
+ <text fg={speciesColor()}>{stripAnsi(lines()[5] ?? "")}</text>
115
122
  <text fg={theme().textMuted}>hunger {barStr(current()?.hunger ?? 0)} {Math.floor(current()?.hunger ?? 0)}</text>
116
123
  <text fg={theme().textMuted}>happy {barStr(current()?.happiness ?? 0)} {Math.floor(current()?.happiness ?? 0)}</text>
117
124
  <text fg={theme().textMuted}>energy {barStr(current()?.energy ?? 0)} {Math.floor(current()?.energy ?? 0)}</text>
@@ -121,11 +128,34 @@ function View(props) {
121
128
  }
122
129
 
123
130
  const tui = async (api) => {
131
+ // The animation timer lives at the plugin level so it survives View
132
+ // remounts. We expose a single global frame counter via a SolidJS
133
+ // signal stored on the api object, which every View can read.
134
+ // This decouples the animation loop from any individual View's
135
+ // lifecycle.
136
+ const [getFrame, setFrame] = createSignal(0)
137
+ api.__buddyFrame = { getFrame, setFrame }
138
+ setInterval(async () => {
139
+ // Read current state from disk. If the buddy is sleeping or has
140
+ // very low energy, freeze the frame so animation visually pauses
141
+ // — a sleeping buddy should not be flickering.
142
+ const s = await persistence.load()
143
+ const sleeping = s && (s.state === "sleeping" || s.energy < 20)
144
+ if (!sleeping) setFrame((f) => f + 1)
145
+ }, ANIMATION_TICK_MS)
146
+
147
+ // Cache the View element so slot re-renders don't recreate it.
148
+ const viewCache = new Map()
149
+
124
150
  api.slots.register({
125
151
  order: 50,
126
152
  slots: {
127
153
  sidebar_content(_ctx, props) {
128
- return <View api={api} session_id={props.session_id} />
154
+ const key = props.session_id ?? "default"
155
+ if (!viewCache.has(key)) {
156
+ viewCache.set(key, <View api={api} session_id={props.session_id} frame={getFrame} />)
157
+ }
158
+ return viewCache.get(key)
129
159
  },
130
160
  },
131
161
  })