Ask about a highlighted passage of a reply and see the answer inline under that paragraph

A Claude Code mod. Highlight text in one of Claude's earlier replies, type /ask <question>, and the question and answer appear as an indented thread directly under the paragraph you highlighted, instead of at the bottom of the conversation.
/ask sends a normal turn: same conversation, same model, full context, recorded in history. Only where it is drawn changes. ctrl+o shows the transcript as it really is.
You ask for three short paragraphs about pulsars. The second one mentions "LGM-1" and you want to know what that is. Select the sentence, type /ask what does this mean, and press Enter (the drawing below is a text sketch of the result):
❯ in three short paragraphs tell me about pulsars
● Pulsars are rapidly rotating neutron stars, the collapsed cores left behind when massive stars
explode as supernovae. They pack more than a sun's worth of mass into a sphere only about 20
kilometers across.
The first pulsar was found in 1967 by Jocelyn Bell Burnell. The signal was so regular that it was
briefly nicknamed "LGM-1," for "little green men."
▎ what does this mean
▎ It means the signal was so precise and regular that the researchers half-seriously wondered
▎ whether it could come from an intelligent source, such as an alien civilization. "LGM" stands
▎ for "little green men," a popular way of referring to imagined extraterrestrials.
▎ why a joke and not a real hypothesis?
▎ Because they quickly found more pulsars in other parts of the sky, which made an alien
▎ explanation very unlikely.
Millisecond pulsars keep time so precisely that they rival atomic clocks, which makes them useful
for testing general relativity and detecting gravitational waves.
The thread sits right under the paragraph you selected from, not at the bottom. The second thread is a follow-up on the first answer: it is indented one level deeper. The conversation itself is normal; Claude answered in context and later turns can refer back to it.
$.ui.selection. Developed and tested on 2.1.293. On a build that lacks the selection API, /ask shows a toast asking you to update instead of failing./fullscreen), because the mod reads Claude Code's own transcript selection.From inside Claude Code in a terminal, install it from this repository:
/plugin install inline-ask --marketplace makslevental/claude-inline-ask
Answer y to add the marketplace, then pick a scope. With the user scope the mod loads in every later session; it is active in the current one straight away. This command works in a terminal session, not in the desktop app's Code tab.
To try it for one session without installing, clone the repository and point Claude Code at it:
git clone git@github.com:makslevental/claude-inline-ask.git
claude --plugin-dir ./claude-inline-ask
Then switch to fullscreen with /fullscreen if you are not already in it, and see Use below.
If you installed it with /plugin install, a copy was made at install time, so new versions do not arrive by themselves. To update:
claude plugin update inline-ask
then run /reload-plugins in any session that is already open (a new session picks it up on its own). Reloading resets the mod's memory, so threads already on screen fall back to the bottom of the transcript (see Limitations).
If you cloned the repository and run it with --plugin-dir, git pull is enough: Claude Code reloads the mod when its files change.
/ask what do you mean by this? and press Enter. The highlight disappears as you type; the mod still sees it.⎿ N tool calls. Press Escape to interrupt; the thread shows (interrupted) and keeps the partial answer./ask again. The follow-up appears below the answer you selected from, indented one level deeper.Nothing selected, or no question, shows a short toast and sends nothing.
› Prompt from the inline-ask plugin block at the bottom for any prompt a plugin submits. The mod cannot hide it./resume, or after the mod reloads, existing threads are shown where they really are, at the bottom.claude -p.claude --plugin-dir . # hot reloads on save; also writes the API types to .claude-plugin/types/
claude plugin validate .
claude plugin test .
tsconfig.json extends the generated types, so run the first command once before type-checking (npx tsc -p .).
Layout: hooks/anchor.ts (finds the paragraph holding the selection), hooks/threads.ts (thread state and the prompt format), hooks/view.tsx (render trees), hooks/register.tsx (the hooks), tests/.
hooks/register.tsx 119 lines1import type { Register } from 'claude-code'
2import { locateAnchor } from './anchor'
3import { buildPrompt, claimOf, createThreads } from './threads'
4import { anchorView, hidden } from './view'
5
6export const register: Register = (on) => {
7 // Assistant block id → markdown, in render order. Module state: lost on a mod reload (v1).
8 const blocks = new Map<string, string>()
9 const threads = createThreads()
10
11 on('session.start', async ($, e, next) => {
12 await $.command.register({
13 name: 'ask',
14 description: 'Ask about the selected text; the answer appears under its paragraph',
15 argumentHint: '<question>',
16 immediate: true,
17 })
18 return next(e)
19 })
20
21 on('command.run', { command: 'ask' }, async ($, e) => {
22 const question = e.args.trim()
23 if (question === '') {
24 $.ui.toast('Usage: /ask <question> (select text in a reply first)')
25 return {}
26 }
27 // A Claude Code too old to have `$.ui.selection` (or one that refuses it) rejects here.
28 let selection
29 try {
30 selection = await $.ui.selection()
31 } catch {
32 $.ui.toast('This needs a recent Claude Code with fullscreen mode (tested on 2.1.293): update it, then /fullscreen')
33 return {}
34 }
35 if (selection === undefined) {
36 $.ui.toast('Select text in a reply first (needs fullscreen: /fullscreen)')
37 return {}
38 }
39 // A selection inside a drawn thread makes a follow-up: anchored where that thread is, nested beneath it.
40 const located = locateAnchor(selection, blocks)
41 const parent = located === undefined ? threads.findByText(selection.text) : undefined
42 const anchor = located ?? parent?.anchor
43 if (anchor === undefined) {
44 $.ui.toast("Couldn't find the selection in one of Claude's replies")
45 return {}
46 }
47 const text = buildPrompt(selection.text, question)
48 const thread = threads.add({ ...anchor }, question, text, parent)
49 $.ui.invalidate('ui.render')
50 // The engine rejects a submit made inside this hook (it would wait on the turn the hook holds),
51 // so submit from a timer. Not awaited: it resolves when the turn starts, and waits while Claude is busy.
52 $.clock.after(200, () => {
53 const fail = (reason: string) => {
54 threads.remove(thread)
55 $.ui.invalidate('ui.render')
56 $.ui.toast(reason)
57 }
58 $.prompt.submit({ text, asUser: true }).then(
59 // Another plugin may drop the prompt: the call then resolves with `drop` and no turn starts.
60 result => {
61 if (result.drop !== undefined) fail(`Could not send the question: ${result.drop}`)
62 },
63 () => fail('Could not send the question'),
64 )
65 })
66 return {}
67 })
68
69 on('turn.start', async ($, e, next) => {
70 if (threads.startTurn(e.text, e.turnId) !== undefined) $.ui.invalidate('ui.render')
71 return next(e)
72 })
73
74 on('session.append', async ($, e, next) => {
75 if (threads.claim(claimOf(e))) $.ui.invalidate('ui.render')
76 return next(e)
77 })
78
79 on('turn.complete', async ($, e, next) => {
80 threads.complete(e.turnId, e.reason)
81 $.ui.invalidate('ui.render')
82 return next(e)
83 })
84
85 // The thread's own rows are drawn inline in the anchored block, not at the bottom.
86 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
87 if (threads.ownerOf(e.requestId) === undefined) return next(e)
88 return hidden($.ui.resolve(e))
89 })
90 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
91 if (threads.ownerOf(e.requestId) === undefined) return next(e)
92 return hidden($.ui.resolve(e))
93 })
94 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
95 if (threads.ownerOf(e.requestId) === undefined) return next(e)
96 return hidden($.ui.resolve(e))
97 })
98 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
99 const ids = e.props.calls.map(call => call.tool_use_id)
100 const isOwned = ids.length > 0 && ids.every(id => id !== undefined && threads.ownerOf(id) !== undefined)
101 return isOwned ? hidden($.ui.resolve(e)) : next(e)
102 })
103 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
104 const owner = threads.ownerOf(e.requestId)
105 if (owner !== undefined) {
106 // The thread's reply: keep its text for the anchor to draw, and redraw when it grew.
107 // A summary block shares its reply's id but holds only a summary of the text: never stored.
108 if (e.props.isSummary !== true && threads.setReply(owner, e.requestId, e.props.text)) $.ui.invalidate('ui.render')
109 return hidden($.ui.resolve(e))
110 }
111 // A summary block is neither recorded nor redrawn as the anchor.
112 if (e.props.isSummary === true) return next(e)
113 blocks.set(e.requestId, e.props.text)
114 const here = threads.all().filter(t => t.anchor.messageId === e.requestId)
115 if (here.length === 0) return next(e)
116 return anchorView($.ui.resolve(e), e.props.text, here)
117 })
118}
119hooks/anchor.ts 95 lines1export type Anchor = { messageId: string; paragraphIndex: number }
2export type SelectionLike = { text: string; requestId?: string }
3
4const FENCE_OPEN = /^\s*(`{3,}|~{3,})(.*)$/
5const FENCE_CLOSE = /^\s*(`{3,}|~{3,})\s*$/
6const FENCE_LINE = /^\s*(`{3,}|~{3,})[^`]*$/
7
8/** Splits markdown into paragraphs on blank lines; a fenced code block is one paragraph. */
9export function splitParagraphs(markdown: string): string[] {
10 const paragraphs: string[] = []
11 let current: string[] = []
12 let fence: { char: string; length: number } | undefined
13 const flush = () => {
14 if (current.length > 0) paragraphs.push(current.join('\n'))
15 current = []
16 }
17 for (const line of markdown.split('\n')) {
18 if (fence) {
19 current.push(line)
20 const close = FENCE_CLOSE.exec(line)
21 if (close && close[1]![0] === fence.char && close[1]!.length >= fence.length) {
22 flush()
23 fence = undefined
24 }
25 continue
26 }
27 const open = FENCE_OPEN.exec(line)
28 if (open && !(open[1]![0] === '`' && open[2]!.includes('`'))) {
29 flush()
30 current.push(line)
31 fence = { char: open[1]![0]!, length: open[1]!.length }
32 } else if (line.trim() === '') {
33 flush()
34 } else {
35 current.push(line)
36 }
37 }
38 flush()
39 return paragraphs
40}
41
42/** Strips markdown syntax and collapses whitespace so rendered and source text compare equal. */
43export function normalize(markdown: string): string {
44 return markdown
45 .split('\n')
46 .filter(line => !FENCE_LINE.test(line))
47 .map(line =>
48 line
49 .replace(/^\s*#{1,6}\s+/, '')
50 .replace(/^\s*>\s?/, '')
51 .replace(/^\s*(?:[-*+•]|\d+[.)])\s+/, ''),
52 )
53 .join(' ')
54 .replace(/!\[([^\]\n]{0,500})\]\([^)\n]{0,500}\)/g, '$1')
55 .replace(/\[([^\]\n]{0,500})\]\([^)\n]{0,500}\)/g, '$1')
56 .replace(/`+/g, '')
57 .replace(/(\*\*|__|~~)/g, '')
58 .replace(/\*/g, '')
59 .replace(/(^|\s)_+(?=\S)/g, '$1')
60 .replace(/(?<=\S)_+(?=\s|$|[.,;:!?)])/g, '')
61 .replace(/\s+/g, ' ')
62 .trim()
63}
64
65/**
66 * Finds the paragraph holding the end of the selection. A selection that names its row (`requestId`)
67 * is searched in that block alone: no fallback to other blocks, which may no longer be drawn; an
68 * unknown row gives undefined. Only a selection spanning rows (no `requestId`) searches every block,
69 * newest first (`blocks` iterates in render order).
70 */
71export function locateAnchor(selection: SelectionLike, blocks: ReadonlyMap<string, string>): Anchor | undefined {
72 const needle = normalize(selection.text)
73 if (needle === '') return undefined
74 const order =
75 selection.requestId !== undefined
76 ? blocks.has(selection.requestId)
77 ? [selection.requestId]
78 : []
79 : [...blocks.keys()].reverse()
80 for (const messageId of order) {
81 const kept = splitParagraphs(blocks.get(messageId) ?? '')
82 .map((paragraph, index) => ({ text: normalize(paragraph), index }))
83 .filter(paragraph => paragraph.text !== '')
84 const start = kept.map(paragraph => paragraph.text).join(' ').indexOf(needle)
85 if (start === -1) continue
86 const end = start + needle.length - 1
87 let offset = 0
88 for (const paragraph of kept) {
89 if (end < offset + paragraph.text.length) return { messageId, paragraphIndex: paragraph.index }
90 offset += paragraph.text.length + 1
91 }
92 }
93 return undefined
94}
95hooks/threads.ts 156 lines1import { normalize } from './anchor'
2import type { Anchor } from './anchor'
3
4export type ThreadStatus = 'pending' | 'streaming' | 'done' | 'interrupted'
5
6export type Thread = {
7 anchor: Anchor
8 question: string
9 submittedText: string
10 status: ThreadStatus
11 turnId?: string
12 userMessageId?: string
13 /** reply block id → latest text, in arrival order */
14 replyBlocks: Map<string, string>
15 toolCallIds: Set<string>
16 /** The thread whose question or answer this one follows up on; drawn nested beneath it. */
17 parent?: Thread
18}
19
20/** What a stored row contributes to the active thread. */
21export type RowClaim = { userId?: string; userText?: string; replyId?: string; toolIds: string[] }
22
23type Block = { type?: string; id?: string; text?: string }
24type AppendedRow = { door: string; uuid: string; agentId?: string; message: { content: unknown } }
25
26export function buildPrompt(selectedText: string, question: string): string {
27 const quoted = selectedText
28 .trim()
29 .split('\n')
30 .map(line => `> ${line}`)
31 .join('\n')
32 return `About this part of your earlier reply:\n${quoted}\n\n${question}`
33}
34
35/** Reads a `session.append` row into the ids and text a thread needs. Subagent rows claim nothing. */
36export function claimOf(row: AppendedRow): RowClaim {
37 if (row.agentId !== undefined) return { toolIds: [] }
38 const content = row.message.content
39 const blocks: Block[] = Array.isArray(content) ? (content as Block[]) : []
40 if (row.door === 'prompt') {
41 const text = typeof content === 'string' ? content : blocks.filter(b => b.type === 'text').map(b => b.text ?? '').join('\n')
42 return { userId: row.uuid, userText: text.trim(), toolIds: [] }
43 }
44 const toolIds = blocks.flatMap(b => (b.type === 'tool_use' && b.id !== undefined ? [b.id] : []))
45 // At append time a response row may hold only a `thinking` block; its text arrives later under the same id.
46 if (row.door === 'response') return { replyId: row.uuid, toolIds }
47 return { toolIds: [] }
48}
49
50export function createThreads() {
51 const threads: Thread[] = []
52 const streaming = () => threads.find(t => t.status === 'streaming')
53
54 return {
55 all: (): readonly Thread[] => threads,
56
57 add(anchor: Anchor, question: string, submittedText: string, parent?: Thread): Thread {
58 const thread: Thread = {
59 anchor,
60 question,
61 submittedText: submittedText.trim(),
62 status: 'pending',
63 replyBlocks: new Map(),
64 toolCallIds: new Set(),
65 ...(parent !== undefined ? { parent } : {}),
66 }
67 threads.push(thread)
68 return thread
69 },
70
71 remove(thread: Thread): void {
72 const i = threads.indexOf(thread)
73 if (i !== -1) threads.splice(i, 1)
74 },
75
76 /** The prompt's turn began: the pending thread with that exact text starts streaming. */
77 startTurn(text: string, turnId: string): Thread | undefined {
78 const thread = threads.find(t => t.status === 'pending' && t.submittedText === text.trim())
79 if (thread === undefined) return undefined
80 for (const stale of threads) if (stale.status === 'streaming') stale.status = 'interrupted'
81 thread.status = 'streaming'
82 thread.turnId = turnId
83 return thread
84 },
85
86 /**
87 * Binds a stored row: the user message by its text (it is stored before the turn starts), the rest to the
88 * streaming thread. A prompt row no pending thread takes ends the streaming thread's claim: it was delivered
89 * into or queued after that turn, so what follows belongs to the normal transcript. True when anything drawn changed.
90 */
91 claim(claim: RowClaim): boolean {
92 if (claim.userId !== undefined) {
93 let changed = false
94 for (const stale of threads) {
95 if (stale.status === 'pending' && stale.userMessageId !== undefined && stale.submittedText === claim.userText) {
96 stale.status = 'interrupted'
97 changed = true
98 }
99 }
100 const thread = threads.find(t => t.status === 'pending' && t.userMessageId === undefined && t.submittedText === claim.userText)
101 if (thread !== undefined) {
102 thread.userMessageId = claim.userId
103 return true
104 }
105 const current = streaming()
106 if (current !== undefined) current.status = 'done'
107 return changed
108 }
109 const thread = streaming()
110 if (thread === undefined) return false
111 let changed = false
112 if (claim.replyId !== undefined && !thread.replyBlocks.has(claim.replyId)) {
113 thread.replyBlocks.set(claim.replyId, '')
114 changed = true
115 }
116 for (const id of claim.toolIds) {
117 if (!thread.toolCallIds.has(id)) {
118 thread.toolCallIds.add(id)
119 changed = true
120 }
121 }
122 return changed
123 },
124
125 /** The turn ended: 'answer' means done; any other reason ('aborted', 'error', 'refusal') means interrupted. */
126 complete(turnId: string, reason: string): void {
127 const thread = threads.find(t => t.turnId === turnId)
128 if (thread === undefined) return
129 thread.status = reason === 'answer' ? 'done' : 'interrupted'
130 },
131
132 /** Stores a reply block's text; true when it changed. */
133 setReply(thread: Thread, blockId: string, text: string): boolean {
134 if (thread.replyBlocks.get(blockId) === text) return false
135 thread.replyBlocks.set(blockId, text)
136 return true
137 },
138
139 /** The newest thread whose question or answer holds the selected text: a follow-up anchors where it does. */
140 findByText(selectedText: string): Thread | undefined {
141 const needle = normalize(selectedText)
142 if (needle === '') return undefined
143 for (let i = threads.length - 1; i >= 0; i--) {
144 const thread = threads[i]!
145 const text = normalize(thread.question + ' ' + [...thread.replyBlocks.values()].join(' '))
146 if (text.includes(needle)) return thread
147 }
148 return undefined
149 },
150
151 ownerOf(id: string): Thread | undefined {
152 return threads.find(t => t.userMessageId === id || t.replyBlocks.has(id) || t.toolCallIds.has(id))
153 },
154 }
155}
156hooks/view.tsx 73 lines1import { splitParagraphs } from './anchor'
2import type { Thread } from './threads'
3
4// The element table `$.ui.resolve(e)` returns; only these three are used.
5type Els = { Box: any; Text: any; Markdown: any }
6
7/** A row that takes no room: the thread's own messages, drawn inline instead. */
8export function hidden(els: Els) {
9 const { Box } = els
10 return <Box display="none" />
11}
12
13export function threadView(els: Els, thread: Thread, depth = 0) {
14 const { Box, Text, Markdown } = els
15 const answer = [...thread.replyBlocks.values()].filter(text => text !== '').join('\n\n')
16 const toolCount = thread.toolCallIds.size
17 const children = [
18 <Text dimColor>{thread.question}</Text>,
19 ...(toolCount > 0 ? [<Text dimColor>{`⎿ ${toolCount} tool call${toolCount === 1 ? '' : 's'}`}</Text>] : []),
20 answer !== '' ? <Markdown text={answer} /> : <Text dimColor>…</Text>,
21 ...(thread.status === 'interrupted' ? [<Text dimColor>(interrupted)</Text>] : []),
22 ]
23 return (
24 <Box marginLeft={depth * 2} flexDirection="column">
25 <Box borderStyle="quote" flexDirection="column" paddingLeft={1}>
26 {children}
27 </Box>
28 </Box>
29 )
30}
31
32const MAX_DEPTH = 8
33
34/**
35 * Threads as a tree in drawing order: each root (no parent among `threads`) in creation order, followed
36 * at once by its follow-ups, recursively, each one level deeper (indentation capped at MAX_DEPTH).
37 */
38export function orderedWithDepth(threads: readonly Thread[]): { thread: Thread; depth: number }[] {
39 const ordered: { thread: Thread; depth: number }[] = []
40 const placed = new Set<Thread>()
41 const visit = (thread: Thread, depth: number) => {
42 if (placed.has(thread)) return
43 placed.add(thread)
44 ordered.push({ thread, depth })
45 for (const child of threads) if (child.parent === thread) visit(child, Math.min(depth + 1, MAX_DEPTH))
46 }
47 for (const thread of threads) {
48 if (thread.parent === undefined || !threads.includes(thread.parent)) visit(thread, 0)
49 }
50 // A cycle has no root to reach it from: draw what it left out as roots rather than lose it.
51 for (const thread of threads) visit(thread, 0)
52 return ordered
53}
54
55/** An assistant block with each thread inserted after its anchored paragraph. */
56export function anchorView(els: Els, markdown: string, threads: readonly Thread[]) {
57 const { Box, Markdown } = els
58 const paragraphs = splitParagraphs(markdown)
59 const children: unknown[] = []
60 let start = 0
61 const anchoredAt = [...new Set(threads.map(t => t.anchor.paragraphIndex))].sort((x, y) => x - y)
62 for (const index of anchoredAt) {
63 const text = paragraphs.slice(start, index + 1).join('\n\n')
64 if (text !== '') children.push(<Markdown text={text} />)
65 for (const { thread, depth } of orderedWithDepth(threads.filter(t => t.anchor.paragraphIndex === index))) {
66 children.push(threadView(els, thread, depth))
67 }
68 start = index + 1
69 }
70 if (start < paragraphs.length) children.push(<Markdown text={paragraphs.slice(start).join('\n\n')} />)
71 return <Box flexDirection="column">{children}</Box>
72}
73