@ramarivera/coding-buddy 0.6.0 → 0.7.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.
package/hooks/suggest.sh CHANGED
@@ -1,55 +1,11 @@
1
1
  #!/usr/bin/env bash
2
- # coding-buddy Stop hook — proactive suggestion detection
3
- # Detects coding patterns and offers suggestions when relevant.
4
- #
5
- # Combined with buddy-comment.sh to avoid running two separate Stop hooks.
6
-
7
- # shellcheck source=../scripts/paths.sh
8
- source "$(dirname "${BASH_SOURCE[0]}")/../scripts/paths.sh"
9
-
10
- STATE_DIR="$BUDDY_STATE_DIR"
11
- CONFIG_FILE="$STATE_DIR/config.json"
12
- LAST_SUGGESTION_FILE="$STATE_DIR/.last_suggestion"
13
- SUGGESTIONS_FILE="$STATE_DIR/suggestions.json"
14
-
15
- [ -f "$CONFIG_FILE" ] || exit 0
16
-
17
- # Check if suggestions are enabled (default: true)
18
- SUGGESTIONS_ENABLED="true"
19
- if [ -f "$CONFIG_FILE" ]; then
20
- _enabled=$(jq -r '.suggestionsEnabled // true' "$CONFIG_FILE" 2>/dev/null)
21
- [ "$_enabled" = "false" ] && exit 0
22
- fi
23
-
24
- # Check cooldown (default: 180s = 3 min)
25
- SUGGESTION_COOLDOWN=180
26
- if [ -f "$CONFIG_FILE" ]; then
27
- _cd=$(jq -r '.suggestionCooldown // 180' "$CONFIG_FILE" 2>/dev/null || echo 180)
28
- [[ "$_cd" =~ ^[0-9]+$ ]] && SUGGESTION_COOLDOWN=$_cd
29
- fi
30
-
31
- if [ -f "$LAST_SUGGESTION_FILE" ]; then
32
- LAST=$(cat "$LAST_SUGGESTION_FILE" 2>/dev/null)
33
- NOW=$(date +%s)
34
- DIFF=$(( NOW - ${LAST:-0} ))
35
- [ "$DIFF" -lt "$SUGGESTION_COOLDOWN" ] && exit 0
36
- fi
37
-
38
- INPUT=$(cat)
39
-
40
- # Extract messages
41
- ASSISTANT_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
42
- [ -z "$ASSISTANT_MSG" ] && exit 0
43
-
44
- # Record turn and check patterns
45
- PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
46
-
47
- # Check for patterns via the suggestions script
48
- if [ -x "$(command -v bun)" ]; then
49
- # Run the suggestion check (async, non-blocking)
50
- bun run "$PLUGIN_ROOT/server/check-suggestions.ts" \
51
- "$(echo "$ASSISTANT_MSG" | jq -Rs .)" \
52
- >/dev/null 2>&1 &
53
- fi
54
-
55
- exit 0
2
+ set +e
3
+ SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
4
+ ROOT="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)" || exit 0
5
+ [ -n "$ROOT" ] || exit 0
6
+ BUN="$(command -v bun 2>/dev/null || true)"
7
+ [ -x "$BUN" ] || BUN="$HOME/.bun/bin/bun"
8
+ [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
+ [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
+ [ -x "$BUN" ] || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/suggest.ts" || exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -66,9 +66,9 @@
66
66
  ],
67
67
  "repository": {
68
68
  "type": "git",
69
- "url": "git+https://github.com/1270011/claude-buddy.git"
69
+ "url": "git+https://github.com/ramarivera/coding-buddy.git"
70
70
  },
71
- "homepage": "https://github.com/1270011/claude-buddy#readme",
71
+ "homepage": "https://github.com/ramarivera/coding-buddy#readme",
72
72
  "license": "MIT",
73
73
  "dependencies": {
74
74
  "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1",
package/scripts/paths.sh CHANGED
@@ -37,7 +37,22 @@ else
37
37
  BUDDY_STATE_DIR="$HOME/.claude-buddy"
38
38
  fi
39
39
 
40
- export CLAUDE_CFG_DIR CLAUDE_SETTINGS_FILE CLAUDE_USER_CONFIG BUDDY_STATE_DIR
40
+ # Per-session ID for namespacing transient buddy state files
41
+ # (reaction.$SID.json, .last_reaction.$SID, etc.). Precedence:
42
+ # 1. $CLAUDE_CODE_SESSION_ID — Claude Code sets a UUID per session and
43
+ # propagates it to every child process (hooks, MCP server, statusline).
44
+ # Shortened to first 8 chars for filename hygiene.
45
+ # 2. $TMUX_PANE — legacy tmux-pane isolation, kept as a fallback.
46
+ # 3. "default" — single-session world.
47
+ if [[ -n "${CLAUDE_CODE_SESSION_ID:-}" ]]; then
48
+ BUDDY_SID="${CLAUDE_CODE_SESSION_ID:0:8}"
49
+ elif [[ -n "${TMUX_PANE:-}" ]]; then
50
+ BUDDY_SID="${TMUX_PANE#%}"
51
+ else
52
+ BUDDY_SID="default"
53
+ fi
54
+
55
+ export CLAUDE_CFG_DIR CLAUDE_SETTINGS_FILE CLAUDE_USER_CONFIG BUDDY_STATE_DIR BUDDY_SID
41
56
 
42
57
  # Windows: Chocolatey installs a PowerShell shim for jq that doesn't work in
43
58
  # Git Bash. If jq is not found on PATH, check the real Chocolatey binary path
package/server/art.ts CHANGED
@@ -116,7 +116,7 @@ export const SPECIES_ART: Record<Species, string[][]> = {
116
116
  "\\ {E}' '{E} /",
117
117
  " \\ } { /",
118
118
  " ≈(° °)≈",
119
- " \x1b[38;2;255;120;0m//|\\\\\x1b[0m"],
119
+ " \x1b[38;2;255;120;0m/\x1b[0m'\x1b[38;2;255;120;0m|\x1b[0m'\x1b[38;2;255;120;0m\\\x1b[0m"],
120
120
  ["} {",
121
121
  "|\\^```^/|",
122
122
  "\\ {E}' '{E} /",
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import {
6
+ defaultSpawnDetached,
7
+ fileExists,
8
+ isOnCooldown,
9
+ nonNegativeInteger,
10
+ parseHookInput,
11
+ readJsonFile,
12
+ readStdin,
13
+ resolveHookSessionId,
14
+ resolveHookStateDir,
15
+ stringField,
16
+ type HookRuntime,
17
+ } from "./common.ts";
18
+
19
+ interface BuddyConfig {
20
+ commentCooldown?: unknown;
21
+ }
22
+
23
+ interface Events {
24
+ turns?: number;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
29
+
30
+ export interface BuddyCommentResult {
31
+ comment?: string;
32
+ updated: boolean;
33
+ }
34
+
35
+ export function extractBuddyComment(message: string): string {
36
+ let comment = "";
37
+ for (const match of message.matchAll(BUDDY_COMMENT_PATTERN)) {
38
+ const candidate = match[1]?.trim();
39
+ if (candidate) comment = candidate;
40
+ }
41
+ return comment;
42
+ }
43
+
44
+ export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult {
45
+ const stateDir = resolveHookStateDir(runtime);
46
+ if (!fileExists(join(stateDir, "status.json"))) return { updated: false };
47
+
48
+ const input = parseHookInput(rawInput);
49
+ if (!input) return { updated: false };
50
+
51
+ const assistantMessage = stringField(input, "last_assistant_message");
52
+ if (!assistantMessage) return { updated: false };
53
+
54
+ const comment = extractBuddyComment(assistantMessage);
55
+ if (!comment) return { updated: false };
56
+
57
+ const now = runtime.now?.() ?? Date.now();
58
+ const sid = resolveHookSessionId(runtime);
59
+ const config = readJsonFile<BuddyConfig>(join(stateDir, "config.json")) ?? {};
60
+ const cooldown = nonNegativeInteger(config.commentCooldown, 30);
61
+ const cooldownFile = join(stateDir, `.last_comment.${sid}`);
62
+ if (isOnCooldown(cooldownFile, cooldown, now)) return { updated: false };
63
+
64
+ mkdirSync(stateDir, { recursive: true });
65
+ writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
66
+ writeFileSync(
67
+ join(stateDir, `reaction.${sid}.json`),
68
+ JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
69
+ );
70
+
71
+ const eventsFile = join(stateDir, "events.json");
72
+ const events = readJsonFile<Events>(eventsFile) ?? {};
73
+ events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
74
+ writeFileSync(eventsFile, JSON.stringify(events, null, 2));
75
+
76
+ const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
77
+ spawnDetached("server/award-xp.ts", ["turn"]);
78
+ spawnDetached("server/consolidate.ts", [
79
+ assistantMessage,
80
+ stringField(input, "last_user_message"),
81
+ ]);
82
+
83
+ return { comment, updated: true };
84
+ }
85
+
86
+ if (import.meta.main) {
87
+ try {
88
+ handleBuddyComment(await readStdin());
89
+ } catch {
90
+ // Claude Code hooks must never fail the turn.
91
+ }
92
+ process.exit(0);
93
+ }
@@ -0,0 +1,95 @@
1
+ import { spawn } from "child_process";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { join, resolve } from "path";
4
+ import { buddyStateDir } from "../path.ts";
5
+
6
+ export type JsonObject = Record<string, unknown>;
7
+
8
+ export interface HookRuntime {
9
+ now?: () => number;
10
+ packageRoot?: string;
11
+ sessionId?: string;
12
+ spawnDetached?: (script: string, args: string[]) => void;
13
+ stateDir?: string;
14
+ }
15
+
16
+ export function parseHookInput(raw: string): JsonObject | null {
17
+ try {
18
+ const value = JSON.parse(raw) as unknown;
19
+ return value && typeof value === "object" && !Array.isArray(value)
20
+ ? value as JsonObject
21
+ : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export function stringField(input: JsonObject, field: string): string {
28
+ const value = input[field];
29
+ return typeof value === "string" ? value : "";
30
+ }
31
+
32
+ export function resolveHookStateDir(runtime: HookRuntime = {}): string {
33
+ return runtime.stateDir ?? buddyStateDir();
34
+ }
35
+
36
+ export function resolveHookSessionId(runtime: HookRuntime = {}): string {
37
+ if (runtime.sessionId) return runtime.sessionId;
38
+ const ccSid = process.env.CLAUDE_CODE_SESSION_ID;
39
+ if (ccSid) return ccSid.slice(0, 8);
40
+ const pane = process.env.TMUX_PANE;
41
+ if (pane) return pane.replace(/^%/, "");
42
+ return "default";
43
+ }
44
+
45
+ export function readJsonFile<T>(path: string): T | null {
46
+ try {
47
+ return JSON.parse(readFileSync(path, "utf8")) as T;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function nonNegativeInteger(value: unknown, fallback: number): number {
54
+ if (Number.isInteger(value) && Number(value) >= 0) return Number(value);
55
+ if (typeof value === "string" && /^[0-9]+$/.test(value)) return Number(value);
56
+ return fallback;
57
+ }
58
+
59
+ export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
60
+ try {
61
+ const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
62
+ if (!Number.isFinite(last)) return false;
63
+ return Math.floor(nowMs / 1000) - last < cooldownSeconds;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ export function packageRootFromHooks(): string {
70
+ return resolve(import.meta.dir, "..", "..");
71
+ }
72
+
73
+ export function defaultSpawnDetached(runtime: HookRuntime = {}): (script: string, args: string[]) => void {
74
+ return (script, args) => {
75
+ const root = runtime.packageRoot ?? packageRootFromHooks();
76
+ const child = spawn(process.execPath, ["run", join(root, script), ...args], {
77
+ cwd: root,
78
+ detached: true,
79
+ stdio: "ignore",
80
+ });
81
+ child.unref();
82
+ };
83
+ }
84
+
85
+ export function fileExists(path: string): boolean {
86
+ try {
87
+ return existsSync(path);
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export async function readStdin(): Promise<string> {
94
+ return await Bun.stdin.text();
95
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { spawnSync } from "child_process";
4
+ import { existsSync } from "fs";
5
+ import { join } from "path";
6
+ import { packageRootFromHooks, readStdin } from "./common.ts";
7
+
8
+ const ALLOWED = new Set([
9
+ "react.sh",
10
+ "file-type-react.sh",
11
+ "name-react.sh",
12
+ "mood-react.sh",
13
+ ]);
14
+
15
+ if (import.meta.main) {
16
+ try {
17
+ const script = process.argv[2] ?? "";
18
+ if (ALLOWED.has(script)) {
19
+ const root = packageRootFromHooks();
20
+ const hook = join(root, "hooks", "legacy", script);
21
+ if (existsSync(hook)) {
22
+ spawnSync("bash", [hook], {
23
+ cwd: root,
24
+ input: await readStdin(),
25
+ stdio: ["pipe", "ignore", "ignore"],
26
+ timeout: 14_000,
27
+ });
28
+ }
29
+ }
30
+ } catch {
31
+ // Claude Code hooks must never fail the turn.
32
+ }
33
+ process.exit(0);
34
+ }
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { join } from "path";
4
+ import {
5
+ defaultSpawnDetached,
6
+ fileExists,
7
+ isOnCooldown,
8
+ nonNegativeInteger,
9
+ parseHookInput,
10
+ readJsonFile,
11
+ readStdin,
12
+ resolveHookStateDir,
13
+ stringField,
14
+ type HookRuntime,
15
+ } from "./common.ts";
16
+
17
+ interface SuggestConfig {
18
+ suggestionCooldown?: unknown;
19
+ suggestionsEnabled?: unknown;
20
+ }
21
+
22
+ export interface SuggestHookResult {
23
+ checked: boolean;
24
+ }
25
+
26
+ export function handleSuggest(rawInput: string, runtime: HookRuntime = {}): SuggestHookResult {
27
+ const stateDir = resolveHookStateDir(runtime);
28
+ const configFile = join(stateDir, "config.json");
29
+ if (!fileExists(configFile)) return { checked: false };
30
+
31
+ const config = readJsonFile<SuggestConfig>(configFile) ?? {};
32
+ if (config.suggestionsEnabled === false || config.suggestionsEnabled === "false") {
33
+ return { checked: false };
34
+ }
35
+
36
+ const now = runtime.now?.() ?? Date.now();
37
+ const cooldown = nonNegativeInteger(config.suggestionCooldown, 180);
38
+ if (isOnCooldown(join(stateDir, ".last_suggestion"), cooldown, now)) {
39
+ return { checked: false };
40
+ }
41
+
42
+ const input = parseHookInput(rawInput);
43
+ if (!input) return { checked: false };
44
+
45
+ const assistantMessage = stringField(input, "last_assistant_message");
46
+ if (!assistantMessage) return { checked: false };
47
+
48
+ const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
49
+ spawnDetached("server/check-suggestions.ts", [JSON.stringify(assistantMessage)]);
50
+ return { checked: true };
51
+ }
52
+
53
+ if (import.meta.main) {
54
+ try {
55
+ handleSuggest(await readStdin());
56
+ } catch {
57
+ // Claude Code hooks must never fail the turn.
58
+ }
59
+ process.exit(0);
60
+ }
package/server/state.ts CHANGED
@@ -42,9 +42,20 @@ export const STATE_DIR = buddyStateDir();
42
42
  const MANIFEST_FILE = join(STATE_DIR, "menagerie.json");
43
43
  const CONFIG_FILE = join(STATE_DIR, "config.json");
44
44
 
45
- // ─── Session ID (PR #6: tmux session isolation) ─────────────────────────────
45
+ // ─── Session ID (Claude Code session isolation, tmux pane fallback) ─────────
46
+ //
47
+ // Precedence:
48
+ // 1. $CLAUDE_CODE_SESSION_ID — Claude Code sets a UUID per session and
49
+ // propagates it to the MCP server, hooks, and statusline. Shortened
50
+ // to first 8 chars to keep filenames readable.
51
+ // 2. $TMUX_PANE — legacy tmux-pane isolation kept as a fallback.
52
+ // 3. "default" — single-session world.
53
+ //
54
+ // Must match the bash equivalent in scripts/paths.sh.
46
55
 
47
56
  function sessionId(): string {
57
+ const ccSid = process.env.CLAUDE_CODE_SESSION_ID;
58
+ if (ccSid && ccSid.length > 0) return ccSid.slice(0, 8);
48
59
  const pane = process.env.TMUX_PANE;
49
60
  if (!pane) return "default";
50
61
  return pane.replace(/^%/, "");
@@ -416,7 +427,7 @@ export function writeStatusState(
416
427
  eye: companion.bones.eye,
417
428
  shiny: companion.bones.shiny,
418
429
  hat: companion.bones.hat,
419
- reaction: reaction ?? "",
430
+ reaction: "",
420
431
  muted: muted ?? false,
421
432
  achievement: achievement ?? "",
422
433
  frames,
@@ -426,6 +437,7 @@ export function writeStatusState(
426
437
  mood: moodStr,
427
438
  };
428
439
  writeFileSync(join(STATE_DIR, "status.json"), JSON.stringify(state));
440
+ if (reaction) saveReaction(reaction, "mcp");
429
441
  }
430
442
 
431
443
  // ─── Claude Code settings.json patching (for buddy_statusline tool) ──────────
@@ -19,9 +19,8 @@ source "$(dirname "${BASH_SOURCE[0]}")/../scripts/paths.sh"
19
19
 
20
20
  STATE="$BUDDY_STATE_DIR/status.json"
21
21
  CONFIG_FILE="$BUDDY_STATE_DIR/config.json"
22
- # Session ID: sanitized tmux pane number, or "default" outside tmux
23
- SID="${TMUX_PANE#%}"
24
- SID="${SID:-default}"
22
+ # Per-session ID resolved by paths.sh (CLAUDE_CODE_SESSION_ID > TMUX_PANE > default)
23
+ SID="$BUDDY_SID"
25
24
 
26
25
  [ -f "$STATE" ] || exit 0
27
26
 
@@ -32,8 +31,10 @@ NAME=$(jq -r '.name // ""' "$STATE" 2>/dev/null)
32
31
  [ -z "$NAME" ] && exit 0
33
32
 
34
33
  RARITY=$(jq -r '.rarity // "common"' "$STATE" 2>/dev/null)
34
+ STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
35
35
  SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
36
- REACTION=$(jq -r '.reaction // ""' "$STATE" 2>/dev/null)
36
+ REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
37
+ REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
37
38
  ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
38
39
  LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
39
40
  MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
@@ -67,6 +68,7 @@ if [ -f "$CONFIG_FILE" ]; then
67
68
  fi
68
69
 
69
70
  NC=$'\033[0m'
71
+ NEUTRAL=$'\033[39m'
70
72
  case "$RARITY" in
71
73
  common)
72
74
  [ "$_THEME" = "light" ] && C=$'\033[38;2;90;90;90m' || C=$'\033[38;2;153;153;153m' ;;
@@ -150,7 +152,6 @@ BUBBLE=""
150
152
  if [ -n "$ACHIEVEMENT" ] && [ "$ACHIEVEMENT" != "null" ] && [ "$ACHIEVEMENT" != "" ]; then
151
153
  BUBBLE=$'\xf0\x9f\x8f\x86'" $ACHIEVEMENT"
152
154
  fi
153
- REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
154
155
  REACTION_TTL=0
155
156
  INNER_W=44
156
157
  MARGIN=8
@@ -189,6 +190,7 @@ fi
189
190
  # so the geometric center sits at col 6.
190
191
  NAME_WITH_LEVEL="$NAME"
191
192
  [ "$LEVEL" -gt 1 ] 2>/dev/null && NAME_WITH_LEVEL="${NAME} [L${LEVEL}]"
193
+ [ -n "$STARS" ] && NAME_WITH_LEVEL="${NAME_WITH_LEVEL} ${STARS}"
192
194
  case "$MOOD" in
193
195
  happy) MOOD_EMOJI="" ;;
194
196
  focused) MOOD_EMOJI="" ;;
@@ -215,13 +217,19 @@ for line in "${ART_LINES[@]}"; do
215
217
  if [ "$SHINY" = "true" ]; then
216
218
  ALL_COLORS+=("${RAINBOW[$(( (_arc + RAINBOW_OFFSET) % RAINBOW_LEN ))]}")
217
219
  else
218
- ALL_COLORS+=("$C")
220
+ ALL_COLORS+=("$NEUTRAL")
219
221
  fi
220
222
  _arc=$(( _arc + 1 ))
221
223
  done
222
- ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$DIM")
223
-
224
- ART_W=14
224
+ ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$C")
225
+
226
+ case "$SPECIES" in
227
+ axolotl|capybara|rabbit|mushroom|chonk) ART_W=10 ;;
228
+ duck|blob|cat|dragon|robot|wyvern) ART_W=9 ;;
229
+ octopus|owl|turtle|snail|ghost|cactus) ART_W=8 ;;
230
+ goose|penguin) ART_W=7 ;;
231
+ *) ART_W=10 ;;
232
+ esac
225
233
  ART_COUNT=${#ALL_LINES[@]}
226
234
 
227
235
  # ─── Speech bubble (left of art, word-wrapped) ──────────────────────────────
@@ -372,18 +380,18 @@ for (( i=0; i<MAX_LINES; i++ )); do
372
380
 
373
381
  # Connector: "-- " on the middle text line, spaces otherwise
374
382
  if [ $bi -eq $CONNECTOR_BI ]; then
375
- gap="${C}--${NC} "
383
+ gap="${NEUTRAL}--${NC} "
376
384
  else
377
385
  gap=" "
378
386
  fi
379
387
 
380
388
  if [ "$btype" = "border" ]; then
381
- echo "${SPACER}${C}${bline}${NC}${gap}${art_part}"
389
+ echo "${SPACER}${NEUTRAL}${bline}${NC}${gap}${art_part}"
382
390
  else
383
391
  pipe_l="${bline:0:1}"
384
392
  pipe_r="${bline: -1}"
385
393
  inner="${bline:1:$(( ${#bline} - 2 ))}"
386
- echo "${SPACER}${C}${pipe_l}${NC}${DIM}${inner}${NC}${C}${pipe_r}${NC}${gap}${art_part}"
394
+ echo "${SPACER}${NEUTRAL}${pipe_l}${NC}${DIM}${inner}${NC}${NEUTRAL}${pipe_r}${NC}${gap}${art_part}"
387
395
  fi
388
396
  else
389
397
  empty=$(printf '%*s' "$BOX_W" '')
@@ -99,9 +99,11 @@ def fmt_stat(label, pct, reset):
99
99
  c = color_for(pct)
100
100
  pct_str = f'{round(pct):3d}%' if pct is not None else ' -%'
101
101
  bar = build_bar(pct)
102
- text = f'{DIM}{label}{NC} {c}{pct_str}{NC} {bar} {c}↻{reset}{NC}'
103
- visual_width = 2 + 1 + 4 + 1 + 10 + 1 + 1 + len(reset)
104
- return text, visual_width
102
+ bar_text = f'{DIM}{label}{NC} {c}{pct_str}{NC} {bar}'
103
+ bar_width = 2 + 1 + 4 + 1 + 10
104
+ timer_text = f' {c}↻{reset}{NC}'
105
+ timer_width = 3 + 1 + len(reset)
106
+ return (bar_text, bar_width), (timer_text, timer_width)
105
107
 
106
108
  try:
107
109
  stats = json.loads(os.environ.get('STATS_JSON', '{}'))
@@ -112,14 +114,13 @@ lines = sys.stdin.read().splitlines()
112
114
  n = len(lines)
113
115
  center = 1
114
116
 
115
- stat_items = [
116
- fmt_stat('5h', stats.get('sess_pct'), stats.get('sess_reset', '--')),
117
- fmt_stat('7d', stats.get('week_pct'), stats.get('week_reset', '--')),
118
- ]
117
+ sess = fmt_stat('5h', stats.get('sess_pct'), stats.get('sess_reset', '--'))
118
+ week = fmt_stat('7d', stats.get('week_pct'), stats.get('week_reset', '--'))
119
+ stat_items = [sess[0], sess[1], week[0], week[1]]
119
120
 
120
121
  for i, line in enumerate(lines):
121
122
  si = i - center
122
- if 0 <= si < 2 and line.startswith(BRAILLE):
123
+ if 0 <= si < 4 and line.startswith(BRAILLE):
123
124
  stat_text, stat_width = stat_items[si]
124
125
  after_braille = line[1:]
125
126
  num_spaces = len(after_braille) - len(after_braille.lstrip(' '))