mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
feat: 第一版可用 pokemon
This commit is contained in:
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* 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 '@anthropic/ink';
|
||||
import { useInput } from '@anthropic/ink';
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,347 +1,288 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import figures from 'figures'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js'
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink'
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js'
|
||||
import type { AppState } from '../state/AppStateStore.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js'
|
||||
import type { Theme } from '../utils/theme.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'
|
||||
import { RARITY_COLORS } from './types.js'
|
||||
import { feature } from 'bun:bundle';
|
||||
import figures from 'figures';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { Box, Text, stringWidth } from '@anthropic/ink';
|
||||
import { useAppState, useSetAppState } from '../state/AppState.js';
|
||||
import type { AppState } from '../state/AppStateStore.js';
|
||||
import { getGlobalConfig } from '../utils/config.js';
|
||||
import { isFullscreenActive } from '../utils/fullscreen.js';
|
||||
import {
|
||||
loadBuddyData,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
loadSprite,
|
||||
getFallbackSprite,
|
||||
renderAnimatedSprite,
|
||||
getIdleAnimMode,
|
||||
SPECIES_DATA,
|
||||
type Creature,
|
||||
type AnimMode,
|
||||
} from '@claude-code-best/pokemon';
|
||||
|
||||
const TICK_MS = 500
|
||||
const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500 // how long hearts float after /buddy pet
|
||||
|
||||
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
|
||||
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
|
||||
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]
|
||||
const TICK_MS = 500;
|
||||
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
|
||||
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
|
||||
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
|
||||
|
||||
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
|
||||
const H = figures.heart
|
||||
const H = figures.heart;
|
||||
const PET_HEARTS = [
|
||||
` ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
` ${H} ${H} ${H} `,
|
||||
`${H} ${H} ${H} `,
|
||||
'· · · ',
|
||||
]
|
||||
];
|
||||
|
||||
function wrap(text: string, width: number): string[] {
|
||||
const words = text.split(' ')
|
||||
const lines: string[] = []
|
||||
let cur = ''
|
||||
const words = text.split(' ');
|
||||
const lines: string[] = [];
|
||||
let cur = '';
|
||||
for (const w of words) {
|
||||
if (cur.length + w.length + 1 > width && cur) {
|
||||
lines.push(cur)
|
||||
cur = w
|
||||
lines.push(cur);
|
||||
cur = w;
|
||||
} else {
|
||||
cur = cur ? `${cur} ${w}` : w
|
||||
cur = cur ? `${cur} ${w}` : w;
|
||||
}
|
||||
}
|
||||
if (cur) lines.push(cur)
|
||||
return lines
|
||||
if (cur) lines.push(cur);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function SpeechBubble({
|
||||
text,
|
||||
color,
|
||||
fading,
|
||||
tail,
|
||||
}: {
|
||||
text: string
|
||||
color: keyof Theme
|
||||
fading: boolean
|
||||
tail: 'down' | 'right'
|
||||
}): React.ReactNode {
|
||||
const lines = wrap(text, 30)
|
||||
const borderColor = fading ? 'inactive' : color
|
||||
function SpeechBubble({ text, fading }: { text: string; fading: boolean; tail: 'down' | 'right' }): React.ReactNode {
|
||||
const lines = wrap(text, 30);
|
||||
const borderColor = fading ? 'inactive' : 'claude';
|
||||
const bubble = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={borderColor}
|
||||
paddingX={1}
|
||||
width={34}
|
||||
>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||
{lines.map((l, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
italic
|
||||
dimColor={!fading}
|
||||
color={fading ? 'inactive' : undefined}
|
||||
>
|
||||
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
if (tail === 'right') {
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
);
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
{bubble}
|
||||
<Text color={borderColor}>─</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// For fullscreen floating bubble
|
||||
function FloatingBubble({ text, fading }: { text: string; fading: boolean }): React.ReactNode {
|
||||
const lines = wrap(text, 30);
|
||||
const borderColor = fading ? 'inactive' : 'claude';
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="flex-end" marginRight={1}>
|
||||
{bubble}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={1} width={34}>
|
||||
{lines.map((l, i) => (
|
||||
<Text key={i} italic dimColor={!fading} color={fading ? 'inactive' : undefined}>
|
||||
{l}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box flexDirection="column" alignItems="flex-end" paddingRight={6}>
|
||||
<Text color={borderColor}>╲ </Text>
|
||||
<Text color={borderColor}>╲</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100
|
||||
const SPRITE_BODY_WIDTH = 12
|
||||
const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name `
|
||||
const SPRITE_PADDING_X = 2
|
||||
const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column
|
||||
const NARROW_QUIP_CAP = 24
|
||||
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
||||
const SPRITE_BODY_WIDTH = 12;
|
||||
const NAME_ROW_PAD = 2;
|
||||
const SPRITE_PADDING_X = 2;
|
||||
const BUBBLE_WIDTH = 36;
|
||||
const NARROW_QUIP_CAP = 24;
|
||||
|
||||
function spriteColWidth(nameWidth: number): number {
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD)
|
||||
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
|
||||
}
|
||||
|
||||
// Width the sprite area consumes. PromptInput subtracts this so text wraps
|
||||
// correctly. In fullscreen the bubble floats over scrollback (no extra
|
||||
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
|
||||
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
||||
// (above input in fullscreen, below in scrollback), so no reservation.
|
||||
export function companionReservedColumns(
|
||||
terminalColumns: number,
|
||||
speaking: boolean,
|
||||
): number {
|
||||
if (!feature('BUDDY')) return 0
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return 0
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0
|
||||
const nameWidth = stringWidth(companion.name)
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble
|
||||
/**
|
||||
* Get active Pokémon creature, or null if buddy system not initialized.
|
||||
*/
|
||||
function getPokemonCreature(): Creature | null {
|
||||
try {
|
||||
const data = loadBuddyData();
|
||||
return getActiveCreature(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
||||
if (!feature('BUDDY')) return 0;
|
||||
const creature = getPokemonCreature();
|
||||
if (!creature || getGlobalConfig().companionMuted) return 0;
|
||||
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
||||
const name = getCreatureName(creature);
|
||||
const nameWidth = stringWidth(name);
|
||||
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
|
||||
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sprite lines for a creature with animated mode applied.
|
||||
*/
|
||||
function getAnimatedSpriteLines(creature: Creature, tick: number, mode: AnimMode): string[] {
|
||||
const cached = loadSprite(creature.speciesId);
|
||||
const baseLines = cached?.lines ?? getFallbackSprite(creature.speciesId);
|
||||
return renderAnimatedSprite(baseLines, tick, mode);
|
||||
}
|
||||
|
||||
export function CompanionSprite(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const petAt = useAppState(s => s.companionPetAt)
|
||||
const focused = useAppState(s => s.footerSelection === 'companion')
|
||||
const setAppState = useSetAppState()
|
||||
const { columns } = useTerminalSize()
|
||||
const [tick, setTick] = useState(0)
|
||||
const lastSpokeTick = useRef(0)
|
||||
// Sync-during-render (not useEffect) so the first post-pet render already
|
||||
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const petAt = useAppState(s => s.companionPetAt);
|
||||
const focused = useAppState(s => s.footerSelection === 'companion');
|
||||
const setAppState = useSetAppState();
|
||||
const { columns } = useTerminalSize();
|
||||
const [tick, setTick] = useState(0);
|
||||
const lastSpokeTick = useRef(0);
|
||||
const [{ petStartTick, forPetAt }, setPetStart] = useState({
|
||||
petStartTick: 0,
|
||||
forPetAt: petAt,
|
||||
})
|
||||
});
|
||||
if (petAt !== forPetAt) {
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt })
|
||||
setPetStart({ petStartTick: tick, forPetAt: petAt });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(
|
||||
setT => setT((t: number) => t + 1),
|
||||
(setT: React.Dispatch<React.SetStateAction<number>>) => setT((t: number) => t + 1),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
lastSpokeTick.current = tick
|
||||
if (!reaction) return;
|
||||
lastSpokeTick.current = tick;
|
||||
const timer = setTimeout(
|
||||
setA =>
|
||||
(setA: React.Dispatch<React.SetStateAction<AppState>>) =>
|
||||
setA((prev: AppState) =>
|
||||
prev.companionReaction === undefined
|
||||
? prev
|
||||
: { ...prev, companionReaction: undefined },
|
||||
prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined },
|
||||
),
|
||||
BUBBLE_SHOW * TICK_MS,
|
||||
setAppState,
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
||||
}, [reaction, setAppState])
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reaction, setAppState]);
|
||||
|
||||
if (!feature('BUDDY')) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY')) return null;
|
||||
const creature = getPokemonCreature();
|
||||
if (!creature || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
const color = RARITY_COLORS[companion.rarity]
|
||||
const colWidth = spriteColWidth(stringWidth(companion.name))
|
||||
const species = SPECIES_DATA[creature.speciesId];
|
||||
const name = getCreatureName(creature);
|
||||
const color = creature.isShiny ? 'warning' : 'claude';
|
||||
const colWidth = spriteColWidth(stringWidth(name));
|
||||
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0
|
||||
const fading =
|
||||
reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW
|
||||
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
||||
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
||||
|
||||
const petAge = petAt ? tick - petStartTick : Infinity
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS
|
||||
const petAge = petAt ? tick - petStartTick : Infinity;
|
||||
const petting = petAge * TICK_MS < PET_BURST_MS;
|
||||
|
||||
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
||||
// replaces the name beside the face (no room for a bubble).
|
||||
// Narrow terminals: collapse to one-line face
|
||||
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
||||
const quip =
|
||||
reaction && reaction.length > NARROW_QUIP_CAP
|
||||
? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…'
|
||||
: reaction
|
||||
const label = quip
|
||||
? `"${quip}"`
|
||||
: focused
|
||||
? ` ${companion.name} `
|
||||
: companion.name
|
||||
reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
||||
const label = quip ? `"${quip}"` : focused ? ` ${name} ` : name;
|
||||
return (
|
||||
<Box paddingX={1} alignSelf="flex-end">
|
||||
<Text>
|
||||
{petting && <Text color="autoAccept">{figures.heart} </Text>}
|
||||
<Text bold color={color}>
|
||||
{renderFace(companion)}
|
||||
{species.names.zh ?? species.name}
|
||||
</Text>{' '}
|
||||
<Text
|
||||
italic
|
||||
dimColor={!focused && !reaction}
|
||||
bold={focused}
|
||||
inverse={focused && !reaction}
|
||||
color={
|
||||
reaction
|
||||
? fading
|
||||
? 'inactive'
|
||||
: color
|
||||
: focused
|
||||
? color
|
||||
: undefined
|
||||
}
|
||||
color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
const frameCount = spriteFrameCount(companion.species)
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null
|
||||
|
||||
let spriteFrame: number
|
||||
let blink = false
|
||||
// Determine animation mode
|
||||
let animMode: AnimMode = 'idle';
|
||||
if (reaction || petting) {
|
||||
// Excited: cycle all fidget frames fast
|
||||
spriteFrame = tick % frameCount
|
||||
animMode = 'excited';
|
||||
} else {
|
||||
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!
|
||||
if (step === -1) {
|
||||
spriteFrame = 0
|
||||
blink = true
|
||||
} else {
|
||||
spriteFrame = step % frameCount
|
||||
}
|
||||
animMode = getIdleAnimMode(tick);
|
||||
if (petting) animMode = 'pet';
|
||||
}
|
||||
|
||||
const body = renderSprite(companion, spriteFrame).map(line =>
|
||||
blink ? line.replaceAll(companion.eye, '-') : line,
|
||||
)
|
||||
const sprite = heartFrame ? [heartFrame, ...body] : body
|
||||
const spriteLines = getAnimatedSpriteLines(creature, tick, animMode);
|
||||
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
||||
const displayLines = heartFrame ? [heartFrame, ...spriteLines] : spriteLines;
|
||||
|
||||
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
|
||||
// focused shows inverse name. The enter-to-open hint lives in
|
||||
// PromptInputFooter's right column so this row stays one line and the
|
||||
// sprite doesn't jump up when selected. flexShrink=0 stops the
|
||||
// inline-bubble row wrapper from squeezing the sprite to fit.
|
||||
const spriteColumn = (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
width={colWidth}
|
||||
>
|
||||
{sprite.map((line, i) => (
|
||||
<Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
||||
{displayLines.map((line, i) => (
|
||||
<Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
<Text
|
||||
italic
|
||||
bold={focused}
|
||||
dimColor={!focused}
|
||||
color={focused ? color : undefined}
|
||||
inverse={focused}
|
||||
>
|
||||
{focused ? ` ${companion.name} ` : companion.name}
|
||||
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
||||
{focused ? ` ${name} ` : name}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
if (!reaction) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
|
||||
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
||||
// FullscreenLayout's bottomFloat slot (the bottom slot's overflowY:hidden
|
||||
// would clip a position:absolute overlay here). Sprite body only.
|
||||
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
|
||||
// because floating into Static scrollback can't be cleared.
|
||||
if (isFullscreenActive()) {
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>
|
||||
return <Box paddingX={1}>{spriteColumn}</Box>;
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
||||
<SpeechBubble
|
||||
text={reaction}
|
||||
color={color}
|
||||
fading={fading}
|
||||
tail="right"
|
||||
/>
|
||||
<SpeechBubble text={reaction} fading={fading} tail="right" />
|
||||
{spriteColumn}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
||||
// bottomFloat slot (outside the overflowY:hidden clip) so it can extend into
|
||||
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
|
||||
// just reads companionReaction and renders the fade.
|
||||
// Floating bubble overlay for fullscreen mode
|
||||
export function CompanionFloatingBubble(): React.ReactNode {
|
||||
const reaction = useAppState(s => s.companionReaction)
|
||||
const reaction = useAppState(s => s.companionReaction);
|
||||
const [{ tick, forReaction }, setTick] = useState({
|
||||
tick: 0,
|
||||
forReaction: reaction,
|
||||
})
|
||||
});
|
||||
|
||||
// Reset tick synchronously when reaction changes (not in useEffect, which
|
||||
// runs post-render and would show one stale-faded frame). Storing the
|
||||
// reaction the tick is counting FOR alongside the tick itself means the
|
||||
// fade computation never sees a tick from a previous reaction.
|
||||
if (reaction !== forReaction) {
|
||||
setTick({ tick: 0, forReaction: reaction })
|
||||
setTick({ tick: 0, forReaction: reaction });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!reaction) return
|
||||
if (!reaction) return;
|
||||
const timer = setInterval(
|
||||
set => set(s => ({ ...s, tick: s.tick + 1 })),
|
||||
(set: React.Dispatch<React.SetStateAction<{ tick: number; forReaction: string | undefined }>>) =>
|
||||
set(s => ({ ...s, tick: s.tick + 1 })),
|
||||
TICK_MS,
|
||||
setTick,
|
||||
)
|
||||
return () => clearInterval(timer)
|
||||
}, [reaction])
|
||||
);
|
||||
return () => clearInterval(timer);
|
||||
}, [reaction]);
|
||||
|
||||
if (!feature('BUDDY') || !reaction) return null
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return null
|
||||
if (!feature('BUDDY') || !reaction) return null;
|
||||
const creature = getPokemonCreature();
|
||||
if (!creature || getGlobalConfig().companionMuted) return null;
|
||||
|
||||
return (
|
||||
<SpeechBubble
|
||||
text={reaction}
|
||||
color={RARITY_COLORS[companion.rarity]}
|
||||
fading={tick >= BUBBLE_SHOW - FADE_WINDOW}
|
||||
tail="down"
|
||||
/>
|
||||
)
|
||||
return <FloatingBubble text={reaction} fading={tick >= BUBBLE_SHOW - FADE_WINDOW} />;
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import {
|
||||
type Companion,
|
||||
type CompanionBones,
|
||||
EYES,
|
||||
HATS,
|
||||
RARITIES,
|
||||
RARITY_WEIGHTS,
|
||||
type Rarity,
|
||||
SPECIES,
|
||||
STAT_NAMES,
|
||||
type StatName,
|
||||
} from './types.js'
|
||||
|
||||
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0
|
||||
return function () {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function hashString(s: string): number {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
|
||||
}
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
}
|
||||
return h >>> 0
|
||||
}
|
||||
|
||||
function pick<T>(rng: () => number, arr: readonly T[]): T {
|
||||
return arr[Math.floor(rng() * arr.length)]!
|
||||
}
|
||||
|
||||
function rollRarity(rng: () => number): Rarity {
|
||||
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
|
||||
let roll = rng() * total
|
||||
for (const rarity of RARITIES) {
|
||||
roll -= RARITY_WEIGHTS[rarity]
|
||||
if (roll < 0) return rarity
|
||||
}
|
||||
return 'common'
|
||||
}
|
||||
|
||||
const RARITY_FLOOR: Record<Rarity, number> = {
|
||||
common: 5,
|
||||
uncommon: 15,
|
||||
rare: 25,
|
||||
epic: 35,
|
||||
legendary: 50,
|
||||
}
|
||||
|
||||
// One peak stat, one dump stat, rest scattered. Rarity bumps the floor.
|
||||
function rollStats(
|
||||
rng: () => number,
|
||||
rarity: Rarity,
|
||||
): Record<StatName, number> {
|
||||
const floor = RARITY_FLOOR[rarity]
|
||||
const peak = pick(rng, STAT_NAMES)
|
||||
let dump = pick(rng, STAT_NAMES)
|
||||
while (dump === peak) dump = pick(rng, STAT_NAMES)
|
||||
|
||||
const stats = {} as Record<StatName, number>
|
||||
for (const name of STAT_NAMES) {
|
||||
if (name === peak) {
|
||||
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
|
||||
} else if (name === dump) {
|
||||
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
|
||||
} else {
|
||||
stats[name] = floor + Math.floor(rng() * 40)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
const SALT = 'friend-2026-401'
|
||||
|
||||
export type Roll = {
|
||||
bones: CompanionBones
|
||||
inspirationSeed: number
|
||||
}
|
||||
|
||||
function rollFrom(rng: () => number): Roll {
|
||||
const rarity = rollRarity(rng)
|
||||
const bones: CompanionBones = {
|
||||
rarity,
|
||||
species: pick(rng, SPECIES),
|
||||
eye: pick(rng, EYES),
|
||||
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
|
||||
shiny: rng() < 0.01,
|
||||
stats: rollStats(rng, rarity),
|
||||
}
|
||||
return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
|
||||
}
|
||||
|
||||
// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
|
||||
// per-turn observer) with the same userId → cache the deterministic result.
|
||||
let rollCache: { key: string; value: Roll } | undefined
|
||||
export function roll(userId: string): Roll {
|
||||
const key = userId + SALT
|
||||
if (rollCache?.key === key) return rollCache.value
|
||||
const value = rollFrom(mulberry32(hashString(key)))
|
||||
rollCache = { key, value }
|
||||
return value
|
||||
}
|
||||
|
||||
export function rollWithSeed(seed: string): Roll {
|
||||
return rollFrom(mulberry32(hashString(seed)))
|
||||
}
|
||||
|
||||
export function generateSeed(): string {
|
||||
return `rehatch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function companionUserId(): string {
|
||||
const config = getGlobalConfig()
|
||||
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
|
||||
}
|
||||
|
||||
// Regenerate bones from seed or userId, merge with stored soul.
|
||||
export function getCompanion(): Companion | undefined {
|
||||
const stored = getGlobalConfig().companion
|
||||
if (!stored) return undefined
|
||||
const seed = stored.seed ?? companionUserId()
|
||||
const { bones } = rollWithSeed(seed)
|
||||
// bones last so stale bones fields in old-format configs get overridden
|
||||
return { ...stored, ...bones }
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
/**
|
||||
* Companion reaction system — aligns with official ZUK + Dc8 pattern.
|
||||
* Companion reaction system — adapted for Pokémon buddy system.
|
||||
*
|
||||
* 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'
|
||||
import {
|
||||
loadBuddyData,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
calculateStats,
|
||||
SPECIES_DATA,
|
||||
type Creature,
|
||||
} from '@claude-code-best/pokemon'
|
||||
|
||||
// ─── Rate limiting ──────────────────────────────────
|
||||
|
||||
let lastReactTime = 0
|
||||
const MIN_INTERVAL_MS = 45_000 // official is roughly 30-60s
|
||||
const MIN_INTERVAL_MS = 45_000
|
||||
|
||||
// ─── Recent reactions (avoid repetition) ────────────
|
||||
|
||||
@@ -26,23 +33,17 @@ const MAX_RECENT = 8
|
||||
|
||||
/**
|
||||
* 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 data = loadBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature || getGlobalConfig().companionMuted) return
|
||||
|
||||
const addressed = isAddressed(messages, companion.name)
|
||||
const name = getCreatureName(creature)
|
||||
const addressed = isAddressed(messages, name)
|
||||
|
||||
const now = Date.now()
|
||||
if (!addressed && now - lastReactTime < MIN_INTERVAL_MS) return
|
||||
@@ -52,7 +53,7 @@ export function triggerCompanionReaction(
|
||||
|
||||
lastReactTime = now
|
||||
|
||||
void callBuddyReactAPI(companion, transcript, addressed)
|
||||
void callBuddyReactAPI(creature, transcript, addressed)
|
||||
.then(reaction => {
|
||||
if (!reaction) return
|
||||
recentReactions.push(reaction)
|
||||
@@ -109,13 +110,7 @@ function buildTranscript(messages: Message[]): string {
|
||||
// ─── API call ───────────────────────────────────────
|
||||
|
||||
async function callBuddyReactAPI(
|
||||
companion: {
|
||||
name: string
|
||||
personality: string
|
||||
species: string
|
||||
rarity: string
|
||||
stats: Record<string, number>
|
||||
},
|
||||
creature: Creature,
|
||||
transcript: string,
|
||||
addressed: boolean,
|
||||
): Promise<string | null> {
|
||||
@@ -125,6 +120,10 @@ async function callBuddyReactAPI(
|
||||
const orgId = getGlobalConfig().oauthAccount?.organizationUuid
|
||||
if (!orgId) return null
|
||||
|
||||
const species = SPECIES_DATA[creature.speciesId]
|
||||
const name = getCreatureName(creature)
|
||||
const stats = calculateStats(creature)
|
||||
|
||||
const baseUrl = getOauthConfig().BASE_API_URL
|
||||
const url = `${baseUrl}/api/organizations/${orgId}/claude_code/buddy_react`
|
||||
|
||||
@@ -136,11 +135,25 @@ async function callBuddyReactAPI(
|
||||
'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,
|
||||
name: name.slice(0, 32),
|
||||
personality: species.personality.slice(0, 200),
|
||||
species: creature.speciesId,
|
||||
rarity: creature.isShiny
|
||||
? 'legendary'
|
||||
: creature.level >= 36
|
||||
? 'epic'
|
||||
: creature.level >= 16
|
||||
? 'rare'
|
||||
: 'common',
|
||||
stats: {
|
||||
HP: stats.hp,
|
||||
ATK: stats.attack,
|
||||
DEF: stats.defense,
|
||||
SPA: stats.spAtk,
|
||||
SPD: stats.spDef,
|
||||
SPE: stats.speed,
|
||||
Level: creature.level,
|
||||
},
|
||||
transcript,
|
||||
reason: addressed ? 'addressed' : 'turn',
|
||||
recent: recentReactions.map(r => r.slice(0, 200)),
|
||||
|
||||
@@ -2,12 +2,17 @@ import { feature } from 'bun:bundle'
|
||||
import type { Message } from '../types/message.js'
|
||||
import type { Attachment } from '../utils/attachments.js'
|
||||
import { getGlobalConfig } from '../utils/config.js'
|
||||
import { getCompanion } from './companion.js'
|
||||
import {
|
||||
loadBuddyData,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
SPECIES_DATA,
|
||||
} from '@claude-code-best/pokemon'
|
||||
|
||||
export function companionIntroText(name: string, species: string): string {
|
||||
return `# Companion
|
||||
|
||||
A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
|
||||
A ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
|
||||
|
||||
When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
|
||||
}
|
||||
@@ -16,21 +21,25 @@ export function getCompanionIntroAttachment(
|
||||
messages: Message[] | undefined,
|
||||
): Attachment[] {
|
||||
if (!feature('BUDDY')) return []
|
||||
const companion = getCompanion()
|
||||
if (!companion || getGlobalConfig().companionMuted) return []
|
||||
const data = loadBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature || getGlobalConfig().companionMuted) return []
|
||||
|
||||
const name = getCreatureName(creature)
|
||||
const species = SPECIES_DATA[creature.speciesId]
|
||||
|
||||
// Skip if already announced for this companion.
|
||||
for (const msg of messages ?? []) {
|
||||
if (msg.type !== 'attachment') continue
|
||||
if (msg.attachment!.type !== 'companion_intro') continue
|
||||
if (msg.attachment!.name === companion.name) return []
|
||||
if ((msg as any).attachment?.type !== 'companion_intro') continue
|
||||
if ((msg as any).attachment?.name === name) return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'companion_intro',
|
||||
name: companion.name,
|
||||
species: companion.species,
|
||||
name,
|
||||
species: species.names.zh ?? species.name,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
import type { CompanionBones, Eye, Hat, Species } from './types.js'
|
||||
import {
|
||||
axolotl,
|
||||
blob,
|
||||
cactus,
|
||||
capybara,
|
||||
cat,
|
||||
chonk,
|
||||
dragon,
|
||||
duck,
|
||||
ghost,
|
||||
goose,
|
||||
mushroom,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
rabbit,
|
||||
robot,
|
||||
snail,
|
||||
turtle,
|
||||
} from './types.js'
|
||||
|
||||
// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution).
|
||||
// Multiple frames per species for idle fidget animation.
|
||||
// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it.
|
||||
const BODIES: Record<Species, string[][]> = {
|
||||
[duck]: [
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( ._> ',
|
||||
' `--´~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' __ ',
|
||||
' <({E} )___ ',
|
||||
' ( .__> ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[goose]: [
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ({E}>> ',
|
||||
' || ',
|
||||
' _(__)_ ',
|
||||
' ^^^^ ',
|
||||
],
|
||||
],
|
||||
[blob]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .------. ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .--. ',
|
||||
' ({E} {E}) ',
|
||||
' ( ) ',
|
||||
' `--´ ',
|
||||
],
|
||||
],
|
||||
[cat]: [
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\_/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(")~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\-/\\ ',
|
||||
' ( {E} {E}) ',
|
||||
' ( ω ) ',
|
||||
' (")_(") ',
|
||||
],
|
||||
],
|
||||
[dragon]: [
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' /^\\ /^\\ ',
|
||||
' < {E} {E} > ',
|
||||
' ( ~~ ) ',
|
||||
' `-vvvv-´ ',
|
||||
],
|
||||
],
|
||||
[octopus]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' \\/\\/\\/\\/ ',
|
||||
],
|
||||
[
|
||||
' o ',
|
||||
' .----. ',
|
||||
' ( {E} {E} ) ',
|
||||
' (______) ',
|
||||
' /\\/\\/\\/\\ ',
|
||||
],
|
||||
],
|
||||
[owl]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})({E})) ',
|
||||
' ( >< ) ',
|
||||
' .----. ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' (({E})(-)) ',
|
||||
' ( >< ) ',
|
||||
' `----´ ',
|
||||
],
|
||||
],
|
||||
[penguin]: [
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' |( )| ',
|
||||
' `---´ ',
|
||||
],
|
||||
[
|
||||
' .---. ',
|
||||
' ({E}>{E}) ',
|
||||
' /( )\\ ',
|
||||
' `---´ ',
|
||||
' ~ ~ ',
|
||||
],
|
||||
],
|
||||
[turtle]: [
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[______]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' _,--._ ',
|
||||
' ( {E} {E} ) ',
|
||||
' /[======]\\ ',
|
||||
' `` `` ',
|
||||
],
|
||||
],
|
||||
[snail]: [
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' | ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' {E} .--. ',
|
||||
' \\ ( @ ) ',
|
||||
' \\_`--´ ',
|
||||
' ~~~~~~ ',
|
||||
],
|
||||
],
|
||||
[ghost]: [
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~`~``~`~ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' `~`~~`~` ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' .----. ',
|
||||
' / {E} {E} \\ ',
|
||||
' | | ',
|
||||
' ~~`~~`~~ ',
|
||||
],
|
||||
],
|
||||
[axolotl]: [
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'~}(______){~',
|
||||
'~}({E} .. {E}){~',
|
||||
' ( .--. ) ',
|
||||
' (_/ \\_) ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
'}~(______)~{',
|
||||
'}~({E} .. {E})~{',
|
||||
' ( -- ) ',
|
||||
' ~_/ \\_~ ',
|
||||
],
|
||||
],
|
||||
[capybara]: [
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' n______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( Oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ~ ~ ',
|
||||
' u______n ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( oo ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[cactus]: [
|
||||
[
|
||||
' ',
|
||||
' n ____ n ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' ____ ',
|
||||
' n |{E} {E}| n ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
[
|
||||
' n n ',
|
||||
' | ____ | ',
|
||||
' | |{E} {E}| | ',
|
||||
' |_| |_| ',
|
||||
' | | ',
|
||||
],
|
||||
],
|
||||
[robot]: [
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ -==- ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' * ',
|
||||
' .[||]. ',
|
||||
' [ {E} {E} ] ',
|
||||
' [ ==== ] ',
|
||||
' `------´ ',
|
||||
],
|
||||
],
|
||||
[rabbit]: [
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (|__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( .. )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' (\\__/) ',
|
||||
' ( {E} {E} ) ',
|
||||
' =( . . )= ',
|
||||
' (")__(") ',
|
||||
],
|
||||
],
|
||||
[mushroom]: [
|
||||
[
|
||||
' ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' .-O-oo-O-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
[
|
||||
' . o . ',
|
||||
' .-o-OO-o-. ',
|
||||
'(__________)',
|
||||
' |{E} {E}| ',
|
||||
' |____| ',
|
||||
],
|
||||
],
|
||||
[chonk]: [
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /| ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´ ',
|
||||
],
|
||||
[
|
||||
' ',
|
||||
' /\\ /\\ ',
|
||||
' ( {E} {E} ) ',
|
||||
' ( .. ) ',
|
||||
' `------´~ ',
|
||||
],
|
||||
],
|
||||
}
|
||||
|
||||
const HAT_LINES: Record<Hat, string> = {
|
||||
none: '',
|
||||
crown: ' \\^^^/ ',
|
||||
tophat: ' [___] ',
|
||||
propeller: ' -+- ',
|
||||
halo: ' ( ) ',
|
||||
wizard: ' /^\\ ',
|
||||
beanie: ' (___) ',
|
||||
tinyduck: ' ,> ',
|
||||
}
|
||||
|
||||
export function renderSprite(bones: CompanionBones, frame = 0): string[] {
|
||||
const frames = BODIES[bones.species]
|
||||
const body = frames[frame % frames.length]!.map(line =>
|
||||
line.replaceAll('{E}', bones.eye),
|
||||
)
|
||||
const lines = [...body]
|
||||
// Only replace with hat if line 0 is empty (some fidget frames use it for smoke etc)
|
||||
if (bones.hat !== 'none' && !lines[0]!.trim()) {
|
||||
lines[0] = HAT_LINES[bones.hat]
|
||||
}
|
||||
// Drop blank hat slot — wastes a row in the Card and ambient sprite when
|
||||
// there's no hat and the frame isn't using it for smoke/antenna/etc.
|
||||
// Only safe when ALL frames have blank line 0; otherwise heights oscillate.
|
||||
if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
|
||||
return lines
|
||||
}
|
||||
|
||||
export function spriteFrameCount(species: Species): number {
|
||||
return BODIES[species].length
|
||||
}
|
||||
|
||||
export function renderFace(bones: CompanionBones): string {
|
||||
const eye: Eye = bones.eye
|
||||
switch (bones.species) {
|
||||
case duck:
|
||||
case goose:
|
||||
return `(${eye}>`
|
||||
case blob:
|
||||
return `(${eye}${eye})`
|
||||
case cat:
|
||||
return `=${eye}ω${eye}=`
|
||||
case dragon:
|
||||
return `<${eye}~${eye}>`
|
||||
case octopus:
|
||||
return `~(${eye}${eye})~`
|
||||
case owl:
|
||||
return `(${eye})(${eye})`
|
||||
case penguin:
|
||||
return `(${eye}>)`
|
||||
case turtle:
|
||||
return `[${eye}_${eye}]`
|
||||
case snail:
|
||||
return `${eye}(@)`
|
||||
case ghost:
|
||||
return `/${eye}${eye}\\`
|
||||
case axolotl:
|
||||
return `}${eye}.${eye}{`
|
||||
case capybara:
|
||||
return `(${eye}oo${eye})`
|
||||
case cactus:
|
||||
return `|${eye} ${eye}|`
|
||||
case robot:
|
||||
return `[${eye}${eye}]`
|
||||
case rabbit:
|
||||
return `(${eye}..${eye})`
|
||||
case mushroom:
|
||||
return `|${eye} ${eye}|`
|
||||
case chonk:
|
||||
return `(${eye}.${eye})`
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
export const RARITIES = [
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
] as const
|
||||
export type Rarity = (typeof RARITIES)[number]
|
||||
|
||||
// One species name collides with a model-codename canary in excluded-strings.txt.
|
||||
// The check greps build output (not source), so runtime-constructing the value keeps
|
||||
// the literal out of the bundle while the check stays armed for the actual codename.
|
||||
// All species encoded uniformly; `as` casts are type-position only (erased pre-bundle).
|
||||
const c = String.fromCharCode
|
||||
// biome-ignore format: keep the species list compact
|
||||
|
||||
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
|
||||
export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose'
|
||||
export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob'
|
||||
export const cat = c(0x63, 0x61, 0x74) as 'cat'
|
||||
export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon'
|
||||
export const octopus = c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73) as 'octopus'
|
||||
export const owl = c(0x6f, 0x77, 0x6c) as 'owl'
|
||||
export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin'
|
||||
export const turtle = c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65) as 'turtle'
|
||||
export const snail = c(0x73, 0x6e, 0x61, 0x69, 0x6c) as 'snail'
|
||||
export const ghost = c(0x67, 0x68, 0x6f, 0x73, 0x74) as 'ghost'
|
||||
export const axolotl = c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c) as 'axolotl'
|
||||
export const capybara = c(
|
||||
0x63,
|
||||
0x61,
|
||||
0x70,
|
||||
0x79,
|
||||
0x62,
|
||||
0x61,
|
||||
0x72,
|
||||
0x61,
|
||||
) as 'capybara'
|
||||
export const cactus = c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73) as 'cactus'
|
||||
export const robot = c(0x72, 0x6f, 0x62, 0x6f, 0x74) as 'robot'
|
||||
export const rabbit = c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74) as 'rabbit'
|
||||
export const mushroom = c(
|
||||
0x6d,
|
||||
0x75,
|
||||
0x73,
|
||||
0x68,
|
||||
0x72,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x6d,
|
||||
) as 'mushroom'
|
||||
export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk'
|
||||
|
||||
export const SPECIES = [
|
||||
duck,
|
||||
goose,
|
||||
blob,
|
||||
cat,
|
||||
dragon,
|
||||
octopus,
|
||||
owl,
|
||||
penguin,
|
||||
turtle,
|
||||
snail,
|
||||
ghost,
|
||||
axolotl,
|
||||
capybara,
|
||||
cactus,
|
||||
robot,
|
||||
rabbit,
|
||||
mushroom,
|
||||
chonk,
|
||||
] as const
|
||||
export type Species = (typeof SPECIES)[number] // biome-ignore format: keep compact
|
||||
|
||||
export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
|
||||
export type Eye = (typeof EYES)[number]
|
||||
|
||||
export const HATS = [
|
||||
'none',
|
||||
'crown',
|
||||
'tophat',
|
||||
'propeller',
|
||||
'halo',
|
||||
'wizard',
|
||||
'beanie',
|
||||
'tinyduck',
|
||||
] as const
|
||||
export type Hat = (typeof HATS)[number]
|
||||
|
||||
export const STAT_NAMES = [
|
||||
'DEBUGGING',
|
||||
'PATIENCE',
|
||||
'CHAOS',
|
||||
'WISDOM',
|
||||
'SNARK',
|
||||
] as const
|
||||
export type StatName = (typeof STAT_NAMES)[number]
|
||||
|
||||
// Deterministic parts — derived from hash(userId)
|
||||
export type CompanionBones = {
|
||||
rarity: Rarity
|
||||
species: Species
|
||||
eye: Eye
|
||||
hat: Hat
|
||||
shiny: boolean
|
||||
stats: Record<StatName, number>
|
||||
}
|
||||
|
||||
// Model-generated soul — stored in config after first hatch
|
||||
export type CompanionSoul = {
|
||||
name: string
|
||||
personality: string
|
||||
seed?: string
|
||||
}
|
||||
|
||||
export type Companion = CompanionBones &
|
||||
CompanionSoul & {
|
||||
hatchedAt: number
|
||||
}
|
||||
|
||||
// What actually persists in config. Bones are regenerated from hash(userId)
|
||||
// on every read so species renames don't break stored companions and users
|
||||
// can't edit their way to a legendary.
|
||||
export type StoredCompanion = CompanionSoul & { hatchedAt: number }
|
||||
|
||||
export const RARITY_WEIGHTS = {
|
||||
common: 60,
|
||||
uncommon: 25,
|
||||
rare: 10,
|
||||
epic: 4,
|
||||
legendary: 1,
|
||||
} as const satisfies Record<Rarity, number>
|
||||
|
||||
export const RARITY_STARS = {
|
||||
common: '★',
|
||||
uncommon: '★★',
|
||||
rare: '★★★',
|
||||
epic: '★★★★',
|
||||
legendary: '★★★★★',
|
||||
} as const satisfies Record<Rarity, string>
|
||||
|
||||
export const RARITY_COLORS = {
|
||||
common: 'inactive',
|
||||
uncommon: 'success',
|
||||
rare: 'permission',
|
||||
epic: 'autoAccept',
|
||||
legendary: 'warning',
|
||||
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
||||
403
src/commands/buddy/BuddyPanel.tsx
Normal file
403
src/commands/buddy/BuddyPanel.tsx
Normal file
@@ -0,0 +1,403 @@
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text, Pane, Tab, Tabs, type Color } from '@anthropic/ink';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import {
|
||||
STAT_NAMES,
|
||||
STAT_LABELS,
|
||||
ALL_SPECIES_IDS,
|
||||
type BuddyData,
|
||||
type Creature,
|
||||
type SpeciesId,
|
||||
} from '@claude-code-best/pokemon';
|
||||
import { SPECIES_DATA } from '@claude-code-best/pokemon';
|
||||
import { SPECIES_PERSONALITY } from '@claude-code-best/pokemon';
|
||||
import { getNextEvolution } from '@claude-code-best/pokemon';
|
||||
import { calculateStats, getCreatureName, getTotalEV, getActiveCreature } from '@claude-code-best/pokemon';
|
||||
import { getXpProgress } from '@claude-code-best/pokemon';
|
||||
import { getEVSummary } from '@claude-code-best/pokemon';
|
||||
import { getGenderSymbol } from '@claude-code-best/pokemon';
|
||||
import { StatBar } from '@claude-code-best/pokemon';
|
||||
import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
|
||||
const CYAN: Color = 'ansi:cyan';
|
||||
const YELLOW: Color = 'ansi:yellow';
|
||||
const GREEN: Color = 'ansi:green';
|
||||
const BLUE: Color = 'ansi:blue';
|
||||
const RED: Color = 'ansi:red';
|
||||
const MAGENTA: Color = 'ansi:magenta';
|
||||
const WHITE: Color = 'ansi:whiteBright';
|
||||
const GRAY: Color = 'ansi:white';
|
||||
|
||||
const TYPE_COLORS: Record<string, Color> = {
|
||||
grass: 'ansi:green',
|
||||
poison: 'ansi:magenta',
|
||||
fire: 'ansi:red',
|
||||
flying: 'ansi:cyan',
|
||||
water: 'ansi:blue',
|
||||
electric: 'ansi:yellow',
|
||||
normal: 'ansi:white',
|
||||
};
|
||||
|
||||
interface BuddyPanelProps {
|
||||
buddyData: BuddyData;
|
||||
spriteLines?: string[];
|
||||
onClose: LocalJSXCommandOnDone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified buddy panel with tabs — same pattern as Settings.
|
||||
* ESC closes, ←/→ switch tabs, Ctrl+C/D double-press exits.
|
||||
*/
|
||||
export function BuddyPanel({ buddyData, spriteLines, onClose }: BuddyPanelProps) {
|
||||
const [selectedTab, setSelectedTab] = useState('Buddy');
|
||||
|
||||
useExitOnCtrlCDWithKeybindings();
|
||||
|
||||
const handleEscape = () => {
|
||||
onClose('buddy panel closed');
|
||||
};
|
||||
|
||||
useKeybinding('confirm:no', handleEscape, {
|
||||
context: 'Settings',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const creature = getActiveCreature(buddyData);
|
||||
|
||||
const tabs = [
|
||||
<Tab key="buddy" title="Buddy">
|
||||
{creature ? (
|
||||
<BuddyTab creature={creature} buddyData={buddyData} spriteLines={spriteLines} />
|
||||
) : (
|
||||
<Text color={GRAY}>No buddy yet. Keep coding!</Text>
|
||||
)}
|
||||
</Tab>,
|
||||
<Tab key="dex" title="Pokédex">
|
||||
<DexTab buddyData={buddyData} />
|
||||
</Tab>,
|
||||
<Tab key="egg" title="Egg">
|
||||
<EggTab buddyData={buddyData} />
|
||||
</Tab>,
|
||||
];
|
||||
|
||||
return (
|
||||
<Pane color="permission">
|
||||
<Tabs color="permission" selectedTab={selectedTab} onTabChange={setSelectedTab} initialHeaderFocused={true}>
|
||||
{tabs}
|
||||
</Tabs>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Buddy Tab ────────────────────────────────────────
|
||||
|
||||
function BuddyTab({
|
||||
creature,
|
||||
buddyData,
|
||||
spriteLines,
|
||||
}: {
|
||||
creature: Creature;
|
||||
buddyData: BuddyData;
|
||||
spriteLines?: string[];
|
||||
}) {
|
||||
const species = SPECIES_DATA[creature.speciesId];
|
||||
const stats = calculateStats(creature);
|
||||
const xp = getXpProgress(creature);
|
||||
const genderSymbol = getGenderSymbol(creature.gender);
|
||||
const name = getCreatureName(creature);
|
||||
const evSummary = getEVSummary(creature);
|
||||
const totalEV = getTotalEV(creature);
|
||||
const nextEvo = getNextEvolution(creature.speciesId);
|
||||
|
||||
const typeBadges = species.types
|
||||
.filter((t): t is string => Boolean(t))
|
||||
.map((t, i) => (
|
||||
<React.Fragment key={t}>
|
||||
{i > 0 && <Text color={GRAY}>/</Text>}
|
||||
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.toUpperCase()}</Text>
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
const friendshipColor: Color = creature.friendship > 200 ? GREEN : creature.friendship > 100 ? YELLOW : RED;
|
||||
const shinyBadge = creature.isShiny ? <Text color={YELLOW}> ★SHINY★</Text> : null;
|
||||
const evoHint = nextEvo ? (
|
||||
<Text color={GRAY}>
|
||||
{' '}
|
||||
→ <Text color={CYAN}>{SPECIES_DATA[nextEvo.to].names.zh ?? SPECIES_DATA[nextEvo.to].name}</Text> Lv.
|
||||
{nextEvo.minLevel}
|
||||
</Text>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Text bold color={CYAN}>
|
||||
{name}
|
||||
</Text>
|
||||
<Text color={GRAY}> #{String(species.dexNumber).padStart(3, '0')}</Text>
|
||||
{shinyBadge}
|
||||
</Box>
|
||||
<Text bold>Lv.{creature.level}</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={GRAY}>{species.names.zh ?? species.name}</Text>
|
||||
<Text> </Text>
|
||||
{typeBadges}
|
||||
{genderSymbol && <Text> {genderSymbol}</Text>}
|
||||
</Box>
|
||||
|
||||
{spriteLines && (
|
||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||
{spriteLines.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text color={GRAY} italic>
|
||||
"{SPECIES_PERSONALITY[creature.speciesId] ?? species.personality}"
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" marginTop={0}>
|
||||
<Text color={GRAY}>─── Stats ───</Text>
|
||||
{STAT_NAMES.map(stat => (
|
||||
<StatBar key={stat} label={STAT_LABELS[stat]} value={stats[stat]} maxValue={255} color={getStatColor(stat)} />
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={0}>
|
||||
<Text color={GRAY}>XP </Text>
|
||||
<Text color={BLUE}>
|
||||
{'█'.repeat(Math.round(xp.percentage / 10))}
|
||||
{'░'.repeat(10 - Math.round(xp.percentage / 10))}
|
||||
</Text>
|
||||
<Text>
|
||||
{' '}
|
||||
{xp.current}/{xp.needed}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={GRAY}>EV </Text>
|
||||
<Text color={totalEV >= 510 ? GREEN : GRAY}>{evSummary}</Text>
|
||||
<Text color={GRAY}> ({totalEV}/510)</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={GRAY}>♥ </Text>
|
||||
<Text color={friendshipColor}>
|
||||
{'█'.repeat(Math.round((creature.friendship / 255) * 10))}
|
||||
{'░'.repeat(10 - Math.round((creature.friendship / 255) * 10))}
|
||||
</Text>
|
||||
<Text> {creature.friendship}/255</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{evoHint && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={GRAY}>Next: </Text>
|
||||
{evoHint}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dex Tab ──────────────────────────────────────────
|
||||
|
||||
function DexTab({ buddyData }: { buddyData: BuddyData }) {
|
||||
const dexMap = new Map(buddyData.dex.map(d => [d.speciesId, d]));
|
||||
const collected = buddyData.dex.length;
|
||||
const total = ALL_SPECIES_IDS.length;
|
||||
const chains = groupByChain();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box justifyContent="space-between">
|
||||
<Text bold color={CYAN}>
|
||||
Pokédex
|
||||
</Text>
|
||||
<Text>
|
||||
<Text bold color={collected === total ? GREEN : WHITE}>
|
||||
{collected}
|
||||
</Text>
|
||||
<Text color={GRAY}>/{total}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text color={GREEN}>{'█'.repeat(collected)}</Text>
|
||||
<Text color={GRAY}>{'░'.repeat(total - collected)}</Text>
|
||||
<Text> {Math.floor((collected / total) * 100)}%</Text>
|
||||
</Box>
|
||||
|
||||
{chains.map((chain, ci) => (
|
||||
<Box key={ci} flexDirection="column">
|
||||
{chain.map((speciesId, si) => {
|
||||
const species = SPECIES_DATA[speciesId];
|
||||
const entry = dexMap.get(speciesId);
|
||||
const discovered = !!entry;
|
||||
const isActive = buddyData.activeCreatureId
|
||||
? buddyData.creatures.some(c => c.id === buddyData.activeCreatureId && c.speciesId === speciesId)
|
||||
: false;
|
||||
const nextEvo = getNextEvolution(speciesId);
|
||||
|
||||
return (
|
||||
<Box key={speciesId}>
|
||||
<Text color={GRAY}>{si === 0 ? ' ' : '├'}</Text>
|
||||
<Text>{isActive ? <Text color={YELLOW}>▶</Text> : ' '}</Text>
|
||||
<Text color={GRAY}>#{String(species.dexNumber).padStart(3, '0')} </Text>
|
||||
<Text color={discovered ? WHITE : GRAY} bold={isActive}>
|
||||
{discovered ? (species.names.zh ?? species.name) : '???'}
|
||||
</Text>
|
||||
{discovered && (
|
||||
<Text>
|
||||
{' '}
|
||||
{species.types
|
||||
.filter((t): t is string => Boolean(t))
|
||||
.map((t, ti) => (
|
||||
<React.Fragment key={t}>
|
||||
{ti > 0 && <Text color={GRAY}>/</Text>}
|
||||
<Text color={TYPE_COLORS[t] ?? GRAY}>{t.slice(0, 3).toUpperCase()}</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
{discovered && entry ? (
|
||||
<Text color={GREEN}> Lv.{entry.bestLevel}</Text>
|
||||
) : (
|
||||
<Text color={GRAY}> ───</Text>
|
||||
)}
|
||||
{nextEvo && (
|
||||
<Text color={GRAY}>
|
||||
{' '}
|
||||
→<Text color={CYAN}>Lv.{nextEvo.minLevel}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box marginTop={0} flexDirection="column">
|
||||
<Text color={GRAY}>─── Stats ───</Text>
|
||||
<Box>
|
||||
<Text color={GRAY}>Turns: </Text>
|
||||
<Text>{buddyData.stats.totalTurns}</Text>
|
||||
<Text color={GRAY}> Days: </Text>
|
||||
<Text>{buddyData.stats.consecutiveDays}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={GRAY}>Eggs: </Text>
|
||||
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||
<Text color={GRAY}> Evolutions: </Text>
|
||||
<Text>{buddyData.stats.totalEvolutions}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{buddyData.eggs.length > 0 && (
|
||||
<Box marginTop={0}>
|
||||
<Text color={YELLOW}>🥚 Egg: </Text>
|
||||
<Text>
|
||||
{buddyData.eggs[0].stepsRemaining}/{buddyData.eggs[0].totalSteps}
|
||||
</Text>
|
||||
<Text color={GRAY}> steps</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Egg Tab ──────────────────────────────────────────
|
||||
|
||||
function EggTab({ buddyData }: { buddyData: BuddyData }) {
|
||||
const eggs = buddyData.eggs;
|
||||
|
||||
if (eggs.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={CYAN}>
|
||||
Egg
|
||||
</Text>
|
||||
<Text color={GRAY}>No egg currently. Keep coding!</Text>
|
||||
{buddyData.stats.consecutiveDays < 7 && (
|
||||
<Text color={GRAY}>Next egg: {7 - buddyData.stats.consecutiveDays} more days</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const egg = eggs[0]!;
|
||||
const percentage = Math.floor(((egg.totalSteps - egg.stepsRemaining) / egg.totalSteps) * 100);
|
||||
const filled = Math.round(percentage / 10);
|
||||
const empty = 10 - filled;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={CYAN}>
|
||||
Egg Status
|
||||
</Text>
|
||||
|
||||
<Box flexDirection="column" alignItems="center" marginY={0}>
|
||||
<Text> . </Text>
|
||||
<Text> / \ </Text>
|
||||
<Text> | | </Text>
|
||||
<Text> \_/ </Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="column" alignItems="center">
|
||||
<Text>
|
||||
Steps: {egg.totalSteps - egg.stepsRemaining} / {egg.totalSteps}
|
||||
</Text>
|
||||
<Text color={YELLOW}>
|
||||
{'█'.repeat(filled)}
|
||||
{'░'.repeat(empty)}
|
||||
</Text>
|
||||
<Text>{percentage}%</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={0} flexDirection="column" alignItems="center">
|
||||
<Text color={GRAY}>Pet (+5) · Chat (+3) · Cmd (+1)</Text>
|
||||
<Text color={GRAY}>Hatch: ~{egg.stepsRemaining} more interactions</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={0} flexDirection="column">
|
||||
<Text color={GRAY}>─── Egg Stats ───</Text>
|
||||
<Box>
|
||||
<Text color={GRAY}>Total eggs: </Text>
|
||||
<Text>{buddyData.stats.totalEggsObtained}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────
|
||||
|
||||
function getStatColor(stat: string): Color {
|
||||
const colors: Record<string, Color> = {
|
||||
hp: 'ansi:green',
|
||||
attack: 'ansi:red',
|
||||
defense: 'ansi:yellow',
|
||||
spAtk: 'ansi:blue',
|
||||
spDef: 'ansi:magenta',
|
||||
speed: 'ansi:cyan',
|
||||
};
|
||||
return colors[stat] ?? 'ansi:white';
|
||||
}
|
||||
|
||||
function groupByChain(): SpeciesId[][] {
|
||||
return [
|
||||
['bulbasaur', 'ivysaur', 'venusaur'],
|
||||
['charmander', 'charmeleon', 'charizard'],
|
||||
['squirtle', 'wartortle', 'blastoise'],
|
||||
['pikachu'],
|
||||
];
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
getCompanion,
|
||||
rollWithSeed,
|
||||
generateSeed,
|
||||
} from '../../buddy/companion.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 { triggerCompanionReaction } from '../../buddy/companionReact.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
@@ -14,57 +6,46 @@ import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import {
|
||||
loadBuddyData,
|
||||
saveBuddyData,
|
||||
getDefaultBuddyData,
|
||||
migrateFromLegacy,
|
||||
getActiveCreature,
|
||||
getCreatureName,
|
||||
awardXP,
|
||||
advanceEggSteps,
|
||||
checkEvolution,
|
||||
checkEggEligibility,
|
||||
generateEgg,
|
||||
isEggReadyToHatch,
|
||||
hatchEgg,
|
||||
fetchAndCacheSprite,
|
||||
loadSprite,
|
||||
getFallbackSprite,
|
||||
SPECIES_DATA,
|
||||
type BuddyData,
|
||||
type Creature,
|
||||
} from '@claude-code-best/pokemon'
|
||||
import { BuddyPanel } from './BuddyPanel.js'
|
||||
|
||||
// Species → default name fragments for hatch (no API needed)
|
||||
const SPECIES_NAMES: Record<string, string> = {
|
||||
duck: 'Waddles',
|
||||
goose: 'Goosberry',
|
||||
blob: 'Gooey',
|
||||
cat: 'Whiskers',
|
||||
dragon: 'Ember',
|
||||
octopus: 'Inky',
|
||||
owl: 'Hoots',
|
||||
penguin: 'Waddleford',
|
||||
turtle: 'Shelly',
|
||||
snail: 'Trailblazer',
|
||||
ghost: 'Casper',
|
||||
axolotl: 'Axie',
|
||||
capybara: 'Chill',
|
||||
cactus: 'Spike',
|
||||
robot: 'Byte',
|
||||
rabbit: 'Flops',
|
||||
mushroom: 'Spore',
|
||||
chonk: 'Chonk',
|
||||
}
|
||||
/**
|
||||
* Load or initialize Pokémon buddy data.
|
||||
* Migrates from legacy buddy system if needed.
|
||||
*/
|
||||
function getOrInitBuddyData(): BuddyData {
|
||||
let data = loadBuddyData()
|
||||
|
||||
const SPECIES_PERSONALITY: Record<string, string> = {
|
||||
duck: 'Quirky and easily amused. Leaves rubber duck debugging tips everywhere.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
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.',
|
||||
}
|
||||
// If no active creature, check for legacy companion to migrate
|
||||
if (!data.activeCreatureId || data.creatures.length === 0) {
|
||||
const legacyCompanion = getGlobalConfig().companion
|
||||
if (legacyCompanion) {
|
||||
data = migrateFromLegacy(legacyCompanion)
|
||||
saveBuddyData(data)
|
||||
}
|
||||
}
|
||||
|
||||
function speciesLabel(species: string): string {
|
||||
return species.charAt(0).toUpperCase() + species.slice(1)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function call(
|
||||
@@ -89,18 +70,45 @@ export async function call(
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy pet — trigger heart animation + auto unmute ──
|
||||
// ── /buddy pet — trigger heart animation + XP + egg steps ──
|
||||
if (sub === 'pet') {
|
||||
const companion = getCompanion()
|
||||
if (!companion) {
|
||||
onDone('no companion yet \u00b7 run /buddy first', { display: 'system' })
|
||||
const data = getOrInitBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature) {
|
||||
onDone('no companion yet · run /buddy first', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// Auto-unmute on pet + trigger heart animation
|
||||
// Auto-unmute + heart animation
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||
setState?.(prev => ({ ...prev, companionPetAt: Date.now() }))
|
||||
|
||||
// Award pet XP
|
||||
const result = awardXP(creature, 2)
|
||||
data.creatures = data.creatures.map(c =>
|
||||
c.id === creature.id ? result.creature : c,
|
||||
)
|
||||
|
||||
// Advance egg steps
|
||||
if (data.eggs.length > 0) {
|
||||
data.eggs = data.eggs.map(egg => advanceEggSteps(egg, 5))
|
||||
|
||||
// Check hatch
|
||||
const readyEgg = data.eggs.find(isEggReadyToHatch)
|
||||
if (readyEgg) {
|
||||
const { buddyData: updatedData, creature: newCreature } = hatchEgg(
|
||||
data,
|
||||
readyEgg,
|
||||
)
|
||||
Object.assign(data, updatedData)
|
||||
onDone(`🥚 Egg hatched! You got a ${getCreatureName(newCreature)}!`, {
|
||||
display: 'system',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
saveBuddyData(data)
|
||||
|
||||
// Trigger a post-pet reaction
|
||||
triggerCompanionReaction(context.messages ?? [], reaction =>
|
||||
setState?.(prev =>
|
||||
@@ -110,60 +118,111 @@ export async function call(
|
||||
),
|
||||
)
|
||||
|
||||
onDone(`petted ${companion.name}`, { display: 'system' })
|
||||
if (!data.eggs.find(isEggReadyToHatch)) {
|
||||
onDone(`petted ${getCreatureName(creature)} (+2 XP)`, {
|
||||
display: 'system',
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy (no args) — show existing or hatch ──
|
||||
const companion = getCompanion()
|
||||
// ── /buddy rename — rename current creature ──
|
||||
if (sub.startsWith('rename ')) {
|
||||
const nickname = sub.slice(7).trim()
|
||||
if (!nickname) {
|
||||
onDone('Usage: /buddy rename <name>', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
const data = getOrInitBuddyData()
|
||||
const creature = getActiveCreature(data)
|
||||
if (!creature) {
|
||||
onDone('no companion yet · run /buddy first', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
data.creatures = data.creatures.map(c =>
|
||||
c.id === creature.id ? { ...c, nickname } : c,
|
||||
)
|
||||
saveBuddyData(data)
|
||||
onDone(`renamed to "${nickname}"`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy switch — switch active creature ──
|
||||
if (sub === 'switch') {
|
||||
const data = getOrInitBuddyData()
|
||||
if (data.creatures.length <= 1) {
|
||||
onDone('You only have one buddy!', { display: 'system' })
|
||||
return null
|
||||
}
|
||||
const lines = data.creatures.map((c, i) => {
|
||||
const name = getCreatureName(c)
|
||||
const species = SPECIES_DATA[c.speciesId]
|
||||
const active = c.id === data.activeCreatureId ? ' ← active' : ''
|
||||
return `${i + 1}. ${name} (${species.names.zh ?? species.name}) Lv.${c.level}${active}`
|
||||
})
|
||||
onDone(
|
||||
['Switch buddy:', ...lines, '', 'Use: /buddy switch <number>'].join('\n'),
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
if (sub.startsWith('switch ')) {
|
||||
const num = parseInt(sub.slice(7).trim(), 10)
|
||||
const data = getOrInitBuddyData()
|
||||
if (isNaN(num) || num < 1 || num > data.creatures.length) {
|
||||
onDone('Invalid number. Use /buddy switch to see list.', {
|
||||
display: 'system',
|
||||
})
|
||||
return null
|
||||
}
|
||||
const creature = data.creatures[num - 1]!
|
||||
data.activeCreatureId = creature.id
|
||||
saveBuddyData(data)
|
||||
onDone(`Switched to ${getCreatureName(creature)}!`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
|
||||
// ── /buddy (no args) — show unified BuddyPanel ──
|
||||
const data = getOrInitBuddyData()
|
||||
let creature = getActiveCreature(data)
|
||||
|
||||
// Auto-unmute when viewing
|
||||
if (companion && getGlobalConfig().companionMuted) {
|
||||
if (getGlobalConfig().companionMuted) {
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companionMuted: false }))
|
||||
}
|
||||
|
||||
if (companion) {
|
||||
// Return JSX card — matches official vc8 component
|
||||
const lastReaction = context.getAppState?.()?.companionReaction
|
||||
return React.createElement(CompanionCard, {
|
||||
companion,
|
||||
lastReaction,
|
||||
onDone: onDone as unknown as Parameters<typeof CompanionCard>[0]['onDone'],
|
||||
})
|
||||
// No creature → initialize new one
|
||||
if (!creature) {
|
||||
const legacyCompanion = getGlobalConfig().companion
|
||||
if (legacyCompanion) {
|
||||
const migrated = migrateFromLegacy(legacyCompanion)
|
||||
saveBuddyData(migrated)
|
||||
creature = getActiveCreature(migrated)!
|
||||
} else {
|
||||
const defaultData = getDefaultBuddyData()
|
||||
saveBuddyData(defaultData)
|
||||
creature = getActiveCreature(defaultData)!
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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(),
|
||||
// Pre-fetch sprite if not cached
|
||||
const spriteCached = loadSprite(creature.speciesId)
|
||||
if (!spriteCached) {
|
||||
fetchAndCacheSprite(creature.speciesId).catch(() => {})
|
||||
}
|
||||
|
||||
saveGlobalConfig(cfg => ({ ...cfg, companion: stored }))
|
||||
const spriteLines =
|
||||
spriteCached?.lines ?? getFallbackSprite(creature.speciesId)
|
||||
|
||||
const stars = RARITY_STARS[r.bones.rarity]
|
||||
const sprite = renderSprite(r.bones, 0)
|
||||
const shiny = r.bones.shiny ? ' \u2728 Shiny!' : ''
|
||||
// Reload data to get latest state after possible initialization
|
||||
const latestData = loadBuddyData()
|
||||
|
||||
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
|
||||
return React.createElement(BuddyPanel, {
|
||||
buddyData: latestData,
|
||||
spriteLines,
|
||||
onClose: () => {
|
||||
onDone('buddy panel closed', { display: 'system' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { isBuddyLive } from '../../buddy/useBuddyNotification.js'
|
||||
const buddy = {
|
||||
type: 'local-jsx',
|
||||
name: 'buddy',
|
||||
description: 'Hatch a coding companion · pet, off',
|
||||
argumentHint: '[pet|off]',
|
||||
description: 'Pokémon coding companion · pet, dex, egg, switch, rename, off',
|
||||
argumentHint: '[pet|dex|egg|switch|rename <name>|on|off]',
|
||||
immediate: true,
|
||||
get isHidden() {
|
||||
return !isBuddyLive()
|
||||
|
||||
@@ -1,52 +1,84 @@
|
||||
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import * as React from 'react'
|
||||
import { CHANNEL_ARROW } from '../../constants/figures.js'
|
||||
import { CHANNEL_TAG } from '../../constants/xml.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { truncateToWidth } from '../../utils/format.js'
|
||||
import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import * as React from 'react';
|
||||
import { CHANNEL_ARROW } from '../../constants/figures.js';
|
||||
import { CHANNEL_TAG } from '../../constants/xml.js';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import { truncateToWidth } from '../../utils/format.js';
|
||||
|
||||
type Props = {
|
||||
addMargin: boolean
|
||||
param: TextBlockParam
|
||||
}
|
||||
addMargin: boolean;
|
||||
param: TextBlockParam;
|
||||
};
|
||||
|
||||
// <channel source="..." user="..." chat_id="...">content</channel>
|
||||
// source is always first (wrapChannelMessage writes it), user is optional.
|
||||
const CHANNEL_RE = new RegExp(
|
||||
`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`,
|
||||
)
|
||||
const USER_ATTR_RE = /\buser="([^"]+)"/
|
||||
const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?</${CHANNEL_TAG}>`);
|
||||
const USER_ATTR_RE = /\buser="([^"]+)"/;
|
||||
|
||||
// Plugin-provided servers get names like plugin:slack-channel:slack via
|
||||
// addPluginScopeToServers — show just the leaf. Matches the suffix-match
|
||||
// logic in isServerInChannels.
|
||||
function displayServerName(name: string): string {
|
||||
const i = name.lastIndexOf(':')
|
||||
return i === -1 ? name : name.slice(i + 1)
|
||||
const i = name.lastIndexOf(':');
|
||||
return i === -1 ? name : name.slice(i + 1);
|
||||
}
|
||||
|
||||
const TRUNCATE_AT = 60
|
||||
const MAX_LINE_WIDTH = 80;
|
||||
const MAX_LINES = 3;
|
||||
|
||||
/**
|
||||
* Formats multi-line channel content for compact display in the terminal.
|
||||
* Collapses excessive blank lines, limits to MAX_LINES, truncates each line.
|
||||
*/
|
||||
function formatChannelBody(raw: string): { lines: string[]; truncated: boolean } {
|
||||
const body = raw.trim();
|
||||
// Split into lines, collapse runs of blank lines into single empty line
|
||||
const allLines = body.split(/\n/).reduce<string[]>((acc, line) => {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed === '' && acc.length > 0 && acc[acc.length - 1] === '') return acc;
|
||||
acc.push(trimmed);
|
||||
return acc;
|
||||
}, []);
|
||||
// Remove leading/trailing blank lines
|
||||
while (allLines.length > 0 && allLines[0] === '') allLines.shift();
|
||||
while (allLines.length > 0 && allLines[allLines.length - 1] === '') allLines.pop();
|
||||
|
||||
const truncated = allLines.length > MAX_LINES;
|
||||
const visible = allLines.slice(0, MAX_LINES);
|
||||
const lines = visible.map(l => (l === '' ? '' : truncateToWidth(l, MAX_LINE_WIDTH)));
|
||||
return { lines, truncated };
|
||||
}
|
||||
|
||||
export function UserChannelMessage({ addMargin, param: { text } }: Props): React.ReactNode {
|
||||
const m = CHANNEL_RE.exec(text);
|
||||
if (!m) return null;
|
||||
const [, source, attrs, content] = m;
|
||||
const user = USER_ATTR_RE.exec(attrs ?? '')?.[1];
|
||||
const { lines, truncated } = formatChannelBody(content ?? '');
|
||||
|
||||
export function UserChannelMessage({
|
||||
addMargin,
|
||||
param: { text },
|
||||
}: Props): React.ReactNode {
|
||||
const m = CHANNEL_RE.exec(text)
|
||||
if (!m) return null
|
||||
const [, source, attrs, content] = m
|
||||
const user = USER_ATTR_RE.exec(attrs ?? '')?.[1]
|
||||
const body = (content ?? '').trim().replace(/\s+/g, ' ')
|
||||
const truncated = truncateToWidth(body, TRUNCATE_AT)
|
||||
return (
|
||||
<Box marginTop={addMargin ? 1 : 0}>
|
||||
<Text>
|
||||
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
|
||||
<Text dimColor>
|
||||
{displayServerName(source ?? '')}
|
||||
{user ? ` \u00b7 ${user}` : ''}:
|
||||
</Text>{' '}
|
||||
{truncated}
|
||||
</Text>
|
||||
<Box marginTop={addMargin ? 1 : 0} flexDirection="column">
|
||||
{lines.map((line, i) => (
|
||||
<Box key={i}>
|
||||
{i === 0 ? (
|
||||
<Text>
|
||||
<Text color="suggestion">{CHANNEL_ARROW}</Text>{' '}
|
||||
<Text dimColor>
|
||||
{displayServerName(source ?? '')}
|
||||
{user ? ` \u00b7 ${user}` : ''}:
|
||||
</Text>{' '}
|
||||
{line}
|
||||
{truncated && i === lines.length - 1 ? ' …' : ''}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}
|
||||
{line}
|
||||
{truncated && i === lines.length - 1 ? ' …' : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type CancelRequestHandlerProps = {
|
||||
popCommandFromQueue?: () => void
|
||||
vimMode?: VimMode
|
||||
isLocalJSXCommand?: boolean
|
||||
onDismissLocalJSX?: () => void
|
||||
isSearchingHistory?: boolean
|
||||
isHelpOpen?: boolean
|
||||
inputMode?: PromptInputMode
|
||||
@@ -71,6 +72,7 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
||||
popCommandFromQueue,
|
||||
vimMode,
|
||||
isLocalJSXCommand,
|
||||
onDismissLocalJSX,
|
||||
isSearchingHistory,
|
||||
isHelpOpen,
|
||||
inputMode,
|
||||
@@ -92,6 +94,12 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
||||
streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
}
|
||||
|
||||
// Priority 0: Dismiss local JSX command panel (e.g. /buddy, /config)
|
||||
if (isLocalJSXCommand && onDismissLocalJSX) {
|
||||
onDismissLocalJSX()
|
||||
return
|
||||
}
|
||||
|
||||
// Priority 1: If there's an active task running, cancel it first
|
||||
// This takes precedence over queue management so users can always interrupt Claude
|
||||
if (abortSignal !== undefined && !abortSignal.aborted) {
|
||||
@@ -140,16 +148,16 @@ export function CancelRequestHandler(props: CancelRequestHandlerProps): null {
|
||||
screen !== 'transcript' &&
|
||||
!isSearchingHistory &&
|
||||
!isMessageSelectorVisible &&
|
||||
!isLocalJSXCommand &&
|
||||
!isHelpOpen &&
|
||||
!isOverlayActive &&
|
||||
!(isVimModeEnabled() && vimMode === 'INSERT')
|
||||
|
||||
// Escape (chat:cancel) defers to mode-exit when in special mode with empty
|
||||
// input, and to useBackgroundTaskNavigation when viewing a teammate
|
||||
// input, and to useBackgroundTaskNavigation when viewing a teammate.
|
||||
// Also active when a local JSX command panel (e.g. /buddy) is showing.
|
||||
const isEscapeActive =
|
||||
isContextActive &&
|
||||
(canCancelRunningTask || hasQueuedCommands) &&
|
||||
(canCancelRunningTask || hasQueuedCommands || !!isLocalJSXCommand) &&
|
||||
!isInSpecialModeWithEmptyInput &&
|
||||
!isViewingTeammate
|
||||
|
||||
|
||||
@@ -2564,6 +2564,9 @@ export function REPL({
|
||||
popCommandFromQueue: handleQueuedCommandOnCancel,
|
||||
vimMode,
|
||||
isLocalJSXCommand: toolJSX?.isLocalJSXCommand,
|
||||
onDismissLocalJSX: useCallback(() => {
|
||||
setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true });
|
||||
}, [setToolJSX]),
|
||||
isSearchingHistory,
|
||||
isHelpOpen,
|
||||
inputMode,
|
||||
@@ -3434,6 +3437,69 @@ export function REPL({
|
||||
// Log query profiling report if enabled
|
||||
logQueryProfileReport();
|
||||
|
||||
// ── Buddy EV/XP/egg hook ──
|
||||
if (feature('BUDDY')) {
|
||||
try {
|
||||
const {
|
||||
loadBuddyData: _load,
|
||||
saveBuddyData: _save,
|
||||
getActiveCreature: _getActive,
|
||||
awardXP: _awardXP,
|
||||
awardTurnEV: _awardEV,
|
||||
advanceEggSteps: _advSteps,
|
||||
checkEvolution: _checkEvo,
|
||||
checkEggEligibility: _checkEgg,
|
||||
generateEgg: _genEgg,
|
||||
isEggReadyToHatch: _isReady,
|
||||
hatchEgg: _hatchEgg,
|
||||
updateDailyStats: _updateDaily,
|
||||
incrementTurns: _incTurns,
|
||||
} = await import('@claude-code-best/pokemon');
|
||||
const _data = _updateDaily(_incTurns(_load()));
|
||||
const _creature = _getActive(_data);
|
||||
if (_creature) {
|
||||
// 1. Collect tool names from this turn's messages
|
||||
const _toolNames: string[] = [];
|
||||
for (const _msg of messagesRef.current) {
|
||||
if (_msg.role === 'assistant' && Array.isArray(_msg.content)) {
|
||||
for (const _block of _msg.content) {
|
||||
if (_block.type === 'tool_use') _toolNames.push(_block.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. Award EV for tool usage
|
||||
const _evolved = _awardEV(_creature, _toolNames);
|
||||
if (_evolved !== _creature) {
|
||||
_data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _evolved : c));
|
||||
}
|
||||
// 3. Award conversation XP
|
||||
const _xpResult = _awardXP(_evolved, 5 + _toolNames.length);
|
||||
_data.creatures = _data.creatures.map((c: any) => (c.id === _creature.id ? _xpResult.creature : c));
|
||||
// 4. Advance egg steps
|
||||
if (_data.eggs.length > 0) {
|
||||
_data.eggs = _data.eggs.map((e: any) => _advSteps(e, 3));
|
||||
const _readyEgg = _data.eggs.find(_isReady);
|
||||
if (_readyEgg) {
|
||||
const { buddyData: _hatched, creature: _newC } = _hatchEgg(_data, _readyEgg);
|
||||
Object.assign(_data, _hatched);
|
||||
}
|
||||
}
|
||||
// 5. Check evolution
|
||||
const _evoResult = _checkEvo(_xpResult.creature);
|
||||
if (_evoResult) {
|
||||
setAppState(prev => ({ ...prev, companionEvolving: { from: _evoResult.from, to: _evoResult.to } }));
|
||||
}
|
||||
// 6. Check egg eligibility
|
||||
if (_checkEgg(_data)) {
|
||||
_data.eggs.push(_genEgg(_data));
|
||||
}
|
||||
_save(_data);
|
||||
}
|
||||
} catch {
|
||||
// Buddy system is non-critical; silently ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Signal that a query turn has completed successfully
|
||||
await onTurnComplete?.(messagesRef.current);
|
||||
},
|
||||
|
||||
@@ -169,6 +169,10 @@ export type AppState = DeepImmutable<{
|
||||
companionReaction?: string
|
||||
// Timestamp of last /buddy pet — CompanionSprite renders hearts while recent
|
||||
companionPetAt?: number
|
||||
// Pokémon evolution animation state
|
||||
companionEvolving?: { from: string; to: string }
|
||||
// Egg steps update counter (triggers UI refresh)
|
||||
companionEggSteps?: number
|
||||
// TODO (ashwin): see if we can use utility-types DeepReadonly for this
|
||||
mcp: {
|
||||
clients: MCPServerConnection[]
|
||||
|
||||
@@ -266,8 +266,13 @@ export type GlobalConfig = {
|
||||
[tipId: string]: number // Key is tipId, value is the numStartups when tip was last shown
|
||||
}
|
||||
|
||||
// /buddy companion soul — bones regenerated from userId on read. See src/buddy/.
|
||||
companion?: import('../buddy/types.js').StoredCompanion
|
||||
// /buddy companion — legacy data (migrated to buddy-data.json by Pokémon system)
|
||||
companion?: {
|
||||
name: string
|
||||
personality: string
|
||||
seed?: string
|
||||
hatchedAt: number
|
||||
}
|
||||
companionMuted?: boolean
|
||||
|
||||
// Feedback survey tracking
|
||||
|
||||
Reference in New Issue
Block a user