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

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.last / next to page through them.中文说明见文末。
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.
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.
$.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.$.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.
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.
/config.~/.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:
/diff panel is open, the key does its original job there instead./wtf some text./clear, has no context to read until the first reply.In a clone of the repository:
claude plugin validate --strict ./wtf
claude plugin test ./wtf
MIT
读对话时看到一句看不懂的话:用鼠标划选它,输入 /wtf,侧边面板会结合当前对话的语境解释它是什么意思。答案不进入主对话,不占主线程的上下文。
/wtf:解释刚才划选的文字;/wtf 某段文字:解释直接输入的文字。last / next 翻看;每条答案下方可以追问。/config 里打开 Hotkey button,再按上面的示例在 ~/.claude/keybindings.json 里绑定一个键。它会接管输入框下方的提示行,开启前请先看上面的说明。/clear 后要先聊一轮才有上下文。hooks/register.tsx 285 lines1import { 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}
285types/index.d.ts 17 lines1export 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