Weebo HUD resume pane: goal, plan, notes and what Claude waits on, kept current by Claude.

A Claude Code mod: the resume pane, a pane written in French (objectif, plan, notes, en attente) that Claude keeps current, so you can pick a session back up at a glance. /etat toggles it.
The status line under the prompt (folder, model, context, quotas, free RAM, CPU and disk) is not in this mod: a plugin cannot set statusLine. It comes from wska hud statusline, installed by wska hud install.
Install both at user scope:
task skills:install ITEM=weebo-hud SCOPE=user
# or by hand: wska hud install && claude plugin install weebo-hud@weebo --scope user
Full documentation is in docs/content/docs/weebo-hud.md. The design is in the spec.
hooks/register.tsx 247 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { Notes, Todo } from '../types'
5import { isEmpty, loadNotes, mergeNotes, purgeKeys } from './notes'
6
7const notesAtom = atom({ plugin: 'weebo-hud', key: 'notes' } as const, null)
8
9const PANE = { id: 'weebo-hud', title: 'Reprise' }
10const TOOL = 'mcp__weebo-hud__set_status'
11const WARN = '#d7af00' // orange of "En attente"
12
13// Opens the pane unasked, at most once, when it has content and is not already up.
14const autoOpen = async ($: EngineInterface, n: Notes | null, options: PluginOptions, auto: { done: boolean }) => {
15 if (auto.done || options.paneAuto === false || isEmpty(n)) return
16 if ((await $.ui.panes()).some((p) => p.id === PANE.id)) return
17 auto.done = true // once per session: a pane the user closed stays closed until /etat
18 await $.ui.open(PANE)
19}
20
21const keyOf = (id: string) => `session:${id}`
22
23// Main conversation only: a subagent's calls would clobber the pane.
24const fromSubagent = (e: unknown) => !!(e as { agentId?: string }).agentId
25
26const STATUSES = ['pending', 'in_progress', 'completed'] as const
27const asStatus = (v: unknown): Todo['status'] | undefined => STATUSES.find((s) => s === v)
28
29// Per-load state, owned by `register`. `synced` names the session the atom's notes belong to:
30// /clear and an in-REPL /resume change the id without a session.start, so every save re-checks
31// it; `ready` makes parallel saves share one reload.
32type Ctx = { auto: { done: boolean }; synced?: { sid: string; ready: Promise<void> } }
33
34const sync = async ($: EngineInterface, ctx: Ctx) => {
35 const id = await $.session.id()
36 if (ctx.synced?.sid !== id) {
37 ctx.auto.done = false
38 const load = async () => {
39 const mine = loadNotes(await $.store.get(keyOf(id)))
40 await update($, notesAtom, () => mine)
41 }
42 const mine: Ctx['synced'] = {
43 sid: id,
44 ready: load().catch((err) => {
45 if (ctx.synced === mine) ctx.synced = undefined // retry the reload on the next save
46 throw err
47 }),
48 }
49 ctx.synced = mine
50 }
51 await ctx.synced!.ready
52
53 return id
54}
55
56// Merges inside the atom's updater (atomic, so parallel saves keep each other's fields), then
57// persists under the session the notes were loaded for, never another one's key.
58const save = async ($: EngineInterface, ctx: Ctx, patch: Parameters<typeof mergeNotes>[1]) => {
59 const id = await sync($, ctx)
60 const now = await $.clock.now()
61 if (ctx.synced?.sid !== id) throw new Error('session switched mid-save')
62 await update($, notesAtom, (cur) => mergeNotes(cur, patch, now))
63 const latest = await read($, notesAtom)
64 if (ctx.synced?.sid === id && (await $.session.id()) === id) await $.store.set(keyOf(id), latest)
65
66 return latest
67}
68
69// Mirrors a successful tool call into the plan; never alters `ran`, never throws.
70const mirror = async (
71 $: EngineInterface,
72 ctx: Ctx,
73 options: PluginOptions,
74 e: unknown,
75 ran: { deny?: string; isError?: boolean },
76 todos: Parameters<typeof mergeNotes>[1]['todos'],
77) => {
78 if (fromSubagent(e) || ran.deny || ran.isError || !todos) return
79 try {
80 await autoOpen($, await save($, ctx, { todos }), options, ctx.auto)
81 } catch {}
82}
83
84export const register: Register = (on, options) => {
85 const ctx: Ctx = { auto: { done: false } }
86
87 on('session.start', async ($, e, next) => {
88 const started = await next(e)
89
90 // Own try: a registration failure must not stop the purge and load below.
91 // Headless runs (-p, SDK) get no tool and no command: nobody sees the pane.
92 if (e.isInteractive) {
93 try {
94 await $.tool.register({
95 name: 'set_status',
96 description:
97 "Update the user's resume pane (weebo-hud). Call it when work starts, after each step completed, and whenever you wait on the user. Main conversation only: subagents must not call it. Each field given replaces the stored one; an empty string clears it. Write in the conversation's language.",
98 inputSchema: {
99 type: 'object',
100 properties: {
101 objectif: { type: 'string', description: 'One-line goal of the current work.' },
102 notes: { type: 'string', description: 'Where things stand and the decisions made.' },
103 attente: { type: 'string', description: 'What you are waiting on from the user; empty string clears.' },
104 },
105 additionalProperties: false,
106 },
107 isDeferred: false,
108 })
109 await $.command.register({ name: 'etat', description: 'Ouvrir/fermer le panneau de reprise' })
110 } catch {}
111 }
112
113 // Purge and load fail apart: a failing purge must not skip the resume load.
114 try {
115 const now = await $.clock.now()
116 const keys = (await $.store.keys()).filter((k) => k.startsWith('session:'))
117 const entries = await Promise.all(keys.map(async (k): Promise<[string, unknown]> => [k, await $.store.get(k)]))
118 for (const k of purgeKeys(entries, now)) await $.store.delete(k)
119 } catch {}
120 try {
121 ctx.synced = undefined // a start (or reload) always reloads from the store
122 await sync($, ctx)
123 await autoOpen($, await read($, notesAtom), options, ctx.auto)
124 } catch {}
125
126 return started
127 })
128
129 // The next save or render re-syncs to the new session.
130 on('session.end', async ($, e, next) => {
131 ctx.synced = undefined
132 ctx.auto.done = false
133
134 return next(e)
135 })
136
137 on('tool.call', { tool: TOOL }, async ($, e) => {
138 if (fromSubagent(e)) return { result: 'ok' }
139 const { objectif, notes, attente } = e as unknown as Record<string, unknown>
140 const pick = (v: unknown) => (typeof v === 'string' ? v : undefined)
141 // The result is 'ok' whatever the pane does after the write.
142 try {
143 const saved = await save($, ctx, { objectif: pick(objectif), notes: pick(notes), attente: pick(attente) })
144 await autoOpen($, saved, options, ctx.auto).catch(() => undefined)
145 } catch {}
146
147 return { result: 'ok' }
148 })
149
150 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
151 const ran = await next(e)
152 const todos = (e as unknown as { todos?: Todo[] }).todos
153 await mirror($, ctx, options, e, ran, Array.isArray(todos) ? todos : undefined)
154
155 return ran
156 })
157
158 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
159 const ran = await next(e)
160 const id = (ran.result as { task?: { id?: unknown } } | undefined)?.task?.id
161 const { subject, activeForm } = e as unknown as { subject?: unknown; activeForm?: unknown }
162 const add =
163 typeof id === 'string' && typeof subject === 'string'
164 ? (cur: Todo[]) => [
165 ...cur.filter((t) => t.id !== id),
166 { id, content: subject, status: 'pending' as const, activeForm: typeof activeForm === 'string' ? activeForm : '' },
167 ]
168 : undefined
169 await mirror($, ctx, options, e, ran, add)
170
171 return ran
172 })
173
174 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
175 const ran = await next(e)
176 const ok = (ran.result as { success?: unknown } | undefined)?.success === true
177 const { taskId, status, subject, activeForm } = e as unknown as Record<string, unknown>
178 const edit =
179 ok && typeof taskId === 'string'
180 ? (cur: Todo[]) =>
181 status === 'deleted'
182 ? cur.filter((t) => t.id !== taskId)
183 : cur.map((t) =>
184 t.id !== taskId
185 ? t
186 : {
187 ...t,
188 status: asStatus(status) ?? t.status,
189 content: typeof subject === 'string' ? subject : t.content,
190 activeForm: typeof activeForm === 'string' ? activeForm : t.activeForm,
191 },
192 )
193 : undefined
194 await mirror($, ctx, options, e, ran, edit)
195
196 return ran
197 })
198
199 on('command.run', { command: 'etat' }, async ($) => {
200 if ((await $.ui.panes()).some((p) => p.id === PANE.id)) {
201 await $.ui.close({ id: PANE.id })
202
203 return { text: 'Panneau de reprise fermé' }
204 }
205 await $.ui.open({ ...PANE, focus: true })
206
207 return { text: 'Panneau de reprise ouvert' }
208 })
209
210 on('ui.render', { component: 'Pane', requestId: PANE.id }, async ($, e) => {
211 const { Box, Text } = $.ui.resolve(e)
212 // Reading the atom subscribes the pane; after a session switch nothing has re-synced it yet,
213 // so draw the current session's stored notes instead (render never writes state).
214 const fromAtom = await read($, notesAtom)
215 const id = await $.session.id().catch(() => undefined)
216 const n =
217 id !== undefined && id !== ctx.synced?.sid
218 ? loadNotes(await $.store.get(keyOf(id)).catch(() => null))
219 : fromAtom
220 const head = (t: string) => <Text bold>{t}</Text>
221
222 return (
223 <Box flexDirection="column" width={e.props.bodyColumns}>
224 {isEmpty(n) || !n ? (
225 <Text dimColor>Aucune note pour cette session.</Text>
226 ) : (
227 <Box flexDirection="column">
228 {n.objectif && head('Objectif')}
229 {n.objectif && <Text>{n.objectif}</Text>}
230 {n.todos.length > 0 && head('Plan')}
231 {n.todos.map((t, i) => (
232 <Text key={String(i)}>
233 {t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '▸' : '·'}{' '}
234 {t.status === 'in_progress' ? t.activeForm || t.content : t.content}
235 </Text>
236 ))}
237 {n.notes && head('Notes')}
238 {n.notes && <Text>{n.notes}</Text>}
239 {n.attente && <Text bold color={WARN}>En attente</Text>}
240 {n.attente && <Text color={WARN}>{n.attente}</Text>}
241 </Box>
242 )}
243 </Box>
244 )
245 })
246}
247hooks/notes.ts 40 lines1import type { Notes, Todo } from '../types'
2
3const DAY30 = 30 * 24 * 3600 * 1000
4const str = (v: unknown) => (typeof v === 'string' ? v : '')
5
6export const loadNotes = (raw: unknown): Notes | null => {
7 if (!raw || typeof raw !== 'object') return null
8 const r = raw as Record<string, unknown>
9
10 return {
11 objectif: str(r.objectif),
12 notes: str(r.notes),
13 attente: str(r.attente),
14 todos: Array.isArray(r.todos) ? (r.todos as Todo[]) : [],
15 updatedAt: typeof r.updatedAt === 'number' ? r.updatedAt : 0,
16 }
17}
18
19export const mergeNotes = (
20 cur: Notes | null,
21 // A function patch edits the current list (Task* mirror); an array replaces it (TodoWrite).
22 patch: Partial<Pick<Notes, 'objectif' | 'notes' | 'attente'>> & { todos?: Todo[] | ((cur: Todo[]) => Todo[]) },
23 now: number,
24): Notes => {
25 const base = cur ?? { objectif: '', notes: '', attente: '', todos: [], updatedAt: 0 }
26 const next = { ...base, updatedAt: now }
27 for (const k of ['objectif', 'notes', 'attente'] as const) if (typeof patch[k] === 'string') next[k] = patch[k]
28 if (patch.todos) next.todos = typeof patch.todos === 'function' ? patch.todos(base.todos) : patch.todos
29
30 return next
31}
32
33export const isEmpty = (n: Notes | null): boolean =>
34 !n || !(n.objectif || n.notes || n.attente || n.todos.length)
35
36export const purgeKeys = (entries: [string, unknown][], now: number): string[] =>
37 entries
38 .filter(([k, v]) => k.startsWith('session:') && now - (loadNotes(v)?.updatedAt ?? 0) > DAY30)
39 .map(([k]) => k)
40types/index.d.ts 10 lines1// `id` only on items mirrored from TaskCreate/TaskUpdate.
2export type Todo = { id?: string; content: string; status: 'pending' | 'in_progress' | 'completed'; activeForm: string }
3export type Notes = { objectif: string; notes: string; attente: string; todos: Todo[]; updatedAt: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'weebo-hud': { notes: Notes | null }
8 }
9}
10