SLOPSHOPPER

vome-esphome

A side pane for ESPHome in Claude Code: a map of the device from its YAML (board, network, buses, entities, pins, triggers) with what a save changed lit up…

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vome-esphome
│ ┃ ESPHome ✕ › 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) │ ┃ Nothing building. When Claude reads, edits ⎿ 3 pass, 1 fail │ ┃ or builds an ESPHome device, its map shows │ ┃ here, and a build's progress as it runs. ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /esphome │ ⎿ vome-esphome: ESPHome pane open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ESPHome
⢠ ⡄⢠ ⡄⢠ ⡄⢠ ⡄ ⢀⣈⡀ ⣀⠤⠤⠤⢄⡀⠠ ⡀ ⢸⠩⣭⠩⠩⠩⠩⠩⠩⠩⠩⠩⠩⡇⠠⣭⣯⢄⣀⡠⠊ ⡄⡄ ⠈⠢⣀⣀ ⢸⠨⠨⠨⠸⠬⠪⠨⠸⠬⠪⠨⠨⡇⠈⠉⢰⠁ ⣿⡇ ⢱ ⢸⣨⣨⣨⣨⣨⣨⣨⣨⣨⣨⣨⣨⡇ ⠑⠢⢄⣀⣀⣀⡛⣃⣀⣀⣀⠤⠒⠁ ⠘ ⠃⠘ Nothing building. When Claude reads, edits or builds an ESPHome device, its map shows here, and a build's progress as it runs.
README

vome-esphome: ESPHome at the side of Claude Code

The pane's chip on the bench: dreaming, compiling in the forge, flashing, fireworks, then the device's things wired to it.

A side pane for the ESPHome device Claude is working on. Claude reads and edits YAML and runs builds that take minutes, and its tools answer only at the end; this pane shows the device and the build as they are:

  • A map of the device, from its YAML, whenever Claude reads, saves or builds a config: the board and framework, how it connects, its buses, every entity with its pins, what reacts to what (on press → light.toggle), and which pin does what. A pin used twice, or a strapping pin the chip boots from, is flagged. After a save, what it added, changed or removed is lit for a few seconds. Passwords and keys in the YAML are never shown.
  • The build as it runs: the phase and a progress bar (compiling with its percentage, linking, uploading), the latest two lines of output with errors in red and warnings in yellow, and the result: "✓ Flashed in 2:41", or "✗ Failed after 1:02" with the first error. Press l for the whole log.
  • Your devices, after Claude lists them, with the ones that have newer firmware waiting marked ↑.
  • A chip on the bench, in the terminal, in round dots. Compiling is a forge: cogs turn and spark, object files fall onto the growing firmware. Flashing sends it to the chip in glittering whooshes. A good build ends in fireworks, a failed one in smoke. At rest the device's own things hang off the chip (a bulb glowing, a thermometer creeping, a button blinking), and with no device known it sleeps and dreams of some. Press b to turn it off.

The pane only reads. It never starts a build: Claude does, with its own tool calls, which you approve as usual.

Quickstart

1. Add the Vome app to Home Assistant, then get a key from it. No sign-up needed.

Add the Vome app to your Home Assistant.

The button opens your own Home Assistant and adds the app's repository. Pick Vome and Install, open it, and on the Agent tab create a key. The same Home Assistant needs the ESPHome Device Builder app: that is what compiles and flashes, and Vome reaches it for Claude with no ports opened.

2. Paste these into Claude Code, in a terminal (version 2.1.275 or newer):

/plugin install vome-connect --marketplace Vortitron/home-assistant-mcp

It asks for the key from step 1. Then:

/plugin install vome-esphome --marketplace Vortitron/home-assistant-mcp
/reload-plugins

Or all four Vome panes at once (automations, ESPHome, health and a working dashboard): /plugin install vome-panes --marketplace Vortitron/home-assistant-mcp.

3. Ask Claude about a device, for example "Add a temperature sensor on GPIO4 to the hallway node and flash it". The pane opens beside the conversation with the device's map, and the build when it starts.

In auto mode, the pane asks you to allow its two read-only tools the first time; it shows the exact lines and where they go.

Allow its reads (auto mode)

The pane reads in the background with two of the MCP's read-only tools: the build's progress (esphome_activity) and, when Claude builds a device it has not read, its YAML for the map (esphome_get_config). In auto mode, Claude Code refuses background calls that no request of yours is behind, unless they're allowed by name; the pane then shows the exact lines to add and where. Under your server's name, for example:

{ "permissions": { "allow": ["mcp__vome__esphome_activity", "mcp__vome__esphome_get_config"] } }

With vome-connect the server is plugin_vome-connect_vome, so the lines are mcp__plugin_vome-connect_vome__esphome_activity and mcp__plugin_vome-connect_vome__esphome_get_config. Without them, the pane still shows the elapsed time, the map of any config Claude reads, and the output once the build finishes.

Keys

/esphomeopen the pane
lthe whole build log, or back to the map and the latest lines
bthe chip animation off or on (remembered)

If another pane is in front, the ESPHome tab's title says how the build is going (ESPHome · 45%, ESPHome ✓).

Privacy

The pane reads only from your own MCP server, through the connection Claude Code already has: what the build commands Claude ran have printed, and the YAML of the device being built. Nothing is sent anywhere else.

Source 5 files
hooks/register.tsx 734 lines
1// A side pane for the ESPHome build Claude is running.
2//
3// A compile or a flash takes minutes and its tool returns all of its output
4// only at the end, so there was nothing to watch while it ran. This pane opens
5// when Claude starts one, reads the build's output as it arrives (the MCP's
6// esphome_activity, read-only), and shows the phase, a progress bar where the
7// output gives one, the latest lines with errors picked out, and the result.
8// With an MCP that predates esphome_activity it still shows the elapsed time
9// and then the output once the build returns. It never runs a build itself.
10//
11// Under the build it draws a map of the device from its YAML (board, network,
12// buses, entities, pins, triggers), whenever Claude reads or writes a config or
13// builds one, with what a save changed lit for a few seconds. The build log is
14// two lines by default; l shows all of it.
15
16import type { EngineInterface, Register } from 'claude-code'
17
18import type { Build, ConfigView, Device } from '../types'
19import { bar, elapsed, kindOf, phaseWords, progressOf, toLines } from './build'
20import { chipFrame, STRIP_ROWS } from './chip'
21import type { ChipState, Scene, Thing } from './chip'
22import { diffMaps, mapDevice, rowKey } from './config'
23import type { MapRow } from './config'
24import type { LineKind } from './build'
25
26const PANE = 'vome-esphome'
27const POLL_MS = 1500
28const KEEP_LINES = 300
29const BUILD_COMMANDS: Record<string, string> = {
30  esphome_validate: 'validate',
31  esphome_compile: 'compile',
32  esphome_upload: 'upload',
33  esphome_logs: 'logs',
34}
35const README_URL = 'https://github.com/Vortitron/home-assistant-mcp/tree/main/claude-plugin/vome-esphome'
36
37const build = { plugin: 'vome-esphome', key: 'build' } as const
38const devices = { plugin: 'vome-esphome', key: 'devices' } as const
39const blocked = { plugin: 'vome-esphome', key: 'blocked' } as const
40const fxOn = { plugin: 'vome-esphome', key: 'fxOn' } as const
41const config = { plugin: 'vome-esphome', key: 'config' } as const
42const showLog = { plugin: 'vome-esphome', key: 'showLog' } as const
43const sibling = { plugin: 'vome-esphome', key: 'sibling' } as const
44const AUTOMATION_INSTALL = '/plugin install vome-automation --marketplace Vortitron/home-assistant-mcp'
45/** vome-automation joined the chain after this plugin, so its hint is not needed (it cannot see one loaded before it). */
46let hasSibling = false
47/** How long a save's changes stay lit in the map. */
48const CHANGE_MS = 8_000
49const LOG_LINES = 2
50/** The strip repaints this often while a build runs, and every few frames at rest. */
51const FRAME_MS = 80
52const IDLE_EVERY = 3
53
54// Module variables reset on a reload, which is all these need.
55let isPolling = false
56/** The MCP has no esphome_activity: stop asking until the next build. */
57let isUnsupported = false
58let settingsFile = '~/.claude/settings.json'
59/** Where the strip is mounted and what it shows, for the frame timer. */
60let strip: { columns: number; state: ChipState } | null = null
61let frame = 0
62let isFxOn = true
63let isBlitting = false
64let shownTitle = 'ESPHome'
65/** The configuration whose YAML the poll has asked for, once a build. */
66let configAsked: string | null = null
67
68export const register: Register = on => {
69  on('plugin.register', ($, e, next) => {
70    if (e.name === 'vome-automation') hasSibling = true
71    return next(e)
72  })
73
74  on('session.start', async ($, e, next) => {
75    await $.command.register({ name: 'esphome', description: 'Show the ESPHome build pane' })
76    const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
77    settingsFile = `${configDir.replace(/\/+$/, '')}/settings.json`
78    $.clock.every(POLL_MS, () => void poll($))
79    isFxOn = (await $.store.get('fxOn')) !== false
80    await $.state.set(fxOn, isFxOn)
81    $.clock.every(FRAME_MS, () => void animate($))
82
83    return next(e)
84  })
85
86  on('command.run', { command: 'esphome' }, async $ => {
87    const opened = await $.ui.open({ id: PANE, title: 'ESPHome' })
88    return { text: opened.isPlaced ? 'ESPHome pane open.' : `The ESPHome pane is not drawn here: ${opened.reason}.` }
89  })
90
91  // The MCP's ESPHome tools, on whatever its server is called (tool matchers must be literals).
92  on('tool.call', async ($, e, next) => {
93    const match = /^mcp__(.+)__(esphome_[a-z_]+)$/.exec(e.tool)
94    // Claude is on an automation: say once that the automation pane shows it, unless it is here or was waved off.
95    if (!match && !hasSibling && /^mcp__.+__ha_(get|set|list|trigger)_automations?$|^mcp__.+__ha_(get|list)_traces?$/.test(e.tool)) {
96      if (!(await $.state.get(sibling)).value && !(await isHintOff($))) await $.state.set(sibling, true)
97    }
98    if (!match?.[1] || !match[2]) return next(e)
99    const server = match[1]
100    const name = match[2]
101    const args = e as unknown as { configuration?: unknown }
102
103    if (name === 'esphome_get_config' && typeof args.configuration === 'string') {
104      const ran = await next(e)
105      if (ran.deny === undefined && !ran.isError && ran.text) await showConfig($, args.configuration, stripStamp(ran.text))
106      return ran
107    }
108    if (name === 'esphome_save_config' && typeof args.configuration === 'string') {
109      const yaml = (e as unknown as { yaml?: unknown }).yaml
110      const ran = await next(e)
111      if (ran.deny === undefined && !ran.isError && typeof yaml === 'string') await showConfig($, args.configuration, yaml)
112      return ran
113    }
114
115    if (name === 'esphome_edit_config' && typeof args.configuration === 'string') {
116      // The edits carry only the changed lines: read the file back for the map, which marks what changed.
117      const ran = await next(e)
118      if (ran.deny === undefined && !ran.isError) await loadConfig($, server, args.configuration)
119      return ran
120    }
121
122    if (name === 'esphome_list_devices') {
123      const ran = await next(e)
124      if (ran.deny === undefined && !ran.isError) {
125        const list = parseDevices(ran.text ?? '')
126        if (list) await $.state.set(devices, list)
127      }
128      return ran
129    }
130
131    const command = BUILD_COMMANDS[name]
132    if (!command || typeof args.configuration !== 'string') return next(e)
133
134    const started: Build = {
135      server,
136      command,
137      configuration: args.configuration,
138      startedAt: Date.now(),
139      finishedAt: null,
140      lines: [],
141      seq: 0,
142      isLive: false,
143      outcome: 'running',
144      error: null,
145    }
146    isUnsupported = false
147    await $.state.set(build, started)
148    await $.ui.open({ id: PANE, title: 'ESPHome' }).catch(() => undefined)
149    shownTitle = 'ESPHome'
150    configAsked = null
151
152    const ran = await next(e)
153    const current = (await $.state.get(build)).value ?? started
154    const body = parseJson(ran.text ?? '')
155    const output = body && typeof body.output === 'string' ? body.output : ''
156    // Lines that arrived live stay; otherwise the full output is the first the pane sees of it.
157    const lines = current.isLive && current.lines.length > 0 ? current.lines : toLines([output]).slice(-KEEP_LINES)
158    const isOk =
159      ran.deny === undefined && !ran.isError && body !== null && (body.success === true || (command === 'logs' && body.stopped === 'timeout'))
160    // Claude Code moved the call to the background (after 120 s) or gave up waiting on it (a silent
161    // 300 s), but the build carries on at home: keep following it, and let the job say how it ended.
162    const said = ran.text ?? ran.deny ?? ''
163    const isBackground = !isOk && /moved to the background|still running after/i.test(said)
164    const isAbandoned = !isOk && (isBackground || (!isUnsupported && /no response or progress|aborted|cancel|timed? ?out/i.test(said)))
165    if (isAbandoned) {
166      await $.state.set(build, {
167        ...current,
168        error: isBackground ? 'Running in the background; it shows here as it goes.' : 'Claude stopped waiting; the build carries on at home and shows here.',
169      })
170      // A read refused earlier may be allowed by now: try again.
171      isUnsupported = false
172      return ran
173    }
174    await $.state.set(build, {
175      ...current,
176      lines,
177      finishedAt: Date.now(),
178      outcome: isOk ? 'ok' : 'failed',
179      error: isOk ? null : firstLine(ran.deny ?? (body === null ? ran.text : null) ?? null),
180    })
181
182    return ran
183  })
184
185  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
186    const { Box, Text, Button, Code, Markdown } = $.ui.resolve(e)
187    const current = (await $.state.get(build)).value ?? null
188    const known = (await $.state.get(devices)).value ?? null
189    const refusal = (await $.state.get(blocked)).value ?? null
190    const isFxEnabled = (await $.state.get(fxOn)).value ?? true
191    const view = (await $.state.get(config)).value ?? null
192    const isFullLog = (await $.state.get(showLog)).value ?? false
193    const siblingHint =
194      !hasSibling && ((await $.state.get(sibling)).value ?? false) ? (
195        <Box flexDirection="row" gap={2} marginTop={1}>
196          <Text dimColor wrap="wrap">
197            Working on automations too? The automation pane shows them, and their runs: <Text color="suggestion">{AUTOMATION_INSTALL}</Text>
198          </Text>
199          <Button
200            key="hide-sibling"
201            dimColor
202            onPress={async () => {
203              await $.store.set('siblingHintOff', true).catch(() => undefined)
204              await $.state.set(sibling, false)
205            }}
206          >
207            Hide
208          </Button>
209        </Box>
210      ) : null
211    const width = Math.max(20, e.props.bodyColumns)
212    const rows = Math.max(6, (e.viewport?.rows ?? 30) - 14)
213
214    const deviceRows = known && known.length > 0 ? (
215      <Box flexDirection="column" marginTop={1}>
216        <Text bold>Devices</Text>
217        {known.map(d => {
218          const isBehind = d.deployed !== null && d.current !== null && d.deployed !== d.current
219          return (
220            <Text wrap="truncate-end">
221              <Text color={isBehind ? 'warning' : undefined}>{isBehind ? '↑ ' : '  '}</Text>
222              {d.name}
223              <Text dimColor>
224                {'  '}
225                {d.configuration}
226                {d.deployed ? `  ${d.deployed}` : ''}
227                {isBehind ? ` → ${d.current} available` : ''}
228              </Text>
229            </Text>
230          )
231        })}
232      </Box>
233    ) : null
234
235    // Auto mode refuses a background read nobody asked for unless it is allowed by name.
236    const help = refusal ? (
237      <Box flexDirection="column" borderStyle="round" borderColor="warning" paddingX={1} marginTop={1}>
238        <Text color="warning" wrap="wrap">
239          Auto mode refused the pane's read of the build's progress or the device's YAML. Add these to permissions.allow
240          in {settingsFile}, then the next build shows live:
241        </Text>
242        <Markdown text={`[${settingsFile}](file://${settingsFile})`} />
243        <Code source={allowLines(refusal.server)} />
244        <Box flexDirection="row" gap={2}>
245          <Button key="copy-rule" hotkey="c" onPress={press => $.ui.copy({ text: allowLines(refusal.server), surface: press.surface })}>
246            Copy the lines
247          </Button>
248          <Button
249            key="retry"
250            hotkey="r"
251            onPress={async () => {
252              // Allowed since? Try the reads again now rather than at the next build.
253              await $.state.set(blocked, null)
254              isUnsupported = false
255              configAsked = null
256              const running = (await $.state.get(build)).value ?? null
257              if (running && running.command !== 'logs') void loadConfig($, refusal.server, running.configuration)
258              void poll($)
259            }}
260          >
261            Retry
262          </Button>
263        </Box>
264      </Box>
265    ) : null
266
267    const map = view ? deviceMap($.ui.resolve(e), view) : null
268    const isStage = e.surface === 'terminal' && isFxEnabled
269    const things = view ? thingsOf(view) : []
270    const stage = (state: Omit<ChipState, 'things' | 'hasWifi'>) => {
271      if (e.surface !== 'terminal') return null
272      const { Raster } = $.ui.resolve(e)
273      const columns = Math.min(512, width)
274      strip = { columns, state: { ...state, things, hasWifi: !!view?.rows.some(row => row.section === 'Network' && row.label === 'wifi') } }
275      return <Raster key="chip" columns={columns} rows={STRIP_ROWS} cells={chipFrame(Date.now(), strip.state, columns)} />
276    }
277
278    if (!current) {
279      if (!isStage) strip = null
280      return (
281        <Box flexDirection="column">
282          {isStage
283            ? stage({
284                outcome: 'idle',
285                scene: 'idle',
286                percent: null,
287                words: view ? `${view.name} · ${things.length || 'no'} thing${things.length === 1 ? '' : 's'} attached` : 'dreaming of devices…',
288                endedAt: null,
289              })
290            : null}
291          {map ?? (
292            <Text dimColor wrap="wrap">
293              Nothing building. When Claude reads, edits or builds an ESPHome device, its map shows here, and a build's
294              progress as it runs.
295            </Text>
296          )}
297          {deviceRows}
298          {help}
299          {siblingHint}
300        </Box>
301      )
302    }
303
304    const parsed = progressOf(current.lines, current.command)
305    const progress = { ...parsed, uploadPercent: uploadShown(parsed, current) }
306    const took = elapsed((current.finishedAt ?? Date.now()) - current.startedAt)
307    const verb = { validate: 'Checking', compile: 'Compiling', upload: 'Flashing', logs: 'Logs from' }[current.command] ?? current.command
308    const done = { validate: 'Valid', compile: 'Compiled', upload: 'Flashed', logs: 'Read the logs' }[current.command] ?? 'Done'
309    const status =
310      current.outcome === 'running'
311        ? `${phaseWords(progress, current.command)} · ${took}`
312        : current.outcome === 'ok'
313          ? `✓ ${done} in ${took}`
314          : `✗ Failed after ${took}`
315    const isUploading = current.outcome === 'running' && progress.uploadPercent !== null
316    const isCompiling = current.outcome === 'running' && !isUploading && progress.compilePercent !== null
317    const kept = current.lines.filter(line => current.command === 'logs' || kindOf(line) !== 'debug')
318    // The latest couple of lines unless asked for all; a failure keeps a few more, where it explains itself.
319    const tail = isFullLog || current.command === 'logs' ? kept.slice(-rows) : kept.slice(current.outcome === 'failed' ? -6 : -LOG_LINES)
320
321    // The chip on the bench: a Raster, which only the terminal draws. Not for a logs read.
322    let chip = null
323    if (isStage) {
324      // The scene follows the build, not the command: a flash compiles first, then uploads.
325      const isUploadPhase = current.command === 'upload' && (progress.uploadPercent !== null || progress.phase === 'uploading')
326      const scene: Scene =
327        current.command === 'logs' ? 'logs' : current.command === 'validate' ? 'validate' : isUploadPhase ? 'upload' : 'compile'
328      const words =
329        current.outcome === 'running'
330          ? `${scene === 'upload' ? 'flashing' : scene === 'validate' ? 'checking' : scene === 'logs' ? 'listening to' : 'compiling'} ${current.configuration}${(scene === 'upload' ? progress.uploadPercent : progress.compilePercent) !== null && scene !== 'logs' ? ` · ${scene === 'upload' ? progress.uploadPercent : progress.compilePercent}%` : ''}`
331          : current.outcome === 'ok'
332            ? `✓ ${current.configuration} ${done.toLowerCase()}`
333            : `✗ ${current.configuration} failed`
334      chip = stage({
335        outcome: current.outcome,
336        scene,
337        percent: scene === 'upload' ? progress.uploadPercent : progress.compilePercent,
338        words,
339        endedAt: current.finishedAt,
340      })
341    } else {
342      strip = null
343    }
344
345    return (
346      <Box flexDirection="column">
347        {chip}
348        <Text bold wrap="truncate-end">
349          {verb} {current.configuration}
350        </Text>
351        <Text color={current.outcome === 'ok' ? 'success' : current.outcome === 'failed' ? 'error' : 'warning'} wrap="truncate-end">
352          {status}
353        </Text>
354        {isUploading ? (
355          <Text color="suggestion">
356            {bar(progress.uploadPercent ?? 0, Math.min(40, width - 8))} {progress.uploadPercent}%
357          </Text>
358        ) : isCompiling ? (
359          <Text color="suggestion">
360            {bar(progress.compilePercent ?? 0, Math.min(40, width - 8))} {progress.compilePercent}%
361          </Text>
362        ) : current.outcome === 'running' ? (
363          <Text color="suggestion">{busy(Date.now(), Math.min(40, width - 8))}</Text>
364        ) : null}
365        <Text dimColor wrap="truncate-end">
366          {[
367            progress.ram ? `RAM ${progress.ram}` : null,
368            progress.flash ? `Flash ${progress.flash}` : null,
369            progress.warnings ? `${progress.warnings} warning${progress.warnings === 1 ? '' : 's'}` : null,
370            current.outcome === 'running' && !current.isLive ? 'output arrives when it finishes' : null,
371          ]
372            .filter(Boolean)
373            .join(' · ') || ' '}
374        </Text>
375        {current.outcome === 'failed' && (progress.errors[0] || current.error) ? (
376          <Text color="error" wrap="wrap">
377            {progress.errors[0] ?? current.error}
378          </Text>
379        ) : null}
380        <Box flexDirection="column">
381          {tail.map(line => (
382            <Text wrap="truncate-end" color={lineColour(kindOf(line))} dimColor={kindOf(line) === 'plain' || kindOf(line) === 'debug'}>
383              {line}
384            </Text>
385          ))}
386        </Box>
387        {isFullLog ? null : map}
388        {deviceRows}
389        {help}
390        {siblingHint}
391        <Box marginTop={1} gap={2}>
392          <Button key="log" hotkey="l" dimColor onPress={() => $.state.set(showLog, !isFullLog)}>
393            {isFullLog ? 'Map and latest lines' : 'Full log'}
394          </Button>
395          <Button
396            key="fx"
397            hotkey="b"
398            dimColor
399            onPress={async () => {
400              isFxOn = !isFxEnabled
401              if (!isFxOn) strip = null
402              await $.store.set('fxOn', isFxOn)
403              await $.state.set(fxOn, isFxOn)
404            }}
405          >
406            {isFxEnabled ? 'Animation off' : 'Animation on'}
407          </Button>
408        </Box>
409      </Box>
410    )
411  })
412}
413
414/** Repaints the chip while the build runs and briefly after, without redrawing the pane. */
415async function animate($: EngineInterface) {
416  if (!isFxOn || !strip || isBlitting) return
417  // At rest (idle, or a finished build that has settled), a gentler frame rate.
418  const ended = strip.state.endedAt
419  const isResting = strip.state.outcome === 'idle' || (ended !== null && Date.now() - ended > 5_000)
420  frame = (frame + 1) % IDLE_EVERY
421  if (isResting && frame !== 0) return
422  isBlitting = true
423  try {
424    const result = await $.ui.blit({ requestId: PANE, key: 'chip', cells: chipFrame(Date.now(), strip.state, strip.columns) })
425    if (result.deny !== undefined) strip = null
426  } finally {
427    isBlitting = false
428  }
429}
430
431// ---------------------------------------------------------------- the device map
432
433function deviceMap(ui: ReturnType<EngineInterface['ui']['resolve']>, view: ConfigView) {
434  const { Box, Text } = ui
435  const isFresh = view.changedAt !== null && Date.now() - view.changedAt < CHANGE_MS
436  const rows: MapRow[] = [...view.rows]
437  if (isFresh) {
438    // A removed row stays a moment where it was, struck out, after the rest of its section.
439    for (const gone of view.removed) {
440      const last = rows.map(r => r.section).lastIndexOf(gone.section)
441      rows.splice(last < 0 ? rows.length : last + 1, 0, gone)
442    }
443  }
444  let section = ''
445  return (
446    <Box flexDirection="column" marginTop={1}>
447      <Text bold wrap="truncate-end">
448        {view.name}
449        <Text dimColor>
450          {'  '}
451          {view.chip}
452          {view.chip ? ' · ' : ''}
453          {view.configuration}
454        </Text>
455      </Text>
456      {rows.map(row => {
457        const change = isFresh ? view.changes[rowKey(row)] : undefined
458        const heading = row.section !== section ? (section = row.section) : null
459        const mark = change === 'added' ? '+' : change === 'changed' ? '~' : change === 'removed' ? '-' : ' '
460        const colour = change === 'removed' ? 'error' : change ? 'suggestion' : undefined
461        return (
462          <Box flexDirection="column">
463            {heading ? (
464              <Text color="subtle" wrap="truncate-end">
465                {heading}
466              </Text>
467            ) : null}
468            <Text wrap="truncate-end" color={colour} strikethrough={change === 'removed'}>
469              {mark}
470              {'  '.repeat(row.depth + 1)}
471              <Text color={colour ?? (row.icon === '!' ? 'warning' : 'suggestion')}>{row.icon}</Text> {row.label}
472              <Text dimColor>
473                {row.detail ? '  ' : ''}
474                {row.detail}
475              </Text>
476            </Text>
477          </Box>
478        )
479      })}
480    </Box>
481  )
482}
483
484/** What to draw wired to the chip at rest: the device's things, from its map, six at most. */
485function thingsOf(view: ConfigView): Thing[] {
486  const things: Thing[] = []
487  for (const row of view.rows) {
488    if (row.icon === '⚡') continue
489    const words = `${row.label} ${row.detail}`.toLowerCase()
490    const kind: Thing | null =
491      row.section === 'Lights' ? 'light'
492      : row.section === 'Sensors' ? (/humid|moist|water/.test(words) ? 'droplet' : /batt|adc|volt/.test(words) ? 'battery' : row.depth === 0 && view.rows.some(r => r.depth === 1 && r.section === 'Sensors' && view.rows.indexOf(r) === view.rows.indexOf(row) + 1) ? null : 'thermometer')
493      : row.section === 'Binary sensors' || row.section === 'Buttons' ? 'button'
494      : row.section === 'Switches' ? 'toggle'
495      : row.section === 'Fans' ? 'fan'
496      : row.section === 'Displays' ? 'screen'
497      : row.section === 'Climate' ? 'thermometer'
498      : ['Covers', 'Locks', 'Valves', 'Numbers', 'Selects', 'Media', 'Infrared and RF'].includes(row.section) ? 'gadget'
499      : null
500    if (kind) things.push(kind)
501  }
502  return things.slice(0, 6)
503}
504
505/** Maps the YAML and, against the last map of the same file, marks what changed. */
506async function showConfig($: EngineInterface, configuration: string, yaml: string) {
507  let device
508  try {
509    device = mapDevice(yaml)
510  } catch {
511    return
512  }
513  const before = (await $.state.get(config)).value ?? null
514  const isSame = before?.configuration === configuration
515  const diff = isSame ? diffMaps(before.rows, device.rows) : { changes: {}, removed: [] }
516  const hasChanges = Object.keys(diff.changes).length > 0
517  await $.state.set(config, {
518    configuration,
519    name: device.name,
520    chip: device.chip,
521    rows: device.rows,
522    changes: hasChanges ? diff.changes : isSame ? before.changes : {},
523    removed: hasChanges ? diff.removed : isSame ? before.removed : [],
524    changedAt: hasChanges ? Date.now() : isSame ? before.changedAt : null,
525  })
526  await $.ui.open({ id: PANE, title: shownTitle }).catch(() => undefined)
527}
528
529/** Reads the YAML of what is being built, for the map; a refusal shows the line to allow. */
530async function loadConfig($: EngineInterface, server: string, configuration: string) {
531  try {
532    const result = await $.mcp.call(server, 'esphome_get_config', { configuration })
533    if (result.isError) return
534    await showConfig($, configuration, stripStamp(result.content.map(block => block.text ?? '').join('\n')))
535  } catch (error) {
536    if (/auto mode|classifier|permission|denied|not allowed/i.test(String(error))) {
537      await $.state.set(blocked, { server, error: String(error) })
538    }
539  }
540}
541
542function allowLines(server: string): string {
543  return `"mcp__${server}__esphome_activity",\n"mcp__${server}__esphome_get_config"`
544}
545
546function stripStamp(text: string): string {
547  return text.replace(/\n?\[vome-instance\][^\n]*/g, '')
548}
549
550/** The pane's tab says how the build is going, since another pane may be in front of it. */
551async function retitle($: EngineInterface, current: Build) {
552  const parsed = progressOf(current.lines, current.command)
553    const progress = { ...parsed, uploadPercent: uploadShown(parsed, current) }
554  const percent = progress.uploadPercent ?? progress.compilePercent
555  const title =
556    current.outcome === 'ok'
557      ? 'ESPHome ✓'
558      : current.outcome === 'failed'
559        ? 'ESPHome ✗'
560        : `ESPHome · ${percent !== null ? `${Math.floor(percent / 5) * 5}%` : '…'}`
561  if (title === shownTitle) return
562  shownTitle = title
563  try {
564    // Only a pane that is up: one the person closed stays closed.
565    if (!(await $.ui.panes()).some(pane => pane.id === PANE)) return
566    await $.ui.open({ id: PANE, title })
567  } catch {
568    // A surface without panes: the title is a nicety.
569  }
570}
571
572// ---------------------------------------------------------------- polling
573
574/** While a build runs: its new output from esphome_activity, and a redraw for the clock. */
575async function poll($: EngineInterface) {
576  if (isPolling) return
577  const view = (await $.state.get(config)).value ?? null
578  if (view?.changedAt && Date.now() - view.changedAt < CHANGE_MS + POLL_MS) $.ui.invalidate('ui.render')
579  const current = (await $.state.get(build)).value ?? null
580  if (current && current.finishedAt && Date.now() - current.finishedAt < POLL_MS * 2) await retitle($, current)
581  if (!current || current.outcome !== 'running') return
582  // The map of what is being built, read alongside when Claude has not read it this session.
583  // (Here, not in the tool hook: a state read there before the call pins its view of the build.)
584  if (configAsked !== current.configuration && current.command !== 'logs') {
585    configAsked = current.configuration
586    if (view?.configuration !== current.configuration) void loadConfig($, current.server, current.configuration)
587  }
588  isPolling = true
589  try {
590    if (isUnsupported) {
591      $.ui.invalidate('ui.render')
592      return
593    }
594    let text = ''
595    try {
596      const result = await $.mcp.call(current.server, 'esphome_activity', { since: current.seq })
597      text = result.content.map(block => block.text ?? '').join('\n')
598      if (result.isError) {
599        isUnsupported = true
600        await giveUp($)
601        return
602      }
603    } catch (error) {
604      if (/auto mode|classifier|permission|denied|not allowed/i.test(String(error))) {
605        await $.state.set(blocked, { server: current.server, error: String(error) })
606      }
607      isUnsupported = true
608      await giveUp($)
609      return
610    }
611    const body = parseJson(text)
612    const jobs = body && Array.isArray(body.jobs) ? (body.jobs as Record<string, unknown>[]) : []
613    // This build's jobs: same configuration, started no earlier than the call. A flash is two
614    // (the MCP compiles, then uploads), followed as one build.
615    const commands = current.command === 'upload' ? ['compile', 'upload'] : [current.command]
616    const mine = jobs
617      .filter(j => commands.includes(String(j.command)) && j.configuration === current.configuration)
618      .filter(j => Date.parse(String(j.started)) >= current.startedAt - 5_000)
619    const fresh = mine.flatMap(j => (Array.isArray(j.lines) ? toLines(j.lines.map(String)) : []))
620    // The job that says how it ended: the last one, once it is the build's own command or has failed.
621    const last = mine.at(-1)
622    const job = last && (last.command === current.command || (last.done === true && last.exit_code !== 0)) ? last : undefined
623    const seq = typeof body?.seq === 'number' ? body.seq : current.seq
624    const latest = (await $.state.get(build)).value ?? current
625    if (latest.outcome !== 'running') return
626    // The job finished: the result comes from it when the tool call was abandoned before it.
627    if (job && job.done === true && latest.error) {
628      await $.state.set(build, {
629        ...latest,
630        lines: [...latest.lines, ...fresh].slice(-KEEP_LINES),
631        seq,
632        finishedAt: Date.now(),
633        outcome: job.exit_code === 0 ? 'ok' : 'failed',
634        error: job.exit_code === 0 ? null : typeof job.error === 'string' ? job.error : `exit code ${String(job.exit_code)}`,
635      })
636      return
637    }
638    await retitle($, { ...latest, lines: [...latest.lines, ...fresh] })
639    const isUploadStage = !latest.uploadSeenAt && progressOf([...latest.lines, ...fresh], latest.command).phase === 'uploading'
640    if (fresh.length > 0 || seq !== latest.seq || isUploadStage) {
641      await $.state.set(build, {
642        ...latest,
643        uploadSeenAt: latest.uploadSeenAt ?? (isUploadStage ? Date.now() : null),
644        lines: [...latest.lines, ...fresh].slice(-KEEP_LINES),
645        seq,
646        isLive: latest.isLive || fresh.length > 0,
647      })
648      if ((await $.state.get(blocked)).value) await $.state.set(blocked, null)
649    } else {
650      $.ui.invalidate('ui.render')
651    }
652  } finally {
653    isPolling = false
654  }
655}
656
657/** A call Claude abandoned, with no way to follow the job: say so rather than spin for ever. */
658async function giveUp($: EngineInterface) {
659  const latest = (await $.state.get(build)).value ?? null
660  if (latest?.outcome === 'running' && latest.error) await $.state.set(build, { ...latest, finishedAt: Date.now(), outcome: 'failed' })
661}
662
663/**
664 * The upload's percentage as shown. ESPHome's OTA redraws its bar with carriage returns and the
665 * stream delivers only the last state, "100% Done", at the end, so the chip used to fill in one
666 * jump. A real 1-99% wins; otherwise pace it from the firmware's size at about 90 KB/s (GamlaBio's
667 * LoftC3: 1.37 MB in 14.9 s), holding at 97% until the device says it is done.
668 */
669function uploadShown(progress: ReturnType<typeof progressOf>, current: Build): number | null {
670  if (progress.phase !== 'uploading' && progress.uploadPercent === null) return null
671  const real = progress.uploadPercent
672  if (real !== null && (real >= 100 || real > 0)) return real
673  if (current.outcome !== 'running') return current.outcome === 'ok' ? 100 : real
674  const since = current.uploadSeenAt ? Date.now() - current.uploadSeenAt : 0
675  const expected = progress.uploadBytes ? (progress.uploadBytes / 90_000) * 1000 + 1500 : 15_000
676  return Math.min(97, Math.round((since / expected) * 100))
677}
678
679// ---------------------------------------------------------------- small things
680
681function lineColour(kind: LineKind): string | undefined {
682  return kind === 'error' ? 'error' : kind === 'warning' ? 'warning' : undefined
683}
684
685/** A bar that moves while there is no percentage to show. */
686function busy(now: number, width: number): string {
687  const at = Math.floor(now / 400) % (width + 6)
688  return Array.from({ length: width }, (_, i) => (i >= at - 6 && i < at ? '▰' : '▱')).join('')
689}
690
691function firstLine(text: string | null): string | null {
692  return text ? (text.split('\n').find(line => line.trim()) ?? null) : null
693}
694
695/** The JSON body of a vome MCP reply, which ends with a `[vome-instance]` line. */
696function parseJson(raw: string): Record<string, unknown> | null {
697  // Drop the stamp first: its own brackets would otherwise end the slice below inside it.
698  const text = raw.replace(/\[vome-instance\][^\n]*/g, '')
699  const start = text.search(/[[{]/)
700  const end = Math.max(text.lastIndexOf('}'), text.lastIndexOf(']'))
701  if (start < 0 || end <= start) return null
702  try {
703    const value = JSON.parse(text.slice(start, end + 1)) as unknown
704    return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : { list: value }
705  } catch {
706    return null
707  }
708}
709
710/** esphome_list_devices: the dashboard's own model, `{configured: [...]}` or a bare list, tolerated either way. */
711function parseDevices(text: string): Device[] | null {
712  const body = parseJson(text)
713  if (!body) return null
714  const holder = (body.devices && typeof body.devices === 'object' ? body.devices : body) as Record<string, unknown>
715  const rows = Array.isArray(holder.configured) ? holder.configured : Array.isArray(holder.list) ? holder.list : Array.isArray(holder) ? holder : []
716  return (rows as Record<string, unknown>[])
717    .filter(row => row && typeof row === 'object')
718    .map(row => ({
719      name: String(row.friendly_name ?? row.name ?? row.configuration ?? '?'),
720      configuration: String(row.configuration ?? ''),
721      deployed: typeof row.deployed_version === 'string' ? row.deployed_version : null,
722      current: typeof row.current_version === 'string' ? row.current_version : null,
723    }))
724}
725
726/** Whether the person waved the other pane's hint off; a store that cannot answer means no. */
727async function isHintOff($: EngineInterface): Promise<boolean> {
728  try {
729    return (await $.store.get('siblingHintOff')) === true
730  } catch {
731    return false
732  }
733}
734
hooks/build.ts 144 lines
1// Reads ESPHome's build and log output into what the pane shows: the phase a
2// build is in, how far through it is where that is knowable, the errors and
3// warnings, and each line's kind. Pure: no `$` here.
4//
5// ESPHome drives PlatformIO, whose output has a recognisable shape: one
6// "Compiling <file>.o" per source file, then "Linking", then the firmware image,
7// RAM and Flash usage, and for a flash an "Uploading: [====   ] 45%" bar that
8// redraws itself with carriage returns.
9
10export type Phase = 'starting' | 'config' | 'setup' | 'compiling' | 'linking' | 'image' | 'uploading' | 'done' | 'failed'
11
12export type LineKind = 'error' | 'warning' | 'info' | 'debug' | 'plain'
13
14export type Progress = {
15  phase: Phase
16  /** Source files compiled so far. */
17  compiled: number
18  /** ESP-IDF's ninja counts its build steps ("[437/972] Building C object ..."), so a percentage is known. */
19  compilePercent: number | null
20  /** Upload progress, 0 to 100, once uploading. */
21  uploadPercent: number | null
22  /** The firmware's size, from "Uploading <file> (N bytes)": paces the bar when OTA reports only its end. */
23  uploadBytes: number | null
24  ram: string | null
25  flash: string | null
26  errors: string[]
27  warnings: number
28}
29
30// Colour codes, real or written out as text: the dashboard's stream sends `\033[32m` as characters.
31const ANSI = /(\x1b|\\033|\\x1b|\\u001b)\[[0-9;]*[A-Za-z]/g
32
33/** Output chunks as display lines: colours removed, carriage-return redraws collapsed to their last state. */
34export function toLines(chunks: string[]): string[] {
35  const out: string[] = []
36  for (const chunk of chunks) {
37    for (const raw of chunk.replace(ANSI, '').split('\n')) {
38      const last = raw.split('\r').filter(part => part.trim() !== '').at(-1)
39      if (last !== undefined) out.push(last.trimEnd())
40    }
41  }
42  return out
43}
44
45export function kindOf(line: string): LineKind {
46  if (/\[E\]|^ERROR\b|\berror:|\bFAILED\b|Error \d+$/i.test(line) && !/\b0 errors?\b/i.test(line)) return 'error'
47  if (/\[W\]|^WARNING\b|\bwarning:/i.test(line)) return 'warning'
48  if (/\[I\]|^INFO\b/.test(line)) return 'info'
49  if (/\[[DVC]\]/.test(line)) return 'debug'
50  return 'plain'
51}
52
53/** Where a build is, from all its lines so far. */
54export function progressOf(lines: string[], command: string): Progress {
55  let mainTotal = 0
56  const p: Progress = { phase: 'starting', compiled: 0, compilePercent: null, uploadPercent: null, uploadBytes: null, ram: null, flash: null, errors: [], warnings: 0 }
57  for (const line of lines) {
58    const kind = kindOf(line)
59    if (kind === 'error') p.errors.push(line)
60    if (kind === 'warning') p.warnings += 1
61    if (/Reading configuration|Generating C\+\+ source|Configuration is valid/.test(line)) p.phase = higher(p.phase, 'config')
62    // A first build fetches and unpacks the toolchain before compiling anything.
63    if (/Downloading ESP-IDF|Installing ESP-IDF|Installing tools|ESP-IDF tool|^Extracting |platformio.*install/i.test(line)) p.phase = higher(p.phase, 'setup')
64    if (/^Compiling .*\.o\b/.test(line)) {
65      p.compiled += 1
66      p.phase = higher(p.phase, 'compiling')
67    }
68    const step = /^\[\s*(\d+)\/(\d+)\]\s+(.*)/.exec(line)
69    // Near the end ESP-IDF builds the bootloader as a project of its own, counting "[1/110]" again:
70    // follow the main build's count (the largest total) so the bar does not drop from 99% to 1%.
71    if (step && Number(step[2]) >= mainTotal) {
72      const done = Number(step[1])
73      const total = Number(step[2])
74      mainTotal = total
75      if (total > 0) p.compilePercent = Math.min(100, Math.round((done / total) * 100))
76      p.compiled = done
77      // Static libraries are linked all through the build; only the executable is the final link.
78      p.phase = higher(p.phase, /^Linking (C|CXX) executable/.test(step[3] ?? '') ? 'linking' : 'compiling')
79    }
80    if (/^Linking \.pio|^Linking .*firmware\.elf|Linking (C|CXX) executable/.test(line)) p.phase = higher(p.phase, 'linking')
81    if (/^Building .*firmware\.(bin|elf|factory\.bin)|Successfully created .* image|elf2image|Generated .*\.bin/i.test(line)) p.phase = higher(p.phase, 'image')
82    const ram = /^RAM:\s+\[[^\]]*\]\s+([\d.]+%)/.exec(line)
83    if (ram) p.ram = ram[1] ?? null
84    const flash = /^Flash:\s+\[[^\]]*\]\s+([\d.]+%)/.exec(line)
85    if (flash) p.flash = flash[1] ?? null
86    const upload = /Uploading:\s+\[[=\s]*\]\s+(\d+)%/.exec(line)
87    if (upload) {
88      p.uploadPercent = Number(upload[1])
89      p.phase = higher(p.phase, 'uploading')
90    }
91    // The OTA has begun before any percentage: ESPHome connects, says what it sends, shakes hands.
92    if (command === 'upload' && /Connecting to .* port \d+|^INFO Uploading \/|^Uploading \/|Handshake complete/.test(line)) p.phase = higher(p.phase, 'uploading')
93    const size = /Uploading \S+ \((\d+) bytes\)/.exec(line)
94    if (size) p.uploadBytes = Number(size[1])
95    if (/OTA successful|Successfully uploaded program/i.test(line)) p.uploadPercent = 100
96  }
97  if (command === 'validate' && p.phase === 'starting' && lines.length > 0) p.phase = 'config'
98  return p
99}
100
101const ORDER: Phase[] = ['starting', 'config', 'setup', 'compiling', 'linking', 'image', 'uploading', 'done', 'failed']
102
103function higher(a: Phase, b: Phase): Phase {
104  return ORDER.indexOf(b) > ORDER.indexOf(a) ? b : a
105}
106
107/** "1:23" from milliseconds. */
108export function elapsed(ms: number): string {
109  const s = Math.max(0, Math.floor(ms / 1000))
110  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
111}
112
113/** A text progress bar: `width` cells, filled to `percent`. */
114export function bar(percent: number, width: number): string {
115  const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
116  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
117}
118
119/** What a phase reads as, for the status line. */
120export function phaseWords(p: Progress, command: string): string {
121  switch (p.phase) {
122    case 'starting':
123      return command === 'logs' ? 'Connecting to the device' : 'Starting'
124    case 'config':
125      return command === 'validate' ? 'Checking the configuration' : 'Reading the configuration'
126    case 'setup':
127      return 'Setting up the toolchain (first build)'
128    case 'compiling':
129      return p.compilePercent !== null
130        ? `Compiling ${p.compilePercent}% (${p.compiled} steps)`
131        : `Compiling (${p.compiled} file${p.compiled === 1 ? '' : 's'})`
132    case 'linking':
133      return 'Linking'
134    case 'image':
135      return 'Building the firmware image'
136    case 'uploading':
137      return `Uploading ${p.uploadPercent ?? 0}%`
138    case 'done':
139      return 'Done'
140    case 'failed':
141      return 'Failed'
142  }
143}
144
hooks/chip.ts 556 lines
1// The dot-matrix stage over the ESPHome pane: a chip on the bench, and what is
2// happening to it, scene by scene as the build goes rather than by command (a
3// flash compiles first, then uploads).
4//
5// - compile: a forge. Two cogs turn and throw sparks, object files rain onto a
6//   firmware block that grows with the build; the chip waits.
7// - upload: from the finished block, big dots whoosh to the chip with
8//   glittering tails, and the chip fills as it flashes.
9// - validate: a magnifier scans lines of code. logs: a heartbeat.
10// - idle: the chip with its things wired to it, from the device's map (a bulb
11//   glowing, a thermometer creeping, a button blinking...), pulses running down
12//   the wires. With no device known it sleeps, and dreams of devices.
13// - the end: fireworks for a good build, sparks and smoke for a failed one.
14//
15// Pure: one frame per call, as braille Raster cells (2 x 4 dots a cell, one
16// colour a cell), the last row a caption.
17
18export const STRIP_ROWS = 6
19
20export type Scene = 'compile' | 'upload' | 'validate' | 'logs' | 'idle'
21
22/** Something on the device, drawn wired to the chip when it is idle. */
23export type Thing = 'light' | 'thermometer' | 'droplet' | 'button' | 'battery' | 'toggle' | 'fan' | 'screen' | 'gadget'
24
25export type ChipState = {
26  outcome: 'running' | 'ok' | 'failed' | 'idle'
27  scene: Scene
28  /** Build or upload progress where known, else null. */
29  percent: number | null
30  words: string
31  /** When the build ended, for the fireworks or smoke after it. */
32  endedAt: number | null
33  things: Thing[]
34  hasWifi: boolean
35}
36
37const PANEL = 0x0b1020
38const BODY = 0x3a4252
39const EDGE = 0x8e96a3
40const PIN = 0xc9a227
41const PACKET = 0x64d2ff
42const PAGE = 0xd8dee9
43const AMBER = 0xffb43c
44const YELLOW = 0xffe066
45const GREEN = 0x30d158
46const RED = 0xff453a
47const SMOKE = 0x6b7280
48const QUIET = 0x8e96a3
49const WIRE = 0x4b5566
50const NIGHT = 0x9d8cff
51
52/** After this long, a finished build settles into the idle scene. */
53const SETTLE_S = 4
54
55type Canvas = { w: number; h: number; px: Uint32Array }
56type Chip = { left: number; top: number; cw: number; ch: number; cy: number }
57
58export function chipFrame(now: number, state: ChipState, columns: number): string {
59  const c: Canvas = { w: columns * 2, h: (STRIP_ROWS - 1) * 4, px: new Uint32Array(columns * 2 * (STRIP_ROWS - 1) * 4) }
60  const t = now / 1000
61  const since = state.endedAt === null ? Infinity : (now - state.endedAt) / 1000
62  const isSettled = state.outcome === 'idle' || (state.outcome !== 'running' && since > SETTLE_S)
63  // Lined up with the braille cells (4 dots high): the body fills rows 1-3, the pins have rows of
64  // their own above and below, so a pin's colour never bleeds into the body (one colour a cell).
65  const cw = 26
66  const ch = 12
67  const top = 4
68  const left = Math.max(cw, Math.min(c.w - cw - 6, Math.floor(c.w * (isSettled ? 0.5 : 0.56) - (isSettled ? cw / 2 : 0))))
69  const chip: Chip = { left, top, cw, ch, cy: top + Math.floor(ch / 2) }
70
71  if (isSettled) {
72    idle(c, t, chip, state)
73    return encode(c, columns, state.words, state.outcome === 'failed' ? RED : state.outcome === 'ok' ? GREEN : QUIET)
74  }
75
76  const isRunning = state.outcome === 'running'
77  if (isRunning) {
78    if (state.scene === 'upload') upload(c, t, chip)
79    else if (state.scene === 'validate') scan(c, t, chip)
80    else if (state.scene === 'logs') heartbeat(c, t, chip)
81    else forge(c, t, chip, state.percent)
82  }
83  const fill = state.outcome === 'ok' ? 100 : state.scene === 'upload' || !isRunning ? state.percent : null
84  drawChip(c, t, chip, {
85    fill,
86    fillColour: state.outcome === 'failed' ? RED : PACKET,
87    pins: isRunning ? 'chase' : state.outcome === 'ok' ? GREEN : RED,
88    led: state.outcome === 'ok' ? GREEN : state.outcome === 'failed' ? RED : Math.floor(t * (state.scene === 'upload' ? 8 : 2)) % 2 === 0 ? AMBER : BODY,
89    isFilling: isRunning,
90  })
91  if (state.outcome === 'ok') fireworks(c, since, left + cw / 2, cw)
92  if (state.outcome === 'failed') smoke(c, t, since, chip)
93
94  return encode(c, columns, state.words, state.outcome === 'ok' ? GREEN : state.outcome === 'failed' ? RED : QUIET)
95}
96
97// ---------------------------------------------------------------- the chip
98
99function drawChip(
100  c: Canvas,
101  t: number,
102  { left, top, cw, ch }: Chip,
103  look: { fill: number | null; fillColour: number; pins: 'chase' | number; led: number; isFilling: boolean },
104) {
105  for (let y = top; y < top + ch; y++) {
106    for (let x = left; x < left + cw; x++) {
107      const isEdge = y === top || y === top + ch - 1 || x === left || x === left + cw - 1
108      // Round dots, not a block: the outline, and a sparse grid inside it.
109      if (isEdge) set(c, x, y, EDGE)
110      else if ((x - left) % 2 === 0 && (y - top) % 2 === 0) set(c, x, y, BODY)
111    }
112  }
113  if (look.fill !== null) {
114    const filled = Math.round((look.fill / 100) * (cw - 4))
115    for (let x = 0; x < filled; x++) {
116      for (let y = top + 3; y < top + ch - 2; y++) set(c, left + 2 + x, y, x === filled - 1 && look.isFilling ? PAGE : look.fillColour)
117    }
118  }
119  const pinCount = Math.floor((cw - 2) / 3)
120  for (let i = 0; i < pinCount; i++) {
121    const x = left + 2 + i * 3
122    let colour = typeof look.pins === 'number' ? look.pins : PIN
123    if (look.pins === 'chase') {
124      const lit = (i + Math.floor(t * 12)) % 5 === 0 || (pinCount - i + Math.floor(t * 12)) % 7 === 0
125      if (lit) colour = hue((i / pinCount + t * 0.4) % 1)
126    }
127    for (const y of [top - 1, top - 2, top + ch, top + ch + 1]) set(c, x, y, colour)
128  }
129  for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) set(c, left + 3 + dx, top + 2 + dy, look.led)
130}
131
132// ---------------------------------------------------------------- building
133
134/** Where the forge stands: the firmware block, and the two cogs left of it. */
135function forgeAt({ left, cy }: Chip) {
136  const bw = 10
137  const bx = Math.max(16, Math.floor(left * 0.42))
138  return { bx, bw, base: cy + 7, gearA: { x: bx - 21, y: cy + 2, r: 4 }, gearB: { x: bx - 9, y: cy - 3, r: 3 } }
139}
140
141/** A cog: a rim, short teeth round it and spokes inside, all turning. */
142function cog(c: Canvas, x: number, y: number, r: number, turn: number, teeth: number) {
143  if (x < 4) return
144  const at = (angle: number, d: number) => [Math.round(x + Math.cos(angle) * d * 1.5), Math.round(y + Math.sin(angle) * d)] as const
145  for (let a = 0; a < 28; a++) {
146    const [px, py] = at((a / 28) * Math.PI * 2, r)
147    set(c, px, py, EDGE)
148  }
149  for (let k = 0; k < teeth; k++) {
150    const [px, py] = at(turn + (k / teeth) * Math.PI * 2, r + 1.2)
151    set(c, px, py, AMBER)
152  }
153  for (let k = 0; k < 3; k++) {
154    for (let d = 1; d < r; d++) {
155      const [px, py] = at(turn + (k / 3) * Math.PI * 2, d)
156      set(c, px, py, scale(AMBER, 0.7))
157    }
158  }
159  set(c, Math.round(x), Math.round(y), YELLOW)
160}
161
162/** The firmware block: rows of dots, as high as the build is far along. */
163function block(c: Canvas, bx: number, bw: number, base: number, percent: number, t: number) {
164  const tall = 13
165  const rows = Math.round((percent / 100) * tall)
166  for (let r = 0; r < rows; r++) {
167    for (let x = 0; x < bw; x++) {
168      if ((x + r) % 2 === 0) set(c, bx + x, base - r, r === rows - 1 ? (Math.floor(t * 6 + x) % 3 === 0 ? PAGE : PACKET) : PACKET)
169    }
170  }
171  // Its outline, waiting to be filled.
172  for (let r = 0; r <= tall; r += 2) {
173    set(c, bx - 1, base - r, WIRE)
174    set(c, bx + bw, base - r, WIRE)
175  }
176  return base - rows
177}
178
179/** Compiling: cogs turn and spark, object files rain onto the growing firmware. */
180function forge(c: Canvas, t: number, chip: Chip, percent: number | null) {
181  const { bx, bw, base, gearA, gearB } = forgeAt(chip)
182  cog(c, gearA.x, gearA.y, gearA.r, t * 2.2, 8)
183  cog(c, gearB.x, gearB.y, gearB.r, -t * 2.2 * (gearA.r / gearB.r), 6)
184  const surface = block(c, bx, bw, base, percent ?? (t * 7) % 100, t)
185  // Object files, little pages, falling onto the top of the block.
186  for (let k = 0; k < 3; k++) {
187    const drop = Math.max(2, surface - 3)
188    const run = t * 9 + k * (drop / 3)
189    const fall = run % drop
190    const x = bx + 1 + ((k * 3 + Math.floor(run / drop) * 4) % (bw - 3))
191    const colour = hue((k * 0.33 + Math.floor(run / drop) * 0.17) % 1)
192    for (let dx = 0; dx < 2; dx++) for (let dy = 0; dy < 2; dy++) set(c, x + dx, Math.round(fall) + dy, colour)
193  }
194  // Sparks where the cogs meet.
195  const mx = (gearA.x + gearB.x) / 2 + 2
196  const my = (gearA.y + gearB.y) / 2
197  for (let k = 0; k < 5; k++) {
198    const life = (t * 2.4 + k * 0.21) % 1
199    const angle = -Math.PI / 2 + Math.sin(k * 12.9898 + Math.floor(t * 2.4 + k * 0.21) * 4.1) * 1.3
200    if (life < 0.7) set(c, Math.round(mx + Math.cos(angle) * life * 10), Math.round(my + Math.sin(angle) * life * 7 + life * life * 6), life < 0.3 ? YELLOW : AMBER)
201  }
202}
203
204/** Flashing: big dots leave the finished firmware and whoosh to the chip, glittering behind. */
205function upload(c: Canvas, t: number, chip: Chip) {
206  const { bx, bw, base, gearA, gearB } = forgeAt(chip)
207  cog(c, gearA.x, gearA.y, gearA.r, t * 0.6, 8)
208  cog(c, gearB.x, gearB.y, gearB.r, -t * 0.6 * (gearA.r / gearB.r), 6)
209  block(c, bx, bw, base, 100, t)
210  const x0 = bx + bw + 1
211  const y0 = base - 6
212  const x1 = chip.left + 3
213  const y1 = chip.cy
214  for (let k = 0; k < 3; k++) {
215    const p = (t * 0.75 + k / 3) % 1
216    const at = (q: number) => {
217      const e = q * q * (3 - 2 * q)
218      return { x: x0 + (x1 - x0) * e, y: y0 + (y1 - y0) * e - Math.sin(e * Math.PI) * 7 }
219    }
220    // The glittering tail: behind the head, twinkling, fading.
221    for (let j = 1; j <= 14; j++) {
222      const q = p - j * 0.022
223      if (q <= 0) break
224      const spot = at(q)
225      const twinkle = hash(j, k, Math.floor(t * 18)) % 10
226      if (twinkle < 6) set(c, Math.round(spot.x + (twinkle % 3) - 1), Math.round(spot.y + ((twinkle >> 1) % 3) - 1), scale(hue((j * 0.07 + t * 0.5 + k / 3) % 1), 1 - j / 16))
227    }
228    const head = at(p)
229    for (const [dx, dy] of [[0, -1], [-1, 0], [0, 0], [1, 0], [0, 1], [1, -1], [1, 1], [-1, -1], [-1, 1]] as const) {
230      const isCorner = dx !== 0 && dy !== 0
231      set(c, Math.round(head.x) + dx, Math.round(head.y) + dy, isCorner ? PACKET : PAGE)
232    }
233  }
234}
235
236/** Lines of code on the left, a magnifier sliding down them, ticked green behind it. */
237function scan(c: Canvas, t: number, { left, cy }: Chip) {
238  const sx = Math.max(10, Math.floor(left * 0.35))
239  const rows = 7
240  const at = (t * 2.5) % rows
241  for (let row = 0; row < rows; row++) {
242    const y = cy - 7 + row * 2
243    const indent = (row % 3) * 2
244    const length = 8 + ((row * 7) % 9)
245    for (let x = indent; x < indent + length; x++) set(c, x + sx - 8, y, row < at ? GREEN : PAGE)
246  }
247  const gy = cy - 7 + at * 2
248  const gx = sx + 4
249  for (let a = 0; a < 16; a++) {
250    const angle = (a / 16) * Math.PI * 2
251    set(c, Math.round(gx + Math.cos(angle) * 4), Math.round(gy + Math.sin(angle) * 2.5), AMBER)
252  }
253  for (let d = 0; d < 4; d++) set(c, gx + 3 + d, Math.round(gy + 2 + d * 0.7), AMBER)
254}
255
256/** A heartbeat trace running out of the chip to the left. */
257function heartbeat(c: Canvas, t: number, { left, cy }: Chip) {
258  const shift = Math.floor(t * 30)
259  for (let x = 0; x < left - 1; x++) {
260    const phase = (x + shift) % 40
261    const y = phase === 20 ? cy - 6 : phase === 21 ? cy + 4 : phase === 22 ? cy - 2 : cy
262    set(c, left - 2 - x, y, x < 20 ? GREEN : x < 50 ? PACKET : EDGE)
263  }
264}
265
266// ---------------------------------------------------------------- endings
267
268/** Three bursts, coloured, falling a little as they open. */
269function fireworks(c: Canvas, since: number, centre: number, cw: number) {
270  for (let b = 0; b < 3; b++) {
271    const age = since - b * 0.5
272    if (age < 0 || age > 1.8) continue
273    const bx = centre + (b - 1) * cw * 0.9
274    const by = 6 + (b % 2) * 4
275    const r = age * 9
276    for (let k = 0; k < 14; k++) {
277      const angle = (k / 14) * Math.PI * 2 + b
278      const bright = hue((b * 0.3 + k / 14) % 1)
279      set(c, Math.round(bx + Math.cos(angle) * r * 1.6), Math.round(by + Math.sin(angle) * r + age * age * 2), age > 1.3 ? scale(bright, 0.5) : bright)
280    }
281  }
282}
283
284/** Sparks off the chip at once, then smoke drifting up and away to the right. */
285function smoke(c: Canvas, t: number, since: number, { left, cw, top }: Chip) {
286  if (since < 0.8) {
287    for (let k = 0; k < 8; k++) {
288      const angle = (k / 8) * Math.PI * 2 + t * 9
289      const r = 3 + since * 10
290      if (Math.sin(t * 40 + k) > 0) set(c, Math.round(left + cw / 2 + Math.cos(angle) * r * 1.5), Math.round(top + 4 + Math.sin(angle) * r * 0.6), k % 2 ? AMBER : RED)
291    }
292  }
293  for (let k = 0; k < 14; k++) {
294    const rise = ((since * 2.5 + k * 0.45) % 5) * 1.6
295    set(c, Math.round(left + cw - 4 + rise * 2.2 + Math.sin(t * 2 + k) * 1.5), Math.round(top + 8 - rise * 0.8), SMOKE)
296  }
297}
298
299// ---------------------------------------------------------------- idle
300
301/** The chip at rest: its things wired to it and alive, or, with none known, its dreams. */
302function idle(c: Canvas, t: number, chip: Chip, state: ChipState) {
303  const { left, cw, cy, top } = chip
304  const right = left + cw
305  const led = state.outcome === 'failed' ? RED : state.things.length > 0 ? (Math.sin(t * 2) > 0.6 ? GREEN : scale(GREEN, 0.4)) : BODY
306  drawChip(c, t, chip, { fill: null, fillColour: PACKET, pins: PIN, led, isFilling: false })
307  if (state.hasWifi) {
308    // On the network: a little signal in the chip's corner, breathing.
309    const strength = Math.floor((t * 1.5) % 4)
310    for (let k = 0; k < strength; k++) for (let d = 0; d <= k; d++) set(c, right - 5 + k, top + 3 - d, PACKET)
311  }
312  if (state.things.length === 0) {
313    dreams(c, t, chip)
314    return
315  }
316
317  // Slots: two a side close in (above and below the middle), then one a side further out.
318  const slots = [
319    { x: left - 12, y: cy - 4, side: -1 },
320    { x: right + 11, y: cy - 4, side: 1 },
321    { x: left - 12, y: cy + 5, side: -1 },
322    { x: right + 11, y: cy + 5, side: 1 },
323    { x: left - 28, y: cy, side: -1 },
324    { x: right + 27, y: cy, side: 1 },
325  ].filter(slot => slot.x > 3 && slot.x < c.w - 4)
326  state.things.slice(0, slots.length).forEach((thing, i) => {
327    const slot = slots[i]!
328    const from = slot.side < 0 ? left - 1 : right
329    const to = slot.x - slot.side * 4
330    const span = Math.abs(to - from)
331    for (let d = 0; d <= span; d += 2) set(c, from + slot.side * d, slot.y, WIRE)
332    // A pulse now and then, out to the thing.
333    const p = (t * 0.5 + i * 0.37) % 1
334    if (p < 0.5) set(c, Math.round(from + slot.side * span * (p * 2)), slot.y, PACKET)
335    sprite(c, t + i * 1.7, thing, slot.x, slot.y)
336  })
337}
338
339function sprite(c: Canvas, t: number, thing: Thing, x: number, y: number) {
340  switch (thing) {
341    case 'light': {
342      const glow = 0.55 + 0.45 * Math.sin(t * 1.6)
343      ring(c, x, y - 1, 2.6, 1.4, scale(YELLOW, glow))
344      set(c, x, y - 1, scale(YELLOW, glow))
345      for (const dx of [-1, 0, 1]) set(c, x + dx, y + 2, EDGE)
346      set(c, x, y + 3, EDGE)
347      if (glow > 0.9) for (const [dx, dy] of [[-4, -1], [4, -1], [0, -4]] as const) set(c, x + dx, y + dy, YELLOW)
348      return
349    }
350    case 'thermometer': {
351      for (let dy = -3; dy <= 1; dy++) {
352        set(c, x - 1, y + dy, EDGE)
353        set(c, x + 1, y + dy, EDGE)
354      }
355      const level = Math.round(1 + (Math.sin(t * 0.9) + 1) * 1.5)
356      for (let d = 0; d < level; d++) set(c, x, y + 1 - d, RED)
357      ring(c, x, y + 3, 1.4, 1, RED)
358      set(c, x, y + 3, RED)
359      return
360    }
361    case 'droplet': {
362      const sway = Math.round(Math.sin(t * 1.3) * 0.6)
363      set(c, x + sway, y - 3, PACKET)
364      for (const dx of [-1, 1]) set(c, x + dx + sway, y - 1, PACKET)
365      ring(c, x + sway, y + 1, 1.8, 1.4, PACKET)
366      return
367    }
368    case 'button': {
369      for (let d = -2; d <= 2; d++) {
370        set(c, x + d, y - 2, EDGE)
371        set(c, x + d, y + 2, EDGE)
372        set(c, x - 2, y + d, EDGE)
373        set(c, x + 2, y + d, EDGE)
374      }
375      const isPressed = (t % 3) < 0.35
376      set(c, x, y, isPressed ? GREEN : AMBER)
377      if (isPressed) for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) set(c, x + dx, y + dy, GREEN)
378      return
379    }
380    case 'battery': {
381      for (let d = -3; d <= 3; d++) {
382        set(c, x + d, y - 2, EDGE)
383        set(c, x + d, y + 2, EDGE)
384      }
385      for (let d = -2; d <= 2; d++) {
386        set(c, x - 3, y + d, EDGE)
387        set(c, x + 3, y + d, EDGE)
388      }
389      set(c, x + 4, y, EDGE)
390      const level = Math.floor((t * 0.8) % 6)
391      for (let d = 0; d < level; d++) for (const dy of [-1, 0, 1]) set(c, x - 2 + d, y + dy, level > 2 ? GREEN : AMBER)
392      return
393    }
394    case 'toggle': {
395      ring(c, x, y, 3.2, 1.6, EDGE)
396      const isOn = Math.floor(t / 2.5) % 2 === 0
397      for (const [dx, dy] of [[0, 0], [1, 0], [0, -1], [1, -1]] as const) set(c, x + (isOn ? 1 : -2) + dx, y + dy + 1 - 1, isOn ? GREEN : QUIET)
398      return
399    }
400    case 'fan': {
401      set(c, x, y, EDGE)
402      for (let k = 0; k < 3; k++) {
403        const angle = t * 6 + (k / 3) * Math.PI * 2
404        for (let d = 1; d <= 3; d++) set(c, Math.round(x + Math.cos(angle) * d * 1.3), Math.round(y + Math.sin(angle) * d * 0.8), PACKET)
405      }
406      return
407    }
408    case 'screen': {
409      for (let d = -3; d <= 3; d++) {
410        set(c, x + d, y - 2, EDGE)
411        set(c, x + d, y + 2, EDGE)
412      }
413      for (const dy of [-1, 0, 1]) {
414        set(c, x - 3, y + dy, EDGE)
415        set(c, x + 3, y + dy, EDGE)
416      }
417      const scroll = Math.floor(t * 4) % 5
418      for (let d = 0; d < 3; d++) set(c, x - 2 + ((scroll + d) % 5), y - 1 + (d % 3), PACKET)
419      return
420    }
421    default: {
422      ring(c, x, y, 2, 1.5, EDGE)
423      if (Math.floor(t * 2) % 2 === 0) set(c, x, y, PACKET)
424    }
425  }
426}
427
428/** Asleep: Zs drift up to a thought cloud, where the devices it could be come and go under the stars. */
429function dreams(c: Canvas, t: number, { left, cw, top, cy }: Chip) {
430  const right = left + cw
431  // Eyes closed: two little smiles in the body.
432  for (const ex of [left + 8, left + cw - 10]) for (const [dx, dy] of [[0, 0], [1, 1], [2, 1], [3, 0]] as const) set(c, ex + dx, cy + dy - 1, PAGE)
433  const cx = Math.min(c.w - 14, right + 20)
434  const cyCloud = 9
435  // The cloud: a few overlapping puffs.
436  // Puffs, of which only the outline is drawn: a dot inside one, beside a dot in none.
437  const puffs = [[-8, 1, 6.5, 4], [0, -3, 7.5, 4.5], [8, 1, 6.5, 4], [0, 4, 11, 3]] as const
438  const isIn = (x: number, y: number) => puffs.some(([dx, dy, rx, ry]) => ((x - cx - dx) / rx) ** 2 + ((y - cyCloud - dy) / ry) ** 2 <= 1)
439  for (let y = 0; y < c.h; y++) {
440    for (let x = cx - 20; x <= cx + 20; x++) {
441      if (isIn(x, y) && (!isIn(x - 1, y) || !isIn(x + 1, y) || !isIn(x, y - 1) || !isIn(x, y + 1))) set(c, x, y, scale(NIGHT, 0.6))
442    }
443  }
444  // What it dreams of, one after another.
445  const dreamt: Thing[] = ['light', 'thermometer', 'fan', 'button', 'droplet', 'screen']
446  sprite(c, t, dreamt[Math.floor(t / 2.5) % dreamt.length]!, cx, cyCloud)
447  // Stars twinkling round it.
448  for (let k = 0; k < 6; k++) {
449    if (hash(k, 3, Math.floor(t * 3)) % 3 === 0) continue
450    set(c, Math.round(cx - 14 + ((k * 11) % 30)), (k * 5) % 3 === 0 ? 0 : (k * 7) % 4, k % 2 ? YELLOW : PAGE)
451  }
452  // Zs rising from the chip towards the cloud, small to large.
453  for (let k = 0; k < 3; k++) {
454    const p = (t * 0.3 + k / 3) % 1
455    const zx = Math.round(right + 1 + (cx - 16 - (right + 1)) * p)
456    const zy = Math.round(top + 5 - p * 4)
457    const size = p < 0.35 ? 3 : 4
458    const colour = scale(NIGHT, 1 - p * 0.5)
459    for (let d = 0; d < size; d++) {
460      set(c, zx + d, zy - size + 1, colour)
461      set(c, zx + d, zy, colour)
462      set(c, zx + size - 1 - d, zy - size + 1 + d, colour)
463    }
464  }
465}
466
467// ---------------------------------------------------------------- drawing
468
469function ring(c: Canvas, x: number, y: number, rx: number, ry: number, colour: number) {
470  const steps = Math.max(10, Math.round((rx + ry) * 4))
471  for (let a = 0; a < steps; a++) {
472    const angle = (a / steps) * Math.PI * 2
473    set(c, Math.round(x + Math.cos(angle) * rx), Math.round(y + Math.sin(angle) * ry), colour)
474  }
475}
476
477/** A bright colour round the wheel, h from 0 to 1. */
478function hue(h: number): number {
479  const f = (n: number) => {
480    const k = (n + h * 6) % 6
481    return Math.round(255 * (1 - Math.max(0, Math.min(1, Math.min(k, 4 - k)))))
482  }
483  return (f(5) << 16) | (f(3) << 8) | f(1) || PAGE
484}
485
486function scale(colour: number, by: number): number {
487  const f = Math.max(0.15, Math.min(1, by))
488  const r = Math.round(((colour >> 16) & 255) * f)
489  const g = Math.round(((colour >> 8) & 255) * f)
490  const b = Math.round((colour & 255) * f)
491  return (r << 16) | (g << 8) | b || BODY
492}
493
494function hash(a: number, b: number, c: number): number {
495  return (Math.imul(a + 1, 73856093) ^ Math.imul(b + 1, 19349663) ^ Math.imul(c + 1, 83492791)) >>> 0
496}
497
498function set(c: Canvas, x: number, y: number, colour: number) {
499  if (x < 0 || y < 0 || x >= c.w || y >= c.h) return
500  c.px[y * c.w + x] = colour
501}
502
503const DOT_BITS = [
504  [0x01, 0x02, 0x04, 0x40],
505  [0x08, 0x10, 0x20, 0x80],
506]
507
508function encode(c: Canvas, columns: number, words: string, colour: number): string {
509  const bytes = new Uint8Array(columns * STRIP_ROWS * 12)
510  const view = new DataView(bytes.buffer)
511  const put = (cell: number, code: number, fg: number) => {
512    view.setUint32(cell * 12, code, true)
513    view.setUint32(cell * 12 + 4, fg, true)
514    view.setUint32(cell * 12 + 8, PANEL, true)
515  }
516  for (let row = 0; row < STRIP_ROWS - 1; row++) {
517    for (let col = 0; col < columns; col++) {
518      let bits = 0
519      let fg = PANEL
520      for (let dx = 0; dx < 2; dx++) {
521        for (let dy = 0; dy < 4; dy++) {
522          const dot = c.px[(row * 4 + dy) * c.w + col * 2 + dx] ?? 0
523          if (!dot) continue
524          bits |= DOT_BITS[dx]![dy]!
525          // A cell has one colour: the liveliest dot in it wins over the chip's greys.
526          if (fg === PANEL || (dot !== BODY && dot !== EDGE && dot !== WIRE)) fg = dot
527        }
528      }
529      put(row * columns + col, bits ? 0x2800 + bits : 0x20, fg)
530    }
531  }
532  const base = (STRIP_ROWS - 1) * columns
533  const text = words.length > columns ? `${words.slice(0, columns - 1)}…` : words
534  const start = Math.max(0, Math.floor((columns - text.length) / 2))
535  for (let col = 0; col < columns; col++) {
536    const i = col - start
537    const code = i >= 0 && i < text.length ? text.charCodeAt(i) : 0x20
538    const isPrintable = code >= 0x20 && code !== 0x7f && code < 0x1100
539    put(base + col, isPrintable || code === 0x2026 || code === 0x2713 || code === 0x2717 ? code : 0xb7, colour)
540  }
541  return base64(bytes)
542}
543
544const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
545
546function base64(bytes: Uint8Array): string {
547  let out = ''
548  for (let i = 0; i < bytes.length; i += 3) {
549    const n = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
550    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
551    out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
552    out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
553  }
554  return out
555}
556
hooks/config.ts 387 lines
1// A map of an ESPHome device, read from its YAML: the board, how it connects,
2// its buses, every entity with its pins, and what reacts to what. The pane
3// shows it whenever Claude reads or writes a config, and marks what a save
4// changed, as the automation pane does for automations.
5//
6// ESPHome YAML is a small, regular subset (keys, lists of maps, block scalars
7// for lambdas, a few tags), so a forgiving indentation parser reads it; it
8// never needs to be right about anything the map does not show. Passwords and
9// keys written into the YAML are never shown.
10
11export type YNode = {
12  key: string | null
13  value: string | null
14  /** A `!secret`, `!lambda`, `!include`... tag on the value. */
15  tag: string | null
16  isItem: boolean
17  children: YNode[]
18}
19
20import type { MapRow, RowChange } from '../types'
21
22export type { MapRow, RowChange }
23
24export type DeviceMap = {
25  name: string
26  chip: string
27  rows: MapRow[]
28}
29
30
31// ---------------------------------------------------------------- parsing
32
33export function parseYaml(text: string): YNode {
34  const root: YNode = { key: null, value: null, tag: null, isItem: false, children: [] }
35  const stack: { indent: number; node: YNode }[] = [{ indent: -1, node: root }]
36  const lines = text.replace(/\t/g, '  ').split('\n')
37
38  for (let i = 0; i < lines.length; i++) {
39    const raw = lines[i] ?? ''
40    const body = stripComment(raw).trimEnd()
41    if (!body.trim() || body.trim() === '---') continue
42    const indent = body.length - body.trimStart().length
43    let rest = body.trimStart()
44    let at = indent
45
46    if (rest.startsWith('- ') || rest === '-') {
47      while (stack.length > 1 && (stack.at(-1)!.indent > indent || (stack.at(-1)!.indent === indent && stack.at(-1)!.node.isItem))) stack.pop()
48      const item: YNode = { key: null, value: null, tag: null, isItem: true, children: [] }
49      stack.at(-1)!.node.children.push(item)
50      stack.push({ indent, node: item })
51      const after = rest.slice(1)
52      at = indent + 1 + (after.length - after.trimStart().length)
53      rest = after.trim()
54      if (!rest) continue
55      if (!keyOf(rest)) {
56        const { value, tag } = scalar(rest)
57        item.value = value
58        item.tag = tag
59        continue
60      }
61    } else {
62      while (stack.length > 1 && stack.at(-1)!.indent >= indent) stack.pop()
63    }
64
65    const pair = keyOf(rest)
66    if (!pair) continue
67    const node: YNode = { key: pair.key, value: null, tag: null, isItem: false, children: [] }
68    stack.at(-1)!.node.children.push(node)
69    const { value, tag } = scalar(pair.value)
70    node.tag = tag
71    if (value !== null && /^[|>][-+]?\d*$/.test(value)) {
72      // A block scalar (a lambda, usually): the more-indented lines after it.
73      const block: string[] = []
74      while (i + 1 < lines.length) {
75        const next = lines[i + 1] ?? ''
76        if (next.trim() && next.length - next.trimStart().length <= at) break
77        block.push(next.trim())
78        i++
79      }
80      node.value = block.filter(Boolean).join('\n')
81      continue
82    }
83    node.value = value
84    if (value === null) stack.push({ indent: at, node })
85  }
86  return root
87}
88
89function stripComment(line: string): string {
90  let quote: string | null = null
91  for (let i = 0; i < line.length; i++) {
92    const ch = line[i]
93    if (quote) {
94      if (ch === quote) quote = null
95    } else if (ch === '"' || ch === "'") {
96      quote = ch
97    } else if (ch === '#' && (i === 0 || /\s/.test(line[i - 1] ?? ''))) {
98      return line.slice(0, i)
99    }
100  }
101  return line
102}
103
104function keyOf(text: string): { key: string; value: string } | null {
105  const match = /^("[^"]*"|'[^']*'|[^\s"'{[!][^:]*?):(?:\s+(.*)|$)/.exec(text)
106  if (!match?.[1]) return null
107  return { key: match[1].replace(/^["']|["']$/g, ''), value: (match[2] ?? '').trim() }
108}
109
110function scalar(text: string): { value: string | null; tag: string | null } {
111  let value = text.trim()
112  let tag: string | null = null
113  const tagged = /^(![a-z_]+)(?:\s+(.*))?$/i.exec(value)
114  if (tagged?.[1]) {
115    tag = tagged[1]
116    value = (tagged[2] ?? '').trim()
117  }
118  if (!value) return { value: tag ? '' : null, tag }
119  return { value: value.replace(/^"(.*)"$|^'(.*)'$/, '$1$2'), tag }
120}
121
122// ---------------------------------------------------------------- the map
123
124const PLATFORMS = ['esp32', 'esp8266', 'rp2040', 'bk72xx', 'rtl87xx', 'ln882x', 'libretiny', 'nrf52', 'host']
125const NETWORK = ['wifi', 'ethernet', 'api', 'ota', 'mqtt', 'web_server', 'captive_portal', 'improv_serial', 'esp32_improv', 'bluetooth_proxy', 'esp32_ble_tracker', 'time', 'logger', 'safe_mode']
126const BUSES = ['i2c', 'spi', 'uart', 'one_wire', 'i2s_audio', 'canbus', 'modbus']
127const DOMAINS: [string, string, string][] = [
128  ['binary_sensor', 'Binary sensors', '◐'],
129  ['sensor', 'Sensors', '≈'],
130  ['text_sensor', 'Text sensors', '¶'],
131  ['switch', 'Switches', '⏻'],
132  ['light', 'Lights', '✺'],
133  ['fan', 'Fans', '✣'],
134  ['cover', 'Covers', '▤'],
135  ['climate', 'Climate', '❄'],
136  ['button', 'Buttons', '▣'],
137  ['number', 'Numbers', '#'],
138  ['select', 'Selects', '☰'],
139  ['text', 'Text', '¶'],
140  ['lock', 'Locks', '⊡'],
141  ['valve', 'Valves', '⊗'],
142  ['event', 'Events', '⚑'],
143  ['output', 'Outputs', '⇥'],
144  ['status_led', 'Outputs', '•'],
145  ['display', 'Displays', '▭'],
146  ['media_player', 'Media', '♫'],
147  ['speaker', 'Media', '♫'],
148  ['microphone', 'Media', '♪'],
149  ['voice_assistant', 'Media', '◎'],
150  ['remote_transmitter', 'Infrared and RF', '⇢'],
151  ['remote_receiver', 'Infrared and RF', '⇠'],
152  ['deep_sleep', 'Power', '☾'],
153  ['globals', 'Logic', '='],
154  ['script', 'Logic', '▶'],
155  ['interval', 'Logic', '↻'],
156]
157/** A section lists this many, then says how many more. */
158const MAX_PER_DOMAIN = 8
159const QUIET_KEYS = new Set(['esphome', 'substitutions', 'packages', 'dashboard_import', 'preferences', 'external_components', 'font', 'image', 'color', 'animation', 'psram', 'debug'])
160const HIDDEN = /password|(^|_)key$|^key$|psk|token|secret/i
161const PIN_KEY = /^(pin|.*_pin|sda|scl|tx|rx|clk|mosi|miso|cs|dc|reset|data)$/
162
163export function mapDevice(yaml: string): DeviceMap {
164  const root = parseYaml(yaml)
165  const subs = new Map<string, string>()
166  for (const s of child(root, 'substitutions')?.children ?? []) if (s.key && s.value) subs.set(s.key, s.value)
167  const sub = (text: string) => text.replace(/\$\{?([a-z_][a-z0-9_]*)\}?/gi, (all, name: string) => subs.get(name) ?? all)
168  const val = (node: YNode | undefined, key: string) => {
169    const found = node ? child(node, key) : undefined
170    return found?.value ? sub(found.value) : null
171  }
172
173  const core = child(root, 'esphome')
174  const name = val(core, 'friendly_name') ?? val(core, 'name') ?? 'device'
175  const platformNode = PLATFORMS.map(p => child(root, p)).find(Boolean)
176  const variant = val(platformNode, 'variant') ?? val(platformNode, 'board') ?? ''
177  const framework = val(child(platformNode ?? root, 'framework'), 'type')
178  const chip = [platformNode?.key?.toUpperCase().replace('ESP32', 'ESP32'), variant && variant !== platformNode?.key ? variant : null, framework]
179    .filter(Boolean)
180    .join(' · ')
181
182  const rows: MapRow[] = []
183  const add = (section: string, depth: number, icon: string, label: string, detail: string) => rows.push({ section, depth, icon, label: sub(label), detail: sub(detail) })
184  const pins: { pin: string; owner: string }[] = []
185
186  // How it connects.
187  for (const key of NETWORK) {
188    const node = child(root, key)
189    if (!node) continue
190    add('Network', 0, networkIcon(key), key.replace(/_/g, ' '), networkDetail(key, node, sub))
191  }
192  for (const key of BUSES) {
193    for (const item of itemsOf(child(root, key))) {
194      const label = val(item, 'id') ?? key
195      const wires = item.children.filter(c => c.key && PIN_KEY.test(c.key)).map(c => `${c.key} ${pinOf(c, sub) ?? '?'}`)
196      for (const c of item.children) if (c.key && PIN_KEY.test(c.key) && pinOf(c, sub)) pins.push({ pin: pinOf(c, sub)!, owner: `${key} ${c.key}` })
197      add('Buses', 0, '═', `${key}${label !== key ? ` ${label}` : ''}`, [...wires, val(item, 'baud_rate') ? `${val(item, 'baud_rate')} baud` : null, val(item, 'frequency')].filter(Boolean).join(' · '))
198    }
199  }
200
201  // Everything it does.
202  const seen = new Set<string>()
203  const labels = new Map<string, number>()
204  for (const [domain, section, icon] of DOMAINS) {
205    const node = child(root, domain)
206    if (!node) continue
207    seen.add(domain)
208    const items = itemsOf(node)
209    // Values the device only reads from Home Assistant, and its own variables, are one row each:
210    // a real device has dozens of them, and they are not what the map is for.
211    const imported = items.filter(item => val(item, 'platform') === 'homeassistant')
212    if (imported.length > 0) add(section, 0, '⌂', `${imported.length} from Home Assistant`, imported.slice(0, 4).map(item => val(item, 'name') ?? val(item, 'id') ?? '').filter(Boolean).join(', ') + (imported.length > 4 ? ', …' : ''))
213    if (domain === 'globals') {
214      add(section, 0, icon, `${items.length} variable${items.length === 1 ? '' : 's'}`, items.slice(0, 5).map(item => val(item, 'id') ?? '').filter(Boolean).join(', ') + (items.length > 5 ? ', …' : ''))
215      continue
216    }
217    const shown = items.filter(item => !imported.includes(item))
218    for (const item of shown.slice(0, MAX_PER_DOMAIN)) {
219      const named = val(item, 'name') ?? val(item, 'id') ?? (domain === 'interval' && val(item, 'interval') ? `every ${val(item, 'interval')}` : null)
220      const base = named ?? val(item, 'platform') ?? (item.value ? sub(item.value) : domain)
221      // Two rows with one label (two `every 1s` intervals) would share a key in the diff.
222      const repeats = labels.get(`${section}|${base}`) ?? 0
223      labels.set(`${section}|${base}`, repeats + 1)
224      const label = repeats > 0 ? `${base} (${repeats + 1})` : base
225      const detail = [
226        named ? val(item, 'platform') : null,
227        val(item, 'address'),
228        ...pinsIn(item, sub).map(p => p.pin),
229
230        val(item, 'update_interval') ? `every ${val(item, 'update_interval')}` : null,
231        item.children.some(c => c.tag === '!lambda') ? 'λ' : null,
232      ]
233        .filter(Boolean)
234        .join(' · ')
235      add(section, 0, icon, label, detail)
236      for (const p of pinsIn(item, sub)) pins.push({ pin: p.pin, owner: label })
237      // A platform that makes several entities (a BME280's temperature, humidity...).
238      for (const sub of item.children) {
239        if (sub.key && !sub.isItem && val(sub, 'name') && sub.key !== 'then') add(section, 1, '↳', val(sub, 'name')!, sub.key.replace(/_/g, ' '))
240      }
241      for (const trigger of item.children.filter(c => c.key?.startsWith('on_') || (domain === 'interval' && c.key === 'then') || (domain === 'script' && c.key === 'then'))) {
242        add(section, 1, '⚡', trigger.key === 'then' ? 'runs' : trigger.key!.replace(/^on_/, 'on ').replace(/_/g, ' '), actionsOf(trigger).join(', ') || '…')
243      }
244    }
245    if (shown.length > MAX_PER_DOMAIN) {
246      const rest = shown.slice(MAX_PER_DOMAIN)
247      add(section, 0, '…', `${rest.length} more`, rest.slice(0, 4).map(item => val(item, 'name') ?? val(item, 'id') ?? '').filter(Boolean).join(', ') + (rest.length > 4 ? ', …' : ''))
248    }
249  }
250  for (const trigger of (core?.children ?? []).filter(c => c.key?.startsWith('on_'))) {
251    add('Logic', 0, '⚡', trigger.key!.replace(/^on_/, 'on ').replace(/_/g, ' '), actionsOf(trigger).join(', ') || '…')
252  }
253
254  const other = root.children
255    .map(c => c.key)
256    .filter((k): k is string => !!k && !seen.has(k) && !QUIET_KEYS.has(k) && !PLATFORMS.includes(k) && !NETWORK.includes(k) && !BUSES.includes(k))
257  if (other.length > 0) add('Other', 0, '·', other.join(', '), '')
258  const packages = child(root, 'packages')
259  if (packages) add('Other', 0, '⧉', 'packages', packages.children.map(p => p.key ?? p.value).filter(Boolean).join(', ') || (packages.value ?? ''))
260
261  // Which pin does what: the wiring at a glance, with the pins the chip boots from called out.
262  const strapping = strappingPins(platformNode?.key ?? '', `${variant} ${val(platformNode, 'board') ?? ''}`)
263  const byPin = new Map<string, string[]>()
264  for (const p of pins) byPin.set(p.pin, [...(byPin.get(p.pin) ?? []), p.owner])
265  for (const [pin, owners] of [...byPin].sort((a, b) => pinNumber(a[0]) - pinNumber(b[0]))) {
266    const isStrapping = /^GPIO\d+$/.test(pin) && strapping.includes(pinNumber(pin))
267    const notes = [owners.length > 1 ? 'shared' : null, isStrapping ? 'strapping pin' : null].filter(Boolean)
268    add('Pins', 0, owners.length > 1 ? '!' : isStrapping ? '◇' : '○', pin, owners.join(', ') + (notes.length ? `  (${notes.join(', ')})` : ''))
269  }
270
271  return { name: sub(name), chip, rows }
272}
273
274function child(node: YNode, key: string): YNode | undefined {
275  return node.children.find(c => c.key === key)
276}
277
278/** A domain's entries: a list of maps, or a single map (`status_led:` with a pin). */
279function itemsOf(node: YNode | undefined): YNode[] {
280  if (!node) return []
281  const items = node.children.filter(c => c.isItem)
282  return items.length > 0 ? items : node.children.length > 0 ? [node] : []
283}
284
285function pinOf(node: YNode, sub: (text: string) => string = text => text): string | null {
286  const found = node.value ?? child(node, 'number')?.value ?? null
287  const raw = found === null ? null : sub(found)
288  if (!raw) return null
289  const flow = /number:\s*([A-Za-z0-9_]+)/.exec(raw)
290  const pin = (flow?.[1] ?? raw).trim()
291  if (/^\d+$/.test(pin)) return `GPIO${pin}`
292  return /^(GPIO|D|A|P|PA|PB)\d+|^GPIO/i.test(pin) ? pin.toUpperCase() : null
293}
294
295function pinsIn(item: YNode, sub: (text: string) => string): { pin: string }[] {
296  return item.children.filter(c => c.key && PIN_KEY.test(c.key)).map(c => pinOf(c, sub)).filter((p): p is string => !!p).map(pin => ({ pin }))
297}
298
299/** Pins the chip reads at boot, which ESPHome warns about: fine with care, a trap with a pull resistor. */
300function strappingPins(platform: string, variant: string): number[] {
301  const v = variant.toLowerCase().replace(/[-_]/g, '')
302  if (platform === 'esp8266') return [0, 2, 15]
303  if (platform !== 'esp32') return []
304  if (/c3/.test(v)) return [2, 8, 9]
305  if (/c6|h2/.test(v)) return [8, 9, 15]
306  if (/s3/.test(v)) return [0, 3, 45, 46]
307  if (/s2/.test(v)) return [0, 45, 46]
308  return [0, 2, 5, 12, 15]
309}
310
311function pinNumber(pin: string): number {
312  return Number(/\d+/.exec(pin)?.[0] ?? 999)
313}
314
315/** What a trigger does, as its action names: `light.toggle, delay`. */
316function actionsOf(trigger: YNode): string[] {
317  const steps = trigger.children.find(c => c.key === 'then') ?? trigger
318  // A trigger written as a list of `- priority: ... then: ...` entries: their actions, not their settings.
319  if (steps === trigger && trigger.children.some(c => c.isItem && c.children.some(k => k.key === 'then'))) {
320    return [...new Set(trigger.children.filter(c => c.isItem).flatMap(actionsOf))].slice(0, 4)
321  }
322  const names = steps.children.flatMap(c => (c.isItem ? c.children.slice(0, 1).map(a => a.key ?? '') : c.key && !['then', 'priority', 'mode'].includes(c.key) ? [c.key] : []))
323  if (trigger.tag === '!lambda' || steps.children.some(c => c.key === 'lambda')) names.push('λ')
324  return [...new Set(names.filter(Boolean).map(n => (n === 'lambda' ? 'λ' : n)))].slice(0, 4)
325}
326
327function networkIcon(key: string): string {
328  return ({ wifi: '◠', ethernet: '⇄', api: '⌂', ota: '⇪', mqtt: '⇋', web_server: '◫', bluetooth_proxy: 'ᛒ', esp32_ble_tracker: 'ᛒ', time: '◷', logger: '≡' } as Record<string, string>)[key] ?? '·'
329}
330
331function networkDetail(key: string, node: YNode, sub: (text: string) => string): string {
332  const v = (k: string, from: YNode = node) => {
333    const found = child(from, k)
334    if (!found) return null
335    if (found.tag === '!secret') return 'from secrets'
336    if (HIDDEN.test(k)) return '•••'
337    return found.value ? sub(found.value) : null
338  }
339  switch (key) {
340    case 'wifi': {
341      const networks = itemsOf(child(node, 'networks')).length
342      return [
343        v('ssid') ? `ssid ${v('ssid')}` : networks ? `${networks} networks` : null,
344        child(node, 'ap') ? 'fallback hotspot' : null,
345        child(node, 'manual_ip') ? `static ${v('static_ip', child(node, 'manual_ip')) ?? ''}`.trim() : null,
346      ].filter(Boolean).join(' · ')
347    }
348    case 'api':
349      return [child(node, 'encryption') ? 'encrypted' : 'not encrypted', child(node, 'actions') || child(node, 'services') ? 'with actions' : null].filter(Boolean).join(' · ')
350    case 'ota':
351      return itemsOf(node).map(item => v('platform', item) ?? 'esphome').join(', ')
352    case 'mqtt':
353      return v('broker') ? `broker ${v('broker')}` : ''
354    case 'web_server':
355      return v('port') ? `port ${v('port')}` : ''
356    case 'time':
357      return itemsOf(node).map(item => v('platform', item)).filter(Boolean).join(', ')
358    case 'logger':
359      return v('level') ? `level ${v('level')}` : ''
360    case 'ethernet':
361      return v('type') ?? ''
362    default:
363      return ''
364  }
365}
366
367// ---------------------------------------------------------------- what a save changed
368
369export function rowKey(row: MapRow): string {
370  return `${row.section}|${row.depth}|${row.label}`
371}
372
373/** Rows a new map adds or changes against the old one, and the old rows it drops. */
374export function diffMaps(before: MapRow[], after: MapRow[]): { changes: Record<string, RowChange>; removed: MapRow[] } {
375  const old = new Map(before.map(row => [rowKey(row), row]))
376  const fresh = new Set(after.map(rowKey))
377  const changes: Record<string, RowChange> = {}
378  for (const row of after) {
379    const was = old.get(rowKey(row))
380    if (!was) changes[rowKey(row)] = 'added'
381    else if (was.detail !== row.detail || was.icon !== row.icon) changes[rowKey(row)] = 'changed'
382  }
383  const removed = before.filter(row => !fresh.has(rowKey(row)))
384  for (const row of removed) changes[rowKey(row)] = 'removed'
385  return { changes, removed }
386}
387
types/index.d.ts 69 lines
1/** One build command Claude ran (validate, compile, upload or logs) and what it has produced. */
2export type Build = {
3  /** The MCP server it ran on, as the engine names it. */
4  server: string
5  command: string
6  configuration: string
7  startedAt: number
8  finishedAt: number | null
9  /** Display lines so far (the last few hundred). */
10  lines: string[]
11  /** The newest esphome_activity sequence seen, to ask only for what follows. */
12  seq: number
13  /** True once lines have arrived while it ran; false when they came only at the end. */
14  isLive: boolean
15  outcome: 'running' | 'ok' | 'failed'
16  error: string | null
17  /** When the upload stage was first seen, to pace its bar: OTA prints its percentage only at the end. */
18  uploadSeenAt?: number | null
19}
20
21/** A device the dashboard knows, from esphome_list_devices. */
22export type Device = {
23  name: string
24  configuration: string
25  deployed: string | null
26  current: string | null
27}
28
29/** One line of a device's map (hooks/config.ts builds them). */
30export type MapRow = {
31  section: string
32  depth: number
33  icon: string
34  label: string
35  detail: string
36}
37
38export type RowChange = 'added' | 'changed' | 'removed'
39
40/** The map of the device Claude last read or wrote, and what its latest save changed. */
41export type ConfigView = {
42  configuration: string
43  name: string
44  chip: string
45  rows: MapRow[]
46  /** Row keys (config.rowKey) a save added, changed or removed, shown for a few seconds. */
47  changes: Record<string, RowChange>
48  removed: MapRow[]
49  changedAt: number | null
50}
51
52declare module 'claude-code' {
53  interface PluginState {
54    'vome-esphome': {
55      build: Build | null
56      devices: Device[] | null
57      /** Auto mode refused the pane's read of esphome_activity: on which server. */
58      blocked: { server: string; error: string } | null
59      /** Whether the chip strip plays; kept across sessions in $.store as well. */
60      fxOn: boolean
61      config: ConfigView | null
62      /** The whole build log instead of its last lines and the map. */
63      showLog: boolean
64      /** Claude used automation tools and the automation pane does not seem to be here: offer it, once. */
65      sibling: boolean
66    }
67  }
68}
69