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 }