SLOPSHOPPER

later

Stash tasks per repo with /later and start them from a side pane

newpanecommandprocesstimer
v0.1.0MITupdated 2026-10-24Visal-Sok/claude-mods/later
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · later
│ ┃ Later (0) ✕ › fix the failing auth test and add an audit log call │ ┃ 󰃀 later app clear │ ┃ ⏺ Read(src/auth.ts) │ ┃ ✓ Nothing stashed for this repo ⎿ Read 6 lines │ ┃ /later <task> to save one for later ⏺ 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 │ │ › /later │ ⎿ later: Nothing stashed for this repo. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Later (0)
󰃀 later app clear ✓ Nothing stashed for this repo /later <task> to save one for later
README

claude-mods

Claude Code mods, in Tokyo Night colours.

ModWhat
hudone-line hud under the prompt: dir, branch, context, 5h/weekly usage limits with reset countdowns, running agents, model
later/later <task> stashes a task for the current repo; a side pane starts or drops them. hud's 󰃀 button toggles it

Install

/plugin marketplace add Visal-Sok/claude-mods
/plugin install hud@claude-mods
/plugin install later@claude-mods

The icons need a Nerd Font in your terminal.

Develop

Add a local clone as the marketplace instead, and edits load as you make them:

git clone git@github.com:Visal-Sok/claude-mods.git
claude plugin marketplace add ./claude-mods
claude plugin test claude-mods/hud

License

MIT

Source 3 files
hooks/register.tsx 267 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Todo } from '../types'
5import { loadTodos, mtimeOf, saveTodos, storagePath } from './storage'
6
7const PANE = 'later'
8const LEGACY_PREFIX = 'later:'
9const POLL_MS = 2000
10const MAX_HOTKEYS = 9
11
12// Tokyo Night, matching the tmux popups.
13const THEME = {
14  fg: '#c0caf5',
15  muted: '#565f89',
16  border: '#3b4261',
17  blue: '#7aa2f7',
18  cyan: '#7dcfff',
19  green: '#9ece6a',
20  yellow: '#e0af68',
21  red: '#f7768e',
22  purple: '#bb9af7',
23} as const
24
25const TICKET = /\b[A-Z][A-Z0-9]+-\d+\b/g
26const MINUTE = 60_000
27const HOUR = 60 * MINUTE
28const DAY = 24 * HOUR
29
30function age(ms: number): string {
31  if (ms < MINUTE) return 'just now'
32  if (ms < HOUR) return `${Math.floor(ms / MINUTE)}m ago`
33  if (ms < DAY) return `${Math.floor(ms / HOUR)}h ago`
34  return `${Math.floor(ms / DAY)}d ago`
35}
36
37// Splits text around ticket keys (JIRA-123) so they can be highlighted.
38function splitTickets(text: string): { text: string; isTicket: boolean }[] {
39  const parts: { text: string; isTicket: boolean }[] = []
40  let last = 0
41  for (const match of text.matchAll(TICKET)) {
42    const at = match.index ?? 0
43    if (at > last) parts.push({ text: text.slice(last, at), isTicket: false })
44    parts.push({ text: match[0], isTicket: true })
45    last = at + match[0].length
46  }
47  if (last < text.length) parts.push({ text: text.slice(last), isTicket: false })
48  return parts
49}
50
51const todos = atom({ plugin: 'later', key: 'todos' } as const, [])
52const root = atom({ plugin: 'later', key: 'root' } as const, '')
53const file = atom({ plugin: 'later', key: 'file' } as const, '')
54
55// The file's mtime as this session last read or wrote it.
56let seenMtime = 0
57
58// The repo's top level, so a subdirectory shares its repo's list; else the cwd.
59async function findRoot($: EngineInterface): Promise<string> {
60  try {
61    const git = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
62    if (git.exitCode === 0 && git.stdout.trim() !== '') return git.stdout.trim()
63  } catch {
64    // git missing or refused: fall back to the session's directory
65  }
66  return $.session.cwd()
67}
68
69async function openPane($: EngineInterface, count: number) {
70  return $.ui.open({ id: PANE, title: `Later (${count})` })
71}
72
73async function isPaneOpen($: EngineInterface): Promise<boolean> {
74  return (await $.ui.panes()).some(pane => pane.id === PANE)
75}
76
77// Shows a list: opens the pane when it grew, retitles it, closes it when empty.
78async function show($: EngineInterface, before: readonly Todo[], after: Todo[]) {
79  await update($, todos, () => after)
80  if (after.length === 0) await $.ui.close({ id: PANE })
81  else if (after.length > before.length || (await isPaneOpen($))) await openPane($, after.length)
82}
83
84// Read-modify-write against the file, so another session's changes are kept.
85async function mutate($: EngineInterface, change: (list: Todo[]) => Todo[]) {
86  const path = await read($, file)
87  const before = await loadTodos($, path)
88  const after = change(before)
89  await saveTodos($, path, await read($, root), after)
90  seenMtime = await mtimeOf($, path)
91  await show($, await read($, todos), after)
92  return { before, after }
93}
94
95// Picks up what other sessions wrote since this one last looked.
96async function sync($: EngineInterface) {
97  const path = await read($, file)
98  const mtime = await mtimeOf($, path)
99  if (mtime === seenMtime) return
100  seenMtime = mtime
101  try {
102    await show($, await read($, todos), await loadTodos($, path))
103  } catch (error) {
104    $.ui.log(`later: ${(error as Error).message}`)
105  }
106}
107
108// Moves a list kept by the first version of this mod into the file.
109async function migrate($: EngineInterface, dir: string) {
110  const legacy = await $.store.get(LEGACY_PREFIX + dir)
111  if (!Array.isArray(legacy) || legacy.length === 0) return
112  await mutate($, list => [...list, ...(legacy as Todo[])])
113  await $.store.delete(LEGACY_PREFIX + dir)
114}
115
116export const register: Register = on => {
117  on('session.start', async ($, e, next) => {
118    await $.command.register({
119      name: 'later',
120      description: 'Stash a task for this repo without running it; bare /later toggles the list',
121      argumentHint: '[task]',
122      immediate: true,
123    })
124
125    const dir = await findRoot($)
126    await update($, root, () => dir)
127    const path = await storagePath($, dir)
128    await update($, file, () => path)
129    await migrate($, dir)
130    await sync($)
131    $.clock.every(POLL_MS, () => void sync($))
132
133    return next(e)
134  })
135
136  on('command.run', { command: 'later' }, async ($, e) => {
137    const text = e.args.trim()
138
139    // Bare /later toggles the pane; it opens on its empty state too.
140    if (text === '') {
141      if (await isPaneOpen($)) {
142        await $.ui.close({ id: PANE })
143        return { text: 'Later pane closed.' }
144      }
145      await sync($)
146      const list = await read($, todos)
147      await openPane($, list.length)
148      return { text: list.length === 0 ? 'Nothing stashed for this repo.' : `${list.length} stashed for later.` }
149    }
150
151    const todo: Todo = { id: crypto.randomUUID(), text, createdAt: await $.clock.now() }
152    try {
153      const { after } = await mutate($, list => [...list, todo])
154      await openPane($, after.length)
155      return { text: `Stashed for later (${after.length} pending).` }
156    } catch (error) {
157      return { text: `Not stashed: ${(error as Error).message}` }
158    }
159  })
160
161  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162    const { Box, Button, Text } = $.ui.resolve(e)
163    const list = await read($, todos)
164    const repo = (await read($, root)).split('/').pop() || '~'
165    const now = await $.clock.now()
166
167    const drop = (id: string) => mutate($, list => list.filter(todo => todo.id !== id))
168    // Only the session whose write removed the task starts it.
169    const start = async (id: string) => {
170      const { before } = await drop(id)
171      const todo = before.find(one => one.id === id)
172      if (todo !== undefined) await $.prompt.submit({ text: todo.text, asUser: true })
173    }
174
175    const header = (
176      <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
177        <Text>
178          <Text color={THEME.purple} bold>{'󰃀 later'}</Text>
179          <Text color={THEME.muted}>{'  '}</Text>
180          <Text color={THEME.cyan}>{repo}</Text>
181        </Text>
182        <Text color={list.length > 0 ? THEME.yellow : THEME.green}>
183          {list.length > 0 ? `${list.length} pending` : 'clear'}
184        </Text>
185      </Box>
186    )
187
188    if (list.length === 0) {
189      return (
190        <Box flexDirection="column" gap={1}>
191          {header}
192          <Box paddingX={1} flexDirection="column">
193            <Text color={THEME.green}>{'✓ Nothing stashed for this repo'}</Text>
194            <Text color={THEME.muted}>{'/later <task> to save one for later'}</Text>
195          </Box>
196        </Box>
197      )
198    }
199
200    const card = (todo: Todo, index: number) => (
201      <Box
202        key={todo.id}
203        flexDirection="row"
204        gap={1}
205        paddingX={1}
206        borderStyle="round"
207        borderColor={THEME.border}
208        hover={{ borderColor: THEME.blue }}
209      >
210        <Text color={THEME.blue} bold>
211          {String(index + 1).padStart(2)}
212        </Text>
213        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
214          <Text color={THEME.fg} wrap="wrap">
215            {splitTickets(todo.text).map(part =>
216              part.isTicket ? (
217                <Text color={THEME.yellow} bold>
218                  {part.text}
219                </Text>
220              ) : (
221                part.text
222              ),
223            )}
224          </Text>
225          <Box flexDirection="row" justifyContent="space-between">
226            <Text color={THEME.muted}>{age(now - todo.createdAt)}</Text>
227            <Box flexDirection="row" gap={1}>
228              <Button
229                key={`start-${todo.id}`}
230                label="▶ Start"
231                variant="primary"
232                hotkey={index < MAX_HOTKEYS ? String(index + 1) : undefined}
233                onPress={() => start(todo.id)}
234              />
235              <Button
236                key={`drop-${todo.id}`}
237                plain
238                hover={{ color: THEME.red }}
239                onPress={() => drop(todo.id)}
240              >
241                <Text color={THEME.muted}>{'✕'}</Text>
242              </Button>
243            </Box>
244          </Box>
245        </Box>
246      </Box>
247    )
248
249    return (
250      <Box flexDirection="column">
251        {header}
252        {list.map(card)}
253        <Box paddingX={1}>
254          <Text color={THEME.muted}>
255            <Text color={THEME.blue}>{'1-9'}</Text>
256            {' start · '}
257            <Text color={THEME.blue}>{'tab'}</Text>
258            {' walk · '}
259            <Text color={THEME.blue}>{'/later <task>'}</Text>
260            {' add'}
261          </Text>
262        </Box>
263      </Box>
264    )
265  })
266}
267
hooks/storage.ts 46 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { Todo } from '../types'
4
5// One JSON file per repo under ~/.claude/later, shared by every session.
6export async function storagePath($: EngineInterface, root: string): Promise<string> {
7  const home = (await $.env.get('HOME')) ?? '/tmp'
8  const slug = root.replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'root'
9  return `${home}/.claude/later/${slug}.json`
10}
11
12export async function mtimeOf($: EngineInterface, file: string): Promise<number> {
13  if (!(await $.fs.exists(file))) return 0
14  return (await $.fs.stat(file)).mtimeMs
15}
16
17function isTodo(value: unknown): value is Todo {
18  const todo = value as Todo
19  return typeof todo?.id === 'string' && typeof todo.text === 'string'
20}
21
22// Throws on a file that is not valid JSON, so a hand edit is never overwritten.
23export async function loadTodos($: EngineInterface, file: string): Promise<Todo[]> {
24  if (!(await $.fs.exists(file))) return []
25  const text = await $.fs.read(file)
26  let data: { todos?: unknown }
27  try {
28    data = JSON.parse(text)
29  } catch {
30    throw new Error(`${file} is not valid JSON; fix or delete it`)
31  }
32  if (!Array.isArray(data?.todos)) return []
33  return data.todos
34    .filter(isTodo)
35    .map(todo => ({ ...todo, createdAt: Number(todo.createdAt) || 0 }))
36}
37
38export async function saveTodos(
39  $: EngineInterface,
40  file: string,
41  root: string,
42  todos: readonly Todo[],
43): Promise<void> {
44  await $.fs.write(file, JSON.stringify({ root, todos }, null, 2) + '\n')
45}
46
types/index.d.ts 8 lines
1export type Todo = { id: string; text: string; createdAt: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    later: { todos: Todo[]; root: string; file: string }
6  }
7}
8