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

Claude Code mods, in Tokyo Night colours.
| Mod | What |
|---|---|
hud | one-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 |
/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.
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
MIT
hooks/register.tsx 267 lines1import { 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}
267hooks/storage.ts 46 lines1import 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}
46types/index.d.ts 8 lines1export 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