SLOPSHOPPER

snake-pane

Snake in the side pane while Claude works: opens when you send a prompt, pauses when the turn ends, keeps your best score. /jatek be|ki. Hungarian or English.

newpanecommandprompt
v0.1.0no licenseupdated 2026-10-07Szotasz/claude-mods/plugins/snake-pane
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · snake-pane
│ ┃ 🐍 Snake ✕ › fix the failing auth test and add an audit log call │ ┃ ✔ Claude is done │ ┃ ▣ client module ./snake.tsx ⏺ Read(src/auth.ts) │ ┃ w: ↑ a: ← s: ↓ d: → p: pause r: new ⎿ Read 6 lines │ ┃ Click the board (arrows, wasd, space), or ⏺ Update(src/auth.ts) │ ┃ ctrl+x tab, then w a s d p r ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /jatek │ ⎿ snake-pane: Game opened. Click the board and steer with the arro │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 🐍 Snake
✔ Claude is done ▣ client module ./snake.tsx w: ↑ a: ← s: ↓ d: → p: pause r: new Click the board (arrows, wasd, space), or ctrl+x tab, then w a s d p r
README

claude-mods

Saját Claude Code mod-ok (function hook pluginek) egy pluginpiacon.

Telepítés

claude plugin marketplace add Szotasz/claude-mods
claude plugin install usage-band@claude-mods
claude plugin install usage-header@claude-mods
claude plugin install project-pane@claude-mods
claude plugin install focus-mode@claude-mods
claude plugin install tool-hub@claude-mods
claude plugin install snake-pane@claude-mods
claude plugin install supabase-guard@claude-mods
claude plugin install public-repo-guard@claude-mods
claude plugin install decision-popup@claude-mods

Frissítés: claude plugin update usage-band@claude-mods, majd /reload-plugins (vagy új session).

Pluginek

usage-band

A státuszsorba (a prompt alá) írja:

🤖 Opus 5.5  │  Ctx ███░░░░░ 42% 420k/1M  │  5h ███░░░░░ 34% → 16:20  │  Hét ███████░ 93% ⚠ → Cs 10:00
  • modell neve (/model váltáskor és turn végén frissül)
  • kontextusablak telítettsége
  • 5 órás és heti rate-limit, reset időponttal (csak Pro/Max előfizetéssel)
  • ⚠ 90% felett, és egy toast, amikor egy limit átlépi (ablakonként, resetenként egyszer)

Nyelv / Language: /config → usage-band.language, vagy a settings.json-ban:

"pluginConfigs": { "usage-band": { "language": "en" } }
  • auto (alapértelmezett): magyar, ha a rendszer nyelve (LANG) magyar, különben angol
  • hu: magyar (Hét, Cs 10:00)
  • en: English (Week, Thu 10:00)

Követelmény: Claude Code 2.1.289 körüli verzió. A mod-API early access, kiadásról kiadásra változhat.

usage-header

Sáv a prompt fölött: a kontextusablak kategóriánként színezve, alatta az 5 órás és a heti limit.

Kontextus  ██▓▓▓▓▓▓████████████▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ 42% 84k/200k
           ■ Rendszerprompt 3k  ■ Eszközök 12k  ■ Memória 400  ■ Üzenetek 69k  ▒ Tömörítési tartalék 33k  ░ Szabad 83k
Limitek    5 óra ███████░░░░░░░░░░░░░ 34% → 16:20   Hét ███████████████████░ 93% ⚠ → Cs 10:00
  • A kontextussáv minden kategóriája a /context saját színével látszik (rendszerprompt, eszközök, MCP, memóriafájlok, skillek, ágensek, üzenetek), utána a tömörítési tartalék és a szabad hely halványan; alatta jelmagyarázat tokenszámmal. Az igény szerint betöltött (halasztott) eszközsémák kimaradnak, mert nincsenek az ablakban.
  • A bontás helyi becslés ($.session.usage({ breakdown: 'summary' })), API-hívás nélkül; minden turn végén és limitmozduláskor frissül.
  • Limitek: zöld 70% alatt, sárga 70–90%, piros 90% fölött (⚠), reset időponttal. Csak Pro/Max előfizetéssel van adat.
  • /hasznalat kapcsolja, /hasznalat be|ki beállítja; az állapotot sessionök között megőrzi. Az eszköztárban (/eszkozok) is kapcsolható.
  • A sávot megosztja a fókusz mód listájával és az eszköztárral (azok alatt jelenik meg).

Beállítás (/config): usage-header.language (auto/hu/en).

project-pane

Oldalpanel az aktuális git repóhoz. Magától megnyílik, ha a session git repóban indul és a terminál legalább 144 oszlop széles (miután egyszer /project-tel megnyitottad, 110 is elég); keskenyebb ablaknál egy toast szól, hogy vár. Bármikor: /project.

acme/shop                          ↻ most [ Frissítés ]
GIT
🌿 develop  ↑0 ↓0  · 7 módosított fájl
utolsó commit: 57704fe · 7 hónapja — Fix checkout flow
⚠ a main 13 committal előrébb jár, te a develop branch-en vagy
Előbb commitold a módosításokat, vagy tedd félre őket:
[ Stash + szinkron ]
DEPLOY (Netlify)
✔ ready  main  3 perce
PULL REQUESTEK
✔ #142 Stripe webhook retry  · approved
CI (GitHub Actions)
✘ netlify-deploy-verify  main · 5 napja      [ Kérdezd Claude-ot ]
SUPABASE
abcdefghijklmnopqrst · Acme Shop · eu-west-3
18 migráció · utolsó: 018_add_orders_rls.sql
GitHub  Netlify  Supabase
  • Szinkron gomb (s): csak fast-forward, soha nem merge-öl, rebase-el vagy töröl.
  • a branch lemaradt az upstreamtől → git pull --ff-only
  • másik branchen vagy, és az alapértelmezett (main) előrébb jár → git switch main + git merge --ff-only origin/main
  • commitolatlan módosítás esetén csak „Stash + szinkron” van: git stash push -u, vissza: git stash pop
  • ha elakad, a hibát Claude-nak szóló kérdésként a promptba teszi
  • Frissítés (r): 60 mp-enként és minden turn végén; git fetch 5 percenként.
  • Kérdezd Claude-ot (a): a legutóbbi elbukott CI-futásról kérdést tesz a promptba.
  • Toast, ha a session alatt elbukik egy Netlify deploy vagy CI-futás.

Adatforrások (a CLI-k saját bejelentkezésével, a plugin tokent nem olvas):

SzekcióForrásHa hiányzik
Gitgit—
PR, CIgh (gh auth login)tipp a panelen
Deploynetlify CLI + .netlify/state.jsonnpm i -g netlify-cli && netlify login
Supabasesupabase/.temp/project-ref, supabase/migrations, supabase projects listsupabase login az állapothoz

Beállítás (/config): project-pane.language (auto/hu/en), project-pane.autoOpen (alapból be).

focus-mode

Fókusz mód: a transcriptből eltűnnek az eszközhívások és eredményeik, a köztes szövegek, a saját promptjaid és a parancsok kimenete. Csak két dolog marad: a prompt fölötti sávban a részfeladatok listája állapotsávval és pipával, alatta a turn végső válasza.

✔ Meglévő mod-repo áttekintése
◐ Játék-mod megírása  ██████░░░░ 60%
○ Felvétel az eszközök közé
  • /fokusz kapcsolja, /fokusz be / /fokusz ki beállítja; az állapotot sessionök között megőrzi.
  • A listát a modell tölti a mod által regisztrált mcp__focus-mode__plan eszközzel; a rendszerprompt bekapcsolt állapotban erre utasítja (minden több lépéses kérésnél előbb a részfeladatok, aztán frissítés indításkor, haladáskor és befejezéskor).
  • Kikapcsolva minden a megszokott módon látszik.

Beállítás (/config): focus-mode.language (auto/hu/en).

tool-hub

Eszköztár: egy helyről kapcsolhatók a fenti modok. A prompt alatti lábléc jobb oldalán lévő ⚙ Eszközök gomb (vagy /eszkozok) a prompt fölötti sávban nyitja meg:

⚙ Szabolcs eszközei  kattintás, vagy ctrl+x tab után szám  [ Bezár ]
1. Fókusz mód     ● BE  [ Kikapcsol ] · Csak a részfeladatok listája és a végső válasz látszik
2. Limitsáv       ○ KI  [ Bekapcsol ] · Alsó sáv: 5 órás és heti limit, kontextusablak
3. Használati sáv ● BE  [ Kikapcsol ] · Prompt fölött: kontextus kategóriánként színezve, 5 órás és heti limit
4. Projektpanel   ● BE  [ Kikapcsol ] · GitHub, CI, Netlify és Supabase állapot oldalt
5. Kígyó játék    ● BE  [ Kikapcsol ] · Oldalt nyílik promptküldéskor, amíg Claude dolgozik
  • Modonként BE/KI állapot és kapcsoló gomb, ctrl+x tab után az 1–5 számbillentyűvel is.
  • Ami nincs telepítve, „nincs betöltve” felirattal jelenik meg.
  • Bekapcsolt fókusz módnál a lábléc is kiírja: „Fókusz mód”.
  • Parancsból is ugyanez: /fokusz, /limitsav, /hasznalat, /project, /jatek (mind be|ki argumentummal).
  • A sávot megosztja a fókusz mód listájával (a kapcsolók alatta jelennek meg).

Új mod felvétele az eszköztárba: a mod írja a saját isOn állapotát, adjon /<parancs> be|ki-t, és hookolja a state.set-et { plugin: 'tool-hub', key: 'request' }-re; a hubban egy sor a TOOLS listába, egy ág a stateOf-ba és a kulcs a types/index.d.ts-be.

Beállítás (/config): tool-hub.language (auto/hu/en).

snake-pane

Kígyó játék az oldalpanelben, amíg Claude dolgozik.

⏳ Claude dolgozik…
Pont: 4  Rekord: 17
╭──────────────────────────────────────╮
│                                      │
│          ████████                    │
│                ██      ●             │
╰──────────────────────────────────────╯
p: szünet · r: újra
w: ↑ a: ← s: ↓ d: → p: szünet r: új
  • Promptküldéskor magától megnyílik (ha ebben a sessionben bezártad, már nem; /jatek mindig nyitja). /jatek ki kikapcsolja, az eszköztárban (/eszkozok) is kapcsolható.
  • Irányítás: kattints a táblára, utána nyilak / wasd / hjkl, szóköz vagy p szünet, r új játék. Vagy ctrl+x tab a panelre, és a w a s d p r gombok.
  • A turn végén a futó játék szünetel („Claude végzett”), hogy visszatérj a munkához.
  • A pontszámmal gyorsul; a rekordot sessionök között megőrzi.
  • A játék a rajzoló szálon fut (Client surface modul), nem terheli a sessiont.

supabase-guard

Supabase-őr: megállítja a Supabase-hívást, ha nem az aktuális mappa projektjére menne, és egygombos felugróban rákérdez.

  • A mappa projektjét a supabase/.temp/project-ref, a .env* fájlok https://<ref>.supabase.co címei és a CLAUDE.md (ha egyetlen projekt-ID-t említ) adják, plusz amit a felugróban „Ez a mappa projektje” gombbal megerősítettél.
  • Más projektre menő írás (MCP execute_sql, apply_migration, deploy_edge_function, branch-műveletek, illetve supabase db push --project-ref …): kérdez — Megtiltom / Engedélyezem / Ez a mappa projektje.
  • Romboló művelet a saját projekten is kérdez: DROP, TRUNCATE, DELETE FROM, WHERE nélküli UPDATE, REVOKE, RLS kikapcsolása, reset_branch, delete_branch, pause_project, supabase db reset --linked.
  • Ha a mappához nem talál projektet, írás előtt kérdez (van „Engedem a session végéig” gomb is).
  • Más projekt olvasása fut, de a modell kap egy figyelmeztetést, hogy ne a rossz projektről vonjon le következtetést.
  • Hiba esetén zárva marad: ha az ellenőrzés elhasal, a Supabase-hívás nem fut le. /supaor be|ki, vagy az eszköztárban.

public-repo-guard

Push-őr: nyilvános GitHub-repóba push előtt (és gh repo edit --visibility public / gh repo create --public előtt) átnézi, mi menne ki.

  • A távoli ágon még nem lévő commitokat nézi (üzenet + hozzáadott sorok, a history is); ha nincs mihez mérni, az egész fát. Kezeli a git push origin forrás:cél alakot is.
  • Keres: Supabase projekt-ID (URL, project_id, táblázatsor), JWT / Supabase-kulcs, Stripe/Anthropic/OpenAI/GitHub/AWS/Google/Slack/Resend/Telegram/ElevenLabs kulcsok, privát kulcs, e-mail-cím (a saját git e-mail és a noreply/példa címek kivételével), helyi /Users/<te>/ útvonal, privát repóid nevei (gh repo list --visibility private), .env fájlok, és amit /pushor figyel <szöveg>-gel felvettél.
  • Találatnál felugró, maszkolt listával — Megtiltom / Pushold így is / Pushold, jegyezd meg (az utóbbi kivételként megjegyzi a találatokat). Tiltáskor a modell megkapja a teljes listát a javításhoz.
  • Privát repóba menő pushnál nem csinál semmit. Kell hozzá a gh CLI (bejelentkezve). /pushor be|ki.

decision-popup

Döntési felugró: a döntések egygombos kérdésként jönnek.

  • A rendszerpromptban arra kéri Claude-ot, hogy minden döntést (engedélyezés, A vagy B, visszafordíthatatlan vagy kifelé ható lépés) az AskUserQuestion dialógussal kérdezzen meg, 2–4 rövid opcióval, a javasoltat vagy a biztonságosabbat elöl — egy számgombbal válaszolsz.
  • A dialógus keretet és címet kap („Döntés kell”); az őr-modok (supabase-guard, public-repo-guard) kérdése piros keretben, „Az őr megállított egy műveletet” címmel.
  • /dontes be|ki, vagy az eszköztárban.

Ötletek

project-pane bővítése:

  • Vercel deploy-ok (vercel ls)
  • remote Supabase migrációk összevetése a lokálissal
  • több repó egy panelen

Státuszsor bővítése (usage-band):

  • figyelmeztetés kontextusablakra (pl. 85%), opcionálisan automatikus /compact
  • session költsége ($.session.usage().cost)
  • fast mód jelzése, git branch, aktuális projekt
  • beállítható küszöb és megjelenő elemek (userConfig)

Új mod-ok:

  • env-guard: .env, kulcsfájlok, supabase/.temp szerkesztésének tiltása (tool.call → deny)
  • turn-timer: hosszú turn végén hang/toast, hogy vissza lehet nézni (turn.complete, $.audio.play)
  • quote: /quote — a kijelölt szöveget idézetként a promptba teszi ($.ui.selection)
  • hu-prompt: a rendszerpromptba magyar válasz- és stílusszabály (prompt.compose)

Fejlesztés

Egy plugin mappája közvetlenül is betölthető:

claude --plugin-dir ./plugins/usage-band

Ellenőrzés:

claude plugin validate plugins/usage-band
claude plugin test plugins/usage-band
Source 3 files
hooks/register.tsx 192 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SnakeCmd, SnakeProps } from '../types'
5
6const PANE = 'snake'
7const isOn = atom({ plugin: 'snake-pane', key: 'isOn' } as const, true)
8const working = atom({ plugin: 'snake-pane', key: 'working' } as const, false)
9// A panel gombjainak utolsó parancsa; a kígyó a sorszámból látja, hogy új.
10const cmd = atom({ plugin: 'snake-pane', key: 'cmd' } as const, null)
11const best = atom({ plugin: 'snake-pane', key: 'best' } as const, 0)
12
13type Lang = 'hu' | 'en'
14
15const STRINGS = {
16  hu: {
17    title: '🐍 Kígyó',
18    command: 'Kígyó játék oldalt, amíg Claude dolgozik',
19    usage: 'Használat: /jatek [be|ki]',
20    opened: 'Játék megnyitva. Kattints a táblára, és nyilakkal irányíts.',
21    closed: 'Játék kikapcsolva.',
22    working: '⏳ Claude dolgozik…',
23    idle: '✔ Claude végzett',
24    hint: 'Kattints a táblára (nyilak, wasd, szóköz), vagy ctrl+x tab, aztán w a s d p r',
25    pause: 'szünet',
26    restart: 'új',
27  },
28  en: {
29    title: '🐍 Snake',
30    command: 'Snake in the side pane while Claude works',
31    usage: 'Usage: /jatek [on|off]',
32    opened: 'Game opened. Click the board and steer with the arrows.',
33    closed: 'Game turned off.',
34    working: '⏳ Claude is working…',
35    idle: '✔ Claude is done',
36    hint: 'Click the board (arrows, wasd, space), or ctrl+x tab, then w a s d p r',
37    pause: 'pause',
38    restart: 'new',
39  },
40}
41
42let lang: Lang = 'en'
43const t = () => STRINGS[lang]
44// A person zárta be a panelt ebben a sessionben: a promptok már nem nyitják újra (a parancs igen).
45let dismissed = false
46
47async function pickLanguage($: EngineInterface, setting: unknown): Promise<Lang> {
48  if (setting === 'hu' || setting === 'en') return setting
49  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || ''
50  return locale.toLowerCase().startsWith('hu') ? 'hu' : 'en'
51}
52
53const background = (work: Promise<unknown>) => void work.catch(() => undefined)
54
55// Be/ki kapcsolás: a /jatek parancs és az eszköztár (tool-hub) gombja is ezt hívja.
56async function setOn($: EngineInterface, wanted: boolean): Promise<string> {
57  await update($, isOn, () => wanted)
58  await $.store.set('isOn', wanted)
59  if (!wanted) {
60    await $.ui.close({ id: PANE })
61    return t().closed
62  }
63  dismissed = false
64  await $.ui.open({ id: PANE, title: t().title })
65  return t().opened
66}
67
68const press = ($: EngineInterface, c: SnakeCmd) =>
69  update($, cmd, prev => ({ c, n: (prev?.n ?? 0) + 1 })).then(() => undefined)
70
71const CONTROLS: { c: SnakeCmd; hotkey: string; label: () => string }[] = [
72  { c: 'up', hotkey: 'w', label: () => '↑' },
73  { c: 'left', hotkey: 'a', label: () => '←' },
74  { c: 'down', hotkey: 's', label: () => '↓' },
75  { c: 'right', hotkey: 'd', label: () => '→' },
76  { c: 'pause', hotkey: 'p', label: () => t().pause },
77  { c: 'restart', hotkey: 'r', label: () => t().restart },
78]
79
80export const register: Register = (on, options) => {
81  if (options.language === 'hu' || options.language === 'en') lang = options.language
82
83  on('session.start', async ($, e, next) => {
84    const started = await next(e)
85    lang = await pickLanguage($, options.language)
86    const saved = (await $.store.get('isOn')) !== false
87    await update($, isOn, () => saved)
88    const record = await $.store.get('best')
89    if (typeof record === 'number') await update($, best, () => record)
90    await $.command.register({
91      name: 'jatek',
92      description: t().command,
93      argumentHint: lang === 'hu' ? '[be|ki]' : '[on|off]',
94      immediate: true,
95    })
96    await $.command.register({ name: 'játék', description: t().command, immediate: true }).catch(() => undefined)
97    return started
98  })
99
100  on('command.run', { command: ['jatek', 'játék'] }, async ($, e) => {
101    const arg = e.args.trim().toLowerCase()
102    if (arg !== '' && !['be', 'on', 'ki', 'off'].includes(arg)) return { text: t().usage }
103    return { text: await setOn($, !['ki', 'off'].includes(arg)) }
104  })
105
106  // Az eszköztár gombja parancs nélkül kapcsol (így nem marad parancssor a transcriptben).
107  on('state.set', { plugin: 'tool-hub', key: 'request' }, async ($, e, next) => {
108    const done = await next(e)
109    if (e.value?.tool === 'game') await setOn($, e.value.on)
110    return done
111  })
112
113  // Promptküldéskor nyílik: ez a person saját lépése, így keskeny terminálon is helyet kap.
114  on('prompt.submit', async ($, e, next) => {
115    const done = await next(e)
116    if (options.autoOpen !== false && !dismissed && (await read($, isOn))) {
117      background($.ui.open({ id: PANE, title: t().title }))
118    }
119    return done
120  })
121
122  on('turn.start', async ($, e, next) => {
123    await update($, working, () => true)
124    return next(e)
125  })
126
127  on('turn.complete', async ($, e, next) => {
128    const done = await next(e)
129    if (e.agentId === undefined) await update($, working, () => false)
130    return done
131  })
132
133  on('ui.close', async ($, e, next) => {
134    if (e.id === PANE && e.origin.kind === 'person') dismissed = true
135    return next(e)
136  })
137
138  // A kígyó új rekordja: tartósan mentjük, és visszaadjuk neki a props-ban.
139  on('ui.message', async ($, e) => {
140    const data = e.data as { best?: unknown } | null
141    const score = typeof data?.best === 'number' && Number.isFinite(data.best) ? Math.floor(data.best) : undefined
142    if (score !== undefined && score > (await read($, best))) {
143      await update($, best, () => score)
144      await $.store.set('best', score)
145    }
146    return {}
147  })
148
149  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
150    const elements = $.ui.resolve(e)
151    const { Box, Text, Button } = elements
152    // Client csak a terminálon és a desktopon van: máshol csak az állapotsor látszik.
153    const Client = 'Client' in elements ? elements.Client : undefined
154    const isWorking = await read($, working)
155    const status = (
156      <Text key="status" color={isWorking ? 'yellow' : 'green'} bold>
157        {isWorking ? t().working : t().idle}
158      </Text>
159    )
160    if (Client === undefined) {
161      return <Box flexDirection="column">{status}</Box>
162    }
163    // Négyzetes cellák: 2 karakter széles egy mező. A Client alatt a gombok és a súgó két sora.
164    const props: SnakeProps = {
165      w: Math.max(8, Math.min(30, Math.floor((e.props.bodyColumns - 2) / 2))),
166      h: Math.max(6, Math.min(20, e.props.scroll.bodyRows - 7)),
167      cmd: await read($, cmd),
168      working: isWorking,
169      best: await read($, best),
170      lang,
171    }
172    return (
173      <Box flexDirection="column">
174        {status}
175        <Client key="board" module="./snake.tsx" props={props} />
176        <Box flexDirection="row" gap={1}>
177          {CONTROLS.map(control => (
178            <Button
179              key={`ctl-${control.c}`}
180              plain
181              hotkey={control.hotkey}
182              label={control.label()}
183              onPress={() => press($, control.c)}
184            />
185          ))}
186        </Box>
187        <Text key="hint" dimColor wrap="wrap">{t().hint}</Text>
188      </Box>
189    )
190  })
191}
192
hooks/snake.tsx 228 lines
1import type { ClientKeyEvent, ClientModule } from 'claude-code'
2
3import type { SnakeCmd, SnakeProps } from '../types'
4
5// A kígyó a rajzoló szálon fut (Client surface modul): saját órája és billentyűfigyelője van,
6// a hooks modult csak a rekord mentésére hívja (post). Így a játék nem terheli a motort.
7
8type Dir = 'up' | 'down' | 'left' | 'right'
9type Cell = [number, number]
10type Phase = 'ready' | 'run' | 'pause' | 'over'
11
12type Game = {
13  w: number
14  h: number
15  snake: Cell[] // fej elöl
16  dir: Dir
17  queue: Dir[] // a két lépés között lenyomott irányok, sorban
18  food: Cell
19  score: number
20  best: number
21  phase: Phase
22  lastCmd: number
23  wasWorking: boolean
24  notice?: 'done'
25}
26
27const FRAME_MS = 50
28const DELTA: Record<Dir, Cell> = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }
29const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
30
31const STRINGS = {
32  hu: {
33    score: 'Pont',
34    best: 'Rekord',
35    ready: 'Nyomj egy irányt (nyilak / wasd) a kezdéshez',
36    paused: 'Szünet — szóköz vagy p a folytatáshoz',
37    done: '✔ Claude végzett — szünet. Szóköz: folytatás',
38    over: (s: number, record: boolean) => `Vége: ${s} pont${record ? ' — új rekord!' : ''} · r vagy irány: új játék`,
39    run: 'p: szünet · r: újra',
40  },
41  en: {
42    score: 'Score',
43    best: 'Best',
44    ready: 'Press a direction (arrows / wasd) to start',
45    paused: 'Paused — space or p to resume',
46    done: '✔ Claude finished — paused. Space: resume',
47    over: (s: number, record: boolean) => `Game over: ${s}${record ? ' — new best!' : ''} · r or a direction: new game`,
48    run: 'p: pause · r: restart',
49  },
50}
51
52// Lépésköz képkockában: 200 ms-ról 100 ms-ig gyorsul, ahogy nő a pontszám.
53const framesPerStep = (score: number) => Math.max(2, 4 - Math.floor(score / 6))
54
55const same = (a: Cell, b: Cell) => a[0] === b[0] && a[1] === b[1]
56
57function placeFood(w: number, h: number, snake: Cell[]): Cell {
58  const free: Cell[] = []
59  for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) if (!snake.some(c => c[0] === x && c[1] === y)) free.push([x, y])
60  return free[Math.floor(Math.random() * free.length)] ?? [0, 0]
61}
62
63function newGame(props: SnakeProps, prev?: Game): Game {
64  const w = props.w
65  const h = props.h
66  const y = Math.floor(h / 2)
67  const x = Math.floor(w / 3)
68  const snake: Cell[] = [[x, y], [x - 1, y], [x - 2, y]]
69  return {
70    w,
71    h,
72    snake,
73    dir: 'right',
74    queue: [],
75    food: placeFood(w, h, snake),
76    score: 0,
77    best: Math.max(props.best, prev?.best ?? 0),
78    phase: 'ready',
79    lastCmd: prev?.lastCmd ?? props.cmd?.n ?? 0,
80    wasWorking: props.working,
81  }
82}
83
84function step(g: Game): { game: Game; record?: number } {
85  const [dir, ...queue] = g.queue.length > 0 ? g.queue : [g.dir]
86  const [dx, dy] = DELTA[dir!]
87  const head: Cell = [g.snake[0]![0] + dx, g.snake[0]![1] + dy]
88  const eats = same(head, g.food)
89  const body = eats ? g.snake : g.snake.slice(0, -1)
90  const dead = head[0] < 0 || head[1] < 0 || head[0] >= g.w || head[1] >= g.h || body.some(c => same(c, head))
91  if (dead) {
92    const record = g.score > g.best ? g.score : undefined
93    return { game: { ...g, dir: dir!, queue: [], phase: 'over', best: Math.max(g.best, g.score) }, record }
94  }
95  const snake = [head, ...body]
96  return {
97    game: {
98      ...g,
99      dir: dir!,
100      queue,
101      snake,
102      score: eats ? g.score + 1 : g.score,
103      food: eats ? placeFood(g.w, g.h, snake) : g.food,
104    },
105  }
106}
107
108function apply(g: Game, cmd: SnakeCmd, props: SnakeProps): Game {
109  if (cmd === 'restart') return { ...newGame(props, g), lastCmd: g.lastCmd }
110  if (cmd === 'pause') {
111    if (g.phase === 'run') return { ...g, phase: 'pause' }
112    if (g.phase === 'pause') return { ...g, phase: 'run', notice: undefined }
113    return g
114  }
115  if (g.phase === 'over') return apply({ ...newGame(props, g), lastCmd: g.lastCmd }, cmd, props)
116  const last = g.queue.at(-1) ?? g.dir
117  const queue = cmd === last || cmd === OPPOSITE[last] || g.queue.length >= 3 ? g.queue : [...g.queue, cmd]
118  return { ...g, queue, phase: 'run', notice: undefined }
119}
120
121function toCmd(event: ClientKeyEvent): SnakeCmd | undefined {
122  if (event.ctrl || event.meta) return undefined
123  switch (event.key.toLowerCase()) {
124    case 'up': case 'w': case 'k': return 'up'
125    case 'down': case 's': case 'j': return 'down'
126    case 'left': case 'a': case 'h': return 'left'
127    case 'right': case 'd': case 'l': return 'right'
128    case ' ': case 'space': case 'p': return 'pause'
129    case 'r': return 'restart'
130    default: return undefined
131  }
132}
133
134// Pluginonként egy példány fut, így a legutóbbi props modulszinten tartható: az óra és a
135// billentyűfigyelő az első hívásban indul, de mindig a friss méretet és rekordot látja.
136let latest: SnakeProps | undefined
137
138const Snake: ClientModule<SnakeProps, Game> = (props, surface) => {
139  const { Box, Text } = surface.elements
140  const t = STRINGS[props.lang]
141  latest = props
142  const current = () => surface.state ?? newGame(latest ?? props)
143
144  if (surface.state === undefined) {
145    // A képkocka-számláló nem állapot: csak a lépés rajzol újra, a köztes képkocka nem.
146    let frame = 0
147    surface.every(FRAME_MS, () => {
148      const g = current()
149      if (g.phase !== 'run') return
150      frame += 1
151      if (frame % framesPerStep(g.score) !== 0) return
152      const { game, record } = step(g)
153      surface.setState(game)
154      if (record !== undefined) surface.post({ best: record })
155    })
156    surface.onKey(event => {
157      const cmd = toCmd(event)
158      if (cmd !== undefined) surface.setState(apply(current(), cmd, latest ?? props))
159    })
160    surface.setState(newGame(props))
161  }
162
163  let g = current()
164  // Új parancs a panel gombjairól (props-ban jön), és a turn vége: Claude végzett, szünet.
165  let changed = false
166  if (props.cmd && props.cmd.n > g.lastCmd) {
167    g = { ...apply(g, props.cmd.c, props), lastCmd: props.cmd.n }
168    changed = true
169  }
170  if (g.wasWorking !== props.working) {
171    g = { ...g, wasWorking: props.working, ...(!props.working && g.phase === 'run' ? { phase: 'pause', notice: 'done' } : {}) }
172    changed = true
173  }
174  if (props.best > g.best) {
175    g = { ...g, best: props.best }
176    changed = true
177  }
178  if (changed && surface.state !== undefined) surface.setState(g)
179
180  const rows = []
181  for (let y = 0; y < g.h; y++) {
182    const runs: { kind: string; text: string }[] = []
183    for (let x = 0; x < g.w; x++) {
184      const at: Cell = [x, y]
185      const kind = same(g.snake[0]!, at) ? 'head' : g.snake.some(c => same(c, at)) ? 'body' : same(g.food, at) ? 'food' : 'empty'
186      const text = kind === 'food' ? '● ' : kind === 'empty' ? '  ' : '██'
187      const last = runs.at(-1)
188      if (last?.kind === kind) last.text += text
189      else runs.push({ kind, text })
190    }
191    rows.push(
192      <Box key={`r${y}`} flexDirection="row">
193        {runs.map((run, i) => (
194          <Text
195            key={`r${y}-${i}`}
196            color={run.kind === 'head' ? (g.phase === 'over' ? 'red' : 'greenBright') : run.kind === 'body' ? 'green' : run.kind === 'food' ? 'redBright' : undefined}
197          >
198            {run.text}
199          </Text>
200        ))}
201      </Box>,
202    )
203  }
204
205  const message =
206    g.phase === 'ready' ? t.ready
207    : g.phase === 'pause' ? (g.notice === 'done' ? t.done : t.paused)
208    : g.phase === 'over' ? t.over(g.score, g.score > 0 && g.score >= g.best)
209    : t.run
210
211  return (
212    <Box flexDirection="column">
213      <Box flexDirection="row" gap={2}>
214        <Text key="score" bold>{`${t.score}: ${g.score}`}</Text>
215        <Text key="best" color="yellow">{`${t.best}: ${g.best}`}</Text>
216      </Box>
217      <Box flexDirection="column" borderStyle="round" borderColor={g.phase === 'run' ? 'green' : 'gray'} width={g.w * 2 + 2}>
218        {rows}
219      </Box>
220      <Text key="message" dimColor={g.phase === 'run'} color={g.notice === 'done' ? 'green' : undefined} wrap="truncate-end">
221        {message}
222      </Text>
223    </Box>
224  )
225}
226
227export default Snake
228
types/index.d.ts 28 lines
1export type SnakeCmd = 'up' | 'down' | 'left' | 'right' | 'pause' | 'restart'
2
3// Amit a panel a kígyó Client moduljának átad (props): táblaméret, a panel gombjainak utolsó
4// parancsa (n sorszámmal, hogy ugyanaz a gomb kétszer is számítson), Claude dolgozik-e, rekord, nyelv.
5export type SnakeProps = {
6  w: number
7  h: number
8  cmd: { c: SnakeCmd; n: number } | null
9  working: boolean
10  best: number
11  lang: 'hu' | 'en'
12}
13
14// Az eszköztár (tool-hub) kérése: a kapcsoló gomb ezt írja, a mod a state.set hookjában veszi át.
15export type HubRequest = { tool: string; on: boolean; n: number }
16
17declare module 'claude-code' {
18  interface PluginState {
19    'tool-hub': { request: HubRequest | null }
20    'snake-pane': {
21      isOn: boolean
22      working: boolean
23      cmd: { c: SnakeCmd; n: number } | null
24      best: number
25    }
26  }
27}
28