@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.
- package/README.md +23 -4
- package/adapters/omp/context.ts +1 -1
- package/adapters/omp/events.ts +286 -81
- package/adapters/omp/index.ts +3 -1
- package/adapters/omp/renderers.ts +4 -54
- package/adapters/omp/storage.ts +6 -1
- package/adapters/omp/ui.ts +30 -1
- package/adapters/pi/events.ts +280 -83
- package/adapters/pi/index.ts +11 -2
- package/adapters/pi/renderers.ts +3 -47
- package/adapters/pi/storage.ts +6 -1
- package/adapters/pi/ui.ts +30 -1
- package/adapters/shared/file-storage.ts +21 -0
- package/adapters/shared/widget-layout.ts +134 -2
- package/cli/settings.ts +1 -1
- package/cli/tui.tsx +1 -1
- package/core/command-service.ts +13 -0
- package/core/engine.ts +11 -2
- package/core/identity.ts +15 -0
- package/core/model.ts +2 -0
- package/core/ports.ts +4 -0
- package/core/reactions.ts +29 -2
- package/core/render-model.ts +4 -3
- package/package.json +6 -2
- package/scripts/ci/Dockerfile.vhs +125 -0
- package/scripts/ci/check-statusline.sh +328 -14
- package/scripts/ci/check-vhs-goldens.sh +97 -0
- package/scripts/ci/fonts.conf +37 -0
- package/scripts/ci/goldens/baseline-40.png +0 -0
- package/scripts/ci/goldens/baseline-80.png +0 -0
- package/scripts/ci/goldens/cjk-80.png +0 -0
- package/scripts/ci/goldens/emoji-80.png +0 -0
- package/scripts/ci/render-statusline-pty.py +99 -0
- package/scripts/ci/render-vhs-goldens.sh +235 -0
- package/scripts/ci/vhs-run-statusline.sh +16 -0
- package/scripts/gen-emoji-widths.ts +41 -17
- package/scripts/paths.sh +3 -1
- package/server/art.ts +45 -13
- package/server/hooks/buddy-comment.ts +166 -17
- package/server/hooks/file-type-react.ts +1 -1
- package/server/hooks/mood-react.ts +1 -1
- package/server/hooks/name-react.ts +1 -1
- package/server/hooks/react.ts +1 -1
- package/server/hooks/reaction-data.ts +10 -3
- package/server/index.ts +67 -27
- package/server/path.ts +3 -0
- package/server/state.ts +63 -17
- package/skills/buddy/SKILL.md +1 -1
- package/statusline/buddy-status.sh +351 -59
- package/statusline/emoji-text.data +3 -0
- package/statusline/emoji-widths.data +2 -3
- 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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
codepoints.
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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 "${
|
|
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
|
|
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
|
|
177
|
-
//
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
254
|
-
resolveFrame(1,
|
|
255
|
-
resolveFrame(2,
|
|
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 =
|
|
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" : "";
|