A toaster above the prompt: empty with no mesh mail, toast popping up when mail arrives

A small toaster above the Claude Code prompt that shows your cortex mesh mail.
Every minute, it reads your mesh inbox with cortex-mesh.py inbox --peek. It uses --peek, so it never marks a message as read on the mesh.
cortex-join plugin, installed and joined (/cortex-join). The mod uses its cortex-mesh.py script./plugin install mesh-toaster --marketplace Mark-Hickey/claude-mods
The toaster shares the rows above the prompt with other mods. linear-watch folds its cards to one line when there are not sufficient rows for the two mods.
claude plugin validate mesh-toaster
claude plugin test mesh-toasterhooks/register.tsx 144 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Mail } from '../types'
5
6// Reads the inbox with --peek, so checking never marks anything read on the mesh.
7const INBOX = [
8 'bash',
9 '-c',
10 'python3 "$(ls ~/.claude/plugins/cache/cortex/cortex-join/*/scripts/cortex-mesh.py | sort | tail -1)" inbox --peek',
11]
12const EVERY_MS = 60_000
13const SEEN_KEY = 'seen'
14// The toast rises out of the slot over these steps when mail arrives.
15const LIFT_STEPS = 3
16const LIFT_MS = 180
17
18const unseen = atom({ plugin: 'mesh-toaster', key: 'unseen' } as const, [])
19const lift = atom({ plugin: 'mesh-toaster', key: 'lift' } as const, 0)
20
21// " [1791070009649-0] msg <uuid> from chris@todie.io @ session-…: subject"
22const LINE = /^\s*\[[^\]]+\]\s+\S+\s+(\S+)\s+from\s+(\S+)\s+@\s+([^:]+):\s*(.*)$/
23
24export function parseInbox(stdout: string): Mail[] {
25 return stdout.split('\n').flatMap(line => {
26 const m = LINE.exec(line)
27 return m ? [{ id: m[1], from: m[2], peer: m[3].trim(), subject: m[4] }] : []
28 })
29}
30
31export const notSeen = (mail: Mail[], seen: string[]) => mail.filter(m => !seen.includes(m.id))
32
33// The toaster, as rows of text. With no mail its slots are empty; with mail, toast
34// stands up out of the slots, `height` rows high (0 to LIFT_STEPS).
35export function toaster(height: number): string[] {
36 const toast = [' ▗▄▄▖ ▗▄▄▖', ' ▐██▌ ▐██▌', ' ▐██▌ ▐██▌'].slice(LIFT_STEPS - height)
37 const slots = height > 0 ? ' ╭─▐██▌─▐██▌─╮' : ' ╭─▁▁▁▁─▁▁▁▁─╮'
38 return [...toast, slots, ' │ │▪', ' ╰┬─────────┬╯']
39}
40
41// The slot row in pieces, so the toast inside the slots can be drawn yellow like the rest of it.
42export function slotSegments(height: number): { text: string; isToast: boolean }[] {
43 if (height === 0) return [{ text: ' ╭─▁▁▁▁─▁▁▁▁─╮', isToast: false }]
44 return [
45 { text: ' ╭─', isToast: false },
46 { text: '▐██▌', isToast: true },
47 { text: '─', isToast: false },
48 { text: '▐██▌', isToast: true },
49 { text: '─╮', isToast: false },
50 ]
51}
52
53export function caption(mail: Mail[]): string {
54 if (mail.length === 0) return 'mesh: no mail'
55 const [first] = mail
56 const who = first.from.split('@')[0]
57 const more = mail.length > 1 ? ` (+${mail.length - 1} more)` : ''
58 return `mesh: ${mail.length} new · ${who}: ${first.subject}${more}`
59}
60
61export const register: Register = on => {
62 let isChecking = false
63
64 on('session.start', async ($, e, next) => {
65 const check = async () => {
66 if (isChecking) return
67 isChecking = true
68 try {
69 const { exitCode, stdout } = await $.process.run(INBOX, { timeoutMs: 20_000 })
70 if (exitCode !== 0) return
71 const seen = ((await $.store.get(SEEN_KEY)) ?? []) as string[]
72 const fresh = notSeen(parseInbox(stdout), seen)
73 const before = await read($, unseen)
74 await update($, unseen, () => fresh)
75 // Pop the toast up only when new mail arrives, not on every check.
76 if (fresh.some(m => !before.some(b => b.id === m.id))) {
77 for (let step = 1; step <= LIFT_STEPS; step++) {
78 await update($, lift, () => step)
79 await $.clock.sleep(LIFT_MS)
80 }
81 }
82 if (fresh.length === 0) await update($, lift, () => 0)
83 } finally {
84 isChecking = false
85 }
86 }
87
88 void check()
89 $.clock.every(EVERY_MS, () => void check())
90 return next(e)
91 })
92
93 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94 // Draw the toaster, then let the mods beneath draw their lines under it.
95 const below = await next(e)
96 if (e.props.hasSurvey) return below
97
98 const mail = await read($, unseen)
99 const height = mail.length > 0 ? await read($, lift) : 0
100 const rows = toaster(height)
101 const { Box, Button, Text } = $.ui.resolve(e)
102
103 return (
104 <Box flexDirection="column">
105 <Box flexDirection="row">
106 <Box flexDirection="column">
107 {rows.map((row, i) =>
108 i === rows.length - 3 ? (
109 <Box key={`r${i}`} flexDirection="row">
110 {slotSegments(height).map((seg, j) => (
111 <Text key={`s${j}`} color={seg.isToast ? 'yellow' : undefined} dimColor={mail.length === 0}>
112 {seg.text}
113 </Text>
114 ))}
115 </Box>
116 ) : (
117 <Text key={`r${i}`} color={i < rows.length - 3 ? 'yellow' : undefined} dimColor={mail.length === 0}>
118 {row}
119 </Text>
120 ),
121 )}
122 </Box>
123 <Box flexDirection="column" justifyContent="flex-end" marginLeft={1}>
124 <Text dimColor={mail.length === 0} wrap="truncate-end">{caption(mail)}</Text>
125 {mail.length > 0 && (
126 <Button
127 key="got-it"
128 label="Got it"
129 onPress={async () => {
130 const seen = ((await $.store.get(SEEN_KEY)) ?? []) as string[]
131 await $.store.set(SEEN_KEY, [...seen, ...mail.map(m => m.id)])
132 await update($, unseen, () => [])
133 await update($, lift, () => 0)
134 }}
135 />
136 )}
137 </Box>
138 </Box>
139 {below}
140 </Box>
141 )
142 })
143}
144types/index.d.ts 8 lines1export type Mail = { id: string; from: string; peer: string; subject: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'mesh-toaster': { unseen: Mail[]; lift: number }
6 }
7}
8