A cache countdown above the prompt with Auto compact or status check before it goes cold, Compact now and Handoff buttons, real step details in the step line…

Claude Code mod. Prompt-cache countdown above the prompt.
/timerce-handoff)~/.claude/mods/cache-timerenv in ~/.claude/settings.json:"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/cache-timer",
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
hooks/register.tsx 512 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Action } from '../types'
5
6// The step running right now, in real details; null between steps.
7const now = atom({ plugin: 'cache-timer', key: 'now' } as const, null)
8const busy = atom({ plugin: 'cache-timer', key: 'busy' } as const, false)
9const hidden = atom({ plugin: 'cache-timer', key: 'hidden' } as const, false)
10// What to do by itself shortly before the cache goes cold, and how many
11// minutes before. Compacting shrinks the chat; a status check keeps it whole
12// and warm for another hour.
13const action = atom({ plugin: 'cache-timer', key: 'action' } as const, 'off')
14// Which of the two it was last set to, so switching Auto back on restores it.
15const prefer = atom({ plugin: 'cache-timer', key: 'prefer' } as const, 'compact')
16const lead = atom({ plugin: 'cache-timer', key: 'lead' } as const, 5)
17const warmUntil = atom({ plugin: 'cache-timer', key: 'warmUntil' } as const, null)
18// The two beats a drawing follows: the desktop redraws every second, the
19// terminal once a minute.
20const second = atom({ plugin: 'cache-timer', key: 'second' } as const, null)
21const minute = atom({ plugin: 'cache-timer', key: 'minute' } as const, null)
22// True after a compaction failed, until the next turn. The button says so.
23const failed = atom({ plugin: 'cache-timer', key: 'failed' } as const, false)
24const LEADS = [2, 5, 10]
25// `short` is the terminal's: its dropdown is as wide as its longest option.
26const CHOICES: readonly { is: Action; label: string; short: string }[] = [
27 { is: 'compact', label: 'Compact', short: 'Compact' },
28 { is: 'status', label: 'Status check', short: 'Status' },
29]
30
31// ponytail: assumes the subscription's one-hour cache. It is five minutes on
32// usage credits or an API key, and a mod can't read which; change it here.
33const CACHE_MINUTES = 60
34
35// What auto-status sends, as if typed.
36const STATUS = 'Status check: where are we, what is done, and what is next? Keep it short.'
37
38// What the Handoff button sends, as if typed.
39const HANDOFF = 'Write a handoff for this session with the ce-handoff skill.'
40
41const short = (path: unknown) => String(path ?? 'a file').split('/').slice(-2).join('/')
42
43const firstLine = (text: unknown) => String(text ?? '').trim().split('\n')[0].slice(0, 70)
44
45// One line built from the real facts of a tool call: the file, the search
46// words, the command. Unknown tools fall through to their own name.
47const describe = (e: Record<string, unknown>): string => {
48 const tool = String(e.tool)
49
50 switch (tool) {
51 case 'Bash':
52 return e.description
53 ? `${String(e.description)} · ${firstLine(e.command)}`
54 : `Running ${firstLine(e.command)}`
55 case 'Read':
56 return `Reading ${short(e.file_path)}`
57 case 'Edit':
58 case 'Write':
59 case 'NotebookEdit':
60 return `Changing ${short(e.file_path ?? e.notebook_path)}`
61 case 'Grep':
62 case 'Glob':
63 return `Searching ${e.path ? short(e.path) : 'this folder'} for "${String(e.pattern)}"`
64 case 'WebSearch':
65 return `Searching the web for "${String(e.query)}"`
66 case 'WebFetch':
67 return `Reading ${String(e.url).replace(/^https?:\/\//, '')}`
68 case 'Agent':
69 return `Handing off to a helper: ${String(e.description ?? 'a side task')}`
70 case 'Skill':
71 return `Loading the ${String(e.skill)} skill`
72 case 'AskUserQuestion':
73 return 'Waiting on your answer'
74 default:
75 return `Using ${(tool.split('__').pop() ?? tool).replace(/_/g, ' ')}`
76 }
77}
78
79const clock = (left: number) => `${String(Math.floor(left / 60)).padStart(2, '0')}:${String(left % 60).padStart(2, '0')}`
80
81// flame:start
82type Mood = { stage: number; color: string; glow: string; word: string }
83
84// Hot to cold: roaring, steady, small and worried, a flame turned to ice and
85// shivering, then an ice cube. `tip` is how tall the flame's point stands (1
86// is full height).
87const STAGES = [
88 { above: 30 * 60, color: '#f04e23', glow: '#ffb020', size: 1.05, flicker: 0.5, tip: 1 },
89 { above: 15 * 60, color: '#f58220', glow: '#ffd04a', size: 1, flicker: 0.5, tip: 0.85 },
90 { above: 5 * 60, color: '#f0a83c', glow: '#fff1a8', size: 0.95, flicker: 1, tip: 0.6 },
91 { above: 0, color: '#6fa8dc', glow: '#d6ecff', size: 0.9, flicker: 1, tip: 0.4 },
92 { above: -1, color: '#7fb6e8', glow: '#cfe9fb', size: 1, flicker: 0, tip: 0 },
93]
94
95const stageOf = (left: number) => STAGES.findIndex(s => left > s.above)
96
97const mood = (left: number): Mood => {
98 const stage = stageOf(left)
99 const { color, glow } = STAGES[stage]
100
101 return { stage, color, glow, word: stage === 4 ? 'cold cache' : clock(left) }
102}
103
104const WIDTH = 200
105const HEIGHT = 34
106const EDGE = 11
107// The flame's outline. Everything above the round body is squashed by
108// `tip`, so the point sinks as the fire cools while the body keeps its size.
109const flame = (tip: number) => {
110 const y = (above: number) => -5 - above * tip
111
112 return `M0 ${y(17)}C6 ${y(10)} 9 ${y(5)} 9 -5A9 9 0 0 1 -9 -5C-9 ${y(4)} -5 ${y(6)} -4 ${y(11)}C-2 ${y(8)} -1 ${y(12)} 0 ${y(17)}Z`
113}
114
115const drift = (x: number, delay: number, from: number, to: number, mark: string) =>
116 `<g transform="translate(${x} ${(from + to) / 2})"><animateTransform attributeName="transform" type="translate" values="${x} ${from};${x} ${to}" dur="1s" begin="${-delay}s" repeatCount="indefinite"/>` +
117 `<animate attributeName="opacity" values="1;0" dur="1s" begin="${-delay}s" repeatCount="indefinite"/>${mark}</g>`
118
119const BROWN = '#5a2a0a'
120const NAVY = '#23405e'
121const EYES = `<circle cx="-3" cy="-6" r="1.2" fill="${BROWN}"/><circle cx="3" cy="-6" r="1.2" fill="${BROWN}"/>`
122
123// One face per stage: a grin, a smile, a worried frown, then wide-eyed fright.
124const FACES = [
125 `${EYES}<path d="M-3 -3.4q3 4.4 6 0z" fill="${BROWN}" stroke="${BROWN}" stroke-width="0.8" stroke-linejoin="round"/>`,
126 `${EYES}<path d="M-2.5 -3q2.5 2.5 5 0" stroke="${BROWN}" stroke-width="1.2" fill="none" stroke-linecap="round"/>`,
127 `${EYES}<path d="M-5 -8.4l2.6 -1M5 -8.4l-2.6 -1M-2 -1.8q2 -1.6 4 0" stroke="${BROWN}" stroke-width="1" fill="none" stroke-linecap="round"/>`,
128 `<circle cx="-3" cy="-6" r="1.7" fill="#fff"/><circle cx="3" cy="-6" r="1.7" fill="#fff"/><circle cx="-3" cy="-6" r="0.8" fill="${NAVY}"/><circle cx="3" cy="-6" r="0.8" fill="${NAVY}"/><ellipse cy="-1.6" rx="1.3" ry="1.6" fill="${NAVY}"/>`,
129]
130
131const SPARK = (glow: string) => `<circle r="1.1" fill="${glow}"/>`
132const SNOW = '<path d="M-1.4 0h2.8M0 -1.4v2.8M-1 -1l2 2M-1 1l2 -2" stroke="#8fc3ee" stroke-width="0.6" stroke-linecap="round"/>'
133
134const fire = (m: Mood) => {
135 const { size, flicker, tip } = STAGES[m.stage]
136 const freezing = m.stage === 3
137 // Nearly out, he is the ice cube's own pale blue with its blue edge, so the
138 // step from frozen flame to cube reads as one thing changing shape.
139 const [color, glow, edge] = freezing ? ['#bfe3fa', '#eef8ff', '#7fb6e8'] : [m.color, m.glow, '#fff']
140 const shape = flame(tip)
141 const shiver = freezing
142 ? '<animateTransform attributeName="transform" type="translate" values="-0.7 0;0.7 0;-0.7 0" dur="0.2s" repeatCount="indefinite"/>'
143 : ''
144 const extras =
145 m.stage === 0
146 ? drift(-7, 0, -14, -27, SPARK(m.glow)) + drift(6, 0.5, -14, -27, SPARK(m.glow))
147 : freezing
148 ? drift(-8, 0, -27, -15, SNOW) + drift(7, 0.5, -27, -15, SNOW)
149 : ''
150
151 return (
152 `<g>${shiver}<g transform="translate(0 -5) scale(${size}) translate(0 5)">` +
153 `<g><animateTransform attributeName="transform" type="scale" values="1 1;1.07 0.93;0.95 1.08;1 1" dur="${flicker}s" repeatCount="indefinite"/>` +
154 `<path d="${shape}" fill="${color}" stroke="${edge}" stroke-width="2" stroke-linejoin="round" paint-order="stroke"/><path d="${shape}" fill="${glow}" transform="translate(0 1.5) scale(0.58)"/></g>` +
155 `${FACES[m.stage]}</g>${extras}</g>`
156 )
157}
158
159const ICE =
160 '<rect x="-10" y="-15" width="20" height="20" rx="5.5" fill="#fff"/><rect x="-9" y="-14" width="18" height="18" rx="4.5" fill="#bfe3fa" stroke="#7fb6e8" stroke-width="1.2"/>' +
161 '<path d="M-5.5 -10.5l3 -1.5" stroke="#fff" stroke-width="1.4" stroke-linecap="round"/>' +
162 '<path d="M-5 -5q1.5 1.5 3 0M2 -5q1.5 1.5 3 0" stroke="#3a6f9f" stroke-width="1.1" fill="none" stroke-linecap="round"/>' +
163 '<path d="M-1.5 -1.2h3" stroke="#3a6f9f" stroke-width="1.1" stroke-linecap="round"/>' +
164 '<path d="M8 -18v4M6 -16h4" stroke="#7fb6e8" stroke-width="1" stroke-linecap="round"><animate attributeName="opacity" values="0;1;0" dur="1s" repeatCount="indefinite"/></path>'
165
166// Light lettering in the system face: white where it sits on the fire's
167// colour, a see-through dark where it sits on the empty track (a see-through
168// white in a dark theme). The attributes repeat the style for a surface that
169// drops the style block.
170const FONT = '-apple-system, system-ui, sans-serif'
171
172const STYLE = `<style>.c{font:500 10.5px ${FONT};letter-spacing:.2px;font-variant-numeric:tabular-nums}.on{fill:#fff}.off{fill:#1f1e1c;fill-opacity:.6}@media (prefers-color-scheme:dark){.off{fill:#fff;fill-opacity:.75}}</style>`
173
174const lettering = (onColour: boolean) =>
175 `class="c ${onColour ? 'on' : 'off'}" font-family="${FONT}" font-size="10.5" font-weight="500" ${onColour ? 'fill="#fff"' : 'fill="#1f1e1c" fill-opacity="0.6"'}`
176
177// A fat bar that drains right to left with the fire riding its end and the
178// clock inside it, on whichever side the fire is not. It shows one moment:
179// the app may throw a picture away and draw it again at any time (it does
180// while the prompt is typed in), so a picture that counted for itself would
181// jump back to the moment it was made. The mod redraws it each second instead.
182// Where the fire's base sits so its round body, as tall as the bar, rests on
183// the bar's centre line (y 23). The tip and the sparks rise into the space
184// above the bar, which is what the picture's extra height is for.
185const FLOOR = 28
186
187// The armed stretch: the bar's last minutes frosted over, fading in from
188// the moment the auto action runs to the cold end. No hard edge.
189const frost = (to: number) =>
190 `<linearGradient id="f" gradientUnits="userSpaceOnUse" x1="1" x2="${to}"><stop offset="0" stop-color="#e6f3ff" stop-opacity="0.7"/><stop offset="1" stop-color="#e6f3ff" stop-opacity="0"/></linearGradient>` +
191 `<clipPath id="t"><rect x="1" y="14" width="${WIDTH - 2}" height="18" rx="9"/></clipPath>` +
192 `<rect x="1" y="14" width="${to - 1}" height="18" fill="url(#f)" clip-path="url(#t)"/>`
193
194const picture = (left: number, m: Mood, lead: number | null) => {
195 const share = Math.min(Math.max(left / (CACHE_MINUTES * 60), 0), 1)
196 const at = EDGE + (WIDTH - 2 * EDGE) * share
197 const frosted = lead === null ? 0 : EDGE + ((WIDTH - 2 * EDGE) * lead) / CACHE_MINUTES
198 // On the left the clock starts clear of the frost, which would wash it out.
199 const side =
200 share > 0.5
201 ? `x="${Math.max(12, frosted + 4)}" ${lettering(true)}`
202 : `x="${WIDTH - 13}" text-anchor="end" ${lettering(false)}`
203
204 return (
205 `<svg xmlns="http://www.w3.org/2000/svg" width="${WIDTH}" height="${HEIGHT}" viewBox="0 0 ${WIDTH} ${HEIGHT}">${STYLE}` +
206 `<rect x="1" y="14" width="${WIDTH - 2}" height="18" rx="9" fill="#888" fill-opacity="0.3"/>` +
207 `<rect x="1" y="14" width="${at + 8}" height="18" rx="9" fill="${m.color}"/>` +
208 (lead === null || m.stage === 4 ? '' : frost(frosted)) +
209 `<text y="26.5" ${side}>${m.word}</text>` +
210 `<g transform="translate(${at} ${FLOOR})">${m.stage === 4 ? ICE : fire(m)}</g>` +
211 `</svg>`
212 )
213}
214// flame:end
215
216// The desktop lines a row's children up by their tops and packs them edge to
217// edge. Empty pictures hold the buttons apart and drop them BUTTON_DROP
218// pixels, which puts their middle on the bar's centre line.
219const BUTTON_DROP = 14
220const spacer = (width: number, height: number) =>
221 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="${width}" height="${height}" fill-opacity="0"/></svg>`
222
223// The cache lifetime runs from the start of the last request that used it.
224async function stamp($: EngineInterface) {
225 const at = await $.clock.now()
226 await update($, warmUntil, () => at + CACHE_MINUTES * 60000)
227}
228
229async function secondsLeft($: EngineInterface) {
230 const until = await read($, warmUntil)
231
232 return until === null ? null : Math.max(Math.ceil((until - (await $.clock.now())) / 1000), 0)
233}
234
235// Compacts by running /compact as a command: `$.session.compact` is refused
236// in the desktop app, and the command works there and in the terminal. A
237// failure shows on the button, since the desktop shows a mod's toasts nowhere.
238async function compact($: EngineInterface) {
239 try {
240 await $.command.run({ command: 'compact' })
241 // The compaction was itself a request, so the cache is warm again.
242 await stamp($)
243 await update($, failed, () => false)
244
245 return true
246 } catch {
247 await update($, failed, () => true)
248
249 return false
250 }
251}
252
253// The auto action runs once per idle stretch. Each one restarts the hour,
254// so without this an unattended session would act again every 55 minutes.
255// `isOwnTurn` marks the turn auto-status starts, which must not count as the
256// person coming back.
257let hasActed = false
258let isOwnTurn = false
259
260async function act($: EngineInterface, left: number) {
261 const what = await read($, action)
262 const isDue = what !== 'off' && left > 0 && left <= (await read($, lead)) * 60 && !hasActed
263
264 if (!isDue || (await read($, busy))) {
265 return
266 }
267
268 hasActed = true
269
270 if (what === 'status') {
271 isOwnTurn = true
272 void $.prompt.submit({ text: STATUS, asUser: true })
273
274 return
275 }
276
277 if (await compact($)) {
278 $.ui.toast('Compacted before the cache went cold')
279 }
280}
281
282async function tick($: EngineInterface) {
283 const left = await secondsLeft($)
284
285 if (left === null) {
286 return
287 }
288
289 await act($, left)
290
291 const nextMinute = Math.ceil(left / 60)
292
293 // The desktop's beat: only while the bar is showing, and never past zero,
294 // where the ice cube has nothing left to count.
295 if (!(await read($, busy)) && !(await read($, hidden)) && (await read($, second)) !== left) {
296 await update($, second, () => left)
297 }
298
299 if ((await read($, minute)) !== nextMinute) {
300 await update($, minute, () => nextMinute)
301 }
302}
303
304async function choose($: EngineInterface, picked: Action) {
305 await update($, action, () => picked)
306 await $.store.set('action', picked)
307
308 if (picked !== 'off') {
309 await update($, prefer, () => picked)
310 await $.store.set('prefer', picked)
311 }
312}
313
314export const register: Register = on => {
315 let running = 0
316
317 on('session.start', async ($, e, next) => {
318 $.clock.every(1000, () => void tick($))
319 await $.command.register({ name: 'timer', description: 'Show or hide the cache timer' })
320 const stored = (await $.store.get('hidden')) === true
321 await update($, hidden, () => stored)
322 const savedAction = String(await $.store.get('action'))
323 const storedAction = CHOICES.find(one => one.is === savedAction)?.is ?? 'off'
324 await update($, action, () => storedAction)
325 const savedPrefer = String(await $.store.get('prefer'))
326 const storedPrefer = CHOICES.find(one => one.is === savedPrefer)?.is ?? 'compact'
327 await update($, prefer, () => (storedAction === 'off' ? storedPrefer : storedAction))
328 const storedLead = Number(await $.store.get('lead'))
329 await update($, lead, () => (LEADS.includes(storedLead) ? storedLead : 5))
330
331 return next(e)
332 })
333
334 // /timer flips the bar on or off, and the choice is kept for every session.
335 on('command.run', { command: 'timer' }, async $ => {
336 const next = !(await read($, hidden))
337 await update($, hidden, () => next)
338 await $.store.set('hidden', next)
339
340 return { text: next ? 'Cache timer hidden. /timer shows it again.' : 'Cache timer showing.' }
341 })
342
343 on('turn.start', async ($, e, next) => {
344 running = 0
345
346 if (isOwnTurn) {
347 isOwnTurn = false
348 } else {
349 hasActed = false
350 }
351
352 await update($, failed, () => false)
353 await stamp($)
354 await tick($)
355 await update($, busy, () => true)
356
357 return next(e)
358 })
359
360 on('tool.call', async ($, e, next) => {
361 running += 1
362 await update($, now, () => describe(e as Record<string, unknown>))
363
364 try {
365 return await next(e)
366 } finally {
367 running -= 1
368
369 // A helper has a cache of its own, so only main-loop requests count.
370 if (e.agentId === undefined) {
371 await stamp($)
372 }
373
374 if (running <= 0) {
375 await update($, now, () => null)
376 }
377 }
378 })
379
380 on('turn.complete', async ($, e, next) => {
381 if (e.agentId === undefined) {
382 await update($, now, () => null)
383 await update($, busy, () => false)
384 await tick($)
385 }
386
387 return next(e)
388 })
389
390 // The app's own step line, with the running step's real details in place
391 // of its label. Thinking and writing are left as the app words them.
392 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
393 const doing = await read($, now)
394
395 return doing === null ? next(e) : next({ ...e, props: { ...e.props, word: doing } })
396 })
397
398 // The cache timer, above the prompt while idle. A turn in progress resets
399 // the clock on every step, so it has nothing to say then.
400 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
401 const beat = e.surface === 'terminal' ? await read($, minute) : await read($, second)
402 const isQuiet = e.props.hasSurvey || beat === null || (await read($, hidden)) || (await read($, busy))
403 const left = isQuiet ? null : await secondsLeft($)
404
405 if (left === null) {
406 return next(e)
407 }
408
409 const m = mood(left)
410 const what = await read($, action)
411 const minutes = await read($, lead)
412 const preferred = await read($, prefer)
413 const compactLabel = (await read($, failed)) ? 'Compact failed' : 'Compact now'
414 const wait = async (picked: string) => {
415 await update($, lead, () => Number(picked))
416 await $.store.set('lead', Number(picked))
417 }
418
419 if (e.surface === 'terminal') {
420 const { Box, Button, Text } = $.ui.resolve(e)
421 const filled = Math.round((20 * left) / (CACHE_MINUTES * 60))
422
423 // A blank row above keeps it off whatever the transcript printed last.
424 // The desktop's controls as bare buttons, dim dots between the groups;
425 // they wrap on a narrow terminal. The terminal's dropdowns take no
426 // mouse clicks and open their list out of line, so here the action and
427 // the time are buttons that step to the next choice on each click.
428 return (
429 <Box marginTop={1} flexDirection="row" flexWrap="wrap">
430 <Text color={m.color}>
431 {'█'.repeat(filled)}
432 {'░'.repeat(20 - filled)} {m.stage === 4 ? m.word : `${Math.ceil(left / 60)} min`}
433 </Text>
434 <Box flexGrow={1} />
435 <Button
436 key="auto"
437 plain
438 hotkey="a"
439 label={what === 'off' ? 'Auto off' : 'Auto on'}
440 onPress={() => choose($, what === 'off' ? preferred : 'off')}
441 />
442 {what === 'off' ? null : (
443 <Box marginLeft={2}>
444 <Button
445 key="what"
446 plain
447 label={CHOICES.find(one => one.is === what)?.short ?? 'Compact'}
448 onPress={() => choose($, what === 'compact' ? 'status' : 'compact')}
449 />
450 </Box>
451 )}
452 {what === 'off' ? null : (
453 <Box marginLeft={2}>
454 <Button
455 key="lead"
456 plain
457 label={`${minutes}m`}
458 onPress={() => wait(String(LEADS[(LEADS.indexOf(minutes) + 1) % LEADS.length]))}
459 />
460 </Box>
461 )}
462 <Text dimColor>{' · '}</Text>
463 <Button key="compact" plain hotkey="c" label={compactLabel} onPress={() => compact($)} />
464 <Text dimColor>{' · '}</Text>
465 <Button key="handoff" plain hotkey="h" label="Handoff" onPress={() => $.prompt.submit({ text: HANDOFF, asUser: true })} />
466 </Box>
467 )
468 }
469
470 const { Box, Button, Select, Svg } = $.ui.resolve(e)
471
472 return (
473 <Box flexDirection="row">
474 <Svg source={picture(left, m, what === 'off' ? null : minutes)} alt={`Memory: ${m.word}`} width={WIDTH} height={HEIGHT} />
475 <Box flexGrow={1} />
476 <Box flexDirection="column">
477 <Svg source={spacer(4, BUTTON_DROP)} alt="spacer" width={4} height={BUTTON_DROP} />
478 <Box flexDirection="row">
479 <Button
480 key="auto"
481 label={what === 'off' ? 'Auto off' : 'Auto on'}
482 onPress={() => choose($, what === 'off' ? preferred : 'off')}
483 />
484 {what === 'off' ? null : <Svg source={spacer(8, 1)} alt="spacer" width={8} height={1} />}
485 {what === 'off' ? null : (
486 <Select
487 key="what"
488 options={CHOICES.map(one => ({ value: one.is, label: one.label }))}
489 value={what}
490 onSelect={picked => choose($, CHOICES.find(one => one.is === picked)?.is ?? 'compact')}
491 />
492 )}
493 {what === 'off' ? null : <Svg source={spacer(8, 1)} alt="spacer" width={8} height={1} />}
494 {what === 'off' ? null : (
495 <Select
496 key="lead"
497 options={LEADS.map(one => ({ value: String(one), label: `◷ ${one}m` }))}
498 value={String(minutes)}
499 onSelect={wait}
500 />
501 )}
502 <Svg source={spacer(8, 1)} alt="spacer" width={8} height={1} />
503 <Button key="compact" label={compactLabel} onPress={() => compact($)} />
504 <Svg source={spacer(8, 1)} alt="spacer" width={8} height={1} />
505 <Button key="handoff" label="Handoff" onPress={() => $.prompt.submit({ text: HANDOFF, asUser: true })} />
506 </Box>
507 </Box>
508 </Box>
509 )
510 })
511}
512types/index.d.ts 10 lines1export type Doing = string | null
2export type Time = number | null
3export type Action = 'off' | 'compact' | 'status'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'cache-timer': { now: Doing; busy: boolean; hidden: boolean; action: Action; prefer: Action; lead: number; warmUntil: Time; second: Time; minute: Time; failed: boolean }
8 }
9}
10