SLOPSHOPPER

aside

/aside: a side pane with its own input box that asks Haiku quick questions outside the main session, with optional return of a compact synopsis to the prompt…

panecommandmodeltimer
v0.4.0MITupdated 2026-10-23mihazip/hyperfocus/plugins/aside
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · aside
│ ┃ aside (haiku) ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ⢀⣠⣤⣤⡄ ⏺ Read(src/auth.ts) │ ┃ ⣰⣚⣷⣶⣤⣾⣿⠿⠟⠋ ⎿ Read 6 lines │ ┃ ⠉⠉⠉⠉⠉⠙⠻⢿⣿⣿⣿⣦ ⏺ Update(src/auth.ts) │ ┃ ⠈⠻⣿⣿⣿⡇ ⎿ Added 2 lines, removed 1 line │ ┃ ⠈⠙⣿⣧⡀ ⏺ Bash(bun test) │ ┃ Ask anything. Enter asks haiku, main sessio ⎿ 3 pass, 1 fail │ ┃ untouched. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ question ⏎ ask │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ more │ ┃ Close › /aside │ ┃ ⎿ aside: aside pane opened. Type a question, Enter to ask. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · aside (haiku)
⢀⣠⣤⣤⡄ ⣰⣚⣷⣶⣤⣾⣿⠿⠟⠋ ⠉⠉⠉⠉⠉⠙⠻⢿⣿⣿⣿⣦ ⠈⠻⣿⣿⣿⡇ ⠈⠙⣿⣧⡀ Ask anything. Enter asks haiku, main session untouched. question ⏎ ask more Close
README

aside

A Claude Code mod for scattered side questions. /aside opens a pane with its own input box that asks a side model (haiku, sonnet, opus or fable) in a separate thread, so your main session and its context stay untouched. You can send a short synopsis of an answer back to the main prompt box, by hand or automatically.

Install

Inside Claude Code:

/plugin install aside --marketplace mihazip/hyperfocus

Restart Claude Code, then run /aside. Part of the hyperfocus collection.

Use

  • Type a question in the pane and press Enter.
  • Follow-ups ("all of them", "more") continue the previous exchange (last 6 are sent as history).
  • Open it with /aside.
  • A coloured braille hummingbird (palette in THEME.bird) flutters for a moment when the pane opens. It shows only on the empty start screen and only when the pane has room.
  • Arrow Up/Down and Tab walk between the input and the options; Esc hands the keys back to the prompt and leaves the pane open, with its log and unsent text.
  • Return synopsis to prompt (once there is an answer) appends a short note with the last Q and A to your main prompt box.
  • Press more under it to unfold the options, less (same place) to fold them again:
  • Model ‹ haiku ›: Enter steps through haiku, sonnet, opus, fable and wraps (default haiku).
  • Effort ‹ medium ›: Enter steps through low / medium / high and wraps (default medium).
  • Auto-return: return the synopsis automatically after every answer.
  • Clear: wipes the side chat.

Notes

  • Model and effort are remembered across sessions. The pane title shows the model.
  • Each question is one call to the chosen model, plus one short haiku call (low effort) when an answer is long enough to need a synopsis.
  • The side chat itself lives in memory for the session.
  • Colors come from one THEME object in hooks/register.tsx, set for the Windows Terminal "Ubuntu" scheme (aubergine background, white text, yellow questions, cyan notices). Change it there for another palette. Every Text and Button label carries explicit colors so the focused button stays readable; Input and Markdown take no color props and use the terminal defaults.

Layout

.claude-plugin/plugin.json        manifest
hooks/hooks.json                  points at the module
hooks/register.tsx                command, pane and model calls
types/index.d.ts                  plugin state types
tests/aside.test.tsx              pane render test

License

MIT

Source 3 files
hooks/register.tsx 363 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Exchange } from '../types'
5
6const PANE = 'aside'
7// Empty start screen: bird 6 + intro 1 + spacer 1 + input 1 + spacer 1 + more 1, with room for the opened options.
8const PANE_ROWS = 14
9const MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
10const DEFAULT_MODEL = 'haiku'
11// The synopsis call is bookkeeping, so it stays cheap whatever model answers the questions.
12const SYNOPSIS_MODEL = 'haiku'
13const SYSTEM =
14  'You are a fast side-answer helper for a developer with scattered, casual questions. ' +
15  'Always give a direct best-effort answer; never ask for clarification unless the question is truly unanswerable. ' +
16  'Treat follow-ups ("all of them", "more") as continuing the previous exchange. ' +
17  'Long lists are fine when asked: give them in full, compactly. Plain text or short markdown, no preamble, no filler.'
18// Windows Terminal 'Ubuntu' scheme; change here for another palette.
19const THEME = {
20  bg: '#300A24',
21  fg: '#EEEEEC',
22  dim: '#D0CFCC',
23  accent: '#E9AD0C',
24  info: '#33C7DE',
25  select: '#4E1E3F',
26} as const
27// Every Text carries explicit colors, so a focused element (drawn as the inverse of its label) stays readable.
28const TEXT = { color: THEME.fg, backgroundColor: THEME.bg } as const
29const HINT = { color: THEME.dim, backgroundColor: THEME.bg } as const
30const LEVELS = ['low', 'medium', 'high'] as const
31
32// Hummingbird in braille, 6 rows x 20 columns per frame.
33const FRAMES = {
34  up: [
35    '         ⣾⣿⡇        ',
36    '         ⣿⣿⠁        ',
37    '     ⣰⣚⣷⣶⣿⡃         ',
38    '⠉⠉⠉⠉⠉⠙⠻⢿⣿⣿⣿⣦        ',
39    '       ⠈⠻⣿⣿⣿⡇       ',
40    '         ⠈⠙⣿⣧⡀      ',
41  ],
42  mid: [
43    '             ⣠⣤⣶    ',
44    '          ⢀⣴⣿⣿⠟⠁    ',
45    '     ⣰⣚⣷⣶⣴⡿⠟⠋⠁      ',
46    '⠉⠉⠉⠉⠉⠙⠻⢿⣿⣿⣿⣦        ',
47    '       ⠈⠻⣿⣿⣿⡇       ',
48    '         ⠈⠙⣿⣧⡀      ',
49  ],
50  down: [
51    '                    ',
52    '                    ',
53    '     ⣰⣚⣷⣶⣤⣴⣶⣶⣶⣤⣄⡀   ',
54    '⠉⠉⠉⠉⠉⠙⠻⢿⣿⣿⣿⣿⠛⠛⠛⠛⠁   ',
55    '       ⠈⠻⣿⣿⣿⡇       ',
56    '         ⠈⠙⣿⣧⡀      ',
57  ],
58  rest: [
59    '                    ',
60    '           ⢀⣠⣤⣤⡄    ',
61    '     ⣰⣚⣷⣶⣤⣾⣿⠿⠟⠋     ',
62    '⠉⠉⠉⠉⠉⠙⠻⢿⣿⣿⣿⣦        ',
63    '       ⠈⠻⣿⣿⣿⡇       ',
64    '         ⠈⠙⣿⣧⡀      ',
65  ],
66} as const
67// Colour of one cell of a frame, by region (the same for every frame); unpainted cells stay dim.
68function birdColor(row: number, col: number): string {
69  if (row <= 1 || (row === 2 && col >= 10) || (row === 3 && col >= 12)) return THEME.bird.wing
70  if (row === 2 && col >= 5) return THEME.bird.head
71  if (row === 3 && col <= 4) return THEME.bird.beak
72  if (row === 3 && col <= 7) return THEME.bird.throat
73  if (row === 4 && col >= 7 && col <= 8) return THEME.bird.throat
74  if (row === 3 || (row === 4 && col >= 9 && col <= 12)) return THEME.bird.body
75  if (row === 5) return THEME.bird.tail
76  return THEME.dim
77}
78
79// One frame row as runs of equally coloured cells.
80function birdRuns(row: number, text: string): { color: string; text: string }[] {
81  const runs: { color: string; text: string }[] = []
82  Array.from(text).forEach((ch, col) => {
83    const color = ch === ' ' ? THEME.bg : birdColor(row, col)
84    const last = runs[runs.length - 1]
85    if (last && last.color === color) last.text += ch
86    else runs.push({ color, text: ch })
87  })
88  return runs
89}
90
91const FLUTTER = [FRAMES.up, FRAMES.mid, FRAMES.down, FRAMES.mid] as const
92const FLUTTER_MS = 1500
93const BEAT_MS = 60
94const BIRD_ROWS = 6
95const BIRD_MIN_BODY = BIRD_ROWS + 4
96
97const log = atom({ plugin: 'aside', key: 'log' } as const, [])
98const isBusy = atom({ plugin: 'aside', key: 'isBusy' } as const, false)
99const isAuto = atom({ plugin: 'aside', key: 'isAuto' } as const, false)
100const effort = atom({ plugin: 'aside', key: 'effort' } as const, 'medium')
101const model = atom({ plugin: 'aside', key: 'model' } as const, DEFAULT_MODEL)
102const notice = atom({ plugin: 'aside', key: 'notice' } as const, '')
103// -1 = resting, otherwise an index into FLUTTER; a timer drives it while the bird flutters.
104const wing = atom({ plugin: 'aside', key: 'wing' } as const, -1)
105// The unsent text in the input, so it survives closing and reopening the pane.
106const draft = atom({ plugin: 'aside', key: 'draft' } as const, '')
107const isMore = atom({ plugin: 'aside', key: 'isMore' } as const, false)
108// The breakout game fills the top two thirds of a tall pane; off by default.
109const isGame = atom({ plugin: 'aside', key: 'isGame' } as const, false)
110const GAME_MIN_BODY = 20
111
112// "more" and "less" share one key, so the focus ring stays where the person pressed.
113async function setMore($: EngineInterface, on: boolean): Promise<void> {
114  await update($, isMore, () => on)
115  await $.ui.focus({ requestId: PANE, key: 'toggle' })
116}
117
118// Wing beats for FLUTTER_MS, then back to rest.
119function flutter($: EngineInterface): void {
120  const beat = $.clock.every(BEAT_MS, () => {
121    void update($, wing, w => (w + 1) % FLUTTER.length)
122  })
123  $.clock.after(FLUTTER_MS, () => {
124    beat.cancel()
125    void update($, wing, () => -1)
126  })
127}
128
129async function isPaneOpen($: EngineInterface): Promise<boolean> {
130  return (await $.ui.panes()).some(p => p.id === PANE)
131}
132
133// Opening an id that is already open only retitles it.
134async function openPane($: EngineInterface, focus: boolean): Promise<void> {
135  const name = await read($, model)
136  await $.ui.open({ id: PANE, title: `aside (${name})`, focus, rows: PANE_ROWS })
137}
138
139// Never stacks a second pane.
140async function showPane($: EngineInterface): Promise<void> {
141  if (await isPaneOpen($)) return
142  await openPane($, true)
143  flutter($)
144  $.ui.invalidate('ui.render')
145}
146
147// Keep model and effort across sessions.
148async function loadSettings($: EngineInterface): Promise<void> {
149  const m = await $.store.get('model')
150  const l = await $.store.get('effort')
151  if (typeof m === 'string' && (MODELS as readonly string[]).includes(m)) await update($, model, () => m)
152  if (typeof l === 'string' && (LEVELS as readonly string[]).includes(l)) await update($, effort, () => l)
153}
154
155async function nextModel($: EngineInterface): Promise<void> {
156  const cur = await read($, model)
157  const name = MODELS[(MODELS.indexOf(cur as (typeof MODELS)[number]) + 1) % MODELS.length]
158  await update($, model, () => name)
159  await $.store.set('model', name)
160  if (await isPaneOpen($)) await openPane($, false)
161}
162
163async function nextEffort($: EngineInterface): Promise<void> {
164  const cur = await read($, effort)
165  const next = LEVELS[(LEVELS.indexOf(cur as (typeof LEVELS)[number]) + 1) % LEVELS.length]
166  await update($, effort, () => next)
167  await $.store.set('effort', next)
168}
169
170async function ask($: EngineInterface, q: string): Promise<void> {
171  const text = q.trim()
172  if (!text || (await read($, isBusy))) return
173
174  await update($, isBusy, () => true)
175  await update($, notice, () => '')
176  const past = (await read($, log))
177    .slice(-6)
178    .map(x => `Q: ${x.q}\nA: ${x.a}`)
179    .join('\n\n')
180  const level = (await read($, effort)) as (typeof LEVELS)[number]
181  const name = await read($, model)
182  const r = await $.model.complete({
183    model: name,
184    system: SYSTEM,
185    prompt: past ? `Earlier in this side chat:\n${past}\n\nNew question: ${text}` : text,
186    maxTokens: 4000,
187    effort: level,
188    timeoutMs: 90000,
189  })
190
191  if (!r.isAnswered) {
192    await update($, notice, () => `${name} failed: ${r.reason}`)
193    await update($, isBusy, () => false)
194    return
195  }
196
197  // A short answer is its own synopsis; a long one gets squeezed.
198  let synopsis = r.text.trim()
199  if (synopsis.length > 400) {
200    const s = await $.model.complete({
201      model: SYNOPSIS_MODEL,
202      prompt: `Compress to at most 2 short sentences, keep every key fact:\n\n${synopsis}`,
203      maxTokens: 200,
204      effort: 'low',
205      timeoutMs: 20000,
206    })
207    if (s.isAnswered) synopsis = s.text.trim()
208  }
209  if (synopsis.length > 300) synopsis = synopsis.slice(0, 297).trimEnd() + '...'
210
211  const item: Exchange = { q: text, a: r.text.trim(), synopsis }
212  await update($, log, list => [...list, item].slice(-20))
213
214  if (await read($, isAuto)) await giveBack($, item)
215  await update($, isBusy, () => false)
216}
217
218async function giveBack($: EngineInterface, item: Exchange): Promise<void> {
219  const out = await $.prompt.fill({
220    text: `(side note from /aside — Q: ${item.q} A: ${item.synopsis}) `,
221    mode: 'append',
222  })
223  await update($, notice, () =>
224    out.isFilled ? 'Synopsis added to your prompt box.' : 'Could not fill the prompt box.',
225  )
226}
227
228export const register: Register = on => {
229  on('session.start', async ($, e, next) => {
230    await loadSettings($)
231    await $.command.register({
232      name: 'aside',
233      description: 'Side pane: quick questions to a side model, outside the main session',
234    })
235
236    return next(e)
237  })
238
239  on('command.run', { command: 'aside' }, async $ => {
240    await showPane($)
241
242    return { text: 'aside pane opened. Type a question, Enter to ask.' }
243  })
244
245  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
246    const { Box, Text, Button, Input, Markdown, Client } = $.ui.resolve(e)
247    const items = await read($, log)
248    const busy = await read($, isBusy)
249    const auto = await read($, isAuto)
250    const lvl = await read($, effort)
251    const name = await read($, model)
252    const note = await read($, notice)
253    const more = await read($, isMore)
254    const typed = await read($, draft)
255    const game = await read($, isGame)
256    const lastItem = items[items.length - 1]
257    const w = await read($, wing)
258    const bird = FLUTTER[w] ?? FRAMES.rest
259    const body = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 24
260    const gameRows = game && body >= GAME_MIN_BODY ? Math.floor((body * 2) / 3) : 0
261    const room = Math.max(1, Math.floor((body - gameRows - 8) / 3))
262    const showBird = items.length === 0 && gameRows === 0 && body >= BIRD_MIN_BODY
263
264    return (
265      <Box
266        flexDirection="column"
267        paddingX={1}
268        width={e.props.bodyColumns}
269        minHeight={body}
270        backgroundColor={THEME.bg}
271      >
272        {gameRows > 0 && <Client key="breakout" module="./breakout.tsx" height={gameRows} />}
273        {showBird && (
274          <Box flexDirection="column">
275            {bird.map(row => (
276              <Text {...HINT}>{row}</Text>
277            ))}
278          </Box>
279        )}
280        {items.length === 0 && <Text {...HINT}>Ask anything. Enter asks {name}, main session untouched.</Text>}
281        {items.slice(-room).map(it => (
282          <Box flexDirection="column">
283            <Text bold color={THEME.accent} backgroundColor={THEME.bg}>
284              ? {it.q}
285            </Text>
286            <Markdown text={it.a} />
287          </Box>
288        ))}
289        {busy && <Text color={THEME.accent} backgroundColor={THEME.bg}>
290            {name} is thinking...
291          </Text>}
292        {note !== '' && <Text color={THEME.info} backgroundColor={THEME.bg}>
293            {note}
294          </Text>}
295        <Text {...TEXT}> </Text>
296        <Input
297          key="q"
298          placeholder="question"
299          submitLabel="ask"
300          autoFocus
301          value={typed}
302          onInput={(value: string) => {
303            void update($, draft, () => value)
304          }}
305          onSubmit={(value: string) => {
306            void update($, draft, () => '')
307            void ask($, value)
308          }}
309        />
310        <Text {...TEXT}> </Text>
311        <Box flexDirection="column">
312          {lastItem && (
313            <Button plain
314              key="return"
315              label="Return synopsis to prompt"
316              onPress={() => {
317                void giveBack($, lastItem)
318              }}
319            >
320              <Text {...TEXT}>Return synopsis to prompt</Text>
321            </Button>
322          )}
323          <Button plain key="toggle" label={more ? 'less' : 'more'} onPress={() => setMore($, !more)}>
324            <Text {...TEXT}>{more ? 'less' : 'more'}</Text>
325          </Button>
326          {more && (
327            <Button plain key="model" label={`Model: ‹ ${name} ›`} onPress={() => nextModel($)}>
328              <Text {...TEXT}>{`Model: ‹ ${name} ›`}</Text>
329            </Button>
330          )}
331          {more && (
332            <Button plain key="effort" label={`Effort: ‹ ${lvl} ›`} onPress={() => nextEffort($)}>
333              <Text {...TEXT}>{`Effort: ‹ ${lvl} ›`}</Text>
334            </Button>
335          )}
336          {more && (
337            <Button plain
338              key="auto"
339              label={auto ? 'Auto-return: on' : 'Auto-return: off'}
340              onPress={() => update($, isAuto, v => !v)}
341            >
342              <Text {...TEXT}>{auto ? 'Auto-return: on' : 'Auto-return: off'}</Text>
343            </Button>
344          )}
345          {more && (
346            <Button plain key="clear" label="Clear" onPress={() => update($, log, () => [])}>
347              <Text {...TEXT}>Clear</Text>
348            </Button>
349          )}
350          {more && (
351            <Button plain key="game" label={game ? 'Game: on' : 'Game: off'} onPress={() => update($, isGame, v => !v)}>
352              <Text {...TEXT}>{game ? 'Game: on' : 'Game: off'}</Text>
353            </Button>
354          )}
355          <Button plain key="close" label="Close" onPress={() => $.ui.close({ id: PANE })}>
356            <Text {...TEXT}>Close</Text>
357          </Button>
358        </Box>
359      </Box>
360    )
361  })
362}
363
hooks/breakout.tsx 216 lines
1import type { ClientModule } from 'claude-code'
2
3// Surface module: a tiny breakout drawn in the top of the aside pane.
4// Row 0 is the HUD, the ceiling is row 1, the paddle sits on the last row.
5// Mouse over the game moves the paddle; a click launches the ball.
6// Arrow keys / a d work once the game has been clicked (that gives it focus).
7
8type Game = {
9  w: number
10  h: number
11  bricks: boolean[][]
12  px: number
13  bx: number
14  by: number
15  vx: number
16  vy: number
17  isLive: boolean
18  score: number
19  lives: number
20  over: 'won' | 'lost' | null
21}
22
23const BRICK_W = 4
24const PADDLE_W = 9
25const TICK_MS = 40
26// Two tints per row so neighbouring bricks read as separate.
27const ROW_COLORS = [
28  ['#E95420', '#C7451A'],
29  ['#E9AD0C', '#C58F08'],
30  ['#38B44A', '#2C913B'],
31  ['#33C7DE', '#27A0B3'],
32]
33
34function fresh(w: number, h: number): Game {
35  const cols = Math.max(1, Math.floor(w / BRICK_W))
36  const rows = Math.max(1, Math.min(ROW_COLORS.length, Math.floor((h - 4) / 3)))
37  const px = Math.floor((w - PADDLE_W) / 2)
38
39  return {
40    w,
41    h,
42    bricks: Array.from({ length: rows }, () => Array.from({ length: cols }, () => true)),
43    px,
44    bx: px + PADDLE_W / 2,
45    by: h - 2,
46    vx: 0,
47    vy: -0.5,
48    isLive: false,
49    score: 0,
50    lives: 3,
51    over: null,
52  }
53}
54
55function clampPaddle(g: Game, px: number): number {
56  return Math.max(0, Math.min(g.w - PADDLE_W, px))
57}
58
59function movePaddle(g: Game, px: number): Game {
60  const next = clampPaddle(g, px)
61  if (next === g.px) return g
62  // A ball that has not launched rides the paddle.
63  return g.isLive ? { ...g, px: next } : { ...g, px: next, bx: next + PADDLE_W / 2 }
64}
65
66function launch(g: Game): Game {
67  if (g.over) return fresh(g.w, g.h)
68  if (g.isLive) return g
69  return { ...g, isLive: true, vx: 0.6, vy: -0.5 }
70}
71
72function step(g: Game): Game {
73  if (!g.isLive || g.over) return g
74
75  let { bx, by, vx, vy } = g
76  let nx = bx + vx
77  let ny = by + vy
78
79  if (nx < 0) {
80    nx = -nx
81    vx = -vx
82  } else if (nx > g.w - 1) {
83    nx = 2 * (g.w - 1) - nx
84    vx = -vx
85  }
86  if (ny < 1) {
87    ny = 2 - ny
88    vy = -vy
89  }
90
91  // Bricks start at row 2.
92  const row = Math.floor(ny) - 2
93  const col = Math.floor(nx / BRICK_W)
94  let bricks = g.bricks
95  let score = g.score
96  if (row >= 0 && row < bricks.length && bricks[row][col]) {
97    bricks = bricks.map((r, i) => (i === row ? r.map((b, j) => (j === col ? false : b)) : r))
98    score += 1
99    vy = -vy
100    ny = by
101  }
102
103  if (bricks.every(r => r.every(b => !b))) {
104    return { ...g, bricks, score, bx: nx, by: ny, vx, vy, over: 'won' }
105  }
106
107  // Paddle on the last row: the hit spot sets the angle.
108  if (vy > 0 && ny >= g.h - 1) {
109    if (nx >= g.px - 0.5 && nx <= g.px + PADDLE_W - 0.5) {
110      const hit = (nx - g.px) / (PADDLE_W - 1)
111      vx = (hit - 0.5) * 2.4
112      vy = -Math.abs(vy)
113      ny = g.h - 2
114    } else if (ny >= g.h) {
115      const lives = g.lives - 1
116      const reset = { ...g, bricks, score, lives, isLive: false, bx: g.px + PADDLE_W / 2, by: g.h - 2, vx: 0, vy: -0.5 }
117
118      return lives <= 0 ? { ...reset, over: 'lost' } : reset
119    }
120  }
121
122  return { ...g, bricks, score, bx: nx, by: ny, vx, vy }
123}
124
125function hud(g: Game): string {
126  if (g.over === 'won') return `You win! score ${g.score}. Click to play again`
127  if (g.over === 'lost') return `Game over, score ${g.score}. Click to play again`
128  const hearts = '♥'.repeat(g.lives)
129  if (!g.isLive) return `${hearts} score ${g.score}  click to launch`
130  return `${hearts} score ${g.score}`
131}
132
133type Run = { s: string; color: string }
134
135function rowRuns(g: Game, y: number): Run[] {
136  const cells: Run[] = Array.from({ length: g.w }, () => ({ s: ' ', color: '#AEA79F' }))
137  const brickRow = g.bricks[y - 2]
138  if (brickRow) {
139    brickRow.forEach((alive, c) => {
140      if (!alive) return
141      for (let i = 0; i < BRICK_W; i++) {
142        const x = c * BRICK_W + i
143        if (x < g.w) cells[x] = { s: '█', color: ROW_COLORS[y - 2][c % 2] }
144      }
145    })
146  }
147  if (y === g.h - 1) {
148    for (let i = 0; i < PADDLE_W; i++) cells[g.px + i] = { s: '▀', color: '#EEEEEC' }
149  }
150  if (Math.round(g.by) === y) {
151    const x = Math.max(0, Math.min(g.w - 1, Math.round(g.bx)))
152    cells[x] = { s: '●', color: '#FFFFFF' }
153  }
154
155  const runs: Run[] = []
156  for (const c of cells) {
157    const last = runs[runs.length - 1]
158    if (last && last.color === c.color) last.s += c.s
159    else runs.push({ ...c })
160  }
161
162  return runs
163}
164
165const Breakout: ClientModule<null, Game> = (_props, surface) => {
166  const { Box, Text } = surface.elements
167  const gridW = surface.columns || 40
168  const gridH = surface.rows || 12
169
170  if (surface.state === undefined) {
171    // `cur` mirrors the state for the callbacks below, which outlive this call.
172    let cur = fresh(gridW, gridH)
173    const commit = (next: Game) => {
174      if (next === cur) return
175      cur = next
176      surface.setState(cur)
177    }
178
179    surface.every(TICK_MS, () => {
180      const cw = surface.columns
181      const ch = surface.rows
182      if (cw > 0 && ch > 0 && (cw !== cur.w || ch !== cur.h)) {
183        commit(fresh(cw, ch))
184        return
185      }
186      commit(step(cur))
187    })
188    surface.onPointer(e => {
189      if (e.type === 'move') commit(movePaddle(cur, Math.round(e.x - PADDLE_W / 2)))
190      else if (e.type === 'down') commit(launch(cur))
191    })
192    surface.onKey(e => {
193      if (e.key === 'left' || e.key === 'a') commit(movePaddle(cur, cur.px - 3))
194      else if (e.key === 'right' || e.key === 'd') commit(movePaddle(cur, cur.px + 3))
195    })
196    surface.setState(cur)
197  }
198
199  const g = surface.state ?? fresh(gridW, gridH)
200
201  return (
202    <Box flexDirection="column">
203      <Text color="#33C7DE">{hud(g)}</Text>
204      {Array.from({ length: g.h - 1 }, (_, i) => (
205        <Text>
206          {rowRuns(g, i + 1).map(r => (
207            <Text color={r.color}>{r.s}</Text>
208          ))}
209        </Text>
210      ))}
211    </Box>
212  )
213}
214
215export default Breakout
216
types/index.d.ts 19 lines
1export type Exchange = { q: string; a: string; synopsis: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    aside: {
6      log: Exchange[]
7      isBusy: boolean
8      isAuto: boolean
9      effort: string
10      model: string
11      notice: string
12      wing: number
13      isMore: boolean
14      isGame: boolean
15      draft: string
16    }
17  }
18}
19