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

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.
| Command | What it does |
|---|---|
/k [more] | Sends "keep going" (or "keep going. more") as your own words. Mid-turn it queues for when the turn ends. |
/links | Every 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. |
/now | A 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.
hooks/register.ts 318 lines1import 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