SLOPSHOPPER

wtf

Select text, run /wtf, and a side pane explains what it means in the context of this conversation, without adding anything to the conversation.

newpanespinnercommandtoastmodel
v0.1.2MITupdated 2026-10-06orangeJigglypuff/better-btw/wtf
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wtf
│ ┃ wtf ✕ › fix the failing auth test and add an audit log call │ ┃ Select some text, then run /wtf. │ ⏺ 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 │ │ › /wtf │ ⎿ wtf: Usage: select text with the mouse, then run /wtf, or run /w │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · wtf
Select some text, then run /wtf.
README

wtf

A Claude Code mod for the moment you read something in the conversation and think "wtf does that mean".

Select the text, run /wtf, and a narrow side pane explains what it means in the context of the current conversation. The answer never enters the conversation, so it costs the main thread no context.

  • /wtf explains the text you last selected with the mouse.
  • /wtf some text explains the text you typed instead.
  • The pane keeps the last 10 questions; click last / next to page through them.
  • A follow-up field under each answer continues that side thread.
  • Works while Claude is mid-turn.
  • The pane speaks English, or Chinese when the question contains Chinese. Answers come in the language you have been writing in.

中文说明见文末。

Install

Requires Claude Code v2.1.287 or later. Tested with v2.1.289. The mods API can change between releases.

From your shell:

claude plugin marketplace add orangeJigglypuff/better-btw
claude plugin install wtf@better-btw

Or, from inside a Claude Code session: /plugin install wtf --marketplace orangeJigglypuff/better-btw.

To try it for one session without installing, clone the repository and run claude --plugin-dir ./better-btw/wtf.

What it does, and what it sends where

The mod is one hooks module, hooks/register.tsx. It hooks four events:

| Hook | What it does | | :- | :- | | session.start | Registers the /wtf command. Then lets the session start as usual. | | command.run for wtf | Reads the command's argument, or your mouse selection when there is none, and asks the question (below). Prints nothing to the transcript, except a one-line usage hint when there was nothing to ask about. | | ui.render for Pane | Draws the side pane from the questions and answers it keeps in session state. | | ui.render for PromptHint | Only with the Hotkey button option on: redraws the hint line under the prompt with the same text plus an invisible button, so a bound key can ask about the selection. With the option off, the hook passes the line through untouched. |

Asking a question sends exactly one request to the model, through Claude Code's own API client, on the model and plan or API key your session already uses. Nothing goes anywhere else: the mod makes no network requests of its own, runs no processes, and reads and writes no files.

  • Normally the request is $.model.fork: your session's transcript as Claude Code last sent it, plus one user message holding the selected text (or your typed question, and on a follow-up the earlier questions and answers of that thread). The same prefix as your session's own requests, so the prompt cache serves most of it.
  • In a resumed session that has not yet sent a request, there is nothing to fork, so the mod instead sends $.model.complete on the session's model: the text of the saved transcript's messages (the last 80,000 characters, without tool inputs or results) plus the same question.

Answers and history live in session state ($.state) and are gone when the session ends. The mod calls no other plugin.

Run claude plugin validate ./wtf to list every event the mod hooks and every API it calls before you load it.

Optional: a hotkey

Mods have no key events of their own. A mod button can be pressed by the key bound to an engine keybinding action, so the hotkey borrows one: app:cycleDiffBase.

  1. Turn on the Hotkey button option for the plugin in /config.
  2. Bind a key to the action in ~/.claude/keybindings.json:
{
  "bindings": [
    {
      "context": "Global",
      "bindings": { "alt+q": "app:cycleDiffBase" }
    }
  ]
}

Select text, press the key, and the pane opens with the answer.

Know what this changes before turning it on:

  • The button has to be mounted somewhere that is always on screen, so the mod redraws the hint line under the prompt (same text, plus one invisible cell). Anything interactive the engine draws on that line may stop responding to clicks, and another mod that draws the same line will conflict.
  • While the /diff panel is open, the key does its original job there instead.

Limitations

  • Reading the mouse selection needs the fullscreen terminal layout. Elsewhere, use /wtf some text.
  • A brand-new session, or one right after /clear, has no context to read until the first reply.
  • A docked pane is always full height; only its width is adjustable.
  • History lasts for the session.

Development

In a clone of the repository:

claude plugin validate --strict ./wtf
claude plugin test ./wtf

License

MIT


中文

读对话时看到一句看不懂的话:用鼠标划选它,输入 /wtf,侧边面板会结合当前对话的语境解释它是什么意思。答案不进入主对话,不占主线程的上下文。

  • /wtf:解释刚才划选的文字;/wtf 某段文字:解释直接输入的文字。
  • 面板保留最近 10 条,点 last / next 翻看;每条答案下方可以追问。
  • Claude 正在回复时也能用。
  • 快捷键是可选的:在 /config 里打开 Hotkey button,再按上面的示例在 ~/.claude/keybindings.json 里绑定一个键。它会接管输入框下方的提示行,开启前请先看上面的说明。
  • 划选只在全屏终端布局下可读;新会话或刚 /clear 后要先聊一轮才有上下文。
Source 2 files
hooks/register.tsx 285 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { WtfEntry } from '../types'
5
6const PANE = { id: 'wtf', title: 'wtf', columns: 36, closeOnEscape: true } as const
7const KEEP = 10
8const MAX_QUESTION = 2000
9const MAX_SHOWN_QUESTION = 160
10const MAX_ANSWER = 9000
11const MAX_BASIS = 6000
12const MAX_TRANSCRIPT = 80000
13const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
14const SPIN_MS = 100
15// 橘色胖丁's orange.
16const ORANGE = '#ff8c42'
17// The engine has no key event for a mod: a Button naming an engine keybinding
18// action is pressed by the chord the person bound to that action, from the
19// prompt, while the Button is mounted. This action's own handler lives in the
20// diff panel only; the README has the person bind a key to it.
21const ASK_ACTION = 'app:cycleDiffBase'
22
23const entries = atom({ plugin: 'wtf', key: 'entries' } as const, [])
24const cursor = atom({ plugin: 'wtf', key: 'cursor' } as const, 0)
25const frame = atom({ plugin: 'wtf', key: 'frame' } as const, 0)
26
27// Text and Markdown take tab and newline as their only control characters.
28const clean = (text: string) =>
29  text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
30
31// The pane speaks the language of the question: Chinese for one holding Han
32// characters, English otherwise. The model is told to answer in the user's.
33const TEXT = {
34  en: {
35    reading: 'Reading the conversation',
36    followUp: 'Follow up…',
37    submit: 'ask',
38    empty: 'Select some text, then run /wtf.',
39    usage: 'Usage: select text with the mouse, then run /wtf, or run /wtf <text>.',
40    nothingSelected: 'wtf: select some text first',
41    unanswered: 'No answer came back.',
42    why: {
43      'nothing-to-fork':
44        'This conversation has no reply yet, so there is no context to read. Try again after one turn.',
45      'api-error': 'The request failed (API error).',
46      'empty-reply': 'The model gave no text answer.',
47      aborted: 'Interrupted.',
48    } as Record<string, string>,
49  },
50  zh: {
51    reading: '正在读对话',
52    followUp: '追问…',
53    submit: '追问',
54    empty: '划选一段文字,然后输入 /wtf。',
55    usage: '用法:先用鼠标划选一句话再输入 /wtf,或直接 /wtf <文字>。',
56    nothingSelected: 'wtf:先用鼠标划选一段文字',
57    unanswered: '没有得到回答。',
58    why: {
59      'nothing-to-fork': '当前对话还没有任何回复,没有上下文可读。先聊一轮再试。',
60      'api-error': '请求失败(API 错误)。',
61      'empty-reply': '模型没有给出文字回答。',
62      aborted: '被中断了。',
63    } as Record<string, string>,
64  },
65}
66
67const langOf = (text: string) => (/[\u3400-\u9fff]/.test(text) ? 'zh' : 'en')
68
69const ask = (question: string, basis: string | undefined) =>
70  basis === undefined
71    ? [
72        '[/wtf side question. Do not call tools and do not continue the main task; answer only this.]',
73        'This question and your answer appear only in a side pane and are never added to the conversation; do not comment on that.',
74        'Explain what the text below means in the context of this conversation: what it refers to and why it appears here.',
75        'Be direct and brief (a few sentences). Answer in the language the user has been writing in.',
76        '',
77        '<selected>',
78        question,
79        '</selected>',
80      ].join('\n')
81    : [
82        '[/wtf side follow-up. Do not call tools and do not continue the main task; answer only this.]',
83        'This question and your answer appear only in a side pane and are never added to the conversation; do not comment on that.',
84        'The user asked the side questions below earlier (the main conversation does not show them) and now follows up.',
85        'Answer the follow-up in the context of this conversation. Be direct and brief, in the language the user has been writing in.',
86        '',
87        '<earlier>',
88        basis,
89        '</earlier>',
90        '',
91        '<follow-up>',
92        question,
93        '</follow-up>',
94      ].join('\n')
95
96// A fork reads the transcript as the main thread last sent it, and a resumed
97// session has sent nothing yet: until its first turn there is nothing to fork
98// although the conversation is all there. Then the question goes out as a
99// completion of its own, over the tail of the saved transcript as text.
100const answer = async ($: EngineInterface, prompt: string) => {
101  const forked = await $.model.fork({ prompt })
102
103  if (forked.isAnswered || forked.reason !== 'nothing-to-fork') {
104    return forked
105  }
106
107  const rows = await $.session.messages().catch(() => [])
108  const said = Array.isArray(rows) ? rows.filter(row => row.text.trim() !== '') : []
109
110  if (!said.some(row => row.role === 'assistant')) {
111    return forked
112  }
113
114  const transcript = said
115    .map(row => `${row.role === 'user' ? 'User' : 'Assistant'}: ${row.text}`)
116    .join('\n\n')
117    .slice(-MAX_TRANSCRIPT)
118
119  return $.model.complete({
120    model: await $.session.model(),
121    prompt: `<conversation>\n${transcript}\n</conversation>\n\n${prompt}`,
122  })
123}
124
125// Answers whether there was anything to explain. With `parent`, the question
126// is a follow-up on that entry and carries its thread along.
127const explain = async ($: EngineInterface, raw: string | undefined, parent?: WtfEntry) => {
128  const question = clean(raw ?? '').trim().slice(0, MAX_QUESTION)
129
130  if (question === '') {
131    return false
132  }
133
134  const basis =
135    parent === undefined
136      ? undefined
137      : `${parent.basis ?? ''}Q: ${parent.question}\nA: ${parent.answer}\n\n`.slice(-MAX_BASIS)
138  const lang = parent?.lang ?? langOf(question)
139  let id = 0
140  await update($, entries, list => {
141    id = (list.at(-1)?.id ?? 0) + 1
142    const entry: WtfEntry = { id, question, answer: '', status: 'pending', lang, basis }
143
144    return [...list, entry].slice(-KEEP)
145  })
146  await update($, cursor, () => KEEP)
147  await $.ui.open(PANE)
148
149  const spin = $.clock.every(SPIN_MS, () => update($, frame, n => (n + 1) % SPINNER.length))
150  const reply = await answer($, ask(question, basis))
151    .catch(() => ({ isAnswered: false, reason: 'api-error' }) as const)
152    .finally(() => spin.cancel())
153  const text = reply.isAnswered
154    ? clean(reply.text).slice(0, MAX_ANSWER)
155    : (TEXT[lang].why[reply.reason] ?? TEXT[lang].unanswered)
156  const status = reply.isAnswered ? 'done' : 'failed'
157  await update($, entries, list =>
158    list.map(one => (one.id === id ? { ...one, answer: text, status } : one)),
159  )
160
161  return true
162}
163
164// What is said with no question to take the language from: the last one's.
165const spoken = async ($: EngineInterface) => TEXT[(await read($, entries)).at(-1)?.lang ?? 'en']
166
167export const register: Register = (on, options) => {
168  on('session.start', async ($, e, next) => {
169    await $.command.register({
170      name: 'wtf',
171      description: 'Explain the selected text (or the argument) in the context of this conversation',
172      argumentHint: '[text]',
173      immediate: true,
174    })
175
176    return next(e)
177  })
178
179  on('command.run', { command: 'wtf' }, async ($, e) => {
180    const typed = e.args.trim()
181    const picked = typed === '' ? (await $.ui.selection())?.text : typed
182
183    if (!(await explain($, picked))) {
184      return { text: (await spoken($)).usage }
185    }
186
187    return {}
188  })
189
190  // The one site that is always mounted and not above the prompt: the hint
191  // line under it carries, unseen, the Button the ask chord presses. Opt-in,
192  // since the line is then this mod's drawing and not the engine's.
193  on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
194    if (options.hotkey !== true) {
195      return next(e)
196    }
197
198    const { Box, Button, Text } = $.ui.resolve(e)
199    const onAsk = async () => {
200      if (!(await explain($, (await $.ui.selection())?.text))) {
201        $.ui.toast((await spoken($)).nothingSelected)
202      }
203    }
204
205    return (
206      <Box>
207        <Text dimColor>{e.props.hint}</Text>
208        <Button key="ask" label=" " action={ASK_ACTION} plain onPress={onAsk} />
209      </Box>
210    )
211  })
212
213  on('ui.render', { component: 'Pane', requestId: 'wtf' }, async ($, e) => {
214    const { Box, Button, Markdown, Text } = $.ui.resolve(e)
215    const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
216    const list = await read($, entries)
217    const last = list.length - 1
218    const at = Math.min(Math.max(await read($, cursor), 0), Math.max(last, 0))
219    const entry = list[at]
220
221    if (entry === undefined) {
222      return (
223        <Box flexDirection="column" width={e.props.bodyColumns} paddingX={1}>
224          <Text dimColor>{TEXT.en.empty}</Text>
225        </Box>
226      )
227    }
228
229    const text = TEXT[entry.lang]
230    const move = (by: number) => () =>
231      update($, cursor, n => Math.min(Math.max(Math.min(n, last) + by, 0), last))
232    const spinner = entry.status === 'pending' ? SPINNER[await read($, frame)] : undefined
233    const shown =
234      entry.question.length > MAX_SHOWN_QUESTION
235        ? `${entry.question.slice(0, MAX_SHOWN_QUESTION)}…`
236        : entry.question
237
238    return (
239      <Box flexDirection="column" width={e.props.bodyColumns}>
240        <Box justifyContent="space-between" paddingX={1}>
241          <Button
242            key="prev"
243            label="last"
244            plain
245            dimColor={at === 0}
246            onPress={move(-1)}
247          />
248          <Text bold color={ORANGE}>
249            [{at + 1} / {list.length}]
250          </Text>
251          <Button
252            key="next"
253            label="next"
254            plain
255            dimColor={at === last}
256            onPress={move(1)}
257          />
258        </Box>
259        <Box borderStyle="round" borderDimColor paddingX={1} marginTop={1}>
260          <Text italic dimColor>
261            {entry.basis === undefined ? shown : `↳ ${shown}`}
262          </Text>
263        </Box>
264        <Box flexDirection="column" paddingX={1} marginY={1}>
265          {spinner !== undefined && <Text color={ORANGE}>{spinner} {text.reading}</Text>}
266          {entry.status === 'failed' && <Text color="red">{entry.answer}</Text>}
267          {entry.status === 'done' && <Markdown text={entry.answer} />}
268        </Box>
269        {entry.status === 'done' && Input !== undefined && (
270          <Input
271            key="follow"
272            label="↳"
273            placeholder={text.followUp}
274            value=""
275            submitLabel={text.submit}
276            onSubmit={text => {
277              void explain($, text, entry)
278            }}
279          />
280        )}
281      </Box>
282    )
283  })
284}
285
types/index.d.ts 17 lines
1export type WtfEntry = {
2  id: number
3  question: string
4  answer: string
5  status: 'pending' | 'done' | 'failed'
6  /** The language the pane speaks for this entry, taken from the question. */
7  lang: 'en' | 'zh'
8  /** The side thread before this question, when it is a follow-up. */
9  basis?: string
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    wtf: { entries: WtfEntry[]; cursor: number; frame: number }
15  }
16}
17