mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user