diff --git a/README.md b/README.md index e6c3a6f..8b994d7 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,8 @@ claude-buddy/ | `/buddy position [top\|left]` | Bubble position (tmux only) | | `/buddy rarity [on\|off]` | Show or hide stars + rarity line (tmux only) | | `/buddy width [10-60]` | Set bubble text width in chars (tmux only) | -| `/buddy margin [0-20]` | Set right-side margin (tmux only) | +| `/buddy margin [0-20]` | Set distance from terminal right edge to buddy art | +| `/buddy bars_offset [0-40]` | Set distance from terminal left edge to usage bars (combined mode) | | `/buddy statusline [on\|off]` | Enable or disable buddy in the status line | | `/buddy statusline combined` | Show rate-limit usage bars alongside buddy (needs python3) | | `/buddy statusline basic` | Switch back to buddy-only status line | diff --git a/bun.lock b/bun.lock index eb838ad..09e7bd5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "claude-buddy", "dependencies": { + "@anthropic-ai/sdk": "^0.90.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@modelcontextprotocol/sdk": "^1.12.1", "@xterm/addon-serialize": "^0.14.0", @@ -26,6 +27,10 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.90.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -258,6 +263,8 @@ "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -394,6 +401,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], diff --git a/cli/pick.ts b/cli/pick.ts index 6fabcdc..e99d5e3 100644 --- a/cli/pick.ts +++ b/cli/pick.ts @@ -20,10 +20,11 @@ import { loadCompanionSlot, saveCompanionSlot, slugify, unusedName, writeStatusState, } from "../server/state.ts"; import { - generateBones, generatePersonality, SPECIES, RARITIES, STAT_NAMES, RARITY_STARS, EYES, HATS, + generateBones, SPECIES, RARITIES, STAT_NAMES, RARITY_STARS, EYES, HATS, type Species, type Rarity, type StatName, type Eye, type Hat, type BuddyBones, type Companion, } from "../server/engine.ts"; +import { generateBuddy } from "../server/generation.ts"; import { renderCompanionCard } from "../server/art.ts"; import { randomBytes } from "crypto"; @@ -113,37 +114,43 @@ interface SlotEntry { slot: string; companion: Companion; } interface BuddyResult { userId: string; bones: BuddyBones; } interface State { - mode: Mode; - searching: boolean; - savedSlots: SlotEntry[]; - savedCursor: number; - activeSlot: string; - criteriaFocus: number; - ci: number[]; // [speciesIdx, rarityIdx, shinyIdx, peakIdx, dumpIdx, eyeIdx, hatIdx, avgIdx, dbgIdx, patIdx, chaIdx, wisIdx, snkIdx] - results: BuddyResult[]; - resultCursor: number; - searchStatus: string; - nameInput: string; - pendingResult: BuddyResult | null; - message: string; + mode: Mode; + searching: boolean; + savedSlots: SlotEntry[]; + savedCursor: number; + activeSlot: string; + criteriaFocus: number; + ci: number[]; // [speciesIdx, rarityIdx, shinyIdx, peakIdx, dumpIdx, eyeIdx, hatIdx, avgIdx, dbgIdx, patIdx, chaIdx, wisIdx, snkIdx] + results: BuddyResult[]; + resultCursor: number; + searchStatus: string; + nameInput: string; + pendingResult: BuddyResult | null; + pendingGen: { name: string; personality: string } | null; + pendingGenLoading: boolean; + message: string; + spinnerTick: number; } function fresh(): State { return { - mode: "saved", - searching: false, - savedSlots: listCompanionSlots(), - savedCursor: 0, - activeSlot: loadActiveSlot(), - criteriaFocus: 0, + mode: "saved", + searching: false, + savedSlots: listCompanionSlots(), + savedCursor: 0, + activeSlot: loadActiveSlot(), + criteriaFocus: 0, // Default criteria: legendary, any species/shiny/peak/dump/eye/hat/avg/stats ci: [0, RA_OPTS.indexOf("legendary"), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], - results: [], - resultCursor: 0, - searchStatus: "", - nameInput: "", - pendingResult: null, - message: "", + results: [], + resultCursor: 0, + searchStatus: "", + nameInput: "", + pendingResult: null, + pendingGen: null, + pendingGenLoading: false, + message: "", + spinnerTick: 0, }; } @@ -249,10 +256,17 @@ function namingPane(s: State): string[] { if (b) lines.push(` ${clr}${b.rarity} ${b.species}${N}`); lines.push(GR + " " + "─".repeat(LEFT_W - 2) + N); lines.push(` ${B}Name:${N} ${s.nameInput}${YL}▌${N}`); - lines.push(` ${GR}(type a name, or enter for random)${N}`); + if (s.pendingGenLoading) { + const frame = SPINNER_FRAMES[s.spinnerTick % SPINNER_FRAMES.length]; + lines.push(` ${GR}${frame} generating suggestion...${N}`); + } else { + lines.push(` ${GR}(enter to save, or type a different name)${N}`); + } return lines; } +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + function previewPane(s: State): string[] { let c: Companion | null = null; @@ -260,18 +274,14 @@ function previewPane(s: State): string[] { c = s.savedSlots[s.savedCursor]?.companion ?? null; } else if (s.mode === "results") { const r = s.results[s.resultCursor]; - if (r) c = { - bones: r.bones, name: "???", - personality: generatePersonality(r.bones, r.userId), - hatchedAt: Date.now(), userId: r.userId, - }; + if (r) { + c = { bones: r.bones, name: "???", personality: "", hatchedAt: Date.now(), userId: r.userId }; + } } else if (s.mode === "naming" && s.pendingResult) { const r = s.pendingResult; - c = { - bones: r.bones, name: s.nameInput || "???", - personality: generatePersonality(r.bones, r.userId), - hatchedAt: Date.now(), userId: r.userId, - }; + const displayName = s.nameInput || s.pendingGen?.name || "???"; + const displayPersonality = s.pendingGen?.personality ?? ""; + c = { bones: r.bones, name: displayName, personality: displayPersonality, hatchedAt: Date.now(), userId: r.userId }; } if (!c) return [` ${GR}no preview${N}`]; @@ -399,6 +409,28 @@ async function runSearch(s: State): Promise { drawScreen(s); } +// ─── Naming-mode generation ─────────────────────────────────────────────────── + +async function startNamingGeneration(r: BuddyResult, s: State, redraw: () => void): Promise { + if (s.pendingGenLoading || s.pendingGen) return; + s.pendingGenLoading = true; + + const spinnerInterval = setInterval(() => { + s.spinnerTick++; + redraw(); + }, 100); + + try { + const { name, personality } = await generateBuddy(r.bones, r.userId); + s.pendingGen = { name, personality }; + if (!s.nameInput) s.nameInput = name; // auto-fill if user hasn't typed yet + } finally { + s.pendingGenLoading = false; + clearInterval(spinnerInterval); + redraw(); + } +} + // ─── Key handlers ───────────────────────────────────────────────────────────── function clamp(v: number, lo: number, hi: number) { return Math.max(lo, Math.min(hi, v)); } @@ -411,19 +443,20 @@ function onKey(key: string, s: State): boolean { case "naming": { if (key === "\x1b") { s.mode = "results"; s.nameInput = ""; s.pendingResult = null; + s.pendingGen = null; s.pendingGenLoading = false; } else if (key === "\r" || key === "\n") { - // Empty input → auto-pick a random unused name - const name = s.nameInput.trim() || unusedName(); + const r = s.pendingResult!; + // Use typed name, or LLM suggestion, or random fallback + const name = s.nameInput.trim() || s.pendingGen?.name || unusedName(); const slot = slugify(name); if (loadCompanionSlot(slot)) { s.message = `"${slot}" already taken — type a different name`; s.nameInput = ""; break; } - const r = s.pendingResult!; const companion: Companion = { bones: r.bones, name, - personality: generatePersonality(r.bones, r.userId), + personality: s.pendingGen?.personality ?? "This creature doesn't want to tell you about itself right now.", hatchedAt: Date.now(), userId: r.userId, }; saveCompanionSlot(companion, slot); @@ -500,9 +533,11 @@ function onKey(key: string, s: State): boolean { else if (key === "\r" || key === "\n") { const r = s.results[s.resultCursor]; if (r) { - s.pendingResult = r; - s.nameInput = ""; // empty — user types name or presses Enter for auto - s.mode = "naming"; + s.pendingResult = r; + s.nameInput = ""; + s.pendingGen = null; + s.pendingGenLoading = false; + s.mode = "naming"; } } break; @@ -536,10 +571,17 @@ async function main(): Promise { const s = fresh(); drawScreen(s); + function redraw() { drawScreen(s); } + await new Promise((resolve) => { process.stdin.on("data", (key: string) => { + const prevMode = s.mode; const quit = onKey(key, s); drawScreen(s); + // Fire generation when entering naming mode + if (s.mode === "naming" && prevMode !== "naming" && s.pendingResult) { + startNamingGeneration(s.pendingResult, s, redraw); + } if (quit) { cleanup(); process.stdout.write("\x1b[2J\x1b[H"); diff --git a/docs/superpowers/specs/2026-04-21-llm-personality-name-generation-design.md b/docs/superpowers/specs/2026-04-21-llm-personality-name-generation-design.md new file mode 100644 index 0000000..4f2542b --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-llm-personality-name-generation-design.md @@ -0,0 +1,84 @@ +# LLM-Based Personality & Name Generation + +**Date:** 2026-04-21 +**Status:** Approved + +## Problem + +Creature personalities and names are currently generated from fixed template pools ("A {rarity} {species} that {peak_phrase}. {rarity_closer} Though it {dump_phrase}.") with 15 peak phrases and 15 dump phrases. The results feel formulaic and repetitive across creatures. Names are random from a fixed word list. + +## Goal + +Replace template-based personality and name generation with LLM-generated text that feels unique to each creature's stats, species, and rarity. Generation should be invisible to the user — results are ready by the time they confirm hatching. + +## Architecture + +### New module: `server/generation.ts` + +Exports two async functions: + +```typescript +generatePersonality(bones: BuddyBones): Promise +generateName(bones: BuddyBones, personality: string): Promise +``` + +Both use `claude-haiku-4-5` via the Anthropic SDK (new dependency: `@anthropic-ai/sdk`). + +**`generatePersonality` prompt inputs:** +- Species, rarity, shiny status +- All 5 stat values (DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK) +- Peak stat and dump stat +- Instruction: 3-4 sentences, trading-card/creature-compendium flavor text style, no meta-commentary, in-world description only + +**`generateName` prompt inputs:** +- Species, rarity +- The already-generated personality (for coherence) +- Instruction: 1-2 words, gender-neutral, evocative + +Name is generated after personality so it can reflect the character. + +**Removals:** +- Template-based `generatePersonality` in `engine.ts` +- `generatePersonalityPrompt` stub in `reactions.ts` + +### Picker TUI: `cli/pick.ts` + +When a creature is highlighted in the picker list: + +1. Fire `generatePersonality(bones)` immediately +2. On resolve, fire `generateName(bones, personality)` +3. Right pane shows a spinner in place of name and personality text while generating +4. On resolve, right pane updates with live results + +**Caching:** Results are stored in a local `Map` keyed by a hash of the creature's bones. Scrolling back to a previously-viewed creature uses the cached result — no second API call. + +**At hatch:** The already-generated name and personality are written directly into the new `Companion`. No second generation call. + +### New MCP tools: `server/index.ts` + +| Tool | Description | +|------|-------------| +| `buddy_generate_personality` | Re-generates personality for the active companion via LLM. Updates stored personality and returns new text. | +| `buddy_generate_name` | Re-generates name for the active companion via LLM. Updates stored name and returns new name. | + +Existing `buddy_set_personality` and `buddy_rename` are unchanged — they remain the manual override path. + +## Error Handling + +On API failure (any error), both functions return a humorous placeholder: + +- **Name:** `TryAgainLater` +- **Personality:** `This creature doesn't want to tell you about itself right now.` + +For re-roll tools (`buddy_generate_personality`, `buddy_generate_name`): failures return a clear user-facing error message ("Generation failed — try again in a moment"). + +## Dependencies + +- Add `@anthropic-ai/sdk` to `package.json` +- API key sourced from `ANTHROPIC_API_KEY` environment variable (always present in Claude Code sessions) + +## Out of Scope + +- Streaming personality text into the picker pane (plain resolved string is sufficient) +- Regenerating personality/name on stat changes +- Batch pre-generation of personalities for the full picker list diff --git a/package.json b/package.json index 028727c..cdbaebb 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "homepage": "https://github.com/1270011/claude-buddy#readme", "license": "MIT", "dependencies": { + "@anthropic-ai/sdk": "^0.90.0", "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@modelcontextprotocol/sdk": "^1.12.1", "@xterm/addon-serialize": "^0.14.0", diff --git a/server/art.ts b/server/art.ts index f8a7d77..ef39d54 100644 --- a/server/art.ts +++ b/server/art.ts @@ -123,11 +123,16 @@ export const SPECIES_ART: Record = { ] }; +// ─── ANSI color constants ──────────────────────────────────────────────────── + +const GOLD = "\x1b[38;2;200;160;0m"; +const NC = "\x1b[0m"; + // ─── Hat art ──────────────────────────────────────────────────────────────── export const HAT_ART: Record = { none: "", - crown: " \\^^^/ ", + crown: ` ${GOLD}\\^^^/${NC} `, tophat: " [___] ", propeller: " -+- ", halo: " ( ) ", @@ -139,7 +144,7 @@ export const HAT_ART: Record = { // Wyvern line 0 is `} {` (7 inner chars between horns). // These replace that line so the hat sits between the horns. const WYVERN_HAT: Partial> = { - crown: "} \\^^^/ {", // \^^^/ (5) centered in 7 + crown: `} ${GOLD}\\^^^/${NC} {`, // \^^^/ (5) centered in 7 tophat: "} [___] {", // [___] (5) centered in 7 propeller: "} -+- {", // -+- (3) centered in 7 halo: "} ( ) {", // ( ) (5) centered in 7 @@ -171,7 +176,6 @@ const RARITY_COLOR: Record = { const SHINY_COLOR = "\x1b[93m"; // bright yellow const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; -const NC = "\x1b[0m"; export const RARITY_STARS: Record = { common: "\u2605", diff --git a/server/engine.ts b/server/engine.ts index f22cd78..e36e0b6 100644 --- a/server/engine.ts +++ b/server/engine.ts @@ -297,84 +297,6 @@ export function generateBones(userId: string, salt: string = SALT): BuddyBones { return { rarity, species, eye, hat, shiny, stats, peak, dump }; } -// ─── Personality ──────────────────────────────────────────────────────────── - -const PEAK_PHRASES: Record = { - DEBUGGING: [ - "spots segfaults before the stack unwinds", - "can read a backtrace like a map", - "finds the off-by-one before the tests do", - ], - PATIENCE: [ - "outlasts any flaky test suite", - "waits for the slow CI build without complaint", - "never merges before the green check", - ], - CHAOS: [ - "treats breaking changes as a love language", - "rewrites the Makefile on a Tuesday for fun", - "thrives wherever the incident channel is loudest", - ], - WISDOM: [ - "has seen this bug before — in three other repos", - "quotes the relevant RFC from memory", - "recognizes the abstraction that will outlive its author", - ], - SNARK: [ - "leaves code review comments that linger", - "finds the edge case you forgot and mentions it twice", - "names things with uncomfortable accuracy", - ], -}; - -const DUMP_PHRASES: Record = { - DEBUGGING: [ - "occasionally ships the workaround instead of the fix", - "skips the repro step", - "trusts the logs a little too much", - ], - PATIENCE: [ - "starts the rebase before the review is done", - "has been known to close slow issues as stale", - "has been known to force-push main", - ], - CHAOS: [ - "prefers everything to stay exactly where it is", - "dislikes surprise refactors", - "writes very thorough migration guides", - ], - WISDOM: [ - "sometimes reinvents the wheel with enthusiasm", - "skips the existing prior art", - "learns by doing, not by reading", - ], - SNARK: [ - "only leaves encouraging comments", - "approves PRs with genuine warmth", - "never says what it actually thinks", - ], -}; - -const RARITY_CLOSER: Record = { - common: ["Gets the job done.", "Reliable, if unassuming."], - uncommon: ["Has a few tricks up its sleeve.", "Worth keeping around."], - rare: ["Not to be underestimated.", "Earns its keep."], - epic: ["Commands quiet respect.", "The kind of companion repos are built around."], - legendary: ["The kind you find once, if you're lucky.", "Leaves every codebase better than it found it."], -}; - -export function generatePersonality(bones: BuddyBones, userId: string): string { - const seed = parseInt(userId.slice(0, 8), 16); - const pickPhrase = (arr: T[], salt: number = 0): T => arr[(seed + salt) % arr.length]; - - const shiny = bones.shiny ? " Shimmers faintly in dark mode." : ""; - const peak = pickPhrase(PEAK_PHRASES[bones.peak]); - const dump = pickPhrase(DUMP_PHRASES[bones.dump], 1); - const closer = pickPhrase(RARITY_CLOSER[bones.rarity] ?? ["Gets the job done."], 2); - - return `A ${bones.rarity} ${bones.species} that ${peak}.${shiny} ${closer} Though it ${dump}.`; -} - // ─── ASCII Art ────────────────────────────────────────────────────────────── const FACE_TEMPLATES: Record = { diff --git a/server/generation.ts b/server/generation.ts new file mode 100644 index 0000000..ca6e2fc --- /dev/null +++ b/server/generation.ts @@ -0,0 +1,221 @@ +import type { BuddyBones, StatName } from "./engine.ts"; +import { loadConfig } from "./state.ts"; + +// ─── Template generation (used when llmGeneration config is disabled) ──────── + +const PEAK_PHRASES: Record = { + DEBUGGING: [ + "spots segfaults before the stack unwinds", + "can read a backtrace like a map", + "finds the off-by-one before the tests do", + ], + PATIENCE: [ + "outlasts any flaky test suite", + "waits for the slow CI build without complaint", + "never merges before the green check", + ], + CHAOS: [ + "treats breaking changes as a love language", + "rewrites the Makefile on a Tuesday for fun", + "thrives wherever the incident channel is loudest", + ], + WISDOM: [ + "has seen this bug before — in three other repos", + "quotes the relevant RFC from memory", + "recognizes the abstraction that will outlive its author", + ], + SNARK: [ + "leaves code review comments that linger", + "finds the edge case you forgot and mentions it twice", + "names things with uncomfortable accuracy", + ], +}; + +const DUMP_PHRASES: Record = { + DEBUGGING: [ + "occasionally ships the workaround instead of the fix", + "skips the repro step", + "trusts the logs a little too much", + ], + PATIENCE: [ + "starts the rebase before the review is done", + "has been known to close slow issues as stale", + "has been known to force-push main", + ], + CHAOS: [ + "prefers everything to stay exactly where it is", + "dislikes surprise refactors", + "writes very thorough migration guides", + ], + WISDOM: [ + "sometimes reinvents the wheel with enthusiasm", + "skips the existing prior art", + "learns by doing, not by reading", + ], + SNARK: [ + "only leaves encouraging comments", + "approves PRs with genuine warmth", + "never says what it actually thinks", + ], +}; + +const RARITY_CLOSER: Record = { + common: ["Gets the job done.", "Reliable, if unassuming."], + uncommon: ["Has a few tricks up its sleeve.", "Worth keeping around."], + rare: ["Not to be underestimated.", "Earns its keep."], + epic: ["Commands quiet respect.", "The kind of companion repos are built around."], + legendary: ["The kind you find once, if you're lucky.", "Leaves every codebase better than it found it."], +}; + +const TEMPLATE_NAMES = [ + "Crumpet", "Soup", "Pickle", "Biscuit", "Moth", "Gravy", + "Nugget", "Sprocket", "Miso", "Waffle", "Pixel", "Ember", + "Thimble", "Marble", "Sesame", "Cobalt", "Rusty", "Nimbus", + "Thunder", "Void", "Velvet", "Rust", "Whisper", "Frost", + "Honey", "Copper", "Dusk", "Quartz", "Soot", "Plum", +]; + +export function templatePersonality(bones: BuddyBones, userId: string): string { + const seed = parseInt(userId.slice(0, 8), 16) || 0; + const pick = (arr: T[], salt: number = 0): T => arr[(seed + salt) % arr.length]; + const shiny = bones.shiny ? " Shimmers faintly in dark mode." : ""; + const peak = pick(PEAK_PHRASES[bones.peak]); + const dump = pick(DUMP_PHRASES[bones.dump], 1); + const closer = pick(RARITY_CLOSER[bones.rarity] ?? ["Gets the job done."], 2); + return `A ${bones.rarity} ${bones.species} that ${peak}.${shiny} ${closer} Though it ${dump}.`; +} + +export function templateName(userId: string): string { + const seed = parseInt(userId.slice(0, 8), 16) || 0; + return TEMPLATE_NAMES[seed % TEMPLATE_NAMES.length]; +} + +// ─── LLM generation ─────────────────────────────────────────────────────────── + +const QUERY_SYSTEM_PROMPT = + "You write in-world flavor text for a creature-collecting game. " + + "Reply with ONLY the requested content — no preamble, no postamble, " + + "no meta-commentary, no markdown headers, no explanation."; + +async function queryClaude(prompt: string): Promise { + // Strip ANTHROPIC_API_KEY so `claude` uses the Pro subscription auth + // rather than a (possibly depleted) API-key account. + const { ANTHROPIC_API_KEY, ...env } = process.env; + + const proc = Bun.spawn( + [ + "claude", "-p", + "--model", "haiku", + "--tools", "", + "--strict-mcp-config", + "--mcp-config", '{"mcpServers":{}}', + "--system-prompt", QUERY_SYSTEM_PROMPT, + prompt, + ], + { stdout: "pipe", stderr: "pipe", env }, + ); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + if (proc.exitCode !== 0) { + throw new Error(`claude CLI exited ${proc.exitCode}: ${stderr.trim() || stdout.trim()}`); + } + return stdout.trim(); +} + +function bonesPromptBlock(bones: BuddyBones): string { + const statLines = Object.entries(bones.stats) + .map(([k, v]) => `${k}: ${v}`) + .join(", "); + return [ + `Species: ${bones.species}`, + `Rarity: ${bones.rarity}`, + `Shiny: ${bones.shiny}`, + `Stats: ${statLines}`, + `Peak stat: ${bones.peak} (${bones.stats[bones.peak]})`, + `Dump stat: ${bones.dump} (${bones.stats[bones.dump]})`, + ].join("\n"); +} + +function sanitizeName(raw: string): string { + return raw.replace(/[^a-zA-Z0-9 '-]/g, "").slice(0, 14).trim(); +} + +export async function generateBuddy( + bones: BuddyBones, + userId?: string, +): Promise<{ name: string; personality: string }> { + if (!loadConfig().llmGeneration) { + const uid = userId ?? "anon"; + return { name: templateName(uid), personality: templatePersonality(bones, uid) }; + } + const prompt = [ + bonesPromptBlock(bones), + "", + "Generate both a NAME and PERSONALITY for this coding companion.", + "Be specific to this creature's stats and species — no generic filler.", + "Reply in exactly this format, with no preamble or extra lines:", + "NAME: <1-2 words, gender-neutral, evocative>", + "PERSONALITY: <3-4 sentences of trading-card / creature-compendium flavor text; stay fully in-world; describe behavior, quirks, and character>", + ].join("\n"); + + try { + const text = await queryClaude(prompt); + const nameMatch = text.match(/^\s*NAME:\s*(.+?)\s*$/mi); + const personalityMatch = text.match(/PERSONALITY:\s*([\s\S]+?)\s*$/i); + const name = sanitizeName(nameMatch?.[1] ?? ""); + const personality = (personalityMatch?.[1] ?? "").trim(); + if (!name || !personality) throw new Error("unparseable response"); + return { name, personality }; + } catch { + return { + name: "TryAgainLater", + personality: "This creature doesn't want to tell you about itself right now.", + }; + } +} + +export async function generatePersonality(bones: BuddyBones, userId?: string): Promise { + if (!loadConfig().llmGeneration) return templatePersonality(bones, userId ?? "anon"); + const prompt = [ + bonesPromptBlock(bones), + "", + "Write 3-4 sentences of trading-card / creature-compendium flavor text for this coding companion.", + "Stay fully in-world — no meta-commentary. Describe behavior, quirks, and character.", + "Be specific to this creature's stats and species. Do not use generic filler.", + ].join("\n"); + + try { + const text = await queryClaude(prompt); + if (!text) throw new Error("empty response"); + return text; + } catch { + return "This creature doesn't want to tell you about itself right now."; + } +} + +export async function generateName( + bones: BuddyBones, + personality: string, + userId?: string, +): Promise { + if (!loadConfig().llmGeneration) return templateName(userId ?? "anon"); + const prompt = [ + `Species: ${bones.species}`, + `Rarity: ${bones.rarity}`, + `Personality: ${personality}`, + "", + "Give this coding companion a name. 1-2 words, gender-neutral, evocative.", + "Reply with ONLY the name — no punctuation, no explanation.", + ].join("\n"); + + try { + const text = await queryClaude(prompt); + if (!text) throw new Error("empty response"); + return sanitizeName(text) || "TryAgainLater"; + } catch { + return "TryAgainLater"; + } +} diff --git a/server/index.ts b/server/index.ts index 5bde690..b425f03 100644 --- a/server/index.ts +++ b/server/index.ts @@ -13,7 +13,6 @@ import { join, resolve, dirname } from "path"; import { generateBones, - generatePersonality, renderFace, SPECIES, RARITIES, @@ -24,6 +23,7 @@ import { type StatName, type Companion, } from "./engine.ts"; +import { generateBuddy, generatePersonality, generateName } from "./generation.ts"; import { loadCompanion, saveCompanion, @@ -51,7 +51,7 @@ import { claudeSettingsPath, } from "./path.ts"; import { - getReaction, generatePersonalityPrompt, + getReaction, } from "./reactions.ts"; import { renderCompanionCardMarkdown } from "./art.ts"; import { @@ -94,7 +94,7 @@ const server = new McpServer( // ─── Helper: ensure companion exists ──────────────────────────────────────── -function ensureCompanion(): Companion { +async function ensureCompanion(): Promise { let companion = loadCompanion(); if (companion) return companion; @@ -110,11 +110,11 @@ function ensureCompanion(): Companion { // Menagerie is empty — generate a fresh companion in a new slot const userId = resolveUserId(); const bones = generateBones(userId); - const name = unusedName(); + const { name, personality } = await generateBuddy(bones, userId); companion = { bones, name, - personality: generatePersonality(bones, userId), + personality, hatchedAt: Date.now(), userId, }; @@ -142,7 +142,7 @@ server.tool( "Show the coding companion with full ASCII art card, stats, and personality", {}, async () => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); const reaction = loadReaction(); const reactionText = reaction?.reaction ?? `*${companion.name} watches your code quietly*`; @@ -173,7 +173,7 @@ server.tool( "Pet your coding companion — they react with happiness", {}, async () => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); const reaction = getReaction( "pet", companion.bones.species, @@ -203,7 +203,7 @@ server.tool( "Show detailed companion stats: species, rarity, all stats with bars", {}, async () => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); // Stats-only card (no personality, no reaction — just the numbers). // Uses markdown renderer so the card displays cleanly in Claude Code's UI. @@ -252,7 +252,7 @@ server.tool( .describe("What triggered the reaction"), }, async ({ comment, reason }) => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); saveReaction(comment, reason ?? "turn"); incrementEvent("reactions_given", 1, activeSlot()); @@ -285,7 +285,7 @@ server.tool( .describe("New name for your buddy (1-14 characters)"), }, async ({ name }) => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); const oldName = companion.name; companion.name = name; saveCompanion(companion); @@ -317,7 +317,7 @@ server.tool( .describe("Personality description (1-500 chars)"), }, async ({ personality }) => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); companion.personality = personality; saveCompanion(companion); incrementEvent("commands_run", 1, activeSlot()); @@ -365,8 +365,9 @@ server.tool( " /buddy style Show or set bubble style (tmux only)", " /buddy position Show or set bubble position (tmux only)", " /buddy rarity Show or hide rarity stars (tmux only)", - " /buddy width Set bubble text width in chars (10-60, tmux only)", - " /buddy margin Set right-side margin in chars (0-20, tmux only)", + " /buddy bubble_width Set bubble text width in chars (10-60, tmux only)", + " /buddy margin Set right-side margin: distance from terminal right edge to buddy (0-20)", + " /buddy bars_offset Set left offset of usage bars from terminal left edge (0-40)", " /buddy rainbow Show or set shiny gradient colors (hex, e.g. #ff0000)", " /buddy statusline Enable or disable buddy in the status line", "", @@ -440,7 +441,7 @@ server.tool( .boolean() .optional() .describe("Show or hide the stars + rarity line in the status line"), - width: z + bubble_width: z .number() .int() .min(10) @@ -453,7 +454,14 @@ server.tool( .min(0) .max(20) .optional() - .describe("Right-side margin between buddy and terminal edge (0–20, default 3)"), + .describe("Right-side margin: columns from terminal right edge to the buddy art (0–20, default 8)"), + bars_left_offset: z + .number() + .int() + .min(0) + .max(40) + .optional() + .describe("Columns from terminal left edge to the start of usage bars in combined mode (0–40, default 2)"), rainbow: z .array(z.string().regex(/^#[0-9a-fA-F]{6}$/, "Must be a hex color like #ff0000")) .min(1) @@ -462,15 +470,23 @@ server.tool( .describe( "Custom rainbow gradient for shiny buddies — array of 1–16 hex colors (e.g. [\"#ff0000\",\"#00ff00\"]). Omit to reset to default ROYGBIV.", ), + llm_generation: z + .boolean() + .optional() + .describe( + "Enable or disable LLM-based personality and name generation (default on). When off, uses fast deterministic template generation instead.", + ), }, - async ({ style, position, showRarity, width, margin, rainbow }) => { + async ({ style, position, showRarity, bubble_width, margin, bars_left_offset, rainbow, llm_generation }) => { if ( style === undefined && position === undefined && showRarity === undefined && - width === undefined && + bubble_width === undefined && margin === undefined && - rainbow === undefined + bars_left_offset === undefined && + rainbow === undefined && + llm_generation === undefined ) { const cfg = loadConfig(); const rainbowDisplay = cfg.rainbowColors @@ -480,7 +496,7 @@ server.tool( content: [ { type: "text", - text: `Bubble style: ${cfg.bubbleStyle}\nBubble position: ${cfg.bubblePosition}\nShow rarity: ${cfg.showRarity}\nBubble width: ${cfg.bubbleWidth}\nBubble margin: ${cfg.bubbleMargin}\nShiny rainbow: ${rainbowDisplay}\nUse /buddy style , /buddy position , /buddy rarity , /buddy width <10-60>, /buddy margin <0-20>, /buddy rainbow [<#hex>...] to change.`, + text: `Bubble style: ${cfg.bubbleStyle}\nBubble position: ${cfg.bubblePosition}\nShow rarity: ${cfg.showRarity}\nBubble width: ${cfg.bubbleWidth}\nBuddy margin (right): ${cfg.buddyMargin}\nBars left offset: ${cfg.barsLeftOffset}\nShiny rainbow: ${rainbowDisplay}\nLLM generation: ${cfg.llmGeneration}\nUse /buddy style , /buddy position , /buddy rarity , /buddy bubble_width <10-60>, /buddy margin <0-20>, /buddy bars_offset <0-40>, /buddy rainbow [<#hex>...], /buddy llm_generation to change.`, }, ], }; @@ -489,9 +505,11 @@ server.tool( if (style !== undefined) updates.bubbleStyle = style; if (position !== undefined) updates.bubblePosition = position; if (showRarity !== undefined) updates.showRarity = showRarity; - if (width !== undefined) updates.bubbleWidth = width; - if (margin !== undefined) updates.bubbleMargin = margin; + if (bubble_width !== undefined) updates.bubbleWidth = bubble_width; + if (margin !== undefined) updates.buddyMargin = margin; + if (bars_left_offset !== undefined) updates.barsLeftOffset = bars_left_offset; if (rainbow !== undefined) updates.rainbowColors = rainbow.length > 0 ? rainbow : undefined; + if (llm_generation !== undefined) updates.llmGeneration = llm_generation; const cfg = saveConfig(updates); const rainbowDisplay = cfg.rainbowColors ? cfg.rainbowColors.join(", ") @@ -500,7 +518,7 @@ server.tool( content: [ { type: "text", - text: `Updated: style=${cfg.bubbleStyle}, position=${cfg.bubblePosition}, showRarity=${cfg.showRarity}, width=${cfg.bubbleWidth}, margin=${cfg.bubbleMargin}, rainbow=${rainbowDisplay}\nRestart Claude Code for changes to take effect.`, + text: `Updated: style=${cfg.bubbleStyle}, position=${cfg.bubblePosition}, showRarity=${cfg.showRarity}, width=${cfg.bubbleWidth}, margin=${cfg.buddyMargin}, barsLeftOffset=${cfg.barsLeftOffset}, rainbow=${rainbowDisplay}, llmGeneration=${cfg.llmGeneration}\nRestart Claude Code for changes to take effect.`, }, ], }; @@ -512,7 +530,7 @@ server.tool( "Mute buddy reactions (buddy stays visible but stops reacting)", {}, async () => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); writeStatusState(companion, "", true); incrementEvent("commands_run", 1, activeSlot()); incrementEvent("mutes", 1); @@ -534,7 +552,7 @@ server.tool( ); server.tool("buddy_unmute", "Unmute buddy reactions", {}, async () => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); writeStatusState(companion, "*stretches* I'm back!", false); saveReaction("*stretches* I'm back!", "pet"); incrementEvent("commands_run", 1, activeSlot()); @@ -674,7 +692,7 @@ server.tool( "Show all achievement badges — earned and locked. Displays a card with progress bar and status for each badge.", {}, async () => { - ensureCompanion(); + await ensureCompanion(); checkAndAward(activeSlot()); incrementEvent("achievement_views", 1); const card = renderAchievementsCardMarkdown(); @@ -769,7 +787,7 @@ server.tool( ), }, async ({ slot }) => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); const targetSlot = slot ? slugify(slot) : slugify(companion.name); saveCompanionSlot(companion, targetSlot); saveActiveSlot(targetSlot); @@ -928,10 +946,11 @@ server.tool( }; } + const personality = await generatePersonality(bones, userId); const companion: Companion = { bones, name: buddyName, - personality: generatePersonality(bones, userId), + personality, hatchedAt: Date.now(), userId, }; @@ -951,6 +970,46 @@ server.tool( }, ); +// ─── Tool: buddy_generate_personality ─────────────────────────────────────── + +server.tool( + "buddy_generate_personality", + "Re-generate personality for the active companion via LLM. Updates stored personality and returns new text.", + {}, + async () => { + const companion = await ensureCompanion(); + const newPersonality = await generatePersonality(companion.bones); + if (newPersonality === "This creature doesn't want to tell you about itself right now.") { + return { content: [{ type: "text", text: "Generation failed — try again in a moment." }] }; + } + companion.personality = newPersonality; + const slot = activeSlot(); + saveCompanionSlot(companion, slot); + writeStatusState(companion); + return { content: [{ type: "text", text: newPersonality }] }; + }, +); + +// ─── Tool: buddy_generate_name ─────────────────────────────────────────────── + +server.tool( + "buddy_generate_name", + "Re-generate name for the active companion via LLM. Updates stored name and returns new name.", + {}, + async () => { + const companion = await ensureCompanion(); + const newName = await generateName(companion.bones, companion.personality); + if (newName === "TryAgainLater") { + return { content: [{ type: "text", text: "Generation failed — try again in a moment." }] }; + } + companion.name = newName; + const slot = activeSlot(); + saveCompanionSlot(companion, slot); + writeStatusState(companion); + return { content: [{ type: "text", text: newName }] }; + }, +); + // ─── Resource: buddy://companion ──────────────────────────────────────────── server.resource( @@ -958,7 +1017,7 @@ server.resource( "buddy://companion", { description: "Current companion data as JSON", mimeType: "application/json" }, async () => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); return { contents: [ { @@ -978,7 +1037,7 @@ server.resource( "buddy://prompt", { description: "System prompt context for the companion", mimeType: "text/markdown" }, async () => { - const companion = ensureCompanion(); + const companion = await ensureCompanion(); const prompt = [ "# Companion", "", diff --git a/server/reactions.test.ts b/server/reactions.test.ts index a249da2..e2127ab 100644 --- a/server/reactions.test.ts +++ b/server/reactions.test.ts @@ -2,7 +2,6 @@ import { describe, test, expect } from "bun:test"; import { getReaction, generateFallbackName, - generatePersonalityPrompt, } from "./reactions.ts"; import { SPECIES, RARITIES, STAT_NAMES } from "./engine.ts"; @@ -261,94 +260,3 @@ describe("generateFallbackName", () => { expect(seen.size).toBeGreaterThan(1); }); }); - -describe("generatePersonalityPrompt", () => { - const sampleStats = { - DEBUGGING: 42, - PATIENCE: 73, - CHAOS: 12, - WISDOM: 88, - SNARK: 55, - }; - - test("includes the species and uppercased rarity", () => { - const prompt = generatePersonalityPrompt("turtle", "legendary", sampleStats, false); - expect(prompt).toContain("Species: turtle"); - expect(prompt).toContain("Rarity: LEGENDARY"); - }); - - test("includes every stat name and its value", () => { - const prompt = generatePersonalityPrompt("owl", "rare", sampleStats, false); - for (const [name, value] of Object.entries(sampleStats)) { - expect(prompt).toContain(`${name}:${value}`); - } - }); - - test("marks shiny variants with the SHINY tag", () => { - const shinyPrompt = generatePersonalityPrompt("dragon", "epic", sampleStats, true); - const plainPrompt = generatePersonalityPrompt("dragon", "epic", sampleStats, false); - expect(shinyPrompt).toContain("SHINY"); - expect(plainPrompt).not.toContain("SHINY"); - }); - - test("includes the JSON output instruction", () => { - const prompt = generatePersonalityPrompt("cat", "common", sampleStats, false); - expect(prompt).toContain('"name"'); - expect(prompt).toContain('"personality"'); - }); - - test("includes exactly 4 inspiration vibe words", () => { - for (let i = 0; i < 20; i++) { - const prompt = generatePersonalityPrompt("blob", "uncommon", sampleStats, false); - const match = prompt.match(/Inspiration words: (.+)/); - expect(match).not.toBeNull(); - const words = match![1].split(",").map((w) => w.trim()); - expect(words.length).toBe(4); - for (const w of words) { - expect(w.length).toBeGreaterThan(0); - expect(w).toMatch(/^[a-z]+$/); - } - } - }); - - test("shape is stable: same set of lines in the same order", () => { - const prompt = generatePersonalityPrompt( - "penguin", "rare", - { DEBUGGING: 1, PATIENCE: 2, CHAOS: 3, WISDOM: 4, SNARK: 5 }, - false, - ); - const lines = prompt.split("\n"); - expect(lines[0]).toMatch(/Generate a coding companion/); - expect(prompt).toContain("distinct"); - const statsIdx = lines.findIndex((l) => l.startsWith("Stats:")); - const vibesIdx = lines.findIndex((l) => l.startsWith("Inspiration words:")); - expect(statsIdx).toBeGreaterThan(-1); - expect(vibesIdx).toBeGreaterThan(statsIdx); - }); - - test("does not crash for any valid species and rarity", () => { - for (const species of SPECIES) { - for (const rarity of RARITIES) { - const prompt = generatePersonalityPrompt(species, rarity, sampleStats, false); - expect(typeof prompt).toBe("string"); - expect(prompt.length).toBeGreaterThan(0); - } - } - }); - - test("handles stats with arbitrary names", () => { - const customStats = { FOO: 10, BAR: 20 }; - const prompt = generatePersonalityPrompt("mushroom", "common", customStats, false); - expect(prompt).toContain("FOO:10"); - expect(prompt).toContain("BAR:20"); - }); - - test("all canonical STAT_NAMES flow through cleanly", () => { - const full: Record = {}; - for (const n of STAT_NAMES) full[n] = 50; - const prompt = generatePersonalityPrompt("duck", "rare", full, false); - for (const n of STAT_NAMES) { - expect(prompt).toContain(`${n}:50`); - } - }); -}); diff --git a/server/reactions.ts b/server/reactions.ts index ebcd968..2cfdcd6 100644 --- a/server/reactions.ts +++ b/server/reactions.ts @@ -575,47 +575,14 @@ export function getReaction( return reaction; } +// ─── Fallback name generation ──────────────────────────────────────────────── + const FALLBACK_NAMES = [ "Crumpet", "Soup", "Pickle", "Biscuit", "Moth", "Gravy", "Nugget", "Sprocket", "Miso", "Waffle", "Pixel", "Ember", "Thimble", "Marble", "Sesame", "Cobalt", "Rusty", "Nimbus", ]; -const VIBE_WORDS = [ - "thunder", "biscuit", "void", "accordion", "moss", "velvet", "rust", - "pickle", "crumb", "whisper", "gravy", "frost", "ember", "soup", - "marble", "thorn", "honey", "static", "copper", "dusk", "sprocket", - "quartz", "soot", "plum", "flint", "oyster", "loom", "anvil", - "cork", "bloom", "pebble", "vapor", "mirth", "glint", "cider", -]; - export function generateFallbackName(): string { return FALLBACK_NAMES[Math.floor(Math.random() * FALLBACK_NAMES.length)]; } - -export function generatePersonalityPrompt( - species: Species, - rarity: Rarity, - stats: Record, - shiny: boolean, -): string { - const vibes: string[] = []; - for (let i = 0; i < 4; i++) { - vibes.push(VIBE_WORDS[Math.floor(Math.random() * VIBE_WORDS.length)]); - } - - const statStr = Object.entries(stats).map(([k, v]) => `${k}:${v}`).join(", "); - - return [ - "Generate a coding companion — a small creature that lives in a developer's terminal.", - "Don't repeat yourself — every companion should feel distinct.", - "", - `Rarity: ${rarity.toUpperCase()}`, - `Species: ${species}`, - `Stats: ${statStr}`, - `Inspiration words: ${vibes.join(", ")}`, - shiny ? "SHINY variant — extra special." : "", - "", - "Return JSON: {\"name\": \"1-14 chars\", \"personality\": \"2-3 sentences describing behavior\"}", - ].filter(Boolean).join("\n"); -} diff --git a/server/state.ts b/server/state.ts index 0647c6f..6ae1cb7 100644 --- a/server/state.ts +++ b/server/state.ts @@ -311,9 +311,11 @@ export interface BuddyConfig { showRarity: boolean; statusLineEnabled: boolean; bubbleWidth: number; - bubbleMargin: number; + buddyMargin: number; + barsLeftOffset: number; useCombinedStatus: boolean; rainbowColors?: string[]; + llmGeneration: boolean; } const DEFAULT_CONFIG: BuddyConfig = { @@ -324,8 +326,10 @@ const DEFAULT_CONFIG: BuddyConfig = { showRarity: true, statusLineEnabled: false, bubbleWidth: 28, - bubbleMargin: 8, + buddyMargin: 8, + barsLeftOffset: 2, useCombinedStatus: false, + llmGeneration: true, }; export function loadConfig(): BuddyConfig { diff --git a/statusline/combined-status.sh b/statusline/combined-status.sh index e01d2cf..40bfc05 100755 --- a/statusline/combined-status.sh +++ b/statusline/combined-status.sh @@ -8,6 +8,9 @@ SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" BUDDY_SCRIPT="$SCRIPT_DIR/buddy-status.sh" +# shellcheck source=../scripts/paths.sh +source "$SCRIPT_DIR/../scripts/paths.sh" + if ! command -v python3 >/dev/null 2>&1; then exec "$BUDDY_SCRIPT" fi @@ -71,8 +74,16 @@ if [ "$HAS_DATA" != "True" ]; then exit 0 fi +# ── Read bars left offset from config ─────────────────────────────────────── +BARS_LEFT_OFFSET=0 +_CONFIG="$BUDDY_STATE_DIR/config.json" +if [ -f "$_CONFIG" ]; then + _blo=$(jq -r '.barsLeftOffset // 0' "$_CONFIG" 2>/dev/null || echo 0) + case "$_blo" in ''|*[!0-9]*) ;; *) BARS_LEFT_OFFSET="$_blo" ;; esac +fi + # ── Merge stat lines into buddy output ────────────────────────────────────── -printf '%s\n' "$BUDDY_OUTPUT" | STATS_JSON="$STATS_JSON" python3 -c " +printf '%s\n' "$BUDDY_OUTPUT" | STATS_JSON="$STATS_JSON" BARS_LEFT_OFFSET="$BARS_LEFT_OFFSET" python3 -c " import sys, json, os, re BRAILLE = '\u2800' @@ -108,9 +119,11 @@ try: except Exception: stats = {} +bars_left = int(os.environ.get('BARS_LEFT_OFFSET', '0')) + lines = sys.stdin.read().splitlines() n = len(lines) -center = 1 +center = 1 stat_items = [ fmt_stat('5h', stats.get('sess_pct'), stats.get('sess_reset', '--')), @@ -123,10 +136,11 @@ for i, line in enumerate(lines): stat_text, stat_width = stat_items[si] after_braille = line[1:] num_spaces = len(after_braille) - len(after_braille.lstrip(' ')) - if num_spaces >= stat_width: - remaining = ' ' * (num_spaces - stat_width) + needed = bars_left + stat_width + if num_spaces >= needed: + remaining = ' ' * (num_spaces - needed) rest = after_braille.lstrip(' ') - print(BRAILLE + stat_text + remaining + rest) + print(BRAILLE + ' ' * bars_left + stat_text + remaining + rest) else: print(line) else: