SLOPSHOPPER

cc-stretch

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

bandrowstoastpromptprocess
★ 421v0.5.4MITupdated 2026-10-24pro-vi/cc-stretch
A shopper browsing a rack in a slop shop
README

cc-stretch

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

A reply in Claude Code growing in place as phrases are clicked

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.

Install

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.

What to expect

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/.

A silly plugin about a serious idea

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.

Three words

  • skim: a reply with every stretch closed. What you read by default.
  • full: the same reply with every stretch open. Everything Claude wrote.
  • read: what you actually read, the skim plus the stretches you opened.

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.

What else it does

  • Stretches nest. A long form can hold more stretches.
  • Words to click before a lone period. When Claude makes a sentence's period the link to more, or a spot with no words at all, the sentence's words before it open it too, marked like the link, so there is a phrase to click rather than one cell. This is in the terminal's fullscreen layout; in the Claude desktop app only the short form is the link.
  • Streaming stays clean. While a reply streams, stretches already show as their short forms.
  • Letters for the keyboard. In the terminal's fullscreen layout, alt+↓ or ctrl+↓ puts a letter over each closed stretch in the latest three replies on screen that hold one (up to 26), and typing that letter opens it. alt+↑ or ctrl+↑ puts letters over each «, to close. Typing anything else puts the letters away and is typed as usual. Recalling a prompt (from Claude Code 2.1.300), sending one, or pressing the same chord again puts them away too. As of Claude Code 2.1.301 a plugin has no chords of its own, and these are the ones that scroll the diff panel's file list, so while that panel is open with more than eight files they scroll it instead. As of macOS 26, ctrl+↑ opens Mission Control and ctrl+↓ shows the app's windows by default. If your terminal or system takes both chords of one action, bind another to app:diffFileListDown or app:diffFileListUp in the Global context of ~/.claude/keybindings.json.
  • The marking is yours to pick. Phrases you can open are underlined by default. In the terminal's fullscreen layout, run /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.

Limits

  • Clicks work in the terminal's fullscreen layout and in the Claude desktop app. Everywhere else every stretch shows open, so nothing is hidden: in other apps, and on the terminal's main screen, where a printed reply can't change. There the first such reply says why. As of Claude Code 2.1.300, some setups start on the main screen even with fullscreen set: 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.
  • In the terminal the plugin draws each paragraph that holds a stretch itself, and as of Claude Code 2.1.302 its transcript selects nothing in it. A drag there is the plugin's: the text it crosses is drawn inverse and copied when the button comes up, and it stays within that paragraph. A phrase you can open acts when the button comes up too, so a drag that starts on one selects. A double-click there selects and copies the word under it, as Claude Code 2.1.303 does in a paragraph without a stretch, except that the # 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.
  • Claude writes every level up front, so a reply costs about as many tokens as before. You read less; you don't pay less.
  • The {{short|long}} markup shows as raw text wherever the plugin isn't loaded.
  • Built on Claude Code's function-hook plugin API, which is early access and changes between releases. Checked in the terminal on macOS with Claude Code 2.1.300 to 2.1.302 and 2.1.306, and with 2.1.292 to 2.1.294 in cc-stretch 0.1.0; in the Claude desktop app with 2.1.295 in cc-stretch 0.2.0. Claude Code before 2.1.300, which the desktop app can still bundle, refused cc-stretch 0.4.0 to 0.5.2 whole (#17); 0.5.3 loads in 2.1.293 and 2.1.295 again, but its clicks in the desktop app there are unchecked. On Linux, a reader reports clicks working with 2.1.302 in a terminal pane of the Orca app (#14). On Windows 11, a reader reports clicks working with 2.1.304 in PowerShell (#1).
  • A terminal session that also has the desktop app attached shows the markup while a reply streams, because the desktop builds its links from it.

Development

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.

Source 9 files
hooks/register.tsx 804 lines
1import { 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}
804
hooks/guide.ts 17 lines
1// 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.`
17
hooks/parse.ts 347 lines
1// 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}
347
hooks/layout.ts 740 lines
1// 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}
740
hooks/contrast.ts 61 lines
1// 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}
61
hooks/look.ts 101 lines
1// 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}
101
hooks/reply.tsx 570 lines
1import 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}
570
hooks/grow.ts 185 lines
1import { 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}
185
types/index.d.ts 48 lines
1declare 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