SLOPSHOPPER

defer

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

newpanebandcommandprompt
v0.1.0no licenseupdated 2026-10-11kdaisho/claude-skills/defer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · defer
│ ┃ defer-picker ✕ › fix the failing auth test and add an audit log call │ ┃ No saved questions. │ ┃ Press a number or click to pick · Esc closes ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /defer │ ⎿ defer: No saved questions. Save one with /defer <question>. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · defer-picker
No saved questions. Press a number or click to pick · Esc closes
README

claude-skills

Reusable Claude Code skills, and a few mods.

Skills

Copy (or symlink) a skill folder into a project's .claude/skills/ to use it.

  • implement
  • skeleton-ui

Mods

book-pager

Shows 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.
  • The text fills the pane, so drag the pane's edge to change the page width. A table is drawn with borders when it fits, with long cells wrapped inside their column, and as one card per row when even its narrowest columns do not fit.
  • The pane opens at the 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 } } }

defer

Saves a question for later without sending it to Claude.

  • Save: /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.
  • Pick: /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.
  • A line above the prompt shows "2 saved questions · /defer to pick". /differ does the same as /defer.
  • Each session has its own list. It survives compaction, restarts and /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).

notify

Raises 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.

Loading the mods

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.

Source 2 files
hooks/register.tsx 277 lines
1import { 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};
277
types/index.d.ts 13 lines
1// 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