diff --git a/src/buddy/CompanionCard.tsx b/src/buddy/CompanionCard.tsx new file mode 100644 index 0000000000..f9264acf3f --- /dev/null +++ b/src/buddy/CompanionCard.tsx @@ -0,0 +1,110 @@ +/** + * 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 clamped = Math.max(0, Math.min(100, value)); + const filled = Math.round(clamped / 10); + const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled); + return ( + + {name.padEnd(10)} {bar} {String(value).padStart(3)} + + ); +} + +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 ( + + {/* Header: rarity + species */} + + + {stars} {companion.rarity.toUpperCase()} + + {companion.species.toUpperCase()} + + + {/* Shiny indicator */} + {companion.shiny && ( + + {'\u2728'} SHINY {'\u2728'} + + )} + + {/* Sprite */} + + {sprite.map((line, i) => ( + + {line} + + ))} + + + {/* Name */} + {companion.name} + + {/* Personality */} + + + "{companion.personality}" + + + + {/* Stats */} + + {STAT_NAMES.map(name => ( + + ))} + + + {/* Last reaction */} + {lastReaction && ( + + last said + + + {lastReaction} + + + + )} + + ); +} diff --git a/src/buddy/companionReact.ts b/src/buddy/companionReact.ts new file mode 100644 index 0000000000..021167e0d6 --- /dev/null +++ b/src/buddy/companionReact.ts @@ -0,0 +1,160 @@ +/** + * 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 + }, + transcript: string, + addressed: boolean, +): Promise { + 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 + + try { + const data = (await resp.json()) as { reaction?: string } + return data.reaction?.trim() || null + } catch { + return null + } +} diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts index ef5057648a..8d14ab4e6a 100644 --- a/src/commands/buddy/buddy.ts +++ b/src/commands/buddy/buddy.ts @@ -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 = { @@ -39,198 +42,128 @@ const SPECIES_PERSONALITY: Record = { 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 { - 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 const call: LocalCommandCall = async (args, _context) => { - const sub = args.trim().toLowerCase() - const config = getGlobalConfig() +export async function call( + onDone: LocalJSXCommandOnDone, + context: ToolUseContext & LocalJSXCommandContext, + args: string, +): Promise { + const sub = args?.trim().toLowerCase() ?? '' + const setState = context.setAppState - // /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! ♥`, - } + // Auto-unmute on pet + trigger heart animation + saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false })) + setState?.(prev => ({ ...prev, companionPetAt: Date.now() })) + + // Trigger a post-pet reaction + triggerCompanionReaction(context.messages ?? [], reaction => + setState?.(prev => + prev.companionReaction === reaction + ? prev + : { ...prev, companionReaction: reaction }, + ), + ) + + onDone(`petted ${companion.name}`, { display: 'system' }) + return null } - // /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 (no args) — show existing or hatch ── + const companion = getCompanion() - // /buddy unmute - if (sub === 'unmute') { - if (!config.companionMuted) { - return { type: 'text', value: ' Companion is not muted.' } - } + // Auto-unmute when viewing + if (companion && getGlobalConfig().companionMuted) { saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false })) - return { type: 'text', value: ' Companion unmuted! Welcome back.' } } - // /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.' - - 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') } + if (companion) { + // Return JSX card — matches official vc8 component + const lastReaction = context.getAppState?.()?.companionReaction + return React.createElement(CompanionCard, { + companion, + lastReaction, + onDone, + }) } - // Unknown subcommand - return { - type: 'text', - value: - ' Unknown command: /buddy ' + - sub + - '\n Commands: /buddy (info) /buddy hatch /buddy rehatch /buddy pet /buddy mute /buddy unmute', + // ── 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 } diff --git a/src/commands/buddy/index.ts b/src/commands/buddy/index.ts index dca9df82d5..8df6830281 100644 --- a/src/commands/buddy/index.ts +++ b/src/commands/buddy/index.ts @@ -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 diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index ce9a5b3c53..34e217ecfd 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -275,6 +275,7 @@ const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/We import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'; import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'; import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js'; +import { triggerCompanionReaction } from '../buddy/companionReact.js'; import { DevBar } from '../components/DevBar.js'; // Session manager removed - using AppState now import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'; @@ -2805,12 +2806,13 @@ export function REPL({ })) { onQueryEvent(event); } - // TODO: implement fireCompanionObserver — companion model reaction after each query turn - if (feature('BUDDY') && typeof fireCompanionObserver === 'function') { - void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : { - ...prev, - companionReaction: reaction as string | undefined - })); + if (feature('BUDDY')) { + triggerCompanionReaction(messagesRef.current, reaction => + setAppState(prev => prev.companionReaction === reaction ? prev : { + ...prev, + companionReaction: reaction as string | undefined, + }) + ); } queryCheckpoint('query_end'); diff --git a/src/state/AppStateStore.ts b/src/state/AppStateStore.ts index fd6526e0f0..14fc845065 100644 --- a/src/state/AppStateStore.ts +++ b/src/state/AppStateStore.ts @@ -165,7 +165,7 @@ export type AppState = DeepImmutable<{ foregroundedTaskId?: string // Task ID of in-process teammate whose transcript is being viewed (undefined = leader's view) viewingAgentTaskId?: string - // Latest companion reaction from the friend observer (src/buddy/observer.ts) + // Latest companion reaction from buddy_react API (src/buddy/companionReact.ts) companionReaction?: string // Timestamp of last /buddy pet — CompanionSprite renders hearts while recent companionPetAt?: number diff --git a/src/types/global.d.ts b/src/types/global.d.ts index bff39705f1..c774e3862b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -28,11 +28,7 @@ declare function getAntModelOverrideConfig(): { [key: string]: unknown } | null -// Companion/buddy observer (internal) -declare function fireCompanionObserver( - messages: unknown[], - callback: (reaction: unknown) => void, -): void +// Companion reactions handled by src/buddy/companionReact.ts (direct import) // Metrics (internal) type ApiMetricEntry = { ttftMs: number; firstTokenTime: number; lastTokenTime: number; responseLengthBaseline: number; endResponseLength: number }