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:
109
src/buddy/CompanionCard.tsx
Normal file
109
src/buddy/CompanionCard.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Companion display card — shown by /buddy (no args).
|
||||
* Mirrors official vc8 component: bordered box with sprite, stats, last reaction.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Box, Text } from '../ink.js';
|
||||
import { useInput } from '../ink.js';
|
||||
import { renderSprite } from './sprites.js';
|
||||
import { RARITY_COLORS, RARITY_STARS, STAT_NAMES, type Companion } from './types.js';
|
||||
|
||||
const CARD_WIDTH = 40;
|
||||
const CARD_PADDING_X = 2;
|
||||
|
||||
function StatBar({ name, value }: { name: string; value: number }) {
|
||||
const filled = Math.round(value / 10);
|
||||
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
|
||||
return (
|
||||
<Text>
|
||||
{name.padEnd(10)} {bar} {String(value).padStart(3)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompanionCard({
|
||||
companion,
|
||||
lastReaction,
|
||||
onDone,
|
||||
}: {
|
||||
companion: Companion;
|
||||
lastReaction?: string;
|
||||
onDone?: (result?: string, options?: { display?: string }) => void;
|
||||
}) {
|
||||
const color = RARITY_COLORS[companion.rarity];
|
||||
const stars = RARITY_STARS[companion.rarity];
|
||||
const sprite = renderSprite(companion, 0);
|
||||
|
||||
// Press any key to dismiss
|
||||
useInput(
|
||||
() => {
|
||||
onDone?.(undefined, { display: 'skip' });
|
||||
},
|
||||
{ isActive: onDone !== undefined },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={color}
|
||||
paddingX={CARD_PADDING_X}
|
||||
paddingY={1}
|
||||
width={CARD_WIDTH}
|
||||
flexShrink={0}
|
||||
>
|
||||
{/* Header: rarity + species */}
|
||||
<Box justifyContent="space-between">
|
||||
<Text bold color={color}>
|
||||
{stars} {companion.rarity.toUpperCase()}
|
||||
</Text>
|
||||
<Text color={color}>{companion.species.toUpperCase()}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Shiny indicator */}
|
||||
{companion.shiny && (
|
||||
<Text color="warning" bold>
|
||||
{'\u2728'} SHINY {'\u2728'}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Sprite */}
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
{sprite.map((line, i) => (
|
||||
<Text key={i} color={color}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Name */}
|
||||
<Text bold>{companion.name}</Text>
|
||||
|
||||
{/* Personality */}
|
||||
<Box marginY={1}>
|
||||
<Text dimColor italic>
|
||||
"{companion.personality}"
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Stats */}
|
||||
<Box flexDirection="column">
|
||||
{STAT_NAMES.map(name => (
|
||||
<StatBar key={name} name={name} value={companion.stats[name] ?? 0} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Last reaction */}
|
||||
{lastReaction && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text dimColor>last said</Text>
|
||||
<Box borderStyle="round" borderColor="inactive" paddingX={1}>
|
||||
<Text dimColor italic>
|
||||
{lastReaction}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
156
src/buddy/companionReact.ts
Normal file
156
src/buddy/companionReact.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Companion reaction system — aligns with official ZUK + Dc8 pattern.
|
||||
*
|
||||
* Called from REPL.tsx after each query turn. Checks mute state, frequency
|
||||
* limits, and @-mention detection, then calls the buddy_react API to
|
||||
* generate a reaction shown in the CompanionSprite speech bubble.
|
||||
*/
|
||||
import { getCompanion } from './companion.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { getUserAgent } from '../utils/http.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
|
||||
// ─── Rate limiting ──────────────────────────────────
|
||||
|
||||
let lastReactTime = 0
|
||||
const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s
|
||||
|
||||
// ─── Recent reactions (avoid repetition) ────────────
|
||||
|
||||
const recentReactions: string[] = []
|
||||
const MAX_RECENT = 8
|
||||
|
||||
// ─── Public API ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Trigger a companion reaction after a query turn.
|
||||
*
|
||||
* Mirrors official `ZUK()`:
|
||||
* 1. Check companion exists and is not muted
|
||||
* 2. Detect if user @-mentioned companion by name
|
||||
* 3. Apply rate limiting (skip if not addressed and too soon)
|
||||
* 4. Build conversation transcript
|
||||
* 5. Call buddy_react API
|
||||
* 6. Pass reaction text to setReaction callback
|
||||
*/
|
||||
export function triggerCompanionReaction(
|
||||
messages: Message[],
|
||||
setReaction: (text: string | undefined) => void,
|
||||
): void {
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return
|
||||
|
||||
const addressed = isAddressed(messages, companion.name)
|
||||
|
||||
const now = Date.now()
|
||||
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
|
||||
|
||||
const transcript = buildTranscript(messages)
|
||||
if (!transcript.trim()) return
|
||||
|
||||
lastReactTime = now
|
||||
|
||||
void callBuddyReactAPI(companion, transcript, addressed)
|
||||
.then(reaction => {
|
||||
if (!reaction) return
|
||||
recentReactions.push(reaction)
|
||||
if (recentReactions.length > MAX_RECENT) recentReactions.shift()
|
||||
setReaction(reaction)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────
|
||||
|
||||
function isAddressed(messages: Message[], name: string): boolean {
|
||||
const pattern = new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')
|
||||
for (
|
||||
let i = messages.length - 1;
|
||||
i >= Math.max(0, messages.length - 3);
|
||||
i--
|
||||
) {
|
||||
const m = messages[i]
|
||||
if (m?.type !== 'user') continue
|
||||
const content = (m as any).message?.content
|
||||
if (typeof content === 'string' && pattern.test(content)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function buildTranscript(messages: Message[]): string {
|
||||
return messages
|
||||
.slice(-12)
|
||||
.filter(m => m.type === 'user' || m.type === 'assistant')
|
||||
.map(m => {
|
||||
const role = m.type === 'user' ? 'user' : 'claude'
|
||||
const content = (m as any).message?.content
|
||||
const text =
|
||||
typeof content === 'string'
|
||||
? content.slice(0, 300)
|
||||
: Array.isArray(content)
|
||||
? content
|
||||
.filter((b: any) => b?.type === 'text')
|
||||
.map((b: any) => b.text)
|
||||
.join(' ')
|
||||
.slice(0, 300)
|
||||
: ''
|
||||
return `${role}: ${text}`
|
||||
})
|
||||
.join('\n')
|
||||
.slice(0, 5000)
|
||||
}
|
||||
|
||||
// ─── API call ───────────────────────────────────────
|
||||
|
||||
async function callBuddyReactAPI(
|
||||
companion: {
|
||||
name: string
|
||||
personality: string
|
||||
species: string
|
||||
rarity: string
|
||||
stats: Record<string, number>
|
||||
},
|
||||
transcript: string,
|
||||
addressed: boolean,
|
||||
): Promise<string | null> {
|
||||
const tokens = getClaudeAIOAuthTokens()
|
||||
if (!tokens?.accessToken) return null
|
||||
|
||||
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
|
||||
if (!orgId) return null
|
||||
|
||||
const baseUrl = getOauthConfig().BASE_API_URL
|
||||
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': getUserAgent(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: companion.name.slice(0, 32),
|
||||
personality: companion.personality.slice(0, 200),
|
||||
species: companion.species,
|
||||
rarity: companion.rarity,
|
||||
stats: companion.stats,
|
||||
transcript,
|
||||
reason: addressed ? 'addressed' : 'turn',
|
||||
recent: recentReactions.map(r => r.slice(0, 200)),
|
||||
addressed,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
|
||||
if (!resp.ok) return null
|
||||
|
||||
const data = (await resp.json()) as { reaction?: string }
|
||||
return data.reaction?.trim() || null
|
||||
}
|
||||
Reference in New Issue
Block a user