@ramarivera/coding-buddy 0.8.0 → 0.9.0

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.
Files changed (52) hide show
  1. package/README.md +23 -4
  2. package/adapters/omp/context.ts +1 -1
  3. package/adapters/omp/events.ts +286 -81
  4. package/adapters/omp/index.ts +3 -1
  5. package/adapters/omp/renderers.ts +4 -54
  6. package/adapters/omp/storage.ts +6 -1
  7. package/adapters/omp/ui.ts +30 -1
  8. package/adapters/pi/events.ts +280 -83
  9. package/adapters/pi/index.ts +11 -2
  10. package/adapters/pi/renderers.ts +3 -47
  11. package/adapters/pi/storage.ts +6 -1
  12. package/adapters/pi/ui.ts +30 -1
  13. package/adapters/shared/file-storage.ts +21 -0
  14. package/adapters/shared/widget-layout.ts +134 -2
  15. package/cli/settings.ts +1 -1
  16. package/cli/tui.tsx +1 -1
  17. package/core/command-service.ts +13 -0
  18. package/core/engine.ts +11 -2
  19. package/core/identity.ts +15 -0
  20. package/core/model.ts +2 -0
  21. package/core/ports.ts +4 -0
  22. package/core/reactions.ts +29 -2
  23. package/core/render-model.ts +4 -3
  24. package/package.json +6 -2
  25. package/scripts/ci/Dockerfile.vhs +125 -0
  26. package/scripts/ci/check-statusline.sh +328 -14
  27. package/scripts/ci/check-vhs-goldens.sh +97 -0
  28. package/scripts/ci/fonts.conf +37 -0
  29. package/scripts/ci/goldens/baseline-40.png +0 -0
  30. package/scripts/ci/goldens/baseline-80.png +0 -0
  31. package/scripts/ci/goldens/cjk-80.png +0 -0
  32. package/scripts/ci/goldens/emoji-80.png +0 -0
  33. package/scripts/ci/render-statusline-pty.py +99 -0
  34. package/scripts/ci/render-vhs-goldens.sh +235 -0
  35. package/scripts/ci/vhs-run-statusline.sh +16 -0
  36. package/scripts/gen-emoji-widths.ts +41 -17
  37. package/scripts/paths.sh +3 -1
  38. package/server/art.ts +45 -13
  39. package/server/hooks/buddy-comment.ts +166 -17
  40. package/server/hooks/file-type-react.ts +1 -1
  41. package/server/hooks/mood-react.ts +1 -1
  42. package/server/hooks/name-react.ts +1 -1
  43. package/server/hooks/react.ts +1 -1
  44. package/server/hooks/reaction-data.ts +10 -3
  45. package/server/index.ts +67 -27
  46. package/server/path.ts +3 -0
  47. package/server/state.ts +63 -17
  48. package/skills/buddy/SKILL.md +1 -1
  49. package/statusline/buddy-status.sh +351 -59
  50. package/statusline/emoji-text.data +3 -0
  51. package/statusline/emoji-widths.data +2 -3
  52. package/statusline/substatus.sh +52 -10
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env bash
2
+ # Render statusline VHS goldens (PNG frames) for the committed fixtures.
3
+ #
4
+ # Goldens are platform-sensitive (font rasterization). This script ALWAYS
5
+ # renders inside a pinned linux/amd64 container unless you pass --native
6
+ # (which the container entrypoint uses). That way a macOS developer and
7
+ # GitHub Actions ubuntu-latest produce comparable pixels.
8
+ #
9
+ # Usage (from repo root):
10
+ # bash scripts/ci/render-vhs-goldens.sh # -> scripts/ci/vhs-out/
11
+ # bash scripts/ci/render-vhs-goldens.sh --update # also refresh scripts/ci/goldens/
12
+ #
13
+ # Re-baseline (MUST go through this script / the container — never host macOS vhs):
14
+ # bun run ci:vhs:update
15
+ # # inspect scripts/ci/goldens/*.png, then:
16
+ # git add scripts/ci/goldens/*.png
17
+ #
18
+ # Requires: docker (host path). Inside the container: vhs, ffmpeg, ttyd, bun, jq.
19
+ set -euo pipefail
20
+
21
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
22
+ cd "$ROOT"
23
+
24
+ UPDATE=0
25
+ NATIVE=0
26
+ OUT_DIR="scripts/ci/vhs-out"
27
+ GOLDEN_DIR="scripts/ci/goldens"
28
+ IMAGE_NAME="${VHS_GOLDEN_IMAGE:-coding-buddy-vhs:local}"
29
+ DOCKERFILE="scripts/ci/Dockerfile.vhs"
30
+
31
+ for arg in "$@"; do
32
+ case "$arg" in
33
+ --update) UPDATE=1 ;;
34
+ --native) NATIVE=1 ;;
35
+ -h|--help)
36
+ sed -n '2,20p' "$0"
37
+ exit 0
38
+ ;;
39
+ *)
40
+ echo "unknown arg: $arg" >&2
41
+ exit 2
42
+ ;;
43
+ esac
44
+ done
45
+
46
+ # ── Host path: build pinned linux/amd64 image and re-exec inside it ──────────
47
+ if [ "$NATIVE" -eq 0 ]; then
48
+ command -v docker >/dev/null 2>&1 || {
49
+ echo "FAIL: docker is required to render platform-matched VHS goldens" >&2
50
+ echo " Install Docker, or if you are already inside scripts/ci/Dockerfile.vhs," >&2
51
+ echo " re-run with --native." >&2
52
+ exit 1
53
+ }
54
+
55
+ echo "build: $IMAGE_NAME (linux/amd64) from $DOCKERFILE"
56
+ docker build \
57
+ --platform linux/amd64 \
58
+ -f "$DOCKERFILE" \
59
+ -t "$IMAGE_NAME" \
60
+ scripts/ci
61
+
62
+ args=("--native")
63
+ [ "$UPDATE" -eq 1 ] && args+=("--update")
64
+
65
+ # Host creates output dirs world-writable so the container's uid 1000 can
66
+ # write through the bind mount (GHA checkout owner is not 1000).
67
+ mkdir -p "$OUT_DIR" "$GOLDEN_DIR"
68
+ chmod a+rwx "$OUT_DIR" "$GOLDEN_DIR" 2>/dev/null || true
69
+
70
+ echo "run: docker render (${args[*]})"
71
+ set +e
72
+ # Match the image USER (vhs:1000). Chromium will not start as root.
73
+ # HOME is the image's /home/vhs so rod can cache its browser there.
74
+ # seccomp=unconfined is required for Chromium under Docker Desktop/OrbStack.
75
+ docker run --rm \
76
+ --platform linux/amd64 \
77
+ --security-opt seccomp=unconfined \
78
+ --user 1000:1000 \
79
+ -e CI= \
80
+ -e COLORTERM=truecolor \
81
+ -e HOME=/home/vhs \
82
+ -e XDG_CACHE_HOME=/home/vhs/.cache \
83
+ -e VHS_GOLDEN_IMAGE="$IMAGE_NAME" \
84
+ -v "$ROOT":/work \
85
+ -w /work \
86
+ "$IMAGE_NAME" \
87
+ bash scripts/ci/render-vhs-goldens.sh "${args[@]}"
88
+ status=$?
89
+ set -e
90
+
91
+ # Fix ownership when the host user is non-root (best-effort).
92
+ if [ "$(id -u)" -ne 0 ]; then
93
+ docker run --rm --platform linux/amd64 \
94
+ --user 0:0 \
95
+ -v "$ROOT":/work -w /work \
96
+ "$IMAGE_NAME" \
97
+ bash -lc "chown -R $(id -u):$(id -g) scripts/ci/vhs-out scripts/ci/goldens 2>/dev/null || true" \
98
+ >/dev/null 2>&1 || true
99
+ fi
100
+ exit "$status"
101
+ fi
102
+
103
+ # ── Native path (inside the pinned container, or VHS_NATIVE override) ────────
104
+ command -v vhs >/dev/null 2>&1 || { echo "vhs not on PATH (native mode)" >&2; exit 1; }
105
+ command -v ffmpeg >/dev/null 2>&1 || { echo "ffmpeg not on PATH (native mode)" >&2; exit 1; }
106
+ command -v ttyd >/dev/null 2>&1 || { echo "ttyd not on PATH (native mode)" >&2; exit 1; }
107
+ command -v bun >/dev/null 2>&1 || { echo "bun not on PATH (native mode)" >&2; exit 1; }
108
+
109
+ echo "native render host=$(uname -s)/$(uname -m) vhs=$(vhs --version 2>/dev/null | head -n1)"
110
+ echo "font: $(fc-match 'DejaVu Sans Mono' 2>/dev/null || echo 'fc-match unavailable')"
111
+
112
+ # Prefer cleaning contents over rm -rf of the directory itself: on CI the
113
+ # host owns scripts/ci/vhs-out (created world-writable above) and uid 1000
114
+ # cannot unlink the directory from scripts/.
115
+ rm -rf "${OUT_DIR:?}/"* 2>/dev/null || true
116
+ mkdir -p "$OUT_DIR"
117
+
118
+ env_ci_was="${CI-}"
119
+ unset CI || true
120
+ export COLORTERM=truecolor
121
+
122
+ # Fixture generation uses only in-repo modules (server/art.ts). Never touch
123
+ # the developer's live ~/.config/claude/buddy-state — CLAUDE_CONFIG_DIR is
124
+ # pointed at temp fixture dirs below.
125
+ gen_dir="$OUT_DIR/fixtures"
126
+ mkdir -p "$gen_dir/cjk/buddy-state" "$gen_dir/emoji/buddy-state"
127
+ cp scripts/ci/statusline-fixture/buddy-state/config.json "$gen_dir/cjk/buddy-state/config.json"
128
+ cp scripts/ci/statusline-fixture/buddy-state/config.json "$gen_dir/emoji/buddy-state/config.json"
129
+
130
+ bun -e "
131
+ import { writeFileSync } from 'fs';
132
+ import { getStatusFrames } from './server/art.ts';
133
+ const bones = {
134
+ rarity: 'common', species: 'duck', eye: '°', hat: 'none', shiny: false,
135
+ stats: { DEBUGGING: 50, PATIENCE: 50, CHAOS: 50, WISDOM: 50, SNARK: 50 },
136
+ peak: 'DEBUGGING', dump: 'PATIENCE',
137
+ };
138
+ const { frames, frameSequence } = getStatusFrames(bones);
139
+ const status = {
140
+ name: 'Daffodil', species: 'duck', rarity: 'common', stars: '★',
141
+ face: '(°>', eye: '°', shiny: false, hat: 'none', reaction: '',
142
+ muted: false, achievement: '', frames, frameSequence,
143
+ level: 1, xp: 0, mood: 'focused',
144
+ };
145
+ writeFileSync(process.argv[1], JSON.stringify(status));
146
+ writeFileSync(process.argv[2], JSON.stringify(status));
147
+ writeFileSync(process.argv[3], JSON.stringify({
148
+ reaction: '编译通过了 你好世界', timestamp: Date.now(), reason: 'turn',
149
+ }));
150
+ writeFileSync(process.argv[4], JSON.stringify({
151
+ reaction: 'ship it 🎉 ❤️ ✨', timestamp: Date.now(), reason: 'success',
152
+ }));
153
+ " \
154
+ "$gen_dir/cjk/buddy-state/status.json" \
155
+ "$gen_dir/emoji/buddy-state/status.json" \
156
+ "$gen_dir/cjk/buddy-state/reaction.default.json" \
157
+ "$gen_dir/emoji/buddy-state/reaction.default.json"
158
+
159
+ render_one() {
160
+ local name="$1"
161
+ local cols="$2"
162
+ local lines="$3"
163
+ local fixture_rel="$4"
164
+ local tape out_png out_gif
165
+ tape="$OUT_DIR/${name}.tape"
166
+ out_png="$OUT_DIR/${name}.png"
167
+ out_gif="$OUT_DIR/${name}.gif"
168
+
169
+ # Relative paths only — vhs's tape parser treats absolute "/..." paths as
170
+ # separate commands. JSON stays out of the tape via vhs-run-statusline.sh.
171
+ #
172
+ # Hide/Show: the Type+Enter setup must not appear in the golden. Previous
173
+ # macOS baselines accidentally baked the shell command into the frame, which
174
+ # made pixel goldens useless as a layout check.
175
+ cat > "$tape" <<EOF
176
+ Output ${out_gif}
177
+ Set Width $(( cols * 9 ))
178
+ Set Height $(( lines * 18 ))
179
+ Set FontSize 14
180
+ Set FontFamily "DejaVu Sans Mono"
181
+ Set Padding 12
182
+ Set Margin 0
183
+ Set Theme "Catppuccin Mocha"
184
+ Set Shell "bash"
185
+ Set TypingSpeed 0ms
186
+ Set Framerate 10
187
+ Hide
188
+ Type "PS1=; tput civis 2>/dev/null; clear; bash scripts/ci/vhs-run-statusline.sh ${cols} ${fixture_rel}"
189
+ Enter
190
+ Sleep 800ms
191
+ Show
192
+ Sleep 2s
193
+ EOF
194
+
195
+ echo "render: $name (cols=$cols fixture=$fixture_rel)"
196
+ vhs "$tape"
197
+ [ -f "$out_gif" ] || { echo "missing gif: $out_gif" >&2; exit 1; }
198
+
199
+ # Extract the final settled frame as a still PNG.
200
+ local nframes last
201
+ nframes=$(ffprobe -v error -select_streams v:0 -count_frames \
202
+ -show_entries stream=nb_read_frames -of csv=p=0 "$out_gif" || echo 1)
203
+ last=0
204
+ if [ "${nframes:-0}" -gt 1 ] 2>/dev/null; then
205
+ last=$(( nframes - 1 ))
206
+ fi
207
+ ffmpeg -hide_banner -loglevel error -y \
208
+ -i "$out_gif" \
209
+ -vf "select=eq(n\,${last})" \
210
+ -update 1 -frames:v 1 \
211
+ "$out_png"
212
+ [ -f "$out_png" ] || { echo "missing png: $out_png" >&2; exit 1; }
213
+ }
214
+
215
+ render_one "baseline-80" 80 18 "scripts/ci/statusline-fixture"
216
+ render_one "baseline-40" 40 18 "scripts/ci/statusline-fixture"
217
+ render_one "cjk-80" 80 18 "scripts/ci/vhs-out/fixtures/cjk"
218
+ # Emoji reaction needs room for the bubble (INNER_W default 44). Narrow
219
+ # coverage lives in baseline-40; this fixture is about glyph rasterization.
220
+ render_one "emoji-80" 80 18 "scripts/ci/vhs-out/fixtures/emoji"
221
+
222
+ if [ "$UPDATE" -eq 1 ]; then
223
+ mkdir -p "$GOLDEN_DIR"
224
+ for png in "$OUT_DIR"/*.png; do
225
+ base=$(basename "$png")
226
+ cp "$png" "$GOLDEN_DIR/$base"
227
+ echo "updated golden: $GOLDEN_DIR/$base"
228
+ done
229
+ fi
230
+
231
+ if [ -n "${env_ci_was}" ]; then
232
+ export CI="$env_ci_was"
233
+ fi
234
+
235
+ echo "OK: vhs renders written to $OUT_DIR (native=$(uname -s)/$(uname -m))"
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # Invoked from VHS tapes. Args: <cols> <fixture-relpath-from-repo-root>
3
+ set -euo pipefail
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
5
+ cols="${1:?cols}"
6
+ fixture_rel="${2:?fixture}"
7
+ payload='{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}'
8
+ cd "$ROOT"
9
+ printf '%s' "$payload" \
10
+ | COLUMNS="$cols" \
11
+ BUDDY_FAKE_NOW=0 \
12
+ CLAUDE_CONFIG_DIR="$ROOT/$fixture_rel" \
13
+ CLAUDE_CODE_SESSION_ID= \
14
+ TMUX_PANE= \
15
+ BUDDY_SHELL= \
16
+ bash statusline/buddy-status.sh
@@ -5,30 +5,54 @@
5
5
  *
6
6
  * Regenerate: bun run gen:emoji-widths
7
7
  *
8
- * The emitted list holds decimal codepoints in U+2600-U+27BF that default to
9
- * emoji rendering (2 cols). Everything outside that range is handled by
10
- * simple range checks in the bash/TS code, so we only need to export the
11
- * ambiguous Miscellaneous Symbols + Dingbats block.
12
- */
8
+ * The emitted list holds every codepoint with Emoji_Presentation=Yes, so the
9
+ * bash width walker stays in parity with server/art.ts for both BMP symbols
10
+ * and supplementary-plane emoji.
13
11
 
12
+ */
14
13
  import { writeFileSync } from "fs";
15
14
  import { join } from "path";
16
15
 
17
16
  const OUT = join(import.meta.dir, "..", "statusline", "emoji-widths.data");
17
+ const TEXT_OUT = join(import.meta.dir, "..", "statusline", "emoji-text.data");
18
18
 
19
19
  const EMOJI_PRES_RE = /\p{Emoji_Presentation}/u;
20
- const codepoints: number[] = [];
21
- for (let cp = 0x2600; cp <= 0x27BF; cp++) {
22
- if (EMOJI_PRES_RE.test(String.fromCodePoint(cp))) codepoints.push(cp);
20
+ const EMOJI_RE = /\p{Emoji}/u;
21
+ const presentation: number[] = [];
22
+ const textDefault: number[] = [];
23
+ for (let cp = 0; cp <= 0x10FFFF; cp++) {
24
+ const character = String.fromCodePoint(cp);
25
+ if (EMOJI_PRES_RE.test(character)) presentation.push(cp);
26
+ else if (EMOJI_RE.test(character)) textDefault.push(cp);
23
27
  }
24
28
 
25
- const content = [
26
- "# Auto-generated by scripts/gen-emoji-widths.ts — do not edit by hand.",
27
- "# Decimal codepoints in U+2600-U+27BF with Unicode Emoji_Presentation=Yes.",
28
- "# Regenerate with: bun run gen:emoji-widths",
29
- codepoints.join(" "),
30
- "",
31
- ].join("\n");
29
+ function encodeRanges(codepoints: number[]): string {
30
+ const ranges: string[] = [];
31
+ let start = codepoints[0];
32
+ let end = start;
33
+ for (const codepoint of codepoints.slice(1)) {
34
+ if (codepoint === end + 1) {
35
+ end = codepoint;
36
+ continue;
37
+ }
38
+ ranges.push(start === end ? String(start) : `${start}-${end}`);
39
+ start = end = codepoint;
40
+ }
41
+ ranges.push(start === end ? String(start) : `${start}-${end}`);
42
+ return ranges.join(",");
43
+ }
32
44
 
33
- writeFileSync(OUT, content);
34
- console.log(`Wrote ${codepoints.length} codepoints to ${OUT}`);
45
+ const header = "# Auto-generated by scripts/gen-emoji-widths.ts — do not edit by hand.";
46
+ writeFileSync(OUT, [
47
+ header,
48
+ "# Decimal codepoint ranges with Unicode Emoji_Presentation=Yes.",
49
+ encodeRanges(presentation),
50
+ "",
51
+ ].join("\n"));
52
+ writeFileSync(TEXT_OUT, [
53
+ header,
54
+ "# Decimal codepoint ranges with Unicode Emoji=Yes and Emoji_Presentation=No.",
55
+ encodeRanges(textDefault),
56
+ "",
57
+ ].join("\n"));
58
+ console.log(`Wrote ${presentation.length} presentation and ${textDefault.length} text-default codepoints`);
package/scripts/paths.sh CHANGED
@@ -31,7 +31,9 @@ else
31
31
  CLAUDE_USER_CONFIG="$HOME/.claude.json"
32
32
  fi
33
33
 
34
- if [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then
34
+ if [[ -n "${CODING_BUDDY_USER_ID:-}" ]]; then
35
+ BUDDY_STATE_DIR="${CODING_BUDDY_STATE_DIR:-$HOME/.coding-buddy/shared}"
36
+ elif [[ -n "${CLAUDE_CONFIG_DIR:-}" ]]; then
35
37
  BUDDY_STATE_DIR="$CLAUDE_CONFIG_DIR/buddy-state"
36
38
  else
37
39
  BUDDY_STATE_DIR="$HOME/.claude-buddy"
package/server/art.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  * {E} is replaced with the eye character at render time.
7
7
  */
8
8
 
9
- import type { Species, Eye, Hat, Rarity, StatName, BuddyBones } from "../core/engine.ts"
9
+ import { resolveEyeGlyph, type Species, type Hat, type Rarity, type StatName, type BuddyBones } from "../core/engine.ts"
10
+ export { resolveEyeGlyph };
10
11
  import { HAT_ART } from "../core/art-data.ts";
11
12
  import { getRarityColor } from "./theme.ts";
12
13
  export { HAT_ART };
@@ -171,10 +172,38 @@ export const RARITY_STARS: Record<Rarity, string> = {
171
172
 
172
173
  function stripAnsi(s: string): string { return s.replace(/\x1b\[[^m]*m/g, ""); }
173
174
 
175
+ const SGR_TOKEN_RE = /\x1b\[[^m]*m|./gu;
176
+
177
+ /** Truncate a string to terminal columns without splitting wide characters. */
178
+ export function truncateDisplayWidth(
179
+ value: string,
180
+ maxWidth: number,
181
+ suffix = "\u2026",
182
+ ): string {
183
+ if (maxWidth <= 0) return "";
184
+ if (displayWidth(value) <= maxWidth) return value;
185
+
186
+ const suffixWidth = displayWidth(suffix);
187
+ const contentWidth = Math.max(0, maxWidth - suffixWidth);
188
+ let result = "";
189
+ for (const token of value.match(SGR_TOKEN_RE) ?? []) {
190
+ if (token.startsWith("\x1b[")) {
191
+ result += token;
192
+ continue;
193
+ }
194
+ // Measure the candidate, not the codepoint in isolation: VS16 upgrades
195
+ // the preceding symbol and must be accounted for before accepting it.
196
+ if (displayWidth(result + token) > contentWidth) break;
197
+ result += token;
198
+ }
199
+ return `${result}${suffix}${NC}`;
200
+ }
201
+
174
202
  // Unicode property escapes (ES2018) are the source of truth for which
175
203
  // codepoints terminals render 2 cols wide. The statusline (bash) can't use
176
- // these directly, so scripts/gen-emoji-widths.ts exports the subset that
177
- // bash needs into statusline/emoji-widths.data — regenerate on version bumps.
204
+ // these directly, so scripts/gen-emoji-widths.ts exports the complete
205
+ // Emoji_Presentation table into statusline/emoji-widths.data — regenerate on
206
+ // Unicode/runtime version bumps.
178
207
  const EMOJI_PRES_RE = /\p{Emoji_Presentation}/u;
179
208
  const EMOJI_RE = /\p{Emoji}/u;
180
209
 
@@ -216,13 +245,13 @@ function dpad(s: string, targetW: number): string {
216
245
  const w = displayWidth(s);
217
246
  return w < targetW ? s + " ".repeat(targetW - w) : s;
218
247
  }
219
-
220
248
  // ─── Render functions ───────────────────────────────────────────────────────
221
249
 
222
- export function getArtFrame(species: Species, eye: Eye, frame: number = 0): string[] {
250
+ export function getArtFrame(species: Species, eye: string, frame: number = 0): string[] {
223
251
  const frames = SPECIES_ART[species];
224
252
  const f = frames[frame % frames.length];
225
- return f.map((line) => line.replace(/\{E\}/g, eye));
253
+ const glyph = resolveEyeGlyph(eye);
254
+ return f.map((line) => line.replace(/\{E\}/g, glyph));
226
255
  }
227
256
 
228
257
  // Original 15-tick cycle [0,0,0,0,1,0,0,0,-1,0,0,2,0,0,0]: -1 (blink) becomes
@@ -238,9 +267,9 @@ export function getStatusFrames(bones: BuddyBones): {
238
267
  frames: string[];
239
268
  frameSequence: number[];
240
269
  } {
241
- const resolveFrame = (frameIdx: number, eye: string): string => {
270
+ const resolveFrame = (frameIdx: number, eyeGlyph: string): string => {
242
271
  const raw = SPECIES_ART[bones.species][frameIdx];
243
- const art = raw.map((line) => line.replace(/\{E\}/g, eye));
272
+ const art = raw.map((line) => line.replace(/\{E\}/g, eyeGlyph));
244
273
  const hatLine = HAT_ART[bones.hat];
245
274
  if (hatLine && !art[0].trim()) {
246
275
  art[0] = hatLine;
@@ -248,11 +277,14 @@ export function getStatusFrames(bones: BuddyBones): {
248
277
  return art.join("\n");
249
278
  };
250
279
 
280
+ // Sanitize once at the boundary. Blink uses a literal "-" eye, which is
281
+ // intentionally outside EYES and must not pass through resolveEyeGlyph.
282
+ const eye = resolveEyeGlyph(bones.eye);
251
283
  return {
252
284
  frames: [
253
- resolveFrame(0, bones.eye),
254
- resolveFrame(1, bones.eye),
255
- resolveFrame(2, bones.eye),
285
+ resolveFrame(0, eye),
286
+ resolveFrame(1, eye),
287
+ resolveFrame(2, eye),
256
288
  resolveFrame(0, "-"),
257
289
  ],
258
290
  frameSequence: [...STATUS_FRAME_SEQUENCE],
@@ -326,7 +358,7 @@ export function renderCompanionCard(
326
358
  if (reaction) {
327
359
  lines.push(`${color}\u251c${"╌".repeat(W - 2)}\u2524${NC}`);
328
360
  const maxMsg = innerW - 3; // "💬 " prefix (💬 = 2 cols + space)
329
- const msg = displayWidth(reaction) > maxMsg ? reaction.slice(0, maxMsg - 1) + "\u2026" : reaction;
361
+ const msg = truncateDisplayWidth(reaction, maxMsg);
330
362
  const msgPad = Math.max(0, innerW - displayWidth(msg) - 3);
331
363
  lines.push(`${color}\u2502${NC} \ud83d\udcac ${msg}${" ".repeat(msgPad)}${color}\u2502${NC}`);
332
364
  }
@@ -441,7 +473,7 @@ export function renderStatusLine(
441
473
  name: string,
442
474
  reaction?: string,
443
475
  ): string {
444
- const face = SPECIES_ART[bones.species][0][2]?.replace(/\{E\}/g, bones.eye).trim() || "(?)";
476
+ const face = SPECIES_ART[bones.species][0][2]?.replace(/\{E\}/g, resolveEyeGlyph(bones.eye)).trim() || "(?)";
445
477
  const color = getRarityColor(bones.rarity);
446
478
  const stars = RARITY_STARS[bones.rarity];
447
479
  const shiny = bones.shiny ? "\u2728" : "";