A sidebar pane with a spinner and title for each background shell while it runs

A little inspiration. A lot of good food.
Recipeat turns recipes from photos, websites, and scraps of text into your own collection. It is a Nuxt 4 app with Zitadel login, a hosted model for reading recipes and one small Python service behind an authenticated extraction API.
npm install
npm run dev
Needs Node.js 22.19+. Login and extraction both require a running server; nuxt generate cannot serve them. Storage needs a database — see Database — and without NUXT_DATABASE_URL the server starts and answers 503.
npm run build
node .output/server/index.mjs
npm run test:database # node:test, no database needed
npm run test:recipes # node:test, the validator on the way in
NUXT_DATABASE_URL=... npm run test:database:live # the migration runner, against a real Postgres
npm run test:extraction # node:test, no browser or model needed
npm run build && npm run test:auth # Playwright against a mock OIDC issuer
cd services/recipeat-fetcher && uv run pytest # the fetcher service
nuxt-oidc-auth, with GET /api/me as the worked example of a server-enforced private endpoint.POST /api/extract/text sends text to the model and returns a normalized recipe. It is authenticated, and it stores nothing yet.POST /api/extract/website turns a recipe URL into the same shape through the fetcher service, with no model involved. Also authenticated, also stores nothing.POST /api/extract/photo sends an uploaded photo to the same model the text path uses, which reads the page itself. The image is read and discarded — nothing stores it yet.POST /api/recipes writes an extracted recipe to Postgres, owned by the subject in the session. GET /api/recipes lists a user's collection and GET /api/recipes/{id} reads one; nobody reaches another user's rows.PUT /api/recipes/{id} corrects a version in place; POST /api/recipes/{id}/progressions adds a version to its line and moves the pin to it; POST /api/recipes/{id}/variants branches into a line of its own. Planning has what those words mean.The browser calls the extraction routes and POST /api/recipes: an imported recipe can be added to the collection. The shelf does not read that collection yet, and still shows samples saved in browser local storage. Planning covers what is left.
| Authentication | Zitadel application, secrets, deployment |
| Fetcher | The Python service behind website import |
| Database | Postgres, migrations, and the third container |
| Extraction | The API, the pipeline, the recipe shape |
| API map | Every route, arranged for testing by hand in Postman |
| Planning | What is next, and what is still undecided |
| Design | Colour, type, shape, and how the app moves between places |
| Architecture | The two containers, their networks, and every request path |
Photography comes from Unsplash and fonts from Google Fonts, so both need internet access.
hooks/register.tsx 87 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Shell } from '../types'
5
6const PANE = 'shells'
7const TITLE = 'Shells'
8const FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
9const TICK_MS = 100
10
11const shells = atom({ plugin: 'shell-spinners', key: 'shells' } as const, [])
12const frame = atom({ plugin: 'shell-spinners', key: 'frame' } as const, 0)
13
14// The call's description when Claude gave one, else the command's first line.
15export const titleOf = (call: { command: string; description?: string }) =>
16 (call.description || call.command).split('\n')[0]!.trim()
17
18// A task notification names the task's id in its text, so every shell it names has ended.
19export const without = (list: readonly Shell[], notification: string) => list.filter(s => !notification.includes(s.id))
20
21let spinner: Timer | undefined
22
23// Spins while a shell runs, and stops redrawing once none does.
24async function sync($: EngineInterface, list: readonly Shell[]) {
25 if (list.length > 0 && !spinner) {
26 spinner = $.clock.every(TICK_MS, () => update($, frame, f => (f + 1) % FRAMES.length))
27 // Unasked, the pane only seats from 144 columns; /shells opens it at any width.
28 void $.ui.open({ id: PANE, title: TITLE })
29 } else if (list.length === 0 && spinner) {
30 spinner.cancel()
31 spinner = undefined
32 await $.ui.close({ id: PANE })
33 }
34}
35
36async function set($: EngineInterface, fn: (list: readonly Shell[]) => Shell[]) {
37 await update($, shells, fn)
38 await sync($, await read($, shells))
39}
40
41export const register: Register = on => {
42 on('session.start', async ($, e, next) => {
43 await $.command.register({ name: 'shells', description: 'Show the running background shells in a pane' })
44 // A reload starts the module over; pick the spinner back up for shells still listed.
45 await sync($, await read($, shells))
46 return next(e)
47 })
48
49 on('command.run', { command: 'shells' }, async $ => {
50 await $.ui.open({ id: PANE, title: TITLE })
51 return { text: 'Shells pane opened.' }
52 })
53
54 on('tool.call', async ($, e, next) => {
55 if (e.tool !== 'Bash' && e.tool !== 'PowerShell') return next(e)
56 const ran = await next(e)
57 if (ran.deny !== undefined || ran.isError) return ran
58 // Set when the call ran in the background from the start, or was moved there with ctrl+b.
59 const id = ran.result?.backgroundTaskId
60 if (id) await set($, list => [...list, { id, title: titleOf(e) }])
61 return ran
62 })
63
64 on('prompt.submit', async ($, e, next) => {
65 if (e.origin.kind === 'task-notification') await set($, list => without(list, e.text))
66 return next(e)
67 })
68
69 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
70 const { Box, Text } = $.ui.resolve(e)
71 const list = await read($, shells)
72 const spin = FRAMES[await read($, frame)]
73
74 return (
75 <Box flexDirection="column">
76 {list.length === 0 && <Text color="subtle">No shells running.</Text>}
77 {list.map(s => (
78 <Text wrap="truncate-end">
79 <Text color="warning">{spin}</Text>
80 <Text color="subtle"> {s.title}</Text>
81 </Text>
82 ))}
83 </Box>
84 )
85 })
86}
87types/index.d.ts 9 lines1// `id` is the background task id the shell's Bash or PowerShell call returned.
2export type Shell = { id: string; title: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'shell-spinners': { shells: Shell[]; frame: number }
7 }
8}
9