SLOPSHOPPER

Define Word

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

newpanecommandtoaststatusmodel
v0.2.1MITupdated 2026-10-03brianium/define-word
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · define-word
│ ┃ define-word ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Select a word and run /define. │ define-word │ │ ⏺ Read(src/auth.ts) │ define: select a word first, or type │ │ ⎿ Read 6 lines │ /define <word> │ │ ⏺ 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 │ │ › /define │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · define-word
Select a word and run /define.
README

define-word

A Claude Code mod for looking up words without leaving the terminal.

  • Select a word (or a phrase of up to three words) with the mouse, and a toast shows a short definition in the top-right corner. The status line says what it's looking up while it works.
  • /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.

Requirements

  • Claude Code 2.1.287 or later (mods). Tested with 2.1.288; the mods API can change between releases.
  • The fullscreen layout, "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.

Install

claude plugin marketplace add brianium/define-word
claude plugin install define-word@brianium

Update with claude plugin update define-word@brianium.

What it sends where, and what it costs

A mod runs with your permissions, so here is everything this one reaches:

WhenWhere it goesCost
You select a termThe term, to the free dictionary at api.dictionaryapi.devFree
The dictionary lacks the term or is slow (> 0.7 s)The term, to Haiku on your plan or API keyOne small completion
You run /defineOne extra question over this conversation, on your main model, with every tool deniedMostly a prompt-cache read, plus a few sentences of output
You run /define in a resumed session before its first new turnThe text of the latest messages (up to about 24,000 characters, tool calls left out), to HaikuOne small completion

Lookups are cached for the session. Run claude plugin validate . in this repository to list every hook and call the mod makes.

How it works

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.

Development

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.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 306 lines
1import { 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}
306
types/index.d.ts 16 lines
1export 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