SLOPSHOPPER

chess

Chess against Claude in a pane, any time, without taking a chat turn: click a piece and its square, Claude thinks and answers in a few seconds. English and…

newpanecommandmodel
v0.2.0MITupdated 2026-10-21jahirxtrap/claude-toolkit/plugins/chess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chess
│ ┃ Chess ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./board.tsx Your move: you │ ┃ ⏺ Read(src/auth.ts) │ ┃ Click a piece, ⎿ Read 6 lines │ ┃ square. Keyboa ⏺ Update(src/auth.ts) │ ┃ Enter. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ You open. ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ New game ] [ Undo ] Language: English ▾ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /chess │ ⎿ chess: Board open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Chess
▣ client module ./board.tsx Your move: you play White. Click a piece, then its square. Keyboard: arrows and Enter. You open. [ New game ] [ Undo ] Language: English ▾
README

claude-toolkit

A general-use toolkit of Claude Code plugins by jahirtrap. A plugin can bring a mod (a pane next to the chat, a line above the prompt), an MCP server, skills, commands or agents, or several of them together; each one installs on its own. This repository is also their marketplace: add it once, then install only the plugins you want.

[Claude Code] ──marketplace──> [a claude-toolkit plugin: mod, MCP server, skill, command]

Every plugin draws on each surface Claude Code has (the terminal, the desktop app, VS Code, the phone app) as far as that surface allows, and says so where it can't.

Plugins

PluginIncludesWhat it doesCommand
chessmodPlay chess against Claude in a pane, any time, without taking a chat turn./chess
meowzikmodA music player beside the chat: YouTube, SoundCloud, Spotify and Suno, played by you or by Claude./meowzik

chess

chess puts a game against Claude next to the chat. You play White, Claude plays Black, and the game never takes a turn of the conversation, so you can play while Claude works on something else or waits for you.

  • Click to play: click a piece => its squares light up (a dot on the empty ones) => click one. The arrows and Enter work too.
  • Claude thinks: it gets the board drawn as text, both piece lists and the legal moves, looks at your threats and at its king's escape squares, then answers with its move and a short remark (a few seconds, Sonnet at low effort).
  • Grows with the pane: resize the pane and the board follows, squares kept square and pieces scaled up.
  • English and Spanish: pick the language in the pane and Claude talks to you in it too.
  • Your game is kept: close the pane or Claude Code and carry on later; New game, Undo, and Ask Claude to move when a reply goes wrong.
  • Every surface: a clickable board in the terminal and the desktop app, a move field in VS Code (e4, Nf3, O-O or e2e4), the board alone in the phone app.

The rules are complete (castling, en passant, promotion, check, mate, stalemate, the fifty-move rule and bare kings) and checked with perft against the reference counts.

See plugins/chess for the code and its tests.

meowzik

meowzik is the Meowzik Discord bot as a Claude Code mod: the same player, the same buttons, in a pane next to the chat, and without Discord's limits (no cap on the queue, the history, the favorites or a playlist).

  • Any source: a song name (searched on YouTube, or SoundCloud if you pick it), a YouTube or SoundCloud link, a whole YouTube playlist or SoundCloud set, a Spotify track, album or playlist (each song is looked up when it plays), or a Suno song.
  • Player controls: the bot's buttons as a player's, with icons (⏮ ▶ ⏭ ⏹, then shuffle, loop for the queue or the track, autoplay and favorite, lit while on), a thick progress bar you drag to seek and a volume slider you drag or move with the arrows.
  • Mini player: a one-row player above the prompt (back, play or pause, skip, stop, the song and its progress bar); the song opens the pane. Show it while music plays, always or never, and keep or hide the song in the status line, in /config.
  • Lyrics: a Lyrics tab with the song's lyrics from LRCLIB, synced lines lit as they are sung; press a line to jump to it. When LRCLIB has none, a YouTube song uses the video's own subtitles, then its automatic captions.
  • Autoplay: when the queue runs out it follows YouTube's mix or SoundCloud's recommendations for what you were listening to, skipping what already played in the session.
  • Lists without limits: Queue, History and Favorites in tabs, page by page; press a song to play it now, queue it, move it, remove it or favorite it. They are kept in ~/.claude/meowzik/lists, so they survive the session.
  • Claude plays too: ask for music and Claude uses the mod's tools (play, control, status, list), so "play some Daft Punk", "skip this" or "what is playing?" just work.
  • Installs what is missing: the first song brings mpv, the standalone yt-dlp (it carries the scripts that solve YouTube's challenges) and, when there is no deno, Node.js or Bun to run them, deno; all into ~/.claude/meowzik/bin (on macOS mpv and deno come from Homebrew when it is there; on Linux it shows the command for mpv). Nothing else on your system changes.
  • YouTube's bot check: when YouTube asks to confirm you are not a bot, point the mod at a cookies file (or a browser) in /config; like the bot it works on a copy and keeps the session YouTube rotates. Or switch the search source to SoundCloud, which needs no login.
  • Every surface: the cover, the draggable progress and volume sliders and the field to add songs in the terminal and the desktop app; the field with seek and volume buttons in VS Code; the player, the lists and Claude's tools in the phone app.

Settings (in /config, under meowzik): the mini player, the song in the status line, the search source, the YouTube cookies file or browser, extra yt-dlp arguments, and the mpv and yt-dlp paths if you want your own.

See plugins/meowzik for the code and its tests.

Structure

claude-toolkit/
├── .claude-plugin/
│   └── marketplace.json   # the catalog: one entry per plugin
├── plugins/
│   ├── chess/             # chess against Claude in a pane (mod)
│   └── meowzik/           # a music player beside the chat (mod)
├── scripts/
│   └── check.sh           # validate the marketplace, then validate and test every plugin
└── .github/workflows/     # CI runs scripts/check.sh

Each plugin keeps Claude Code's own layout inside its folder (hooks/ for a mod, .mcp.json for an MCP server, skills/, commands/, agents/), so a plugin that mixes kinds stays in one place.

Install

One plugin, in one step (Claude Code 2.1.275 or later):

/plugin install chess --marketplace jahirxtrap/claude-toolkit

Or add the marketplace once and install the plugins you want:

claude plugin marketplace add jahirxtrap/claude-toolkit
claude plugin install chess@claude-toolkit

From inside Claude Code it is /plugin marketplace add jahirxtrap/claude-toolkit and /plugin install chess@claude-toolkit, then /reload-plugins. To get new versions later, claude plugin marketplace update claude-toolkit.

To try a plugin without installing it:

git clone https://github.com/jahirxtrap/claude-toolkit && cd claude-toolkit
claude --plugin-dir plugins/chess

Built and tested on Claude Code 2.1.306.

Develop

bash scripts/check.sh   # the marketplace validates, the catalog matches plugins/, every plugin validates and passes its tests

Adding a plugin is one folder under plugins/ plus its entry in marketplace.json; CLAUDE.md has the steps and the rules.

License

MIT (see LICENSE).

Source 9 files
hooks/register.tsx 396 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderInput, RenderSurface } from 'claude-code'
3
4import type { Language, Notice } from '../types'
5import type { BoardProps } from './board'
6import { inCheck, legalMoves, parseMove, replay, squareName, statusOf, uciOf } from './chess'
7import type { Color, GameStatus, PieceType, Position } from './chess'
8import { drawBoard } from './grid'
9import { boardColumnsFor, FILE_LABEL_ROWS, squareRowsFor } from './layout'
10import { DEFAULT_LANGUAGE, isLanguage, isMessageKey, LANGUAGES, languageName, translate } from './locales'
11import type { MessageKey } from './locales'
12import { squareColor } from './palette'
13import { promptFor, readReply, systemPromptFor } from './prompt'
14
15const PANE = 'chess'
16const BOARD_KEY = 'board'
17const GAME_KEY = 'game'
18const LANGUAGE_KEY = 'language'
19const PLAYER: Color = 'w'
20const MODEL = 'sonnet'
21const EFFORT = 'low'
22const ATTEMPTS = 2
23const REPLY_TOKENS = 700
24const REPLY_TIMEOUT_MS = 45_000
25const HISTORY_ROWS = 10
26const SIDE_COLUMNS = 30
27const SIDE_GAP = 3
28const CONTROL_ROWS = 3
29const STATIC_SQUARE_ROWS = 1
30
31const TEXT_PRESENTATION = String.fromCodePoint(0xfe0e)
32
33const GLYPHS: Record<Color, Record<PieceType, string>> = {
34  w: { k: '♔', q: '♕', r: '♖', b: '♗', n: '♘', p: '♙' },
35  b: { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' },
36}
37
38const DRAW_KEYS: Record<Exclude<GameStatus, 'playing' | 'checkmate'>, MessageKey> = {
39  stalemate: 'stalemate',
40  'fifty-moves': 'fiftyMoves',
41  'insufficient-material': 'insufficientMaterial',
42}
43
44const moves = atom({ plugin: 'chess', key: 'moves' } as const, [] as string[])
45const isThinking = atom({ plugin: 'chess', key: 'isThinking' } as const, false)
46const notice = atom({ plugin: 'chess', key: 'notice' } as const, null as Notice | null)
47const remark = atom({ plugin: 'chess', key: 'remark' } as const, null as string | null)
48const language = atom({ plugin: 'chess', key: 'language' } as const, DEFAULT_LANGUAGE as Language)
49
50let generation = 0
51
52type Game = ReturnType<typeof replay>
53
54const movePairs = (sans: readonly string[]) =>
55  Array.from({ length: Math.ceil(sans.length / 2) }, (_, index) =>
56    `${index + 1}. ${sans[index * 2] ?? ''} ${sans[index * 2 + 1] ?? ''}`.trimEnd(),
57  )
58
59const say = (key: MessageKey, params: Record<string, string> = {}): Notice => ({ key, params })
60
61const noticeText = (current: Language, shown: Notice) =>
62  isMessageKey(shown.key) ? translate(current, shown.key, shown.params) : shown.key
63
64const saveMoves = async ($: EngineInterface, list: string[]) => {
65  await update($, moves, () => list)
66  await $.store.set(GAME_KEY, list)
67}
68
69const askClaude = async ($: EngineInterface, game: Game) => {
70  const legal = legalMoves(game.position)
71  const system = systemPromptFor(languageName(await read($, language)))
72  const prompt = promptFor(game.position, movePairs(game.sans).join(' '), legal)
73
74  for (let attempt = 1; attempt <= ATTEMPTS; attempt += 1) {
75    const answered = await $.model.complete({
76      model: MODEL,
77      effort: EFFORT,
78      system,
79      prompt,
80      maxTokens: REPLY_TOKENS,
81      timeoutMs: REPLY_TIMEOUT_MS,
82    })
83
84    if (!answered.isAnswered) {
85      return { failure: say('noAnswer', { reason: answered.reason }) }
86    }
87
88    const reply = readReply(game.position, answered.text)
89    if (reply.move) {
90      return { move: reply.move, remark: reply.remark }
91    }
92  }
93
94  return { failure: say('noValidMove') }
95}
96
97const claudeMove = async ($: EngineInterface) => {
98  const history = await read($, moves)
99  const game = replay(history)
100
101  if (game.position.turn === PLAYER || statusOf(game.position) !== 'playing') {
102    return
103  }
104
105  generation += 1
106  const request = generation
107  await update($, isThinking, () => true)
108  await update($, notice, () => null)
109
110  try {
111    const answer = await askClaude($, game)
112    if (request !== generation) {
113      return
114    }
115    if ('failure' in answer) {
116      await update($, notice, () => answer.failure)
117      return
118    }
119    await update($, remark, () => answer.remark)
120    await saveMoves($, [...history, uciOf(answer.move)])
121  } finally {
122    if (request === generation) {
123      await update($, isThinking, () => false)
124    }
125  }
126}
127
128const playMove = async ($: EngineInterface, text: string) => {
129  const history = await read($, moves)
130  const game = replay(history)
131  const refuse = (shown: Notice) => update($, notice, () => shown)
132
133  if (statusOf(game.position) !== 'playing') {
134    await refuse(say('gameOver'))
135    return
136  }
137  if (game.position.turn !== PLAYER || (await read($, isThinking))) {
138    await refuse(say('waitForClaude'))
139    return
140  }
141
142  const move = parseMove(game.position, text)
143  if (!move) {
144    await refuse(say('illegalMove', { move: text.trim() }))
145    return
146  }
147
148  await update($, notice, () => null)
149  await update($, remark, () => null)
150  await saveMoves($, [...history, uciOf(move)])
151  void claudeMove($)
152}
153
154const resetThinking = async ($: EngineInterface) => {
155  generation += 1
156  await update($, isThinking, () => false)
157  await update($, notice, () => null)
158  await update($, remark, () => null)
159}
160
161const newGame = async ($: EngineInterface) => {
162  await resetThinking($)
163  await saveMoves($, [])
164}
165
166const undo = async ($: EngineInterface) => {
167  const history = await read($, moves)
168  const drop = history.length % 2 === 1 ? 1 : 2
169
170  await resetThinking($)
171  await saveMoves($, history.slice(0, Math.max(0, history.length - drop)))
172}
173
174const openBoard = async ($: EngineInterface) =>
175  $.ui.open({ id: PANE, title: translate(await read($, language), 'paneTitle') })
176
177const chooseLanguage = async ($: EngineInterface, value: string) => {
178  if (!isLanguage(value)) {
179    return
180  }
181
182  await update($, language, () => value)
183  await $.store.set(LANGUAGE_KEY, value)
184  await openBoard($)
185}
186
187const headlineKeyOf = (position: Position, status: GameStatus, thinking: boolean): MessageKey => {
188  if (status === 'checkmate') {
189    return position.turn === PLAYER ? 'claudeWins' : 'youWin'
190  }
191  if (status !== 'playing') {
192    return DRAW_KEYS[status]
193  }
194  if (thinking) {
195    return 'thinking'
196  }
197  if (position.turn !== PLAYER) {
198    return 'claudesTurn'
199  }
200
201  return inCheck(position) ? 'yourTurnInCheck' : 'yourTurn'
202}
203
204const glyphsOf = (position: Position) =>
205  position.board.map(piece => (piece ? `${GLYPHS[piece.color][piece.type]}${TEXT_PRESENTATION}` : ''))
206
207const lastSquaresOf = (game: Game) => (game.lastMove ? [game.lastMove.from, game.lastMove.to] : [])
208
209const boardPropsOf = (game: Game, isLocked: boolean): BoardProps => {
210  const targets: Record<string, number[]> = {}
211
212  if (!isLocked) {
213    for (const move of legalMoves(game.position)) {
214      const reachable = targets[String(move.from)] ?? []
215      if (!reachable.includes(move.to)) {
216        targets[String(move.from)] = [...reachable, move.to]
217      }
218    }
219  }
220
221  return { squares: glyphsOf(game.position), last: lastSquaresOf(game), targets, isLocked }
222}
223
224const boardClient = ($: EngineInterface, e: RenderInput<'Pane', 'terminal' | 'desktop'>, props: BoardProps) => {
225  const { Client } = $.ui.resolve(e)
226  const isSideBySide = e.props.bodyColumns >= boardColumnsFor(1) + SIDE_GAP + SIDE_COLUMNS
227  const columns = isSideBySide ? e.props.bodyColumns - SIDE_GAP - SIDE_COLUMNS : e.props.bodyColumns
228  const squareRows = squareRowsFor(columns, e.props.scroll.bodyRows - CONTROL_ROWS)
229
230  return (
231    <Client
232      key={BOARD_KEY}
233      module="./board.tsx"
234      props={props}
235      width={boardColumnsFor(squareRows)}
236      height={8 * squareRows + FILE_LABEL_ROWS}
237    />
238  )
239}
240
241const staticBoard = ($: EngineInterface, e: RenderInput<'Pane'>, game: Game) => {
242  const squares = glyphsOf(game.position)
243  const last = lastSquaresOf(game)
244
245  return drawBoard($.ui.resolve(e), {
246    squareRows: STATIC_SQUARE_ROWS,
247    glyphSize: 'body',
248    glyphOf: square => squares[square] ?? '',
249    colorOf: (square, rank, file) => squareColor((rank + file) % 2 === 1, last.includes(square)),
250  })
251}
252
253const moveInput = ($: EngineInterface, e: RenderInput<'Pane', Exclude<RenderSurface, 'mobile'>>, current: Language) => {
254  const { Input } = $.ui.resolve(e)
255
256  return (
257    <Input
258      key="move"
259      label={translate(current, 'moveLabel')}
260      value=""
261      placeholder={translate(current, 'movePlaceholder')}
262      submitLabel={translate(current, 'moveSubmit')}
263      onSubmit={value => void playMove($, value)}
264    />
265  )
266}
267
268const languageSelect = ($: EngineInterface, e: RenderInput<'Pane', Exclude<RenderSurface, 'mobile'>>, current: Language) => {
269  const { Select } = $.ui.resolve(e)
270
271  return (
272    <Select
273      key="language"
274      label={translate(current, 'languageLabel')}
275      options={LANGUAGES.map(option => ({ value: option.value, label: option.label }))}
276      value={current}
277      onSelect={value => void chooseLanguage($, value)}
278    />
279  )
280}
281
282const languageToggle = ($: EngineInterface, e: RenderInput<'Pane', 'mobile'>, current: Language) => {
283  const { Button } = $.ui.resolve(e)
284  const next = LANGUAGES.find(option => option.value !== current) ?? LANGUAGES[0]
285
286  return next && <Button key="language" dimColor label={next.label} onPress={() => void chooseLanguage($, next.value)} />
287}
288
289const isBoardMove = (data: unknown): data is { move: [number, number] } => {
290  const move = (data as { move?: unknown } | null)?.move
291
292  return Array.isArray(move) && move.length === 2 && move.every(square => Number.isInteger(square) && square >= 0 && square < 64)
293}
294
295export const register: Register = on => {
296  on('session.start', async ($, e, next) => {
297    await $.command.register({
298      name: 'chess',
299      description: 'Play chess against Claude in a pane, any time',
300    })
301    const [savedGame, savedLanguage] = await Promise.all([$.store.get(GAME_KEY), $.store.get(LANGUAGE_KEY)])
302
303    if (Array.isArray(savedGame) && savedGame.every(item => typeof item === 'string')) {
304      await update($, moves, () => savedGame as string[])
305    }
306    if (isLanguage(savedLanguage)) {
307      await update($, language, () => savedLanguage)
308    }
309
310    return next(e)
311  })
312
313  on('command.run', { command: 'chess' }, async $ => {
314    const opened = await openBoard($)
315    const current = await read($, language)
316
317    return {
318      text: opened.isPlaced
319        ? translate(current, 'boardOpened')
320        : translate(current, 'boardWaiting', { reason: opened.reason }),
321    }
322  })
323
324  on('ui.message', async ($, e) => {
325    if (e.element === BOARD_KEY && isBoardMove(e.data)) {
326      const [from, to] = e.data.move
327      await playMove($, `${squareName(from)}${squareName(to)}`)
328    }
329
330    return {}
331  })
332
333  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
334    const { Box, Button, Text } = $.ui.resolve(e)
335    const [history, thinking, shown, said, current] = await Promise.all([
336      read($, moves),
337      read($, isThinking),
338      read($, notice),
339      read($, remark),
340      read($, language),
341    ])
342    const game = replay(history)
343    const status = statusOf(game.position)
344    const isPlayersTurn = game.position.turn === PLAYER && status === 'playing' && !thinking
345    const isClaudesTurn = game.position.turn !== PLAYER && status === 'playing' && !thinking
346
347    return (
348      <Box flexDirection="column" gap={1}>
349        <Box flexWrap="wrap" columnGap={SIDE_GAP} rowGap={1}>
350          {e.surface === 'terminal' || e.surface === 'desktop'
351            ? boardClient($, e, boardPropsOf(game, !isPlayersTurn))
352            : staticBoard($, e, game)}
353
354          <Box flexDirection="column" gap={1} width={SIDE_COLUMNS}>
355            <Text weight="bold">{translate(current, headlineKeyOf(game.position, status, thinking))}</Text>
356            {said !== null && !thinking && <Text italic>{translate(current, 'claudeSays', { remark: said })}</Text>}
357            {(e.surface === 'terminal' || e.surface === 'desktop') && (
358              <Text dimColor>{translate(current, 'clickHint')}</Text>
359            )}
360            {game.sans.length === 0 ? (
361              <Text dimColor>{translate(current, 'youOpen')}</Text>
362            ) : (
363              <Box flexDirection="column">
364                {movePairs(game.sans)
365                  .slice(-HISTORY_ROWS)
366                  .map(row => (
367                    <Text mono>{row}</Text>
368                  ))}
369              </Box>
370            )}
371            {shown !== null && <Text color="error">{noticeText(current, shown)}</Text>}
372          </Box>
373        </Box>
374
375        {e.surface === 'vscode' && moveInput($, e, current)}
376        {e.surface === 'mobile' && <Text dimColor>{translate(current, 'phoneHint')}</Text>}
377
378        <Box flexWrap="wrap" columnGap={1} rowGap={1} alignItems="center">
379          <Button key="new" hotkey="n" dimColor label={translate(current, 'newGame')} onPress={() => void newGame($)} />
380          <Button key="undo" hotkey="u" dimColor label={translate(current, 'undo')} onPress={() => void undo($)} />
381          {isClaudesTurn && (
382            <Button
383              key="retry"
384              hotkey="r"
385              variant="primary"
386              label={translate(current, 'askClaude')}
387              onPress={() => void claudeMove($)}
388            />
389          )}
390          {e.surface === 'mobile' ? languageToggle($, e, current) : languageSelect($, e, current)}
391        </Box>
392      </Box>
393    )
394  })
395}
396
hooks/board.tsx 118 lines
1import type { ClientModule, ClientSurface } from 'claude-code'
2
3import { drawBoard } from './grid'
4import type { GlyphSize } from './grid'
5import { RANK_LABEL_COLUMNS, squareColumnsFor, squareRowsFor } from './layout'
6import { BOARD_COLORS, squareColor } from './palette'
7
8export type BoardProps = {
9  squares: string[]
10  last: number[]
11  targets: Record<string, number[]>
12  isLocked: boolean
13}
14
15type BoardState = {
16  cursor: number
17  selected: number | null
18  usesKeys: boolean
19  live: { props: BoardProps; squareRows: number }
20}
21
22const START_CURSOR = 12
23const TARGET_MARK = '•'
24const GLYPH_SIZES: readonly GlyphSize[] = ['body', 'body', 'heading', 'title']
25const STEPS: Partial<Record<string, readonly [number, number]>> = {
26  up: [0, 1],
27  down: [0, -1],
28  left: [-1, 0],
29  right: [1, 0],
30}
31const CHOOSE_KEYS = ['return', ' ']
32
33const squareAt = (x: number, y: number, squareRows: number) => {
34  const file = Math.floor((x - RANK_LABEL_COLUMNS) / squareColumnsFor(squareRows))
35  const rank = 7 - Math.floor(y / squareRows)
36
37  return x < RANK_LABEL_COLUMNS || file > 7 || rank < 0 || rank > 7 ? null : rank * 8 + file
38}
39
40const stepped = (square: number, [files, ranks]: readonly [number, number]) => {
41  const file = Math.min(7, Math.max(0, (square % 8) + files))
42  const rank = Math.min(7, Math.max(0, Math.floor(square / 8) + ranks))
43
44  return rank * 8 + file
45}
46
47const choose = (surface: ClientSurface<BoardState>, square: number, usesKeys: boolean) => {
48  const now = surface.state
49  if (!now) {
50    return
51  }
52
53  const { targets, isLocked } = now.live.props
54  const reachable = now.selected === null ? [] : (targets[String(now.selected)] ?? [])
55
56  if (!isLocked && now.selected !== null && reachable.includes(square)) {
57    surface.post({ move: [now.selected, square] })
58    surface.setState({ ...now, cursor: square, selected: null, usesKeys })
59    return
60  }
61
62  const canSelect = !isLocked && targets[String(square)] !== undefined && now.selected !== square
63  surface.setState({ ...now, cursor: square, selected: canSelect ? square : null, usesKeys })
64}
65
66const ChessBoard: ClientModule<BoardProps, BoardState> = (props, surface) => {
67  const squareRows = squareRowsFor(surface.columns, surface.rows)
68  const glyphSize = GLYPH_SIZES[Math.min(squareRows, GLYPH_SIZES.length - 1)] ?? 'body'
69
70  if (surface.state === undefined) {
71    surface.onPointer(event => {
72      const live = surface.state?.live.squareRows ?? 1
73      const square = event.type === 'down' && event.button === 'left' ? squareAt(event.x, event.y, live) : null
74      if (square !== null) {
75        choose(surface, square, false)
76      }
77    })
78    surface.onKey(event => {
79      const now = surface.state
80      const step = STEPS[event.key]
81      if (!now) {
82        return
83      }
84      if (step) {
85        surface.setState({ ...now, cursor: stepped(now.cursor, step), usesKeys: true })
86      } else if (CHOOSE_KEYS.includes(event.key)) {
87        choose(surface, now.cursor, true)
88      }
89    })
90    surface.setState({ cursor: START_CURSOR, selected: null, usesKeys: false, live: { props, squareRows } })
91  }
92
93  const state = surface.state ?? { cursor: START_CURSOR, selected: null, usesKeys: false, live: { props, squareRows } }
94  state.live.props = props
95  state.live.squareRows = squareRows
96
97  const selected = props.isLocked ? null : state.selected
98  const reachable = selected === null ? [] : (props.targets[String(selected)] ?? [])
99
100  const colorOf = (square: number, rank: number, file: number) =>
101    state.usesKeys && state.cursor === square
102      ? BOARD_COLORS.cursor
103      : square === selected
104        ? BOARD_COLORS.selected
105        : reachable.includes(square)
106          ? BOARD_COLORS.target
107          : squareColor((rank + file) % 2 === 1, props.last.includes(square))
108
109  return drawBoard(surface.elements, {
110    squareRows,
111    glyphSize,
112    glyphOf: square => props.squares[square] || (reachable.includes(square) ? TARGET_MARK : ''),
113    colorOf,
114  })
115}
116
117export default ChessBoard
118
hooks/chess.ts 435 lines
1export type Color = 'w' | 'b'
2export type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k'
3export type Piece = { color: Color; type: PieceType }
4export type Castling = { wk: boolean; wq: boolean; bk: boolean; bq: boolean }
5
6export type Position = {
7  board: readonly (Piece | null)[]
8  turn: Color
9  castling: Castling
10  enPassant: number | null
11  halfmove: number
12  fullmove: number
13}
14
15export type Move = {
16  from: number
17  to: number
18  piece: PieceType
19  captured: PieceType | null
20  promotion: PieceType | null
21  castle: 'k' | 'q' | null
22  isEnPassant: boolean
23}
24
25export type GameStatus = 'playing' | 'checkmate' | 'stalemate' | 'fifty-moves' | 'insufficient-material'
26
27export const START_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
28
29const FILES = 'abcdefgh'
30const PROMOTIONS: readonly PieceType[] = ['q', 'r', 'b', 'n']
31const KNIGHT_STEPS = [[1, 2], [2, 1], [2, -1], [1, -2], [-1, -2], [-2, -1], [-2, 1], [-1, 2]] as const
32const KING_STEPS = [[1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], [0, -1], [1, -1]] as const
33const DIAGONALS = [[1, 1], [1, -1], [-1, 1], [-1, -1]] as const
34const LINES = [[1, 0], [-1, 0], [0, 1], [0, -1]] as const
35const SLIDES: Partial<Record<PieceType, readonly (readonly [number, number])[]>> = {
36  b: DIAGONALS,
37  r: LINES,
38  q: [...DIAGONALS, ...LINES],
39}
40const CORNER_RIGHTS: Partial<Record<number, keyof Castling>> = { 0: 'wq', 7: 'wk', 56: 'bq', 63: 'bk' }
41
42export const fileOf = (square: number) => square % 8
43export const rankOf = (square: number) => Math.floor(square / 8)
44export const opponent = (color: Color): Color => (color === 'w' ? 'b' : 'w')
45export const squareName = (square: number) => `${FILES.charAt(fileOf(square))}${rankOf(square) + 1}`
46export const uciOf = (move: Move) => `${squareName(move.from)}${squareName(move.to)}${move.promotion ?? ''}`
47
48const forward = (color: Color) => (color === 'w' ? 1 : -1)
49
50const step = (square: number, files: number, ranks: number) => {
51  const file = fileOf(square) + files
52  const rank = rankOf(square) + ranks
53
54  return file < 0 || file > 7 || rank < 0 || rank > 7 ? null : rank * 8 + file
55}
56
57export const squareFrom = (name: string) => {
58  const file = FILES.indexOf(name.charAt(0))
59  const rank = Number(name.charAt(1)) - 1
60
61  return name.length === 2 && file >= 0 && rank >= 0 && rank <= 7 ? rank * 8 + file : null
62}
63
64export const parseFen = (fen: string): Position => {
65  const [placement = '', turn = 'w', castling = '-', enPassant = '-', halfmove = '0', fullmove = '1'] = fen.trim().split(/\s+/)
66  const board: (Piece | null)[] = Array.from({ length: 64 }, () => null)
67
68  placement.split('/').forEach((row, index) => {
69    let file = 0
70    for (const char of row) {
71      if (/\d/.test(char)) {
72        file += Number(char)
73        continue
74      }
75      board[(7 - index) * 8 + file] = {
76        color: char === char.toUpperCase() ? 'w' : 'b',
77        type: char.toLowerCase() as PieceType,
78      }
79      file += 1
80    }
81  })
82
83  return {
84    board,
85    turn: turn === 'b' ? 'b' : 'w',
86    castling: {
87      wk: castling.includes('K'),
88      wq: castling.includes('Q'),
89      bk: castling.includes('k'),
90      bq: castling.includes('q'),
91    },
92    enPassant: enPassant === '-' ? null : squareFrom(enPassant),
93    halfmove: Number(halfmove),
94    fullmove: Number(fullmove),
95  }
96}
97
98export const toFen = (position: Position) => {
99  const rows = Array.from({ length: 8 }, (_, index) => {
100    const rank = 7 - index
101    let row = ''
102    let empty = 0
103
104    for (let file = 0; file < 8; file += 1) {
105      const piece = position.board[rank * 8 + file]
106      if (!piece) {
107        empty += 1
108        continue
109      }
110      row += `${empty > 0 ? empty : ''}${piece.color === 'w' ? piece.type.toUpperCase() : piece.type}`
111      empty = 0
112    }
113
114    return `${row}${empty > 0 ? empty : ''}`
115  })
116  const { wk, wq, bk, bq } = position.castling
117  const rights = `${wk ? 'K' : ''}${wq ? 'Q' : ''}${bk ? 'k' : ''}${bq ? 'q' : ''}` || '-'
118  const enPassant = position.enPassant === null ? '-' : squareName(position.enPassant)
119
120  return [rows.join('/'), position.turn, rights, enPassant, position.halfmove, position.fullmove].join(' ')
121}
122
123export const isAttacked = (board: Position['board'], square: number, by: Color) => {
124  const holds = (target: number | null, types: string) => {
125    const piece = target === null ? null : board[target]
126
127    return !!piece && piece.color === by && types.includes(piece.type)
128  }
129  const ray = (directions: readonly (readonly [number, number])[], types: string) =>
130    directions.some(([files, ranks]) => {
131      let target = step(square, files, ranks)
132      while (target !== null) {
133        const piece = board[target]
134        if (piece) {
135          return piece.color === by && types.includes(piece.type)
136        }
137        target = step(target, files, ranks)
138      }
139      return false
140    })
141  const pawnRank = -forward(by)
142
143  return (
144    holds(step(square, -1, pawnRank), 'p') ||
145    holds(step(square, 1, pawnRank), 'p') ||
146    KNIGHT_STEPS.some(([files, ranks]) => holds(step(square, files, ranks), 'n')) ||
147    KING_STEPS.some(([files, ranks]) => holds(step(square, files, ranks), 'k')) ||
148    ray(DIAGONALS, 'bq') ||
149    ray(LINES, 'rq')
150  )
151}
152
153export const inCheck = (position: Position, color: Color = position.turn) => {
154  const king = position.board.findIndex(piece => piece?.color === color && piece.type === 'k')
155
156  return king >= 0 && isAttacked(position.board, king, opponent(color))
157}
158
159const moveOf = (position: Position, from: number, to: number, extra: Partial<Move> = {}): Move => ({
160  from,
161  to,
162  piece: position.board[from]?.type ?? 'p',
163  captured: position.board[to]?.type ?? null,
164  promotion: null,
165  castle: null,
166  isEnPassant: false,
167  ...extra,
168})
169
170const pawnMoves = (position: Position, from: number): Move[] => {
171  const color = position.turn
172  const direction = forward(color)
173  const lastRank = color === 'w' ? 7 : 0
174  const moves: Move[] = []
175  const add = (to: number, extra: Partial<Move> = {}) => {
176    if (rankOf(to) === lastRank) {
177      for (const promotion of PROMOTIONS) {
178        moves.push(moveOf(position, from, to, { ...extra, promotion }))
179      }
180    } else {
181      moves.push(moveOf(position, from, to, extra))
182    }
183  }
184  const one = step(from, 0, direction)
185
186  if (one !== null && !position.board[one]) {
187    add(one)
188    const two = step(from, 0, 2 * direction)
189    if (rankOf(from) === (color === 'w' ? 1 : 6) && two !== null && !position.board[two]) {
190      add(two)
191    }
192  }
193
194  for (const side of [-1, 1]) {
195    const to = step(from, side, direction)
196    if (to === null) {
197      continue
198    }
199    const target = position.board[to]
200    if (target && target.color !== color) {
201      add(to)
202    } else if (!target && to === position.enPassant) {
203      add(to, { captured: 'p', isEnPassant: true })
204    }
205  }
206
207  return moves
208}
209
210const pieceMoves = (position: Position, from: number): Move[] => {
211  const piece = position.board[from]
212  if (!piece) {
213    return []
214  }
215
216  if (piece.type === 'n' || piece.type === 'k') {
217    return (piece.type === 'n' ? KNIGHT_STEPS : KING_STEPS)
218      .map(([files, ranks]) => step(from, files, ranks))
219      .filter((to): to is number => to !== null && position.board[to]?.color !== piece.color)
220      .map(to => moveOf(position, from, to))
221  }
222
223  const moves: Move[] = []
224  for (const [files, ranks] of SLIDES[piece.type] ?? []) {
225    let to = step(from, files, ranks)
226    while (to !== null) {
227      const target = position.board[to]
228      if (target?.color === piece.color) {
229        break
230      }
231      moves.push(moveOf(position, from, to))
232      if (target) {
233        break
234      }
235      to = step(to, files, ranks)
236    }
237  }
238
239  return moves
240}
241
242const castlingMoves = (position: Position): Move[] => {
243  const color = position.turn
244  const home = color === 'w' ? 0 : 56
245  const king = home + 4
246  const kingPiece = position.board[king]
247
248  if (kingPiece?.type !== 'k' || kingPiece.color !== color || inCheck(position, color)) {
249    return []
250  }
251
252  const enemy = opponent(color)
253  const sides = [
254    { castle: 'k', right: color === 'w' ? 'wk' : 'bk', rook: 7, empty: [5, 6], safe: [5, 6], target: 6 },
255    { castle: 'q', right: color === 'w' ? 'wq' : 'bq', rook: 0, empty: [1, 2, 3], safe: [3, 2], target: 2 },
256  ] as const
257
258  return sides
259    .filter(side => {
260      const rook = position.board[home + side.rook]
261
262      return (
263        position.castling[side.right] &&
264        rook?.type === 'r' &&
265        rook.color === color &&
266        side.empty.every(offset => !position.board[home + offset]) &&
267        side.safe.every(offset => !isAttacked(position.board, home + offset, enemy))
268      )
269    })
270    .map(side => moveOf(position, king, home + side.target, { castle: side.castle }))
271}
272
273const pseudoMoves = (position: Position): Move[] => [
274  ...position.board.flatMap((piece, from) => {
275    if (!piece || piece.color !== position.turn) {
276      return []
277    }
278    return piece.type === 'p' ? pawnMoves(position, from) : pieceMoves(position, from)
279  }),
280  ...castlingMoves(position),
281]
282
283export const applyMove = (position: Position, move: Move): Position => {
284  const board = [...position.board]
285  const color = position.turn
286  const piece = board[move.from] ?? { color, type: move.piece }
287
288  board[move.to] = move.promotion ? { color, type: move.promotion } : piece
289  board[move.from] = null
290
291  if (move.isEnPassant) {
292    board[move.to - 8 * forward(color)] = null
293  }
294
295  if (move.castle) {
296    const home = color === 'w' ? 0 : 56
297    const [rookFrom, rookTo] = move.castle === 'k' ? [home + 7, home + 5] : [home, home + 3]
298    board[rookTo] = board[rookFrom] ?? null
299    board[rookFrom] = null
300  }
301
302  const castling = { ...position.castling }
303  if (piece.type === 'k') {
304    castling[color === 'w' ? 'wk' : 'bk'] = false
305    castling[color === 'w' ? 'wq' : 'bq'] = false
306  }
307  for (const square of [move.from, move.to]) {
308    const right = CORNER_RIGHTS[square]
309    if (right) {
310      castling[right] = false
311    }
312  }
313
314  return {
315    board,
316    turn: opponent(color),
317    castling,
318    enPassant: piece.type === 'p' && Math.abs(move.to - move.from) === 16 ? (move.from + move.to) / 2 : null,
319    halfmove: piece.type === 'p' || move.captured ? 0 : position.halfmove + 1,
320    fullmove: position.fullmove + (color === 'b' ? 1 : 0),
321  }
322}
323
324export const legalMoves = (position: Position): Move[] =>
325  pseudoMoves(position).filter(move => !inCheck(applyMove(position, move), position.turn))
326
327export const statusOf = (position: Position): GameStatus => {
328  if (legalMoves(position).length === 0) {
329    return inCheck(position) ? 'checkmate' : 'stalemate'
330  }
331  if (position.halfmove >= 100) {
332    return 'fifty-moves'
333  }
334  const rest = position.board.filter((piece): piece is Piece => piece !== null && piece.type !== 'k')
335  const lone = rest[0]
336  if (rest.length === 0 || (rest.length === 1 && lone !== undefined && (lone.type === 'n' || lone.type === 'b'))) {
337    return 'insufficient-material'
338  }
339
340  return 'playing'
341}
342
343const sanBody = (position: Position, move: Move) => {
344  if (move.castle) {
345    return move.castle === 'k' ? 'O-O' : 'O-O-O'
346  }
347
348  const capture = move.captured === null ? '' : 'x'
349
350  if (move.piece === 'p') {
351    const origin = capture ? FILES.charAt(fileOf(move.from)) : ''
352    const promotion = move.promotion ? `=${move.promotion.toUpperCase()}` : ''
353
354    return `${origin}${capture}${squareName(move.to)}${promotion}`
355  }
356
357  const rivals = legalMoves(position).filter(
358    other => other.piece === move.piece && other.to === move.to && other.from !== move.from,
359  )
360  const sharesFile = rivals.some(other => fileOf(other.from) === fileOf(move.from))
361  const sharesRank = rivals.some(other => rankOf(other.from) === rankOf(move.from))
362  const origin =
363    rivals.length === 0
364      ? ''
365      : !sharesFile
366        ? FILES.charAt(fileOf(move.from))
367        : !sharesRank
368          ? String(rankOf(move.from) + 1)
369          : squareName(move.from)
370
371  return `${move.piece.toUpperCase()}${origin}${capture}${squareName(move.to)}`
372}
373
374export const toSan = (position: Position, move: Move) => {
375  const after = applyMove(position, move)
376  const suffix = inCheck(after) ? (legalMoves(after).length === 0 ? '#' : '+') : ''
377
378  return `${sanBody(position, move)}${suffix}`
379}
380
381const normalize = (text: string) =>
382  text
383    .trim()
384    .replace(/[+#!?\s]/g, '')
385    .replace(/0/g, 'O')
386    .replace(/^o-o-o$/i, 'O-O-O')
387    .replace(/^o-o$/i, 'O-O')
388
389const loose = (san: string) => san.replace(/[x=]/g, '')
390
391export const parseMove = (position: Position, text: string): Move | null => {
392  const legal = legalMoves(position)
393  const plain = normalize(text)
394  const uci = /^([a-h][1-8])([a-h][1-8])([qrbn])?$/i.exec(plain)
395
396  if (uci) {
397    const [, from = '', to = '', promotion] = uci
398    const matches = legal.filter(
399      move => squareName(move.from) === from.toLowerCase() && squareName(move.to) === to.toLowerCase(),
400    )
401    const wanted = promotion?.toLowerCase() ?? (matches.some(move => move.promotion) ? 'q' : null)
402
403    return matches.find(move => move.promotion === wanted) ?? null
404  }
405
406  const candidates = [plain, /^[nrqk]/.test(plain) ? `${plain.charAt(0).toUpperCase()}${plain.slice(1)}` : plain]
407
408  return (
409    legal.find(move => {
410      const san = loose(sanBody(position, move))
411      const unnamedQueen = move.promotion === 'q' ? san.slice(0, -1) : null
412
413      return candidates.some(candidate => loose(candidate) === san || loose(candidate) === unnamedQueen)
414    }) ?? null
415  )
416}
417
418export const replay = (history: readonly string[]) => {
419  let position = parseFen(START_FEN)
420  let lastMove: Move | null = null
421  const sans: string[] = []
422
423  for (const text of history) {
424    const move = parseMove(position, text)
425    if (!move) {
426      break
427    }
428    sans.push(toSan(position, move))
429    position = applyMove(position, move)
430    lastMove = move
431  }
432
433  return { position, sans, lastMove }
434}
435
hooks/grid.tsx 55 lines
1import type { ClientElements } from 'claude-code'
2
3import { FILE_INDEXES, FILE_LABEL_ROWS, FILE_LETTERS, RANK_LABEL_COLUMNS, RANKS, squareColumnsFor } from './layout'
4import { BOARD_COLORS } from './palette'
5
6export type GlyphSize = 'body' | 'heading' | 'title'
7
8export type BoardGrid = {
9  squareRows: number
10  glyphSize: GlyphSize
11  glyphOf: (square: number) => string
12  colorOf: (square: number, rank: number, file: number) => string
13}
14
15export const drawBoard = ({ Box, Text }: Pick<ClientElements, 'Box' | 'Text'>, grid: BoardGrid) => {
16  const squareColumns = squareColumnsFor(grid.squareRows)
17
18  return (
19    <Box flexDirection="column">
20      {RANKS.map(rank => (
21        <Box flexDirection="row">
22          <Box width={RANK_LABEL_COLUMNS} height={grid.squareRows} alignItems="center">
23            <Text dimColor>{String(rank + 1)}</Text>
24          </Box>
25          {FILE_INDEXES.map(file => {
26            const square = rank * 8 + file
27
28            return (
29              <Box
30                width={squareColumns}
31                height={grid.squareRows}
32                justifyContent="center"
33                alignItems="center"
34                backgroundColor={grid.colorOf(square, rank, file)}
35              >
36                <Text size={grid.glyphSize} color={BOARD_COLORS.piece}>
37                  {grid.glyphOf(square) || ' '}
38                </Text>
39              </Box>
40            )
41          })}
42        </Box>
43      ))}
44      <Box flexDirection="row">
45        <Box width={RANK_LABEL_COLUMNS} height={FILE_LABEL_ROWS} />
46        {FILE_LETTERS.map(letter => (
47          <Box width={squareColumns} height={FILE_LABEL_ROWS} justifyContent="center">
48            <Text dimColor>{letter}</Text>
49          </Box>
50        ))}
51      </Box>
52    </Box>
53  )
54}
55
hooks/layout.ts 18 lines
1export const RANK_LABEL_COLUMNS = 2
2export const FILE_LABEL_ROWS = 1
3export const MAX_SQUARE_ROWS = 5
4export const RANKS = [7, 6, 5, 4, 3, 2, 1, 0]
5export const FILE_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7]
6export const FILE_LETTERS = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
7
8export const squareColumnsFor = (squareRows: number) => 2 * squareRows + 1
9
10export const boardColumnsFor = (squareRows: number) => RANK_LABEL_COLUMNS + 8 * squareColumnsFor(squareRows)
11
12export const squareRowsFor = (columns: number, rows: number) => {
13  const byRows = Math.floor((rows - FILE_LABEL_ROWS) / 8)
14  const byColumns = Math.floor(((columns - RANK_LABEL_COLUMNS) / 8 - 1) / 2)
15
16  return Math.max(1, Math.min(byRows, byColumns, MAX_SQUARE_ROWS))
17}
18
hooks/locales.ts 85 lines
1import type { Language } from '../types'
2
3export const LANGUAGES: readonly { value: Language; label: string; name: string }[] = [
4  { value: 'en', label: 'English', name: 'English' },
5  { value: 'es', label: 'Español', name: 'Spanish' },
6]
7
8export const DEFAULT_LANGUAGE: Language = 'en'
9
10const ENGLISH = {
11  paneTitle: 'Chess',
12  boardOpened: 'Board open.',
13  boardWaiting: 'The board is waiting: {reason}',
14  yourTurn: 'Your move: you play White.',
15  yourTurnInCheck: 'Check! Your move: you play White.',
16  claudesTurn: "Claude's move.",
17  thinking: 'Claude is thinking…',
18  youWin: 'Checkmate! You win.',
19  claudeWins: 'Checkmate. Claude wins.',
20  stalemate: 'Draw by stalemate.',
21  fiftyMoves: 'Draw by the fifty-move rule.',
22  insufficientMaterial: 'Draw: not enough material to mate.',
23  youOpen: 'You open.',
24  clickHint: 'Click a piece, then its square. Keyboard: arrows and Enter.',
25  phoneHint: 'Open the board on a computer to play.',
26  moveLabel: 'Your move',
27  movePlaceholder: 'e4, Nf3, O-O or e2e4',
28  moveSubmit: 'play',
29  newGame: 'New game',
30  undo: 'Undo',
31  askClaude: 'Ask Claude to move',
32  languageLabel: 'Language',
33  gameOver: 'The game is over: start a new one.',
34  waitForClaude: 'Wait for Claude to move.',
35  illegalMove: '“{move}” is not a legal move.',
36  noAnswer: 'Claude did not answer ({reason}).',
37  noValidMove: 'Claude did not give a valid move.',
38  claudeSays: 'Claude: {remark}',
39}
40
41export type MessageKey = keyof typeof ENGLISH
42
43const SPANISH: Record<MessageKey, string> = {
44  paneTitle: 'Ajedrez',
45  boardOpened: 'Tablero abierto.',
46  boardWaiting: 'El tablero quedó a la espera: {reason}',
47  yourTurn: 'Te toca: juegas con blancas.',
48  yourTurnInCheck: '¡Jaque! Te toca: juegas con blancas.',
49  claudesTurn: 'Le toca a Claude.',
50  thinking: 'Claude está pensando…',
51  youWin: '¡Jaque mate! Ganas tú.',
52  claudeWins: 'Jaque mate. Gana Claude.',
53  stalemate: 'Tablas por ahogado.',
54  fiftyMoves: 'Tablas por la regla de los 50 movimientos.',
55  insufficientMaterial: 'Tablas: no queda material para dar mate.',
56  youOpen: 'Abres tú.',
57  clickHint: 'Pulsa una pieza y luego su destino. Con el teclado: flechas y Enter.',
58  phoneHint: 'Abre el tablero en un ordenador para jugar.',
59  moveLabel: 'Tu jugada',
60  movePlaceholder: 'e4, Nf3, O-O o e2e4',
61  moveSubmit: 'jugar',
62  newGame: 'Nueva partida',
63  undo: 'Deshacer',
64  askClaude: 'Pedir jugada a Claude',
65  languageLabel: 'Idioma',
66  gameOver: 'La partida terminó: empieza una nueva.',
67  waitForClaude: 'Espera a que juegue Claude.',
68  illegalMove: '«{move}» no es una jugada legal.',
69  noAnswer: 'Claude no respondió ({reason}).',
70  noValidMove: 'Claude no devolvió una jugada válida.',
71  claudeSays: 'Claude: {remark}',
72}
73
74const MESSAGES: Record<Language, Record<MessageKey, string>> = { en: ENGLISH, es: SPANISH }
75
76export const isLanguage = (value: unknown): value is Language => LANGUAGES.some(language => language.value === value)
77
78export const isMessageKey = (value: string): value is MessageKey => Object.hasOwn(ENGLISH, value)
79
80export const languageName = (language: Language) =>
81  LANGUAGES.find(candidate => candidate.value === language)?.name ?? 'English'
82
83export const translate = (language: Language, key: MessageKey, params: Readonly<Record<string, string>> = {}) =>
84  Object.entries(params).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, value), MESSAGES[language][key])
85
hooks/palette.ts 20 lines
1export const BOARD_COLORS = {
2  light: '#f0d9b5',
3  dark: '#b58863',
4  lightLast: '#cdd26a',
5  darkLast: '#aaa23a',
6  selected: '#f7ec74',
7  target: '#a9c26b',
8  cursor: '#86a666',
9  piece: '#111111',
10} as const
11
12export const squareColor = (isLight: boolean, isLast: boolean) =>
13  isLast
14    ? isLight
15      ? BOARD_COLORS.lightLast
16      : BOARD_COLORS.darkLast
17    : isLight
18      ? BOARD_COLORS.light
19      : BOARD_COLORS.dark
20
hooks/prompt.ts 72 lines
1import { parseMove, squareName, toFen, uciOf } from './chess'
2import type { Move, Piece, Position } from './chess'
3import { FILE_INDEXES, FILE_LETTERS, RANKS } from './layout'
4
5const MAX_THINKING_WORDS = 120
6const MAX_REMARK_LENGTH = 200
7const EMPTY_SQUARE = '.'
8const PIECE_ORDER = 'kqrbnp'
9
10const MOVE_LINE = /^\s*MOVE:\s*(\S+)/im
11const SAY_LINE = /^\s*SAY:\s*(.+)$/im
12const UCI_TOKEN = /[a-h][1-8][a-h][1-8][qrbn]?/gi
13
14export const systemPromptFor = (languageName: string) =>
15  [
16    'You are Claude, playing a friendly game of chess as Black against the user.',
17    'Think briefly before you move: read the board, check what White threatens (checks, captures,',
18    'mating ideas such as an open back rank) and whether your king has an escape square, then',
19    `compare two or three candidate moves. Keep your thinking under ${MAX_THINKING_WORDS} words.`,
20    'Then end with exactly these two lines and nothing after them:',
21    'MOVE: <your move in UCI notation, taken from the list of legal moves>',
22    `SAY: <one short, friendly sentence to the user about your move, in ${languageName}>`,
23  ].join('\n')
24
25const letterOf = (piece: Piece) => (piece.color === 'w' ? piece.type.toUpperCase() : piece.type)
26
27export const diagramOf = (position: Position) =>
28  [
29    ...RANKS.map(
30      rank =>
31        `${rank + 1} ${FILE_INDEXES.map(file => {
32          const piece = position.board[rank * 8 + file]
33          return piece ? letterOf(piece) : EMPTY_SQUARE
34        }).join(' ')}`,
35    ),
36    `  ${FILE_LETTERS.join(' ')}`,
37  ].join('\n')
38
39const piecesOf = (position: Position, color: Piece['color']) =>
40  position.board
41    .flatMap((piece, square) =>
42      piece?.color === color ? [{ type: piece.type, name: `${piece.type.toUpperCase()}${squareName(square)}` }] : [],
43    )
44    .sort((first, second) => PIECE_ORDER.indexOf(first.type) - PIECE_ORDER.indexOf(second.type))
45    .map(piece => piece.name)
46    .join(' ')
47
48export const promptFor = (position: Position, history: string, legal: readonly Move[]) =>
49  [
50    'Board (White at the bottom; uppercase is White, lowercase is Black, a dot is empty):',
51    diagramOf(position),
52    '',
53    `Black pieces (yours): ${piecesOf(position, 'b')}`,
54    `White pieces: ${piecesOf(position, 'w')}`,
55    `FEN: ${toFen(position)}`,
56    `Moves so far: ${history === '' ? 'none' : history}`,
57    `Legal moves for Black (UCI): ${legal.map(uciOf).join(' ')}`,
58  ].join('\n')
59
60export const readReply = (position: Position, reply: string) => {
61  const named = MOVE_LINE.exec(reply)?.[1]
62  const fromLine = named === undefined ? null : parseMove(position, named)
63  const tokens = reply.match(UCI_TOKEN) ?? []
64  const lastLegal = tokens
65    .map(token => parseMove(position, token))
66    .filter(move => move !== null)
67    .at(-1)
68  const remark = SAY_LINE.exec(reply)?.[1]?.trim().slice(0, MAX_REMARK_LENGTH) ?? null
69
70  return { move: fromLine ?? lastLegal ?? null, remark: remark === '' ? null : remark }
71}
72
types/index.d.ts 16 lines
1export type Language = 'en' | 'es'
2
3export type Notice = { key: string; params: Record<string, string> }
4
5declare module 'claude-code' {
6  interface PluginState {
7    chess: {
8      moves: string[]
9      isThinking: boolean
10      notice: Notice | null
11      remark: string | null
12      language: Language
13    }
14  }
15}
16