diff --git a/README.md b/README.md index 4db0ba5..da75483 100644 --- a/README.md +++ b/README.md @@ -347,6 +347,8 @@ Level progress shows on the status card: ### 🖥️ Statusline Integration +Buddy has optional statusline integrations. The core MCP server does not depend on any HUD. + For Claude Code users, Buddy renders in your statusline alongside the HUD: ![Nuzzlecap Statusline](demo/screenshots/statusline.png) @@ -368,6 +370,22 @@ Add the statusline wrapper to your Claude Code settings: } ``` +Experimental and disabled by default: for patched Codex builds that expose `tui.status_line_command`, Buddy can render a compact footer block without depending on `codex-hud` itself: + +```toml +[tui] +status_line_command = "node /path/to/buddy/dist/codex-statusline.js" +``` + +Or, if Buddy is installed as a package and the bin is on `PATH`: + +```toml +[tui] +status_line_command = "buddy-codex-statusline" +``` + +This Codex integration is optional, experimental, and only works in Codex builds that already support `status_line_command`. The main Buddy install does not patch or rebuild Codex. + ### 💬 Speech Bubbles Buddy reactions render as speech bubbles next to your companion's ASCII art: @@ -417,7 +435,7 @@ git clone https://github.com/fiorastudio/buddy.git cd buddy npm install npm run build -npm test # 243 tests +npm test # 246 tests npm start # runs the MCP server on stdio ``` @@ -433,7 +451,7 @@ npm start # runs the MCP server on stdio | **Species** | 21 | 18 | | **Install** | One-liner (curl/PowerShell) | git clone + npm | | **Windows** | ✅ | ❌ | -| **Tests** | 243 | 140 | +| **Tests** | 246 | 140 | **save-buddy** is a faithful preservation of the original Claude Code buddy experience. It's great for purists who want the exact original. diff --git a/package.json b/package.json index e764980..f40c4a6 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "Persistent AI coding companion - MCP server with 21 species, personality stats, and code feedback", "main": "dist/server/index.js", "bin": { - "buddy-statusline": "dist/statusline-wrapper.js" + "buddy-statusline": "dist/statusline-wrapper.js", + "buddy-codex-statusline": "dist/codex-statusline.js" }, "files": [ "dist", diff --git a/src/__tests__/codex-statusline.test.ts b/src/__tests__/codex-statusline.test.ts new file mode 100644 index 0000000..b57027b --- /dev/null +++ b/src/__tests__/codex-statusline.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { renderCodexStatusline, type CodexBuddyStatus } from '../lib/codex-statusline.js'; + +const baseStatus: CodexBuddyStatus = { + name: 'Rustbot', + species: 'Goose', + level: 3, + xp: 42, + mood: 'happy', + rarity: 'rare', +}; + +describe('renderCodexStatusline', () => { + it('renders two footer-safe lines', () => { + const lines = renderCodexStatusline(baseStatus, 0); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('Rustbot'); + expect(lines[0]).toContain('Goose'); + expect(lines[1]).toContain('happy'); + expect(lines[1]).toContain('XP:42'); + }); + + it('shows active reaction text when present', () => { + const lines = renderCodexStatusline({ + ...baseStatus, + reaction_indicator: '♥', + reaction_text: '*tolerates petting with dignity*', + reaction_expires: 10_000, + }, 5_000); + expect(lines[0]).toContain('Rustbot'); + expect(lines[1]).toContain('♥'); + expect(lines[1]).toContain('tolerates petting'); + }); + + it('falls back to XP progress when reaction is expired', () => { + const lines = renderCodexStatusline({ + ...baseStatus, + reaction_text: 'old reaction', + reaction_expires: 10, + }, 20); + expect(lines[1]).toContain('XP:42'); + expect(lines[1]).not.toContain('old reaction'); + }); +}); diff --git a/src/codex-statusline.ts b/src/codex-statusline.ts new file mode 100644 index 0000000..623a6de --- /dev/null +++ b/src/codex-statusline.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import Database from 'better-sqlite3'; +import { existsSync, readFileSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import { renderCodexStatusline, type CodexBuddyStatus } from './lib/codex-statusline.js'; + +const BUDDY_STATUS_PATH = join(homedir(), '.claude', 'buddy-status.json'); +const BUDDY_DB_PATH = join(homedir(), '.buddy', 'buddy.db'); + +type DbRow = { + name: string; + species: string; + level: number; + xp: number; + mood: string; +}; + +function loadStatusFile(): CodexBuddyStatus | null { + try { + if (!existsSync(BUDDY_STATUS_PATH)) return null; + return JSON.parse(readFileSync(BUDDY_STATUS_PATH, 'utf-8')) as CodexBuddyStatus; + } catch { + return null; + } +} + +function loadFromDb(): CodexBuddyStatus | null { + let db: Database.Database | null = null; + try { + if (!existsSync(BUDDY_DB_PATH)) return null; + db = new Database(BUDDY_DB_PATH, { readonly: true }); + const row = db.prepare( + 'SELECT name, species, level, xp, mood FROM companions ORDER BY created_at DESC LIMIT 1' + ).get() as DbRow | undefined; + if (!row) return null; + return row; + } catch { + return null; + } finally { + db?.close(); + } +} + +function main(): void { + const status = loadStatusFile() || loadFromDb(); + if (!status || !status.name) return; + + for (const line of renderCodexStatusline(status)) { + if (line.trim()) { + console.log(line); + } + } +} + +main(); diff --git a/src/lib/codex-statusline.ts b/src/lib/codex-statusline.ts new file mode 100644 index 0000000..550e120 --- /dev/null +++ b/src/lib/codex-statusline.ts @@ -0,0 +1,149 @@ +import { levelProgress } from './leveling.js'; +import { RARITY_STARS, type Rarity } from './types.js'; + +export type CodexBuddyStatus = { + name: string; + species: string; + level: number; + xp: number; + mood?: string; + rarity?: Rarity; + rarity_stars?: string; + is_shiny?: boolean; + reaction_text?: string; + reaction_indicator?: string; + reaction_expires?: number; +}; + +const MINI_ICONS: Record = { + 'Void Cat': `|\\---/|`, + 'Rust Hound': `/\\_/\\\\`, + 'Data Drake': `/\\^/\\\\`, + 'Log Golem': `[::::]`, + 'Cache Crow': `\\v/`, + 'Shell Turtle': `(___)`, + 'Duck': `--`, + 'Goose': `(·>`, + 'Blob': `(~~)`, + 'Octopus': `\\(o.o)/`, + 'Owl': `(o,o)`, + 'Penguin': `.---.`, + 'Snail': `@_/'`, + 'Ghost': `,_,`, + 'Axolotl': `(:>`, + 'Capybara': `(===)`, + 'Cactus': `[++]`, + 'Robot': `[::]`, + 'Rabbit': `()/)`, + 'Mushroom': `.-o-OO-o-.`, + 'Chonk': `(____)`, +}; + +const MINI_BODIES: Record = { + 'Void Cat': `| o o |`, + 'Rust Hound': `|| ||`, + 'Data Drake': `\\\\_//`, + 'Log Golem': `[____]`, + 'Cache Crow': `\\__/`, + 'Shell Turtle': `(_^_)`, + 'Duck': `<(·)___`, + 'Goose': `_(__)_`, + 'Blob': `(___)`, + 'Octopus': ` /|\\\\ `, + 'Owl': `/)__)`, + 'Penguin': `(·>·)`, + 'Snail': `(___)`, + 'Ghost': `( )`, + 'Axolotl': `(:::)`, + 'Capybara': `(____)`, + 'Cactus': `| || |`, + 'Robot': `[__]`, + 'Rabbit': `('')`, + 'Mushroom': `(__________)`, + 'Chonk': `|____|`, +}; + +const AMBIENT_TEXT: Record = { + 'Void Cat': ['judging your code', 'staring into void', 'plotting silently'], + 'Rust Hound': ['sniffing for bugs', 'guarding the repo', 'chasing a pointer'], + 'Data Drake': ['hoarding abstractions', 'sorting interfaces', 'guarding the architecture'], + 'Log Golem': ['processing stack traces', 'reciting status codes', 'holding the line'], + 'Cache Crow': ['stealing good patterns', 'watching cache hits', 'hoarding snippets'], + 'Shell Turtle': ['reviewing carefully', 'moving slow, shipping safe', 'triple-checking deploys'], + 'Duck': ['rubber ducking', 'waddling in place', 'quacking softly'], + 'Goose': ['eyeing your code', 'standing guard', 'scheming'], + 'Blob': ['adapting quietly', 'squishing through modules', 'absorbing the framework'], + 'Octopus': ['untangling dependencies', 'multi-tasking aggressively', 'wrapping around the problem'], + 'Owl': ['reviewing after midnight', 'watching the patterns', 'judging your docs'], + 'Penguin': ['enforcing interfaces', 'keeping it structured', 'refusing to use any'], + 'Snail': ['reading every line', 'taking geological time', 'leaving review trails'], + 'Ghost': ['haunting your logs', 'flickering softly', 'phasing through code'], + 'Axolotl': ['regrowing morale', 'recovering from rollback', 'wiggling optimistically'], + 'Capybara': ['keeping things calm', 'de-escalating incidents', 'bringing peaceful vibes'], + 'Cactus': ['delivering tough love', 'flowering under pressure', 'growing through critique'], + 'Robot': ['processing...', 'scanning code', 'quantifying the damage'], + 'Rabbit': ['ready to critique', 'thumping softly', 'moving too fast'], + 'Mushroom': ['decomposing problems', 'spreading spores', 'growing quietly'], + 'Chonk': ['taking up space', 'sitting on the keyboard', 'owning the room'], +}; + +function isReactionActive(status: CodexBuddyStatus, now: number): boolean { + return typeof status.reaction_expires === 'number' && status.reaction_expires > now; +} + +function rarityStars(status: CodexBuddyStatus): string { + if (status.rarity_stars) return status.rarity_stars; + if (status.rarity) return RARITY_STARS[status.rarity]; + return ''; +} + +function miniIcon(species: string): string { + return MINI_ICONS[species] || '(-)'; +} + +function miniBody(species: string): string { + return MINI_BODIES[species] || '(___)'; +} + +function truncate(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + if (maxLength <= 1) return text.slice(0, maxLength); + return `${text.slice(0, maxLength - 1)}…`; +} + +function ambientText(species: string, xp: number): string { + const pool = AMBIENT_TEXT[species] || ['watching your cursor', 'vibing quietly', 'counting semicolons']; + return pool[Math.abs(xp) % pool.length]; +} + +export function renderCodexStatusline(status: CodexBuddyStatus, now = Date.now()): string[] { + const stars = rarityStars(status); + const shiny = status.is_shiny ? ' ✨' : ''; + const levelLabel = Number.isFinite(status.level) ? `Lv.${status.level}` : 'Lv.?'; + const reactionActive = isReactionActive(status, now); + const progress = levelProgress(Math.max(0, status.xp || 0)); + const xpLabel = progress.level >= 50 ? 'MAX' : `${progress.currentXp}/${progress.neededXp} XP`; + const line1 = [ + miniIcon(status.species), + status.name, + `(${status.species})`, + levelLabel, + ].filter(Boolean).join(' '); + + if (reactionActive && status.reaction_text) { + const line2 = [ + miniBody(status.species), + truncate(`${status.reaction_indicator || '·'} ${status.reaction_text}`, 42), + ].join(' '); + return [line1, line2]; + } + + const line2 = [ + miniBody(status.species), + status.mood || 'present', + `XP:${status.xp || 0}`, + `${stars}${shiny}`.trim(), + `· ${ambientText(status.species, status.xp || 0)}`, + ].filter(Boolean).join(' '); + return [line1, line2]; +}