@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
@@ -12,6 +12,27 @@
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
+ # Width math and substring slicing must agree on UTF-8 codepoints. Claude Code
20
+ # emits Unicode art even when a caller inherited the POSIX C locale.
21
+ _LOCALE_HINT="${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"
22
+ case "$_LOCALE_HINT" in
23
+ *UTF-8|*utf-8|*utf8|*UTF8) ;;
24
+ *)
25
+ _UTF8_LOCALE=""
26
+ for _candidate in C.UTF-8 en_US.UTF-8; do
27
+ if locale -a 2>/dev/null | grep -Fx "$_candidate" >/dev/null 2>&1; then
28
+ _UTF8_LOCALE="$_candidate"
29
+ break
30
+ fi
31
+ done
32
+ [ -n "$_UTF8_LOCALE" ] && export LC_ALL="$_UTF8_LOCALE"
33
+ ;;
34
+ esac
35
+
15
36
  [ "$BUDDY_SHELL" = "1" ] && exit 0
16
37
 
17
38
  # shellcheck source=../scripts/paths.sh
@@ -35,7 +56,6 @@ RARITY=$(jq -r '.rarity // "common"' "$STATE" 2>/dev/null)
35
56
  STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
36
57
  SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
37
58
  REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
38
- REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
39
59
  ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
40
60
  LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
41
61
  MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
@@ -113,57 +133,132 @@ if [ -f "$CONFIG_FILE" ]; then
113
133
  fi
114
134
  fi
115
135
 
136
+ COLOR_ENABLED=1
137
+ [ -n "${NO_COLOR:-}" ] && COLOR_ENABLED=0
138
+
116
139
  RAINBOW_LEN=${#RAINBOW[@]}
117
140
  RAINBOW_OFFSET=$(( NOW % RAINBOW_LEN ))
118
141
 
142
+ _is_positive_cols() {
143
+ case "$1" in
144
+ ''|*[!0-9]*) return 1 ;;
145
+ esac
146
+ [ "$1" -gt 0 ] 2>/dev/null && return 0
147
+ return 1
148
+ }
149
+
119
150
  # ─── Terminal width ──────────────────────────────────────────────────────────
120
151
  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
152
 
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
153
+ # Claude Code renders this command inside a content box, not across the full
154
+ # terminal. Reserve two columns for settings.json padding (1 per side) and
155
+ # twelve for Claude Code's internal left/right content margins. The 14-column
156
+ # reserve is calibrated against the reported ~74-column content box; the
157
+ # regression fixture is rendered at 60, 80, and 120 columns to assert the
158
+ # resulting budget before output.
159
+ CHROME_RESERVE=14
160
+ STATUSLINE_WIDTH_ADJUST=0
161
+
162
+ # Width resolution precedence (real use):
163
+ # 1. BUDDY_STATUSLINE_COLS (validated positive integer)
164
+ # 2. exported COLUMNS (validated positive integer)
165
+ # 3. Linux: /proc/$PID/fd/0 PTY via stty size
166
+ # 4. macOS: ps + stty on /dev/$TTY_NAME
167
+ # 5. Windows: PowerShell (Get-Host).UI.RawUI.WindowSize.Width
168
+ # 6. final default 125
169
+ if _is_positive_cols "${BUDDY_STATUSLINE_COLS:-}"; then
170
+ COLS=$((10#${BUDDY_STATUSLINE_COLS}))
171
+ elif _is_positive_cols "$_INHERITED_COLUMNS"; then
172
+ COLS=$((10#$_INHERITED_COLUMNS))
173
+ else
174
+ PID=$$
175
+ for _ in 1 2 3 4 5; do
176
+ PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ')
177
+ [ -z "$PID" ] || [ "$PID" = "1" ] && break
178
+
179
+ # Linux: read PTY device from /proc
180
+ PTY=$(readlink "/proc/${PID}/fd/0" 2>/dev/null)
181
+ if [ -c "$PTY" ] 2>/dev/null; then
182
+ COLS=$(stty size < "$PTY" 2>/dev/null | awk '{print $2}')
183
+ _is_positive_cols "${COLS:-}" && break
184
+ fi
185
+
186
+ # macOS: /proc doesn't exist — get TTY name from process table
187
+ TTY_NAME=$(ps -o tty= -p "$PID" 2>/dev/null | tr -d ' ')
188
+ if [ -n "$TTY_NAME" ] && [ "$TTY_NAME" != "??" ] && [ "$TTY_NAME" != "?" ]; then
189
+ TTY_DEV="/dev/$TTY_NAME"
190
+ if [ -c "$TTY_DEV" ] 2>/dev/null; then
191
+ COLS=$(stty size < "$TTY_DEV" 2>/dev/null | awk '{print $2}')
192
+ _is_positive_cols "${COLS:-}" && break
193
+ fi
194
+ fi
195
+ done
196
+
197
+ [ "${COLS:-0}" -lt 1 ] 2>/dev/null && COLS=0
198
+ # Windows: /proc and TTY device detection don't exist; use PowerShell as fallback
199
+ if [ "${COLS:-0}" -lt 1 ] 2>/dev/null; then
200
+ _ps_cols=$(powershell.exe -NoProfile -Command "(Get-Host).UI.RawUI.WindowSize.Width" 2>/dev/null | tr -d '\r\n')
201
+ if _is_positive_cols "$_ps_cols"; then
202
+ [ "$_ps_cols" -gt 40 ] 2>/dev/null && COLS=$((10#$_ps_cols))
140
203
  fi
141
204
  fi
142
- done
143
- [ "${COLS:-0}" -lt 40 ] 2>/dev/null && COLS=${COLUMNS:-0}
144
- # Windows: /proc and TTY device detection don't exist; use PowerShell as fallback
145
- if [ "${COLS:-0}" -lt 40 ] 2>/dev/null; then
146
- _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
205
+ [ "${COLS:-0}" -lt 1 ] 2>/dev/null && COLS=125
148
206
  fi
149
- [ "${COLS:-0}" -lt 40 ] 2>/dev/null && COLS=125
207
+ COLS=$((10#$COLS))
208
+ DETECTED_COLS="$COLS"
150
209
 
151
210
  # ─── Reaction bubble (with TTL check) ────────────────────────────────────────
152
211
  BUBBLE=""
153
212
  if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ] && [ "$ACHIEVEMENT" != "" ]; then
154
213
  BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT"
155
214
  fi
156
- REACTION_TTL=0
215
+ REACTION_TTL=900
157
216
  INNER_W=44
158
217
  MARGIN=8
159
218
  if [ -f "$CONFIG_FILE" ]; then
160
- _ttl=$(jq -r '.reactionTTL // 0' "$CONFIG_FILE" 2>/dev/null || echo 0)
219
+ _ttl=$(jq -r '.reactionTTL // 900' "$CONFIG_FILE" 2>/dev/null || echo 900)
161
220
  case "$_ttl" in ''|*[!0-9]*) ;; *) REACTION_TTL="$_ttl" ;; esac
162
221
  _bw=$(jq -r '.bubbleWidth // 44' "$CONFIG_FILE" 2>/dev/null || echo 44)
163
222
  case "$_bw" in ''|*[!0-9]*) ;; *) INNER_W="$_bw" ;; esac
164
223
  _bm=$(jq -r '.bubbleMargin // 8' "$CONFIG_FILE" 2>/dev/null || echo 8)
165
224
  case "$_bm" in ''|*[!0-9]*) ;; *) MARGIN="$_bm" ;; esac
225
+ _wa=$(jq -r '.statuslineWidthAdjust // 0' "$CONFIG_FILE" 2>/dev/null || echo 0)
226
+ if printf '%s' "$_wa" | grep -Eq '^[+-]?[0-9]+$'; then
227
+ case "$_wa" in
228
+ +*) STATUSLINE_WIDTH_ADJUST=$((10#${_wa#+})) ;;
229
+ -*) STATUSLINE_WIDTH_ADJUST=$((-10#${_wa#-})) ;;
230
+ *) STATUSLINE_WIDTH_ADJUST=$((10#$_wa)) ;;
231
+ esac
232
+ fi
166
233
  fi
234
+ _sweep_expired_reactions() {
235
+ [ "$REACTION_TTL" -gt 0 ] 2>/dev/null || return 0
236
+ local now cutoff_ms cutoff_seconds file ts
237
+ now=$(date +%s)
238
+ cutoff_ms=$(( (now - REACTION_TTL) * 1000 ))
239
+ cutoff_seconds=$(( now - REACTION_TTL ))
240
+
241
+ for file in "$BUDDY_STATE_DIR"/reaction.*.json; do
242
+ [ -f "$file" ] || continue
243
+ ts=$(jq -r '.timestamp // 0' "$file" 2>/dev/null || echo 0)
244
+ case "$ts" in
245
+ ''|*[!0-9]*) rm -f "$file" 2>/dev/null ;;
246
+ *) [ "$ts" -le "$cutoff_ms" ] 2>/dev/null && rm -f "$file" 2>/dev/null ;;
247
+ esac
248
+ done
249
+
250
+ for file in "$BUDDY_STATE_DIR"/.last_comment.*; do
251
+ [ -f "$file" ] || continue
252
+ ts=$(cat "$file" 2>/dev/null)
253
+ case "$ts" in
254
+ ''|*[!0-9]*) rm -f "$file" 2>/dev/null ;;
255
+ *) [ "$ts" -le "$cutoff_seconds" ] 2>/dev/null && rm -f "$file" 2>/dev/null ;;
256
+ esac
257
+ done
258
+ }
259
+
260
+ _sweep_expired_reactions
261
+ REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
167
262
  if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; then
168
263
  FRESH=0
169
264
  if [ "$REACTION_TTL" -eq 0 ]; then
@@ -172,7 +267,7 @@ if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; the
172
267
  TS=$(jq -r '.timestamp // 0' "$REACTION_FILE" 2>/dev/null || echo 0)
173
268
  if [ "$TS" != "0" ]; then
174
269
  NOW=$(date +%s)
175
- AGE=$(( NOW - TS / 1000 ))
270
+ AGE=$(( (NOW * 1000 - TS) / 1000 ))
176
271
  [ "$AGE" -lt "$REACTION_TTL" ] && FRESH=1
177
272
  fi
178
273
  fi
@@ -182,6 +277,8 @@ if [ -n "$REACTION" ] && [ "$REACTION" != "null" ] && [ "$REACTION" != "" ]; the
182
277
  else
183
278
  BUBBLE="\"${REACTION}\""
184
279
  fi
280
+ else
281
+ rm -f "$REACTION_FILE" 2>/dev/null
185
282
  fi
186
283
  fi
187
284
 
@@ -209,6 +306,15 @@ NAME_PAD=$(( ART_CENTER - NAME_LEN / 2 ))
209
306
  NAME_LINE="$(printf '%*s%s' "$NAME_PAD" '' "$NAME_WITH_LEVEL")"
210
307
 
211
308
  DIM=$'\033[2;3m'
309
+ if [ "$COLOR_ENABLED" -eq 0 ]; then
310
+ C=""
311
+ NC=""
312
+ NEUTRAL=""
313
+ DIM=""
314
+ for _rainbow_index in "${!RAINBOW[@]}"; do
315
+ RAINBOW[$_rainbow_index]=""
316
+ done
317
+ fi
212
318
 
213
319
  ALL_LINES=()
214
320
  ALL_COLORS=()
@@ -218,19 +324,12 @@ for line in "${ART_LINES[@]}"; do
218
324
  if [ "$SHINY" = "true" ]; then
219
325
  ALL_COLORS+=("${RAINBOW[$(( (_arc + RAINBOW_OFFSET) % RAINBOW_LEN ))]}")
220
326
  else
221
- ALL_COLORS+=("$NEUTRAL")
327
+ ALL_COLORS+=("$C")
222
328
  fi
223
329
  _arc=$(( _arc + 1 ))
224
330
  done
225
331
  ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$C")
226
332
 
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
333
  ART_COUNT=${#ALL_LINES[@]}
235
334
 
236
335
  # ─── Speech bubble (left of art, word-wrapped) ──────────────────────────────
@@ -243,25 +342,33 @@ fi
243
342
 
244
343
  # ─── Display width (emojis count as 2 cols) ──────────────────────────────────
245
344
  # 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.
345
+ # widths. Rules mirror server/art.ts:displayWidth; the generated data lists
346
+ # every Unicode Emoji_Presentation codepoint, while VS16 upgrades a previous
347
+ # narrow emoji to 2 cols (e.g. ❤ + VS16).
251
348
  EMOJI_WIDTHS_DATA="$(dirname "${BASH_SOURCE[0]}")/emoji-widths.data"
252
349
  EMOJI_PRES_2600="$(grep -v '^#' "$EMOJI_WIDTHS_DATA" 2>/dev/null | tr -d '\n')"
350
+ EMOJI_TEXT_DATA="$(dirname "${BASH_SOURCE[0]}")/emoji-text.data"
351
+ EMOJI_TEXT="$(grep -v '^#' "$EMOJI_TEXT_DATA" 2>/dev/null | tr -d '\n')"
253
352
 
254
353
  dwidth() {
255
- printf '%s' "$1" | iconv -f UTF-8 -t UTF-32LE 2>/dev/null | od -An -tu4 | awk -v pres="$EMOJI_PRES_2600" '
354
+ 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" '
355
+ function load_ranges(value, target, n, i, count, piece, bounds, start, end, cp) {
356
+ n = split(value, ranges, ",")
357
+ for (i = 1; i <= n; i++) {
358
+ count = split(ranges[i], bounds, "-")
359
+ start = bounds[1] + 0
360
+ end = (count == 2) ? bounds[2] + 0 : start
361
+ for (cp = start; cp <= end; cp++) target[cp] = 1
362
+ }
363
+ }
256
364
  BEGIN {
257
- n = split(pres, arr)
258
- for (k = 1; k <= n; k++) wide[arr[k]] = 1
365
+ load_ranges(pres, wide)
366
+ load_ranges(text, text_default)
259
367
  }
260
368
  # Precondition: cp is neither a variation selector (65024-65039) nor ZWJ
261
369
  # (8205); the main loop filters those before calling in.
262
370
  function char_width(cp) {
263
- if (cp >= 126976) return 2
264
- if (cp >= 9728 && cp <= 10175) return (cp in wide) ? 2 : 1
371
+ if (cp in wide) return 2
265
372
  if (cp >= 9472 && cp <= 9631) return 1
266
373
  if (cp >= 12288 && cp <= 40959) return 2
267
374
  if (cp >= 65281 && cp <= 65376) return 2
@@ -276,15 +383,117 @@ dwidth() {
276
383
  if ((cp >= 65024 && cp <= 65038) || cp == 8205) { upgradable = 0; continue }
277
384
  cw = char_width(cp)
278
385
  w += cw
279
- upgradable = (cw == 1 && cp >= 9728 && cp <= 10175) ? 1 : 0
386
+ upgradable = 0
387
+ if (cw == 1 && (cp in text_default)) upgradable = 1
280
388
  } }
281
389
  END { print w+0 }'
282
390
  }
391
+ # Emit one display-width value per UTF-8 codepoint. ANSI-aware truncation uses
392
+ # this profile to make one Unicode-width pass over the complete output row.
393
+ dwidth_profile() {
394
+ 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" '
395
+ function load_ranges(value, target, n, i, count, piece, bounds, start, end, cp) {
396
+ n = split(value, ranges, ",")
397
+ for (i = 1; i <= n; i++) {
398
+ count = split(ranges[i], bounds, "-")
399
+ start = bounds[1] + 0
400
+ end = (count == 2) ? bounds[2] + 0 : start
401
+ for (cp = start; cp <= end; cp++) target[cp] = 1
402
+ }
403
+ }
404
+ BEGIN {
405
+ load_ranges(pres, wide)
406
+ load_ranges(text, text_default)
407
+ }
408
+ function char_width(cp) {
409
+ if (cp in wide) return 2
410
+ if (cp >= 9472 && cp <= 9631) return 1
411
+ if (cp >= 12288 && cp <= 40959) return 2
412
+ if (cp >= 65281 && cp <= 65376) return 2
413
+ return 1
414
+ }
415
+ {
416
+ for (j = 1; j <= NF; j++) {
417
+ cp = $j + 0
418
+ idx++
419
+ if (cp == 65039) {
420
+ if (upgradable && idx > 1) widths[idx - 1] += 1
421
+ widths[idx] = 0
422
+ upgradable = 0
423
+ continue
424
+ }
425
+ if ((cp >= 65024 && cp <= 65038) || cp == 8205) {
426
+ widths[idx] = 0
427
+ upgradable = 0
428
+ continue
429
+ }
430
+ cw = char_width(cp)
431
+ widths[idx] = cw
432
+ upgradable = 0
433
+ if (cw == 1 && (cp in text_default)) upgradable = 1
434
+ }
435
+ }
436
+ END {
437
+ for (j = 1; j <= idx; j++) print widths[j] + 0
438
+ }'
439
+ }
440
+ ART_W=0
441
+ for line in "${ART_LINES[@]}"; do
442
+ line_w=$(dwidth "$line")
443
+ [ "$line_w" -gt "$ART_W" ] && ART_W="$line_w"
444
+ done
445
+
446
+ # Keep the label inside the same sprite column as the art. The exact Unicode
447
+ # width rules live in dwidth(), so the shell and TS renderers agree on bounds.
448
+ LABEL_W=$(dwidth "$NAME_WITH_LEVEL")
449
+ if [ "$LABEL_W" -gt "$ART_W" ] 2>/dev/null; then
450
+ ART_W="$LABEL_W"
451
+ NAME_PAD=$(( (ART_W - LABEL_W) / 2 ))
452
+ NAME_LINE="$(printf '%*s%s' "$NAME_PAD" '' "$NAME_WITH_LEVEL")"
453
+ ALL_LINES[$(( ART_COUNT - 1 ))]="$NAME_LINE"
454
+ fi
455
+ NAME_LINE_W=$(dwidth "$NAME_LINE")
456
+ # Centering the name against a short fixture frame can make the label wider
457
+ # than every art row; include that width before sizing the card.
458
+ [ "$NAME_LINE_W" -gt "$ART_W" ] && ART_W="$NAME_LINE_W"
459
+
460
+ STATUSLINE_BUDGET=$(( DETECTED_COLS - CHROME_RESERVE + STATUSLINE_WIDTH_ADJUST ))
461
+ # Preserve the existing compact-card behavior at the smallest supported
462
+ # terminal size; the reserve applies once there is enough room for chrome.
463
+ if [ "$DETECTED_COLS" -ge 40 ] 2>/dev/null && [ "$STATUSLINE_BUDGET" -lt 40 ]; then
464
+ STATUSLINE_BUDGET=40
465
+ fi
466
+ [ "$STATUSLINE_BUDGET" -gt "$DETECTED_COLS" ] && STATUSLINE_BUDGET="$DETECTED_COLS"
467
+
468
+ # The sprite and its identifying name are the irreducible minimum of the card.
469
+ # Never let the chrome reserve push the usable budget below the sprite's own
470
+ # width; if it does, fall back to the raw terminal width. If the terminal
471
+ # itself is narrower than the sprite, use the historical default rather than
472
+ # slicing art or name.
473
+ if [ "$STATUSLINE_BUDGET" -lt "$ART_W" ]; then
474
+ STATUSLINE_BUDGET="$DETECTED_COLS"
475
+ fi
476
+ if [ "$STATUSLINE_BUDGET" -lt "$ART_W" ]; then
477
+ STATUSLINE_BUDGET=125
478
+ fi
479
+ COLS="$STATUSLINE_BUDGET"
480
+
481
+ # The bubble, tail, and sprite are one unit. At narrow widths, drop the
482
+ # bubble rather than allowing a partial border or tail to escape the panel.
483
+ MIN_BUBBLE_INNER=12
484
+ TAIL_W=3
485
+ MAX_INNER=$(( COLS - ART_W - TAIL_W - 4 - MARGIN ))
486
+ if [ -n "$BUBBLE" ] && [ "$MAX_INNER" -ge "$MIN_BUBBLE_INNER" ] 2>/dev/null; then
487
+ [ "$INNER_W" -gt "$MAX_INNER" ] && INNER_W="$MAX_INNER"
488
+ else
489
+ BUBBLE=""
490
+ BUBBLE_TEXT=""
491
+ fi
283
492
 
284
493
  # ─── Word-wrap bubble text ────────────────────────────────────────────────────
285
494
  TEXT_LINES=()
286
495
  if [ -n "$BUBBLE_TEXT" ]; then
287
- WORDS=($BUBBLE_TEXT)
496
+ read -r -a WORDS <<< "$BUBBLE_TEXT"
288
497
  CUR_LINE=""
289
498
  CUR_W=0
290
499
  for word in "${WORDS[@]}"; do
@@ -329,13 +538,16 @@ fi
329
538
  BUBBLE_COUNT=${#BUBBLE_LINES[@]}
330
539
 
331
540
  # ─── Right-align with bubble box to the left ─────────────────────────────────
332
- GAP=2
541
+ GAP=3
333
542
  if [ $BUBBLE_COUNT -gt 0 ]; then
334
543
  TOTAL_W=$(( BOX_W + GAP + ART_W ))
335
544
  else
336
545
  TOTAL_W=$ART_W
337
546
  fi
338
- PAD=$(( COLS - TOTAL_W - MARGIN ))
547
+ # COLS already includes the Claude Code chrome reserve. The spacer starts with
548
+ # one Braille Blank cell, so account for that cell but don't subtract MARGIN
549
+ # again; doing so leaves the card visibly short of the pane's right edge.
550
+ PAD=$(( COLS - TOTAL_W - 1 ))
339
551
  [ "$PAD" -lt 0 ] && PAD=0
340
552
 
341
553
  # On Windows (Git Bash / MSYS2), Braille Blank (U+2800) renders as double-width,
@@ -344,7 +556,6 @@ case "$(uname -s)" in
344
556
  MINGW*|CYGWIN*|MSYS*) SPACER=$(printf '%*s' "$PAD" '') ;;
345
557
  *) SPACER=$(printf "${B}%${PAD}s" "") ;;
346
558
  esac
347
- GAP_STR=$(printf '%*s' "$GAP" '')
348
559
 
349
560
  # Vertically center bubble box on the art
350
561
  BUBBLE_START=0
@@ -362,9 +573,10 @@ if [ $BUBBLE_COUNT -gt 2 ]; then
362
573
  CONNECTOR_BI=$(( (FIRST_TEXT + LAST_TEXT) / 2 ))
363
574
  fi
364
575
 
365
- # ─── Output: merged bubble box + connector + art per line ─────────────────────
576
+ # ─── Output: merged bubble box + art per line ──────────────────────────────────
366
577
  TOTAL_BUBBLE=$(( BUBBLE_START + BUBBLE_COUNT ))
367
578
  MAX_LINES=$(( ART_COUNT > TOTAL_BUBBLE ? ART_COUNT : TOTAL_BUBBLE ))
579
+ OUTPUT_LINES=()
368
580
  for (( i=0; i<MAX_LINES; i++ )); do
369
581
  # Art part: actual art line or blank filler
370
582
  if [ $i -lt $ART_COUNT ]; then
@@ -379,30 +591,110 @@ for (( i=0; i<MAX_LINES; i++ )); do
379
591
  bline="${BUBBLE_LINES[$bi]}"
380
592
  btype="${BUBBLE_TYPES[$bi]}"
381
593
 
382
- # Connector: "-- " on the middle text line, spaces otherwise
594
+ # Connector: "-- " on the middle text line, spaces otherwise.
383
595
  if [ $bi -eq $CONNECTOR_BI ]; then
384
- gap="${NEUTRAL}--${NC} "
596
+ gap="${C}--${NC} "
385
597
  else
386
598
  gap=" "
387
599
  fi
388
600
 
389
601
  if [ "$btype" = "border" ]; then
390
- echo "${SPACER}${NEUTRAL}${bline}${NC}${gap}${art_part}"
602
+ OUTPUT_LINES+=("${SPACER}${C}${bline}${NC}${gap}${art_part}")
391
603
  else
392
604
  pipe_l="${bline:0:1}"
393
605
  pipe_r="${bline: -1}"
394
606
  inner="${bline:1:$(( ${#bline} - 2 ))}"
395
- echo "${SPACER}${NEUTRAL}${pipe_l}${NC}${DIM}${inner}${NC}${NEUTRAL}${pipe_r}${NC}${gap}${art_part}"
607
+ OUTPUT_LINES+=("${SPACER}${C}${pipe_l}${NC}${DIM}${inner}${NC}${C}${pipe_r}${NC}${gap}${art_part}")
396
608
  fi
397
609
  else
398
610
  empty=$(printf '%*s' "$BOX_W" '')
399
- echo "${SPACER}${empty} ${art_part}"
611
+ OUTPUT_LINES+=("${SPACER}${empty} ${art_part}")
400
612
  fi
401
613
  else
402
- echo "${SPACER}${art_part}"
614
+ OUTPUT_LINES+=("${SPACER}${art_part}")
403
615
  fi
404
616
  done
405
617
 
618
+ ansi_truncate() {
619
+ local text="$1"
620
+ local max_width="$2"
621
+ local out=""
622
+ local plain=""
623
+ local i=0
624
+ local text_len=${#text}
625
+ local char seq char_width truncated=0 saw_sgr=0
626
+ local visible_width=0
627
+ local -a widths
628
+ local visible_index=0
629
+
630
+ [ "$max_width" -lt 0 ] && max_width=0
631
+
632
+ # Strip SGR while building the one string sent to dwidth_profile. The
633
+ # profile uses one iconv/od/awk pass for the whole row; never spawn a
634
+ # subprocess for each Unicode character.
635
+ while [ "$i" -lt "$text_len" ]; do
636
+ char="${text:$i:1}"
637
+ if [ "$char" = $'\033' ]; then
638
+ saw_sgr=1
639
+ i=$(( i + 1 ))
640
+ while [ "$i" -lt "$text_len" ]; do
641
+ char="${text:$i:1}"
642
+ i=$(( i + 1 ))
643
+ [ "$char" = "m" ] && break
644
+ done
645
+ continue
646
+ fi
647
+ plain="${plain}${char}"
648
+ i=$(( i + 1 ))
649
+ done
650
+
651
+ widths=()
652
+ if [ -n "$plain" ]; then
653
+ while IFS= read -r char_width; do
654
+ widths+=("$char_width")
655
+ done < <(dwidth_profile "$plain")
656
+ fi
657
+
658
+ i=0
659
+ while [ "$i" -lt "$text_len" ]; do
660
+ char="${text:$i:1}"
661
+ if [ "$char" = $'\033' ]; then
662
+ seq="$char"
663
+ i=$(( i + 1 ))
664
+ while [ "$i" -lt "$text_len" ]; do
665
+ char="${text:$i:1}"
666
+ seq="${seq}${char}"
667
+ i=$(( i + 1 ))
668
+ [ "$char" = "m" ] && break
669
+ done
670
+ out="${out}${seq}"
671
+ continue
672
+ fi
673
+
674
+ char_width="${widths[$visible_index]:-1}"
675
+ if [ $(( visible_width + char_width )) -gt "$max_width" ]; then
676
+ truncated=1
677
+ break
678
+ fi
679
+ out="${out}${char}"
680
+ visible_width=$(( visible_width + char_width ))
681
+ visible_index=$(( visible_index + 1 ))
682
+ i=$(( i + 1 ))
683
+ done
684
+
685
+ [ "$truncated" -eq 1 ] && [ "$saw_sgr" -eq 1 ] && out="${out}${NC}"
686
+ printf '%s' "$out"
687
+ }
688
+
689
+ statusline_output_line() {
690
+ ansi_truncate "$1" "$STATUSLINE_BUDGET"
691
+ printf '\n'
692
+ }
693
+
694
+ for line in "${OUTPUT_LINES[@]}"; do
695
+ statusline_output_line "$line"
696
+ done
697
+
406
698
  # Append the last cached sub-status result below the buddy panel and refresh
407
699
  # it asynchronously when stale. The statusline itself never waits on it.
408
700
  append_substatus
@@ -0,0 +1,3 @@
1
+ # Auto-generated by scripts/gen-emoji-widths.ts — do not edit by hand.
2
+ # Decimal codepoint ranges with Unicode Emoji=Yes and Emoji_Presentation=No.
3
+ 35,42,48-57,169,174,8252,8265,8482,8505,8596-8601,8617-8618,9000,9167,9197-9199,9201-9202,9208-9210,9410,9642-9643,9654,9664,9723-9724,9728-9732,9742,9745,9752,9757,9760,9762-9763,9766,9770,9774-9775,9784-9786,9792,9794,9823-9824,9827,9829-9830,9832,9851,9854,9874,9876-9879,9881,9883-9884,9888,9895,9904-9905,9928,9935,9937,9939,9961,9968-9969,9972,9975-9977,9986,9992-9993,9996-9997,9999,10002,10004,10006,10013,10017,10035-10036,10052,10055,10083-10084,10145,10548-10549,11013-11015,12336,12349,12951,12953,127344-127345,127358-127359,127490,127543,127777,127780-127788,127798,127869,127894-127895,127897-127899,127902-127903,127947-127950,127956-127967,127987,127989,127991,128063,128065,128253,128329-128330,128367-128368,128371-128377,128391,128394-128397,128400,128421,128424,128433-128434,128444,128450-128452,128465-128467,128476-128478,128481,128483,128488,128495,128499,128506,128715,128717-128719,128736-128741,128745,128752,128755
@@ -1,4 +1,3 @@
1
1
  # Auto-generated by scripts/gen-emoji-widths.ts — do not edit by hand.
2
- # Decimal codepoints in U+2600-U+27BF with Unicode Emoji_Presentation=Yes.
3
- # Regenerate with: bun run gen:emoji-widths
4
- 9748 9749 9800 9801 9802 9803 9804 9805 9806 9807 9808 9809 9810 9811 9855 9875 9889 9898 9899 9917 9918 9924 9925 9934 9940 9962 9970 9971 9973 9978 9981 9989 9994 9995 10024 10060 10062 10067 10068 10069 10071 10133 10134 10135 10160 10175
2
+ # Decimal codepoint ranges with Unicode Emoji_Presentation=Yes.
3
+ 8986-8987,9193-9196,9200,9203,9725-9726,9748-9749,9800-9811,9855,9875,9889,9898-9899,9917-9918,9924-9925,9934,9940,9962,9970-9971,9973,9978,9981,9989,9994-9995,10024,10060,10062,10067-10069,10071,10133-10135,10160,10175,11035-11036,11088,11093,126980,127183,127374,127377-127386,127462-127487,127489,127514,127535,127538-127542,127544-127546,127568-127569,127744-127776,127789-127797,127799-127868,127870-127891,127904-127946,127951-127955,127968-127984,127988,127992-128062,128064,128066-128252,128255-128317,128331-128334,128336-128359,128378,128405-128406,128420,128507-128591,128640-128709,128716,128720-128722,128725-128728,128732-128735,128747-128748,128756-128764,128992-129003,129008,129292-129338,129340-129349,129351-129535,129648-129660,129664-129674,129678-129734,129736,129741-129756,129759-129770,129775-129784
@@ -25,12 +25,24 @@ append_substatus() {
25
25
  local cache_file="$state_dir/.substatus.$sid"
26
26
  local lock_dir="$state_dir/.substatus.$sid.lock"
27
27
  local command=""
28
- local now cache_age lock_age refresh_seconds configured_refresh_seconds
28
+ local now cache_age lock_age refresh_seconds configured_refresh_seconds line sweep_lock sweep_lock_age
29
29
 
30
- # Remove abandoned refresh output without touching the complete cache or
31
- # the lock directory. The refresh path below uses the same deterministic
32
- # temp filename, so this also cleans up files from older implementations.
33
- find "$state_dir" -type f -name ".substatus.$sid.*" -mmin +60 -exec rm -f {} \; 2>/dev/null
30
+ # Cleanup is intentionally detached: stale temp files are housekeeping and
31
+ # must not make the statusline wait on a filesystem walk. The lock keeps
32
+ # concurrent statusline ticks from launching duplicate sweeps.
33
+ sweep_lock="$state_dir/.substatus-sweep.$sid.lock"
34
+ if [ -d "$sweep_lock" ]; then
35
+ sweep_lock_age=$(( $(date +%s) - $(_substatus_mtime "$sweep_lock") ))
36
+ if [ "$sweep_lock_age" -gt 300 ]; then
37
+ rmdir "$sweep_lock" 2>/dev/null || rm -rf "$sweep_lock" 2>/dev/null
38
+ fi
39
+ fi
40
+ if mkdir "$sweep_lock" 2>/dev/null; then
41
+ (
42
+ trap 'rmdir "$sweep_lock" 2>/dev/null' EXIT
43
+ find "$state_dir" -type f -name ".substatus.$sid.*" -mmin +60 -exec rm -f {} + 2>/dev/null
44
+ ) >/dev/null 2>&1 &
45
+ fi
34
46
 
35
47
  [ -f "$config_file" ] || return 0
36
48
  command=$(jq -r '.subStatusCommand // ""' "$config_file" 2>/dev/null)
@@ -43,10 +55,21 @@ append_substatus() {
43
55
  *) [ "$configured_refresh_seconds" -gt 0 ] && refresh_seconds="$configured_refresh_seconds" ;;
44
56
  esac
45
57
 
46
- # Always show the last complete result first. A missing or stale cache is
47
- # allowed to be blank; the refresh below will populate the next tick.
48
- [ -f "$cache_file" ] && cat "$cache_file"
49
-
58
+ # The main statusline supplies ANSI-aware output when it has a width
59
+ # budget. Keep this helper standalone for callers that don't.
60
+ _substatus_print_line() {
61
+ local line="$1"
62
+ if [ "$(type -t statusline_output_line 2>/dev/null)" = "function" ]; then
63
+ statusline_output_line "$line"
64
+ else
65
+ printf '%s\n' "$line"
66
+ fi
67
+ }
68
+ if [ -f "$cache_file" ]; then
69
+ while IFS= read -r line || [ -n "$line" ]; do
70
+ _substatus_print_line "$line"
71
+ done < "$cache_file"
72
+ fi
50
73
  now=$(date +%s)
51
74
  cache_age=999999
52
75
  if [ -f "$cache_file" ]; then
@@ -59,7 +82,11 @@ append_substatus() {
59
82
  # over a minute old, so a slow but healthy command is never duplicated.
60
83
  if [ -d "$lock_dir" ]; then
61
84
  lock_age=$(( now - $(_substatus_mtime "$lock_dir") ))
62
- if [ "$lock_age" -gt 60 ]; then
85
+ # A refresh killed alongside its parent leaves this lock behind. The
86
+ # window must be short enough that a fresh session recovers within a
87
+ # few ticks instead of rendering no sub-status at all, but longer than
88
+ # a healthy slow command's runtime so it is never duplicated.
89
+ if [ "$lock_age" -gt 20 ]; then
63
90
  rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null
64
91
  fi
65
92
  fi
@@ -85,3 +112,18 @@ append_substatus() {
85
112
  fi
86
113
  ) </dev/null > /dev/null 2>/dev/null &
87
114
  }
115
+
116
+ # Claude Code kills the statusline process when the next render starts, and a
117
+ # plain `&` child stays in that process group — so the refresh was being killed
118
+ # mid-write, leaving a held lock and a 0-byte temp file behind. A session that
119
+ # lost the race that way never rendered a sub-status at all.
120
+ #
121
+ # setsid detaches into a new session so the refresh survives; it is Linux-only,
122
+ # so fall back to nohup (which at least survives SIGHUP) on macOS.
123
+ _substatus_detach() {
124
+ if command -v setsid >/dev/null 2>&1; then
125
+ setsid "$@" </dev/null >/dev/null 2>&1 &
126
+ else
127
+ nohup "$@" </dev/null >/dev/null 2>&1 &
128
+ fi
129
+ }