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

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.
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.
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
| Piece | Job |
|---|---|
ui.render on AssistantMessage | matches words against the skill's vocabulary.txt, draws each as its own hoverable Text |
ui.render on AbovePrompt | one 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.complete | redraws 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.
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.Text nested in a Text cannot be hovered), so long paragraphs can wrap slightly differently.ENGLISH_HOMOGRAPHS in hooks/register.tsx) is never underlined, even when used as Spanish. ñ is kept distinct from n.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.
hooks/register.tsx 545 lines1import 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