SLOPSHOPPER

inline-ask

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

newrowscommandtoasttimer
v0.1.2no licenseupdated 2026-10-15makslevental/claude-inline-ask
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · inline-ask
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ inline-ask │ ⏺ Read(src/auth.ts) │ Usage: /ask <question> (select text in a │ ⎿ Read 6 lines │ reply first) │ ⏺ 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 › /ask ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

inline-ask

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.

Example

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.

Requirements

  • A Claude Code with mods (2.1.287 or later) and $.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 rendering (/fullscreen), because the mod reads Claude Code's own transcript selection.

Install

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.

Updating

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.

Use

  1. Select text in one of Claude's replies with the mouse.
  2. Type /ask what do you mean by this? and press Enter. The highlight disappears as you type; the mod still sees it.
  3. The question appears under the paragraph you selected from, and the answer streams in beneath it. Tool calls the answer makes are summarised as ⎿ N tool calls. Press Escape to interrupt; the thread shows (interrupted) and keeps the partial answer.
  4. To follow up on an answer, select text inside the thread and /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.

Limitations

  • Claude Code draws its own › Prompt from the inline-ask plugin block at the bottom for any prompt a plugin submits. The mod cannot hide it.
  • A thread goes after the whole paragraph, list or code block that contains the selection, not at the exact spot.
  • After /resume, or after the mod reloads, existing threads are shown where they really are, at the bottom.
  • Not available in non-fullscreen mode or claude -p.

Develop

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

Source 4 files
hooks/register.tsx 119 lines
1import 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}
119
hooks/anchor.ts 95 lines
1export 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}
95
hooks/threads.ts 156 lines
1import { 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}
156
hooks/view.tsx 73 lines
1import { 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