@ramarivera/coding-buddy 0.8.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.
Files changed (56) 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 +17 -6
  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 +17 -6
  13. package/adapters/shared/file-storage.ts +22 -0
  14. package/adapters/shared/widget-layout.ts +248 -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 +3 -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 -3
  25. package/scripts/ci/Dockerfile.vhs +125 -0
  26. package/scripts/ci/check-statusline.sh +441 -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 +107 -0
  34. package/scripts/ci/render-vhs-goldens.sh +226 -0
  35. package/scripts/ci/statusline.tape +28 -2
  36. package/scripts/ci/vhs-density-fixtures.sh +27 -0
  37. package/scripts/ci/vhs-run-statusline.sh +28 -0
  38. package/scripts/ci/write-density-status.ts +86 -0
  39. package/scripts/gen-emoji-widths.ts +41 -17
  40. package/scripts/paths.sh +3 -1
  41. package/server/art.ts +70 -17
  42. package/server/hooks/buddy-comment.ts +166 -17
  43. package/server/hooks/file-type-react.ts +1 -1
  44. package/server/hooks/mood-react.ts +1 -1
  45. package/server/hooks/name-react.ts +1 -1
  46. package/server/hooks/react.ts +1 -1
  47. package/server/hooks/reaction-data.ts +10 -3
  48. package/server/index.ts +67 -27
  49. package/server/path.ts +3 -0
  50. package/server/state.ts +147 -41
  51. package/skills/buddy/SKILL.md +1 -1
  52. package/statusline/buddy-status.sh +488 -76
  53. package/statusline/emoji-text.data +3 -0
  54. package/statusline/emoji-widths.data +2 -3
  55. package/statusline/substatus.sh +52 -10
  56. package/scripts/postinstall.cjs +0 -42
@@ -12,6 +12,28 @@
12
12
  #
13
13
  # When running inside buddy-shell (the PTY wrapper), skip status line rendering
14
14
  # so the buddy doesn't show up twice (once in status line, once in wrapper panel).
15
+ # Bash treats COLUMNS as a special variable and may refresh it from a
16
+ # controlling PTY after startup. Snapshot the exported value before any
17
+ # sourced helper or external command can trigger that refresh.
18
+ _INHERITED_COLUMNS="${COLUMNS:-}"
19
+ _INHERITED_ROWS="${LINES:-}"
20
+ # Width math and substring slicing must agree on UTF-8 codepoints. Claude Code
21
+ # emits Unicode art even when a caller inherited the POSIX C locale.
22
+ _LOCALE_HINT="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
23
+ case "$_LOCALE_HINT" in
24
+ *UTF-8|*utf-8|*utf8|*UTF8) ;;
25
+ *)
26
+ _UTF8_LOCALE=""
27
+ for _candidate in C.UTF-8 en_US.UTF-8; do
28
+ if locale -a 2>/dev/null | grep -Fx "$_candidate" >/dev/null 2>&1; then
29
+ _UTF8_LOCALE="$_candidate"
30
+ break
31
+ fi
32
+ done
33
+ [ -n "$_UTF8_LOCALE" ] && export LC_ALL="$_UTF8_LOCALE"
34
+ ;;
35
+ esac
36
+
15
37
  [ "$BUDDY_SHELL" = "1" ] && exit 0
16
38
 
17
39
  # shellcheck source=../scripts/paths.sh
@@ -35,31 +57,15 @@ RARITY=$(jq -r '.rarity // "common"' "$STATE" 2>/dev/null)
35
57
  STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
36
58
  SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
37
59
  REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
38
- REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
39
60
  ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
40
61
  LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
41
62
  MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
42
63
 
43
64
  BUDDY_STATUSLINE_INPUT=$(cat)
44
65
 
45
- # ─── Animation: pick current frame from server-rendered frames ──────────────
66
+ # ─── Animation timing ───────────────────────────────────────────────────────
46
67
  NOW=${BUDDY_FAKE_NOW:-$(date +%s)}
47
- FRAME_BODY=$(jq -r --argjson now "$NOW" '
48
- .frameSequence[$now % (.frameSequence | length)] as $idx
49
- | .frames[$idx] // ""
50
- ' "$STATE" 2>/dev/null)
51
-
52
- # Fallback when status.json lacks .frames — e.g. server/bash version skew
53
- # during install or while the MCP server hasn't rewritten the file yet. Keep
54
- # the buddy visible in a degraded form instead of emitting an empty block.
55
- if [ -z "$FRAME_BODY" ]; then
56
- FRAME_BODY=$' \n (°°) \n ( ) \n \n '
57
- fi
58
-
59
- ART_LINES=()
60
- while IFS= read -r line; do
61
- ART_LINES+=("$line")
62
- done <<< "$FRAME_BODY"
68
+ # The actual frame body is selected later once density/rows are known.
63
69
 
64
70
  # ─── Rarity color (theme-aware) ─────────────────────────────────────────────
65
71
  _THEME="dark"
@@ -113,57 +119,195 @@ if [ -f "$CONFIG_FILE" ]; then
113
119
  fi
114
120
  fi
115
121
 
122
+ COLOR_ENABLED=1
123
+ [ -n "${NO_COLOR:-}" ] && COLOR_ENABLED=0
124
+
116
125
  RAINBOW_LEN=${#RAINBOW[@]}
117
126
  RAINBOW_OFFSET=$(( NOW % RAINBOW_LEN ))
118
127
 
119
- # ─── Terminal width ──────────────────────────────────────────────────────────
128
+ _is_positive_int() {
129
+ case "$1" in
130
+ ''|*[!0-9]*) return 1 ;;
131
+ esac
132
+ [ "$1" -gt 0 ] 2>/dev/null && return 0
133
+ return 1
134
+ }
135
+
136
+ # ─── Terminal size (rows + columns from the same stty size probe) ─────────────
137
+ COLS=0
138
+ ROWS=0
139
+
140
+ # Claude Code renders this command inside a content box, not across the full
141
+ # terminal. Reserve two columns for settings.json padding (1 per side) and
142
+ # twelve for Claude Code's internal left/right content margins. The 14-column
143
+ # reserve is calibrated against the reported ~74-column content box; the
144
+ # regression fixture is rendered at 60, 80, and 120 columns to assert the
145
+ # resulting budget before output.
146
+ CHROME_RESERVE=14
147
+ STATUSLINE_WIDTH_ADJUST=0
148
+
149
+ ROWS=0
120
150
  COLS=0
121
- PID=$$
122
- for _ in 1 2 3 4 5; do
123
- PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ')
124
- [ -z "$PID" ] || [ "$PID" = "1" ] && break
125
-
126
- # Linux: read PTY device from /proc
127
- PTY=$(readlink "/proc/${PID}/fd/0" 2>/dev/null)
128
- if [ -c "$PTY" ] 2>/dev/null; then
129
- COLS=$(stty size < "$PTY" 2>/dev/null | awk '{print $2}')
130
- [ "${COLS:-0}" -gt 40 ] 2>/dev/null && break
131
- fi
132
151
 
133
- # macOS: /proc doesn't exist — get TTY name from process table
134
- TTY_NAME=$(ps -o tty= -p "$PID" 2>/dev/null | tr -d ' ')
135
- if [ -n "$TTY_NAME" ] && [ "$TTY_NAME" != "??" ] && [ "$TTY_NAME" != "?" ]; then
136
- TTY_DEV="/dev/$TTY_NAME"
137
- if [ -c "$TTY_DEV" ] 2>/dev/null; then
138
- COLS=$(stty size < "$TTY_DEV" 2>/dev/null | awk '{print $2}')
139
- [ "${COLS:-0}" -gt 40 ] 2>/dev/null && break
152
+ # Rows/cols resolution precedence (real use):
153
+ # 1. BUDDY_STATUSLINE_ROWS / BUDDY_STATUSLINE_COLS (validated positive ints)
154
+ # 2. exported LINES / COLUMNS (validated positive ints)
155
+ # 3. Linux: /proc/$PID/fd/0 PTY via stty size (captures both values)
156
+ # 4. macOS: ps + stty on /dev/$TTY_NAME (captures both values)
157
+ # 5. Windows: PowerShell (Get-Host).UI.RawUI.WindowSize.Height/Width
158
+ # 6. final defaults: ROWS 999 (full), COLS 125
159
+ if _is_positive_int "${BUDDY_STATUSLINE_ROWS:-}"; then
160
+ ROWS=$((10#${BUDDY_STATUSLINE_ROWS}))
161
+ elif _is_positive_int "$_INHERITED_ROWS"; then
162
+ ROWS=$((10#$_INHERITED_ROWS))
163
+ fi
164
+
165
+ if _is_positive_int "${BUDDY_STATUSLINE_COLS:-}"; then
166
+ COLS=$((10#${BUDDY_STATUSLINE_COLS}))
167
+ elif _is_positive_int "$_INHERITED_COLUMNS"; then
168
+ COLS=$((10#$_INHERITED_COLUMNS))
169
+ fi
170
+
171
+ # If either dimension is still unknown, walk the process tree and read the
172
+ # PTY dimensions once. stty size returns "rows cols"; parse both from the same
173
+ # probe so rows and cols are consistent and no extra detection pass is needed.
174
+ if [ "$ROWS" -lt 1 ] 2>/dev/null || [ "$COLS" -lt 1 ] 2>/dev/null; then
175
+ PID=$$
176
+ for _ in 1 2 3 4 5; do
177
+ PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ')
178
+ [ -z "$PID" ] || [ "$PID" = "1" ] && break
179
+
180
+ # Linux: read PTY device from /proc
181
+ PTY=$(readlink "/proc/${PID}/fd/0" 2>/dev/null)
182
+ if [ -c "$PTY" ] 2>/dev/null; then
183
+ _stty=$(stty size < "$PTY" 2>/dev/null)
184
+ if [ -n "$_stty" ]; then
185
+ _rows=${_stty%% *}
186
+ _cols=${_stty##* }
187
+ [ "$ROWS" -lt 1 ] 2>/dev/null && _is_positive_int "$_rows" && ROWS=$((10#$_rows))
188
+ [ "$COLS" -lt 1 ] 2>/dev/null && _is_positive_int "$_cols" && COLS=$((10#$_cols))
189
+ [ "$ROWS" -gt 0 ] 2>/dev/null && [ "$COLS" -gt 0 ] 2>/dev/null && break
190
+ fi
140
191
  fi
141
- fi
142
- done
143
- [ "${COLS:-0}" -lt 40 ] 2>/dev/null && COLS=${COLUMNS:-0}
192
+
193
+ # macOS: /proc doesn't exist — get TTY name from process table
194
+ TTY_NAME=$(ps -o tty= -p "$PID" 2>/dev/null | tr -d ' ')
195
+ if [ -n "$TTY_NAME" ] && [ "$TTY_NAME" != "??" ] && [ "$TTY_NAME" != "?" ]; then
196
+ TTY_DEV="/dev/$TTY_NAME"
197
+ if [ -c "$TTY_DEV" ] 2>/dev/null; then
198
+ _stty=$(stty size < "$TTY_DEV" 2>/dev/null)
199
+ if [ -n "$_stty" ]; then
200
+ _rows=${_stty%% *}
201
+ _cols=${_stty##* }
202
+ [ "$ROWS" -lt 1 ] 2>/dev/null && _is_positive_int "$_rows" && ROWS=$((10#$_rows))
203
+ [ "$COLS" -lt 1 ] 2>/dev/null && _is_positive_int "$_cols" && COLS=$((10#$_cols))
204
+ [ "$ROWS" -gt 0 ] 2>/dev/null && [ "$COLS" -gt 0 ] 2>/dev/null && break
205
+ fi
206
+ fi
207
+ fi
208
+ done
209
+ fi
210
+
211
+ [ "${COLS:-0}" -lt 1 ] 2>/dev/null && COLS=0
144
212
  # Windows: /proc and TTY device detection don't exist; use PowerShell as fallback
145
- if [ "${COLS:-0}" -lt 40 ] 2>/dev/null; then
213
+ if [ "${COLS:-0}" -lt 1 ] 2>/dev/null; then
146
214
  _ps_cols=$(powershell.exe -NoProfile -Command "(Get-Host).UI.RawUI.WindowSize.Width" 2>/dev/null | tr -d '\r\n')
147
- case "$_ps_cols" in ''|*[!0-9]*) ;; *) [ "$_ps_cols" -gt 40 ] 2>/dev/null && COLS=$_ps_cols ;; esac
215
+ if _is_positive_int "$_ps_cols"; then
216
+ [ "$_ps_cols" -gt 40 ] 2>/dev/null && COLS=$((10#$_ps_cols))
217
+ fi
148
218
  fi
149
- [ "${COLS:-0}" -lt 40 ] 2>/dev/null && COLS=125
219
+ if [ "${ROWS:-0}" -lt 1 ] 2>/dev/null; then
220
+ _ps_rows=$(powershell.exe -NoProfile -Command "(Get-Host).UI.RawUI.WindowSize.Height" 2>/dev/null | tr -d '\r\n')
221
+ _is_positive_int "$_ps_rows" && ROWS=$((10#$_ps_rows))
222
+ fi
223
+
224
+ [ "${COLS:-0}" -lt 1 ] 2>/dev/null && COLS=125
225
+ [ "${ROWS:-0}" -lt 1 ] 2>/dev/null && ROWS=999
226
+
227
+ COLS=$((10#$COLS))
228
+ DETECTED_COLS="$COLS"
229
+ DETECTED_ROWS="$ROWS"
150
230
 
151
231
  # ─── Reaction bubble (with TTL check) ────────────────────────────────────────
152
232
  BUBBLE=""
153
233
  if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ] && [ "$ACHIEVEMENT" != "" ]; then
154
234
  BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT"
155
235
  fi
156
- REACTION_TTL=0
236
+ REACTION_TTL=900
157
237
  INNER_W=44
158
238
  MARGIN=8
239
+ DENSITY="auto"
159
240
  if [ -f "$CONFIG_FILE" ]; then
160
- _ttl=$(jq -r '.reactionTTL // 0' "$CONFIG_FILE" 2>/dev/null || echo 0)
241
+ _ttl=$(jq -r '.reactionTTL // 900' "$CONFIG_FILE" 2>/dev/null || echo 900)
161
242
  case "$_ttl" in ''|*[!0-9]*) ;; *) REACTION_TTL="$_ttl" ;; esac
162
243
  _bw=$(jq -r '.bubbleWidth // 44' "$CONFIG_FILE" 2>/dev/null || echo 44)
163
244
  case "$_bw" in ''|*[!0-9]*) ;; *) INNER_W="$_bw" ;; esac
164
245
  _bm=$(jq -r '.bubbleMargin // 8' "$CONFIG_FILE" 2>/dev/null || echo 8)
165
246
  case "$_bm" in ''|*[!0-9]*) ;; *) MARGIN="$_bm" ;; esac
247
+ _wa=$(jq -r '.statuslineWidthAdjust // 0' "$CONFIG_FILE" 2>/dev/null || echo 0)
248
+ if printf '%s' "$_wa" | grep -Eq '^[+-]?[0-9]+$'; then
249
+ case "$_wa" in
250
+ +*) STATUSLINE_WIDTH_ADJUST=$((10#${_wa#+})) ;;
251
+ -*) STATUSLINE_WIDTH_ADJUST=$((-10#${_wa#-})) ;;
252
+ *) STATUSLINE_WIDTH_ADJUST=$((10#$_wa)) ;;
253
+ esac
254
+ fi
255
+ _density=$(jq -r '.statuslineDensity // "auto"' "$CONFIG_FILE" 2>/dev/null || echo "auto")
256
+ case "$_density" in
257
+ auto|full|compact|minimal) DENSITY="$_density" ;;
258
+ *) DENSITY="auto" ;;
259
+ esac
166
260
  fi
261
+ # ─── Statusline density tier ─────────────────────────────────────────────────
262
+ # Explicit config/env density overrides pin the tier; otherwise rows drive it:
263
+ # full >= 40, compact 20-39, minimal < 20. Very narrow terminals also force minimal.
264
+ if [ -n "${BUDDY_STATUSLINE_DENSITY:-}" ]; then
265
+ DENSITY="${BUDDY_STATUSLINE_DENSITY}"
266
+ fi
267
+ case "$DENSITY" in
268
+ full|compact|minimal) TIER="$DENSITY" ;;
269
+ *)
270
+ TIER="full"
271
+ if [ "$DETECTED_ROWS" -lt 40 ] 2>/dev/null; then
272
+ TIER="compact"
273
+ fi
274
+ if [ "$DETECTED_ROWS" -lt 20 ] 2>/dev/null; then
275
+ TIER="minimal"
276
+ fi
277
+ if [ "$DETECTED_COLS" -lt 40 ] 2>/dev/null; then
278
+ TIER="minimal"
279
+ fi
280
+ ;;
281
+ esac
282
+
283
+ _sweep_expired_reactions() {
284
+ [ "$REACTION_TTL" -gt 0 ] 2>/dev/null || return 0
285
+ local now cutoff_ms cutoff_seconds file ts
286
+ now=$(date +%s)
287
+ cutoff_ms=$(( (now - REACTION_TTL) * 1000 ))
288
+ cutoff_seconds=$(( now - REACTION_TTL ))
289
+
290
+ for file in "$BUDDY_STATE_DIR"/reaction.*.json; do
291
+ [ -f "$file" ] || continue
292
+ ts=$(jq -r '.timestamp // 0' "$file" 2>/dev/null || echo 0)
293
+ case "$ts" in
294
+ ''|*[!0-9]*) rm -f "$file" 2>/dev/null ;;
295
+ *) [ "$ts" -le "$cutoff_ms" ] 2>/dev/null && rm -f "$file" 2>/dev/null ;;
296
+ esac
297
+ done
298
+
299
+ for file in "$BUDDY_STATE_DIR"/.last_comment.*; do
300
+ [ -f "$file" ] || continue
301
+ ts=$(cat "$file" 2>/dev/null)
302
+ case "$ts" in
303
+ ''|*[!0-9]*) rm -f "$file" 2>/dev/null ;;
304
+ *) [ "$ts" -le "$cutoff_seconds" ] 2>/dev/null && rm -f "$file" 2>/dev/null ;;
305
+ esac
306
+ done
307
+ }
308
+
309
+ _sweep_expired_reactions
310
+ REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
167
311
  if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; then
168
312
  FRESH=0
169
313
  if [ "$REACTION_TTL" -eq 0 ]; then
@@ -172,7 +316,7 @@ if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; the
172
316
  TS=$(jq -r '.timestamp // 0' "$REACTION_FILE" 2>/dev/null || echo 0)
173
317
  if [ "$TS" != "0" ]; then
174
318
  NOW=$(date +%s)
175
- AGE=$(( NOW - TS / 1000 ))
319
+ AGE=$(( (NOW * 1000 - TS) / 1000 ))
176
320
  [ "$AGE" -lt "$REACTION_TTL" ] && FRESH=1
177
321
  fi
178
322
  fi
@@ -182,9 +326,33 @@ if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; the
182
326
  else
183
327
  BUBBLE="\"${REACTION}\""
184
328
  fi
329
+ else
330
+ rm -f "$REACTION_FILE" 2>/dev/null
185
331
  fi
186
332
  fi
187
333
 
334
+ # ─── Animation: pick current density frame from server-rendered frames ───────
335
+ NOW=${BUDDY_FAKE_NOW:-$(date +%s)}
336
+ FRAME_BODY=$(jq -r --argjson now "$NOW" --arg tier "$TIER" '
337
+ .frameSequence[$now % (.frameSequence | length)] as $idx
338
+ | if $tier == "compact" then ((.compactFrames? // .frames) | .[$idx] // .frames[$idx])
339
+ elif $tier == "minimal" then ((.minimalFrames? // .frames) | .[$idx] // .frames[$idx])
340
+ else .frames[$idx]
341
+ end // ""
342
+ ' "$STATE" 2>/dev/null)
343
+
344
+ # Fallback when status.json lacks .frames — e.g. server/bash version skew
345
+ # during install or while the MCP server hasn't rewritten the file yet. Keep
346
+ # the buddy visible in a degraded form instead of emitting an empty block.
347
+ if [ -z "$FRAME_BODY" ]; then
348
+ FRAME_BODY=$' \n (°°) \n ( ) \n \n '
349
+ fi
350
+
351
+ ART_LINES=()
352
+ while IFS= read -r line; do
353
+ ART_LINES+=("$line")
354
+ done <<< "$FRAME_BODY"
355
+
188
356
  # ─── Build all art lines ──────────────────────────────────────────────────────
189
357
  # ART_LINES comes from the pre-rendered frame (already includes hat + blink).
190
358
  # Center the name under the art. Frames are 12 cols wide (see server/art.ts),
@@ -209,6 +377,15 @@ NAME_PAD=$(( ART_CENTER - NAME_LEN / 2 ))
209
377
  NAME_LINE="$(printf '%*s%s' "$NAME_PAD" '' "$NAME_WITH_LEVEL")"
210
378
 
211
379
  DIM=$'\033[2;3m'
380
+ if [ "$COLOR_ENABLED" -eq 0 ]; then
381
+ C=""
382
+ NC=""
383
+ NEUTRAL=""
384
+ DIM=""
385
+ for _rainbow_index in "${!RAINBOW[@]}"; do
386
+ RAINBOW[$_rainbow_index]=""
387
+ done
388
+ fi
212
389
 
213
390
  ALL_LINES=()
214
391
  ALL_COLORS=()
@@ -218,19 +395,12 @@ for line in "${ART_LINES[@]}"; do
218
395
  if [ "$SHINY" = "true" ]; then
219
396
  ALL_COLORS+=("${RAINBOW[$(( (_arc + RAINBOW_OFFSET) % RAINBOW_LEN ))]}")
220
397
  else
221
- ALL_COLORS+=("$NEUTRAL")
398
+ ALL_COLORS+=("$C")
222
399
  fi
223
400
  _arc=$(( _arc + 1 ))
224
401
  done
225
402
  ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$C")
226
403
 
227
- case "$SPECIES" in
228
- axolotl|capybara|rabbit|mushroom|chonk) ART_W=10 ;;
229
- duck|blob|cat|dragon|robot|wyvern) ART_W=9 ;;
230
- octopus|owl|turtle|snail|ghost|cactus) ART_W=8 ;;
231
- goose|penguin) ART_W=7 ;;
232
- *) ART_W=10 ;;
233
- esac
234
404
  ART_COUNT=${#ALL_LINES[@]}
235
405
 
236
406
  # ─── Speech bubble (left of art, word-wrapped) ──────────────────────────────
@@ -243,25 +413,33 @@ fi
243
413
 
244
414
  # ─── Display width (emojis count as 2 cols) ──────────────────────────────────
245
415
  # iconv turns the string into a stream of UTF-32LE codepoints, then awk sums
246
- # widths. Rules mirror server/art.ts:displayWidth — the U+2600-U+27BF range
247
- # is split by Emoji_Presentation (2) vs text-presentation (1), and VS16
248
- # (U+FE0F) upgrades the previous narrow symbol to 2 cols (e.g. ❤ + VS16).
249
- # The ambiguous codepoint list comes from emoji-widths.data, generated by
250
- # scripts/gen-emoji-widths.ts from the Unicode Emoji_Presentation property.
416
+ # widths. Rules mirror server/art.ts:displayWidth; the generated data lists
417
+ # every Unicode Emoji_Presentation codepoint, while VS16 upgrades a previous
418
+ # narrow emoji to 2 cols (e.g. ❤ + VS16).
251
419
  EMOJI_WIDTHS_DATA="$(dirname "${BASH_SOURCE[0]}")/emoji-widths.data"
252
420
  EMOJI_PRES_2600="$(grep -v '^#' "$EMOJI_WIDTHS_DATA" 2>/dev/null | tr -d '\n')"
421
+ EMOJI_TEXT_DATA="$(dirname "${BASH_SOURCE[0]}")/emoji-text.data"
422
+ EMOJI_TEXT="$(grep -v '^#' "$EMOJI_TEXT_DATA" 2>/dev/null | tr -d '\n')"
253
423
 
254
424
  dwidth() {
255
- printf '%s' "$1" | iconv -f UTF-8 -t UTF-32LE 2>/dev/null | od -An -tu4 | awk -v pres="$EMOJI_PRES_2600" '
425
+ printf '%s' "$1" | iconv -f UTF-8 -t UTF-32LE 2>/dev/null | od -An -tu4 | awk -v pres="$EMOJI_PRES_2600" -v text="$EMOJI_TEXT" '
426
+ function load_ranges(value, target, n, i, count, piece, bounds, start, end, cp) {
427
+ n = split(value, ranges, ",")
428
+ for (i = 1; i <= n; i++) {
429
+ count = split(ranges[i], bounds, "-")
430
+ start = bounds[1] + 0
431
+ end = (count == 2) ? bounds[2] + 0 : start
432
+ for (cp = start; cp <= end; cp++) target[cp] = 1
433
+ }
434
+ }
256
435
  BEGIN {
257
- n = split(pres, arr)
258
- for (k = 1; k <= n; k++) wide[arr[k]] = 1
436
+ load_ranges(pres, wide)
437
+ load_ranges(text, text_default)
259
438
  }
260
439
  # Precondition: cp is neither a variation selector (65024-65039) nor ZWJ
261
440
  # (8205); the main loop filters those before calling in.
262
441
  function char_width(cp) {
263
- if (cp >= 126976) return 2
264
- if (cp >= 9728 && cp <= 10175) return (cp in wide) ? 2 : 1
442
+ if (cp in wide) return 2
265
443
  if (cp >= 9472 && cp <= 9631) return 1
266
444
  if (cp >= 12288 && cp <= 40959) return 2
267
445
  if (cp >= 65281 && cp <= 65376) return 2
@@ -276,15 +454,158 @@ dwidth() {
276
454
  if ((cp >= 65024 && cp <= 65038) || cp == 8205) { upgradable = 0; continue }
277
455
  cw = char_width(cp)
278
456
  w += cw
279
- upgradable = (cw == 1 && cp >= 9728 && cp <= 10175) ? 1 : 0
457
+ upgradable = 0
458
+ if (cw == 1 && (cp in text_default)) upgradable = 1
280
459
  } }
281
460
  END { print w+0 }'
282
461
  }
462
+ # Emit one display-width value per UTF-8 codepoint. ANSI-aware truncation uses
463
+ # this profile to make one Unicode-width pass over the complete output row.
464
+ dwidth_profile() {
465
+ printf '%s' "$1" | iconv -f UTF-8 -t UTF-32LE 2>/dev/null | od -An -tu4 | awk -v pres="$EMOJI_PRES_2600" -v text="$EMOJI_TEXT" '
466
+ function load_ranges(value, target, n, i, count, piece, bounds, start, end, cp) {
467
+ n = split(value, ranges, ",")
468
+ for (i = 1; i <= n; i++) {
469
+ count = split(ranges[i], bounds, "-")
470
+ start = bounds[1] + 0
471
+ end = (count == 2) ? bounds[2] + 0 : start
472
+ for (cp = start; cp <= end; cp++) target[cp] = 1
473
+ }
474
+ }
475
+ BEGIN {
476
+ load_ranges(pres, wide)
477
+ load_ranges(text, text_default)
478
+ }
479
+ function char_width(cp) {
480
+ if (cp in wide) return 2
481
+ if (cp >= 9472 && cp <= 9631) return 1
482
+ if (cp >= 12288 && cp <= 40959) return 2
483
+ if (cp >= 65281 && cp <= 65376) return 2
484
+ return 1
485
+ }
486
+ {
487
+ for (j = 1; j <= NF; j++) {
488
+ cp = $j + 0
489
+ idx++
490
+ if (cp == 65039) {
491
+ if (upgradable && idx > 1) widths[idx - 1] += 1
492
+ widths[idx] = 0
493
+ upgradable = 0
494
+ continue
495
+ }
496
+ if ((cp >= 65024 && cp <= 65038) || cp == 8205) {
497
+ widths[idx] = 0
498
+ upgradable = 0
499
+ continue
500
+ }
501
+ cw = char_width(cp)
502
+ widths[idx] = cw
503
+ upgradable = 0
504
+ if (cw == 1 && (cp in text_default)) upgradable = 1
505
+ }
506
+ }
507
+ END {
508
+ for (j = 1; j <= idx; j++) print widths[j] + 0
509
+ }'
510
+ }
511
+ ART_W=0
512
+ for line in "${ART_LINES[@]}"; do
513
+ line_w=$(dwidth "$line")
514
+ [ "$line_w" -gt "$ART_W" ] && ART_W="$line_w"
515
+ done
516
+
517
+ # Keep the label inside the same sprite column as the art. The exact Unicode
518
+ # width rules live in dwidth(), so the shell and TS renderers agree on bounds.
519
+ LABEL_W=$(dwidth "$NAME_WITH_LEVEL")
520
+ if [ "$LABEL_W" -gt "$ART_W" ] 2>/dev/null; then
521
+ ART_W="$LABEL_W"
522
+ NAME_PAD=$(( (ART_W - LABEL_W) / 2 ))
523
+ NAME_LINE="$(printf '%*s%s' "$NAME_PAD" '' "$NAME_WITH_LEVEL")"
524
+ ALL_LINES[$(( ART_COUNT - 1 ))]="$NAME_LINE"
525
+ fi
526
+ NAME_LINE_W=$(dwidth "$NAME_LINE")
527
+ # Centering the name against a short fixture frame can make the label wider
528
+ # than every art row; include that width before sizing the card.
529
+ [ "$NAME_LINE_W" -gt "$ART_W" ] && ART_W="$NAME_LINE_W"
530
+
531
+ STATUSLINE_BUDGET=$(( DETECTED_COLS - CHROME_RESERVE + STATUSLINE_WIDTH_ADJUST ))
532
+ # Preserve the existing compact-card behavior at the smallest supported
533
+ # terminal size; the reserve applies once there is enough room for chrome.
534
+ if [ "$DETECTED_COLS" -ge 40 ] 2>/dev/null && [ "$STATUSLINE_BUDGET" -lt 40 ]; then
535
+ STATUSLINE_BUDGET=40
536
+ fi
537
+ [ "$STATUSLINE_BUDGET" -gt "$DETECTED_COLS" ] && STATUSLINE_BUDGET="$DETECTED_COLS"
538
+
539
+ # The sprite and its identifying name are the irreducible minimum of the card.
540
+ # Never let the chrome reserve push the usable budget below the sprite's own
541
+ # width; if it does, fall back to the raw terminal width. If the terminal
542
+ # itself is narrower than the sprite, use the historical default rather than
543
+ # slicing art or name.
544
+ if [ "$STATUSLINE_BUDGET" -lt "$ART_W" ]; then
545
+ STATUSLINE_BUDGET="$DETECTED_COLS"
546
+ fi
547
+ if [ "$STATUSLINE_BUDGET" -lt "$ART_W" ]; then
548
+ STATUSLINE_BUDGET=125
549
+ fi
550
+ COLS="$STATUSLINE_BUDGET"
551
+
552
+ # ─── Density branch: compact drops the bubble; minimal is a single line. ─────
553
+ if [ "$TIER" = "minimal" ]; then
554
+ _face_plain="${ART_LINES[0]:-}"
555
+ if [ -z "$_face_plain" ]; then
556
+ for _fline in "${ART_LINES[@]}"; do
557
+ [ -n "$_fline" ] && _face_plain="$_fline" && break
558
+ done
559
+ fi
560
+ [ -z "$_face_plain" ] && _face_plain=$' (°°) '
561
+ _min_plain="${_face_plain} ${NAME_WITH_LEVEL}"
562
+ _min_w=$(dwidth "$_min_plain")
563
+ if [ -n "$REACTION" ]; then
564
+ _react_plain=" │ \"${REACTION}\""
565
+ _react_w=$(dwidth "$_react_plain")
566
+ if [ $((_min_w + _react_w)) -le "$STATUSLINE_BUDGET" ]; then
567
+ _min_plain="${_min_plain}${_react_plain}"
568
+ _min_w=$(dwidth "$_min_plain")
569
+ fi
570
+ fi
571
+ if [ "$STATUSLINE_BUDGET" -lt "$_min_w" ]; then
572
+ STATUSLINE_BUDGET="$DETECTED_COLS"
573
+ fi
574
+ # Never widen past the real terminal: a hard-coded fallback here would
575
+ # defeat ansi_truncate below and emit rows wider than the pane, wrapping
576
+ # and clobbering the prompt on genuinely narrow terminals.
577
+ if [ "$STATUSLINE_BUDGET" -lt "$_min_w" ]; then
578
+ STATUSLINE_BUDGET="$DETECTED_COLS"
579
+ fi
580
+ COLS="$STATUSLINE_BUDGET"
581
+ ART_LINES=("$_min_plain")
582
+ ALL_COLORS=("$C")
583
+ ALL_LINES=("$_min_plain")
584
+ ART_COUNT=1
585
+ ART_W="$_min_w"
586
+ BUBBLE=""
587
+ BUBBLE_TEXT=""
588
+ elif [ "$TIER" = "compact" ]; then
589
+ BUBBLE=""
590
+ BUBBLE_TEXT=""
591
+ fi
592
+
593
+ # The bubble, tail, and sprite are one unit. At narrow widths, drop the
594
+ # bubble rather than allowing a partial border or tail to escape the panel.
595
+ MIN_BUBBLE_INNER=12
596
+ TAIL_W=3
597
+ MAX_INNER=$(( COLS - ART_W - TAIL_W - 4 - MARGIN ))
598
+ if [ -n "$BUBBLE" ] && [ "$MAX_INNER" -ge "$MIN_BUBBLE_INNER" ] 2>/dev/null; then
599
+ [ "$INNER_W" -gt "$MAX_INNER" ] && INNER_W="$MAX_INNER"
600
+ else
601
+ BUBBLE=""
602
+ BUBBLE_TEXT=""
603
+ fi
283
604
 
284
605
  # ─── Word-wrap bubble text ────────────────────────────────────────────────────
285
606
  TEXT_LINES=()
286
607
  if [ -n "$BUBBLE_TEXT" ]; then
287
- WORDS=($BUBBLE_TEXT)
608
+ read -r -a WORDS <<< "$BUBBLE_TEXT"
288
609
  CUR_LINE=""
289
610
  CUR_W=0
290
611
  for word in "${WORDS[@]}"; do
@@ -329,13 +650,16 @@ fi
329
650
  BUBBLE_COUNT=${#BUBBLE_LINES[@]}
330
651
 
331
652
  # ─── Right-align with bubble box to the left ─────────────────────────────────
332
- GAP=2
653
+ GAP=3
333
654
  if [ $BUBBLE_COUNT -gt 0 ]; then
334
655
  TOTAL_W=$(( BOX_W + GAP + ART_W ))
335
656
  else
336
657
  TOTAL_W=$ART_W
337
658
  fi
338
- PAD=$(( COLS - TOTAL_W - MARGIN ))
659
+ # COLS already includes the Claude Code chrome reserve. The spacer starts with
660
+ # one Braille Blank cell, so account for that cell but don't subtract MARGIN
661
+ # again; doing so leaves the card visibly short of the pane's right edge.
662
+ PAD=$(( COLS - TOTAL_W - 1 ))
339
663
  [ "$PAD" -lt 0 ] && PAD=0
340
664
 
341
665
  # On Windows (Git Bash / MSYS2), Braille Blank (U+2800) renders as double-width,
@@ -344,7 +668,14 @@ case "$(uname -s)" in
344
668
  MINGW*|CYGWIN*|MSYS*) SPACER=$(printf '%*s' "$PAD" '') ;;
345
669
  *) SPACER=$(printf "${B}%${PAD}s" "") ;;
346
670
  esac
347
- GAP_STR=$(printf '%*s' "$GAP" '')
671
+
672
+ # Minimal tier uses plain spaces so the one-line sprite + name fits without
673
+ # sacrificing a Braille Blank column on narrow terminals.
674
+ if [ "$TIER" = "minimal" ]; then
675
+ PAD=$(( COLS - ART_W ))
676
+ [ "$PAD" -lt 0 ] && PAD=0
677
+ SPACER=$(printf '%*s' "$PAD" '')
678
+ fi
348
679
 
349
680
  # Vertically center bubble box on the art
350
681
  BUBBLE_START=0
@@ -362,9 +693,10 @@ if [ $BUBBLE_COUNT -gt 2 ]; then
362
693
  CONNECTOR_BI=$(( (FIRST_TEXT + LAST_TEXT) / 2 ))
363
694
  fi
364
695
 
365
- # ─── Output: merged bubble box + connector + art per line ─────────────────────
696
+ # ─── Output: merged bubble box + art per line ──────────────────────────────────
366
697
  TOTAL_BUBBLE=$(( BUBBLE_START + BUBBLE_COUNT ))
367
698
  MAX_LINES=$(( ART_COUNT > TOTAL_BUBBLE ? ART_COUNT : TOTAL_BUBBLE ))
699
+ OUTPUT_LINES=()
368
700
  for (( i=0; i<MAX_LINES; i++ )); do
369
701
  # Art part: actual art line or blank filler
370
702
  if [ $i -lt $ART_COUNT ]; then
@@ -379,30 +711,110 @@ for (( i=0; i<MAX_LINES; i++ )); do
379
711
  bline="${BUBBLE_LINES[$bi]}"
380
712
  btype="${BUBBLE_TYPES[$bi]}"
381
713
 
382
- # Connector: "-- " on the middle text line, spaces otherwise
714
+ # Connector: "-- " on the middle text line, spaces otherwise.
383
715
  if [ $bi -eq $CONNECTOR_BI ]; then
384
- gap="${NEUTRAL}--${NC} "
716
+ gap="${C}--${NC} "
385
717
  else
386
718
  gap=" "
387
719
  fi
388
720
 
389
721
  if [ "$btype" = "border" ]; then
390
- echo "${SPACER}${NEUTRAL}${bline}${NC}${gap}${art_part}"
722
+ OUTPUT_LINES+=("${SPACER}${C}${bline}${NC}${gap}${art_part}")
391
723
  else
392
724
  pipe_l="${bline:0:1}"
393
725
  pipe_r="${bline: -1}"
394
726
  inner="${bline:1:$(( ${#bline} - 2 ))}"
395
- echo "${SPACER}${NEUTRAL}${pipe_l}${NC}${DIM}${inner}${NC}${NEUTRAL}${pipe_r}${NC}${gap}${art_part}"
727
+ OUTPUT_LINES+=("${SPACER}${C}${pipe_l}${NC}${DIM}${inner}${NC}${C}${pipe_r}${NC}${gap}${art_part}")
396
728
  fi
397
729
  else
398
730
  empty=$(printf '%*s' "$BOX_W" '')
399
- echo "${SPACER}${empty} ${art_part}"
731
+ OUTPUT_LINES+=("${SPACER}${empty} ${art_part}")
400
732
  fi
401
733
  else
402
- echo "${SPACER}${art_part}"
734
+ OUTPUT_LINES+=("${SPACER}${art_part}")
403
735
  fi
404
736
  done
405
737
 
738
+ ansi_truncate() {
739
+ local text="$1"
740
+ local max_width="$2"
741
+ local out=""
742
+ local plain=""
743
+ local i=0
744
+ local text_len=${#text}
745
+ local char seq char_width truncated=0 saw_sgr=0
746
+ local visible_width=0
747
+ local -a widths
748
+ local visible_index=0
749
+
750
+ [ "$max_width" -lt 0 ] && max_width=0
751
+
752
+ # Strip SGR while building the one string sent to dwidth_profile. The
753
+ # profile uses one iconv/od/awk pass for the whole row; never spawn a
754
+ # subprocess for each Unicode character.
755
+ while [ "$i" -lt "$text_len" ]; do
756
+ char="${text:$i:1}"
757
+ if [ "$char" = $'\033' ]; then
758
+ saw_sgr=1
759
+ i=$(( i + 1 ))
760
+ while [ "$i" -lt "$text_len" ]; do
761
+ char="${text:$i:1}"
762
+ i=$(( i + 1 ))
763
+ [ "$char" = "m" ] && break
764
+ done
765
+ continue
766
+ fi
767
+ plain="${plain}${char}"
768
+ i=$(( i + 1 ))
769
+ done
770
+
771
+ widths=()
772
+ if [ -n "$plain" ]; then
773
+ while IFS= read -r char_width; do
774
+ widths+=("$char_width")
775
+ done < <(dwidth_profile "$plain")
776
+ fi
777
+
778
+ i=0
779
+ while [ "$i" -lt "$text_len" ]; do
780
+ char="${text:$i:1}"
781
+ if [ "$char" = $'\033' ]; then
782
+ seq="$char"
783
+ i=$(( i + 1 ))
784
+ while [ "$i" -lt "$text_len" ]; do
785
+ char="${text:$i:1}"
786
+ seq="${seq}${char}"
787
+ i=$(( i + 1 ))
788
+ [ "$char" = "m" ] && break
789
+ done
790
+ out="${out}${seq}"
791
+ continue
792
+ fi
793
+
794
+ char_width="${widths[$visible_index]:-1}"
795
+ if [ $(( visible_width + char_width )) -gt "$max_width" ]; then
796
+ truncated=1
797
+ break
798
+ fi
799
+ out="${out}${char}"
800
+ visible_width=$(( visible_width + char_width ))
801
+ visible_index=$(( visible_index + 1 ))
802
+ i=$(( i + 1 ))
803
+ done
804
+
805
+ [ "$truncated" -eq 1 ] && [ "$saw_sgr" -eq 1 ] && out="${out}${NC}"
806
+ printf '%s' "$out"
807
+ }
808
+
809
+ statusline_output_line() {
810
+ ansi_truncate "$1" "$STATUSLINE_BUDGET"
811
+ printf '\n'
812
+ }
813
+
814
+ for line in "${OUTPUT_LINES[@]}"; do
815
+ statusline_output_line "$line"
816
+ done
817
+
406
818
  # Append the last cached sub-status result below the buddy panel and refresh
407
819
  # it asynchronously when stale. The statusline itself never waits on it.
408
820
  append_substatus