refactor(buddy): align companion system with official CLI

## Summary

Reverse-engineered the official Claude Code CLI (v2.1.91) buddy/companion
system and aligned our implementation to match.

## Changes (7 files)

### Added
- `src/buddy/CompanionCard.tsx` (+109)
  JSX bordered card matching official vc8: rarity header, colored sprite,
  name, personality, 10-bar stats, last reaction in nested border.

- `src/buddy/companionReact.ts` (+156)
  Reaction system matching official ZUK+Dc8: 45s rate limiting, @-mention
  detection, transcript builder (12 msgs, 5000 chars), POST buddy_react API.

### Modified
- `src/commands/buddy/index.ts`
  type: local -> local-jsx, description/argumentHint/immediate/isHidden.

- `src/commands/buddy/buddy.ts`
  LocalCommandCall -> LocalJSXCommandCall signature (onDone, context, args).
  Removed mute/unmute/rehatch (official uses off/on only).
  /buddy show returns CompanionCard JSX instead of plain text.
  Pet auto-unmutes. companionMuted writes globalConfig (matches UI read source).

- `src/screens/REPL.tsx` (line 2808)
  globalThis.fireCompanionObserver -> import triggerCompanionReaction.

- `src/state/AppStateStore.ts` — comment fix.
- `src/types/global.d.ts` — removed fireCompanionObserver declaration.

## Data flow (verified consistent)
- companionMuted: saveGlobalConfig() <-> getGlobalConfig() (6 read sites)
- companionReaction: setAppState() <-> useAppState() (4 sites)
- companionPetAt: setAppState() <-> useAppState() (2 sites)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
unraid
2026-04-03 16:36:22 +08:00
parent 7935bfb4b8
commit 991119491c
7 changed files with 384 additions and 183 deletions

View File

@@ -1,16 +1,19 @@
import React from 'react'
import {
getCompanion,
rollWithSeed,
generateSeed,
} from '../../buddy/companion.js'
import {
type StoredCompanion,
RARITY_STARS,
STAT_NAMES,
} from '../../buddy/types.js'
import { type StoredCompanion, RARITY_STARS } from '../../buddy/types.js'
import { renderSprite } from '../../buddy/sprites.js'
import { CompanionCard } from '../../buddy/CompanionCard.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import type { LocalCommandCall } from '../../types/command.js'
import { triggerCompanionReaction } from '../../buddy/companionReact.js'
import type { ToolUseContext } from '../../Tool.js'
import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
// Species → default name fragments for hatch (no API needed)
const SPECIES_NAMES: Record<string, string> = {
@@ -39,198 +42,128 @@ const SPECIES_PERSONALITY: Record<string, string> = {
goose: 'Assertive and honks at bad code. Takes no prisoners in code reviews.',
blob: 'Adaptable and goes with the flow. Sometimes splits into two when confused.',
cat: 'Independent and judgmental. Watches you type with mild disdain.',
dragon: 'Fiery and passionate about architecture. Hoards good variable names.',
octopus: 'Multitasker extraordinaire. Wraps tentacles around every problem at once.',
dragon:
'Fiery and passionate about architecture. Hoards good variable names.',
octopus:
'Multitasker extraordinaire. Wraps tentacles around every problem at once.',
owl: 'Wise but verbose. Always says "let me think about that" for exactly 3 seconds.',
penguin: 'Cool under pressure. Slides gracefully through merge conflicts.',
turtle: 'Patient and thorough. Believes slow and steady wins the deploy.',
snail: 'Methodical and leaves a trail of useful comments. Never rushes.',
ghost: 'Ethereal and appears at the worst possible moments with spooky insights.',
ghost:
'Ethereal and appears at the worst possible moments with spooky insights.',
axolotl: 'Regenerative and cheerful. Recovers from any bug with a smile.',
capybara: 'Zen master. Remains calm while everything around is on fire.',
cactus: 'Prickly on the outside but full of good intentions. Thrives on neglect.',
cactus:
'Prickly on the outside but full of good intentions. Thrives on neglect.',
robot: 'Efficient and literal. Processes feedback in binary.',
rabbit: 'Energetic and hops between tasks. Finishes before you start.',
mushroom: 'Quietly insightful. Grows on you over time.',
chonk: 'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.',
chonk:
'Big, warm, and takes up the whole couch. Prioritizes comfort over elegance.',
}
function speciesLabel(species: string): string {
return species.charAt(0).toUpperCase() + species.slice(1)
}
function renderStats(stats: Record<string, number>): string {
const lines = STAT_NAMES.map(name => {
const val = stats[name] ?? 0
const filled = Math.round(val / 5)
const bar = '█'.repeat(filled) + '░'.repeat(20 - filled)
return ` ${name.padEnd(10)} ${bar} ${val}`
})
return lines.join('\n')
}
export async function call(
onDone: LocalJSXCommandOnDone,
context: ToolUseContext & LocalJSXCommandContext,
args: string,
): Promise<null> {
const sub = args?.trim().toLowerCase() ?? ''
const setState = context.setAppState
export const call: LocalCommandCall = async (args, _context) => {
const sub = args.trim().toLowerCase()
const config = getGlobalConfig()
// /buddy — show current companion or hint to hatch
if (sub === '') {
const companion = getCompanion()
if (!companion) {
return {
type: 'text',
value:
"You don't have a companion yet! Use /buddy hatch to get one.",
}
}
const stars = RARITY_STARS[companion.rarity]
const sprite = renderSprite(companion, 0)
const shiny = companion.shiny ? ' ✨ Shiny!' : ''
const lines = [
sprite.join('\n'),
'',
` ${companion.name} the ${speciesLabel(companion.species)}${shiny}`,
` Rarity: ${stars} (${companion.rarity})`,
` Eye: ${companion.eye} Hat: ${companion.hat}`,
companion.personality ? `\n "${companion.personality}"` : '',
'',
' Stats:',
renderStats(companion.stats),
'',
' Commands: /buddy pet /buddy mute /buddy unmute /buddy hatch /buddy rehatch',
]
return { type: 'text', value: lines.join('\n') }
// ── /buddy off — mute companion ──
if (sub === 'off') {
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: true }))
onDone('companion muted', { display: 'system' })
return null
}
// /buddy hatch — create a new companion
if (sub === 'hatch') {
if (config.companion) {
return {
type: 'text',
value: `You already have a companion! Use /buddy to see it.\n(Tip: /buddy hatch again will re-roll a new one.)`,
}
}
const seed = generateSeed()
const r = rollWithSeed(seed)
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
const personality =
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
const stored: StoredCompanion = {
name,
personality,
seed,
hatchedAt: Date.now(),
}
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
const stars = RARITY_STARS[r.bones.rarity]
const sprite = renderSprite(r.bones, 0)
const shiny = r.bones.shiny ? ' ✨ Shiny!' : ''
const lines = [
' 🎉 A wild companion appeared!',
'',
sprite.join('\n'),
'',
` ${name} the ${speciesLabel(r.bones.species)}${shiny}`,
` Rarity: ${stars} (${r.bones.rarity})`,
` "${personality}"`,
'',
' Your companion will now appear beside your input box!',
]
return { type: 'text', value: lines.join('\n') }
// ── /buddy on — unmute companion ──
if (sub === 'on') {
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
onDone('companion unmuted', { display: 'system' })
return null
}
// /buddy pet — trigger heart animation
// ── /buddy pet — trigger heart animation + auto unmute ──
if (sub === 'pet') {
const companion = getCompanion()
if (!companion) {
return {
type: 'text',
value:
"You don't have a companion yet! Use /buddy hatch to get one.",
}
onDone('no companion yet \u00b7 run /buddy first', { display: 'system' })
return null
}
try {
const { setAppState } = await import('../../state/AppStateStore.js')
setAppState(prev => ({
...prev,
companionPetAt: Date.now(),
}))
} catch {
// non-interactive mode — AppState not available
}
return {
type: 'text',
value: ` ${renderSprite(companion, 0).join('\n')}\n\n ${companion.name} purrs happily! ♥`,
}
}
// /buddy mute
if (sub === 'mute') {
if (config.companionMuted) {
return { type: 'text', value: ' Companion is already muted.' }
}
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: true }))
return { type: 'text', value: ' Companion muted. It will hide quietly. Use /buddy unmute to bring it back.' }
}
// /buddy unmute
if (sub === 'unmute') {
if (!config.companionMuted) {
return { type: 'text', value: ' Companion is not muted.' }
}
// Auto-unmute on pet + trigger heart animation
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
return { type: 'text', value: ' Companion unmuted! Welcome back.' }
setState?.(prev => ({ ...prev, companionPetAt: Date.now() }))
// Trigger a post-pet reaction
triggerCompanionReaction((context as any).messages ?? [], reaction =>
setState?.(prev =>
prev.companionReaction === reaction
? prev
: { ...prev, companionReaction: reaction },
),
)
onDone(`petted ${companion.name}`, { display: 'system' })
return null
}
// /buddy rehatch — re-roll a new companion (replaces existing)
if (sub === 'rehatch') {
const seed = generateSeed()
const r = rollWithSeed(seed)
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
const personality =
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
// ── /buddy (no args) — show existing or hatch ──
const companion = getCompanion()
const stored: StoredCompanion = {
name,
personality,
seed,
hatchedAt: Date.now(),
}
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
const stars = RARITY_STARS[r.bones.rarity]
const sprite = renderSprite(r.bones, 0)
const shiny = r.bones.shiny ? ' ✨ Shiny!' : ''
const lines = [
' 🎉 A new companion appeared!',
'',
sprite.join('\n'),
'',
` ${name} the ${speciesLabel(r.bones.species)}${shiny}`,
` Rarity: ${stars} (${r.bones.rarity})`,
` "${personality}"`,
'',
' Your old companion has been replaced!',
]
return { type: 'text', value: lines.join('\n') }
// Auto-unmute when viewing
if (companion && getGlobalConfig().companionMuted) {
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
}
// Unknown subcommand
return {
type: 'text',
value:
' Unknown command: /buddy ' +
sub +
'\n Commands: /buddy (info) /buddy hatch /buddy rehatch /buddy pet /buddy mute /buddy unmute',
if (companion) {
// Return JSX card — matches official vc8 component
const lastReaction = context.getAppState?.()?.companionReaction
return React.createElement(CompanionCard, {
companion,
lastReaction,
onDone,
})
}
// ── No companion → hatch ──
const seed = generateSeed()
const r = rollWithSeed(seed)
const name = SPECIES_NAMES[r.bones.species] ?? 'Buddy'
const personality =
SPECIES_PERSONALITY[r.bones.species] ?? 'Mysterious and code-savvy.'
const stored: StoredCompanion = {
name,
personality,
seed,
hatchedAt: Date.now(),
}
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
const stars = RARITY_STARS[r.bones.rarity]
const sprite = renderSprite(r.bones, 0)
const shiny = r.bones.shiny ? ' \u2728 Shiny!' : ''
const lines = [
'A wild companion appeared!',
'',
...sprite,
'',
`${name} the ${speciesLabel(r.bones.species)}${shiny}`,
`Rarity: ${stars} (${r.bones.rarity})`,
`"${personality}"`,
'',
'Your companion will now appear beside your input box!',
'Say its name to get its take \u00b7 /buddy pet \u00b7 /buddy off',
]
onDone(lines.join('\n'), { display: 'system' })
return null
}

View File

@@ -1,10 +1,15 @@
import type { Command } from '../../commands.js'
import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
const buddy = {
type: 'local',
type: 'local-jsx',
name: 'buddy',
description: 'View and manage your companion buddy',
supportsNonInteractive: false,
description: 'Hatch a coding companion · pet, off',
argumentHint: '[pet|off]',
immediate: true,
get isHidden() {
return !isBuddyLive()
},
load: () => import('./buddy.js'),
} satisfies Command