Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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
```

Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions src/__tests__/codex-statusline.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
57 changes: 57 additions & 0 deletions src/codex-statusline.ts
Original file line number Diff line number Diff line change
@@ -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();
149 changes: 149 additions & 0 deletions src/lib/codex-statusline.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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<string, string> = {
'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<string, string[]> = {
'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];
}