/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…

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.
Inside Claude Code:
/plugin install aside --marketplace mihazip/hyperfocus
Restart Claude Code, then run /aside. Part of the hyperfocus collection.
/aside.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.‹ haiku ›: Enter steps through haiku, sonnet, opus, fable and wraps (default haiku).‹ medium ›: Enter steps through low / medium / high and wraps (default medium).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..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
MIT
hooks/register.tsx 363 lines1import { 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}
363hooks/breakout.tsx 216 lines1import 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
216types/index.d.ts 19 lines1export 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