SLOPSHOPPER

quick

Instant commands that run without a Claude turn: /k, /links, /yank, /now.

newpanespinnerguardcommandtoast
v0.1.0MITupdated 2026-10-02DominickGiordano/claude-mods/quick
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · quick
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ quick │ ⏺ Read(src/auth.ts) │ copied the whole last reply (no code │ ⎿ Read 6 lines │ block), 71 chars: I updated src/auth.ts to │ ⏺ Update(src/auth.ts) │ reject expired claims and │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ quick │ │ idle, last turn took 42s · last tool Bash: │ ● Done. refresh now rejects expired claims and logs an audit event. │ cat .env · context 49% │ ╰────────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM › /k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

quick

Slash commands that answer on the spot, with no Claude turn of their own. All four can be typed mid-turn: /k queues, /yank copies from the last completed reply, /links lists what the transcript holds so far, and /now reports the running turn.

CommandWhat it does
/k [more]Sends "keep going" (or "keep going. more") as your own words. Mid-turn it queues for when the turn ends.
/linksEvery URL in this session's messages and Bash/WebFetch output, newest first, grouped: PRs (owner/repo#n), other GitHub, claude.ai artifacts, the rest. A pane with Copy per row and Copy all.
/yank [n]Copies the last code block of Claude's latest reply, or the nth from last. No code block: the whole reply. An interrupted reply's unclosed block is copied as a partial block. Copying out of the terminal picks up gutter characters; this doesn't.
/nowA toast: turn running and for how long, the running or last tool, running subagents, context %.

Turn and tool state is tracked from the moment the mod loads. Right after a reload, /now says it hasn't seen a turn until the prompt hint or a turn event tells it otherwise.

Source 1 files
hooks/register.ts 318 lines
1import type { EngineInterface, On, RenderSurface, SessionMessage } from 'claude-code'
2
3const PANE = 'quick-links'
4
5const COMMANDS = [
6  { name: 'k', description: 'Send "keep going" now, or queue it for when the turn ends', argumentHint: '[more]' },
7  { name: 'links', description: "Every URL in this session's messages, with copy buttons" },
8  { name: 'yank', description: "Copy the last code block of Claude's latest reply", argumentHint: '[n]' },
9  { name: 'now', description: "What's happening: turn, tool, subagents, context" },
10]
11
12export type Group = 'PRs' | 'GitHub' | 'Artifacts' | 'Other'
13export type Link = { url: string; label: string; group: Group }
14const GROUPS: Group[] = ['PRs', 'GitHub', 'Artifacts', 'Other']
15
16// Brackets, quotes and backticks end a URL; parentheses are trimmed only when unbalanced,
17// so a markdown link loses its closer and a Wikipedia-style path keeps its own.
18const URL_RE = /https?:\/\/[^\s<>"'`[\]{}]+/g
19const PR_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/|$)/
20// Other tools' output is file contents and search hits: links in code, not links Claude gave you.
21const URL_TOOLS = new Set(['Bash', 'WebFetch'])
22const AGENT_TOOLS = new Set(['Agent', 'Task'])
23
24function trimUrl(raw: string): string {
25  let url = raw
26  for (;;) {
27    const bare = url.replace(/[.,;:!?*]+$/, '')
28    const isUnbalanced = bare.endsWith(')') && bare.split(')').length > bare.split('(').length
29    const trimmed = isUnbalanced ? bare.slice(0, -1) : bare
30    if (trimmed === url) return url
31    url = trimmed
32  }
33}
34
35function classify(raw: string): Link | null {
36  const url = trimUrl(raw)
37  if (!URL.canParse(url)) return null
38  const { hostname, pathname } = new URL(url)
39  const pr = hostname === 'github.com' ? PR_RE.exec(pathname) : null
40  if (pr) {
41    const [, owner, repo, n] = pr
42    return { url: `https://github.com/${owner}/${repo}/pull/${n}`, label: `${owner}/${repo}#${n}`, group: 'PRs' }
43  }
44  if (hostname === 'github.com' || hostname.endsWith('.github.com')) return { url, label: url, group: 'GitHub' }
45  if (hostname === 'claude.ai' && /\/artifacts?\//.test(pathname)) return { url, label: url, group: 'Artifacts' }
46  return { url, label: url, group: 'Other' }
47}
48
49/** Newest first, deduped; a PR's /files and /checks pages fold into the PR. */
50export function collectLinks(messages: SessionMessage[]): Link[] {
51  const seen = new Set<string>()
52  const links: Link[] = []
53  for (const m of [...messages].reverse()) {
54    const outputs = m.toolUses.filter(t => URL_TOOLS.has(t.tool)).map(t => t.text ?? '')
55    for (const text of [m.text, ...outputs]) {
56      for (const raw of (text.match(URL_RE) ?? []).reverse()) {
57        const link = classify(raw)
58        if (!link) continue
59        const key = link.url.replace(/\/$/, '')
60        if (seen.has(key)) continue
61        seen.add(key)
62        links.push(link)
63      }
64    }
65  }
66  return links
67}
68
69// Prompts typed or delivered while a turn ran land inside that turn's messages.
70const deliveredMidTurn = new Set<string>()
71
72// Notifications, reminders and interrupt markers are user-role rows the engine writes.
73function isPrompt(m: SessionMessage): boolean {
74  if (m.role !== 'user' || m.toolResults?.length) return false
75  const text = m.text.trimStart()
76  return !text.startsWith('<') && !text.startsWith('[Request interrupted') && !deliveredMidTurn.has(m.text)
77}
78
79/** Claude's reply to the person's last prompt, or with `skip` to the one that many prompts back. */
80export function latestReply(messages: SessionMessage[], skip = 0): string {
81  let parts: string[] = []
82  for (const m of [...messages].reverse()) {
83    if (isPrompt(m)) {
84      if (skip-- === 0) break
85      parts = []
86    }
87    if (m.role === 'assistant' && m.text) parts.unshift(m.text)
88  }
89  return skip > 0 ? '' : parts.join('\n\n')
90}
91
92export type Block = { text: string; isPartial: boolean }
93
94/** Fenced blocks as CommonMark reads them: a closer at least as long, the opener's indent removed. */
95export function codeBlocks(text: string): Block[] {
96  const blocks: Block[] = []
97  let open: { indent: number; fence: string; lines: string[] } | null = null
98  for (const line of text.split('\n')) {
99    if (!open) {
100      const m = /^( *)(`{3,}|~{3,})([^`]*)$/.exec(line)
101      if (m) open = { indent: m[1]!.length, fence: m[2]!, lines: [] }
102      continue
103    }
104    const close = /^ *(`{3,}|~{3,}) *$/.exec(line)?.[1]
105    if (close && close[0] === open.fence[0] && close.length >= open.fence.length) {
106      blocks.push({ text: open.lines.join('\n'), isPartial: false })
107      open = null
108      continue
109    }
110    const indent = /^ */.exec(line)![0].length
111    open.lines.push(line.slice(Math.min(indent, open.indent)))
112  }
113  if (open) blocks.push({ text: open.lines.join('\n'), isPartial: true })
114  return blocks
115}
116
117const ARG_KEYS = ['command', 'file_path', 'pattern', 'url', 'query', 'skill', 'description', 'prompt']
118
119function toolLabel(e: { tool: string }): string {
120  const fields: Record<string, unknown> = { ...e }
121  const arg = ARG_KEYS.map(k => fields[k]).find(v => typeof v === 'string')
122  return typeof arg === 'string' ? `${e.tool}: ${arg.split('\n')[0]!.slice(0, 40)}` : e.tool
123}
124
125function duration(ms: number): string {
126  const s = Math.round(ms / 1000)
127  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`
128}
129
130const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
131
132// null until a turn event or the prompt hint says: a reload mid-turn must not claim idle.
133let isRunning: boolean | null = null
134let turnStartedAt: number | null = null
135let lastTurn: { ms: number; isAborted: boolean } | null = null
136let lastTool: string | null = null
137const runningTools = new Map<string, string>()
138let links: Link[] = []
139
140async function now($: EngineInterface): Promise<string> {
141  const parts: string[] = []
142  if (isRunning === null) parts.push('no turn seen since quick loaded')
143  else if (isRunning) {
144    parts.push(turnStartedAt === null
145      ? 'turn running (started before quick loaded)'
146      : `turn running ${duration((await $.clock.now()) - turnStartedAt)}`)
147  } else if (!lastTurn) parts.push('idle, no turn seen since quick loaded')
148  else parts.push(`idle, last turn ${lastTurn.isAborted ? 'interrupted after' : 'took'} ${duration(lastTurn.ms)}`)
149
150  if (runningTools.size) parts.push(`running ${[...runningTools.values()].join(', ')}`)
151  else if (lastTool) parts.push(`last tool ${lastTool}`)
152
153  const agents = (await $.agent.list()).filter(a => a.status === 'running')
154  if (agents.length) {
155    const names = agents.slice(0, 3).map(a => a.description).join(', ')
156    parts.push(`${plural(agents.length, 'subagent')}: ${names}${agents.length > 3 ? ` +${agents.length - 3}` : ''}`)
157  }
158
159  const { context } = await $.session.usage()
160  parts.push(context.percent === undefined ? 'context unknown' : `context ${context.percent}%`)
161  return parts.join(' · ')
162}
163
164async function yank($: EngineInterface, args: string) {
165  const n = args.trim() ? Number(args.trim()) : 1
166  if (!Number.isInteger(n) || n < 1) return $.ui.toast('usage: /yank [n], n counts code blocks back from the last')
167  // Mid-turn the newest reply is still being written, so take the last finished one.
168  const isMidTurn = isRunning === true
169  const reply = latestReply(await $.session.messages(), isMidTurn ? 1 : 0)
170  const which = isMidTurn ? 'last completed reply' : 'last reply'
171  if (!reply) return $.ui.toast(`yank: no ${which} from Claude yet`)
172
173  const blocks = codeBlocks(reply)
174  if (n > blocks.length && blocks.length) return $.ui.toast(`yank: the ${which} has ${plural(blocks.length, 'code block')}`)
175  const block = blocks[blocks.length - n]
176  const text = block ? block.text : reply
177  const what = block
178    ? `${block.isPartial ? 'partial block' : 'block'} ${blocks.length - n + 1}/${blocks.length} of the ${which}`
179    : `the whole ${which} (no code block)`
180
181  const copied = await $.ui.copy({ text })
182  if (!copied.isCopied) return $.ui.toast(`yank: not copied: ${copied.reason}`)
183  $.ui.toast(`copied ${what}, ${text.length} chars: ${text.split('\n')[0]!.slice(0, 50)}`)
184}
185
186async function submitK($: EngineInterface, text: string, isAnnounced: boolean) {
187  try {
188    const r = await $.prompt.submit({ text, asUser: true })
189    if (r.drop !== undefined) return $.ui.toast(`/k not sent: ${r.drop}`)
190    if (!isAnnounced) $.ui.toast(`sent: ${text}`)
191  } catch (err) {
192    $.ui.toast(`/k failed: ${err instanceof Error ? err.message : String(err)}`)
193  }
194}
195
196async function copyLink($: EngineInterface, text: string, label: string, surface: RenderSurface) {
197  const copied = await $.ui.copy({ text, surface })
198  $.ui.toast(copied.isCopied ? `copied ${label}` : `not copied: ${copied.reason}`)
199}
200
201export function register(on: On) {
202  on('session.start', async ($, e, next) => {
203    for (const spec of COMMANDS) {
204      // A refused name (one a built-in takes later) must not take the other commands with it.
205      try {
206        await $.command.register({ ...spec, immediate: true })
207      } catch (err) {
208        $.ui.log(`quick: /${spec.name} not registered: ${err instanceof Error ? err.message : String(err)}`)
209      }
210    }
211    return next(e)
212  })
213
214  // Seeds the turn state after a reload; turn.start and turn.complete keep it from then on.
215  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
216    isRunning ??= e.props.isWorking
217    return next(e)
218  })
219
220  on('prompt.submit', ($, e, next) => {
221    if (e.turnId !== undefined) deliveredMidTurn.add(e.text)
222    return next(e)
223  })
224
225  on('turn.start', async ($, e, next) => {
226    isRunning = true
227    turnStartedAt = await $.clock.now()
228    deliveredMidTurn.delete(e.text)
229    runningTools.clear()
230    return next(e)
231  })
232
233  on('turn.complete', ($, e, next) => {
234    if (e.agentId !== undefined) return next(e)
235    isRunning = false
236    turnStartedAt = null
237    lastTurn = { ms: e.durationMs, isAborted: e.isAborted }
238    runningTools.clear()
239    return next(e)
240  })
241
242  on('tool.call', async ($, e, next) => {
243    // A running Agent call shows as its subagent instead.
244    if (e.agentId !== undefined || AGENT_TOOLS.has(e.tool)) return next(e)
245    lastTool = toolLabel(e)
246    runningTools.set(e.tool_use_id, lastTool)
247    try {
248      return await next(e)
249    } finally {
250      runningTools.delete(e.tool_use_id)
251    }
252  })
253
254  on('command.run', { command: 'k' }, ($, e) => {
255    const more = e.args.trim()
256    const text = more ? `keep going. ${more}` : 'keep going'
257    if (isRunning) $.ui.toast(`queued for when the turn ends: ${text}`)
258    else if (isRunning === null) $.ui.toast(`submitted; runs when the session is idle: ${text}`)
259    // The engine refuses a submit from inside command.run (it would wait on the turn the
260    // hook holds), so it goes from a timer, outside any event; it then waits for idle.
261    const isAnnounced = isRunning !== false
262    $.clock.after(0, () => submitK($, text, isAnnounced))
263    return {}
264  })
265
266  on('command.run', { command: 'yank' }, async ($, e) => {
267    await yank($, e.args)
268    return {}
269  })
270
271  on('command.run', { command: 'now' }, async $ => {
272    $.ui.toast(await now($), { timeoutMs: 8000 })
273    return {}
274  })
275
276  on('command.run', { command: 'links' }, async $ => {
277    links = collectLinks(await $.session.messages())
278    if (!links.length) {
279      $.ui.toast("links: no URLs in this session's messages")
280      return {}
281    }
282    $.ui.invalidate('ui.render')
283    const rows = Math.min(links.length + 2 * GROUPS.length + 2, 24)
284    await $.ui.open({ id: PANE, title: `Links (${links.length})`, focus: true, closeOnEscape: true, rows })
285    return {}
286  })
287
288  on('ui.render', { component: 'Pane' }, ($, e, next) => {
289    if (e.requestId !== PANE) return next(e)
290    const { Box, Text, Button } = $.ui.resolve(e)
291    const sections = GROUPS.flatMap(group => {
292      const rows = links.map((link, i) => ({ link, i })).filter(r => r.link.group === group)
293      if (!rows.length) return []
294      return [Box({
295        key: group, flexDirection: 'column', marginTop: 1,
296        children: [
297          Text({ bold: true, children: `${group} (${rows.length})` }),
298          ...rows.map(({ link, i }) => Box({
299            key: `row-${i}`, flexDirection: 'row', gap: 1,
300            children: [
301              Button({ key: `copy-${i}`, label: 'Copy', onPress: press => copyLink($, link.url, link.label, press.surface) }),
302              Text({ wrap: 'truncate-middle', children: link.label }),
303            ],
304          })),
305        ],
306      })]
307    })
308    const all = links.map(l => l.url).join('\n')
309    return Box({
310      flexDirection: 'column', paddingX: 1,
311      children: [
312        Button({ key: 'copy-all', label: 'Copy all', onPress: press => copyLink($, all, `${links.length} links`, press.surface) }),
313        ...sections,
314      ],
315    })
316  })
317}
318