Select a word to see its definition in a toast; /define opens a pane with the full entry and its meaning in this conversation

A Claude Code mod for looking up words without leaving the terminal.
/define (or /define <word>) opens a side pane with the full dictionary entry and a short explanation of what the term means in the current conversation.Nothing it shows is added to the conversation; Claude never sees it.
"tui": "fullscreen" in ~/.claude/settings.json. On the main-screen layout the terminal owns the mouse selection, so the toast never fires; /define <word> still works.claude plugin marketplace add brianium/define-word
claude plugin install define-word@brianium
Update with claude plugin update define-word@brianium.
A mod runs with your permissions, so here is everything this one reaches:
| When | Where it goes | Cost |
|---|---|---|
| You select a term | The term, to the free dictionary at api.dictionaryapi.dev | Free |
| The dictionary lacks the term or is slow (> 0.7 s) | The term, to Haiku on your plan or API key | One small completion |
You run /define | One extra question over this conversation, on your main model, with every tool denied | Mostly a prompt-cache read, plus a few sentences of output |
You run /define in a resumed session before its first new turn | The text of the latest messages (up to about 24,000 characters, tool calls left out), to Haiku | One small completion |
Lookups are cached for the session. Run claude plugin validate . in this repository to list every hook and call the mod makes.
Claude Code has no event for a mouse selection, so the mod checks $.ui.selection() every 150 ms and looks a term up once the same selection has held for two checks, so a drag in progress doesn't fire on half a word. Multi-line selections, anything over three words, and anything that looks like code (paths, flags, env_vars, camelCase, name@scope, words with digits) are ignored, so selecting text to copy stays quiet. /define <word> looks up whatever you type.
/define opens the pane at once and fills its two sections as they arrive: the dictionary entry (the same lookup as the toast), and the in-conversation meaning, from $.model.fork, which asks the main model one question over the conversation as last sent without adding it to the transcript.
A fork can only replay a request this process has already sent, so right after --resume (before your first new turn) it has nothing to fork. The mod then reads the transcript with $.session.messages() and asks Haiku the same question over the newest messages; the pane notes when Haiku answered.
claude --plugin-dir ~/Projects/define-word # load for one session, hot-reloading
claude plugin test # run tests/ with no session or network
claude plugin validate --strict . # check the manifest and module
Claude Code writes type declarations for your build to .claude-plugin/types/ on every load (ignored by git), so tsc -p . type-checks once the mod has loaded once.
To release, bump version in .claude-plugin/plugin.json and push; users on the same version won't see new commits.
MIT. See LICENSE.
hooks/register.tsx 306 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Definition } from '../types'
5
6const PANE = 'define-word'
7const POLL_MS = 150
8// How long a selection must hold still to count as done: a drag can pause
9// mid-word for a poll or two, and no event says the mouse came up.
10const SETTLE_MS = 450
11// The dictionary answers a word it knows in well under a second, and hangs on
12// one it lacks; past this, Haiku is asked too and the first answer wins.
13const HEDGE_MS = 700
14const DICTIONARY_URL = 'https://api.dictionaryapi.dev/api/v2/entries/en/'
15
16const entry = atom({ plugin: 'define-word', key: 'entry' } as const, null)
17
18type DictionaryMeaning = {
19 partOfSpeech?: string
20 definitions?: { definition?: string; example?: string }[]
21}
22type DictionaryEntry = { word?: string; phonetic?: string; meanings?: DictionaryMeaning[] }
23
24/**
25 * A selection worth defining: one to three words, no line breaks, with the
26 * surrounding punctuation trimmed. Anything else (a code block, a paragraph
27 * selected to copy) is left alone.
28 */
29export const asTerm = (text: string, maxWords = 3): string | undefined => {
30 // A line break is a block; a leading -, /, ~, $, @ or backtick is a flag,
31 // a path, a command or a variable.
32 if (/[\r\n]/.test(text) || /^[-/~$@`]/.test(text.trim())) return undefined
33 const term = text.trim().replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')
34 const words = term.split(/\s+/).filter(Boolean)
35 const isTermLike =
36 words.length >= 1 &&
37 words.length <= maxWords &&
38 term.length <= 48 &&
39 // Letters, hyphens and apostrophes only: a path, a flag, an env var or
40 // anything with digits in it was selected to copy, not to look up.
41 /^[\p{L}\p{M}'’\s-]+$/u.test(term) &&
42 // camelCase is an identifier; acronyms (API) and capitals (Claude) pass.
43 !/\p{Ll}\p{Lu}/u.test(term)
44
45 return isTermLike ? term : undefined
46}
47
48const clip = (text: string, length: number) =>
49 text.length <= length ? text : `${text.slice(0, length - 1).trimEnd()}…`
50
51/** The dictionary's JSON as a brief line for the toast and markdown for the pane. */
52export const fromDictionary = (term: string, body: string): Definition | undefined => {
53 let entries: DictionaryEntry[]
54 try {
55 entries = JSON.parse(body)
56 } catch {
57 return undefined
58 }
59 if (!Array.isArray(entries) || entries.length === 0) return undefined
60
61 const first = entries[0]
62 const meanings = entries.flatMap(one => one.meanings ?? [])
63 const firstSense = meanings.find(m => m.definitions?.[0]?.definition)
64 if (firstSense === undefined) return undefined
65
66 const lines = [`## ${first?.word ?? term}${first?.phonetic ? ` \`${first.phonetic}\`` : ''}`]
67 for (const meaning of meanings.slice(0, 4)) {
68 lines.push('', `*${meaning.partOfSpeech ?? 'sense'}*`)
69 for (const [i, sense] of (meaning.definitions ?? []).slice(0, 3).entries()) {
70 lines.push(`${i + 1}. ${sense.definition ?? ''}`)
71 if (sense.example) lines.push(` > ${sense.example}`)
72 }
73 }
74
75 return {
76 term,
77 brief: `${term} (${firstSense.partOfSpeech}): ${firstSense.definitions?.[0]?.definition}`,
78 markdown: lines.join('\n'),
79 }
80}
81
82// Module state; a reload starts it over, which only empties the cache.
83const cache = new Map<string, Definition | undefined>()
84let candidate = ''
85let held = 0
86let shown = ''
87let isPolling = false
88
89/** Resolves the first promise to give a value, or undefined once none has. */
90export const firstDefined = <T,>(promises: Promise<T | undefined>[]): Promise<T | undefined> =>
91 new Promise(resolve => {
92 let pending = promises.length
93 for (const promise of promises) {
94 void promise.then(value => {
95 pending -= 1
96 if (value !== undefined) resolve(value)
97 else if (pending === 0) resolve(undefined)
98 })
99 }
100 })
101
102async function fromHaiku($: EngineInterface, term: string): Promise<Definition | undefined> {
103 const reply = await $.model.complete({
104 model: 'haiku',
105 effort: 'low',
106 maxTokens: 400,
107 timeoutMs: 15000,
108 system:
109 'You are a concise dictionary. Define the term the user gives in its most common sense; ' +
110 'for technical jargon, its usual technical sense. No preamble.',
111 prompt:
112 `Term: ${term}\n\nReply in exactly this shape:\n` +
113 'LINE: <part of speech>: <one-sentence definition, under 25 words>\n' +
114 'FULL:\n<a short markdown entry: part of speech in italics, 1-3 numbered senses, one example>',
115 })
116 if (!reply.isAnswered) return undefined
117 const line = /LINE:\s*(.+)/.exec(reply.text)?.[1]?.trim()
118 const full = /FULL:\s*([\s\S]+)/.exec(reply.text)?.[1]?.trim()
119 if (!line) return undefined
120
121 return {
122 term,
123 brief: `${term} (${line.replace(/:\s*/, '): ')}`,
124 markdown: `## ${term}\n\n${full ?? line}\n\n*Defined by Haiku: no dictionary entry.*`,
125 }
126}
127
128/**
129 * The free dictionary first. A quick miss goes straight to Haiku; a slow
130 * answer (it hangs on words it lacks) races Haiku from HEDGE_MS on.
131 */
132async function define($: EngineInterface, term: string): Promise<Definition | undefined> {
133 const key = term.toLowerCase()
134 if (cache.has(key)) return cache.get(key)
135
136 const dictionary = $.http
137 .fetch(DICTIONARY_URL + encodeURIComponent(key))
138 .then(r => (r.ok ? fromDictionary(term, r.text) : undefined))
139 .catch(() => undefined)
140 const slow = new Promise<'slow'>(resolve => {
141 $.clock.after(HEDGE_MS, () => resolve('slow'))
142 })
143 const early = await Promise.race([dictionary, slow])
144
145 const found =
146 early === 'slow'
147 ? await firstDefined([dictionary, fromHaiku($, term)])
148 : (early ?? (await fromHaiku($, term)))
149
150 if (found !== undefined) cache.set(key, found)
151 return found
152}
153
154/** Watches the mouse selection; a term held still for SETTLE_MS gets a toast. */
155async function poll($: EngineInterface) {
156 if (isPolling) return
157 isPolling = true
158 try {
159 const selected = await $.ui.selection()
160 const text = selected?.text ?? ''
161 if (text !== candidate) {
162 candidate = text
163 held = 0
164 return
165 }
166 held += 1
167 if (held * POLL_MS < SETTLE_MS || text === shown) return
168 shown = text
169
170 const term = asTerm(text)
171 if (term === undefined) return
172 $.ui.status(`define: looking up “${term}”…`)
173 const found = await define($, term)
174 $.ui.status(undefined)
175 // The drag went on while this looked up a fragment of the word; the
176 // whole word gets its own toast once it settles.
177 if ((await $.ui.selection())?.text !== text) return
178 $.ui.toast(
179 found ? `${clip(found.brief, 220)} · /define for more` : `${term}: no definition found`,
180 { timeoutMs: 9000 },
181 )
182 } finally {
183 isPolling = false
184 }
185}
186
187const CONTEXT_QUESTION = (term: string) =>
188 `In 2-4 sentences of plain prose, explain what "${term}" means as it is used in this ` +
189 `conversation and how it applies to what we are discussing. If it has not come up, say so ` +
190 `in a few words and give the sense most relevant to this conversation's subject.`
191
192/**
193 * The newest messages' text, oldest first, within `budget` characters. Tool
194 * calls and their results are left out; what was said carries the meaning.
195 */
196export const excerpt = (messages: { role: string; text: string }[], budget = 24000): string => {
197 const lines: string[] = []
198 let used = 0
199 for (const message of [...messages].reverse()) {
200 const text = message.text.trim()
201 if (text === '') continue
202 const line = `${message.role === 'user' ? 'User' : 'Assistant'}: ${clip(text, 2000)}`
203 if (used + line.length > budget) break
204 lines.unshift(line)
205 used += line.length
206 }
207 return lines.join('\n\n')
208}
209
210/**
211 * A fork has nothing to replay until this process has sent a request, as on
212 * a resumed session before its first turn; Haiku reads the transcript instead.
213 */
214async function fromTranscript($: EngineInterface, term: string): Promise<string> {
215 const transcript = excerpt(await $.session.messages())
216 if (transcript === '') return '*No conversation yet to read it against.*'
217
218 const reply = await $.model.complete({
219 model: 'haiku',
220 effort: 'low',
221 maxTokens: 400,
222 timeoutMs: 20000,
223 system: 'You explain terms as a conversation uses them. Plain prose, no preamble.',
224 prompt: `<conversation>\n${transcript}\n</conversation>\n\n${CONTEXT_QUESTION(term)}`,
225 })
226 return reply.isAnswered
227 ? `${reply.text.trim()}\n\n*Read by Haiku from the recent transcript.*`
228 : `*Could not ask the model: ${reply.reason}.*`
229}
230
231async function fillContext($: EngineInterface, term: string) {
232 const reply = await $.model.fork({
233 prompt: `[define-word] Pause the task; do not call tools. ${CONTEXT_QUESTION(term)}`,
234 })
235 const text = reply.isAnswered
236 ? reply.text.trim()
237 : reply.reason === 'nothing-to-fork'
238 ? await fromTranscript($, term)
239 : `*Could not ask the model: ${reply.reason}.*`
240 await update($, entry, now => (now?.term === term ? { ...now, context: text } : now))
241}
242
243async function fillDictionary($: EngineInterface, term: string) {
244 const found = await define($, term)
245 const text = found?.markdown ?? `## ${term}\n\n*No definition found.*`
246 await update($, entry, now => (now?.term === term ? { ...now, dictionary: text } : now))
247}
248
249export const register: Register = on => {
250 on('session.start', async ($, e, next) => {
251 await $.command.register({
252 name: 'define',
253 description: 'Define the selected word (or the one given), with its meaning in this conversation',
254 argumentHint: '[word]',
255 immediate: true,
256 })
257 $.clock.every(POLL_MS, () => void poll($))
258
259 return next(e)
260 })
261
262 on('command.run', { command: 'define' }, async ($, e) => {
263 const typed = e.args.trim()
264 const selected = typed === '' ? (await $.ui.selection())?.text : undefined
265 const term = typed !== '' ? clip(typed, 80) : selected ? asTerm(selected, 6) : undefined
266
267 if (term === undefined) {
268 $.ui.toast('define: select a word first, or type /define <word>')
269 return {}
270 }
271
272 await update($, entry, () => ({ term, dictionary: null, context: null }))
273 await $.ui.open({ id: PANE, title: `Define: ${clip(term, 30)}` })
274 void fillDictionary($, term)
275 void fillContext($, term)
276
277 return {}
278 })
279
280 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
281 const { Box, Markdown, Text, Button } = $.ui.resolve(e)
282 const now = await read($, entry)
283
284 if (now === null) {
285 return <Text dimColor>Select a word and run /define.</Text>
286 }
287
288 return (
289 <Box flexDirection="column" gap={1}>
290 {now.dictionary === null ? (
291 <Text dimColor>Looking up “{now.term}”…</Text>
292 ) : (
293 <Markdown text={clip(now.dictionary, 6000)} />
294 )}
295 <Text bold>In this conversation</Text>
296 {now.context === null ? (
297 <Text dimColor>Reading the conversation…</Text>
298 ) : (
299 <Markdown text={clip(now.context, 3000)} />
300 )}
301 <Button key="close" label="Close" onPress={() => $.ui.close({ id: PANE })} />
302 </Box>
303 )
304 })
305}
306types/index.d.ts 16 lines1export type Definition = { term: string; brief: string; markdown: string }
2
3export type Entry = {
4 term: string
5 /** null while loading */
6 dictionary: string | null
7 /** null while loading */
8 context: string | null
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'define-word': { entry: Entry | null }
14 }
15}
16