Stretchtext replies for Claude Code: short by default, each phrase grows in place when you click it

Claude writes a lot. Now you can read a little.

Replies arrive short. Click a linked phrase and it grows right where it stands. Click « and it shrinks back. Claude is told which parts you opened, so it doesn't assume you read the rest.
In a Claude Code session:
/plugin install cc-stretch --marketplace pro-vi/cc-stretch
/tui fullscreen
In the terminal, clicks need Claude Code's fullscreen renderer. /tui fullscreen switches to it, restarting and resuming your session. If stretches still show open, see Limits. In the Claude desktop app stretches are links: click one to open it in place, click « to close it. No setting needed.
To update, run claude plugin update cc-stretch@cc-stretch in a shell, or open /plugin, find cc-stretch under Installed and choose Update now. As of Claude Code 2.1.302 a plugin from this marketplace updates on its own only once you turn on auto-update for the marketplace in /plugin's Marketplaces tab.
You read about a sixth of what Claude writes. Across 69 real replies from four of my coding projects, the version you see before clicking was 17% of the words Claude wrote without the plugin. It carries about two of every three most important points; the rest is one click away. My replies run long, so if yours are short you will save less. How this was measured, and how to run it on your own transcripts: bench/.
In 1967 Ted Nelson described stretchtext: text that "stretches, becoming longer, with replacement phrases, new details and additional clauses popping into place", and shrinks back on demand, while "the reader remains oriented" (Hypertext Note 8). In 1970 he put it like this: "Gaps appear between phrases; new words and phrases pop into the gaps, an item at a time."
It never caught on. The hard part, as later writers put it, was "how to author the text in the first place" (Wadge and schraefel, 1999): someone had to write every level by hand.
Language models write every level for free, and they are good at it. So all this plugin does is plug a 59-year-old idea into a terminal. The idea is Nelson's. Most of the code is Claude's. I mostly clicked things.
Here is what that looks like. The reply opens with
A monad is a type that wraps values and lets you chain steps that each return a wrapped value.
and one click on that sentence turns it into this (new words in bold, the way Dream Machines appears to set the words Nelson's own example adds):
In programming, a monad is a generic type that wraps values, and it lets you chain steps that each take a plain value and return a wrapped value, without unwrapping anything by hand between the steps.
The words you already read never move. New ones open up around them, on both sides.
The point of the plugin is the gap between them. Nelson's word for this was levels; ours is progressive attention. Progressive disclosure is the interface deciding what to hide from you. Here you spend your attention one phrase at a time, and because nothing you have read moves, you never lose your place.
app:diffFileListDown or app:diffFileListUp in the Global context of ~/.claude/keybindings.json./cc-stretch:style and a toolbar above the prompt restyles every reply as you click: marks (bold, italic, underline, strikethrough, dim) and a highlight, either one hex colour or tinted, which highlights each closed stretch in the colour its added words are drawn in once you open it, blended 75% into the background. Under a highlight, dim moves the text toward the highlight. save keeps the style in two plugin settings, marks and highlight, which as of Claude Code 2.1.302 /plugin's Configure options also change; close puts the toolbar away and drops a style you tried but did not save. In the Claude desktop app, where cc-stretch draws stretches as links, there is no toolbar.tmux -CC, SSH from Windows, or a machine where an earlier fullscreen start failed. Starting Claude Code with CLAUDE_CODE_NO_FLICKER=1 forces fullscreen there.# of an id such as #12806 stays in the word and any full stops after it stay out. In the words before a lone period, a double-click copies a word as well, so a single click there opens its stretch half a second later, once no second click can follow. A ctrl- or alt-click on an http: or https: link in such a paragraph opens it, as Claude Code 2.1.304 does in a paragraph without a stretch; a cmd-click, where the terminal keeps one, is the terminal's own.{{short|long}} markup shows as raw text wherever the plugin isn't loaded.npm test # claude plugin validate . && claude plugin test .
npm run typecheck # the benchmark scripts
The launch film in film/ is made from a real recorded session: film/take.mjs drives Claude Code in a WezTerm pane and records its screen, and film/compose.mjs renders the film. Each release from 0.4.0 on has a film of its own: film/releases/<version>/ holds its take script and cut, and node film/compose.mjs --release=<version> renders it. Archive pictures in the launch film: photographs of the Hypertext Editing System at Brown University, October 1969, by Gregory Lloyd (CC BY-SA 4.0, via Wikimedia Commons), and a photograph of Ted Nelson, 2010, by Beatrice Murch (CC BY 2.0, via Wikimedia Commons). They are not stored in this repo; film/CREDITS.md lists each source, its license and where to fetch it into film/out/assets/.
Not affiliated with Anthropic. Clawd belongs to Anthropic; stretchtext belongs to Ted Nelson.
hooks/register.tsx 804 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { ClassicEventOf, ClassicResultOf, EngineInterface, Register, RenderInput, RenderNode, SessionMessage } from 'claude-code'
3
4import { GUIDE } from './guide'
5import { closeAfter, codeSpan, hideLongForms, isLinkable, isOpenable, KEY_SEPARATOR, newStreamState, paragraphsOf, parentOf, parse, stretches } from './parse'
6import type { Segment, StreamState, Stretch } from './parse'
7import { isWebLink, namesOf, openedOf, targetsIn } from './layout'
8import type { Action, OpenedStretch } from './layout'
9import { contrastOn, hex, mix } from './contrast'
10import type { Hex, Rgb } from './contrast'
11import { isColour, isSameLook, lookOf, MARK_NAMES, marksSetting, storedLook, TINTED } from './look'
12import type { DrawnLook, Highlight, Look, Mark } from './look'
13import type { ReplyPost, ReplyProps, Tint } from './reply'
14
15const openStretches = atom({ plugin: 'cc-stretch', key: 'open' } as const, [])
16// Kept under a shape tag: before 0.5.2 it held short forms, and as of Claude Code 2.1.304 plugin state
17// outlives a reload of the plugin's code, so a session that loaded 0.5.1 still holds them; under the
18// tag they read as absent.
19const seen = atom({ plugin: 'cc-stretch', key: 'seen' } as const, [], { shape: 'opened-stretches' })
20const stretchOrder = atom({ plugin: 'cc-stretch', key: 'order' } as const, { order: {}, count: 0 })
21const newestReply = atom({ plugin: 'cc-stretch', key: 'newest' } as const, '')
22const askedStretch = atom({ plugin: 'cc-stretch', key: 'ask' } as const, null)
23const lettering = atom({ plugin: 'cc-stretch', key: 'lettering' } as const, null)
24const pickedLook = atom({ plugin: 'cc-stretch', key: 'look' } as const, null)
25const toolbarShown = atom({ plugin: 'cc-stretch', key: 'toolbar' } as const, false)
26/** Plugin state, not module state: as of Claude Code 2.1.302 each setting written reloads the plugin, and a save outlives the reload. */
27const toolbarHolder = atom({ plugin: 'cc-stretch', key: 'busy' } as const, null)
28/** Plugin state for the same reason: a save can land while a reply streams, and its next flush continues from here. */
29const streamCheckpoints = atom({ plugin: 'cc-stretch', key: 'streams' } as const, {})
30
31// The highlight colours the toolbar offers: deep colours, whose text turns light. A light highlight would draw
32// dark text on a light ground, as a selection does. No text colour is offered: links, code and the
33// words a click added already speak in colour.
34const SWATCHES = ['#264f78', '#1f3a5f', '#3b4261', '#4b3b6b', '#5c2a4a', '#5f2a2a', '#5c4a00', '#1e4d2b', '#1d4b4b'] as const
35
36// Claude Code 2.1.300 can start a session on the main screen even with fullscreen set, and the
37// variable overrides that.
38const FULLSCREEN_HOW = 'run /tui fullscreen. If it says fullscreen is already on, start Claude Code with CLAUDE_CODE_NO_FLICKER=1, which forces fullscreen.'
39const MAIN_SCREEN_NOTE =
40 "cc-stretch: stretches are open because this session is on Claude Code's main screen, where a printed reply cannot change. " +
41 `For clickable stretches, ${FULLSCREEN_HOW}`
42
43// The words a stretch adds are drawn in Claude Code's own theme colours (its 2.1.300 values for
44// warning, autoAccept, planMode and claude), mixed 45% toward the text colour so they tint rather
45// than shout. Each open stretch keeps its colour; all but the newest take a faded form of it, mixed
46// 35% toward the background, quieter but still that colour. In the tinted highlight a closed stretch
47// sits on the same theme colour mixed 75% toward the background, deep on the dark theme and pale on the
48// light one; on both, the text contrastOn picks reads at 10:1 or more.
49const BASE: Record<'dark' | 'light', readonly [Rgb, Rgb, Rgb, Rgb]> = {
50 dark: [[255, 193, 7], [175, 135, 255], [72, 150, 140], [215, 119, 87]],
51 light: [[150, 108, 30], [135, 0, 255], [0, 102, 102], [215, 119, 87]],
52}
53const WHITE: Rgb = [255, 255, 255]
54const BLACK: Rgb = [0, 0, 0]
55const paletteOf = (base: readonly Rgb[], text: Rgb, background: Rgb) =>
56 base.map(rgb => {
57 const soft = mix(rgb, text, 0.45)
58 return { soft: hex(soft), faded: hex(mix(soft, background, 0.35)), highlight: hex(mix(rgb, background, 0.75)) }
59 })
60const PALETTES = { dark: paletteOf(BASE.dark, WHITE, BLACK), light: paletteOf(BASE.light, BLACK, WHITE) }
61/** How many colours stretches take in turn. */
62const HUES = BASE.dark.length
63/** `count`, or the next count when `count` would give the colour of the stretch holding it, whose count is `parent`. */
64const hueAfter = (count: number, parent: number | undefined) => (parent !== undefined && count % HUES === parent % HUES ? count + 1 : count)
65/** The colours (a palette entry) a count or a place picks, taken in turn. */
66const colourAt = <Entry,>(palette: readonly Entry[], n: number): Entry | undefined => palette[n % HUES]
67
68// Every link the mod draws is answered by the mod: a click opens or closes,
69// and the link is never followed. A ctrl- or cmd-click opens it in a browser,
70// so it points at a reserved name that resolves nowhere.
71const HREF = 'https://cc-stretch.invalid/'
72
73/**
74 * A streaming reply's filter state, with what it held and what it was shown as so far; `isWhole` is
75 * false for one first seen midway, after the plugin loaded again, whose text so far is not all of it.
76 */
77type Stream = { state: StreamState; raw: string; shown: string; isWhole: boolean }
78
79/** The letters, in row order: home, top, bottom. */
80const LETTERS = 'asdfghjklqwertyuiopzxcvbnm'
81// As of Claude Code 2.1.301 a plugin has no chord of its own: a Button naming one of the engine's
82// keybinding actions is pressed by that action's chords while no engine handler of it is mounted.
83// These two actions scroll the diff panel's file list, bound in its Global context to alt+down and
84// ctrl+down, and alt+up and ctrl+up.
85const CHORDS: Record<Action, string> = { open: 'app:diffFileListDown', close: 'app:diffFileListUp' }
86/** How many of the latest replies on screen holding a stretch take letters. */
87const LETTERED_REPLIES = 3
88/** How many of the latest replies drawn by Clients are kept, to find the ones that take letters. */
89const KEPT_DRAWN_REPLIES = 50
90
91/**
92 * A reply drawn by Clients: its text, whether any of its rows are on screen, and its paragraphs that
93 * hold a stretch, with each one's state member.
94 */
95type DrawnReply = { text: string; isOnScreen: boolean; paragraphs: { member: string; text: string }[] }
96// Module state, not an atom: as of Claude Code 2.1.301 a render hook cannot write plugin state.
97const drawnReplies = new Map<string, DrawnReply>()
98
99/** A reply's text with its whitespace gone, as the site may break and trim its lines. */
100const keyOf = (text: string) => text.replace(/\s+/g, '')
101
102// Claude Code 2.1.299 and 2.1.300 hand the AssistantMessage site the reply as the streaming filter
103// left it, with its long forms gone. Each filtered reply is kept by what it was shown as, so the
104// site can draw it from its markup again; null for one that held no stretch. Replies it could not
105// keep, from before the plugin last loaded or whose final flush it did not see whole, are read from the
106// session's messages when a draw first finds one missing.
107const unfiltered = new Map<string, string | null>()
108/** The read of the session's replies into `unfiltered`: undefined until a draw needs it, after a read that failed, and after a reply not kept has finished. */
109let sessionKept: Promise<void> | undefined
110
111function remember(raw: string, shown: string): void {
112 unfiltered.set(keyOf(shown), raw === shown ? null : raw)
113}
114
115/** Sets `key` in `map` as its newest entry, dropping the oldest past `size`. */
116function setNewest<V>(map: Map<string, V>, key: string, value: V, size: number): void {
117 map.delete(key)
118 map.set(key, value)
119 for (const old of map.keys()) {
120 if (map.size <= size) break
121 map.delete(old)
122 }
123}
124
125/** The text a reply was written with, markup and all, when `text` is how the filter showed it. */
126async function rawOf($: EngineInterface, text: string): Promise<string | undefined> {
127 const key = keyOf(text)
128 if (!unfiltered.has(key)) await (sessionKept ??= keepSession($))
129 return unfiltered.get(key) ?? undefined
130}
131
132/** Keeps every reply of the session by what the streaming filter shows of it. */
133async function keepSession($: EngineInterface): Promise<void> {
134 const messages = await $.session.messages().catch(() => undefined)
135 if (messages === undefined) {
136 sessionKept = undefined
137 return
138 }
139 for (const message of messages) if (message.role === 'assistant') remember(message.text, hideLongForms(message.text, newStreamState()))
140}
141
142/** One filter state per streaming message, until its final flush, or until another begins while none of its flushes is pending; null for a message left as written. */
143const streams = new Map<string, Stream | null>()
144/** Each streaming message's latest flush, which its next one waits for. */
145const flushes = new Map<string, Promise<void>>()
146
147type Flush = ClassicEventOf['classic.MessageDisplay']
148type Shown = ClassicResultOf['classic.MessageDisplay']
149/** A streaming reply's flush with each stretch shown as its short form. */
150async function display($: EngineInterface, e: Flush, answer: Shown): Promise<Shown> {
151 let stream = streams.get(e.message_id)
152 if (stream === undefined) {
153 // As of Claude Code 2.1.302 a message that begins abandons any not finished, which then gets no
154 // final flush; a finished one may still have flushes on their way.
155 if (e.index === 0) for (const id of streams.keys()) if (!flushes.has(id)) streams.delete(id)
156 // Seen midway: the plugin loaded again while it streamed, and its last flush left its state.
157 const left = e.index === 0 ? undefined : (await read($, streamCheckpoints))[e.message_id]
158 // The desktop app draws a finished reply from this display text, so hiding the long forms here
159 // would strip the markup its links are made from. Decided on a message's first flush, so a
160 // surface that attaches or leaves midway cannot half-filter it.
161 if (left !== undefined) stream = left && { state: left, raw: '', shown: '', isWhole: false }
162 else stream = (await $.session.surfaces()).includes('desktop') ? null : { state: newStreamState(), raw: '', shown: '', isWhole: e.index === 0 }
163 streams.set(e.message_id, stream)
164 }
165
166 let displayContent = answer.displayContent
167 if (stream !== null) {
168 const delta = answer.displayContent ?? e.delta
169 displayContent = hideLongForms(delta, stream.state)
170 stream.raw += delta
171 stream.shown += displayContent
172 }
173 if (e.final) {
174 streams.delete(e.message_id)
175 // A reply seen only in part is read from the session's messages when it is drawn.
176 if (stream?.isWhole) remember(stream.raw, stream.shown)
177 else if (stream !== null) sessionKept = undefined
178 }
179 await update($, streamCheckpoints, () => Object.fromEntries([...streams].map(([id, kept]) => [id, kept && kept.state])))
180 return stream === null ? answer : { ...answer, displayContent }
181}
182
183/**
184 * Writes the style being tried into the plugin's settings, the ones that differ from `saved`, then
185 * drops the try unless a press since made it differ again. A refusal keeps the try and says what was
186 * and was not saved. As of Claude Code 2.1.302 the settings land in the person's user settings, keyed
187 * `<plugin>.<field>`, and each write reloads the plugin with them.
188 */
189function saveLook($: EngineInterface, saved: Look): Promise<void> {
190 return withClaim($, 'save', async () => {
191 // Read when pressed: a mark pressed after the toolbar was last drawn is part of what is saved.
192 const tried = storedLook(await read($, pickedLook))
193 if (tried === null) return
194 const fields: [string, string, string][] = [
195 ['marks', marksSetting(tried.marks), marksSetting(saved.marks)],
196 ['highlight', tried.highlight, saved.highlight],
197 ]
198 const written: string[] = []
199 for (const [field, value] of fields.filter(([, value, before]) => value !== before)) {
200 const result = await $.config.set({ key: `cc-stretch.${field}`, value }).catch((error: unknown) => ({ deny: error instanceof Error ? error.message : String(error) }))
201 if (result.deny !== undefined) {
202 $.ui.toast(written.length === 0 ? `cc-stretch: the style was not saved: ${result.deny}` : `cc-stretch: ${written.join(' and ')} saved, ${field} not saved: ${result.deny}`)
203 return
204 }
205 written.push(field)
206 }
207 await update($, pickedLook, stored => (isSameLook(storedLook(stored) ?? saved, tried) ? null : stored))
208 })
209}
210
211/** Drops the try, unless a save or a close holds the claim: a reset drawn before it began does nothing then. */
212async function resetTry($: EngineInterface): Promise<void> {
213 if ((await read($, toolbarHolder)) === null) await update($, pickedLook, () => null)
214}
215
216/**
217 * Puts the toolbar away; a try not saved goes with it, and the reader is told so. It takes the same
218 * claim as a save, so a close and a save pressed together never both act on the try.
219 */
220function closeToolbar($: EngineInterface, saved: Look): Promise<void> {
221 return withClaim($, 'close', async () => {
222 const [stored] = await Promise.all([read($, pickedLook), update($, toolbarShown, () => false)])
223 await update($, pickedLook, () => null)
224 const tried = storedLook(stored)
225 if (tried !== null && !isSameLook(tried, saved)) $.ui.toast('cc-stretch: the style you tried was not saved')
226 })
227}
228
229/**
230 * Runs a save or a close under the toolbar's one-at-a-time claim, taken before it reads the try and
231 * released after, so a second press, or one from a toolbar drawn before the first began, finds the
232 * claim held and does nothing.
233 */
234async function withClaim($: EngineInterface, holder: 'save' | 'close', run: () => Promise<void>): Promise<void> {
235 const taken = { isMine: false }
236 await update($, toolbarHolder, held => {
237 taken.isMine = held === null
238 return taken.isMine ? holder : held
239 })
240 if (!taken.isMine) return
241 try {
242 await run()
243 } finally {
244 await update($, toolbarHolder, () => null)
245 }
246}
247
248/**
249 * The command that opens a URL on this machine, the URL its last argument: `open` on macOS; on
250 * Windows, where no `uname` starts or Git Bash's or Cygwin's names itself,
251 * `rundll32 url.dll,FileProtocolHandler`, which runs no shell, where `cmd /c start` would read an `&`
252 * or a `|` in the URL as its own; elsewhere `xdg-open`, its output sent to /dev/null, since a browser
253 * it starts can keep those pipes, and `$.process.run` waits for them to close.
254 *
255 * As of Claude Code 2.1.306 a ctrl- or alt-click on a hyperlink in a paragraph a Client draws reaches
256 * the Client, and as of 2.1.302 a Client has no call that opens a URL. So the hooks module opens it,
257 * as Claude Code 2.1.304 opens a link in a paragraph it draws itself.
258 */
259async function openerOf($: EngineInterface): Promise<readonly string[]> {
260 const system = await $.process.run(['uname', '-s']).then(({ stdout }) => stdout.trim(), () => undefined)
261 if (system === 'Darwin') return ['open']
262 if (system === undefined || /^(MINGW|MSYS|CYGWIN)/.test(system)) return ['rundll32', 'url.dll,FileProtocolHandler']
263 return ['sh', '-c', 'xdg-open "$1" </dev/null >/dev/null 2>&1', 'sh']
264}
265
266/** Opens `href` as the machine opens a URL, and says so in a toast when it could not. */
267async function follow($: EngineInterface, href: string): Promise<void> {
268 const isOpened = await $.process.run([...(await openerOf($)), href]).then(({ exitCode }) => exitCode === 0, () => false)
269 if (!isOpened) $.ui.toast(`cc-stretch: could not open ${href}`)
270}
271
272export const register: Register = (on, options) => {
273 const baseLook = lookOf(options)
274 // As of Claude Code 2.1.302, /cc-stretch:style is listed from commands/style.md and answered here;
275 // Claude reads the file only when the hooks did not answer. It only shows the toolbar: the band
276 // above the prompt has its own [-], which collapses it without the plugin knowing, so a toggle could
277 // hide a toolbar the reader no longer sees.
278 on('command.run', { command: 'cc-stretch:style' }, async ($, e) => {
279 if (!(await $.session.surfaces()).includes('terminal')) return { text: 'The style toolbar is drawn in the terminal, above the prompt; with no terminal attached there is none.' }
280 if (!e.presentation.isFullscreen) return { text: `The style toolbar is drawn in fullscreen; on the main screen every stretch is drawn open. To use it, ${FULLSCREEN_HOW}` }
281 await update($, toolbarShown, () => true)
282 return { text: 'Style toolbar shown above the prompt; close puts it away.' }
283 })
284
285 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
286 // Terminal only: as of Claude Code 2.1.302 the desktop app draws each Button natively, where a highlight swatch has no label.
287 if (e.surface !== 'terminal' || e.props.hasSurvey || !(await read($, toolbarShown))) return next(e)
288 const [stored, holder] = await Promise.all([read($, pickedLook), read($, toolbarHolder)])
289 const tried = storedLook(stored)
290 const look = tried ?? baseLook
291 // Only a try that differs from the saved style leaves anything to reset or save.
292 const isTrying = tried !== null && !isSameLook(tried, baseLook)
293 // Each press starts from the look as it is now, not as this draw saw it, so quick presses add up.
294 const change = (edit: (now: Look) => Partial<Look>) =>
295 update($, pickedLook, stored => {
296 const now = storedLook(stored) ?? baseLook
297 return { ...now, ...edit(now) }
298 })
299 const setHighlight = (highlight: Highlight) => () => change(() => ({ highlight }))
300 // One colour for every stretch: the one being tried, else the saved one, else the first swatch; the
301 // swatches then pick which.
302 const startColour = isColour(baseLook.highlight) ? baseLook.highlight : SWATCHES[0]
303 const pickColour = () => change(now => ({ highlight: isColour(now.highlight) ? now.highlight : startColour }))
304 const toggle = (mark: Mark) => change(now => ({ marks: now.marks.includes(mark) ? now.marks.filter(m => m !== mark) : [...now.marks, mark] }))
305 const { Box, Button, Text } = $.ui.resolve(e)
306 const chosen = (isOn: boolean, label: string) => (isOn ? <Text bold>[{label}]</Text> : <Text>{label}</Text>)
307 // While a save or a close holds the claim nothing is pressable: a press then would race it.
308 const pressable = (key: string, onPress: () => unknown, label: RenderNode) =>
309 holder !== null ? <Text key={key}>{label}</Text> : <Button plain key={key} onPress={onPress}>{label}</Button>
310 const swatch = (c: Hex) =>
311 pressable(`highlight-${c}`, setHighlight(c), <Text backgroundColor={c} {...contrastOn(c)}>{look.highlight === c ? '◆◆' : ' '}</Text>)
312 const row = (label: string, parts: RenderNode[]) => (
313 <Box key={`row-${label}`} flexWrap="wrap" columnGap={1}>
314 <Text dimColor>{label.padEnd(9)}</Text>
315 {parts}
316 </Box>
317 )
318 return (
319 <Box flexDirection="column">
320 {row('marks', [
321 ...MARK_NAMES.map(mark => pressable(`mark-${mark}`, () => toggle(mark), chosen(look.marks.includes(mark), mark))),
322 // A close holds the claim only while the toolbar goes away, so it shows no word.
323 ...(holder !== null
324 ? holder === 'save' ? [<Text key="saving" dimColor>saving</Text>] : []
325 : [
326 ...(isTrying
327 ? [
328 <Button plain key="reset" onPress={() => resetTry($)}>reset</Button>,
329 <Button plain key="save" onPress={() => saveLook($, baseLook)}>save</Button>,
330 ]
331 : []),
332 <Button plain key="close" onPress={() => closeToolbar($, baseLook)}>close</Button>,
333 ]),
334 ])}
335 {row('highlight', [
336 pressable('highlight-none', setHighlight(''), chosen(look.highlight === '', 'no highlight')),
337 pressable('highlight-colour', pickColour, chosen(isColour(look.highlight), 'colour')),
338 pressable('highlight-tinted', setHighlight(TINTED), chosen(look.highlight === TINTED, 'tinted')),
339 ...(isColour(look.highlight) ? SWATCHES.map(swatch) : []),
340 ])}
341 </Box>
342 )
343 })
344
345 // While a reply streams, Claude Code draws its lines as they arrive, before the reply reaches
346 // the AssistantMessage site below. Showing each stretch as its short form there too keeps the
347 // raw markup from flashing past. As of Claude Code 2.1.302 up to three flushes of a message run at
348 // once and the final one does not wait, so each flush filters after the one before it.
349 on('classic.MessageDisplay', ($, e, next) => {
350 const before = flushes.get(e.message_id)
351 const flush = (async () => {
352 try {
353 const answer = await next(e)
354 await before
355 return await display($, e, answer)
356 } catch (error) {
357 // A reply whose final flush failed is not kept, so the session is read again when it is drawn.
358 if (e.final) sessionKept = undefined
359 throw error
360 }
361 })()
362 const settled = flush.then(
363 () => undefined,
364 () => undefined,
365 )
366 flushes.set(e.message_id, settled)
367 void settled.then(() => flushes.get(e.message_id) === settled && flushes.delete(e.message_id))
368 return flush
369 })
370
371 on('prompt.compose', async ($, e, next) => {
372 const composed = await next(e)
373 if (e.surfaces.length === 0) return composed
374
375 return {
376 sections: [
377 ...composed.sections,
378 { id: 'cc-stretch:guide', text: GUIDE, scope: 'session' },
379 ],
380 }
381 })
382
383 on('prompt.submit', async ($, e, next) => {
384 if (e.origin.kind !== 'composer') return next(e)
385
386 const opened = await read($, seen)
387 // A stretch that is not a link is drawn open, so it is neither opened nor unopened. Each is named
388 // as its paragraph draws it, where a lone period is named with the words before it.
389 const written = paragraphsOf(lastReply(await $.session.messages()))
390 .filter(paragraph => paragraph.hasStretch)
391 .flatMap(({ text }) => {
392 const parts = parse(text)
393 const names = namesOf(parts)
394 return stretches(parts)
395 .filter(isLinkable)
396 .map(stretch => openedOf(stretch, names))
397 })
398 const unopened = written.filter(stretch => !opened.some(was => isSameStretch(was, stretch)))
399 await update($, seen, () => [])
400 await update($, newestReply, () => '')
401 await update($, lettering, () => null)
402 if (opened.length === 0 && unopened.length === 0) return next(e)
403
404 const quote = (list: OpenedStretch[]) => list.map(stretch => `"${stretch.name}"`).join(', ')
405 // A prompt cannot tell whether the reply was drawn where clicks land, so with no clicks the
406 // note names both cases.
407 const note =
408 opened.length > 0
409 ? `Stretchtext: since your last reply the reader opened ${quote(opened)}.${unopened.length > 0 ? ` They did not open ${quote(unopened)}.` : ''}`
410 : 'Stretchtext: no click on a stretch of your last reply was recorded. The reader may have read only the short forms; where clicks do not reach the plugin (the classic renderer, apps other than the terminal and the Claude desktop app), every stretch was drawn open.'
411
412 return next({ ...e, context: [...(e.context ?? []), note] })
413 }).catch(($, e, next) => next(e))
414
415 // The reply that says why stretches are open on the main screen: the first drawn there since the
416 // plugin loaded. As of Claude Code 2.1.301 a render hook cannot write plugin state, so it is kept here.
417 let notedRequest: string | undefined
418 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
419 if (e.props.isSummary === true) return next(e)
420 // A stretch still streaming has no closing braces yet; drawing it here keeps the raw markup
421 // off the screen until it closes and becomes pressable.
422 let text = e.props.text
423 let parts = parse(text)
424 let all = stretches(parts)
425 // Text without a stretch may be a reply the streaming filter already drew short.
426 if (all.length === 0) {
427 const raw = text.trim() === '' ? undefined : await rawOf($, text)
428 if (raw === undefined) return next(e)
429 text = raw
430 parts = parse(text)
431 all = stretches(parts)
432 if (all.length === 0) return next(e)
433 }
434
435 // The desktop app draws markdown links and hands a press on one to the plugin: the whole block is
436 // one Markdown whose closed stretches are links that open in place, each open one ending in a «.
437 if (e.surface === 'desktop') {
438 const { Markdown } = $.ui.resolve(e)
439 const open = memberOf(openStretches, { requestId: `${e.requestId}#desktop` })
440 const links: string[] = []
441 const text = linkedMarkdown(parts, await read($, open), all, links)
442 // Open that stretch, or close it with every stretch inside it. Opening one is what the next
443 // prompt reports, as a press in the terminal is.
444 const press = async (href: string) => {
445 const [action, index] = href.slice(HREF.length).split('/')
446 const stretch = all[Number(index)]
447 if (stretch === undefined) return
448 const { key } = stretch
449 if (action === 'close') {
450 await update($, open, list => list.filter(k => k !== key && !k.startsWith(key + KEY_SEPARATOR)))
451 return
452 }
453 if (action !== 'open') return
454 await update($, open, list => (list.includes(key) ? list : [...list, key]))
455 const opened = openedOf(stretch, namesOf(parts))
456 await update($, seen, list => (list.some(was => isSameStretch(was, opened)) ? list : [...list, opened]))
457 }
458 return <Markdown key="stretch" text={text} pressableLinks={links} onLinkPress={link => void press(link.href)} />
459 }
460
461 const marker = e.props.isFirstOfReply ? '⏺ ' : ' '
462 if (canPress(e)) {
463 // A paragraph holding a stretch is drawn by a Client, on the screen's side; the rest is
464 // Claude Code's own markdown.
465 const { Box, Button, Client, Markdown, Text } = $.ui.resolve(e)
466 const [{ order, count }, newest, shown, picked] = await Promise.all([
467 read($, memberOf(stretchOrder, { requestId: e.requestId })),
468 read($, newestReply),
469 read($, lettering),
470 read($, pickedLook),
471 ])
472 const look = storedLook(picked) ?? baseLook
473 const isTinted = look.highlight === TINTED
474 // The theme matters only once a stretch in this reply is drawn in its colour: open, or with the tinted highlight.
475 const isLight =
476 (isTinted || Object.keys(order).length > 0) &&
477 (await $.config.list().catch(() => [])).some(row => row.key === 'theme' && String(row.value).startsWith('light'))
478 const palette = isLight ? PALETTES.light : PALETTES.dark
479 const paragraphs = paragraphsOf(text).map((paragraph, i) => ({ ...paragraph, key: `paragraph-${i}`, member: `${e.requestId}#paragraph-${i}` }))
480 const holding = paragraphs.filter(p => p.hasStretch)
481 const places = isTinted ? placesOf(holding) : undefined
482 // The tinted highlight is each stretch's own, handed to the reply by key in place of the look's.
483 const drawnLook: DrawnLook = { ...look, highlight: isColour(look.highlight) ? look.highlight : '' }
484 // The terminal says again whether a reply is on screen whenever a scroll changes it; null is off it.
485 setNewest(drawnReplies, e.requestId, { text, isOnScreen: e.props.onScreen !== null, paragraphs: holding }, KEPT_DRAWN_REPLIES)
486 const parts = await Promise.all(
487 paragraphs.map(async ({ hasStretch, text, key, member }) => {
488 if (!hasStretch) return <Markdown text={text} />
489 const [open, ask] = await Promise.all([read($, memberOf(openStretches, { requestId: member })), read($, memberOf(askedStretch, { requestId: member }))])
490 const placed = places?.get(key)
491 const tints = tintsOf(order, key, newest === e.requestId ? count - 1 : -1, palette, placed)
492 const keys = Object.fromEntries((shown?.targets ?? []).filter(one => one.member === member).map(one => [one.key, one.letter]))
493 const props: ReplyProps = {
494 text,
495 open,
496 tints,
497 ask,
498 letters: shown === null ? null : { action: shown.action, keys },
499 look: drawnLook,
500 highlights: placed === undefined ? {} : highlightsOf(placed, palette),
501 }
502 return <Client key={key} module="./reply.tsx" props={props} width="100%" />
503 }),
504 )
505 // On a reply's first row the bullet and the space after it are Buttons: the first puts up the
506 // letters that open, or puts them away when they show; the second does the same for those that
507 // close. A chord presses them, and so does a click.
508 return (
509 <Box flexDirection="row">
510 {e.props.isFirstOfReply
511 ? [
512 <Button key="open-letters" plain label="⏺" action={CHORDS.open} onPress={() => void showLetters($, 'open')} />,
513 <Button key="close-letters" plain label=" " action={CHORDS.close} onPress={() => void showLetters($, 'close')} />,
514 ]
515 : <Text>{marker}</Text>}
516 <Box flexDirection="column" flexShrink={1} gap={1}>
517 {parts}
518 </Box>
519 </Box>
520 )
521 }
522
523 // Where a press is not known to land, every stretch is drawn open and nothing is hidden; on the
524 // main screen the first such reply says why.
525 const { Box, Markdown, Text } = $.ui.resolve(e)
526 if (isMainScreen(e)) notedRequest ??= e.requestId
527 return (
528 <Box flexDirection="row">
529 <Text>{marker}</Text>
530 <Box flexDirection="column" flexShrink={1}>
531 <Markdown text={writeOpen(parts)} />
532 {isMainScreen(e) && notedRequest === e.requestId && <Text dimColor>{MAIN_SCREEN_NOTE}</Text>}
533 </Box>
534 </Box>
535 )
536 })
537
538 // A paragraph's Client posts text the reader selected, a hyperlink the reader ctrl- or alt-clicked,
539 // and the stretches open in it whenever they change, with the one the reader just opened. Kept
540 // here, the open stretches survive a redraw, reach the next prompt and keep their colours.
541 on('ui.message', async ($, e, next) => {
542 if (!e.module.endsWith('reply.tsx')) return next(e)
543 // Text the reader dragged across in a paragraph, where as of Claude Code 2.1.302 the transcript
544 // selects nothing itself.
545 const copy = copyOf(e.data)
546 if (copy !== undefined) {
547 const copied = await $.ui.copy({ text: copy, surface: e.surface })
548 if (!copied.isCopied) $.ui.toast(`cc-stretch: the selection was not copied: ${copied.reason}`)
549 return next(e)
550 }
551 // A hyperlink the reader ctrl- or alt-clicked, opened without holding the post's chain on another program.
552 const followed = followOf(e.data)
553 if (followed !== undefined) {
554 void follow($, followed)
555 return next(e)
556 }
557 const post = replyPostOf(e.data)
558 if (post === undefined) return next(e)
559 await update($, memberOf(openStretches, { requestId: `${e.requestId}#${e.element}` }), () => post.open)
560 const { opened } = post
561 if (opened !== undefined) await update($, seen, list => (list.some(was => isSameStretch(was, opened)) ? list : [...list, { source: opened.source, name: opened.name }]))
562
563 // Each open stretch keeps the count it was opened at, across the reply's paragraphs, which picks
564 // its colour outside the tinted highlight; a nested one skips its parent's colour. The newest is the
565 // open stretch with the reply's last count.
566 const here = `${e.element}#`
567 await update($, memberOf(stretchOrder, { requestId: e.requestId }), ({ order, count }) => {
568 const kept = Object.fromEntries(Object.entries(order).filter(([at]) => !at.startsWith(here) || post.open.includes(at.slice(here.length))))
569 if (opened === undefined) return { order: kept, count }
570 const parent = kept[`${here}${parentOf(opened.key)}`]
571 const n = hueAfter(count, parent)
572 return { order: { ...kept, [`${here}${opened.key}`]: n }, count: n + 1 }
573 })
574 if (opened !== undefined) await update($, newestReply, () => e.requestId)
575 return next(e)
576 })
577
578 // While the letters show, the next edit at the prompt puts them away. A key that is one of the
579 // letters asks its stretch's paragraph to open or close it, in place as a click does, and is not
580 // typed; any other key, including a capital and a letter no stretch has, is typed as usual.
581 on('prompt.edit', async ($, e, next) => {
582 const shown = await read($, lettering)
583 if (shown === null) return next(e)
584 await update($, lettering, () => null)
585 const typed = e.key?.ctrl === undefined && e.key?.meta === undefined ? e.key?.key : undefined
586 const target = shown.targets.find(one => one.letter === typed)
587 if (target === undefined) return next(e)
588 await update($, memberOf(askedStretch, { requestId: target.member }), last => ({ key: target.key, action: shown.action, n: (last?.n ?? 0) + 1 }))
589
590 return { text: e.text, cursor: e.cursor }
591 })
592
593 // A change to the prompt box that is not an edit, such as a recalled history entry or a clear,
594 // puts the letters away too. As of Claude Code 2.1.295 a module that names an event the engine
595 // lacks is refused whole, and prompt.change came in 2.1.300, so this hook stands on prompt.* and
596 // acts on that event only.
597 on('prompt.*', async ($, e, next) => {
598 if (next.event === 'prompt.change' && (await read($, lettering)) !== null) await update($, lettering, () => null)
599 return next(e)
600 })
601}
602
603/**
604 * Puts up the letters for `action`, or puts them away when they already show for it or no stretch
605 * in the latest replies on screen takes one.
606 */
607async function showLetters($: EngineInterface, action: Action): Promise<void> {
608 const shown = await read($, lettering)
609 const targets = shown?.action === action ? [] : await lettersFor($, action)
610 await update($, lettering, () => (targets.length === 0 ? null : { action, targets }))
611}
612
613/**
614 * A letter for each of the first 26 stretches `action` can act on in the latest three replies on
615 * screen holding a stretch, newest first, in reading order within each. The order is read from the
616 * session's messages because Claude Code 2.1.301 draws a reply for the first time when it scrolls
617 * into view.
618 */
619async function lettersFor($: EngineInterface, action: Action): Promise<{ letter: string; member: string; key: string }[]> {
620 // With their whitespace gone, no reply is found across two messages joined by a line break.
621 const written = (await $.session.messages().catch(() => []))
622 .filter(message => message.role === 'assistant' && message.text.includes('{{'))
623 .map(message => keyOf(message.text))
624 .join('\n')
625 const replies = [...drawnReplies.values()]
626 .filter(reply => reply.isOnScreen)
627 .map(reply => ({ reply, at: written.lastIndexOf(keyOf(reply.text)) }))
628 .filter(({ at }) => at !== -1)
629 .sort((a, b) => b.at - a.at)
630 .slice(0, LETTERED_REPLIES)
631 const paragraphs = replies.flatMap(({ reply }) => reply.paragraphs)
632 const targets = await Promise.all(
633 paragraphs.map(async ({ member, text }) => {
634 const open = await read($, memberOf(openStretches, { requestId: member }))
635 return targetsIn(parse(text), open, action).map(stretch => ({ member, key: stretch.key }))
636 }),
637 )
638
639 return targets.flat().slice(0, LETTERS.length).map((target, i) => ({ ...target, letter: LETTERS.charAt(i) }))
640}
641
642/**
643 * The tint of each open stretch in one paragraph: its colour by the count it was opened at, or under
644 * the tinted highlight by its place, faded unless it was opened at `newest`.
645 */
646function tintsOf(
647 order: Record<string, number>,
648 paragraph: string,
649 newest: number,
650 palette: { soft: string; faded: string }[],
651 places?: ReadonlyMap<string, number>,
652): Record<string, Tint> {
653 const here = `${paragraph}#`
654 return Object.fromEntries(
655 Object.entries(order)
656 .filter(([at]) => at.startsWith(here))
657 .flatMap(([at, n]) => {
658 const key = at.slice(here.length)
659 // Under the tinted highlight a stretch with no place has no colour to keep, so none is made up.
660 const place = places === undefined ? n : places.get(key)
661 const colours = place === undefined ? undefined : colourAt(palette, place)
662 return colours === undefined ? [] : [[key, { words: n === newest ? colours.soft : colours.faded, closer: colours.soft }]]
663 }),
664 )
665}
666
667/**
668 * Each pressable stretch's place in a reply, by paragraph then key, which picks its colour under the
669 * tinted highlight: counted in reading order across the reply's paragraphs, a nested stretch after the
670 * one holding it and never in that one's colour, so a stretch has its colour before it is opened.
671 */
672function placesOf(paragraphs: { text: string; key: string }[]): Map<string, Map<string, number>> {
673 let next = 0
674 return new Map(
675 paragraphs.map(({ text, key }) => {
676 const places = new Map<string, number>()
677 // The holding stretch is passed down the parse, not read back from a key, which a short form can spell like a nested one.
678 const walk = (segments: Segment[], holder: number | undefined) => {
679 for (const segment of segments) {
680 if (typeof segment === 'string') continue
681 let inside = holder
682 if (isLinkable(segment)) {
683 inside = hueAfter(next, holder)
684 places.set(segment.key, inside)
685 next = inside + 1
686 }
687 walk(segment.long ?? [], inside)
688 }
689 }
690 walk(parse(text), undefined)
691 return [key, places]
692 }),
693 )
694}
695
696/** Under the tinted highlight, each stretch's highlight in one paragraph, by key: its colour's deep form. */
697function highlightsOf(places: ReadonlyMap<string, number>, palette: readonly { highlight: Hex }[]): Record<string, Hex> {
698 return Object.fromEntries(
699 [...places].flatMap(([key, place]) => {
700 const colours = colourAt(palette, place)
701 return colours === undefined ? [] : [[key, colours.highlight]]
702 }),
703 )
704}
705
706/**
707 * A reply as markdown for the desktop: each closed stretch a link, each open one its long form then a
708 * «, on a line of its own after a rule or a code fence. A link names its stretch by its place in `all`, not by its key, which can hold any character and
709 * run past the 2048 characters a `pressableLinks` entry may have (Claude Code 2.1.295).
710 */
711function linkedMarkdown(parts: Segment[], open: readonly string[], all: Stretch[], links: string[]): string {
712 return parts
713 .map(part => {
714 if (typeof part === 'string') return part
715 if (!isOpenable(part) || part.long === undefined) return part.short
716 // A short form over several lines is drawn open, or as written when its long form is empty.
717 if (!isLinkable(part)) return part.long.length === 0 ? part.short : linkedMarkdown(part.long, open, all, links)
718 const index = all.indexOf(part)
719 if (open.includes(part.key)) {
720 const close = `${HREF}close/${index}`
721 links.push(close)
722 return closeAfter(linkedMarkdown(part.long, open, all, links), `[«](${close})`, '\n\n')
723 }
724 const href = `${HREF}open/${index}`
725 links.push(href)
726 return `[${labelOf(part.short)}](${href})`
727 })
728 .join('')
729}
730
731/**
732 * A short form as link text. Outside code spans, where an escape would show, a link inside it becomes
733 * its text and target (the pattern `layout.ts` draws that way in the terminal), and brackets and
734 * backslashes are escaped.
735 */
736function labelOf(short: string): string {
737 const plain = (text: string) => text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '$1 ($2)').replace(/([[\]\\])/g, '\\$1')
738 let label = ''
739 let from = 0
740 for (let at = short.indexOf('`'); at !== -1; at = short.indexOf('`', from)) {
741 const { stop } = codeSpan(short, at)
742 label += plain(short.slice(from, at)) + short.slice(at, stop)
743 from = stop
744 }
745
746 return label + plain(short.slice(from))
747}
748
749/** A reply with every stretch drawn as its long form. */
750function writeOpen(parts: Segment[]): string {
751 return parts.map(part => (typeof part === 'string' ? part : part.long === undefined ? part.short : writeOpen(part.long))).join('')
752}
753
754function isSameStretch(a: OpenedStretch, b: OpenedStretch): boolean {
755 return a.source === b.source && a.name === b.name
756}
757
758/** A Client's post, if it is one: the open stretches, and the one just opened. */
759function replyPostOf(data: unknown): ReplyPost | undefined {
760 if (typeof data !== 'object' || data === null || !('open' in data) || !Array.isArray(data.open)) return undefined
761 const open = data.open.filter((key): key is string => typeof key === 'string')
762 const opened: unknown = 'opened' in data ? data.opened : undefined
763 if (typeof opened !== 'object' || opened === null || !('name' in opened) || !('source' in opened) || !('key' in opened)) return { open }
764 const { name, source, key } = opened
765
766 return typeof name === 'string' && typeof source === 'string' && typeof key === 'string' ? { open, opened: { name, source, key } } : { open }
767}
768
769/** A Client's post of a web link to open, if it is one. */
770function followOf(data: unknown): string | undefined {
771 if (typeof data !== 'object' || data === null || !('follow' in data)) return undefined
772 return typeof data.follow === 'string' && isWebLink(data.follow) ? data.follow : undefined
773}
774
775/** A Client's post of text to copy, if it is one. */
776function copyOf(data: unknown): string | undefined {
777 if (typeof data !== 'object' || data === null || !('copy' in data)) return undefined
778 return typeof data.copy === 'string' && data.copy !== '' ? data.copy : undefined
779}
780
781/** The text of the model's last reply: its rows after the person's last prompt. */
782function lastReply(messages: SessionMessage[]): string {
783 const texts: string[] = []
784 for (const message of [...messages].reverse()) {
785 if (message.role === 'user' && (message.toolResults?.length ?? 0) === 0 && message.text !== '') break
786 if (message.role === 'assistant') texts.unshift(message.text)
787 }
788
789 return texts.join('\n')
790}
791
792// A press on a Client, or on the tool-call fold's link, is known to land only in the fullscreen
793// terminal. In the desktop app (Claude Code 2.1.293) a press on a Client's one-cell « missed, so the
794// desktop draws stretches as Markdown links instead (above).
795function canPress<E extends Pick<RenderInput, 'surface' | 'viewport'>>(e: E): e is E & { surface: 'terminal' } {
796 return e.surface === 'terminal' && e.viewport?.isFullscreen === true
797}
798
799// The terminal's main screen prints each reply into its scrollback, where nothing can change it.
800// Unknown is not the main screen, and the phone app's `false` is not the terminal's.
801function isMainScreen(e: Pick<RenderInput, 'surface' | 'viewport'>): boolean {
802 return e.surface === 'terminal' && e.viewport?.isFullscreen === false
803}
804hooks/guide.ts 17 lines1// The section cc-stretch adds to Claude's system prompt. The benchmark rewrites replies under
2// this same text, so it lives on its own.
3export const GUIDE = `# Stretchtext replies
4
5The reader's screen draws each stretch \`{{short|long}}\` in your reply as its short form, a link. A click puts the long form in its place and the text reads on; nothing moves to a separate section. So write every reply to read complete at its shortest, with depth where each stretch stands:
6
7- At its shortest, the reply is one to three short sentences that answer on their own. Anything the reader must decide or do stays outside every stretch.
8- Stretch list items, not whole lists.
9- The long form keeps every word of the short form, in the same order, and grows around them: words added before, between and after the ones the reader already saw. On screen those words stay where they are while the new ones open up around them, so the reader never loses their place. Example: "{{The build fails on a stale cache.|The CI build fails, about one run in five, on a stale dependency cache keyed only by \`package.json\`.}}"
10- The long form must read correctly in the short form's place: a phrase stretches into a clause, a sentence into a paragraph, a paragraph into several.
11- Stretches nest: a long form may hold further stretches, each a further level of depth.
12- The short form is plain inline text on one line. Never put a stretch inside a code span, a code block, a table or a heading.
13- If other instructions require a closing block, such as a status block, make it the reply's last stretch, its short form a one-line gist of the block.
14- A reply that is already short needs no stretches.
15
16With each of the reader's messages you are told which stretches they opened. Do not assume they read a long form they did not open; if it matters to what they asked, say it again.`
17hooks/parse.ts 347 lines1// A reply is markdown in which `{{short|long}}` marks a stretch: the reader
2// sees `short`, and opening it puts `long` in its place. `long` may hold
3// further stretches. Code spans and fences are copied as written, so braces in
4// code never open a stretch; inside a long form, a fence's closing line may
5// carry the `}}` that closes the stretches around the block.
6
7export type Segment = string | Stretch
8
9export type Stretch = {
10 /**
11 * Names the stretch by its trimmed short form, each line break in it written ↵, and
12 * its parents' (`cache › key`), so a stretch added elsewhere in the reply leaves
13 * every other key as it was. A short form repeated among siblings takes `~2`, `~3`.
14 */
15 key: string
16 short: string
17 /** Undefined when no `|` was written: then there is nothing to open, and `short` is plain text. */
18 long: Segment[] | undefined
19 /** False while the reply streams and the closing braces have not arrived. */
20 isClosed: boolean
21}
22
23type Frame = { stretch: Stretch; siblings: Map<string, number> }
24
25export const KEY_SEPARATOR = ' › '
26
27export function isOpenable(stretch: Stretch): boolean {
28 return stretch.isClosed && stretch.long !== undefined
29}
30
31/** Whether a stretch is drawn as a link: openable, with its short form on one line, as a link cannot span lines. */
32export function isLinkable(stretch: Stretch): boolean {
33 return isOpenable(stretch) && !stretch.short.includes('\n')
34}
35
36/**
37 * How the terminal draws a stretch: as plain text when there is nothing to open, already open when
38 * its short form spans lines, otherwise as a link.
39 */
40export function drawnAs(stretch: Stretch): 'text' | 'open' | 'link' {
41 if (!isOpenable(stretch)) return 'text'
42 return isLinkable(stretch) ? 'link' : 'open'
43}
44
45/**
46 * An open stretch's long form followed by its `closer`, after a space, or after `gap` when the long
47 * form ends in a line text after it would change: a rule, which then reads as plain dashes, or a code
48 * fence, which then no longer closes.
49 */
50export function closeAfter(long: string, closer: string, gap: string): string {
51 const trimmed = long.trimEnd()
52 const last = trimmed.slice(trimmed.lastIndexOf('\n') + 1)
53 const fence = openingFence(last)
54 const endsInBlock = isRule(last) || (fence !== undefined && closesFence(last, fence))
55
56 return endsInBlock ? `${trimmed}${gap}${closer}` : `${long} ${closer}`
57}
58
59/** Whether `line` is a rule: three or more of one of `-`, `*` and `_`, spaces between allowed. */
60export function isRule(line: string): boolean {
61 return /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)
62}
63
64/** The key of the stretch holding the one keyed `key`, or '' when no stretch holds it. */
65export function parentOf(key: string): string {
66 return key.split(KEY_SEPARATOR).slice(0, -1).join(KEY_SEPARATOR)
67}
68
69/**
70 * The code span at `i`: where it stops, past the next run of as many backticks or at the text's end,
71 * and its backticks when it is still open there. `open` carries the backticks of a span opened on an
72 * earlier line, whose close is then looked for from `i` on.
73 */
74export function codeSpan(text: string, i: number, open?: string): { stop: number; open: string | undefined } {
75 const run = open ?? /^`+/.exec(text.slice(i))?.[0] ?? '`'
76 const end = text.indexOf(run, open === undefined ? i + run.length : i)
77 return end === -1 ? { stop: text.length, open: run } : { stop: end + run.length, open: undefined }
78}
79
80export function parse(text: string): Segment[] {
81 const root: Segment[] = []
82 const stack: Frame[] = []
83 const rootSiblings = new Map<string, number>()
84
85 const write = (chunk: string) => {
86 const frame = stack.at(-1)
87 if (frame && frame.stretch.long === undefined) {
88 frame.stretch.short += chunk
89 return
90 }
91 const segments = frame?.stretch.long ?? root
92 const last = segments.at(-1)
93 if (typeof last === 'string') segments[segments.length - 1] = last + chunk
94 else segments.push(chunk)
95 }
96
97 let i = 0
98 while (i < text.length) {
99 const frame = stack.at(-1)
100 const isInShort = frame !== undefined && frame.stretch.long === undefined
101 const isInLong = frame !== undefined && !isInShort
102
103 const fenceStop = i === 0 || text[i - 1] === '\n' ? fenceEnd(text, i, isInLong) : undefined
104 if (fenceStop !== undefined) {
105 write(text.slice(i, fenceStop))
106 i = fenceStop
107 continue
108 }
109
110 if (text[i] === '`') {
111 const { stop } = codeSpan(text, i)
112 write(text.slice(i, stop))
113 i = stop
114 continue
115 }
116
117 if (text.startsWith('{{', i) && !isInShort) {
118 const stretch: Stretch = {
119 key: '',
120 short: '',
121 long: undefined,
122 isClosed: false,
123 }
124 ;(frame?.stretch.long ?? root).push(stretch)
125 stack.push({ stretch, siblings: new Map() })
126 i += 2
127 continue
128 }
129
130 if (isInShort && text[i] === '|') {
131 frame.stretch.key = keyOf(frame.stretch.short, stack.at(-2), rootSiblings)
132 frame.stretch.long = []
133 i += 1
134 continue
135 }
136
137 if (frame && text.startsWith('}}', i)) {
138 frame.stretch.isClosed = true
139 stack.pop()
140 i += 2
141 continue
142 }
143
144 write(text[i] ?? '')
145 i += 1
146 }
147
148 return root
149}
150
151function keyOf(short: string, parent: Frame | undefined, rootSiblings: Map<string, number>): string {
152 // A line break in a key would split the link's markup across rows in `layout`; ↵ keeps the key apart
153 // from the same words on one line, which a space would not.
154 const name = short.trim().replaceAll('\n', '↵')
155 const siblings = parent?.siblings ?? rootSiblings
156 const seen = (siblings.get(name) ?? 0) + 1
157 siblings.set(name, seen)
158 const own = seen === 1 ? name : `${name}~${seen}`
159
160 return parent ? `${parent.stretch.key}${KEY_SEPARATOR}${own}` : own
161}
162
163/**
164 * Where a fenced code block opening at `start` ends (past its closing line, or where the braces after
165 * its closing fence begin, inside a long form), or undefined when no fence opens there.
166 */
167function fenceEnd(text: string, start: number, isInLong: boolean): number | undefined {
168 const lineEnd = (from: number) => {
169 const end = text.indexOf('\n', from)
170 return end === -1 ? text.length : end
171 }
172 const opening = openingFence(text.slice(start, lineEnd(start)))
173 if (opening === undefined) return undefined
174
175 for (let at = lineEnd(start) + 1; at < text.length; at = lineEnd(at) + 1) {
176 const line = text.slice(at, lineEnd(at))
177 const close = fenceCloseOn(line, opening, isInLong)
178 if (close === line.length) return Math.min(lineEnd(at) + 1, text.length)
179 if (close !== undefined) return at + close
180 }
181
182 return text.length
183}
184
185/** The marker `line` opens a code fence with, such as ``` or ~~~, or undefined when it opens none. */
186export function openingFence(line: string): string | undefined {
187 return /^[ \t]*(`{3,}|~{3,})/.exec(line)?.[1]
188}
189
190/** Whether `line` closes the code fence `fence` opened: trimmed, it is only the fence's character, repeated at least as often. */
191export function closesFence(line: string, fence: string): boolean {
192 const trimmed = line.trim()
193 return trimmed.startsWith(fence) && trimmed === fence.charAt(0).repeat(trimmed.length)
194}
195
196/**
197 * Where the code fence `fence` opened closes on `line`: at the line's end when the line is only the
198 * fence. Inside a long form (`isInLong`) a closing fence followed by nothing but `}}`, once or more,
199 * spaces between allowed, also closes it, where the braces begin, so they close the stretches around
200 * the block. Undefined when the line does not close it.
201 */
202function fenceCloseOn(line: string, fence: string, isInLong: boolean): number | undefined {
203 if (closesFence(line, fence)) return line.length
204 if (!isInLong) return undefined
205 const braces = line.indexOf('}}')
206 if (braces === -1 || !/^(?:\}\}[ \t]*)+$/.test(line.slice(braces))) return undefined
207 return closesFence(line.slice(0, braces), fence) ? braces : undefined
208}
209
210/** Every stretch, outermost first, in the order the reply writes them. */
211export function stretches(segments: Segment[]): Stretch[] {
212 return segments.flatMap(segment =>
213 typeof segment === 'string' ? [] : [segment, ...stretches(segment.long ?? [])],
214 )
215}
216
217/**
218 * Where a streaming filter stands between flushes: open stretches, an open code fence, and the
219 * backticks of a code span still open, since `parse` lets a span run over line breaks.
220 */
221export type StreamState = { open: { isLong: boolean }[]; fence: string | undefined; span: string | undefined }
222
223/** A streaming filter's state before a reply's first flush. */
224export const newStreamState = (): StreamState => ({ open: [], fence: undefined, span: undefined })
225
226/**
227 * A streaming reply's newly finished lines with each stretch shown as its short form, as the
228 * finished reply will first show it. `state` carries open stretches, fences and code spans from one
229 * flush to the next. A line that held only long-form text is left out, so the stream does not grow
230 * blank lines where hidden depth goes.
231 */
232export function hideLongForms(lines: string, state: StreamState): string {
233 return lines
234 .split('\n')
235 .flatMap(line => {
236 const fence = state.fence === undefined && state.span === undefined ? openingFence(line) : undefined
237 // Past a closing fence followed by braces, the braces are read as outside the block.
238 let start = 0
239 if (state.fence !== undefined || fence !== undefined) {
240 const isShown = state.open.length === 0 || (state.open.length === 1 && state.open[0]?.isLong === false)
241 const close = state.fence === undefined ? undefined : fenceCloseOn(line, state.fence, state.open.at(-1)?.isLong === true)
242 if (state.fence === undefined) state.fence = fence
243 else if (close !== undefined) state.fence = undefined
244 if (close === undefined || close === line.length) return isShown ? [line] : []
245 start = close
246 }
247 let out = ''
248 for (let i = start; i < line.length; i++) {
249 const top = state.open.at(-1)
250 const isShown = state.open.length === 0 || (state.open.length === 1 && top?.isLong === false)
251 if (state.span !== undefined || line[i] === '`') {
252 const span = codeSpan(line, i, state.span)
253 state.span = span.open
254 if (isShown) out += line.slice(i, span.stop)
255 i = span.stop - 1
256 } else if (line.startsWith('{{', i) && top?.isLong !== false) {
257 state.open.push({ isLong: false })
258 i++
259 } else if (top?.isLong === false && line[i] === '|') {
260 top.isLong = true
261 } else if (top !== undefined && line.startsWith('}}', i)) {
262 state.open.pop()
263 i++
264 } else if (isShown) {
265 out += line[i]
266 }
267 }
268 return out.trim() === '' && line.trim() !== '' ? [] : [out]
269 })
270 .join('\n')
271}
272
273/** A run of the reply between blank lines, and whether it holds a stretch. */
274export type Paragraph = { text: string; hasStretch: boolean }
275
276/**
277 * The reply cut at the blank lines that stand outside every stretch, code fence and code span, so a
278 * paragraph holding a stretch can be drawn apart from the rest. Neighbouring paragraphs without one
279 * are kept together.
280 */
281export function paragraphsOf(text: string): Paragraph[] {
282 const pieces: string[] = []
283 let current: string[] = []
284 let depth = 0
285 let span: string | undefined
286 let fence: string | undefined
287 for (const line of text.split('\n')) {
288 if (fence !== undefined) {
289 const close = fenceCloseOn(line, fence, depth > 0)
290 if (close !== undefined) {
291 fence = undefined
292 ;({ depth, span } = depthAfter(line.slice(close), depth, span))
293 }
294 current.push(line)
295 continue
296 }
297 const marker = span === undefined ? openingFence(line) : undefined
298 if (marker !== undefined) {
299 fence = marker
300 current.push(line)
301 continue
302 }
303 if (line.trim() === '' && depth === 0 && span === undefined) {
304 if (current.length > 0) pieces.push(current.join('\n'))
305 current = []
306 continue
307 }
308 ;({ depth, span } = depthAfter(line, depth, span))
309 current.push(line)
310 }
311 if (current.length > 0) pieces.push(current.join('\n'))
312
313 const paragraphs: Paragraph[] = []
314 for (const piece of pieces) {
315 const hasStretch = stretches(parse(piece)).length > 0
316 const last = paragraphs.at(-1)
317 if (!hasStretch && last !== undefined && !last.hasStretch) last.text += `\n\n${piece}`
318 else paragraphs.push({ text: piece, hasStretch })
319 }
320
321 return paragraphs
322}
323
324/**
325 * How many stretches stand open after `line`, and the backticks of a code span still open, given
326 * `depth` and `span` as they stood before it. Braces in a code span are skipped.
327 */
328function depthAfter(line: string, depth: number, span: string | undefined): { depth: number; span: string | undefined } {
329 let open = depth
330 let code = span
331 for (let i = 0; i < line.length; i++) {
332 if (code !== undefined || line[i] === '`') {
333 const at = codeSpan(line, i, code)
334 code = at.open
335 i = at.stop - 1
336 } else if (line.startsWith('{{', i)) {
337 open += 1
338 i += 1
339 } else if (open > 0 && line.startsWith('}}', i)) {
340 open -= 1
341 i += 1
342 }
343 }
344
345 return { depth: open, span: code }
346}
347hooks/layout.ts 740 lines1// Lays out a paragraph that holds stretches, for a Client to draw: its markdown as rows of styled
2// runs, wrapped to the region's width, so a press can be matched to the phrase under it. Claude
3// Code draws every paragraph without a stretch with its own markdown.
4
5import { closesFence, codeSpan, drawnAs, isLinkable, isRule, openingFence, stretches } from './parse'
6import type { Segment, Stretch } from './parse'
7
8/** What a press on a pressable phrase does to its stretch. */
9export type Action = 'open' | 'close'
10
11export type Run = {
12 text: string
13 bold?: true
14 italic?: true
15 code?: true
16 underline?: true
17 dim?: true
18 /** The colour of the words a stretch added. */
19 tint?: string
20 /**
21 * The stretch a press acts on, and how. `isLeadIn` marks lead-in words: the words written just before
22 * a link with no words of its own, and the punctuation glued after an empty short form, which a press
23 * opens too. They stay a run apart from the link, so rows break where they would without it.
24 */
25 link?: { action: Action; key: string; isLeadIn?: true }
26 /** Where a markdown link or a bare URL goes, drawn as a hyperlink. */
27 href?: string
28}
29
30export type Row = Run[]
31
32/** How a row a line wrapped onto joins the row before it: what the break took out, and the lead's cells. */
33export type Continuation = { join: string; lead: number }
34
35// The rows a line wrapped onto, so a copy of several rows gives back the line as written.
36const continuations = new WeakMap<Row, Continuation>()
37
38/** How `row` continues the row before it, if a line wrapped onto it. */
39export function continuationOf(row: Row): Continuation | undefined {
40 return continuations.get(row)
41}
42
43// Noncharacters frame a pressable phrase or a tinted word in composed text: Unicode keeps them for a
44// program's own use, where the Private Use Area holds icons such as Nerd Font's. A start mark with no
45// separator after it is read as a character, so a stray one still lets its line be laid out.
46const START = '\ufdd0'
47const SEP = '\ufdd1'
48const END = '\ufdd2'
49const PHRASE_MARK = /[\ufdd0-\ufdd2]/
50const TINT = '\ufdd3'
51const TINT_END = '\ufdd4'
52
53// A mark drawn on the letter before it, or a character no terminal draws, takes no cell.
54const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Default_Ignorable_Code_Point}]/u
55
56// A character drawn as a picture by default, such as 🚀, takes two cells; the first is U+231A, so
57// widthOf tests nothing below it. A flag is two regional indicators that take two cells together,
58// so each one keeps its single cell.
59const EMOJI = /(?!\p{Regional_Indicator})\p{Emoji_Presentation}/u
60
61// Where a row may break in text without spaces. Chinese and Japanese break between any two
62// characters. Korean spaces its words, so it breaks at spaces as Latin text does. Thai, Lao, Khmer
63// and Burmese write words without spaces, and a row breaks between words where Intl.Segmenter
64// finds them. Closing punctuation, a Japanese small kana, the long-vowel mark and a mark drawn on
65// the letter before it never start a row; opening punctuation never ends one.
66const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Bopomofo}\u3000-\u303f\uff00-\uffef]/u
67const SEGMENTED = /[\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u
68const NO_START = /[\p{Pe}\p{Pf},.:;!?,。、;:?!…·ぁぃぅぇぉっゃゅょゎゕゖァィゥェォッャュョヮヵヶーゝゞヽヾ々]/u
69const NO_END = /[\p{Ps}\p{Pi}]/u
70const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'word' })
71
72// The marks that start a markdown block on a line. The benchmark reads lines with the same ones.
73export const TABLE_ROW = /^\s*\|/
74export const HEADING = /^#{1,6}\s+(.*)$/
75export const QUOTE = /^>\s?(.*)$/
76export const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
77
78/** A line's text without its tint marks, and the tint of each character left. */
79type Line = { text: string; tints: (string | undefined)[] }
80
81/** `word` drawn in `colour`. */
82export function tint(colour: string, word: string): string {
83 return `${TINT}${colour}${SEP}${word}${TINT_END}`
84}
85
86/** Whether a word is markdown syntax rather than text: a bullet, a list number, a rule, a fence, a pipe, a heading mark. */
87export function isMarkup(word: string): boolean {
88 return !HAS_WORDS.test(word) || /^\d+[.)]$/.test(word) || openingFence(word) !== undefined
89}
90
91/**
92 * A pressable phrase for composed text: drawn as `label`, a press does `action` to stretch `key`;
93 * `leadIn` marks lead-in words, which open it.
94 */
95export function link(action: Action | 'leadIn', key: string, label: string): string {
96 return `${START}${action}${SEP}${key}${SEP}${label}${END}`
97}
98
99/** Punctuation that ends a sentence at the end of a word, before any closing quote or bracket. */
100const SENTENCE_END = /[.!?]["'”’)\]]*$/
101/** Sentence-ending punctuation of scripts that write no space after it, so it can stand inside a word. */
102const UNSPACED_SENTENCE_END = /[。!?](?=[^。!?]*$)/u
103/** Markup a pressed phrase would cut in two: emphasis, a markdown link, HTML, a URL, a table's pipe. */
104const CUT_MARKUP = /[*[\]<>|~]|:\/\/|(?:^|[^\p{L}\p{N}])_|_(?:$|[^\p{L}\p{N}])/u
105/** A bold phrase that ends a piece of a line, with any punctuation after it. */
106const BOLD_END = /(?<![*\\])\*\*(?=\S)([^*\n]*?\S)\*\*[.,;:!?"'”’]*$/
107const HAS_WORDS = /[\p{L}\p{N}]/u
108
109/**
110 * Where the lead-in words that close `text` stand, back to the start of their sentence: the line's
111 * start, a list or quote mark, or a sentence's closing punctuation, so a comma or a dash inside the
112 * sentence does not leave the reader pressing only its last clause. A code span counts as one word, and
113 * so does a bold phrase; other markup a press would cut in two ends them, and leading pieces with no
114 * letter or digit are left out. The line's own mark (an indent, a list, quote or heading mark) is never
115 * taken. Words are split at spaces only, where `wrap` breaks a row, and none is taken whose edge stands
116 * against other white space, so the words' edges add no place to break. Spaces after the words are not
117 * theirs. Undefined when no words stand there, in a table, and after an unclosed code span or `[` on
118 * the line, which a press on the words would leave half pressed.
119 */
120export function leadInOf(text: string): { from: number; to: number } | undefined {
121 const lineStart = text.lastIndexOf('\n') + 1
122 const line = text.slice(lineStart)
123 if (TABLE_ROW.test(line) || /\[[^\]]*$/.test(line)) return undefined
124 const block = HEADING.exec(line)?.[1] ?? QUOTE.exec(line)?.[1] ?? LIST_ITEM.exec(line)?.[3] ?? line.trimStart()
125 const floor = line.length - block.length
126 const pieces: { at: number; text: string; isSpace: boolean; isCode: boolean }[] = []
127 for (let i = 0; i < line.length; ) {
128 const span = line[i] === '`' ? codeSpan(line, i) : undefined
129 if (span?.open !== undefined) return undefined
130 const isCode = span !== undefined
131 const stop = span?.stop ?? i + (/^( +|[^ `]+)/.exec(line.slice(i))?.[0].length ?? 1)
132 const piece = line.slice(i, stop)
133 pieces.push({ at: i, text: piece, isSpace: /^ +$/.test(piece), isCode })
134 i = stop
135 }
136 const previous = (k: number) => {
137 let at = k - 1
138 while (at >= 0 && pieces[at]?.isSpace) at -= 1
139 return at
140 }
141 const last = previous(pieces.length)
142 const end = last < 0 ? 0 : (pieces[last]?.at ?? 0) + (pieces[last]?.text.length ?? 0)
143 let from: number | undefined
144 for (let k = last; k >= 0; k = previous(k)) {
145 const piece = pieces[k]
146 if (piece === undefined) break
147 if (!piece.isCode && CUT_MARKUP.test(piece.text)) {
148 // A bold phrase is taken whole when it starts a piece outside a code span, holds words and no
149 // other markup, and no sentence ends inside it.
150 const bold = BOLD_END.exec(line.slice(0, piece.at + piece.text.length))
151 const start = bold === null ? -1 : pieces.findIndex(p => p.at === bold.index && !p.isCode)
152 const inner = bold?.[1] ?? ''
153 const isBoldWhole =
154 start !== -1 && start <= k && HAS_WORDS.test(inner) && !CUT_MARKUP.test(inner) &&
155 pieces.slice(start, k).every(p => p.isSpace || p.isCode || !SENTENCE_END.test(p.text))
156 if (!isBoldWhole) break
157 k = start
158 from = pieces[k]?.at
159 } else {
160 // The line's own indent, list or heading mark.
161 if (piece.at < floor) break
162 const cut = piece.isCode ? null : UNSPACED_SENTENCE_END.exec(piece.text)
163 if (cut !== null) {
164 // The words start where a row may break after the full stop: a closing quote or bracket stays with it.
165 let at = 0
166 for (const unit of breakables(piece.text)) {
167 if (at >= cut.index + cut[0].length) break
168 at += unit.length
169 }
170 if (HAS_WORDS.test(piece.text.slice(at))) from = piece.at + at
171 break
172 }
173 from = piece.at
174 }
175 const before = pieces[previous(k)]
176 if (before !== undefined && !before.isCode && SENTENCE_END.test(before.text)) break
177 }
178 if (from === undefined || from < floor) return undefined
179 // A leading emoji, arrow or bullet is not a word to press.
180 for (const piece of pieces) {
181 if (piece.at < from || piece.isSpace) continue
182 if (piece.at >= end || piece.isCode || HAS_WORDS.test(piece.text)) break
183 from = piece.at + piece.text.length
184 }
185 while (from < end && line[from] === ' ') from += 1
186 const charBefore = line[from - 1] ?? ' '
187 const firstChar = line[from] ?? ''
188 const lastChar = line[end - 1] ?? ''
189 if (/\s/.test(firstChar + lastChar) || (from > floor && /[^\S ]/.test(charBefore))) return undefined
190 return HAS_WORDS.test(line.slice(from, end)) ? { from: lineStart + from, to: lineStart + end } : undefined
191}
192
193/** Roughly what `stretch` draws: its short form, closed or as text, else its long form and a «. */
194function drawnText(stretch: Stretch, isShut: (stretch: Stretch) => boolean): string {
195 const long = () => (stretch.long ?? []).map(part => (typeof part === 'string' ? part : drawnText(part, isShut))).join('')
196 switch (drawnAs(stretch)) {
197 case 'text':
198 return stretch.short
199 case 'open':
200 return long()
201 case 'link':
202 return isShut(stretch) ? stretch.short : `${long()} «`
203 }
204}
205
206/** A short form whose lead-in words open it too: none at all, or only punctuation or symbols. */
207const WORDLESS = /^[\p{P}\p{S}]*$/u
208
209/**
210 * `parts` with the lead-in words of each closed link that has no words of its own, such as a lone
211 * period, marked by `link('leadIn', …)` to open it as well; `isShut` says which links are drawn closed, and
212 * `isParagraphEnd` whether nothing is drawn after `parts`. The text stays as written. Where an empty
213 * short form joins its lead-in words to the text after it, a row cannot break between the two: that
214 * text's first piece is marked too when it is only punctuation, such as the sentence's period, so the
215 * marks add no place to break; otherwise the words are left.
216 */
217export function withLeadIns(parts: Segment[], isShut: (stretch: Stretch) => boolean, isParagraphEnd: boolean): Segment[] {
218 const { leadIns, tails } = leadInsOf(parts, isShut, isParagraphEnd)
219 return parts.map((part, i) => {
220 if (typeof part !== 'string') return part
221 let marked = ''
222 let at = 0
223 for (const span of [tails.get(i), leadIns.get(i)]) {
224 if (span === undefined) continue
225 marked += part.slice(at, span.from) + link('leadIn', span.key, part.slice(span.from, span.to))
226 at = span.to
227 }
228 return marked + part.slice(at)
229 })
230}
231
232/**
233 * What a note to Claude calls each stretch of `parts` drawn as a link, by key: its short form, and for
234 * one with no words of its own its lead-in words and the punctuation glued after it too, as drawn with
235 * every stretch closed, so lone periods after different sentences get different names. `shortsNamed`
236 * in bench/opened.ts reads these names back to short forms, so a change to how they are written
237 * changes it too.
238 */
239export function namesOf(parts: Segment[]): Map<string, string> {
240 const names = new Map<string, string>()
241 const walk = (segments: Segment[], isParagraphEnd: boolean) => {
242 const { leadIns, tails } = leadInsOf(segments, () => true, isParagraphEnd)
243 segments.forEach((part, i) => {
244 if (typeof part === 'string') return
245 if (drawnAs(part) === 'open') walk(part.long ?? [], false)
246 if (drawnAs(part) !== 'link') return
247 const before = segments[i - 1]
248 const after = segments[i + 1]
249 const leadIn = leadIns.get(i - 1)
250 const tail = tails.get(i + 1)
251 // The lead-in words and the spaces after them, as drawn up to the link.
252 const leadInText = leadIn !== undefined && typeof before === 'string' ? before.slice(leadIn.from) : ''
253 const glued = tail !== undefined && typeof after === 'string' ? after.slice(0, tail.to) : ''
254 names.set(part.key, `${leadInText}${part.short}${glued}`.trim())
255 for (const nested of stretches(part.long ?? [])) if (isLinkable(nested)) names.set(nested.key, nested.short.trim())
256 })
257 }
258 walk(parts, true)
259 return names
260}
261
262/**
263 * A stretch the reader opened, for the note to Claude: as written (`sourceOf`) and by its name
264 * (`namesOf`). Two stretches written alike after the same words are the same to the reader too.
265 */
266export type OpenedStretch = { source: string; name: string }
267
268/** `stretch` as a note to Claude names it, `names` being `namesOf` the parts it was parsed from. */
269export function openedOf(stretch: Stretch, names: ReadonlyMap<string, string>): OpenedStretch {
270 return { source: sourceOf(stretch), name: names.get(stretch.key) ?? stretch.short.trim() }
271}
272
273/**
274 * A stretch as written, braces and all, the same wherever it is parsed from, a paragraph (in the
275 * terminal) or the whole reply (in the desktop app), where its key, named among its siblings, can
276 * differ. It is here, not in parse.ts, which the bench hashes as part of the code that asks its tasks.
277 */
278function sourceOf(stretch: Stretch): string {
279 if (stretch.long === undefined) return `{{${stretch.short}}}`
280 return `{{${stretch.short}|${stretch.long.map(part => (typeof part === 'string' ? part : sourceOf(part))).join('')}}}`
281}
282
283/** Where some text in composed parts is marked: from and to, in that text, and the stretch it opens. */
284type Marked = { from: number; to: number; key: string }
285
286/**
287 * Where `withLeadIns` marks words in `parts`, by index of the text that holds them: the lead-in words
288 * before a wordless closed link, and the punctuation an empty short form is glued to (its tail), with
289 * the key each opens. Lead-in words the tail of the stretch before them overlaps are left out.
290 */
291function leadInsOf(
292 parts: Segment[],
293 isShut: (stretch: Stretch) => boolean,
294 isParagraphEnd: boolean,
295): { leadIns: Map<number, Marked>; tails: Map<number, Marked> } {
296 const leadIns = new Map<number, Marked>()
297 const tails = new Map<number, Marked>()
298 // What `parts` draw before index `i` on the same line: the words are found on the whole line, so a
299 // list mark, a code span or a `[` drawn by an earlier stretch is seen too.
300 const drawnBefore = (i: number): string => {
301 let line = ''
302 for (let j = i - 1; j >= 0; j--) {
303 const part = parts[j]
304 const drawn = part === undefined ? '' : typeof part === 'string' ? part : drawnText(part, isShut)
305 line = drawn + line
306 if (drawn.includes('\n')) break
307 }
308 return line.slice(line.lastIndexOf('\n') + 1)
309 }
310 parts.forEach((part, i) => {
311 const next = parts[i + 1]
312 if (typeof part !== 'string' || next === undefined || typeof next === 'string' || drawnAs(next) !== 'link' || !WORDLESS.test(next.short) || !isShut(next)) return
313 const before = part.includes('\n') ? '' : drawnBefore(i)
314 const found = leadInOf(before + part)
315 if (found === undefined || found.to <= before.length) return
316 const leadIn = { from: found.from - before.length, to: found.to - before.length }
317 // A sentence that starts before this text, in or before another stretch, lends only the words
318 // after that stretch, apart from it by a space, so the two links never draw as one.
319 if (leadIn.from < 0) {
320 leadIn.from = /^ */.exec(part)?.[0].length ?? 0
321 if (leadIn.from === 0 || !HAS_WORDS.test(part.slice(leadIn.from, leadIn.to))) return
322 }
323 // Words that start the text right after a stretch drawn without a run of its own (one that is only
324 // text, or an empty short form) are joined to that stretch's text: marking them would add a break.
325 const previous = parts[i - 1]
326 if (leadIn.from === 0 && previous !== undefined && (typeof previous === 'string' || drawnAs(previous) !== 'link' || previous.short === '')) return
327 const after = parts[i + 2]
328 if (leadIn.to === part.length && next.short === '' && (after !== undefined || !isParagraphEnd) && !(typeof after === 'string' && /^[ \n]/.test(after))) {
329 const glued = typeof after === 'string' ? (/^[^ \n]+/.exec(after)?.[0] ?? '') : ''
330 if (!/^[\p{P}\p{S}]+$/u.test(glued) || /[`*_~[\]<>|\\]/.test(glued)) return
331 // Punctuation that runs on into the next stretch is joined to whatever that stretch draws: marking it would add a break.
332 if (typeof after === 'string' && glued.length === after.length && (parts[i + 3] !== undefined || !isParagraphEnd)) return
333 tails.set(i + 2, { from: 0, to: glued.length, key: next.key })
334 }
335 if (leadIn.from >= (tails.get(i)?.to ?? 0)) leadIns.set(i, { ...leadIn, key: next.key })
336 })
337 return { leadIns, tails }
338}
339
340/**
341 * The stretches `action` acts on among those drawn, with the stretches in `open` open: to open, those
342 * drawn as links with every stretch around them open; to close, those open, whose « is drawn.
343 */
344export function targetsIn(parts: Segment[], open: readonly string[], action: Action): Stretch[] {
345 return parts.flatMap(part => {
346 if (typeof part === 'string' || part.long === undefined) return []
347 switch (drawnAs(part)) {
348 case 'text':
349 return []
350 // A short form over several lines is drawn open, with no «, so the stretches inside it are drawn.
351 case 'open':
352 return targetsIn(part.long, open, action)
353 case 'link':
354 if (!open.includes(part.key)) return action === 'open' ? [part] : []
355 return [...(action === 'close' ? [part] : []), ...targetsIn(part.long, open, action)]
356 }
357 })
358}
359
360/** The rows `text` takes in a region `columns` cells wide. */
361export function layout(text: string, columns: number): Row[] {
362 const width = Math.max(10, columns)
363 const rows: Row[] = []
364 let fence: string | undefined
365 for (const marked of text.split('\n')) {
366 const line = untint(marked)
367 if (fence !== undefined) {
368 if (closesFence(line.text, fence)) fence = undefined
369 else rows.push(...cutRows([{ text: ` ${line.text}`, code: true }], width))
370 continue
371 }
372 const marker = openingFence(line.text)
373 if (marker !== undefined) {
374 fence = marker
375 continue
376 }
377 rows.push(...rowsOf(line, width))
378 }
379
380 return rows
381}
382
383/**
384 * The phrase drawn at cell `x` of row `y`, if any. A one-cell `«` is hard to land on, so a press
385 * a cell beside a closer counts as on it.
386 */
387export function linkAt(rows: Row[], x: number, y: number): Run['link'] {
388 const spans: { from: number; to: number; link: Run['link'] }[] = []
389 let at = 0
390 for (const run of rows[y] ?? []) {
391 const w = widthOf(run.text)
392 spans.push({ from: at, to: at + w, link: run.link })
393 at += w
394 }
395 const exact = spans.find(span => x >= span.from && x < span.to)?.link
396 if (exact !== undefined) return exact
397
398 return spans.find(span => span.link?.action === 'close' && x >= span.from - 1 && x < span.to + 1)?.link
399}
400
401/**
402 * Whether a ctrl- or alt-click opens a hyperlink to `href`: a web page does, and a `file:` link,
403 * which could name a program behind its words, is left to the terminal's own click.
404 */
405export function isWebLink(href: string): boolean {
406 return /^https?:/.test(href)
407}
408
409/** Where the hyperlink drawn at cell `x` of row `y` goes, if one is drawn there. */
410export function hrefAt(rows: Row[], x: number, y: number): string | undefined {
411 let at = 0
412 for (const run of rows[y] ?? []) {
413 const w = widthOf(run.text)
414 if (x >= at && x < at + w) return run.href
415 at += w
416 }
417
418 return undefined
419}
420
421/**
422 * Cells a string takes: none for a mark drawn on the letter before it (a Thai vowel written above or
423 * below a letter, a tone mark, an accent) or a character no terminal draws, such as a joiner; two for
424 * wide East Asian characters and most emoji; else one.
425 */
426export function widthOf(text: string): number {
427 let cells = 0
428 for (const ch of text) {
429 const c = ch.codePointAt(0) ?? 0
430 // Every character below U+0300 takes one cell, so most English text skips the tests below. That
431 // includes the soft hyphen (U+00AD), which ZERO_WIDTH would count as none.
432 if (c < 0x300) {
433 cells += 1
434 continue
435 }
436 const isWide =
437 (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
438 (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
439 (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1f64f) || (c >= 0x1f900 && c <= 0x1f9ff) ||
440 (c >= 0x231a && EMOJI.test(ch))
441 cells += ZERO_WIDTH.test(ch) ? 0 : isWide ? 2 : 1
442 }
443
444 return cells
445}
446
447/**
448 * One part of a line split at spaces, cut into the pieces a row may break between and growth adds
449 * one at a time; a run of spaces stays whole.
450 */
451export function breakables(text: string): string[] {
452 // Nothing below Thai breaks, so a Latin word or a run of spaces comes back whole without a look at
453 // each character.
454 if (!/[^\0-\u0dff]/.test(text)) return [text]
455 const wordStarts = new Set<number>()
456 if (SEGMENTED.test(text)) for (const word of SEGMENTER.segment(text)) wordStarts.add(word.index)
457 const pieces: string[] = []
458 let piece = ''
459 let last = ''
460 let isAfterUnspaced = false
461 let at = 0
462 for (const ch of text) {
463 const isUnspaced = UNSPACED.test(ch)
464 const isWordStart = wordStarts.has(at) && SEGMENTED.test(last + ch)
465 if (piece !== '' && (isUnspaced || isAfterUnspaced || isWordStart) && !NO_START.test(ch) && !NO_END.test(last) && !ZERO_WIDTH.test(ch)) {
466 pieces.push(piece)
467 piece = ''
468 }
469 piece += ch
470 last = ch
471 isAfterUnspaced = isUnspaced || (isAfterUnspaced && NO_START.test(ch))
472 at += ch.length
473 }
474 if (piece !== '') pieces.push(piece)
475
476 return pieces
477}
478
479/**
480 * A line without its tint marks, so its markdown is read from the text alone: a stretch tints the
481 * words it adds one at a time, so a code span or a link label can hold several. A space takes the
482 * tint before it, so a tinted phrase stays one run.
483 */
484function untint(marked: string): Line {
485 let text = ''
486 const tints: Line['tints'] = []
487 let tinted: string | undefined
488 for (let i = 0; i < marked.length; i++) {
489 const ch = marked[i] ?? ''
490 const sep = ch === TINT ? marked.indexOf(SEP, i + 1) : -1
491 if (sep !== -1) {
492 tinted = marked.slice(i + 1, sep)
493 i = sep
494 } else if (ch === TINT_END) {
495 tinted = undefined
496 } else {
497 tints.push(ch === ' ' && tinted === undefined ? tints.at(-1) : tinted)
498 text += ch
499 }
500 }
501
502 return { text, tints }
503}
504
505function rowsOf({ text: line, tints }: Line, width: number): Row[] {
506 // The runs of the line's end from `rest` on.
507 const inlineOf = (rest: string, isBold = false) => inline(rest, tints.slice(line.length - rest.length), isBold)
508 if (line.trim() === '') return [[]]
509 if (isRule(line)) return [[{ text: '─'.repeat(Math.min(width, 60)), dim: true }]]
510 // A table row keeps its columns: written as is, cut where the region ends.
511 if (TABLE_ROW.test(line)) return cutRows(inlineOf(line), width)
512 const heading = HEADING.exec(line)
513 if (heading) return wrap(inlineOf(heading[1] ?? '', true), width, '', '')
514 const quote = QUOTE.exec(line)
515 if (quote) return wrap(inlineOf(quote[1] ?? ''), width, '▎ ', '▎ ', true)
516 const item = LIST_ITEM.exec(line)
517 if (item) {
518 const lead = `${item[1] ?? ''}${item[2] ?? ''} `
519 return wrap(inlineOf(item[3] ?? ''), width, lead, ' '.repeat(widthOf(lead)))
520 }
521 const indent = /^\s*/.exec(line)?.[0] ?? ''
522
523 return wrap(inlineOf(line.slice(indent.length)), width, indent, indent)
524}
525
526/** A line's inline markdown as runs: bold, italic, code spans, links, pressable phrases, each character in its tint. */
527function inline(text: string, tints: Line['tints'], isBold = false): Run[] {
528 const runs: Run[] = []
529 let bold = isBold
530 let italic = false
531 let pressable: Run['link']
532 // `chunk` starts at character `at`, and takes its tint.
533 const emit = (chunk: string, at: number, extra: Omit<Run, 'text'> = {}) => {
534 if (chunk === '') return
535 const tint = tints[at]
536 const run: Run = {
537 text: chunk,
538 ...(bold && { bold: true }),
539 ...(italic && { italic: true }),
540 ...(tint && { tint }),
541 ...(pressable && { link: pressable }),
542 ...extra,
543 }
544 const last = runs.at(-1)
545 if (last !== undefined && isSameStyle(last, run)) last.text += chunk
546 else runs.push(run)
547 }
548
549 for (let i = 0; i < text.length; ) {
550 const ch = text[i] ?? ''
551 const first = ch === START ? text.indexOf(SEP, i + 1) : -1
552 const second = first === -1 ? -1 : text.indexOf(SEP, first + 1)
553 if (second !== -1) {
554 const action = text.slice(i + 1, first)
555 pressable = { action: action === 'close' ? 'close' : 'open', key: text.slice(first + 1, second), ...(action === 'leadIn' && { isLeadIn: true }) }
556 i = second + 1
557 continue
558 }
559 if (ch === END) {
560 pressable = undefined
561 i += 1
562 continue
563 }
564 if (ch === '`') {
565 const fence = /^`+/.exec(text.slice(i))?.[0] ?? '`'
566 const end = text.indexOf(fence, i + fence.length)
567 // A code span never runs over a pressable phrase's marks: half of one, as a growing long form
568 // draws it, would pair with a backtick after them and draw the marks as text.
569 if (end !== -1 && !PHRASE_MARK.test(text.slice(i, end))) {
570 emit(text.slice(i + fence.length, end), i + fence.length, { code: true })
571 i = end + fence.length
572 continue
573 }
574 }
575 if (text.startsWith('**', i)) {
576 bold = !bold
577 i += 2
578 continue
579 }
580 if (ch === '*' && /\S/.test((italic ? text[i - 1] : text[i + 1]) ?? ' ')) {
581 italic = !italic
582 i += 1
583 continue
584 }
585 const markdownLink = /^\[([^\]\n\ufdd0-\ufdd2]+)\]\(([^)\s]+)\)/.exec(text.slice(i))
586 if (markdownLink) {
587 const label = markdownLink[1] ?? ''
588 const href = markdownLink[2] ?? ''
589 // A target that opens the same from any folder is drawn as a link; any other keeps its target
590 // beside it. Claude Code 2.1.302's own markdown links a relative path too.
591 const isFollowed = /^(https?|file):/.test(href)
592 for (let k = 0; k < label.length; k++) emit(label[k] ?? '', i + 1 + k, isFollowed ? { href } : { underline: true })
593 if (!isFollowed) emit(` (${href})`, i + markdownLink[0].length - 1, { dim: true })
594 i += markdownLink[0].length
595 continue
596 }
597 const url = ch === 'h' && !/\w/.test(text[i - 1] ?? ' ') ? bareUrlAt(text, i) : undefined
598 if (url !== undefined) {
599 for (let k = 0; k < url.length; k++) emit(url[k] ?? '', i + k, { href: url })
600 i += url.length
601 continue
602 }
603 emit(ch, i)
604 i += 1
605 }
606
607 return runs
608}
609
610/**
611 * The `http:` or `https:` URL written at `at`, without punctuation that ends the sentence after it
612 * or a closing bracket it did not open.
613 */
614function bareUrlAt(text: string, at: number): string | undefined {
615 let url = /^https?:\/\/[^\s<>`-]+/.exec(text.slice(at))?.[0]
616 if (url === undefined) return undefined
617 for (;;) {
618 const opened = url.split('(').length - 1
619 const closed = url.split(')').length - 1
620 if (/[.,;:!?'"*_]$/.test(url) || (url.endsWith(')') && closed > opened)) url = url.slice(0, -1)
621 else break
622 }
623
624 return /^https?:\/\/[^/]/.test(url) ? url : undefined
625}
626
627/**
628 * Wraps runs into rows of `width` cells, breaking at spaces and, in text without them, where
629 * `breakables` allows; the first row starts with `first`, later rows with `rest`.
630 */
631function wrap(runs: Run[], width: number, first: string, rest: string, isDimLead = false): Row[] {
632 const rows: Row[] = []
633 let row: Row = []
634 let used = 0
635 let isBare = true
636 const begin = (lead: string) => {
637 row = lead === '' ? [] : [{ text: lead, ...(isDimLead && { dim: true }) }]
638 used = widthOf(lead)
639 isBare = true
640 }
641 const put = (piece: Run) => {
642 const last = row.at(-1)
643 if (last !== undefined && isSameStyle(last, piece)) last.text += piece.text
644 else row.push({ ...piece })
645 used += widthOf(piece.text)
646 isBare = false
647 }
648 const breakRow = (isAtSpace = false) => {
649 const last = row.at(-1)
650 let join = isAtSpace ? ' ' : ''
651 if (last !== undefined && !isBare) {
652 const trimmed = last.text.trimEnd()
653 if (trimmed !== last.text) join = ' '
654 last.text = trimmed
655 if (last.text === '') row.pop()
656 }
657 rows.push(row)
658 begin(rest)
659 continuations.set(row, { join, lead: used })
660 }
661
662 begin(first)
663 for (const run of runs) {
664 for (const text of run.text.split(/( +)/).flatMap(breakables)) {
665 if (text === '') continue
666 if (text.trim() === '') {
667 if (isBare) continue
668 if (used + widthOf(text) <= width) put({ ...run, text })
669 else breakRow(true)
670 continue
671 }
672 let word = text
673 while (word !== '') {
674 if (used + widthOf(word) <= width) {
675 put({ ...run, text: word })
676 break
677 }
678 if (!isBare) {
679 breakRow()
680 continue
681 }
682 // A word wider than a whole row is cut where the row ends.
683 const [head, tail] = cut(word, width - used)
684 put({ ...run, text: head })
685 breakRow()
686 word = tail
687 }
688 }
689 }
690 rows.push(row)
691
692 return rows
693}
694
695/** Rows of text kept as written, cut every `width` cells. */
696function cutRows(runs: Run[], width: number): Row[] {
697 const rows: Row[] = []
698 let row: Row = []
699 let used = 0
700 for (const run of runs) {
701 let rest = run.text
702 while (rest !== '') {
703 if (used >= width) {
704 rows.push(row)
705 row = []
706 used = 0
707 continuations.set(row, { join: '', lead: 0 })
708 }
709 const [head, tail] = cut(rest, width - used)
710 row.push({ ...run, text: head })
711 used += widthOf(head)
712 rest = tail
713 }
714 }
715 rows.push(row)
716
717 return rows
718}
719
720/** `text` split where it would pass `room` cells, keeping at least one character. */
721function cut(text: string, room: number): [string, string] {
722 const chars = [...text]
723 let used = 0
724 let count = 0
725 for (const ch of chars) {
726 if (count > 0 && used + widthOf(ch) > room) break
727 used += widthOf(ch)
728 count += 1
729 }
730
731 return [chars.slice(0, count).join(''), chars.slice(count).join('')]
732}
733
734function isSameStyle(a: Run, b: Run): boolean {
735 return (
736 a.bold === b.bold && a.italic === b.italic && a.code === b.code && a.underline === b.underline && a.dim === b.dim && a.tint === b.tint &&
737 a.link?.action === b.link?.action && a.link?.key === b.link?.key && a.link?.isLeadIn === b.link?.isLeadIn && a.href === b.href
738 )
739}
740hooks/contrast.ts 61 lines1// Colour arithmetic shared by the reply and the hooks module: the text colour that reads on a highlight,
2// and the mix and hex spelling the stretch palette is built from.
3
4/** A colour's red, green and blue, 0 to 255 each. */
5export type Rgb = readonly [number, number, number]
6/** A colour written `#rgb` or `#rrggbb`. */
7export type Hex = `#${string}`
8
9const DARK: Rgb = [0x1c, 0x1c, 0x1c]
10const LIGHT: Rgb = [0xf2, 0xf2, 0xf2]
11/**
12 * How far dim moves text toward its background: as of Claude Code 2.1.302 Claude Code's dim grey sits
13 * 40% of the way from text to background on both themes (153 between 255 and 0, 102 between 0 and 255).
14 */
15const DIM_SHARE = 0.4
16
17/** A colour written as `#rrggbb`. */
18export const hex = (rgb: Rgb): Hex => `#${rgb.map(v => v.toString(16).padStart(2, '0')).join('')}`
19
20/** `rgb` moved `share` of the way toward `toward`, channel by channel. */
21export function mix(rgb: Rgb, toward: Rgb, share: number): Rgb {
22 const channel = (at: 0 | 1 | 2) => Math.round(rgb[at] + (toward[at] - rgb[at]) * share)
23 return [channel(0), channel(1), channel(2)]
24}
25
26/** The red, green and blue of a `#rgb` or `#rrggbb` colour; undefined for anything else. */
27function rgbOf(colour: string): Rgb | undefined {
28 const short = /^#([0-9a-f]{3})$/i.exec(colour)?.[1]
29 const digits = short === undefined ? /^#([0-9a-f]{6})$/i.exec(colour)?.[1] : [...short].map(c => c + c).join('')
30 if (digits === undefined) return undefined
31 const channel = (at: number) => parseInt(digits.slice(at, at + 2), 16)
32 return [channel(0), channel(2), channel(4)]
33}
34
35/** Whether a colour is written `#rgb` or `#rrggbb`. */
36export const isHex = (colour: string): colour is Hex => rgbOf(colour) !== undefined
37
38/** The relative luminance of a colour, as WCAG defines it. */
39function luminance(rgb: Rgb): number {
40 const linear = (value: number) => {
41 const c = value / 255
42 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
43 }
44 return 0.2126 * linear(rgb[0]) + 0.7152 * linear(rgb[1]) + 0.0722 * linear(rgb[2])
45}
46
47const ratio = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
48
49/**
50 * Text that reads on a highlight: near black or near white, whichever contrasts more, and under dim
51 * moved toward the highlight as dim moves text toward its background. Nothing for a colour that is
52 * not hex, whose luminance is unknown here.
53 */
54export function contrastOn(background: string, isDim = false): { color?: Hex } {
55 const backgroundRgb = rgbOf(background)
56 if (backgroundRgb === undefined) return {}
57 const bg = luminance(backgroundRgb)
58 const text = ratio(bg, luminance(DARK)) >= ratio(bg, luminance(LIGHT)) ? DARK : LIGHT
59 return { color: hex(isDim ? mix(text, backgroundRgb, DIM_SHARE) : text) }
60}
61hooks/look.ts 101 lines1// How a phrase the reader can press is marked: text marks that turn on or off one by one, and a
2// highlight. Shared by the hooks module, which also draws the toolbar, and the reply.
3
4import { contrastOn, isHex } from './contrast'
5import type { Hex } from './contrast'
6
7/** Every text mark a phrase can carry, in the order the toolbar shows them. */
8export const MARK_NAMES = ['bold', 'italic', 'underline', 'strikethrough', 'dim'] as const
9export type Mark = (typeof MARK_NAMES)[number]
10
11/** The highlight that puts each closed stretch on the colour its words take once open, blended 75% into the background. */
12export const TINTED = 'tinted'
13
14/** A highlight: one hex colour for every stretch, TINTED, or '' for none. */
15export type Highlight = Hex | typeof TINTED | ''
16
17/** The marks a pressable phrase carries and its highlight. */
18export type Look = { marks: Mark[]; highlight: Highlight }
19
20/** A look as a reply draws it: the hooks module has already replaced TINTED with '', and gives each stretch's own colour in ReplyProps.highlights. */
21export type DrawnLook = { marks: Mark[]; highlight: Hex | '' }
22
23/** The default: underlined, in the text's own colour. */
24export const DEFAULT_LOOK: DrawnLook = { marks: ['underline'], highlight: '' }
25
26const textOf = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')
27
28/**
29 * A highlight as one spelling: TINTED, or a hex colour in lower case, so `#264F78` and `#264f78` are
30 * one colour; anything else is none, as its luminance is unknown here and no text colour can be chosen
31 * to read on it.
32 */
33const highlightOf = (value: unknown): Highlight => {
34 const text = textOf(value).toLowerCase()
35 return text === TINTED ? TINTED : isHex(text) ? text : ''
36}
37
38/** Whether a highlight is one colour, for every stretch alike. */
39export const isColour = (highlight: Highlight): highlight is Hex => highlight !== '' && highlight !== TINTED
40
41/** The marks named in a list (commas or spaces, any case), in toolbar order, unknown names dropped. */
42export function marksOf(value: unknown): Mark[] {
43 const names = textOf(value).toLowerCase().split(/[\s,;]+/)
44 return MARK_NAMES.filter(mark => names.includes(mark))
45}
46
47/**
48 * The look the options set. `marks` left empty keeps the default underline, so a highlight alone
49 * adds to it; `none` takes every mark away. A value that names no mark (a typo) keeps the default
50 * too, so stretches never lose their mark by accident. A hex highlight and `tinted` are read in
51 * any case, and any other highlight is none.
52 */
53export function lookOf(options: Record<string, unknown>): Look {
54 const named = textOf(options.marks)
55 const parsed = marksOf(named)
56 const marks = named.toLowerCase() === 'none' ? [] : parsed.length > 0 ? parsed : DEFAULT_LOOK.marks
57 return { marks, highlight: highlightOf(options.highlight) }
58}
59
60/** The `marks` setting that reads back as `marks`: `none` for none, as an empty one means the default. */
61export function marksSetting(marks: readonly Mark[]): string {
62 return marks.length === 0 ? 'none' : marks.join(',')
63}
64
65/** A look held in state, checked: what the toolbar stored, or null when there is none. */
66export function storedLook(value: unknown): Look | null {
67 if (typeof value !== 'object' || value === null || !('marks' in value) || !Array.isArray(value.marks)) return null
68 if (!('highlight' in value) || typeof value.highlight !== 'string') return null
69 return { marks: marksOf(value.marks.join(',')), highlight: highlightOf(value.highlight) }
70}
71
72/** Whether two looks draw a phrase the same. */
73export function isSameLook(a: Look, b: Look): boolean {
74 return a.highlight === b.highlight && a.marks.length === b.marks.length && a.marks.every(mark => b.marks.includes(mark))
75}
76
77/** Whether a look draws its highlight on a run: only on plain text, leaving a coloured closer, code and a hyperlink alone. */
78export const drawsHighlight = (look: DrawnLook, isPlainText: boolean) => look.highlight !== '' && isPlainText
79
80/**
81 * The text props a look gives a pressable phrase, whose run may be dim of its own (a link target).
82 * The look's dim and highlight leave a coloured closer, code and a hyperlink inside the phrase alone.
83 * On a highlight the text turns dark or light so it reads, and dim moves that colour toward the
84 * highlight.
85 */
86export function lookProps(look: DrawnLook, isPlainText: boolean, isDim = false) {
87 const has = (mark: Mark) => look.marks.includes(mark)
88 const isHighlighted = drawsHighlight(look, isPlainText)
89 const isDimmed = isDim || (has('dim') && isPlainText)
90 return {
91 ...(has('bold') && { bold: true }),
92 ...(has('italic') && { italic: true }),
93 ...(has('underline') && { underline: true }),
94 ...(has('strikethrough') && { strikethrough: true }),
95 ...(isHighlighted && { backgroundColor: look.highlight, ...contrastOn(look.highlight, isDimmed) }),
96 // As of Claude Code 2.1.302 dimColor draws the theme's grey whatever the colour, so on a highlight
97 // contrastOn dims the colour chosen to read on it instead.
98 ...(isDimmed && !isHighlighted && { dimColor: true }),
99 }
100}
101hooks/reply.tsx 570 lines1import type { ClientModule, ClientPointerEvent, ClientSurface, TextProps } from 'claude-code'
2
3import { grow, lengthOf } from './grow'
4import { continuationOf, hrefAt, isMarkup, isWebLink, layout, link, linkAt, namesOf, openedOf, targetsIn, tint, widthOf, withLeadIns } from './layout'
5import type { Action, OpenedStretch, Row, Run } from './layout'
6import { closeAfter, isLinkable, KEY_SEPARATOR, parse } from './parse'
7import { DEFAULT_LOOK, drawsHighlight, lookProps } from './look'
8import type { Hex } from './contrast'
9import type { DrawnLook } from './look'
10import type { Segment, Stretch } from './parse'
11
12// Draws one paragraph of a reply that holds stretches. It runs where the reader's screen draws it,
13// so a press answers at once and the growth moves on the screen's own frame clock. What the reader
14// opens is posted to the hooks module, which keeps it across redraws, notes it for Claude and picks
15// its colour.
16
17/**
18 * The colours of an open stretch: of the words it added (faded unless it is the newest) and of its
19 * closer, which never fades, as a faded closer reads as disabled.
20 */
21export type Tint = { words: string; closer: string }
22
23/** A stretch the reader asked to open or close from the keyboard, numbered so each ask acts once. */
24type Ask = { key: string; action: Action; n: number }
25
26/** While letters show: whether typing one opens or closes, and the letter of each stretch they cover. */
27type Letters = { action: Action; keys: Record<string, string> }
28
29/**
30 * The paragraph, the stretches the reader already has open in it, the tint of each, the last ask, its
31 * letters, how pressable phrases look, and under the tinted highlight each stretch's own highlight, by
32 * key, in place of the look's.
33 */
34export type ReplyProps = {
35 text: string
36 open: string[]
37 tints: Record<string, Tint>
38 ask: Ask | null
39 letters: Letters | null
40 look?: DrawnLook
41 highlights: Record<string, Hex>
42}
43
44/**
45 * What the instance posts to the hooks module when the open stretches change, with the stretch the
46 * reader just opened: as the note to Claude names it, and its key, for its colour.
47 */
48export type ReplyPost = { open: string[]; opened?: OpenedStretch & { key: string } }
49
50/** What the instance posts to the hooks module when the reader drags across its text: the text, for the clipboard. */
51export type CopyPost = { copy: string }
52
53/** What the instance posts when the reader ctrl- or alt-clicks a hyperlink: where it goes, for the hooks module to open. */
54export type FollowPost = { follow: string }
55
56/**
57 * A run as drawn: a letter over the first character of a short form or a «, or the rest of that
58 * text after it; or text the reader dragged across.
59 */
60type DrawnRun = Run & { isLetter?: true; isAfterLetter?: true; isSelected?: true }
61
62type Growth = { key: string; isOpening: boolean; progress: number; duration: number }
63type Cell = { x: number; y: number }
64/**
65 * Text the reader dragged across: from the cell the press went down on to the one under the pointer.
66 * A word picked by a double-click covers both its ends, even when they are the same cell. `link` is
67 * the link the press went down on, which its release acts on unless the pointer `hasMoved` to another
68 * cell first. `follow` is the web link a ctrl- or alt-press went down on, which its release opens on
69 * the same terms.
70 */
71type Selection = { anchor: Cell; head: Cell; isDragging: boolean; isWord?: true; link?: Run['link']; follow?: string; hasMoved?: true }
72/**
73 * One cell a double-click looks through for a word: what it shows, and where. `startsApart` marks
74 * the first cell of a row that no word runs onto from the row before.
75 */
76type WordCell = { segment: string; x: number; y: number; width: number; startsApart?: true }
77/** What is open, what is growing, the number of the last ask acted on, and what the reader selected. */
78type State = { open: string[]; growth: Growth | null; asked: number; selection: Selection | null }
79
80const FRAME_MS = 16
81// Two presses make a double-click when the second comes less than this long after the first and at
82// most this many cells from it, across and down: the limits of Claude Code 2.1.303's own double-click,
83// so a paragraph that holds a stretch answers like the paragraphs around it.
84const DOUBLE_CLICK_MS = 500
85const DOUBLE_CLICK_CELLS = 1
86// Lead-in words open once a second press can no longer make a double-click of the first.
87const DOUBLE_CLICK_FRAMES = Math.ceil(DOUBLE_CLICK_MS / FRAME_MS)
88// What a double-click takes as one word: Claude Code 2.1.303's own word characters, and `#` as well,
89// so that `#12806` comes out whole.
90const WORD = /[\p{L}\p{N}_/.\-+~\\#]/u
91// What a URL is made of: as Claude Code 2.1.303's own double-click does, a URL is taken from its
92// scheme on, whatever style it is drawn in.
93const IN_URL = /^(?![<>"'`])[!-~]$/
94// What a URL does not end on: the punctuation after it, and a closing bracket it does not open.
95const AFTER_URL = '.,;:!?'
96const OPENERS: Record<string, string> = { ')': '(', ']': '[', '}': '{' }
97const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
98
99// What the last call drew, per instance, so a press is matched against the rows on screen: the rows
100// as laid out, and as drawn with any letters over them.
101const drawn = new WeakMap<object, { laid: Row[]; rows: Row[]; segments: Segment[] }>()
102// The last ask each instance was handed, acted on at its next frame rather than while drawing.
103const asks = new WeakMap<object, Ask>()
104// The last press each instance saw, where and when it went down, and the stretch whose lead-in words
105// it went down on, if any, so the next press soon after, close by and on the same words makes a
106// double-click; a quick third press, measured from the second, picks the word again. It is dropped
107// when its release lands on another cell, so two quick drags from one cell stay two drags, and when a
108// stretch opens or closes, which ends a double-click begun before it.
109const presses = new WeakMap<object, Cell & { at: number; key?: string }>()
110// A click on lead-in words, per instance: the stretch it opens, the frames left before a second press
111// can no longer make it a double-click, and whether the button is up. A double-click there picks a
112// word, as it does in any text, and a drag from there selects.
113const pendingOpens = new WeakMap<object, { key: string; framesLeft: number; isReleased: boolean }>()
114
115const Reply: ClientModule<ReplyProps, State> = (props, surface) => {
116 const { Box, Link, Text } = surface.elements
117 // An ask made before this instance was drawn is not acted on again.
118 const fresh: State = { open: props.open, growth: null, asked: props.ask?.n ?? 0, selection: null }
119 if (surface.state === undefined) {
120 surface.setState(fresh)
121 surface.onPointer(event => pointer(surface, event))
122 surface.every(FRAME_MS, () => tick(surface))
123 }
124 // As of Claude Code 2.1.302 a paragraph an older plugin version drew stays on screen after
125 // /reload-plugins, and this code is handed its old props, without the ask, the letters, the look or
126 // the highlights.
127 const ask = props.ask ?? null
128 const letters = props.letters ?? null
129 if (ask !== null) asks.set(surface, ask)
130 const state = surface.state ?? fresh
131 const segments = parse(props.text)
132 // Read through Maps, which hold own keys only: a stretch keyed `constructor` must not find Object's.
133 const tints = new Map(Object.entries(props.tints))
134 const laid = layout(compose(segments, state, tints), surface.columns > 0 ? surface.columns : 80)
135 const lettering = lettered(laid, letters)
136 drawn.set(surface, { laid, rows: lettering, segments })
137 const rows = state.selection === null ? lettering : selected(lettering, laid, state.selection)
138 const look = props.look ?? DEFAULT_LOOK
139 const highlights = new Map(Object.entries(props.highlights ?? {}))
140 const lookFor = (run: DrawnRun): DrawnLook => {
141 const highlight = run.link && highlights.get(run.link.key)
142 return highlight === undefined ? look : { ...look, highlight }
143 }
144
145 return (
146 <Box flexDirection="column">
147 {rows.map(row => (
148 <Text wrap="truncate">
149 {row.length === 0
150 ? ' '
151 : row.map(run => {
152 const piece = <Text {...styleOf(run, lookFor(run))}>{run.text}</Text>
153 return run.href === undefined ? piece : <Link href={run.href}>{piece}</Link>
154 })}
155 </Text>
156 ))}
157 </Box>
158 )
159}
160
161export default Reply
162
163/**
164 * The paragraph as the reader sees it now: closed stretches as pressable short forms, open ones in
165 * full then a «, the words each added in its tint.
166 */
167function compose(segments: Segment[], state: State, tints: ReadonlyMap<string, Tint>): string {
168 // The lead-in words of a link with no words of its own open it too, also while another stretch
169 // grows: `layout` never lets half a code span or link drawn by that growth run over their marks.
170 const write = (parts: Segment[], isParagraphEnd = false): string =>
171 withLeadIns(parts, stretch => !state.open.includes(stretch.key), isParagraphEnd)
172 .map(part => (typeof part === 'string' ? part : draw(part)))
173 .join('')
174 const draw = (stretch: Stretch): string => {
175 if (stretch.long === undefined || !stretch.isClosed) return stretch.short
176 // A short form over several lines is drawn open, or as written when its long form is empty.
177 if (!isLinkable(stretch)) return stretch.long.length === 0 ? stretch.short : write(stretch.long)
178 const colours = tints.get(stretch.key)
179 const paint = (word: string) => (colours === undefined || isMarkup(word) ? word : tint(colours.words, word))
180 if (state.growth?.key === stretch.key) return grow(stretch.short, stretch.long, easeInOut(state.growth.progress), draw, paint)
181 if (!state.open.includes(stretch.key)) return link('open', stretch.key, stretch.short)
182 const closer = link('close', stretch.key, '«')
183 const long = grow(stretch.short, stretch.long, 1, draw, paint)
184 return closeAfter(long, colours === undefined ? closer : tint(colours.closer, closer), '\n')
185 }
186
187 return write(segments, true)
188}
189
190/**
191 * The rows with each letter drawn over the first character of what a press on its stretch acts on:
192 * its lead-in words if it has them, else its short form, or its «, so nothing on screen moves; the
193 * rest of that text is marked.
194 */
195function lettered(rows: Row[], letters: Letters | null): DrawnRun[][] {
196 if (letters === null) return rows
197 const keys = new Map(Object.entries(letters.keys))
198 const done = new Set<string>()
199 return rows.map(row =>
200 row.flatMap((run): DrawnRun[] => {
201 const { link } = run
202 const letter = link?.action === letters.action ? keys.get(link.key) : undefined
203 if (link === undefined || letter === undefined) return [run]
204 if (done.has(link.key)) return [{ ...run, isAfterLetter: true }]
205 done.add(link.key)
206 const first = GRAPHEMES.segment(run.text).containing(0)?.segment
207 if (first === undefined) return [run]
208 const rest = run.text.slice(first.length)
209 // A letter takes one cell, so a wide first character keeps its second cell as a space.
210 const cover: DrawnRun = { ...run, text: widthOf(first) > 1 ? `${letter} ` : letter, isLetter: true }
211 return rest === '' ? [cover] : [cover, { ...run, text: rest, isAfterLetter: true }]
212 }),
213 )
214}
215
216/**
217 * A click on a short form or a « opens or closes its stretch as the button comes up, and a click on
218 * lead-in words opens it once no second press has made a double-click. As of Claude Code 2.1.302
219 * a Client holds the pointer from a press until its release and the transcript selects nothing
220 * meanwhile, so a drag from anywhere, a link included, selects here: the cells it crosses are
221 * highlighted, and on release their text goes to the clipboard. A double-click selects here too,
222 * taking the URL or word under it at once, except on a short form or a «, which the first click acted on.
223 * A ctrl- or alt-click on a web link posts it for the hooks module to open as the button comes up,
224 * and a ctrl- or alt-drag from one selects; away from a web link either acts as a plain one.
225 */
226function pointer(surface: ClientSurface<State>, event: ClientPointerEvent) {
227 const state = surface.state
228 const shown = drawn.get(surface)
229 if (state === undefined || shown === undefined) return
230 const { selection } = state
231 if (event.type === 'down' && event.button === 'left') {
232 const href = event.ctrl === true || event.alt === true ? hrefAt(shown.rows, event.x, event.y) : undefined
233 const follow = href !== undefined && isWebLink(href) ? href : undefined
234 // A web link inside a short form is the link's under ctrl or alt, not the stretch's.
235 const target = follow === undefined ? linkAt(shown.rows, event.x, event.y) : undefined
236 const leadInKey = target?.isLeadIn === true ? target.key : undefined
237 const cell = clamped(shown.rows, event)
238 const at = performance.now()
239 const last = presses.get(surface)
240 const isDouble =
241 last !== undefined &&
242 at - last.at < DOUBLE_CLICK_MS &&
243 Math.abs(cell.x - last.x) <= DOUBLE_CLICK_CELLS &&
244 Math.abs(cell.y - last.y) <= DOUBLE_CLICK_CELLS &&
245 last.key === leadInKey
246 // A press that makes no double-click of a click on lead-in words lets that click open its stretch
247 // first, so two quick clicks on two stretches open both.
248 if (pendingOpens.get(surface)?.isReleased === true && !isDouble) openPending(surface)
249 const latest = surface.state ?? state
250 // A double-click picks a word in text and in lead-in words, not on a short form or a «.
251 const word = follow === undefined && isDouble && (target === undefined || leadInKey !== undefined) ? wordAt(shown.laid, cell) : undefined
252 presses.set(surface, { ...cell, at, ...(leadInKey !== undefined && { key: leadInKey }) })
253 if (word !== undefined) {
254 pendingOpens.delete(surface)
255 const picked: Selection = { ...word, isDragging: false, isWord: true }
256 surface.setState({ ...latest, selection: picked })
257 const text = textIn(shown.laid, picked)
258 if (text !== '') surface.post({ copy: text } satisfies CopyPost)
259 return
260 }
261 if (leadInKey !== undefined) pendingOpens.set(surface, { key: leadInKey, framesLeft: DOUBLE_CLICK_FRAMES, isReleased: false })
262 surface.setState({ ...latest, selection: { anchor: cell, head: cell, isDragging: true, ...(target !== undefined && { link: target }), ...(follow !== undefined && { follow }) } })
263 return
264 }
265 // A selection left on screen goes once the pointer moves off the paragraph: as of Claude Code
266 // 2.1.302 a press elsewhere does not reach this instance to take it down.
267 if (event.type === 'leave' && selection?.isDragging === false) {
268 surface.setState({ ...state, selection: null })
269 return
270 }
271 if (selection?.isDragging !== true) return
272 if (event.type === 'move') {
273 const head = clamped(shown.rows, event)
274 if (head.x === selection.head.x && head.y === selection.head.y) return
275 surface.setState({ ...state, selection: { ...selection, head, hasMoved: true } })
276 return
277 }
278 if (event.type !== 'up') return
279 const { link: pressed, follow } = selection
280 // A press that moved to another cell was a drag, which selects; one that did not is a click on its link.
281 if (follow !== undefined && selection.hasMoved !== true) {
282 surface.setState({ ...state, selection: null })
283 surface.post({ follow } satisfies FollowPost)
284 return
285 }
286 if (pressed !== undefined && selection.hasMoved !== true) {
287 surface.setState({ ...state, selection: null })
288 // A click on lead-in words opens at a frame once a double-click can no longer follow.
289 const pending = pendingOpens.get(surface)
290 if (pressed.isLeadIn !== true) openOrClose(surface, pressed.action, pressed.key)
291 else if (pending !== undefined) pending.isReleased = true
292 return
293 }
294 const text = textIn(shown.laid, selection)
295 if (text !== '' || selection.anchor.x !== selection.head.x || selection.anchor.y !== selection.head.y) presses.delete(surface)
296 // A click (a press that moved nowhere) takes a selection down, and so does a release off the
297 // paragraph, which as of Claude Code 2.1.302 no leave follows.
298 const isOff = event.x < 0 || event.y < 0 || event.x >= surface.columns || event.y >= shown.rows.length
299 surface.setState({ ...state, selection: text === '' || isOff ? null : { ...selection, isDragging: false } })
300 if (text !== '') surface.post({ copy: text } satisfies CopyPost)
301}
302
303/** The cell under the pointer, kept to the rows drawn: above them is their start, below them their end. */
304function clamped(rows: Row[], { x, y }: Cell): Cell {
305 if (y < 0) return { x: 0, y: 0 }
306 if (y >= rows.length) return { x: Number.POSITIVE_INFINITY, y: Math.max(0, rows.length - 1) }
307 return { x: Math.max(0, x), y }
308}
309
310/** The selection's first and last cells in reading order, both selected; none when it covers no cell. */
311function span({ anchor, head, isWord }: Selection): { from: Cell; to: Cell } | undefined {
312 if (anchor.x === head.x && anchor.y === head.y && isWord !== true) return undefined
313 const isForward = anchor.y < head.y || (anchor.y === head.y && anchor.x < head.x)
314 return isForward ? { from: anchor, to: head } : { from: head, to: anchor }
315}
316
317/**
318 * The URL or the word around cell `at` as a selection's two ends, its first and last cells, which a
319 * line wrapped inside it puts on different rows; none when the cell is in no URL and holds no word
320 * character, so a double-click on a space or a comma picks nothing.
321 */
322function wordAt(rows: Row[], at: Cell): { anchor: Cell; head: Cell } | undefined {
323 const cells = cellsIn(rows)
324 const hit = cells.findIndex(cell => cell.y === at.y && at.x >= cell.x && at.x < cell.x + Math.max(1, cell.width))
325 const ends = urlAround(cells, hit) ?? wordAround(cells, hit)
326 const first = ends && cells[ends[0]]
327 const last = ends && cells[ends[1]]
328 if (first === undefined || last === undefined) return undefined
329 return { anchor: { x: first.x, y: first.y }, head: { x: last.x + Math.max(1, last.width) - 1, y: last.y } }
330}
331
332/**
333 * Every cell of `rows` in reading order, without the lead a wrapped line's later rows start with. A
334 * row starts apart from the one before unless a line wrapped onto it inside a word.
335 */
336function cellsIn(rows: Row[]): WordCell[] {
337 const cells: WordCell[] = []
338 rows.forEach((row, y) => {
339 const continuation = continuationOf(row)
340 let x = 0
341 for (const run of row) {
342 for (const { segment } of GRAPHEMES.segment(run.text)) {
343 const width = widthOf(segment)
344 if (x >= (continuation?.lead ?? 0)) {
345 const isApart = cells.at(-1)?.y !== y && continuation?.join !== ''
346 cells.push({ segment, x, y, width, ...(isApart && { startsApart: true }) })
347 }
348 x += width
349 }
350 }
351 })
352 return cells
353}
354
355/** The first and last of the cells around `hit` that `isIn` holds for, within one line. */
356function around(cells: WordCell[], hit: number, isIn: (cell: WordCell | undefined) => boolean): [number, number] {
357 let first = hit
358 let last = hit
359 while (cells[first]?.startsApart !== true && isIn(cells[first - 1])) first -= 1
360 while (cells[last + 1]?.startsApart !== true && isIn(cells[last + 1])) last += 1
361 return [first, last]
362}
363
364/**
365 * The URL around cell `hit`: its run of URL characters, cut at the last scheme at or before it and
366 * the next scheme after it, less what a URL does not end on; none when `hit` is in no URL.
367 */
368function urlAround(cells: WordCell[], hit: number): [number, number] | undefined {
369 const isInUrl = (cell: WordCell | undefined) => IN_URL.test(cell?.segment ?? '')
370 if (!isInUrl(cells[hit])) return undefined
371 const [first, last] = around(cells, hit, isInUrl)
372 // Each cell of the run holds one character, so a place in its text is a place among its cells.
373 const text = cells.slice(first, last + 1).map(cell => cell.segment).join('')
374 const offset = hit - first
375 const starts = [...text.matchAll(/(?:https?|file):\/\//g)].map(match => match.index)
376 const from = starts.findLast(start => start <= offset)
377 if (from === undefined) return undefined
378 let url = text.slice(from, starts.find(start => start > offset))
379 while (url !== '') {
380 const end = url.slice(-1)
381 const opener = OPENERS[end]
382 const isUnopened = opener !== undefined && url.split(end).length > url.split(opener).length
383 if (!AFTER_URL.includes(end) && !isUnopened) break
384 url = url.slice(0, -1)
385 }
386 return offset < from + url.length ? [first + from, first + from + url.length - 1] : undefined
387}
388
389/**
390 * The run of word characters around cell `hit`, without the full stops it ends on unless it is
391 * nothing else; none when `hit` holds no word character.
392 */
393function wordAround(cells: WordCell[], hit: number): [number, number] | undefined {
394 const isWord = (cell: WordCell | undefined) => WORD.test(cell?.segment ?? '')
395 if (!isWord(cells[hit])) return undefined
396 const [first, last] = around(cells, hit, isWord)
397 const isStop = (at: number) => cells[at]?.segment === '.'
398 let end = last
399 while (end > first && isStop(end)) end -= 1
400 return [first, isStop(end) ? last : end]
401}
402
403/**
404 * The columns of `row`, row `y`, a selection covers, from the first to the last, both included. A row
405 * a line wrapped onto is covered after its lead, as the line itself had none there.
406 */
407function columnsOf(y: number, row: Row, { from, to }: { from: Cell; to: Cell }): [number, number] | undefined {
408 if (y < from.y || y > to.y) return undefined
409 const lead = continuationOf(row)?.lead ?? 0
410 return [Math.max(y === from.y ? from.x : 0, lead), y === to.y ? to.x : Number.POSITIVE_INFINITY]
411}
412
413/**
414 * Each run of `rows` with the part a selection covers split out and marked, character by character:
415 * the cells `textIn` copies. `laid` is `rows` before letters were drawn over them, which is where a
416 * wrapped row's lead is recorded.
417 */
418function selected(rows: DrawnRun[][], laid: Row[], selection: Selection): DrawnRun[][] {
419 const covered = span(selection)
420 if (covered === undefined) return rows
421 return rows.map((row, y) => {
422 const columns = columnsOf(y, laid[y] ?? [], covered)
423 if (columns === undefined) return row
424 const [first, last] = columns
425 const out: DrawnRun[] = []
426 let at = 0
427 for (const run of row) {
428 let piece: DrawnRun | undefined
429 for (const { segment } of GRAPHEMES.segment(run.text)) {
430 const isSelected = at >= first && at <= last
431 if (piece !== undefined && (piece.isSelected === true) === isSelected) piece.text += segment
432 else {
433 piece = { ...run, text: segment, ...(isSelected && { isSelected: true }) }
434 out.push(piece)
435 }
436 at += widthOf(segment)
437 }
438 }
439 return out
440 })
441}
442
443/**
444 * The text a selection covers, as the reader would paste it: a line wrapped over several rows joined
445 * back into one, without the lead its later rows start with, and each line's trailing spaces gone.
446 */
447function textIn(rows: Row[], selection: Selection): string {
448 const covered = span(selection)
449 if (covered === undefined) return ''
450 let text = ''
451 for (let y = covered.from.y; y <= covered.to.y; y += 1) {
452 const row = rows[y] ?? []
453 const [start, last] = columnsOf(y, row, covered) ?? [0, Number.POSITIVE_INFINITY]
454 const continuation = continuationOf(row)
455 let piece = ''
456 let at = 0
457 for (const run of row) {
458 for (const { segment } of GRAPHEMES.segment(run.text)) {
459 if (at >= start && at <= last) piece += segment
460 at += widthOf(segment)
461 }
462 }
463 if (y > covered.from.y) text = continuation === undefined ? `${text.trimEnd()}\n` : `${text}${continuation.join}`
464 text += piece
465 }
466
467 return text.trimEnd()
468}
469
470/**
471 * Opens or closes stretch `key` as a press on it does; false when no link on screen does that, as
472 * for a letter the hooks gave a stretch still growing open, whose « is not drawn yet.
473 */
474function openOrClose(surface: ClientSurface<State>, action: Action, key: string, asked?: number): boolean {
475 const state = surface.state
476 const shown = drawn.get(surface)
477 if (state === undefined || shown === undefined || !hasLink(shown.rows, action, key)) return false
478
479 // A stretch still shrinking when another press lands is closed at once, so what can be pressed is
480 // judged without it: a press inside it, or a letter put up before it closed, does nothing.
481 const open = state.growth !== null && !state.growth.isOpening ? closed(state.open, state.growth.key) : state.open
482 const stretch = targetsIn(shown.segments, open, action).find(one => one.key === key)
483 if (stretch?.long === undefined) return false
484 const duration = 1000 * Math.min(1.4, 0.45 + lengthOf(stretch.long) / 70)
485 const acted = asked ?? state.asked
486 if (action === 'open') {
487 const next = [...open.filter(k => k !== stretch.key), stretch.key]
488 surface.setState({ open: next, growth: { key: stretch.key, isOpening: true, progress: 0, duration }, asked: acted, selection: null })
489 surface.post({ open: next, opened: { ...openedOf(stretch, namesOf(shown.segments)), key: stretch.key } } satisfies ReplyPost)
490 } else {
491 surface.setState({ open, growth: { key: stretch.key, isOpening: false, progress: 1, duration }, asked: acted, selection: null })
492 }
493 presses.delete(surface)
494
495 return true
496}
497
498/** Opens the stretch a click on lead-in words is waiting to open; false when it could not. */
499function openPending(surface: ClientSurface<State>): boolean {
500 const pending = pendingOpens.get(surface)
501 pendingOpens.delete(surface)
502 return pending !== undefined && openOrClose(surface, 'open', pending.key)
503}
504
505function tick(surface: ClientSurface<State>) {
506 const pending = pendingOpens.get(surface)
507 if (pending !== undefined) pending.framesLeft -= 1
508 if (pending !== undefined && pending.framesLeft <= 0 && pending.isReleased && openPending(surface)) return
509 const state = surface.state
510 const ask = asks.get(surface)
511 if (state !== undefined && ask !== undefined && ask.n > state.asked) {
512 // The ask is spent with no change when its stretch cannot be pressed for it.
513 if (!openOrClose(surface, ask.action, ask.key, ask.n)) surface.setState({ ...state, asked: ask.n })
514 return
515 }
516 if (state?.growth == null) return
517 const { growth } = state
518 const progress = growth.progress + ((growth.isOpening ? 1 : -1) * FRAME_MS) / growth.duration
519 if (progress > 0 && progress < 1) {
520 surface.setState({ ...state, growth: { ...growth, progress } })
521 return
522 }
523 // A shrunk stretch, and every stretch inside it, leaves the open list as its growth ends.
524 const open = growth.isOpening ? state.open : closed(state.open, growth.key)
525 surface.setState({ ...state, open, growth: null })
526 if (!growth.isOpening) surface.post({ open } satisfies ReplyPost)
527}
528
529/** Whether `rows` draw a link that does `action` to stretch `key`. */
530function hasLink(rows: Row[], action: Action, key: string): boolean {
531 return rows.some(row => row.some(run => run.link?.action === action && run.link.key === key))
532}
533
534function closed(open: string[], key: string): string[] {
535 return open.filter(k => k !== key && !k.startsWith(`${key}${KEY_SEPARATOR}`))
536}
537
538function styleOf(run: DrawnRun, look: DrawnLook): TextProps {
539 // A phrase a press acts on carries the look: underlined in the text's own colour by default,
540 // leaving colour to code, to the words a click added and to a hyperlink inside the phrase. A
541 // closer is tinted like those words.
542 const isPlainText = !run.tint && !run.code && run.href === undefined
543 // While a letter covers the phrase, the rest of it keeps a highlight drawn on it, so a tinted one
544 // still shows the colour the stretch opens in; any other part is drawn in inverse text on the
545 // permission colour, underlined whatever the look.
546 const isOnPermission = run.isAfterLetter && !drawsHighlight(look, isPlainText)
547 const looked = run.link && !run.isLetter && !isOnPermission ? lookProps(look, isPlainText, run.dim) : undefined
548 return {
549 ...(run.bold && { bold: true }),
550 ...(run.italic && { italic: true }),
551 ...(run.underline && { underline: true }),
552 // A pressable run's own dim, such as a link target's, is drawn by lookProps, which knows the highlight.
553 ...(run.dim && looked === undefined && { dimColor: true }),
554 ...(run.tint && { color: run.tint }),
555 ...(run.code && { color: 'permission' }),
556 ...(run.link && (looked ?? { underline: true })),
557 // A hyperlink is underlined in the suggestion colour, so it is not read as a stretch to press.
558 ...(run.href !== undefined && { underline: true, color: 'suggestion' }),
559 ...(isOnPermission && { color: 'inverseText', backgroundColor: 'permission' }),
560 // A letter is drawn bold in inverse text on the theme's error colour.
561 ...(run.isLetter && { color: 'inverseText', backgroundColor: 'error', bold: true }),
562 // Text the reader dragged across is drawn inverse, as a terminal draws its own selection.
563 ...(run.isSelected && { inverse: true }),
564 }
565}
566
567function easeInOut(k: number): number {
568 return k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2
569}
570hooks/grow.ts 185 lines1import { breakables } from './layout'
2import type { Segment, Stretch } from './parse'
3
4// A long form grows around its short form. A word here is a piece a row may
5// break between (breakables in layout.ts), so Chinese grows a character at a
6// time, with the punctuation that sticks to it. The short form's words, found in
7// the long form in order, are anchors and never move; the words between them
8// arrive from the anchors outward, so the sentence widens at every seam at
9// once, to the left of an anchor as much as to its right. Words arrive whole:
10// a terminal has no room to fade one in, and half a word reads as a typo.
11
12type Token = { kind: 'word' | 'space'; text: string } | { kind: 'nested'; stretch: Stretch }
13
14/** A word or a run of spaces of the long form, or a nested stretch. */
15type Atom = string | Stretch
16
17/** Where a run of new words sits relative to the anchors around it. */
18type Side = 'before' | 'between' | 'after'
19
20/**
21 * The long form at `progress`: 0 shows only the anchors, 1 shows all of it.
22 * `draw` writes a nested stretch, which is never shown in part; `paint` marks each word the long
23 * form adds, so the reader can tell it from the words they already read.
24 */
25export function grow(
26 short: string,
27 long: Segment[],
28 progress: number,
29 draw: (nested: Stretch) => string,
30 paint: (word: string) => string = word => word,
31): string {
32 const tokens = tokenize(long)
33 const { anchors } = anchorsOf(short, tokens)
34 const shown = (atoms: Atom[]) => atoms.map(atom => (typeof atom !== 'string' ? draw(atom) : /\S/.test(atom) ? paint(atom) : atom)).join('')
35
36 let out = ''
37 let run: Atom[] = []
38 let hasAnchorBefore = false
39 const flush = (hasAnchorAfter: boolean) => {
40 if (run.length === 0) return
41 const side: Side = !hasAnchorBefore ? (hasAnchorAfter ? 'before' : 'after') : hasAnchorAfter ? 'between' : 'after'
42 out += shown(reveal(run, side, progress))
43 run = []
44 }
45
46 tokens.forEach((token, i) => {
47 if (anchors.has(i) && token.kind !== 'space') {
48 flush(true)
49 out += token.kind === 'nested' ? draw(token.stretch) : token.text
50 hasAnchorBefore = true
51 return
52 }
53 run.push(token.kind === 'nested' ? token.stretch : token.text)
54 })
55 flush(false)
56
57 return out
58}
59
60/**
61 * Where a long form's added words and nested stretches land around the short form's words: before
62 * the first, between two, or after the last (all of them, when the short form has none in it). And
63 * how many of the short form's words the long form is missing, in order.
64 */
65export function growthOf(short: string, long: Segment[]): Record<Side, number> & { missing: number } {
66 const tokens = tokenize(long)
67 const { anchors, missing } = anchorsOf(short, tokens)
68 const counts = { before: 0, between: 0, after: 0, missing }
69 let run = 0
70 let hasAnchorBefore = false
71 tokens.forEach((token, i) => {
72 if (anchors.has(i) && token.kind !== 'space') {
73 counts[hasAnchorBefore ? 'between' : 'before'] += run
74 run = 0
75 hasAnchorBefore = true
76 } else if (token.kind !== 'space') run += 1
77 })
78 counts.after += run
79
80 return counts
81}
82
83/** How many words and nested stretches a growth adds, to pace it. */
84export function lengthOf(long: Segment[]): number {
85 return tokenize(long).filter(token => token.kind !== 'space').length
86}
87
88function reveal(run: Atom[], side: Side, progress: number): Atom[] {
89 // Two anchors stay a space apart from the start, as they stood in the short form.
90 const isSpace = (atom: Atom | undefined) => typeof atom === 'string' && /^\s+$/.test(atom)
91 if (side === 'between' && run.length > 1 && isSpace(run[0]) && isSpace(run.at(-1))) {
92 const middle = reveal(run.slice(1, -1), side, progress)
93 return middle.length === 0 ? [run[0] ?? ' '] : [run[0] ?? ' ', ...middle, run.at(-1) ?? ' ']
94 }
95 if (side === 'between' && isSpace(run[0])) return [run[0] ?? ' ', ...reveal(run.slice(1), side, progress)]
96 const count = Math.round(run.length * Math.min(1, Math.max(0, progress)))
97 if (side === 'after') return run.slice(0, count)
98 if (side === 'before') return run.slice(run.length - count)
99 const left = run.slice(0, Math.ceil(count / 2))
100 const right = run.slice(run.length - Math.floor(count / 2))
101 if (left.length + right.length === run.length) return [...left, ...right]
102 // The two halves meet where words are still missing: exactly one space between them. The space
103 // is added only where the long form has one beside the gap; a gap between Chinese characters
104 // joins without one, as the finished text reads.
105 const isGapSpaced = isSpace(run[left.length]) || isSpace(run[run.length - right.length - 1])
106 if (isSpace(left.at(-1)) && isSpace(right[0])) return [...left, ...right.slice(1)]
107 if (!isSpace(left.at(-1)) && !isSpace(right[0]) && left.length > 0 && right.length > 0 && isGapSpaced) return [...left, ' ', ...right]
108
109 return [...left, ...right]
110}
111
112function tokenize(long: Segment[]): Token[] {
113 return long.flatMap((part): Token[] =>
114 typeof part === 'string'
115 ? part
116 .split(/(\s+)/)
117 .filter(text => text !== '')
118 .flatMap((text): Token[] => (/^\s+$/.test(text) ? [{ kind: 'space', text }] : breakables(text).map(word => ({ kind: 'word', text: word }))))
119 : [{ kind: 'nested', stretch: part }],
120 )
121}
122
123/**
124 * The token indexes of the long form that carry the short form's words, as many as can be kept in
125 * order (a longest common subsequence), and how many of its words found no place. A nested stretch
126 * shows its own short form, so its words count where it stands.
127 */
128function anchorsOf(short: string, tokens: Token[]): { anchors: Set<number>; missing: number } {
129 const wordsIn = (text: string) => text.split(/\s+/).flatMap(breakables)
130 const want = wordsIn(short).map(bare).filter(word => word !== '')
131 const have: { token: number; word: string }[] = []
132 tokens.forEach((token, i) => {
133 const words = token.kind === 'word' ? [token.text] : token.kind === 'nested' ? wordsIn(token.stretch.short) : []
134 for (const word of words.map(bare).filter(w => w !== '')) have.push({ token: i, word })
135 })
136
137 // hooks/guide.ts asks the long form to keep every word of the short form, in order. When it does,
138 // the earliest match of each word is already the longest common subsequence, so the table below,
139 // one cell per pair of words, is skipped: a Chinese long form has a word per character. When it
140 // does not, the table finds the most words that can be kept.
141 const anchors = new Set<number>()
142 let matched = 0
143 for (const { token, word } of have) {
144 if (matched === want.length) break
145 if (word !== want[matched]) continue
146 anchors.add(token)
147 matched++
148 }
149 if (matched < want.length) {
150 anchors.clear()
151 matched = 0
152 // lengths[i][j]: the longest common run of want[i..] and have[j..].
153 const lengths = Array.from({ length: want.length + 1 }, () => new Array<number>(have.length + 1).fill(0))
154 for (let i = want.length - 1; i >= 0; i--) {
155 for (let j = have.length - 1; j >= 0; j--) {
156 const row = lengths[i] ?? [], below = lengths[i + 1] ?? []
157 row[j] = want[i] === have[j]?.word ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0)
158 }
159 }
160 for (let i = 0, j = 0; i < want.length && j < have.length; ) {
161 if (want[i] === have[j]?.word) {
162 anchors.add(have[j]?.token ?? -1)
163 matched++
164 i++
165 j++
166 } else if ((lengths[i + 1]?.[j] ?? 0) >= (lengths[i]?.[j + 1] ?? 0)) i++
167 else j++
168 }
169 }
170 // Punctuation standing on its own right after an anchor (`{{...}}.`) belongs to that anchor.
171 let last = -1
172 tokens.forEach((token, i) => {
173 if (token.kind === 'space') return
174 if (token.kind === 'word' && bare(token.text) === '' && anchors.has(last)) anchors.add(i)
175 last = i
176 })
177
178 return { anchors, missing: want.length - matched }
179}
180
181/** A word without its surrounding punctuation or markdown marks, lowercased, so `cache.` meets `**cache**`. */
182function bare(word: string): string {
183 return word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').toLowerCase()
184}
185types/index.d.ts 48 lines1declare module 'claude-code' {
2 interface PluginState {
3 'cc-stretch': {
4 /** Per paragraph of a reply drawn by a Client: the keys of the stretches the reader has open. */
5 open: StateFamily<string[]>
6 /**
7 * The stretches the reader opened since their last prompt: each as written and by the name the
8 * note to Claude gives it, which together tell apart two with the same short form after
9 * different words.
10 */
11 seen: Shaped<{ source: string; name: string }[]>
12 /** Per group of tool calls: whether the reader opened it. */
13 groups: StateFamily<boolean>
14 /**
15 * Per reply: the count each open stretch was opened at, keyed `<paragraph>#<stretch key>`, which
16 * picks the newest and, outside the tinted highlight, its colour; and the count the next one
17 * opened takes.
18 */
19 order: StateFamily<{ order: Record<string, number>; count: number }>
20 /** The request id of the reply in which a stretch was opened last, until the next prompt; empty for none. */
21 newest: string
22 /**
23 * Per paragraph of a reply drawn by a Client: the stretch the reader last asked to open or close
24 * from the keyboard, numbered so each ask acts once; null before any.
25 */
26 ask: StateFamily<{ key: string; action: 'open' | 'close'; n: number } | null>
27 /**
28 * While letters show, until the next edit or other change to the prompt, a submitted prompt or
29 * their chord again: whether they open or close, and each one's stretch, by paragraph member
30 * and stretch key; null while none show.
31 */
32 lettering: { action: 'open' | 'close'; targets: { letter: string; member: string; key: string }[] } | null
33 /** The style the toolbar is trying (marks, highlight: a hex colour, `tinted` or empty), drawn in place of the saved one until saved; null for none. */
34 look: { marks: string[]; highlight: string } | null
35 /** Whether /cc-stretch:style has the toolbar open. */
36 toolbar: boolean
37 /** Which action holds the toolbar's one-at-a-time claim, a save or a close; null while none does. */
38 busy: 'save' | 'close' | null
39 /**
40 * Each streaming message's filter state (open stretches, code fence and code span) as its last
41 * flush left it, by message id, until its final flush, or until another message begins while
42 * none of its flushes is pending; null for a message left as written.
43 */
44 streams: Record<string, { open: { isLong: boolean }[]; fence: string | undefined; span: string | undefined } | null>
45 }
46 }
47}
48