SLOPSHOPPER

sticky-todos

Keeps Claude's todo list in a side pane beside the transcript, open only while the list has items.

newpaneguardcommandtool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sticky-todos
│ ┃ Todos ✕ › fix the failing auth test and add an audit log call │ ┃ Todos │ ┃ No todos yet. Claude's checklist appears ⏺ Read(src/auth.ts) │ ┃ here. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todos │ ⎿ sticky-todos: Todo pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Todos
Todos No todos yet. Claude's checklist appears here.
README

claude-code-mods

Mods for Claude Code: small plugins of function hooks that draw inside the terminal or the desktop app's Code tab and react to what the session does. Each folder here is one self-contained mod that works in any repository.

ModWhat it does
cache-watchShows the prompt cache's hit rate and time left, and suggests the cheapest way to carry on before the cache expires.
sticky-todosKeeps Claude's todo list in a pane beside the transcript, open only while the list has items.

[!note] The function-hooks API is in early access and changes between Claude Code releases. These mods were built and checked against Claude Code 2.1.286. If a mod stops loading after an update, run claude plugin validate <mod folder> to see what the engine now refuses.

Install

This repository is a plugin marketplace. Add it once, then install the mods you want. Inside Claude Code:

/plugin marketplace add paweechinagarn/claude-code-mods
/plugin install cache-watch@claude-code-mods
/plugin install sticky-todos@claude-code-mods
/reload-plugins

The same commands work from a shell as claude plugin marketplace add ... and claude plugin install .... If a mod does not appear after /reload-plugins, restart Claude Code.

To pick up new versions, refresh the marketplace with /plugin marketplace update claude-code-mods.

A mod is code that runs inside Claude Code with the same access Claude Code has. Read it before you install it.

Run from a clone (for development)

An installed mod is a cached copy. To edit a mod and see changes live, load it from a clone instead:

git clone https://github.com/paweechinagarn/claude-code-mods.git

For one terminal session, pass the mod's folder:

claude --plugin-dir <clone>/cache-watch

For every session, the desktop app included, list the folder in the env block of ~/.claude/settings.json. Separate several folders with ; on Windows and : elsewhere:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "<clone>/cache-watch;<clone>/sticky-todos"
  }
}

Interactive sessions watch these folders, so an edit or a git pull reloads the mod without a restart. Do not load the same mod both ways at once.

cache-watch

Claude Code caches the start of every request. A cached token costs about a tenth of a normal input token, but the cache only lives for a while after it was last used: one hour on most plans, five minutes on others or during usage overage. Once it expires, the next message pays to write the whole conversation into the cache again. On a long session that is the most expensive message you send.

What it shows

One row above the prompt, readable at a glance. These images are mockups drawn with the mod's own bar code and styled like the desktop app, so spacing in the app differs slightly.

Fresh, with the details panel open (more, or the d key):

The cache row with 58 minutes left and a 99% hit rate, with the details panel open

About to expire, with the advice card:

The cache row in amber with 3 minutes left, above the advice card suggesting a new session

Expired:

The cache row in red, 12 minutes after expiry, warning that the next message re-caches 185k tokens

  • The bar and the time count down from the last request that read or wrote the cache. The bar is shaded red to amber to green from left to right, so its shrinking end drifts into red. The time is green while fresh, amber in the last five minutes, red once expired. A symbol and words carry the state too, so it never rests on color alone.
  • Hits is a sparkline of the last 12 requests and the share of the last request served from the cache. Each bar is shaded by its own hit rate on the same red, amber and green scale, so a miss shows red. Slots not yet filled show an empty track. Only the main conversation counts; subagents keep caches of their own.
  • more (hotkey d) opens the details: the cache lifetime and how the mod knows it, the last request's and the session's token counts, and what an expiry would cost.

Claude Code does not report the cache lifetime on each request, so the mod assumes one hour until it sees proof: a request after a pause of six or more minutes that still hits the cache (one hour), or misses it (five minutes). A model switch reports the lifetime directly. The details panel says which of these it is. On narrow windows the row drops the sparkline and the lifetime.

The desktop app, the editor extension and the phone draw both bars as vector graphics: a rounded gradient pill and a row of rounded columns. The terminal draws them with line and block characters instead.

Advice before the cache expires

Five minutes before a one-hour cache expires, while you are idle, the mod asks the model one side question over your conversation and shows its answer with three buttons:

OptionWhen the model picks it
Continue hereThe remaining work is short, or it really needs the full history.
Compact nowThe task is mid-flight and the history holds a lot that is no longer needed. Compacting while the cache is warm is cheaper than after.
Copy handoff promptThe work has reached a natural break. The mod has already written a self-contained prompt; paste it into a new session. Show prompt opens it in a pane.

All three buttons are always there; the recommended one is highlighted. With under 30,000 tokens of context the mod skips the question and says to continue, because re-caching a small context costs little. Run /cache-advice to get the same advice at any time.

[!important] The advice itself spends a little The side question reads the warm cache, about a twentieth of the cost of re-writing it, and that read also restarts the cache's one-hour clock. The mod asks once per idle stretch, so a session left alone all day spends one read, not one per hour.

Alerts

  • Unexpected miss: the cache missed within five minutes of the last request. Something changed the start of the prompt, such as the connected tools or MCP servers. Misses after a compaction, a model switch or a stale resume are expected and stay quiet.
  • Five-minute caching: a request after a long idle gap missed the cache, so the session now looks like five-minute caching.

Tuning

The constants at the top of cache-watch/hooks/register.tsx:

ConstantDefaultMeaning
WARN_MIN5Minutes before expiry that the advice runs.
SMALL_CONTEXT30_000Below this many tokens, advise continuing without asking the model.
PROBE_GAP_MIN6Idle minutes after which a request tells five-minute from one-hour caching.

Status

Version 0.2.0. Checked with claude plugin validate and a strict TypeScript build, and the line above the prompt is confirmed drawing in the desktop app. The advice flow, the copy button on the desktop surface and Compact now have not yet run in a real session.

sticky-todos

When Claude works through a multi-step task it keeps a checklist, but the checklist sits in the transcript and scrolls away. This mod keeps it in a pane of its own, beside the transcript and clear of the messages and the prompt.

What it shows

A pane beside the transcript, clear of the messages and the prompt. These images are mockups styled like the desktop app. The ring and the step icons come from the mod's own drawing code, but spacing in the app differs slightly. docs/sticky-todos/mockup.html is their source.

A transcript on the left and the Todos pane docked on the right, showing 2 of 4 tasks done

The pane up close:

The Todos pane: a progress ring reading 2 of 4, two finished tasks with green checks, one task in progress in orange, one pending task and a Clear done button

  • The ring fills as tasks finish, with the count inside. The line beside it says how many are done and how many are in progress.
  • The steps are joined by a thin line that turns green between finished tasks. A finished task has a green check and is dimmed and struck through. The task in progress has a spinning orange ring and shows its "doing" wording in bold. A task still to do has an empty circle. The spin stops when the system asks for reduced motion. Clear done removes the finished tasks.
  • The terminal cannot draw these images, so it shows colored symbols instead: ✔ done, ◐ in progress and ○ to do.
  • The pane opens by itself when the list gets its first item and closes when the list empties. If you close it while tasks remain, it stays closed until the list starts again. /todos opens it at any time.
  • The list survives a reload or a resume, and the pane comes back with it.
  • In the desktop app the pane docks beside the transcript. In the terminal it docks in fullscreen mode and otherwise sits above the prompt. A pane that opens by itself needs a terminal at least 144 columns wide; /todos opens it at any width.

Where the list comes from

The mod reads whichever todo tool the session gives the model:

ToolHow the mod reads it
TodoWriteEach call carries the whole list, which replaces the pane's.
TaskCreate and TaskUpdateEach call adds one task or changes one task's status or wording.
mcp__sticky-todos__set_todosThe mod's own tool, in TodoWrite's shape.

Some sessions, such as the desktop app's Code tab, give the model neither built-in tool. For those the mod registers its own set_todos tool, so the model can keep a list anyway. The tool's description tells the model to prefer a built-in todo tool when it has one.

Status

Version 0.2.0. Checked with claude plugin validate and a strict TypeScript build. In a real desktop-app session, the mod's own set_todos tool filled the pane, and the ring, the stepper and the spinning icon drew as designed. Reading TodoWrite, TaskCreate and TaskUpdate has not yet run in a real session.

Writing your own

A mod is a folder with three files: .claude-plugin/plugin.json, hooks/hooks.json naming the module, and the hooks module exporting register(on). A mod that keeps state adds a types/index.d.ts contract. To ship a new mod from this repository, add its folder and list it in .claude-plugin/marketplace.json with its name and source. Inside Claude Code, the bundled plugin-authoring skill holds the full API for the version you run. cache-watch is a working example of a line above the prompt, a pane, a slash command, a timer and a model fork.

License

MIT

Source 2 files
hooks/register.tsx 271 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Todo } from '../types'
5
6const PANE = 'sticky-todos'
7const TITLE = 'Todos'
8const todos = atom({ plugin: 'sticky-todos', key: 'todos' } as const, [])
9
10type Loose = Record<string, unknown>
11const asStatus = (s: unknown): Todo['status'] =>
12  s === 'completed' || s === 'in_progress' ? s : 'pending'
13
14// Colours that read on the desktop app's light and dark themes alike.
15const GREEN = '#22c55e'
16const ACCENT = '#d97757'
17
18// Surfaces that draw Svg (desktop, editor, phone) get a ring and a stepper; the terminal gets glyphs.
19// docs/sticky-todos/mockup.html holds a copy of these builders: change both together.
20const TRACK = 'stroke="#808080" stroke-opacity="0.3"'
21const RING = 36
22const STEP_W = 18
23const STEP_H = 30
24
25/** The header ring: the share done, as an arc over a faint track, with the count inside. */
26const ringSvg = (done: number, total: number) => {
27  const r = 15
28  const c = 2 * Math.PI * r
29  const f = total ? done / total : 0
30  return (
31    `<svg xmlns="http://www.w3.org/2000/svg" width="${RING}" height="${RING}" viewBox="0 0 36 36">` +
32    `<circle cx="18" cy="18" r="${r}" fill="none" stroke-width="3.5" ${TRACK}/>` +
33    `<circle cx="18" cy="18" r="${r}" fill="none" stroke="${GREEN}" stroke-width="3.5" stroke-linecap="round" ` +
34    `stroke-dasharray="${(f * c).toFixed(2)} ${c.toFixed(2)}" transform="rotate(-90 18 18)"/>` +
35    `<text x="18" y="22" text-anchor="middle" font-family="Segoe UI, system-ui, sans-serif" font-size="11" ` +
36    `font-weight="600" fill="${GREEN}">${done}/${total}</text></svg>`
37  )
38}
39
40/** One step of the stepper: a status mark, with rail segments joining it to its neighbours. */
41const stepSvg = (status: Todo['status'], isFirst: boolean, isLast: boolean, isPrevDone: boolean) => {
42  const mid = STEP_H / 2
43  const x = STEP_W / 2
44  const rail = (y1: number, y2: number, isLit: boolean) =>
45    `<line x1="${x}" y1="${y1}" x2="${x}" y2="${y2}" stroke-width="2" ` +
46    (isLit ? `stroke="${GREEN}" stroke-opacity="0.6"` : TRACK) +
47    '/>'
48  const up = isFirst ? '' : rail(0, mid - 8, isPrevDone)
49  const down = isLast ? '' : rail(mid + 8, STEP_H, status === 'completed')
50  const mark =
51    status === 'completed'
52      ? `<circle cx="${x}" cy="${mid}" r="7" fill="${GREEN}"/>` +
53        `<path d="M${x - 3.2} ${mid} l2.2 2.3 l4.2 -4.6" fill="none" stroke="#1d1d1b" stroke-width="2" ` +
54        `stroke-linecap="round" stroke-linejoin="round"/>`
55      : status === 'in_progress'
56        ? `<circle cx="${x}" cy="${mid}" r="6.5" fill="none" stroke-width="2" stroke="${ACCENT}" stroke-opacity="0.3"/>` +
57          `<circle class="spin" cx="${x}" cy="${mid}" r="6.5" fill="none" stroke-width="2" stroke="${ACCENT}" ` +
58          `stroke-linecap="round" stroke-dasharray="12 29"/><circle cx="${x}" cy="${mid}" r="2.5" fill="${ACCENT}"/>`
59        : `<circle cx="${x}" cy="${mid}" r="6.5" fill="none" stroke-width="2" ${TRACK}/>`
60  const motion =
61    status === 'in_progress'
62      ? // The interactive frame paints an opaque white backdrop when its color scheme differs from the page's.
63        `<style>:root{color-scheme:light dark;background:transparent}.spin{transform-origin:${x}px ${mid}px;animation:s 1.1s linear infinite}` +
64        `@keyframes s{to{transform:rotate(360deg)}}@media (prefers-reduced-motion:reduce){.spin{animation:none}}</style>`
65      : ''
66  return (
67    `<svg xmlns="http://www.w3.org/2000/svg" width="${STEP_W}" height="${STEP_H}" viewBox="0 0 ${STEP_W} ${STEP_H}">` +
68    `${motion}${up}${down}${mark}</svg>`
69  )
70}
71
72const GLYPH: Record<Todo['status'], string> = { completed: '✔', in_progress: '◐', pending: '○' }
73const STATUS_WORD: Record<Todo['status'], string> = { completed: 'Done', in_progress: 'In progress', pending: 'To do' }
74
75// The whole list in TodoWrite's shape: { content, status, activeForm? }[].
76const fromTodoWrite = (items: Loose[]): Todo[] =>
77  items.map((t, i) => ({
78    id: String(i),
79    text: String(t.status === 'in_progress' && t.activeForm ? t.activeForm : t.content ?? ''),
80    status: asStatus(t.status),
81  }))
82
83// The mod's own tool, for sessions whose model has no built-in todo tool (the desktop app's, for one).
84const OWN_TOOL = 'mcp__sticky-todos__set_todos'
85const OWN_SCHEMA = {
86  type: 'object',
87  properties: {
88    todos: {
89      type: 'array',
90      description: 'The whole list, in order. Send an empty array to clear it.',
91      items: {
92        type: 'object',
93        properties: {
94          content: { type: 'string', description: 'The task, in the imperative: "Run the tests".' },
95          status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
96          activeForm: { type: 'string', description: 'Shown while in progress: "Running the tests".' },
97        },
98        required: ['content', 'status'],
99      },
100    },
101  },
102  required: ['todos'],
103}
104
105// Writes the list, then opens the pane when it gains its first item and closes it when it empties.
106// Opening only on that change means a pane the person closed stays closed until the list restarts.
107const setTodos = async ($: EngineInterface, fn: (list: Todo[]) => Todo[]) => {
108  const before = (await read($, todos)).length
109  await update($, todos, fn)
110  const after = (await read($, todos)).length
111
112  if (before === 0 && after > 0) {
113    void $.ui.open({ id: PANE, title: TITLE })
114  } else if (before > 0 && after === 0) {
115    void $.ui.close({ id: PANE })
116  }
117}
118
119export const register: Register = on => {
120  on('session.start', async ($, e, next) => {
121    await $.command.register({ name: 'todos', description: 'Show the sticky todo pane' })
122    await $.tool.register({
123      name: 'set_todos',
124      description:
125        'Replace the todo checklist shown in the side pane. Use it for multi-step work when you have ' +
126        'no built-in todo tool (TodoWrite or TaskCreate); keep one item in_progress at a time.',
127      inputSchema: OWN_SCHEMA,
128    })
129
130    // A reload or resume keeps the list: show it again if it has items.
131    if ((await read($, todos)).length > 0) {
132      void $.ui.open({ id: PANE, title: TITLE })
133    }
134
135    return next(e)
136  })
137
138  on('command.run', { command: 'todos' }, async $ => {
139    await $.ui.open({ id: PANE, title: TITLE })
140
141    return { text: 'Todo pane opened.' }
142  })
143
144  // Claude's todo tools are not typed in every build, so read their arguments loosely.
145  on('tool.call', async ($, e, next) => {
146    const tool = e.tool as string
147    const input = e as unknown as Loose
148
149    // The mod's own tool: nothing beneath serves it, so answer here.
150    if (tool === OWN_TOOL) {
151      const items = Array.isArray(input.todos) ? (input.todos as Loose[]) : []
152      const list = fromTodoWrite(items)
153      await setTodos($, () => list)
154      const done = list.filter(t => t.status === 'completed').length
155
156      return { result: `Todo list updated: ${done} of ${list.length} done.` }
157    }
158
159    // Older tool: the whole list is rewritten on every call.
160    if (tool === 'TodoWrite' && Array.isArray(input.todos)) {
161      const list = fromTodoWrite(input.todos as Loose[])
162      await setTodos($, () => list)
163
164      return next(e)
165    }
166
167    // Newer tools: one task created or updated per call.
168    if (tool === 'TaskCreate') {
169      const ran = await next(e)
170      const text = 'text' in ran && typeof ran.text === 'string' ? ran.text : ''
171      const id = /#?(\d+)/.exec(text)?.[1] ?? String(Date.now())
172      await setTodos($, list => [
173        ...list,
174        { id, text: String(input.subject ?? ''), status: 'pending' as const },
175      ])
176
177      return ran
178    }
179
180    if (tool === 'TaskUpdate') {
181      const id = String(input.taskId ?? '')
182      await setTodos($, list =>
183        input.status === 'deleted'
184          ? list.filter(t => t.id !== id)
185          : list.map(t =>
186              t.id === id
187                ? {
188                    ...t,
189                    text: typeof input.subject === 'string' ? input.subject : t.text,
190                    status: input.status ? asStatus(input.status) : t.status,
191                  }
192                : t,
193            ),
194      )
195    }
196
197    return next(e)
198  })
199
200  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
201    const elements = $.ui.resolve(e)
202    const { Box, Text, Button } = elements
203    const Svg = 'Svg' in elements ? elements.Svg : undefined
204    const list = await read($, todos)
205    const done = list.filter(t => t.status === 'completed').length
206    const now = list.filter(t => t.status === 'in_progress').length
207    const summary =
208      list.length === 0
209        ? "No todos yet. Claude's checklist appears here."
210        : `${done} of ${list.length} done` + (now ? ` · ${now} in progress` : '')
211
212    return (
213      <Box flexDirection="column">
214        <Box gap={1} alignItems="center" marginBottom={1}>
215          {Svg && list.length > 0 && (
216            <Svg
217              source={ringSvg(done, list.length)}
218              alt={`${done} of ${list.length} done`}
219              width={RING}
220              height={RING}
221            />
222          )}
223          <Box flexDirection="column">
224            <Text bold>{TITLE}</Text>
225            <Text dimColor>{summary}</Text>
226          </Box>
227        </Box>
228        {list.map((t, i) => (
229          <Box key={t.id} gap={1} alignItems="center">
230            {Svg ? (
231              <Svg
232                source={stepSvg(t.status, i === 0, i === list.length - 1, list[i - 1]?.status === 'completed')}
233                alt={STATUS_WORD[t.status]}
234                width={STEP_W}
235                height={STEP_H}
236                // Only the running step animates, and only an interactive Svg plays CSS animation.
237                isInteractive={t.status === 'in_progress' || undefined}
238              />
239            ) : (
240              <Text
241                color={t.status === 'completed' ? GREEN : t.status === 'in_progress' ? ACCENT : undefined}
242                dimColor={t.status === 'pending'}
243              >
244                {GLYPH[t.status]}
245              </Text>
246            )}
247            <Text
248              wrap="truncate-end"
249              bold={t.status === 'in_progress'}
250              color={t.status === 'in_progress' ? ACCENT : undefined}
251              dimColor={t.status === 'completed'}
252              strikethrough={t.status === 'completed'}
253            >
254              {t.text}
255            </Text>
256          </Box>
257        ))}
258        {done > 0 && (
259          <Box marginTop={1}>
260            <Button
261              key="clear"
262              label="Clear done"
263              onPress={() => setTodos($, l => l.filter(t => t.status !== 'completed'))}
264            />
265          </Box>
266        )}
267      </Box>
268    )
269  })
270}
271
types/index.d.ts 8 lines
1export type Todo = { id: string; text: string; status: 'pending' | 'in_progress' | 'completed' }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'sticky-todos': { todos: Todo[] }
6  }
7}
8