Saves a question for later without sending it to Claude, and puts it back in the prompt when picked

Reusable Claude Code skills, and a few mods.
Copy (or symlink) a skill folder into a project's .claude/skills/ to use it.
implementskeleton-uibook-pagerShows Claude's finished replies in a side pane as fixed pages with short lines, so they are easier to read in a wide terminal. Each session keeps its own book, and a restart or /resume brings it back.
To load it in every session, add this to ~/.claude/settings.json (inside the "env" block if there already is one), then start a new session:
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/code/claude-skills/book-pager" }
/pager shows or hides the pane.p / n turn the page when the pane has focus. With the prompt empty, , and . turn it from the prompt.width setting, in terminal columns of text (default 76, 20 to 200). Change "Page width" in /config, or set it in ~/.claude/settings.json and restart: "pluginConfigs": { "book-pager": { "options": { "width": 90 } } }
deferSaves a question for later without sending it to Claude.
/defer <question>, or end a prompt with /defer. Both work while Claude is busy. The prompt form never reaches Claude, and the command form keeps the question out of the conversation./defer with no text opens a list. Press 1, 2, and so on, or click. The question goes into the prompt box and leaves the list./differ does the same as /defer./resume, and /clear starts an empty one. A list is saved in $.store under items:<session id>, and is removed when it becomes empty, when it has not changed for 30 days, or when more than 50 lists are saved (the one changed longest ago goes first).notifyRaises a desktop notification, "<session> is done", when Claude finishes a reply. The name is the /rename title, else the folder's name. It goes through Claude Code's notification channel: set "preferredNotifChannel": "ghostty" in ~/.claude/settings.json (or auto in Ghostty). Claude Code raises its own notification when it waits for an answer.
A Notification hook also hears this notification, with notification_type plugin_notification, so a voice hook can skip it.
Separate the folders with ::
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/code/claude-skills/book-pager:~/code/claude-skills/defer:~/code/claude-skills/notify" }
The mod API is early access. book-pager was built on Claude Code 2.1.289; defer and notify on 2.1.296. Setup steps, the design and what was tested are in the design note.
hooks/register.tsx 277 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { DeferList, SavedList } from "../types";
5
6const DAY_MS = 24 * 60 * 60 * 1000;
7// Claude Code deletes a session after `cleanupPeriodDays`, 30 by default, so a list older than that has nothing to
8// open it.
9const MAX_AGE_MS = 30 * DAY_MS;
10// Counts sessions, not questions. One session's list has no limit.
11const MAX_LISTS = 50;
12const KEY_PREFIX = "items:";
13const listKey = (sessionId: string) => `${KEY_PREFIX}${sessionId}`;
14
15const EMPTY_LIST: DeferList = { sessionId: "", items: [] };
16const heldList = atom({ plugin: "defer", key: "list" } as const, EMPTY_LIST);
17
18function toSaved(value: unknown): SavedList | undefined {
19 if (Array.isArray(value)) {
20 return { items: value.filter((item) => typeof item === "string") };
21 }
22 if (typeof value !== "object" || value === null) {
23 return undefined;
24 }
25 const { items, changedAt } = value as Partial<SavedList>;
26
27 return {
28 items: Array.isArray(items) ? items : [],
29 ...(typeof changedAt === "number" ? { changedAt } : {}),
30 };
31}
32
33// The running session's list: the copy in $.state, or the saved one when that copy is another session's.
34async function loadList(
35 $: EngineInterface,
36 sessionId?: string,
37): Promise<DeferList> {
38 sessionId ??= await $.session.id();
39 const held = await read($, heldList);
40 if (held.sessionId === sessionId) {
41 return held;
42 }
43 const saved = toSaved(await $.store.get(listKey(sessionId)));
44
45 return { sessionId, items: saved?.items ?? [] };
46}
47
48// Removes empty lists, lists not changed for 30 days, and past 50 lists the ones changed longest ago.
49async function prune($: EngineInterface) {
50 const now = await $.clock.now();
51 const kept: { key: string; changedAt: number }[] = [];
52 for (const key of await $.store.keys()) {
53 if (!key.startsWith(KEY_PREFIX)) {
54 continue;
55 }
56 const saved = toSaved(await $.store.get(key));
57 if (
58 saved === undefined ||
59 saved.items.length === 0 ||
60 now - (saved.changedAt ?? now) > MAX_AGE_MS
61 ) {
62 await $.store.delete(key);
63 continue;
64 }
65 if (saved.changedAt === undefined) {
66 await $.store.set(key, { ...saved, changedAt: now });
67 }
68 kept.push({ key, changedAt: saved.changedAt ?? now });
69 }
70 kept.sort((a, b) => a.changedAt - b.changedAt);
71 for (const { key } of kept.slice(0, Math.max(0, kept.length - MAX_LISTS))) {
72 await $.store.delete(key);
73 }
74}
75
76// Puts the list in $.state, which redraws the band and the picker, and saves it in $.store.
77async function setList($: EngineInterface, list: DeferList) {
78 await update($, heldList, () => list);
79 if (list.items.length === 0) {
80 await $.store.delete(listKey(list.sessionId));
81 } else {
82 await $.store.set(listKey(list.sessionId), {
83 items: list.items,
84 changedAt: await $.clock.now(),
85 } satisfies SavedList);
86 }
87 await prune($);
88}
89
90// Opening or resuming a session counts as a change, so a list in a session still in use is kept.
91async function openSession($: EngineInterface, sessionId: string) {
92 const list = await loadList($, sessionId);
93 await update($, heldList, () => list);
94 if (list.items.length > 0) {
95 await $.store.set(listKey(sessionId), {
96 items: list.items,
97 changedAt: await $.clock.now(),
98 } satisfies SavedList);
99 }
100 await prune($);
101}
102
103const PICKER = "defer-picker";
104// `/differ` is the same command, for the typo.
105const COMMANDS = ["defer", "differ"];
106// A prompt that ends with `/defer` is saved in place of being sent.
107const TRAILING = /^([\s\S]*\S)\s+\/(?:defer|differ)$/;
108// The record of a `/defer <question>` run, which the model reads on its next turn.
109const COMMAND_RECORD = /<command-name>\/(?:defer|differ)<\/command-name>/;
110const COMMAND_ARGS = /<command-args>[\s\S]*?<\/command-args>/g;
111// Picker keys, in list order. A question past these is picked with a click.
112const HOTKEYS = "123456789abcdefghijklmnopqrstuvwxyz";
113// The rows around the questions in the picker: the hint line.
114const PICKER_EXTRA_ROWS = 1;
115
116const plural = (count: number) =>
117 `${count} saved question${count === 1 ? "" : "s"}`;
118
119// A question as one line of the picker, cut with "…" to the pane width.
120function oneLine(text: string, columns: number): string {
121 const line = text.replace(/\s+/g, " ").trim();
122 const room = Math.max(8, columns);
123
124 return line.length > room ? `${line.slice(0, room - 1)}…` : line;
125}
126
127async function save($: EngineInterface, question: string): Promise<string> {
128 const list = await loadList($);
129 const items = [...list.items, question];
130 await setList($, { ...list, items });
131
132 return `Saved for later · ${plural(items.length)} · /defer to pick`;
133}
134
135async function openPicker($: EngineInterface): Promise<string> {
136 const list = await loadList($);
137 if (list.items.length === 0) {
138 return "No saved questions. Save one with /defer <question>.";
139 }
140 await $.ui.open({
141 id: PICKER,
142 title: "Saved questions",
143 focus: true,
144 closeOnEscape: true,
145 holdToasts: true,
146 rows: list.items.length + PICKER_EXTRA_ROWS,
147 });
148
149 return "Pick a saved question.";
150}
151
152// The picked question leaves the list and goes into the prompt box, after any draft already there.
153async function pick($: EngineInterface, index: number) {
154 const list = await loadList($);
155 const question = list.items[index];
156 if (question === undefined) {
157 return;
158 }
159 await setList($, {
160 ...list,
161 items: list.items.filter((_, at) => at !== index),
162 });
163 // The box takes no text while the picker holds it as a dialog.
164 await $.ui.close({ id: PICKER });
165 const draft = await $.prompt.read();
166 await $.prompt.fill(
167 draft.text.trim() === ""
168 ? { text: question }
169 : { text: `\n${question}`, mode: "append" },
170 );
171}
172
173
174export const register: Register = (on) => {
175 on("session.start", async ($, e, next) => {
176 for (const name of COMMANDS) {
177 await $.command.register({
178 name,
179 description: "Save a question for later, or pick a saved one",
180 argumentHint: "[question]",
181 // Runs mid-turn, so a question can be saved while Claude is busy.
182 immediate: true,
183 });
184 }
185 // Saves a list that an open session held in $.state alone before this load.
186 await openSession($, await $.session.id());
187
188 return next(e);
189 });
190
191 // A restart with --resume or --continue, /resume and /clear go on under another session id.
192 on("classic.SessionStart", async ($, e, next) => {
193 await openSession($, e.session_id);
194
195 return next(e);
196 });
197
198 // A prompt typed while Claude works is caught at Enter too, before it is queued.
199 on("prompt.submit", async ($, e, next) => {
200 const trailing = TRAILING.exec(e.text.trim());
201 if (trailing?.[1] !== undefined) {
202 return { drop: await save($, trailing[1].trim()) };
203 }
204
205 return next(e);
206 }).catch(($, e, next) => {
207 // A question meant for later never reaches Claude, even when saving it failed.
208 if (next.called || !TRAILING.test(e.text.trim())) {
209 return next(e);
210 }
211
212 return { drop: "defer: could not save the question. It was not sent." };
213 });
214
215 for (const command of COMMANDS) {
216 on("command.run", { command }, async ($, e) => {
217 const question = e.args.trim();
218
219 return { text: await (question === "" ? openPicker($) : save($, question)) };
220 });
221 }
222
223 // The command's record keeps its name but loses the question, so Claude does not read it.
224 on("session.append", { door: "command" }, ($, e, next) => {
225 const isDefer = e.message.content.some(
226 (block) => block.type === "text" && COMMAND_RECORD.test(block.text),
227 );
228 if (!isDefer) {
229 return next(e);
230 }
231 const content = e.message.content.map((block) =>
232 block.type === "text"
233 ? { ...block, text: block.text.replace(COMMAND_ARGS, "<command-args></command-args>") }
234 : block,
235 );
236
237 return next({ ...e, message: { ...e.message, content } });
238 });
239
240 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
241 const list = await loadList($);
242 if (e.props.hasSurvey || list.items.length === 0) {
243 return next(e);
244 }
245 const { Text } = $.ui.resolve(e);
246
247 return (
248 <Text dimColor>{`${plural(list.items.length)} · /defer to pick`}</Text>
249 );
250 });
251
252 on("ui.render", { component: "Pane", requestId: PICKER }, async ($, e) => {
253 const { Box, Text, Button } = $.ui.resolve(e);
254 const list = await loadList($);
255 // The hotkey's "1: " before each label.
256 const columns = e.props.bodyColumns - 3;
257
258 return (
259 <Box flexDirection="column">
260 {list.items.length === 0 && <Text dimColor>No saved questions.</Text>}
261 {list.items.map((item, index) => (
262 <Button
263 key={`item-${index}`}
264 plain
265 label={oneLine(item, columns)}
266 onPress={() => pick($, index)}
267 {...(HOTKEYS[index] === undefined
268 ? {}
269 : { hotkey: HOTKEYS[index] })}
270 />
271 ))}
272 <Text dimColor>Press a number or click to pick · Esc closes</Text>
273 </Box>
274 );
275 });
276};
277types/index.d.ts 13 lines1// One session's saved questions. `sessionId` says which session the copy in $.state belongs to.
2export type DeferList = { sessionId: string; items: string[] };
3// A list as $.store keeps it. `changedAt` is in milliseconds; a list saved without it gets one when next checked.
4export type SavedList = { items: string[]; changedAt?: number };
5
6declare module "claude-code" {
7 interface PluginState {
8 defer: {
9 list: DeferList;
10 };
11 }
12}
13