SLOPSHOPPER

ambient-spanish-hover

Hover a Spanish word in a reply to see its English in the band above the prompt.

newbandrowstoast
A shopper browsing a rack in a slop shop
README

ambient-spanish-hover

A Claude Code mod that underlines the Spanish words in a reply and shows their English in a row above the prompt when you hover one. Claude Code only; the skill itself still runs anywhere.

Install

claude plugin marketplace add Exdenta/ambient-spanish      # or a local clone's path
claude plugin install ambient-spanish-hover@ambient-spanish

Then start a new session, or run /reload-plugins.

Update

Added from a local clone, Claude Code reads the mod straight from the clone: git pull, then /reload-plugins. Added from GitHub, it runs a cached copy:

claude plugin marketplace update ambient-spanish
claude plugin update ambient-spanish-hover@ambient-spanish

How it works

PieceJob
ui.render on AssistantMessagematches words against the skill's vocabulary.txt, draws each as its own hoverable Text
ui.render on AbovePromptone hidden spanish = english entry per word of the replies on screen (about 300 at most, whole oldest replies dropped first), revealed by the same hover scope
turn.completeredraws the row once the turn is over

The vocabulary is read from ~/.codex/state/ambient-spanish/vocabulary.txt, which ambient_state.py lookup rewrites whenever the weekly additions land (the path follows AMBIENT_SPANISH_STATE when it is set, or a state moved with --state, which leaves vocabulary.path beside the default state). The mod re-reads it every 30 seconds, so new words appear without restarting. Every vocabulary word is underlined; there are no bracketed glosses to skip.

Limits

  • Hover needs a terminal that reports the pointer; the fullscreen layout is the documented case.
  • The mod only draws when CLAUDE_CODE_ENTRYPOINT is cli and the message is drawn for the terminal surface; in the desktop app, IDE extensions and the phone (including a CLI session mirrored there) Claude Code draws replies itself, so Spanish words are not underlined there.
  • Only paragraphs that contain a vocabulary word are redrawn by the mod (headings, blockquotes, links, bold, italics and strikethrough are reproduced; nested ordering and spacing may differ a little from Claude Code's own). Paragraphs without one, fenced code (never matched, drawn with its language so it is highlighted) and tables are drawn by Claude Code itself. Table words get no hover; the row above the prompt lists them as plain text instead.
  • Each word is drawn as its own element (a Text nested in a Text cannot be hovered), so long paragraphs can wrap slightly differently.
  • Verbs are matched against their generated regular forms (present, preterite, imperfect, future, conditional, subjunctive, gerund, participle, with -car/-gar/-zar/-ger/-gir spelling changes; reflexive verbs use the base verb). Irregular and stem-changing forms are missed. Any word that is also English (every word of a gloss and its simple inflections, common function words, and ENGLISH_HOMOGRAPHS in hooks/register.tsx) is never underlined, even when used as Spanish. ñ is kept distinct from n.

Develop

claude plugin validate mods/ambient-spanish-hover
claude plugin test mods/ambient-spanish-hover

The tests stub $.env and $.fs, so they cover the drawn trees, not the pointer.

Source 1 files
hooks/register.tsx 545 lines
1import type { Register } from 'claude-code'
2
3type Word = { id: string; es: string; en: string }
4type Piece = { text: string; bold?: boolean; italic?: boolean; strike?: boolean; code?: boolean; dim?: boolean; word?: Word }
5type Line = { indent: number; chunks: Piece[][]; isRule?: boolean; codeText?: string }
6// Blocks without a vocabulary word, and fenced code, go to the engine's own drawing so they
7// look exactly like an ordinary reply. Only paragraphs holding a word are redrawn by the mod.
8type Block =
9  | { kind: 'lines'; lines: Line[] }
10  | { kind: 'engine'; raw: string }
11  | { kind: 'markdown'; raw: string }
12  | { kind: 'code'; source: string; language?: string }
13  | { kind: 'spacer' }
14type Index = { exact: Map<string, Word>; english: Set<string> }
15
16// Relative to $HOME, unless AMBIENT_SPANISH_STATE moves the state; rewritten when weekly words land.
17const VOCAB_FILE = '.codex/state/ambient-spanish/vocabulary.txt'
18// Names the vocabulary file when `ambient_state.py --state` keeps it elsewhere.
19const POINTER_FILE = '.codex/state/ambient-spanish/vocabulary.path'
20// Distinct words the band keeps, counted over whole messages: a screenful with room to spare.
21const MAX_SEEN = 300
22const MAX_PINNED = 24
23const PLACEHOLDER = 'ES  hover a highlighted word for its English'
24// Flip if the terminal shows no bullet, or two, on a redrawn reply's first block.
25const DRAW_BULLET = false
26
27// Table rows cannot be reproduced; they go through the engine's own drawing, block by block.
28const TABLE_ROW = /^\s*\|/
29const FENCE_LINE = /^\s*```/
30const FENCE_LANGUAGE = /^\s*```\s*([\w+#.-]+)/
31// The engine's Code and Markdown elements take at most this many characters.
32const MAX_NATIVE = 10000
33const HEADING = /^#{1,6}\s+/
34const QUOTE = /^>\s?/
35const ITEM_START = /^\s*([-*+]|\d+[.)])\s|^#{1,6}\s|^>/
36const RULE_LINE =/^\s*(---|___)\s*$/
37const RULE_WIDTH = 40
38const INLINE_SEGMENT = /(`[^`]*`|\*\*[^*]+\*\*|\*[^*\s][^*]*\*|~~[^~]+~~|\[[^\]]*\]\([^)]*\))/
39const LINK = /^\[([^\]]*)\]\(([^)]*)\)$/
40
41// Vocabulary forms that are also ordinary English words.
42const ENGLISH_HOMOGRAPHS = new Set(['color', 'final', 'red', 'pan', 'pie', 'sol', 'solo', 'mes', 'mal', 'ser', 'dar', 'ver', 'ir', 'mano', 'media', 'dos', 'cara', 'mesa', 'plaza', 'tapa', 'copia'])
43
44// Common English function words; a token in this set is never underlined.
45const ENGLISH_FUNCTION_WORDS = `the a an this that these those my your his her its our their me you him us them i he she we they it
46is are was were be been being am do does did done have has had having will would can could shall should may might must
47and or but nor so yet if than then as because while although though
48in on at by for with from to of into onto over under about after before between through during up down out off
49not no yes all any some each every both either neither other another such only own same too very just also
50what which who whom whose when where why how there here now more most much many few less than`.split(/\s+/)
51
52// English words that a generated or exact Spanish form spells, found by checking the
53// full A0–C1 lexicon against an English dictionary, and no gloss already covers.
54const ENGLISH_LOOKALIKES = `accede actual aisle amen anterior arena ate cargo carbon carton cartel cede circular
55coda coma combustible concede conductor conserve constructor construe corporal criteria critique culpable
56curriculum curse diversion dole dote dude echo eleven fallen fatal favorable filial fin gene gratis impede
57impute incline integral inversion labor lance lee lesion librarian maxima median minima mania manifesto mire
58mole motive motor natal pare patron posterior postal potable principal probe procure progenitor regimen regular
59remover repose reunion revolver rogue rumor salve salvo sensible sole sonar surge talon tan temporal timbre toss
60tress tribunal valor via vocal`.split(/\s+/)
61
62const STRIP_ACCENTS = /[̀-ͯ]/g
63// Keeps n-tilde as its own letter ("una" is not "una with a tilde"), like _accentless in ambient_state.py.
64const normalize = (s: string) =>
65  s.toLowerCase().normalize('NFC').split('ñ').map(part => part.normalize('NFD').replace(STRIP_ACCENTS, '')).join('ñ')
66
67function addEnglish(set: Set<string>, w: string) {
68  set.add(w)
69  // Inflecting "to" or a possessive's "s" would hide Spanish tos and sed.
70  if (w.length < 3) return
71  set.add(w + 's')
72  set.add(w + 'es')
73  set.add(w + 'ed')
74  set.add(w + 'd')
75  set.add(w + 'ing')
76  if (w.endsWith('e')) set.add(w.slice(0, -1) + 'ing')
77  if (w.endsWith('y')) {
78    set.add(w.slice(0, -1) + 'ies')
79    set.add(w.slice(0, -1) + 'ied')
80  }
81}
82
83const AR = { pres: ['o', 'as', 'a', 'amos', 'ais', 'an'], pret: ['e', 'aste', 'o', 'amos', 'asteis', 'aron'], imp: ['aba', 'abas', 'aba', 'abamos', 'abais', 'aban'], subj: ['e', 'es', 'e', 'emos', 'eis', 'en'], ger: 'ando', part: 'ad' }
84const ER = { pres: ['o', 'es', 'e', 'emos', 'eis', 'en'], pret: ['i', 'iste', 'io', 'imos', 'isteis', 'ieron'], imp: ['ia', 'ias', 'ia', 'iamos', 'iais', 'ian'], subj: ['a', 'as', 'a', 'amos', 'ais', 'an'], ger: 'iendo', part: 'id' }
85const IR = { ...ER, pres: ['o', 'es', 'e', 'imos', 'is', 'en'] }
86const FUTURE = ['e', 'as', 'a', 'emos', 'eis', 'an']
87const CONDITIONAL = ['ia', 'ias', 'ia', 'iamos', 'iais', 'ian']
88
89// Regular forms of a verb, accents already stripped (the same way tokens are normalized).
90function verbForms(inf: string): string[] {
91  const kind = inf.slice(-2)
92  const stem = inf.slice(0, -2)
93  if (stem.length < 2 || !['ar', 'er', 'ir'].includes(kind)) return []
94  const set = kind === 'ar' ? AR : kind === 'er' ? ER : IR
95  // Spelling that keeps the sound before e (-ar) or before a/o (-ger, -gir).
96  let soft = stem
97  if (kind === 'ar') {
98    if (stem.endsWith('c')) soft = stem.slice(0, -1) + 'qu'
99    else if (stem.endsWith('g')) soft = stem + 'u'
100    else if (stem.endsWith('z')) soft = stem.slice(0, -1) + 'c'
101  }
102  const hard = kind !== 'ar' && stem.endsWith('g') ? stem.slice(0, -1) + 'j' : stem
103  const out: string[] = []
104  const add = (base: string, endings: string[]) => { for (const e of endings) out.push(base + e) }
105  const front = kind === 'ar' ? soft : stem // before e in -ar, otherwise the plain stem
106  add(hard, [set.pres[0]])
107  add(stem, set.pres.slice(1))
108  out.push(front + set.pret[0])
109  add(stem, set.pret.slice(1))
110  add(stem, set.imp)
111  add(inf, FUTURE)
112  add(inf, CONDITIONAL)
113  add(kind === 'ar' ? soft : hard, set.subj)
114  out.push(stem + set.ger)
115  for (const e of ['o', 'a', 'os', 'as']) out.push(stem + set.part + e)
116  return out
117}
118
119function buildIndex(raw: string): Index {
120  const exact = new Map<string, Word>()
121  const english = new Set<string>([...ENGLISH_FUNCTION_WORDS, ...ENGLISH_LOOKALIKES, ...ENGLISH_HOMOGRAPHS])
122  const derived: [string, Word][] = []
123  let pos = ''
124
125  for (const line of raw.split('\n')) {
126    if (line.startsWith('## ')) {
127      pos = line.slice(3).split(' ')[0]
128      continue
129    }
130    if (line.startsWith('#') || !line.trim()) continue
131
132    const [id, es, en] = line.split(' | ').map(s => s.trim())
133    if (!id || !es || !en || es.includes(' ')) continue
134
135    for (const w of en.toLowerCase().split(/[^a-z]+/)) if (w) addEnglish(english, w)
136
137    const n = normalize(es)
138    if (ENGLISH_HOMOGRAPHS.has(n)) continue
139    const word: Word = { id, es, en }
140    exact.set(n, word)
141
142    if (pos === 'verb') {
143      const reflexive = /(ar|er|ir)se$/.test(n)
144      const inf = reflexive ? n.slice(0, -2) : n
145      if (reflexive) derived.push([inf, word])
146      for (const form of verbForms(inf)) derived.push([form, word])
147      continue
148    }
149    if (pos !== 'noun' && pos !== 'adjective') continue
150    const forms = [n + 's', n + 'es']
151    if (n.endsWith('o')) {
152      const base = n.slice(0, -1)
153      forms.push(base + 'a', base + 'as', base + 'os')
154    }
155    for (const form of forms) derived.push([form, word])
156  }
157  // An exact vocabulary form always beats a generated one.
158  for (const [form, word] of derived) if (!exact.has(form)) exact.set(form, word)
159  return { exact, english }
160}
161
162function lookup(index: Index, token: string): Word | undefined {
163  const n = normalize(token)
164  return index.english.has(n) ? undefined : index.exact.get(n)
165}
166
167// Splits a plain run around the Spanish words it holds.
168function pieceRun(index: Index, text: string, style: { bold?: boolean; italic?: boolean; strike?: boolean }): Piece[] {
169  const pieces: Piece[] = []
170  let last = 0
171  for (const m of text.matchAll(/[\p{L}\p{M}]+/gu)) {
172    const word = lookup(index, m[0])
173    if (!word) continue
174    if (m.index > last) pieces.push({ text: text.slice(last, m.index), ...style })
175    pieces.push({ text: m[0], word, ...style })
176    last = m.index + m[0].length
177  }
178  if (last < text.length) pieces.push({ text: text.slice(last), ...style })
179  return pieces
180}
181
182function parseLine(index: Index, raw: string): Line {
183  if (RULE_LINE.test(raw)) return { indent: 0, chunks: [], isRule: true }
184  const isHeading = HEADING.test(raw)
185  const isQuote = !isHeading && QUOTE.test(raw)
186  const stripped = isHeading ? raw.replace(HEADING, '') : isQuote ? raw.replace(QUOTE, '') : raw
187  const bulleted = stripped.replace(/^(\s*)[-*]\s+/, '$1• ')
188  const indent = bulleted.length - bulleted.trimStart().length
189  const chunks: Piece[][] = isQuote ? [[{ text: '│', dim: true }]] : []
190  let chunk: Piece[] = []
191  const close = () => {
192    if (chunk.length) chunks.push(chunk)
193    chunk = []
194  }
195
196  for (const seg of bulleted.trim().split(INLINE_SEGMENT)) {
197    if (!seg) continue
198    const link = LINK.exec(seg)
199    const isCode = !link && seg.startsWith('`') && seg.endsWith('`') && seg.length > 1
200    const isBold = !link && seg.startsWith('**') && seg.endsWith('**') && seg.length > 4
201    const isStrike = !link && seg.startsWith('~~') && seg.endsWith('~~') && seg.length > 4
202    const isItalic = !link && !isBold && !isCode && seg.startsWith('*') && seg.endsWith('*') && seg.length > 2
203    const body = link
204      ? link[1]
205      : isCode
206        ? seg.slice(1, -1)
207        : isBold || isStrike
208          ? seg.slice(2, -2)
209          : isItalic
210            ? seg.slice(1, -1)
211            : seg
212
213    for (const part of body.split(/(\s+)/)) {
214      if (!part) continue
215      if (/^\s+$/.test(part)) close()
216      else if (isCode) chunk.push({ text: part, code: true })
217      else chunk.push(...pieceRun(index, part, { bold: isBold || isHeading, italic: isItalic, strike: isStrike }))
218    }
219    if (link) chunk.push({ text: `(${link[2]})`, dim: true })
220  }
221  close()
222  return { indent, chunks }
223}
224
225// Fenced code and paragraphs without a vocabulary word are drawn by the engine; tables too.
226// Code lines stay unmatched so `buscar` in a command is not underlined.
227function splitBlocks(index: Index, text: string): Block[] {
228  const blocks: Block[] = []
229  let paragraph: string[] = []
230  let fence: { language?: string; rows: string[] } | null = null
231
232  // A list item, heading or quote starts a new item; other rows continue the one before.
233  // Only items holding a word are redrawn, so the rest of a list keeps the engine's own look.
234  const flushParagraph = () => {
235    if (!paragraph.length) return
236    const items: string[][] = []
237    for (const row of paragraph) {
238      if (!items.length || ITEM_START.test(row)) items.push([row])
239      else items[items.length - 1].push(row)
240    }
241    paragraph = []
242
243    let native: string[] = []
244    const flushNative = () => {
245      if (!native.length) return
246      const raw = native.join('\n')
247      blocks.push(
248        raw.length > MAX_NATIVE
249          ? { kind: 'lines', lines: native.map(row => parseLine(index, row)) }
250          : { kind: 'markdown', raw },
251      )
252      native = []
253    }
254    for (const rows of items) {
255      const lines = rows.map(row => parseLine(index, row))
256      if (lines.some(l => wordsOf(l.chunks).length) || rows.join('\n').length > MAX_NATIVE) {
257        flushNative()
258        blocks.push({ kind: 'lines', lines })
259      } else {
260        native.push(...rows)
261      }
262    }
263    flushNative()
264  }
265  const flushFence = () => {
266    if (!fence) return
267    blocks.push({ kind: 'code', source: fence.rows.join('\n'), language: fence.language })
268    fence = null
269  }
270
271  for (const row of text.split('\n')) {
272    if (FENCE_LINE.test(row)) {
273      if (fence) {
274        flushFence()
275      } else {
276        flushParagraph()
277        fence = { language: FENCE_LANGUAGE.exec(row)?.[1], rows: [] }
278      }
279    } else if (fence) {
280      fence.rows.push(row)
281    } else if (TABLE_ROW.test(row)) {
282      flushParagraph()
283      const last = blocks[blocks.length - 1]
284      if (last?.kind === 'engine') last.raw += `\n${row}`
285      else blocks.push({ kind: 'engine', raw: row })
286    } else if (!row.trim()) {
287      flushParagraph()
288      blocks.push({ kind: 'spacer' })
289    } else {
290      paragraph.push(row)
291    }
292  }
293  flushParagraph()
294  flushFence()
295  return blocks
296}
297
298const wordsOf = (chunks: Piece[][]): Word[] => chunks.flat().flatMap(p => (p.word ? [p.word] : []))
299
300let indexLoad: Promise<Index | null> | undefined
301let indexRaw = ''
302let indexAt = 0
303// New words land weekly while a session may stay open, so re-read the file now and then.
304const INDEX_REFRESH_MS = 30_000
305let hasWarned = false
306
307async function vocabPath($: any): Promise<string> {
308  const home = await $.env.get('HOME')
309  const state: string | undefined = await $.env.get('AMBIENT_SPANISH_STATE')
310  if (state) return `${state.replace(/[^/]*$/, '')}vocabulary.txt`
311  // A state moved with --state leaves a pointer beside the default one.
312  try {
313    const target = (await $.fs.read(`${home}/${POINTER_FILE}`)).trim()
314    if (target) return target
315  } catch {}
316  return `${home}/${VOCAB_FILE}`
317}
318
319// Hover only works in the terminal CLI; the desktop app and IDE extensions set another entrypoint.
320// A CLI session mirrored to the phone or desktop app still draws each message for that
321// surface too, so hooks also check `e.surface`: only the terminal gets the redraw.
322let isCli: Promise<boolean> | undefined
323const onCli = ($: any): Promise<boolean> =>
324  (isCli ??= Promise.resolve($.env.get('CLAUDE_CODE_ENTRYPOINT')).then(v => v === 'cli', () => false))
325
326function loadIndex($: any): Promise<Index | null> {
327  const stale = indexLoad && Date.now() - indexAt > INDEX_REFRESH_MS
328  if (stale) {
329    const previous = indexLoad
330    indexAt = Date.now()
331    indexLoad = (async () => {
332      try {
333        const raw: string = await $.fs.read(await vocabPath($))
334        if (raw === indexRaw) return await previous
335        indexRaw = raw
336        return buildIndex(raw)
337      } catch {
338        return await previous
339      }
340    })()
341  }
342  // A failed first load is retried at most every refresh interval, not on every render.
343  if (!indexLoad && Date.now() - indexAt < INDEX_REFRESH_MS) return Promise.resolve(null)
344  indexLoad ??= (async () => {
345    indexAt = Date.now()
346    try {
347      indexRaw = await $.fs.read(await vocabPath($))
348      return buildIndex(indexRaw)
349    } catch {
350      indexLoad = undefined
351      if (!hasWarned) {
352        hasWarned = true
353        $.ui.toast('ambient-spanish-hover: cannot read the vocabulary file')
354      }
355      return null
356    }
357  })()
358  return indexLoad
359}
360
361// What each reply block on screen contributes to the band, keyed by the block's own
362// requestId (the engine's message id: stable across redraws and streaming). A block
363// registers its whole word list, so a partial render replaces what it said before.
364type Entry = { hover: Word[]; table: Word[]; sig: string }
365type Band = { hover: Word[]; table: Word[] }
366
367function createBand() {
368  // Insertion order is age: the first block registered is the oldest.
369  const entries = new Map<string, Entry>()
370  let flushed = ''
371
372  const distinct = (lists: Word[][], max: number): Word[] => {
373    const out = new Map<string, Word>()
374    for (const list of lists) for (const w of list) if (!out.has(w.id)) out.set(w.id, w)
375    return [...out.values()].slice(0, max)
376  }
377  const countOf = (pick: (e: Entry) => Word[]) => new Set([...entries.values()].flatMap(e => pick(e).map(w => w.id))).size
378
379  // Drops whole oldest blocks, never words of recent ones, and never the newest block.
380  const trim = (pick: (e: Entry) => Word[], max: number) => {
381    while (entries.size > 1 && countOf(pick) > max) {
382      const oldest = entries.keys().next().value as string
383      entries.delete(oldest)
384    }
385  }
386
387  // Union over registered blocks, newest first.
388  const view = (): Band => {
389    const newest = [...entries.values()].reverse()
390    return { hover: distinct(newest.map(e => e.hover), MAX_SEEN), table: distinct(newest.map(e => e.table), MAX_PINNED) }
391  }
392  const keyOf = (b: Band) => `${b.hover.map(w => w.id).join(',')}|${b.table.map(w => w.id).join(',')}`
393
394  return {
395    view,
396    register(id: string, hover: Word[], table: Word[]) {
397      // A reply without words adds nothing, so it takes no entry: the registry stays as
398      // small as the band, however long the session.
399      if (!hover.length && !table.length) {
400        entries.delete(id)
401        return
402      }
403      const sig = `${hover.map(w => w.id).join(',')}|${table.map(w => w.id).join(',')}`
404      const previous = entries.get(id)
405      if (previous?.sig === sig) return
406      entries.set(id, { hover, table, sig }) // a replaced entry keeps its age
407      trim(e => e.hover, MAX_SEEN)
408      trim(e => e.table, MAX_PINNED)
409    },
410    // The band is drawn before a reply's words are known; redraw it once they are, and only
411    // when the words it would show have changed.
412    // Returns true once per change; the caller invalidates ($ cannot be passed around).
413    takeChange(): boolean {
414      const key = keyOf(view())
415      if (key === flushed) return false
416      flushed = key
417      return true
418    },
419  }
420}
421
422async function drawByEngine(next: any, e: any, raw: string): Promise<any> {
423  try {
424    return await next({ ...e, props: { ...e.props, text: raw, isFirstOfReply: false } })
425  } catch {
426    return null
427  }
428}
429
430export const register: Register = on => {
431  const band = createBand()
432
433  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
434    if (e.surface !== 'terminal' || !(await onCli($))) return next(e)
435    const index = await loadIndex($)
436    if (!index) return next(e)
437
438    const blocks = splitBlocks(index, e.props.text)
439    const hoverWords = blocks.flatMap(b => (b.kind === 'lines' ? b.lines.flatMap(l => wordsOf(l.chunks)) : []))
440    const tableWords = blocks.flatMap(b =>
441      b.kind === 'engine' ? b.raw.split('\n').flatMap(row => wordsOf(parseLine(index, row).chunks)) : [],
442    )
443    // Two blocks sharing one key would replace each other on every redraw and never settle.
444    band.register(e.requestId || `text:${e.props.text}`, hoverWords, tableWords)
445    // The reply finishes drawing after turn.complete, so the band must be told here.
446    if (band.takeChange()) $.ui.invalidate('ui.render')
447    if (!hoverWords.length) return next(e)
448
449    // Sequential: each call walks the engine's drawing chain for one table.
450    const engineNodes: any[] = []
451    for (const b of blocks) engineNodes.push(b.kind === 'engine' ? await drawByEngine(next, e, b.raw) : null)
452
453    const { Box, Text, Markdown, Code } = $.ui.resolve(e)
454    const style = (p: Piece) => ({
455      bold: p.bold,
456      italic: p.italic,
457      strikethrough: p.strike,
458      dimColor: p.dim,
459      color: p.code ? 'yellow' : undefined,
460    })
461
462    const drawPiece = (p: Piece) =>
463      p.word ? (
464        <Text underline hover={{ scope: `es-${p.word.id}`, bold: true, color: 'cyan' }}>
465          {p.text}
466        </Text>
467      ) : (
468        <Text {...style(p)}>{p.text}</Text>
469      )
470
471    const drawLine = (line: Line) =>
472      line.isRule ? (
473        <Text dimColor>{'─'.repeat(RULE_WIDTH)}</Text>
474      ) : line.codeText !== undefined ? (
475        <Text color="yellow">{line.codeText || ' '}</Text>
476      ) : line.chunks.length ? (
477        <Box flexDirection="row" flexWrap="wrap" columnGap={1} paddingLeft={line.indent}>
478          {line.chunks.map(chunk =>
479            chunk.length === 1 ? drawPiece(chunk[0]) : <Box flexDirection="row">{chunk.map(drawPiece)}</Box>,
480          )}
481        </Box>
482      ) : (
483        <Text> </Text>
484      )
485
486    // A null node means the engine call failed; the raw rows still show.
487    const drawBlock = (block: Block, i: number) => {
488      switch (block.kind) {
489        case 'lines':
490          return block.lines.map(drawLine)
491        case 'markdown':
492          return <Markdown text={block.raw} />
493        case 'code':
494          return block.source.length > MAX_NATIVE
495            ? block.source.split('\n').map(row => <Text>{row || ' '}</Text>)
496            : <Code source={block.source} language={block.language} />
497        case 'spacer':
498          return <Text> </Text>
499        default:
500          return engineNodes[i] ?? block.raw.split('\n').map(row => <Text>{row}</Text>)
501      }
502    }
503
504    return (
505      <Box flexDirection="row">
506        {DRAW_BULLET && e.props.isFirstOfReply ? <Text>{'● '}</Text> : null}
507        <Box flexDirection="column" flexGrow={1}>
508          {blocks.flatMap(drawBlock)}
509        </Box>
510      </Box>
511    )
512  })
513
514  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
515    const { hover: seen, table: pinned } = band.view()
516    if (e.surface !== 'terminal' || e.props.hasSurvey || !(seen.length || pinned.length)) return next(e)
517
518    const { Box, Text } = $.ui.resolve(e)
519    const pad = PLACEHOLDER.length + 2
520
521    // One hover row always, so a reveal never changes the layout under the pointer.
522    return (
523      <Box flexDirection="column">
524        {seen.length ? (
525          <Box height={1}>
526            <Text dimColor>{PLACEHOLDER}</Text>
527            {seen.map(w => (
528              <Box position="absolute" top={0} left={0} display="none" hover={{ scope: `es-${w.id}`, display: 'flex' }}>
529                <Text color="cyan">{`${w.es} = ${w.en}`.padEnd(pad)}</Text>
530              </Box>
531            ))}
532          </Box>
533        ) : null}
534        {pinned.length ? <Text color="cyan">{pinned.map(w => `${w.es} = ${w.en}`).join('  ·  ')}</Text> : null}
535      </Box>
536    )
537  })
538
539  // The band draws before a reply's words are known; redraw it once the turn is over.
540  on('turn.complete', ($, e, next) => {
541    if (band.takeChange()) $.ui.invalidate('ui.render')
542    return next(e)
543  })
544}
545