@ramarivera/coding-buddy 0.9.0 → 0.9.1

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.
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env bash
2
2
  # CI golden check for the Claude Code statusline.
3
3
  #
4
- # Primary signal: loud text assertions over a width matrix + CJK / wide-emoji
5
- # fixtures. A broken render fails the build. VHS image goldens are a separate
6
- # step (scripts/ci/check-vhs-goldens.sh).
4
+ # Primary signal: loud text assertions over a row/column density matrix +
5
+ # CJK / wide-emoji / corrupt-eye fixtures. A broken render fails the build.
6
+ # VHS image goldens are a separate step (scripts/ci/check-vhs-goldens.sh).
7
7
  #
8
8
  # Run from the repo root:
9
9
  # bash scripts/ci/check-statusline.sh
@@ -16,11 +16,24 @@ fixture_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/statusline-fixture" && pwd)"
16
16
  pty_helper="$ROOT/scripts/ci/render-statusline-pty.py"
17
17
  payload='{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}'
18
18
 
19
- # Widths that have historically sheared the card / truncated the right edge.
20
- WIDTHS=(20 40 60 80 93 120 200)
19
+ # Width/row budgets for each density tier. Full frames are 5 art rows + name;
20
+ # compact is 3 art rows + name; minimal is a single sprite/face + name line.
21
+ DENSITY_FULL_MIN=6
22
+ DENSITY_FULL_MAX=8
23
+ DENSITY_COMPACT_MIN=4
24
+ DENSITY_COMPACT_MAX=4
25
+ DENSITY_MINIMAL_MIN=1
26
+ DENSITY_MINIMAL_MAX=1
21
27
 
22
28
  # Claude Code kills statusline scripts around 1s; stay under that budget.
23
- TIME_BUDGET_MS=1000
29
+ TIME_BUDGET_MS=${BUDDY_STATUSLINE_TIME_BUDGET_MS:-1000}
30
+
31
+ # Density matrix uses BUDDY_STATUSLINE_ROWS to make CI independent of the
32
+ # ambient terminal size. Auto tier: full >=40 rows, compact 20-39, minimal <20.
33
+ # Very narrow terminal widths force minimal regardless of rows.
34
+ NARROW_COLS=40
35
+
36
+ CLEANUP_DIRS=()
24
37
 
25
38
  fail() {
26
39
  echo "FAIL: $1" >&2
@@ -30,6 +43,17 @@ fail() {
30
43
  exit 1
31
44
  }
32
45
 
46
+ cleanup() {
47
+ for d in "${CLEANUP_DIRS[@]}"; do
48
+ rm -rf "$d" 2>/dev/null || true
49
+ done
50
+ }
51
+ trap cleanup EXIT
52
+
53
+ add_cleanup() {
54
+ CLEANUP_DIRS+=("$1")
55
+ }
56
+
33
57
  # Strip SGR / CSI color sequences. Keep the rest of the glyphs intact so
34
58
  # display-width math matches what a terminal would paint.
35
59
  strip_ansi() {
@@ -89,34 +113,61 @@ now_ms() {
89
113
  fi
90
114
  }
91
115
 
92
- # Render statusline against a fixture dir at a fixed terminal width.
93
- # Uses a pseudo-TTY so buddy-status.sh's stty/PTY width probe sees the
94
- # requested cols (COLUMNS alone is ignored when the parent TTY is wide).
95
- # Sets globals: OUTPUT, PLAIN, ELAPSED_MS, EFFECTIVE_COLS
116
+ # Augment a committed status.json with compactFrames/minimalFrames arrays.
117
+ # Falls back to full frames when the current getStatusFrames() does not yet
118
+ # emit the new arrays, so the CI harness is honest about missing density.
119
+ write_density_status() {
120
+ local src_status="$1"
121
+ local dest_status="$2"
122
+ mkdir -p "$(dirname "$dest_status")"
123
+ bun "$ROOT/scripts/ci/write-density-status.ts" "$dest_status" < "$src_status"
124
+ }
125
+
126
+ # Build a temporary baseline fixture with density-aware status arrays.
127
+ make_baseline_dir() {
128
+ local dest="$1"
129
+ rm -rf "$dest"
130
+ mkdir -p "$dest/buddy-state"
131
+ cp "$fixture_dir/buddy-state/config.json" "$dest/buddy-state/config.json"
132
+ write_density_status "$fixture_dir/buddy-state/status.json" "$dest/buddy-state/status.json"
133
+ }
134
+
135
+ # Render statusline against a fixture dir at a fixed terminal width/rows.
136
+ # Sets globals: OUTPUT, PLAIN, ELAPSED_MS, EFFECTIVE_COLS, EFFECTIVE_ROWS.
96
137
  render_at() {
97
- local config_dir="$1"
138
+ local source_dir="$1"
98
139
  local cols="$2"
99
- local rendered errfile
140
+ local rows="${3:-50}"
141
+ local density="${4:-auto}"
142
+ local test_dir rendered errfile
143
+ test_dir=$(mktemp -d)
144
+ add_cleanup "$test_dir"
145
+ mkdir -p "$test_dir/buddy-state"
146
+ cp "$source_dir/buddy-state/status.json" "$test_dir/buddy-state/status.json"
147
+ cp "$source_dir/buddy-state/config.json" "$test_dir/buddy-state/config.json"
148
+ for _react in "$source_dir"/buddy-state/reaction.*.json; do
149
+ [ -f "$_react" ] && cp "$_react" "$test_dir/buddy-state/"
150
+ done
151
+
152
+ if [ "$density" != "auto" ]; then
153
+ jq --arg d "$density" '.statuslineDensity = $d' "$test_dir/buddy-state/config.json" \
154
+ > "$test_dir/buddy-state/config.json.tmp" \
155
+ && mv "$test_dir/buddy-state/config.json.tmp" "$test_dir/buddy-state/config.json"
156
+ fi
157
+
100
158
  errfile=$(mktemp)
101
- # The fixtures write reaction.default.json, but paths.sh derives the
102
- # per-session filename from CLAUDE_CODE_SESSION_ID / TMUX_PANE. Whichever of
103
- # those happens to be set in the ambient environment decides which reaction
104
- # file is read, so the check passed locally and failed on CI (no bubble
105
- # rendered, because the reaction was looked up under a different session id).
106
- # Pin them empty so the session id is always "default" and the result does
107
- # not depend on where the check runs.
108
159
  rendered="$(
109
160
  CLAUDE_CODE_SESSION_ID="" \
110
161
  TMUX_PANE="" \
162
+ BUDDY_STATUSLINE_ROWS="$rows" \
111
163
  STATUSLINE_PAYLOAD="$payload" \
112
164
  python3 "$pty_helper" \
113
165
  "$cols" \
114
166
  "$ROOT" \
115
- "$config_dir" \
167
+ "$test_dir" \
116
168
  "$ROOT/statusline/buddy-status.sh" \
117
169
  2>"$errfile"
118
170
  )"
119
- # Prefer the child-only timer from the PTY helper (excludes harness import).
120
171
  ELAPSED_MS=$(sed -n 's/^STATUSLINE_ELAPSED_MS=//p' "$errfile" | tail -n1)
121
172
  rm -f "$errfile"
122
173
  case "$ELAPSED_MS" in
@@ -124,43 +175,67 @@ render_at() {
124
175
  esac
125
176
  OUTPUT="$(printf '%s' "$rendered" | tr -d '\r')"
126
177
  PLAIN="$(strip_ansi "$OUTPUT")"
127
- # buddy-status.sh currently floors detected widths below 40 up to 125
128
- # (owned by the resize worker). Assert against the budget the script
129
- # actually honors so the matrix stays honest without forking that logic.
130
- if [ "$cols" -lt 40 ]; then
131
- EFFECTIVE_COLS=125
132
- else
133
- EFFECTIVE_COLS="$cols"
134
- fi
178
+ # Assert the real terminal width budget; old <40 special-casing is gone.
179
+ EFFECTIVE_COLS="$cols"
180
+ EFFECTIVE_ROWS="$rows"
181
+ }
182
+
183
+ # Resolve the expected density tier for a (density, rows, cols) tuple.
184
+ expected_tier() {
185
+ local density="${1:-auto}"
186
+ local rows="$2"
187
+ local cols="$3"
188
+ case "$density" in
189
+ full|compact|minimal)
190
+ printf '%s\n' "$density"
191
+ ;;
192
+ *)
193
+ if [ "$rows" -lt 20 ] || [ "$cols" -lt "$NARROW_COLS" ]; then
194
+ printf 'minimal\n'
195
+ elif [ "$rows" -lt 40 ]; then
196
+ printf 'compact\n'
197
+ else
198
+ printf 'full\n'
199
+ fi
200
+ ;;
201
+ esac
135
202
  }
136
203
 
137
204
  assert_render() {
138
205
  local label="$1"
139
206
  local cols="$2"
140
- local expect_name="$3"
141
- local expect_substr="${4:-}"
142
- local budget="${EFFECTIVE_COLS:-$cols}"
207
+ local rows="$3"
208
+ local expect_name="$4"
209
+ local density="${5:-auto}"
210
+ local expect_substr="${6:-}"
211
+
212
+ local tier min_lines max_lines line_count max_w w line
213
+ tier="$(expected_tier "$density" "$rows" "$cols")"
214
+ case "$tier" in
215
+ full) min_lines=$DENSITY_FULL_MIN; max_lines=$DENSITY_FULL_MAX ;;
216
+ compact) min_lines=$DENSITY_COMPACT_MIN; max_lines=$DENSITY_COMPACT_MAX ;;
217
+ minimal) min_lines=$DENSITY_MINIMAL_MIN; max_lines=$DENSITY_MINIMAL_MAX ;;
218
+ *) fail "[$label] unknown expected tier '$tier'" "$PLAIN" ;;
219
+ esac
143
220
 
144
- local plain_lines line_count max_w w line
145
- plain_lines=$(printf '%s\n' "$PLAIN")
146
- line_count=$(printf '%s\n' "$plain_lines" | wc -l | tr -d ' ')
221
+ line_count=$(printf '%s' "$PLAIN" | awk 'END {print NR}')
147
222
 
148
- printf '%s\n' "$PLAIN" | grep -Fq "$expect_name" \
223
+ printf '%s' "$PLAIN" | grep -Fq "$expect_name" \
149
224
  || fail "[$label] pet name '$expect_name' missing from output" "$PLAIN"
150
225
 
151
- [ "$line_count" -ge 4 ] \
152
- || fail "[$label] expected >=4 output lines, got $line_count" "$PLAIN"
226
+ [ "$line_count" -ge "$min_lines" ] && [ "$line_count" -le "$max_lines" ] \
227
+ || fail "[$label] expected $tier line count $min_lines-$max_lines, got $line_count" "$PLAIN"
153
228
 
154
229
  if [ -n "$expect_substr" ]; then
155
- printf '%s\n' "$PLAIN" | grep -Fq "$expect_substr" \
230
+ printf '%s' "$PLAIN" | grep -Fq "$expect_substr" \
156
231
  || fail "[$label] expected substring missing: $expect_substr" "$PLAIN"
157
232
  fi
158
233
 
159
234
  # Sprite content must stay art, not descriptor words.
160
- if printf '%s\n' "$PLAIN" | grep -Eq '<\([[:alpha:]]{2,}'; then
235
+ if printf '%s' "$PLAIN" | grep -Eq '<\([[:alpha:]]{2,}'; then
161
236
  fail "[$label] sprite looks corrupted (word leaked into eye slot)" "$PLAIN"
162
237
  fi
163
- if printf '%s\n' "$PLAIN" | grep -Fq '{E}'; then
238
+ if printf '%s' "$PLAIN" | grep -Fq '{E}'; then
164
239
  fail "[$label] unresolved {E} eye placeholder in output" "$PLAIN"
165
240
  fi
166
241
 
@@ -171,16 +246,16 @@ assert_render() {
171
246
  if [ "$w" -gt "$max_w" ]; then
172
247
  max_w=$w
173
248
  fi
174
- if [ "$w" -gt "$budget" ]; then
175
- fail "[$label] row display width $w exceeds budget $budget (requested cols=$cols): $(printf '%q' "$line")" "$PLAIN"
249
+ if [ "$w" -gt "$cols" ]; then
250
+ fail "[$label] row display width $w exceeds requested cols=$cols: $(printf '%q' "$line")" "$PLAIN"
176
251
  fi
177
- done <<< "$plain_lines"
252
+ done < <(printf '%s' "$PLAIN")
178
253
 
179
254
  # Bubble box: border rows and text rows must agree in outer width.
180
255
  # Bubble and art share a line ("| text |-- <(° ..."), so measure only the
181
- # box segment. Ignore short art glyphs like the duck feet (`--') that also
256
+ # box segment. Ignore short art glyphs like the duck feet (\`--') that also
182
257
  # start with a backtick.
183
- local box_w="" border_w text_w trimmed first bubble_only
258
+ local box_w="" trimmed first bubble_only seg_w
184
259
  while IFS= read -r line || [ -n "$line" ]; do
185
260
  [ -z "$line" ] && continue
186
261
  trimmed=$(printf '%s' "$line" | sed $'s/^[ \t\xE2\xA0\x80]*//')
@@ -188,15 +263,12 @@ assert_render() {
188
263
  bubble_only=""
189
264
  if [ "$first" = "." ]; then
190
265
  bubble_only=$(printf '%s' "$trimmed" | sed -n 's/^\(\.[-][-]*\.\).*/\1/p')
191
- elif [ "$first" = '`' ]; then
192
- bubble_only=$(printf '%s' "$trimmed" | sed -n "s/^\\(\`[-][-]*'\).*/\\1/p")
266
+ elif [ "$first" = '\`' ]; then
267
+ bubble_only=$(printf '%s' "$trimmed" | sed -n "s/^\\(\\\`[-][-]*'\\).*/\\1/p")
193
268
  elif [ "$first" = "|" ]; then
194
269
  bubble_only=$(printf '%s' "$trimmed" | sed -n 's/^\(|[^|]*|\).*/\1/p')
195
270
  fi
196
271
  [ -z "$bubble_only" ] && continue
197
- # Real speech boxes are wide (INNER_W defaults to 44 → outer 48). Skip
198
- # any short accidental match.
199
- local seg_w
200
272
  seg_w=$(dwidth "$bubble_only")
201
273
  if [ "$seg_w" -lt 20 ]; then
202
274
  continue
@@ -212,15 +284,22 @@ assert_render() {
212
284
  fail "[$label] bubble border width $seg_w != $box_w" "$PLAIN"
213
285
  fi
214
286
  fi
215
- done <<< "$plain_lines"
287
+ done < <(printf '%s' "$PLAIN")
216
288
 
217
- # Name row must not outrun a reasonable panel width.
218
- local name_line name_w
219
- name_line=$(printf '%s\n' "$PLAIN" | grep -F "$expect_name" | head -n1 || true)
289
+ # Name row must stay within the panel budget. Minimal is a single line, so
290
+ # allow the full terminal width there; full/compact keep the existing panel
291
+ # sanity check.
292
+ local name_line name_w name_limit
293
+ name_line=$(printf '%s' "$PLAIN" | grep -F "$expect_name" | head -n1 || true)
220
294
  if [ -n "$name_line" ]; then
221
295
  name_w=$(dwidth "$(printf '%s' "$name_line" | sed $'s/^[ \t\xE2\xA0\x80]*//')")
222
- if [ "$name_w" -gt 24 ]; then
223
- fail "[$label] name row display width $name_w looks wider than the panel" "$PLAIN"
296
+ if [ "$tier" = "minimal" ]; then
297
+ name_limit=$cols
298
+ else
299
+ name_limit=24
300
+ fi
301
+ if [ "$name_w" -gt "$name_limit" ]; then
302
+ fail "[$label] name row display width $name_w exceeds limit $name_limit" "$PLAIN"
224
303
  fi
225
304
  fi
226
305
 
@@ -228,59 +307,74 @@ assert_render() {
228
307
  fail "[$label] statusline took ${ELAPSED_MS}ms (budget ${TIME_BUDGET_MS}ms)" "$PLAIN"
229
308
  fi
230
309
 
231
- echo "OK: $label requested_cols=$cols budget=$budget lines=$line_count max_row_w=$max_w time=${ELAPSED_MS}ms name='$expect_name'"
310
+ echo "OK: $label density=$tier requested_cols=$cols rows=$rows lines=$line_count max_row_w=$max_w time=${ELAPSED_MS}ms name='$expect_name'"
232
311
  }
233
312
 
234
313
  # ─── Fixture 1: committed baseline (pikachu / Cobalt) ────────────────────────
235
- base_name="$(jq -r .name "$fixture_dir/buddy-state/status.json")"
236
- # Cold-start warmup (not timed). Claude Code also keeps the process hot after
237
- # the first tick; the budget is for steady-state, not first-ever spawn.
238
- render_at "$fixture_dir" 80 >/dev/null || true
239
- for cols in "${WIDTHS[@]}"; do
240
- render_at "$fixture_dir" "$cols"
241
- assert_render "baseline@${cols}" "$cols" "$base_name"
314
+ baseline_dir=$(mktemp -d)
315
+ add_cleanup "$baseline_dir"
316
+ make_baseline_dir "$baseline_dir"
317
+ base_name="$(jq -r .name "$baseline_dir/buddy-state/status.json")"
318
+
319
+ # Warm-up (not timed). Claude Code keeps the process hot after the first tick.
320
+ render_at "$baseline_dir" 80 50 auto >/dev/null || true
321
+
322
+ # Row/column density matrix. BUDDY_STATUSLINE_ROWS is always explicit so the
323
+ # results do not depend on the CI runner's terminal size.
324
+ LABELS=(
325
+ "full@80x50"
326
+ "full@120x50"
327
+ "compact@60x30"
328
+ "compact@80x25"
329
+ "minimal@80x10"
330
+ "narrow-minimal@20x50"
331
+ "explicit-full@80x10"
332
+ "explicit-compact@80x50"
333
+ "explicit-minimal@80x50"
334
+ "invalid-normalizes@80x50"
335
+ )
336
+ ROWS=(50 50 30 25 10 50 10 50 50 50)
337
+ COLS=(80 120 60 80 80 20 80 80 80 80)
338
+ DENSITIES=(auto auto auto auto auto auto full compact minimal invalid)
339
+
340
+ for i in "${!LABELS[@]}"; do
341
+ label="${LABELS[$i]}"
342
+ rows="${ROWS[$i]}"
343
+ cols="${COLS[$i]}"
344
+ density="${DENSITIES[$i]}"
345
+ render_at "$baseline_dir" "$cols" "$rows" "$density"
346
+ assert_render "$label" "$cols" "$rows" "$base_name" "$density"
242
347
  done
243
348
 
244
349
  # ─── Fixture 2: duck + CJK reaction (real prior breakage) ────────────────────
245
350
  cjk_dir=$(mktemp -d)
246
- cleanup() { rm -rf "$cjk_dir" "${emoji_dir:-}" "${corrupt_dir:-}"; }
247
- trap cleanup EXIT
351
+ add_cleanup "$cjk_dir"
248
352
  mkdir -p "$cjk_dir/buddy-state"
249
353
  cp "$fixture_dir/buddy-state/config.json" "$cjk_dir/buddy-state/config.json"
250
354
 
251
- bun -e "
252
- import { writeFileSync } from 'fs';
253
- import { getStatusFrames } from './server/art.ts';
254
- const bones = {
255
- rarity: 'common', species: 'duck', eye: '°', hat: 'none', shiny: false,
256
- stats: { DEBUGGING: 50, PATIENCE: 50, CHAOS: 50, WISDOM: 50, SNARK: 50 },
257
- peak: 'DEBUGGING', dump: 'PATIENCE',
258
- };
259
- const { frames, frameSequence } = getStatusFrames(bones);
260
- writeFileSync(process.argv[1], JSON.stringify({
261
- name: 'Daffodil', species: 'duck', rarity: 'common', stars: '★',
262
- face: '(°>', eye: '°', shiny: false, hat: 'none', reaction: '',
263
- muted: false, achievement: '', frames, frameSequence,
264
- level: 1, xp: 0, mood: 'focused',
265
- }));
266
- " "$cjk_dir/buddy-state/status.json"
355
+ cat <<'JSON' | bun "$ROOT/scripts/ci/write-density-status.ts" "$cjk_dir/buddy-state/status.json"
356
+ {"name":"Daffodil","species":"duck","rarity":"common","stars":"★","face":"(°>","eye":"°","shiny":false,"hat":"none","reaction":"","muted":false,"achievement":"","level":1,"xp":0,"mood":"focused"}
357
+ JSON
267
358
 
268
359
  _ts=$(now_ms)
269
360
  printf '{"reaction":"编译通过了 你好世界","timestamp":%s,"reason":"turn"}\n' "$_ts" \
270
361
  > "$cjk_dir/buddy-state/reaction.default.json"
271
362
 
272
- # Bubble rows use INNER_W=44 (box ~48 cols) today; the resize worker owns
273
- # shrinking/dropping bubbles under that floor. Exercise CJK width accounting
274
- # at budgets that can actually hold the current box.
363
+ # Full width CJK/emoji bubble checks.
275
364
  for cols in 80 93 120 200; do
276
- render_at "$cjk_dir" "$cols"
277
- assert_render "cjk@${cols}" "$cols" "Daffodil" "你好"
278
- printf '%s\n' "$PLAIN" | grep -Fq '<(°' \
365
+ render_at "$cjk_dir" "$cols" 50 auto
366
+ assert_render "cjk@${cols}" "$cols" 50 "Daffodil" auto "你好"
367
+ printf '%s' "$PLAIN" | grep -Fq '<(°' \
279
368
  || fail "[cjk@${cols}] duck eye row missing degree-sign glyph" "$PLAIN"
280
369
  done
281
370
 
371
+ # Minimal sanity: name stays visible and nothing escapes the narrow budget.
372
+ render_at "$cjk_dir" 20 10 auto
373
+ assert_render "cjk-minimal@20x10" 20 10 "Daffodil" auto
374
+
282
375
  # ─── Fixture 3: wide-emoji reaction (VS16 / presentation) ────────────────────
283
376
  emoji_dir=$(mktemp -d)
377
+ add_cleanup "$emoji_dir"
284
378
  mkdir -p "$emoji_dir/buddy-state"
285
379
  cp "$cjk_dir/buddy-state/status.json" "$emoji_dir/buddy-state/status.json"
286
380
  cp "$fixture_dir/buddy-state/config.json" "$emoji_dir/buddy-state/config.json"
@@ -289,50 +383,69 @@ printf '{"reaction":"ship it 🎉 ❤️ ✨","timestamp":%s,"reason":"success"}
289
383
  > "$emoji_dir/buddy-state/reaction.default.json"
290
384
 
291
385
  for cols in 80 93 120 200; do
292
- render_at "$emoji_dir" "$cols"
293
- assert_render "emoji@${cols}" "$cols" "Daffodil" "🎉"
386
+ render_at "$emoji_dir" "$cols" 50 auto
387
+ assert_render "emoji@${cols}" "$cols" 50 "Daffodil" auto "🎉"
294
388
  done
295
389
 
390
+ render_at "$emoji_dir" 20 10 auto
391
+ assert_render "emoji-minimal@20x10" 20 10 "Daffodil" auto
392
+
296
393
  # ─── Fixture 4: corrupt eye descriptor must not reach the sprite ─────────────
297
394
  # Regression for the live "<(normal ..." bug: even if bones.eye is the word
298
- # "normal", getStatusFrames must bake a real glyph.
395
+ # "normal", getStatusFrames must bake a real glyph across every density array.
299
396
  corrupt_dir=$(mktemp -d)
397
+ add_cleanup "$corrupt_dir"
300
398
  mkdir -p "$corrupt_dir/buddy-state"
301
399
  cp "$fixture_dir/buddy-state/config.json" "$corrupt_dir/buddy-state/config.json"
302
400
 
401
+ cat <<'JSON' | bun "$ROOT/scripts/ci/write-density-status.ts" "$corrupt_dir/buddy-state/status.json"
402
+ {"name":"Daffodil","species":"duck","rarity":"common","stars":"★","face":"(°>","eye":"normal","shiny":false,"hat":"none","reaction":"","muted":false,"achievement":"","level":1,"xp":0,"mood":"focused"}
403
+ JSON
404
+
405
+ # Ensure the corrupt eye descriptor was sanitized in every density array.
303
406
  bun -e "
304
- import { writeFileSync } from 'fs';
305
- import { getStatusFrames } from './server/art.ts';
306
- const bones = {
307
- rarity: 'common', species: 'duck', eye: 'normal', hat: 'none', shiny: false,
308
- stats: { DEBUGGING: 50, PATIENCE: 50, CHAOS: 50, WISDOM: 50, SNARK: 50 },
309
- peak: 'DEBUGGING', dump: 'PATIENCE',
310
- };
311
- const { frames, frameSequence } = getStatusFrames(bones);
312
- for (const body of frames) {
313
- if (body.includes('normal')) {
314
- console.error('getStatusFrames leaked descriptor into frame:', body);
315
- process.exit(2);
316
- }
317
- if (body.includes('{E}')) {
318
- console.error('unresolved placeholder:', body);
319
- process.exit(2);
407
+ import { readFileSync } from 'fs';
408
+ const status = JSON.parse(readFileSync(process.argv[1], 'utf8'));
409
+ for (const key of ['frames', 'compactFrames', 'minimalFrames']) {
410
+ const arr = status[key];
411
+ if (!arr) continue;
412
+ for (const body of arr) {
413
+ if (body.includes('normal')) {
414
+ console.error('getStatusFrames leaked descriptor into', key, ':', body);
415
+ process.exit(2);
416
+ }
417
+ if (body.includes('{E}')) {
418
+ console.error('unresolved {E} placeholder in', key);
419
+ process.exit(2);
420
+ }
320
421
  }
321
422
  }
322
- writeFileSync(process.argv[1], JSON.stringify({
323
- name: 'Daffodil', species: 'duck', rarity: 'common', stars: '★',
324
- face: '(°>', eye: '°', shiny: false, hat: 'none', reaction: '',
325
- muted: false, achievement: '', frames, frameSequence,
326
- level: 1, xp: 0, mood: 'focused',
327
- }));
328
423
  " "$corrupt_dir/buddy-state/status.json"
329
424
 
330
- render_at "$corrupt_dir" 80
331
- assert_render "corrupt-eye@80" 80 "Daffodil"
332
- if printf '%s\n' "$PLAIN" | grep -Fq 'normal'; then
425
+ render_at "$corrupt_dir" 80 50 auto
426
+ assert_render "corrupt-eye@80" 80 50 "Daffodil" auto
427
+ if printf '%s' "$PLAIN" | grep -Fq 'normal'; then
333
428
  fail "[corrupt-eye@80] literal normal appeared in render" "$PLAIN"
334
429
  fi
335
- printf '%s\n' "$PLAIN" | grep -Fq '<(°' \
430
+ printf '%s' "$PLAIN" | grep -Fq '<(°' \
336
431
  || fail "[corrupt-eye@80] expected sanitized degree-sign eye" "$PLAIN"
337
432
 
433
+ render_at "$corrupt_dir" 20 10 auto
434
+ assert_render "corrupt-eye-minimal@20x10" 20 10 "Daffodil" auto
435
+
436
+ # ─── Fixture 5: row override precedence smoke test ───────────────────────────
437
+ # PTY rows are set to 50 by the helper, but BUDDY_STATUSLINE_ROWS=10 forces
438
+ # minimal selection. This proves the env var wins over the PTY probe.
439
+ render_at "$baseline_dir" 80 50 auto
440
+ # Sanity: without the override the previous rows=50 call already exercised full.
441
+ # Now pin minimal with the same terminal rows to assert override behavior.
442
+ render_at "$baseline_dir" 80 10 auto
443
+ assert_render "row-override@80x10" 80 10 "$base_name" auto
444
+
445
+ # ─── Fixture 6: width invariants at the historic narrow width ──────────────────
446
+ # COLUMNS=20 used to be special-cased; the new density path must keep the name
447
+ # and sprite intact while honoring the actual 20-column budget.
448
+ render_at "$baseline_dir" 20 50 auto
449
+ assert_render "narrow-width@20x50" 20 50 "$base_name" auto
450
+
338
451
  echo "OK: all statusline golden assertions passed"
@@ -24,6 +24,14 @@ import termios
24
24
  import time
25
25
 
26
26
 
27
+ def _positive_int(env_name: str, default: int) -> int:
28
+ value = os.environ.get(env_name, "")
29
+ if value and value.isdigit():
30
+ parsed = int(value)
31
+ if parsed > 0:
32
+ return parsed
33
+ return default
34
+
27
35
  def main() -> int:
28
36
  if len(sys.argv) != 5:
29
37
  print(
@@ -36,7 +44,7 @@ def main() -> int:
36
44
  root = sys.argv[2]
37
45
  config_dir = sys.argv[3]
38
46
  script = sys.argv[4]
39
- rows = 30
47
+ rows = _positive_int("BUDDY_STATUSLINE_ROWS", 30)
40
48
  payload = os.environ.get(
41
49
  "STATUSLINE_PAYLOAD",
42
50
  '{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}',
@@ -47,7 +55,7 @@ def main() -> int:
47
55
  "printf %s "
48
56
  + repr(payload)
49
57
  + " | "
50
- + f"COLUMNS={cols} BUDDY_FAKE_NOW=0 "
58
+ + f"COLUMNS={cols} BUDDY_FAKE_NOW=0 BUDDY_STATUSLINE_ROWS={rows} "
51
59
  + f"CLAUDE_CONFIG_DIR={config_dir!r} "
52
60
  + "CLAUDE_CODE_SESSION_ID= TMUX_PANE= BUDDY_SHELL= "
53
61
  + f"bash {script!r}"
@@ -57,11 +65,11 @@ def main() -> int:
57
65
  pid, fd = pty.fork()
58
66
  if pid == 0:
59
67
  os.chdir(root)
60
- os.execvp("bash", ["bash", "-lc", cmd])
68
+ os.execvp("bash", ["bash", "-c", cmd])
61
69
 
62
70
  fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
63
71
  out = bytearray()
64
- deadline = time.time() + 5.0
72
+ deadline = time.time() + 15.0
65
73
  while time.time() < deadline:
66
74
  readable, _, _ = select.select([fd], [], [], 0.1)
67
75
  if fd in readable:
@@ -118,6 +118,9 @@ mkdir -p "$OUT_DIR"
118
118
  env_ci_was="${CI-}"
119
119
  unset CI || true
120
120
  export COLORTERM=truecolor
121
+ # Pin rows so VHS golden renders stay at full density even when the terminal
122
+ # emulator reports a smaller size. vhs-run-statusline.sh falls back to 50 too.
123
+ export BUDDY_STATUSLINE_ROWS=50
121
124
 
122
125
  # Fixture generation uses only in-repo modules (server/art.ts). Never touch
123
126
  # the developer's live ~/.config/claude/buddy-state — CLAUDE_CONFIG_DIR is
@@ -127,32 +130,20 @@ mkdir -p "$gen_dir/cjk/buddy-state" "$gen_dir/emoji/buddy-state"
127
130
  cp scripts/ci/statusline-fixture/buddy-state/config.json "$gen_dir/cjk/buddy-state/config.json"
128
131
  cp scripts/ci/statusline-fixture/buddy-state/config.json "$gen_dir/emoji/buddy-state/config.json"
129
132
 
133
+ bun "$ROOT/scripts/ci/write-density-status.ts" "$gen_dir/cjk/buddy-state/status.json" <<'JSON'
134
+ {"name":"Daffodil","species":"duck","rarity":"common","stars":"★","face":"(°>","eye":"°","shiny":false,"hat":"none","reaction":"","muted":false,"achievement":"","level":1,"xp":0,"mood":"focused"}
135
+ JSON
136
+ cp "$gen_dir/cjk/buddy-state/status.json" "$gen_dir/emoji/buddy-state/status.json"
137
+
130
138
  bun -e "
131
139
  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({
140
+ writeFileSync(process.argv[1], JSON.stringify({
148
141
  reaction: '编译通过了 你好世界', timestamp: Date.now(), reason: 'turn',
149
142
  }));
150
- writeFileSync(process.argv[4], JSON.stringify({
143
+ writeFileSync(process.argv[2], JSON.stringify({
151
144
  reaction: 'ship it 🎉 ❤️ ✨', timestamp: Date.now(), reason: 'success',
152
145
  }));
153
146
  " \
154
- "$gen_dir/cjk/buddy-state/status.json" \
155
- "$gen_dir/emoji/buddy-state/status.json" \
156
147
  "$gen_dir/cjk/buddy-state/reaction.default.json" \
157
148
  "$gen_dir/emoji/buddy-state/reaction.default.json"
158
149
 
@@ -3,7 +3,33 @@ Set Width 1400
3
3
  Set Height 500
4
4
  Set FontSize 14
5
5
  Set Shell "bash"
6
- Type "echo '{\"session_id\":\"ci\",\"model\":{\"display_name\":\"CI\"},\"workspace\":{\"current_dir\":\".\"}}' | CLAUDE_CONFIG_DIR=scripts/ci/statusline-fixture bash statusline/buddy-status.sh"
6
+
7
+ # Generate temporary full/compact/minimal fixtures from the committed baseline
8
+ # without touching production fixtures. The helper augments status.json with
9
+ # compactFrames/minimalFrames when getStatusFrames() emits them.
10
+ Hide
11
+ Type "bash scripts/ci/vhs-density-fixtures.sh"
7
12
  Enter
8
- Sleep 12s
13
+ Sleep 2s
14
+ Show
15
+
16
+ Type "clear"
17
+ Enter
18
+ Sleep 500ms
19
+
20
+ # Full density: 50 rows gives the six-ish-line art + optional bubble.
21
+ Type "COLUMNS=80 BUDDY_STATUSLINE_ROWS=50 bash scripts/ci/vhs-run-statusline.sh 80 scripts/ci/vhs-out/density-fixtures/full"
22
+ Enter
23
+ Sleep 2s
24
+
25
+ # Compact density: three art rows + name, no bubble.
26
+ Type "COLUMNS=80 BUDDY_STATUSLINE_ROWS=50 bash scripts/ci/vhs-run-statusline.sh 80 scripts/ci/vhs-out/density-fixtures/compact"
27
+ Enter
28
+ Sleep 2s
29
+
30
+ # Minimal density: a single sprite/face + name line.
31
+ Type "COLUMNS=80 BUDDY_STATUSLINE_ROWS=50 bash scripts/ci/vhs-run-statusline.sh 80 scripts/ci/vhs-out/density-fixtures/minimal"
32
+ Enter
33
+ Sleep 2s
34
+
9
35
  Screenshot scripts/ci/statusline.png
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env bash
2
+ # Generate temporary full/compact/minimal statusline fixtures for the VHS tape.
3
+ # Leaves the committed production fixture untouched and writes into the ignored
4
+ # scripts/ci/vhs-out/ workspace.
5
+ set -euo pipefail
6
+
7
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
8
+ cd "$ROOT"
9
+
10
+ src_status="$ROOT/scripts/ci/statusline-fixture/buddy-state/status.json"
11
+ out_dir="$ROOT/scripts/ci/vhs-out/density-fixtures"
12
+
13
+ rm -rf "$out_dir"
14
+ mkdir -p "$out_dir"/{full,compact,minimal}/buddy-state
15
+
16
+ for d in full compact minimal; do
17
+ cp "$src_status" "$out_dir/$d/buddy-state/status.json"
18
+ printf '{"useCombinedStatus":false,"statuslineDensity":"%s"}\n' "$d" \
19
+ > "$out_dir/$d/buddy-state/config.json"
20
+ done
21
+
22
+ # Augment the committed fixture with full/compact/minimal frame arrays.
23
+ cat "$src_status" | bun "$ROOT/scripts/ci/write-density-status.ts" "$out_dir/full/buddy-state/status.json"
24
+ cp "$out_dir/full/buddy-state/status.json" "$out_dir/compact/buddy-state/status.json"
25
+ cp "$out_dir/full/buddy-state/status.json" "$out_dir/minimal/buddy-state/status.json"
26
+
27
+ echo "$out_dir"