Replies short by default; click a phrase to expand it in place, and finished tool groups fold to one line

A Claude Code plugin. Replies come back short, and the detail is one click away.

/plugin install stretch --marketplace iamJeb/claude
/tui fullscreen
Needs Claude Code 2.1.275 or later and a fullscreen terminal or the desktop app. Details under Install.
/stretch off and /stretch on, /stretch open-all and /stretch close-all, /stretch words N./config settings: the short reply length and whether tool groups fold.Claude writes each reply so it reads complete at its shortest. Where there is more to say, it folds the extra behind a phrase. That phrase is a stretch. Click it and the detail opens in place. Click « and it folds back.
Your next message tells Claude which stretches you opened. If you skipped one that matters to your next question, Claude says it again.
In the fullscreen terminal, reply text with two or more stretches between one tool call and the next ends in a line with open all, close all, or both, and a count such as 2 of 5 open. A stretch whose detail holds a further stretch ends in », so you can see that a click goes deeper. Stretches you open all at once count as read.
Finished groups of tool calls fold to one line, with show and show all beside it to unfold that group or every finished group; fold and fold all put them back. Anything still running, or anything that failed, stays open.
A stretch Claude uses as a label, alone on its own line and not written as a sentence, such as a heading word, a question or a phrase ending in a colon, is drawn open from the start.
Wherever clicking is not possible, every stretch is drawn open and you see the whole reply.
Nothing that leaves the chat carries the markup. Claude is told to keep it out of files, commits, pull requests, issues, work item comments, emails, chat messages, prompts for other agents or sessions, commands, API calls and anything printed for you to paste. The plugin also refuses any tool call whose input holds a stretch, and Claude sends the call again written plain. A copy from the screen takes only what is drawn, so a block meant for pasting is written plain inside a code fence.
It saves reading time, not tokens. Claude still writes every level of detail, so a reply costs about what it did before.
Clicks work in Claude Code's fullscreen terminal layout and in the desktop app. In VS Code, the phone app, or a terminal that is not fullscreen, every stretch shows open.
The instructions that teach Claude the markup arrive with your first message, and again after a compaction or /clear. That way they load even where an organization manages Claude Code settings. Headless runs (claude -p), background task notifications and scheduled prompts never get them, so those replies stay plain.
While a reply is still streaming, a stretch shows as raw {{short|long}} text until its closing braces arrive. The same raw text shows anywhere the plugin is not loaded, such as a transcript pasted elsewhere.
The plugin hook API is early access and changes between Claude Code releases, so an update can break stretch until I catch up.
/stretch off turns the markup off, and your next reply is plain. /stretch on turns it back on. The choice is remembered across sessions. Off also lifts the tool call guard, which is how a file that has to contain the markup, such as this plugin's own tests, gets written.
In the fullscreen terminal, /stretch open-all opens every stretch in the latest reply, and /stretch close-all folds them all back. Stretches opened this way count as read.
/stretch words 80 makes 80 the most words a reply should take with every stretch closed, from 10 to 2000, and keeps it across sessions ahead of the /config setting. /stretch words shows the count in force and where it comes from, and /stretch words default goes back to the setting. The change reaches Claude with your next message.
/stretch on its own shows whether stretch is on, the word count, and how to use the command.
Two settings show up in /config. Short reply length (shortWords, 150 by default) is the most words a reply should take with every stretch closed; a value below 10 or above 2000 falls back to 150, and a count set with /stretch words takes precedence. Fold finished tool groups (foldTools, on by default) can be turned off if you would rather see every tool call as Claude Code draws it.
Needs Claude Code 2.1.275 or later. In a session:
/plugin install stretch --marketplace iamJeb/claude
/tui fullscreen
The second line switches the terminal to the fullscreen layout. If a session still starts on the main screen (inside tmux, for example), start Claude Code with CLAUDE_CODE_NO_FLICKER=1.
From a shell instead:
claude plugin marketplace add iamJeb/claude
claude plugin install stretch@iamjeb-claude
Run those in a terminal, not at the Claude Code prompt. Typing !/plugin ... there fails, because ! sends the line to the shell.
Refresh the marketplace, update the plugin, then restart Claude Code:
claude plugin marketplace update iamjeb-claude
claude plugin update stretch@iamjeb-claude
/plugin uninstall stretch
0.3.1 on 2026-10-19. The open all, close all, show all and fold all rows are drawn by the plugin itself, so the address behind each click no longer shows beside its words. A drag that starts on one of them no longer counts as a click.
0.3.0 on 2026-10-19. A reply with several stretches now ends in open all and close all links with a count of how many are open, and a stretch that holds another ends in ». Tool groups gained show all and fold all beside the per-group toggle, and /stretch words N sets the short reply length from the prompt.
0.2.2 on 2026-10-17. The markup is kept inside the chat: the guide now lists every place it must not go, from files and commits to messages for other agents and anything printed for pasting, and the plugin refuses a tool call whose input carries a stretch so Claude resends it plain.
0.2.1 on 2026-10-15. Long forms now carry real depth, and the guide shows the sentence-ending pattern that gives them room.
0.2.0 on 2026-10-15. The guide is sent with the first prompt so it loads on org-managed installs. Added the /stretch command, two /config settings, and label-style stretches drawn open.
0.1.0 on 2026-10-15. First release.
Problems and ideas go to GitHub issues. MIT licensed, see LICENSE.
hooks/register.tsx 537 lines1// Stretch replies for Claude Code. Adapted from cc-stretch (https://github.com/pro-vi/cc-stretch),
2// MIT License, Copyright (c) 2026 pro-vi; see ../LICENSE-cc-stretch.
3//
4// There is deliberately no classic.MessageDisplay hook. On 2.1.300 a display filter that hides
5// long forms while a reply streams also strips the markup from the text AssistantMessage later
6// receives, so no stretch could become clickable (cc-stretch issue #3). While a reply streams, a
7// stretch whose closing braces have not arrived shows as literal text until they do: the engine
8// draws the streaming text itself, and AssistantMessage first sees a block once it has landed.
9//
10// The guide rides on the prompt as context rather than in the system prompt: where an organization
11// manages Claude Code, prompt.compose passes over the plugins a person installs.
12
13import { atom, memberOf, read, update } from 'claude-code'
14import type { EngineInterface, Register, RenderInput, SessionMessage } from 'claude-code'
15
16import { DEFAULT_SHORT_WORDS, guide, lastSentIn, MAX_SHORT_WORDS, MIN_SHORT_WORDS, OFF_NOTE, settingsOf, wordsOf } from './guide'
17import type { Sent, Settings } from './guide'
18import { stripMarkers } from './layout'
19import type { Link, LinksPost, LinksProps } from './links'
20import { closeAfter, codeSpanEnd, hasNested, isLinkable, isOpenable, KEY_SEPARATOR, markupIn, NESTED_MARK, normalize, paragraphsOf, parentOf, parse, stretches, writeOpen } from './parse'
21import type { Segment, Stretch } from './parse'
22import type { Bulk, ReplyPost, ReplyProps, Tint } from './reply'
23
24const openStretches = atom({ plugin: 'stretch', key: 'open' } as const, [])
25const seen = atom({ plugin: 'stretch', key: 'seen' } as const, [])
26// A group with no toggle of its own stands at -1, before any groupsAll id, so it follows groupsAll.
27const openGroups = atom({ plugin: 'stretch', key: 'groups' } as const, { isOpen: false, at: -1 })
28const groupsAll = atom({ plugin: 'stretch', key: 'groupsAll' } as const, { id: 0, isOpen: false })
29const stretchOrder = atom({ plugin: 'stretch', key: 'order' } as const, { order: {}, count: 0 })
30const newestReply = atom({ plugin: 'stretch', key: 'newest' } as const, '')
31const drawnReplies = atom({ plugin: 'stretch', key: 'drawn' } as const, [])
32const latestReplies = atom({ plugin: 'stretch', key: 'latest' } as const, [])
33const bulkAction = atom({ plugin: 'stretch', key: 'bulk' } as const, { id: 0, action: 'open', requestIds: [] })
34
35// The $.store key holding whether the reader turned stretch off; absent means on.
36const ENABLED = 'enabled'
37// The $.store key holding the word count /stretch words set; absent means the /config setting or its default.
38const SHORT_WORDS = 'shortWords'
39
40export const USAGE =
41 'Usage: /stretch on | off | open-all | close-all | words N. on and off decide whether replies use the markup, and last across sessions; ' +
42 'open-all and close-all open or close every stretch in the latest reply; words N sets how many words a short reply may take ' +
43 `(${MIN_SHORT_WORDS} to ${MAX_SHORT_WORDS}), words alone shows the count, and words default forgets the one set here.`
44
45export const FULLSCREEN_NOTE =
46 'stretch: these replies are drawn fully open because clicks need the fullscreen renderer. ' +
47 'Run /tui fullscreen, or start Claude Code with CLAUDE_CODE_NO_FLICKER=1 if it already says fullscreen is on.'
48
49// Words a stretch adds take one of four theme colours, mixed toward the text colour; all but the
50// newest open stretch fade toward the background.
51const BASE = {
52 dark: [[255, 193, 7], [175, 135, 255], [72, 150, 140], [215, 119, 87]],
53 light: [[150, 108, 30], [135, 0, 255], [0, 102, 102], [215, 119, 87]],
54}
55const mix = (rgb: number[], toward: number, share: number) => rgb.map(v => Math.round(v + (toward - v) * share))
56const hex = (rgb: number[]) => `#${rgb.map(v => v.toString(16).padStart(2, '0')).join('')}`
57const paletteOf = (base: number[][], text: number, background: number) =>
58 base.map(rgb => mix(rgb, text, 0.45)).map(soft => ({ soft: hex(soft), faded: hex(mix(soft, background, 0.35)) }))
59const PALETTES = { dark: paletteOf(BASE.dark, 255, 0), light: paletteOf(BASE.light, 0, 255) }
60const HUES = BASE.dark.length
61
62// Links the plugin draws are answered by the plugin and never followed; a reserved name resolves nowhere.
63export const HREF = 'https://stretch.invalid/'
64// The links under a reply that holds two or more stretches, and on a folded or unfolded tool group.
65const OPEN_ALL = `${HREF}open-all`
66const CLOSE_ALL = `${HREF}close-all`
67const GROUP = `${HREF}group`
68const GROUPS_OPEN = `${HREF}groups-open`
69const GROUPS_CLOSE = `${HREF}groups-close`
70
71export const register: Register = (on, options) => {
72 const settings: Settings = settingsOf(options)
73
74 on('session.start', async ($, e, next) => {
75 await $.command.register({
76 name: 'stretch',
77 description: 'Turn stretch replies on or off, open or close every stretch in the latest reply, or set the short reply length',
78 argumentHint: '[on|off|open-all|close-all|words N]',
79 })
80 return next(e)
81 })
82
83 // A /config value at the default reads as the default.
84 const isConfigured = wordsOf(options?.shortWords) !== undefined && settings.shortWords !== DEFAULT_SHORT_WORDS
85 on('command.run', { command: 'stretch' }, async ($, e) => ({ text: await runCommand($, e.args.trim().toLowerCase(), settings, isConfigured) }))
86
87 on('prompt.submit', async ($, e, next) => {
88 // The /stretch command leaves the latest reply as it was, so open-all and close-all can act on it.
89 if (e.command?.name === 'stretch') return next(e)
90 // Every other prompt ends the reply the opens belonged to, whatever sent it.
91 const [entries, latest] = await Promise.all([read($, seen), read($, latestReplies)])
92 await update($, latestReplies, () => [])
93 await update($, seen, () => [])
94 await update($, newestReply, () => '')
95
96 // Only what a person sends gets the guide: not a headless run, a notification, a peer or a schedule,
97 // whose replies are read where no stretch is drawn.
98 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
99
100 const isOn = await isEnabled($)
101 const note = await guideNote($, isOn, await shortWordsOf($, settings))
102 const context = [...(e.context ?? []), ...(note === undefined ? [] : [note])]
103 if (e.origin.kind !== 'composer' || !isOn) return note === undefined ? next(e) : next({ ...e, context })
104
105 const written = stretches(parse(stripMarkers(lastReply(await $.session.messages()))))
106 .filter(isLinkable)
107 .map(stretch => stretch.short.trim())
108 // Only opens in the latest reply count, and only for stretches that reply wrote.
109 const opened = entries.filter(entry => latest.includes(entry.requestId) && written.includes(entry.short)).map(entry => entry.short)
110 const line = contextLine(opened, written)
111 if (line === undefined) return note === undefined ? next(e) : next({ ...e, context })
112
113 return next({ ...e, context: [...context, line] })
114 }).catch(($, e, next) => next(e))
115
116 // No stretch leaves the chat. A tool call whose input carries the markup is refused with a line
117 // the model can act on, since a file, a commit, a message to a person or another agent, or a
118 // request to an API would show the raw braces or, copied from the screen, lose the folded text.
119 // Turning stretch off lifts the guard, which is how a file that must hold the markup gets written.
120 on('tool.call', async ($, e, next) => {
121 if (!(await isEnabled($))) return next(e)
122 const where = markupIn(e)
123 if (where === undefined) return next(e)
124 return { deny: `${$.plugin.name}: ${e.tool} input "${where}" carries {{short|long}} markup, which belongs only in chat replies. Send the same call again written plain, with the long forms in place.` }
125 }).catch(($, e, next) => next(e))
126
127 // A finished tool group folds to the engine's one-line summary with a link to unfold it, beside
128 // one that unfolds or folds every finished group at once. A live group, or one holding an error,
129 // is drawn as the engine draws it.
130 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
131 if (!settings.foldTools) return next(e)
132 const isFinished = !e.props.isActive && e.props.calls.every(call => !call.isRunning && !call.isErrored && !call.isInterrupted)
133 if (!canPress(e) || !isFinished) return next(e)
134
135 const own = memberOf(openGroups, e)
136 const [mine, all] = await Promise.all([read($, own), read($, groupsAll)])
137 const isOpen = groupIsOpen(mine, all)
138 const { Box, Client } = $.ui.resolve(e)
139 // The row is a Client of the plugin's own, so no address shows; a press posts the address to
140 // the ui.message hook below, which finds the group by the same request id this draw has.
141 const toggle = <Client key="tools" module="./links.tsx" props={{ links: groupLinks(isOpen) } satisfies LinksProps} />
142 const drawn = await next({ ...e, props: { ...e.props, isExpanded: isOpen } })
143
144 return isOpen ? (
145 <Box flexDirection="column">
146 {drawn}
147 <Box paddingLeft={2}>{toggle}</Box>
148 </Box>
149 ) : (
150 <Box flexDirection="row" alignItems="flex-end" gap={1}>
151 {drawn}
152 {toggle}
153 </Box>
154 )
155 })
156
157 // The first reply drawn open in a terminal that cannot take clicks carries the note on its first
158 // block, once per session. A render hook cannot write plugin state, so the request id is kept here.
159 let notedRequest: string | undefined
160 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
161 if (e.props.isSummary === true) return next(e)
162 const text = stripMarkers(normalize(e.props.text))
163 const parts = parse(text)
164 const all = stretches(parts)
165 if (all.length === 0) {
166 // Markup that resolved to plain text (an empty short or long form) is drawn resolved.
167 const plain = writeOpen(parts)
168 return plain === text ? next(e) : next({ ...e, props: { ...e.props, text: plain } })
169 }
170 // Desktop: one Markdown block whose closed stretches are links that open in place.
171 if (e.surface === 'desktop') {
172 const { Markdown } = $.ui.resolve(e)
173 const open = memberOf(openStretches, { requestId: `${e.requestId}#desktop` })
174 const links: string[] = []
175 const text = linkedMarkdown(parts, await read($, open), all, links)
176 const press = async (href: string) => {
177 const [action, index] = href.slice(HREF.length).split('/')
178 const stretch = all[Number(index)]
179 if (stretch === undefined) return
180 const { key } = stretch
181 if (action === 'close') {
182 await update($, open, list => list.filter(k => k !== key && !k.startsWith(key + KEY_SEPARATOR)))
183 return
184 }
185 if (action !== 'open') return
186 await update($, open, list => (list.includes(key) ? list : [...list, key]))
187 const short = stretch.short.trim()
188 await noteDrawn($, e.requestId)
189 await update($, seen, list => record(list, e.requestId, short))
190 }
191 return <Markdown key="stretch" text={text} pressableLinks={links} onLinkPress={link => void press(link.href)} />
192 }
193
194 const marker = e.props.isFirstOfReply ? '⏺ ' : ' '
195 if (canPress(e)) {
196 // Fullscreen terminal: a paragraph holding a stretch is drawn by a Client that takes the
197 // clicks; the rest is the engine's own markdown.
198 const { Box, Client, Markdown, Text } = $.ui.resolve(e)
199 const [{ order, count }, newest, bulk] = await Promise.all([
200 read($, memberOf(stretchOrder, { requestId: e.requestId })),
201 read($, newestReply),
202 read($, bulkAction),
203 ])
204 // An open-all or close-all reaches the Clients of the reply it was aimed at.
205 const aimed: Bulk | undefined = bulk.requestIds.includes(e.requestId) ? { id: bulk.id, action: bulk.action } : undefined
206 const isLight =
207 Object.keys(order).length > 0 &&
208 (await $.config.list().catch(() => [])).some(row => row.key === 'theme' && String(row.value).startsWith('light'))
209 const palette = isLight ? PALETTES.light : PALETTES.dark
210 const counted = await Promise.all(
211 paragraphsOf(text).map(async (paragraph, i) => {
212 // A paragraph whose only stretches are labels is drawn with them open.
213 if (!paragraph.hasStretch) return { node: <Markdown text={paragraph.text.includes('{{') ? writeOpen(parse(paragraph.text)) : paragraph.text} />, opened: 0 }
214 const key = `paragraph-${i}`
215 const open = await read($, memberOf(openStretches, { requestId: `${e.requestId}#${key}` }))
216 const tints = tintsOf(order, key, newest === e.requestId ? count - 1 : -1, palette)
217 const props: ReplyProps = { text: paragraph.text, open, tints, ...(aimed !== undefined && { bulk: aimed }) }
218 // The open list holds only keys of linkable stretches, nested ones among them.
219 const opened = stretches(parse(paragraph.text)).filter(stretch => isLinkable(stretch) && open.includes(stretch.key)).length
220 return { node: <Client key={key} module="./reply.tsx" props={props} width="100%" />, opened }
221 }),
222 )
223 const drawn = counted.map(({ node }) => node)
224 // A block holding two or more stretches ends in a line that opens or closes them all and counts the open ones.
225 const total = all.filter(isLinkable).length
226 const opened = counted.reduce((sum, { opened }) => sum + opened, 0)
227 // A press on it posts the address to the ui.message hook below, which aims the bulk at this reply.
228 const footer = total >= 2 && (
229 <Client key="all" module="./links.tsx" props={{ links: footerLinks(opened, total), note: `${opened === 0 ? 'none' : opened} of ${total} open` } satisfies LinksProps} />
230 )
231 return (
232 <Box flexDirection="row">
233 <Text>{marker}</Text>
234 <Box flexDirection="column" flexShrink={1} gap={1}>
235 {drawn}
236 {footer}
237 </Box>
238 </Box>
239 )
240 }
241
242 // Anywhere a click is not known to land, every stretch is drawn open. In a terminal that is not
243 // fullscreen (or does not say), the first such reply says why.
244 const { Box, Markdown, Text } = $.ui.resolve(e)
245 const needsNote = needsFullscreen(e) && e.props.isFirstOfReply
246 if (needsNote) notedRequest ??= e.requestId
247 return (
248 <Box flexDirection="row">
249 <Text>{marker}</Text>
250 <Box flexDirection="column" flexShrink={1}>
251 <Markdown text={writeOpen(parts)} />
252 {needsNote && notedRequest === e.requestId && <Text dimColor>{FULLSCREEN_NOTE}</Text>}
253 </Box>
254 </Box>
255 )
256 })
257
258 // A paragraph's Client posts its open stretches whenever they change, and the one just opened.
259 on('ui.message', async ($, e, next) => {
260 if (e.module.endsWith('links.tsx')) {
261 const href = linksPostOf(e.data)?.href
262 if (href !== undefined) await linkPressed($, href, e)
263 return next(e)
264 }
265 const post = e.module.endsWith('reply.tsx') ? replyPostOf(e.data) : undefined
266 if (post === undefined) return next(e)
267 if (post.mounted === true) await noteDrawn($, e.requestId)
268 await update($, memberOf(openStretches, { requestId: `${e.requestId}#${e.element}` }), () => post.open)
269 const { opened, openedAll } = post
270 if (opened !== undefined) await update($, seen, list => record(list, e.requestId, opened.short))
271 if (openedAll !== undefined) await update($, seen, list => openedAll.reduce((kept, short) => record(kept, e.requestId, short), list))
272
273 const here = `${e.element}#`
274 await update($, memberOf(stretchOrder, { requestId: e.requestId }), ({ order, count }) => {
275 const kept = Object.fromEntries(Object.entries(order).filter(([at]) => !at.startsWith(here) || post.open.includes(at.slice(here.length))))
276 if (opened === undefined) return { order: kept, count }
277 const parent = kept[`${here}${parentOf(opened.key)}`]
278 const n = parent !== undefined && count % HUES === parent % HUES ? count + 1 : count
279 return { order: { ...kept, [`${here}${opened.key}`]: n }, count: n + 1 }
280 })
281 if (opened !== undefined) await update($, newestReply, () => e.requestId)
282 return next(e)
283 })
284}
285
286/**
287 * The line added to the next prompt: which stretches of the last reply the reader opened and which
288 * they did not. Undefined when the last reply had none and nothing was opened.
289 */
290export function contextLine(openedList: readonly string[], writtenList: readonly string[]): string | undefined {
291 const opened = [...new Set(openedList)]
292 const unopened = [...new Set(writtenList)].filter(short => !opened.includes(short))
293 if (opened.length === 0 && unopened.length === 0) return undefined
294 const quote = (list: readonly string[]) => list.map(s => `"${s}"`).join(', ')
295 if (opened.length > 0) {
296 return `Stretch: in your last reply the reader opened ${quote(opened)}.${unopened.length > 0 ? ` They did not open ${quote(unopened)}.` : ''}`
297 }
298
299 return `Stretch: in your last reply the reader opened none of ${quote(unopened)}. They may have read only the short forms; where clicks cannot land (a terminal not in fullscreen, VS Code, the phone app) every stretch was drawn open.`
300}
301
302/** Whether a tool group is unfolded: its own toggle while that was pressed under the current bulk, else the bulk. */
303function groupIsOpen(own: { isOpen: boolean; at: number } | boolean, all: { id: number; isOpen: boolean }): boolean {
304 // State written by 0.2.2 holds a plain boolean, kept as the reader's own choice.
305 if (typeof own === 'boolean') return own
306 return own.at >= all.id ? own.isOpen : all.isOpen
307}
308
309/**
310 * Answers the address a links row posted. The row under a reply aims an open-all or close-all at
311 * that reply; the row beside a tool group flips the group's own toggle, the member keyed by the
312 * request id the ToolGroup draw had, or bumps the toggle over every group.
313 */
314async function linkPressed($: EngineInterface, href: string, e: Pick<RenderInput, 'requestId' | 'component'>): Promise<void> {
315 // Each row answers only the addresses it draws, so a post from one cannot move the other.
316 if (e.component === 'AssistantMessage' && (href === OPEN_ALL || href === CLOSE_ALL)) {
317 const action: Bulk['action'] = href === OPEN_ALL ? 'open' : 'close'
318 await update($, bulkAction, was => ({ id: was.id + 1, action, requestIds: [e.requestId] }))
319 } else if (e.component !== 'ToolGroup') {
320 return
321 } else if (href === GROUP) {
322 // The group's own toggle flips what the reader sees now, and outlasts the bulk it was pressed under.
323 const own = memberOf(openGroups, e)
324 const [was, now] = await Promise.all([read($, own), read($, groupsAll)])
325 await update($, own, () => ({ isOpen: !groupIsOpen(was, now), at: now.id }))
326 } else if (href === GROUPS_OPEN || href === GROUPS_CLOSE) {
327 await update($, groupsAll, was => ({ id: was.id + 1, isOpen: href === GROUPS_OPEN }))
328 }
329}
330
331/**
332 * The links under a block of a reply: open all while a stretch is closed, close all while one is
333 * open, both when the block is mixed.
334 */
335export function footerLinks(opened: number, total: number): Link[] {
336 return [
337 ...(opened < total ? [{ href: OPEN_ALL, label: '▸ open all' }] : []),
338 ...(opened > 0 ? [{ href: CLOSE_ALL, label: '« close all' }] : []),
339 ]
340}
341
342/** The links beside a tool group: show and show all while it is folded, fold and fold all while it is open. */
343export function groupLinks(isOpen: boolean): Link[] {
344 return isOpen
345 ? [
346 { href: GROUP, label: '▾ fold' },
347 { href: GROUPS_CLOSE, label: '▾ fold all' },
348 ]
349 : [
350 { href: GROUP, label: '▸ show' },
351 { href: GROUPS_OPEN, label: '▸ show all' },
352 ]
353}
354
355function linksPostOf(data: unknown): LinksPost | undefined {
356 if (typeof data !== 'object' || data === null || !('href' in data) || typeof data.href !== 'string') return undefined
357
358 return { href: data.href }
359}
360
361function tintsOf(order: Record<string, number>, paragraph: string, newest: number, palette: { soft: string; faded: string }[]): Record<string, Tint> {
362 const here = `${paragraph}#`
363 return Object.fromEntries(
364 Object.entries(order)
365 .filter(([at]) => at.startsWith(here))
366 .flatMap(([at, n]) => {
367 const colours = palette[n % palette.length]
368 return colours === undefined ? [] : [[at.slice(here.length), { words: n === newest ? colours.soft : colours.faded, closer: colours.soft }]]
369 }),
370 )
371}
372
373/**
374 * A reply as desktop markdown: each closed stretch a link, ending in a » when it holds another, each
375 * open one its long form then a «.
376 */
377function linkedMarkdown(parts: Segment[], open: readonly string[], all: Stretch[], links: string[]): string {
378 return parts
379 .map(part => {
380 if (typeof part === 'string') return part
381 if (!isOpenable(part) || part.long === undefined) return part.short
382 if (!isLinkable(part)) return part.long.length === 0 ? part.short : linkedMarkdown(part.long, open, all, links)
383 const index = all.indexOf(part)
384 if (open.includes(part.key)) {
385 const close = `${HREF}close/${index}`
386 links.push(close)
387 return closeAfter(linkedMarkdown(part.long, open, all, links), `[«](${close})`, '\n\n')
388 }
389 const href = `${HREF}open/${index}`
390 links.push(href)
391 return `[${labelOf(part.short)}${hasNested(part) ? NESTED_MARK : ''}](${href})`
392 })
393 .join('')
394}
395
396/** A short form as link text: links inside it flattened, brackets and backslashes escaped outside code spans. */
397function labelOf(short: string): string {
398 const plain = (text: string) => text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '$1 ($2)').replace(/([[\]\\])/g, '\\$1')
399 let label = ''
400 let from = 0
401 for (let at = short.indexOf('`'); at !== -1; at = short.indexOf('`', from)) {
402 const stop = codeSpanEnd(short, at)
403 label += plain(short.slice(from, at)) + short.slice(at, stop)
404 from = stop
405 }
406
407 return label + plain(short.slice(from))
408}
409
410/** `list` with the open of `short` in reply `requestId` added, once. */
411function record(list: readonly { requestId: string; short: string }[], requestId: string, short: string) {
412 return list.some(entry => entry.requestId === requestId && entry.short === short) ? [...list] : [...list, { requestId, short }]
413}
414
415/**
416 * Records that reply `requestId` was drawn. The first time, it joins the latest reply's ids, which
417 * the next prompt reads and clears. Kept in plugin state, so a reload of the plugin keeps it.
418 */
419async function noteDrawn($: EngineInterface, requestId: string): Promise<void> {
420 if ((await read($, drawnReplies)).includes(requestId)) return
421 await update($, drawnReplies, list => [...list, requestId].slice(-1000))
422 await update($, latestReplies, list => (list.includes(requestId) ? list : [...list, requestId]))
423}
424
425function replyPostOf(data: unknown): ReplyPost | undefined {
426 if (typeof data !== 'object' || data === null || !('open' in data) || !Array.isArray(data.open)) return undefined
427 const open = data.open.filter((key): key is string => typeof key === 'string')
428 const all = 'openedAll' in data && Array.isArray(data.openedAll) ? data.openedAll.filter((short): short is string => typeof short === 'string') : undefined
429 const extra = { ...('mounted' in data && data.mounted === true && { mounted: true as const }), ...(all !== undefined && { openedAll: all }) }
430 const opened: unknown = 'opened' in data ? data.opened : undefined
431 if (typeof opened !== 'object' || opened === null || !('short' in opened) || !('key' in opened)) return { open, ...extra }
432 const { short, key } = opened
433
434 return typeof short === 'string' && typeof key === 'string' ? { open, opened: { short, key }, ...extra } : { open, ...extra }
435}
436
437/** Whether the reader has stretch on: anything but a stored false. */
438async function isEnabled($: EngineInterface): Promise<boolean> {
439 return (await $.store.get(ENABLED).catch(() => undefined)) !== false
440}
441
442/** The word count /stretch words set, when one is stored and within bounds. */
443async function storedWords($: EngineInterface): Promise<number | undefined> {
444 return wordsOf(await $.store.get(SHORT_WORDS).catch(() => undefined))
445}
446
447/** The word count in force: the one /stretch words set, else the /config setting or its default. */
448async function shortWordsOf($: EngineInterface, settings: Settings): Promise<number> {
449 return (await storedWords($)) ?? settings.shortWords
450}
451
452/** Runs `/stretch words <rest>` and answers the line it prints. `isConfigured`: whether /config holds a valid count. */
453async function runWords($: EngineInterface, rest: string, settings: Settings, isConfigured: boolean): Promise<string> {
454 const configured = isConfigured ? 'the shortWords setting in /config' : 'the default'
455 if (rest === '') {
456 const stored = await storedWords($)
457 return `stretch: a short reply is ${stored ?? settings.shortWords} words at most, from ${stored === undefined ? configured : '/stretch words'}.`
458 }
459 if (rest === 'default') {
460 await $.store.delete(SHORT_WORDS)
461 return `stretch: the count set by /stretch words is forgotten. A short reply is ${settings.shortWords} words at most, from ${configured}.`
462 }
463 const words = wordsOf(rest)
464 if (words === undefined) return `stretch: the word count must be a whole number from ${MIN_SHORT_WORDS} to ${MAX_SHORT_WORDS}. Nothing changed.`
465 await $.store.set(SHORT_WORDS, words)
466
467 return `stretch: a short reply is now ${words} words at most. Your next message brings Claude the guide with the new count.`
468}
469
470/** Runs `/stretch <args>` and answers the line it prints. */
471async function runCommand($: EngineInterface, args: string, settings: Settings, isConfigured: boolean): Promise<string> {
472 const [word = '', ...rest] = args.split(/\s+/)
473 if (word === 'words') return runWords($, rest.join(' '), settings, isConfigured)
474 if (word === 'on' || word === 'off') {
475 await $.store.set(ENABLED, word === 'on')
476 return word === 'on'
477 ? 'stretch is on. Your next message brings Claude the guide again, and replies use the markup from then on.'
478 : 'stretch is off. Your next message tells Claude to stop using the markup; stretches already drawn still open.'
479 }
480 if (word === 'open-all' || word === 'close-all') {
481 const latest = await read($, latestReplies)
482 if (latest.length === 0) return 'stretch: no reply with clickable stretches has been drawn since your last message.'
483 const action: Bulk['action'] = word === 'open-all' ? 'open' : 'close'
484 await update($, bulkAction, was => ({ id: was.id + 1, action, requestIds: [...latest] }))
485 return word === 'open-all' ? 'Opened every stretch in the latest reply.' : 'Closed every stretch in the latest reply.'
486 }
487 const state = (await isEnabled($)) ? 'on' : 'off'
488 const unknown = word === '' ? '' : `"${word}" is not a /stretch option. `
489
490 return `${unknown}stretch is ${state}, and a short reply is ${await shortWordsOf($, settings)} words at most. ${USAGE}`
491}
492
493/**
494 * What the next prompt carries for the model about the markup: the guide where the conversation
495 * the next request is built from holds none (a new session, a /clear, a compaction) or holds the
496 * off note or another word count after it; the off note where stretch is off and a guide stands
497 * last. Undefined when nothing needs saying, or when the conversation cannot be read.
498 */
499export async function guideNote($: EngineInterface, isOn: boolean, shortWords: number): Promise<string | undefined> {
500 const messages: unknown = await $.session.messages({ as: 'api' }).catch(() => undefined)
501 if (!Array.isArray(messages)) return undefined
502 let last: Sent | undefined
503 for (const message of messages as unknown[]) {
504 if (typeof message !== 'object' || message === null || !('role' in message) || !('content' in message)) return undefined
505 if (!Array.isArray(message.content)) return undefined
506 if (message.role !== 'user') continue
507 for (const block of message.content as unknown[]) {
508 const text = typeof block === 'object' && block !== null && 'text' in block && typeof block.text === 'string' ? block.text : ''
509 last = lastSentIn(text) ?? last
510 }
511 }
512 if (isOn) return last?.kind === 'guide' && last.words === shortWords ? undefined : guide(shortWords)
513
514 return last?.kind === 'guide' ? OFF_NOTE : undefined
515}
516
517/** The text of the model's last reply: its rows after the person's last prompt. */
518function lastReply(messages: SessionMessage[]): string {
519 const texts: string[] = []
520 for (const message of [...messages].reverse()) {
521 if (message.role === 'user' && (message.toolResults?.length ?? 0) === 0 && message.text !== '') break
522 if (message.role === 'assistant') texts.unshift(message.text)
523 }
524
525 return texts.join('\n')
526}
527
528// A click on a Client, or on the fold link, is known to land only in the fullscreen terminal.
529function canPress<E extends Pick<RenderInput, 'surface' | 'viewport'>>(e: E): e is E & { surface: 'terminal' } {
530 return e.surface === 'terminal' && e.viewport?.isFullscreen === true
531}
532
533// A terminal on its main screen, or one whose viewport is unknown, draws stretches open and says so.
534function needsFullscreen(e: Pick<RenderInput, 'surface' | 'viewport'>): boolean {
535 return e.surface === 'terminal' && e.viewport?.isFullscreen !== true
536}
537hooks/guide.ts 72 lines1// What the plugin sends the model about the markup, and the settings that shape it.
2
3export const GUIDE_TITLE = '# Expandable phrases in chat replies'
4const OFF_TITLE = '# Expandable phrases are off'
5
6// The first line of the guide and of the off note, written by the plugin alone, by which a copy
7// already in the conversation is found. A title quoted in a prompt or in a compaction's summary
8// is not a copy.
9const sentinel = (kind: 'guide' | 'off', words?: number) => `<!-- stretch-${kind} v2${words === undefined ? '' : ` n=${words}`} -->`
10// Found at the start of a line, plain or as the engine labels a hook's context.
11const SENTINEL = /(?:^|\n)(?:prompt\.submit hook additional context: )?<!-- stretch-(guide|off) v2(?: n=(\d+))? -->/g
12
13/** What the plugin last sent about the markup, in `text`: the guide and its word count, or the off note. */
14export type Sent = { kind: 'guide'; words: number } | { kind: 'off' }
15
16/** The last guide or off note the plugin wrote in `text`, found by its sentinel; undefined for none. */
17export function lastSentIn(text: string): Sent | undefined {
18 let last: Sent | undefined
19 for (const match of text.matchAll(SENTINEL)) {
20 last = match[1] === 'off' ? { kind: 'off' } : { kind: 'guide', words: Number(match[2]) }
21 }
22
23 return last
24}
25
26export const DEFAULT_SHORT_WORDS = 150
27export const MIN_SHORT_WORDS = 10
28export const MAX_SHORT_WORDS = 2000
29
30/** The plugin's /config settings, each at its default where missing or invalid. */
31export type Settings = { shortWords: number; foldTools: boolean }
32
33/** `value` as a short reply length: a number, or digits in a string, from 10 to 2000; undefined for anything else. */
34export function wordsOf(value: unknown): number | undefined {
35 const n = typeof value === 'number' ? value : typeof value === 'string' && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN
36
37 return Number.isFinite(n) && n >= MIN_SHORT_WORDS && n <= MAX_SHORT_WORDS ? Math.round(n) : undefined
38}
39
40export function settingsOf(options: Readonly<Record<string, unknown>> | undefined): Settings {
41 const shortWords = wordsOf(options?.shortWords) ?? DEFAULT_SHORT_WORDS
42 const foldTools = typeof options?.foldTools === 'boolean' ? options.foldTools : true
43
44 return { shortWords, foldTools }
45}
46
47/** The guide, sent with the first prompt of a conversation and again after a compaction or a /clear. */
48export function guide(shortWords: number): string {
49 return `${sentinel('guide', shortWords)}
50${GUIDE_TITLE}
51
52You can mark a phrase as \`{{short|long}}\`. On screen the reader sees only "short", underlined. If they click it, "long" replaces it in the sentence, and a close marker after it puts "short" back. Markup can sit inside a long form to go one level deeper.
53
54Write so that the reply, with every phrase unclicked, answers the question by itself in ${shortWords} words or fewer. Keep anything the reader must act on or decide in the unclicked text.
55
56A stretch is where the depth goes, so each one has to hide something worth a click: the reasoning behind a claim, the evidence or numbers that settle it, file paths, the cost or risk, a caveat, an example, or the alternative you ruled out. Ask what the reader would want to know next about that phrase and put the answer in "long". A long form usually runs from one to several sentences. If it would add only a few words, write them into the sentence or leave them out, because a click that reveals an aside wastes the reader's time. Instructions elsewhere to stay terse or to skip unrequested justification govern the unclicked text, not the long forms.
57
58Since "long" takes the place of "short", it has to fit the same grammar. The easiest way to give a stretch room is to let "short" end a sentence and have "long" make the same point and carry on with more sentences, as in \`Most of the run goes to {{the database fixture.|the database fixture, which drops and recreates all 40 tables before each of the 600 tests. Moving it to session scope builds them once per run while each test rolls back its own changes, which should take the suite from twelve minutes to about one.}}\` "long" is always several words, never a single token: text like \`{{name|upper}}\` is left alone as template syntax.
59
60Keep "short" to plain words on a single line, and say the point in it; a label like "Details." or "Why." gives the reader nothing to go on, so never use the markup as a section header. Leave code spans, fenced code, tables and headings free of the markup. Brief replies do not need it at all.
61
62The markup belongs only in replies drawn in this chat, and nothing that leaves the chat carries it. That covers every file you write or edit, including code, docs, journal entries, memory files and scratch notes; every commit message, pull request, GitHub issue or comment, Azure DevOps comment or work item field; every email, Teams or Slack message, and any draft of one; every prompt, brief, handoff or message to another agent or session, whether sent through a tool or printed for the reader to paste; every command, API request body, JSON or other structured output; and the input to any tool at all. Anywhere else the braces show raw, or a copy takes only what was drawn and drops the folded text. Anything the reader will copy out of the chat, such as a command line, a prompt for another session or a message they will send, is written plain inside a fenced block with no stretch in it or around it, and a reply that is itself such a block has no markup. The plugin refuses a tool call whose input carries the markup; answer that refusal by sending the same call written plain, never by retrying it as it was.
63
64Each new message from the reader comes with a note on which phrases they clicked open and which they left closed. Treat text they never opened as unread: when it bears on what they ask next, repeat it.`
65}
66
67/** Sent once after the reader turns stretch off, so the guide earlier in the conversation stops applying. */
68export const OFF_NOTE = `${sentinel('off')}
69${OFF_TITLE}
70
71The reader turned the expandable phrases off. From this message on, write plain replies with no \`{{short|long}}\` markup, and ignore the earlier instructions about it.`
72hooks/layout.ts 478 lines1// Adapted from cc-stretch (https://github.com/pro-vi/cc-stretch), MIT License, Copyright (c) 2026 pro-vi.
2// See ../LICENSE-cc-stretch.
3
4// Lays out a paragraph that holds stretches, for a Client to draw: its markdown as rows of styled
5// runs, wrapped to the region's width, so a press can be matched to the phrase under it. Claude
6// Code draws every paragraph without a stretch with its own markdown.
7
8import { isRule } from './parse'
9
10/** What a press on a pressable phrase does to its stretch. */
11export type Action = 'open' | 'close'
12
13export type Run = {
14 text: string
15 bold?: true
16 italic?: true
17 code?: true
18 underline?: true
19 dim?: true
20 /** The colour of the words a stretch added. */
21 tint?: string
22 link?: { action: Action; key: string }
23}
24
25export type Row = Run[]
26
27// Unicode noncharacters frame a pressable phrase or a tinted word in composed text. Private-use
28// characters are not used, because Nerd Font icons live there and a reply can hold them. A reply's
29// own noncharacters are removed before composing (stripMarkers), every reader of the marks below
30// treats a mark without its partner as plain text, and the finished rows are stripped once more, so
31// a mark never reaches the screen.
32const START = '\ufdd0'
33const SEP = '\ufdd1'
34const END = '\ufdd2'
35const TINT = '\ufdd3'
36const TINT_END = '\ufdd4'
37const DIM = '\ufdd5'
38const DIM_END = '\ufdd6'
39const TAB_STOP = 4
40const TAB = String.fromCharCode(9)
41const MARKDOWN_LINK = /\[([^\]\n]+)\]\(([^)\s]+)\)/y
42// What inline() treats as more than text: the phrase marks, code, emphasis and links.
43const INLINE_SPECIAL = /[\ufdd0\ufdd2`*[]/
44
45/** `text` without the noncharacters the layout uses as marks. */
46export function stripMarkers(text: string): string {
47 return text.replace(/[\ufdd0-\ufdef]/g, '')
48}
49
50/** The tint of each character of a line, and whether it is drawn dim, once the marks are read out. */
51type Marks = { tints: (string | undefined)[]; dims: boolean[] }
52
53/** A line's text without its tint and dim marks, and the marks of each character left. */
54type Line = { text: string } & Marks
55
56/** `word` drawn in `colour`. */
57export function tint(colour: string, word: string): string {
58 return `${TINT}${colour}${SEP}${word}${TINT_END}`
59}
60
61/** `word` drawn dim, inside a link as much as outside one. */
62export function dim(word: string): string {
63 return `${DIM}${word}${DIM_END}`
64}
65
66/** Whether a word is markdown syntax rather than text: a bullet, a list number, a rule, a fence, a pipe, a heading mark. */
67export function isMarkup(word: string): boolean {
68 return !/[\p{L}\p{N}]/u.test(word) || /^\d+[.)]$/.test(word) || /^(`{3,}|~{3,})/.test(word)
69}
70
71/** A pressable phrase for composed text: drawn as `label`, a press does `action` to stretch `key`. */
72export function link(action: Action, key: string, label: string): string {
73 return `${START}${action}${SEP}${key}${SEP}${label}${END}`
74}
75
76/** The rows `text` takes in a region `columns` cells wide. No row is wider than that. */
77export function layout(text: string, columns: number): Row[] {
78 const width = Math.max(10, columns)
79 const rows: Row[] = []
80 let fence: string | undefined
81 for (const marked of text.split('\n')) {
82 const marker = /^[ \t]*(`{3,}|~{3,})/.exec(marked)?.[1]
83 if (fence !== undefined && marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) {
84 fence = undefined
85 continue
86 }
87 if (fence === undefined && marker !== undefined) {
88 fence = marker
89 continue
90 }
91 rows.push(...lineRows(marked, width, fence !== undefined))
92 }
93
94 return rows
95}
96
97// Rows of a line already laid out, by width, fence and text: a frame of a growth changes few
98// lines, so the rest are not laid out again. Cleared when it grows past its bound.
99const lineCache = new Map<string, Row[]>()
100const LINE_CACHE_MAX_CHARS = 2_000_000
101let lineCacheChars = 0
102
103function lineRows(marked: string, width: number, isInFence: boolean): Row[] {
104 const key = `${width}|${isInFence ? 'f' : 't'}|${marked}`
105 const cached = lineCache.get(key)
106 if (cached !== undefined) return cached
107 const line = untint(marked)
108 const rows = isInFence ? cutRows([{ text: ` ${line.text}`, code: true }], width) : rowsOf(line, width)
109 const clean = rows.map(row => row.map(run => ({ ...run, text: stripMarkers(run.text) })).filter(run => run.text !== ''))
110 if (lineCacheChars + key.length > LINE_CACHE_MAX_CHARS) {
111 lineCache.clear()
112 lineCacheChars = 0
113 }
114 lineCache.set(key, clean)
115 lineCacheChars += key.length
116
117 return clean
118}
119
120/**
121 * The phrase drawn at cell `x` of row `y`, if any. A one-cell `«` is hard to land on, so a press
122 * a cell beside a closer counts as on it.
123 */
124export function linkAt(rows: Row[], x: number, y: number): Run['link'] {
125 const spans: { from: number; to: number; link: Run['link'] }[] = []
126 let at = 0
127 for (const run of rows[y] ?? []) {
128 const w = widthOf(run.text)
129 spans.push({ from: at, to: at + w, link: run.link })
130 at += w
131 }
132 const exact = spans.find(span => x >= span.from && x < span.to)?.link
133 if (exact !== undefined) return exact
134
135 return spans.find(span => span.link?.action === 'close' && x >= span.from - 1 && x < span.to + 1)?.link
136}
137
138// Terminal cell widths, measured per grapheme cluster, which is what the reader sees as one character.
139const ZERO_WIDTH = /^[\p{M}\u200b-\u200f\u2060-\u2064\ufe00-\ufe0f\ufeff\u{e0100}-\u{e01ef}\ufdd0-\ufdef]$/u
140const EMOJI_PRESENTATION = /\p{Emoji_Presentation}/u
141const PICTOGRAPHIC = /\p{Extended_Pictographic}/u
142type GraphemeSegmenter = { segment(text: string): Iterable<{ segment: string }> }
143type SegmenterClass = new (locale?: string, options?: { granularity: 'grapheme' }) => GraphemeSegmenter
144const segmenter: GraphemeSegmenter | undefined = (() => {
145 try {
146 const Segmenter = (Intl as unknown as { Segmenter?: SegmenterClass }).Segmenter
147 return Segmenter === undefined ? undefined : new Segmenter(undefined, { granularity: 'grapheme' })
148 } catch {
149 return undefined
150 }
151})()
152
153/** The grapheme clusters of `text`; without Intl.Segmenter, code points with marks, joiners and selectors kept on the one before. */
154export function graphemes(text: string): string[] {
155 if (segmenter !== undefined) return Array.from(segmenter.segment(text), part => part.segment)
156 const out: string[] = []
157 let isJoined = false
158 for (const ch of text) {
159 const last = out.length - 1
160 if (last >= 0 && (isJoined || ZERO_WIDTH.test(ch))) {
161 out[last] += ch
162 isJoined = ch === '\u200d'
163 } else {
164 out.push(ch)
165 isJoined = false
166 }
167 }
168
169 return out
170}
171
172function isWideCodePoint(c: number): boolean {
173 return (
174 (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0x303e) || (c >= 0x3041 && c <= 0x33ff) ||
175 (c >= 0x3400 && c <= 0x4dbf) || (c >= 0x4e00 && c <= 0x9fff) || (c >= 0xa000 && c <= 0xa4cf) ||
176 (c >= 0xa960 && c <= 0xa97f) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) ||
177 (c >= 0xfe10 && c <= 0xfe19) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) ||
178 (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1b000 && c <= 0x1b2ff) || (c >= 0x1f200 && c <= 0x1f2ff) ||
179 (c >= 0x20000 && c <= 0x3fffd)
180 )
181}
182
183/** Cells one grapheme cluster takes: 0 for marks, joiners and selectors alone; 2 for wide East Asian characters and emoji shown as emoji; else 1. */
184export function clusterWidth(cluster: string): number {
185 const points = Array.from(cluster)
186 if (points.length === 0 || points.every(ch => ZERO_WIDTH.test(ch))) return 0
187 if (EMOJI_PRESENTATION.test(cluster)) return 2
188 if ((cluster.includes('\ufe0f') || cluster.includes('\u200d')) && PICTOGRAPHIC.test(cluster)) return 2
189
190 return isWideCodePoint(points[0]?.codePointAt(0) ?? 0) ? 2 : 1
191}
192
193/** Cells a string takes on a terminal, summed over its grapheme clusters. */
194export function widthOf(text: string): number {
195 // Printable ASCII is one cell a character, which is most text.
196 if (/^[\x20-\x7e]*$/.test(text)) return text.length
197 let cells = 0
198 for (const cluster of graphemes(text)) cells += clusterWidth(cluster)
199
200 return cells
201}
202
203/**
204 * A line without its tint and dim marks, so its markdown is read from the text alone: a stretch
205 * tints the words it adds one at a time, so a code span or a link label can hold several. A space
206 * takes the tint before it, so a tinted phrase stays one run. A tab becomes spaces to the next stop
207 * of four.
208 */
209function untint(marked: string): Line {
210 // Most lines carry no tint, no dim mark and no tab: nothing to do.
211 if (!marked.includes(TINT) && !marked.includes(DIM) && !marked.includes(TAB)) return { text: marked, tints: [], dims: [] }
212 let text = ''
213 const tints: Line['tints'] = []
214 const dims: Line['dims'] = []
215 let tinted: string | undefined
216 let isDim = false
217 for (let i = 0; i < marked.length; i++) {
218 const ch = marked[i] ?? ''
219 const sep = ch === TINT ? marked.indexOf(SEP, i + 1) : -1
220 if (sep !== -1) {
221 tinted = marked.slice(i + 1, sep)
222 i = sep
223 } else if (ch === TINT_END) {
224 tinted = undefined
225 } else if (ch === DIM) {
226 isDim = true
227 } else if (ch === DIM_END) {
228 isDim = false
229 } else if (ch === '\t') {
230 const spaces = TAB_STOP - (widthOf(stripMarkers(text)) % TAB_STOP)
231 for (let k = 0; k < spaces; k++) {
232 tints.push(tinted ?? tints.at(-1))
233 dims.push(isDim)
234 }
235 text += ' '.repeat(spaces)
236 } else {
237 tints.push(ch === ' ' && tinted === undefined ? tints.at(-1) : tinted)
238 dims.push(isDim)
239 text += ch
240 }
241 }
242
243 return { text, tints, dims }
244}
245
246/** The marks of a line from character `from` on. */
247function marksFrom({ tints, dims }: Marks, from: number): Marks {
248 return { tints: tints.slice(from), dims: dims.slice(from) }
249}
250
251function rowsOf({ text: line, tints, dims }: Line, width: number): Row[] {
252 // The runs of the line's end from `rest` on.
253 const inlineOf = (rest: string, isBold = false) => inline(rest, marksFrom({ tints, dims }, line.length - rest.length), isBold)
254 if (line.trim() === '') return [[]]
255 if (isRule(line)) return [[{ text: '─'.repeat(Math.min(width, 60)), dim: true }]]
256 // A table row keeps its columns: written as is, cut where the region ends.
257 if (/^\s*\|/.test(line)) return cutRows(inlineOf(line), width)
258 const heading = /^#{1,6}\s+(.*)$/.exec(line)
259 if (heading) return wrap(inlineOf(heading[1] ?? '', true), width, '', '')
260 const quote = /^>\s?(.*)$/.exec(line)
261 if (quote) return wrap(inlineOf(quote[1] ?? ''), width, '▎ ', '▎ ', true)
262 const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line)
263 if (item) {
264 const lead = `${item[1] ?? ''}${item[2] ?? ''} `
265 return wrap(inlineOf(item[3] ?? ''), width, lead, ' '.repeat(widthOf(lead)))
266 }
267 const indent = /^\s*/.exec(line)?.[0] ?? ''
268
269 return wrap(inlineOf(line.slice(indent.length)), width, indent, indent)
270}
271
272/**
273 * A line's inline markdown as runs: bold, italic, code spans, links, pressable phrases, each
274 * character in its tint and dimmed where marked. A markdown link's label is read the same way, so a
275 * stretch inside it stays pressable and its marks never show.
276 */
277function inline(text: string, marks: Marks, isBold = false, outer: Run['link'] = undefined, extraAll: Omit<Run, 'text'> = {}): Run[] {
278 const { tints, dims } = marks
279 const runs: Run[] = []
280 let bold = isBold
281 let italic = false
282 let pressable: Run['link'] = outer
283 // `chunk` starts at character `at`, and takes its tint and dimness.
284 const emit = (chunk: string, at: number, extra: Omit<Run, 'text'> = {}) => {
285 if (chunk === '') return
286 const tint = tints[at]
287 const run: Run = {
288 text: chunk,
289 ...(bold && { bold: true }),
290 ...(italic && { italic: true }),
291 ...(tint && { tint }),
292 ...(dims[at] === true && { dim: true }),
293 ...(pressable && { link: pressable }),
294 ...extraAll,
295 ...extra,
296 }
297 push(run)
298 }
299 const push = (run: Run) => {
300 const last = runs.at(-1)
301 if (last !== undefined && isSameStyle(last, run)) last.text += run.text
302 else runs.push(run)
303 }
304
305 for (let i = 0; i < text.length; ) {
306 const ch = text[i] ?? ''
307 const first = ch === START ? text.indexOf(SEP, i + 1) : -1
308 const second = first === -1 ? -1 : text.indexOf(SEP, first + 1)
309 if (second !== -1) {
310 pressable = { action: text.slice(i + 1, first) === 'close' ? 'close' : 'open', key: text.slice(first + 1, second) }
311 i = second + 1
312 continue
313 }
314 if (ch === END) {
315 pressable = outer
316 i += 1
317 continue
318 }
319 if (ch === '`') {
320 let stop = i
321 while (text[stop] === '`') stop += 1
322 const fence = text.slice(i, stop)
323 const end = text.indexOf(fence, i + fence.length)
324 if (end !== -1) {
325 emit(stripMarkers(text.slice(i + fence.length, end)), i + fence.length, { code: true })
326 i = end + fence.length
327 continue
328 }
329 }
330 if (text.startsWith('**', i)) {
331 bold = !bold
332 i += 2
333 continue
334 }
335 if (ch === '*' && /\S/.test((italic ? text[i - 1] : text[i + 1]) ?? ' ')) {
336 italic = !italic
337 i += 1
338 continue
339 }
340 MARKDOWN_LINK.lastIndex = i
341 const markdownLink = ch === '[' ? MARKDOWN_LINK.exec(text) : null
342 if (markdownLink) {
343 const label = markdownLink[1] ?? ''
344 const labelRuns = inline(label, marksFrom(marks, i + 1), bold, pressable, { ...extraAll, underline: true, ...(italic && { italic: true }) })
345 labelRuns.forEach(push)
346 emit(` (${stripMarkers(markdownLink[2] ?? '')})`, i + markdownLink[0].length - 1, { dim: true })
347 i += markdownLink[0].length
348 continue
349 }
350 // Plain text up to the next character that means something here, or the next change of tint
351 // or dimness, in one piece. A surrogate pair is never special, so a cluster is not split
352 // between runs.
353 let stop = i + 1
354 while (stop < text.length && !INLINE_SPECIAL.test(text[stop] ?? '') && tints[stop] === tints[i] && dims[stop] === dims[i]) stop += 1
355 emit(text.slice(i, stop), i)
356 i = stop
357 }
358
359 return runs
360}
361
362/** Word-wraps runs into rows of `width` cells; the first row starts with `first`, later rows with `rest`. */
363function wrap(runs: Run[], width: number, first: string, rest: string, isDimLead = false): Row[] {
364 const rows: Row[] = []
365 let row: Row = []
366 let used = 0
367 let isBare = true
368 const begin = (lead: string) => {
369 row = lead === '' ? [] : [{ text: lead, ...(isDimLead && { dim: true }) }]
370 used = widthOf(lead)
371 isBare = true
372 }
373 const put = (piece: Run) => {
374 const last = row.at(-1)
375 if (last !== undefined && isSameStyle(last, piece)) last.text += piece.text
376 else row.push({ ...piece })
377 used += widthOf(piece.text)
378 isBare = false
379 }
380 const breakRow = () => {
381 const last = row.at(-1)
382 if (last !== undefined && !isBare) {
383 last.text = last.text.trimEnd()
384 if (last.text === '') row.pop()
385 }
386 rows.push(row)
387 begin(rest)
388 }
389
390 begin(first)
391 const parts = runs.flatMap(run => run.text.split(/( +)/).filter(text => text !== '').map(text => ({ run, text })))
392 const isWord = (at: number) => (parts[at]?.text.trim() ?? '') !== ''
393 parts.forEach(({ run, text }, at) => {
394 if (!isWord(at)) {
395 if (isBare) return
396 if (used + widthOf(text) <= width) put({ ...run, text })
397 else breakRow()
398 return
399 }
400 // Pieces that touch with no space between, such as a link and the » or full stop after it,
401 // read as one word, so a row that cannot take them all breaks before the first.
402 if (!isBare && !isWord(at - 1)) {
403 let whole = 0
404 for (let k = at; isWord(k); k++) whole += widthOf(parts[k]?.text ?? '')
405 if (used + whole > width && whole <= width - widthOf(rest)) breakRow()
406 }
407 let word = text
408 while (word !== '') {
409 if (used + widthOf(word) <= width) {
410 put({ ...run, text: word })
411 break
412 }
413 if (!isBare) {
414 breakRow()
415 continue
416 }
417 // A word wider than a whole row is cut where the row ends; a row always takes one cluster.
418 const [head, tail] = cut(word, width - used, true)
419 put({ ...run, text: head })
420 breakRow()
421 word = tail
422 }
423 })
424 rows.push(row)
425
426 return rows
427}
428
429/** Rows of text kept as written, cut every `width` cells. */
430function cutRows(runs: Run[], width: number): Row[] {
431 const rows: Row[] = []
432 let row: Row = []
433 let used = 0
434 for (const run of runs) {
435 let rest = run.text
436 while (rest !== '') {
437 const [head, tail] = cut(rest, width - used, used === 0)
438 if (head === '') {
439 rows.push(row)
440 row = []
441 used = 0
442 continue
443 }
444 row.push({ ...run, text: head })
445 used += widthOf(head)
446 rest = tail
447 }
448 }
449 rows.push(row)
450
451 return rows
452}
453
454/**
455 * `text` split by grapheme cluster where it would pass `room` cells. With `force`, the head keeps at
456 * least one cluster, so a caller that starts a fresh row always moves forward.
457 */
458function cut(text: string, room: number, force: boolean): [string, string] {
459 const clusters = graphemes(text)
460 let used = 0
461 let count = 0
462 for (const cluster of clusters) {
463 const w = clusterWidth(cluster)
464 if (used + w > room && !(force && count === 0)) break
465 used += w
466 count += 1
467 }
468
469 return [clusters.slice(0, count).join(''), clusters.slice(count).join('')]
470}
471
472function isSameStyle(a: Run, b: Run): boolean {
473 return (
474 a.bold === b.bold && a.italic === b.italic && a.code === b.code && a.underline === b.underline && a.dim === b.dim && a.tint === b.tint &&
475 a.link?.action === b.link?.action && a.link?.key === b.link?.key
476 )
477}
478hooks/links.tsx 75 lines1import type { ClientModule, ClientSurface } from 'claude-code'
2
3import { widthOf } from './layout'
4
5// Draws one row of pressable links for the hooks module: the open all and close all under a reply,
6// and the show and fold toggles beside a folded tool group. The engine's own Markdown shows a link
7// with its address, and the plugin's addresses are internal, so the row is drawn here instead:
8// each label underlined, as a pressable phrase in a paragraph is, and a press posts the address
9// of the label under it for the hooks module to answer.
10
11/** One link of the row: the address the hooks module answers, and the words drawn for it. */
12export type Link = { href: string; label: string }
13
14/** The links of the row, and a note drawn dim after them, such as a count. */
15export type LinksProps = { links: Link[]; note?: string }
16
17/** What a press posts to the hooks module: the address of the label pressed. */
18export type LinksPost = { href: string }
19
20const BETWEEN = ' · '
21const BEFORE_NOTE = ' '
22
23// The props each instance last drew, so a press reads the labels on screen now.
24const shown = new WeakMap<object, LinksProps>()
25// The address under the left button's last down, so a press lands only where the button comes up on the same label.
26const downOn = new WeakMap<object, string>()
27
28const Links: ClientModule<LinksProps, true> = (props, surface) => {
29 const { Box, Text } = surface.elements
30 shown.set(surface, props)
31 if (surface.state === undefined) {
32 surface.setState(true)
33 surface.onPointer(event => {
34 if (event.button !== 'left') return
35 if (event.type === 'down') {
36 const href = hrefAt(surface, event.x, event.y)
37 if (href === undefined) downOn.delete(surface)
38 else downOn.set(surface, href)
39 } else if (event.type === 'up') {
40 const href = downOn.get(surface)
41 downOn.delete(surface)
42 if (href !== undefined && hrefAt(surface, event.x, event.y) === href) surface.post({ href } satisfies LinksPost)
43 }
44 })
45 }
46
47 return (
48 <Box flexDirection="row">
49 <Text wrap="truncate">
50 {props.links.map((link, i) => [i === 0 ? '' : BETWEEN, <Text underline>{link.label}</Text>])}
51 {props.note === undefined ? '' : [BEFORE_NOTE, <Text dimColor>{props.note}</Text>]}
52 </Text>
53 </Box>
54 )
55}
56
57export default Links
58
59/** The link drawn under column `x` of the row, or undefined where none is. */
60export function linkUnder(links: readonly Link[], x: number): Link | undefined {
61 let at = 0
62 for (const [i, link] of links.entries()) {
63 if (i > 0) at += widthOf(BETWEEN)
64 const to = at + widthOf(link.label)
65 if (x >= at && x < to) return link
66 at = to
67 }
68
69 return undefined
70}
71
72function hrefAt(surface: ClientSurface<true>, x: number, y: number): string | undefined {
73 return y === 0 ? linkUnder(shown.get(surface)?.links ?? [], x)?.href : undefined
74}
75hooks/parse.ts 418 lines1// Adapted from cc-stretch (https://github.com/pro-vi/cc-stretch), MIT License, Copyright (c) 2026 pro-vi.
2// See ../LICENSE-cc-stretch.
3
4// A reply is markdown in which `{{short|long}}` marks a stretch: the reader sees `short`, and
5// opening it puts `long` in its place. `long` may hold further stretches. Code spans and fences are
6// copied as written, so braces in code never open a stretch.
7//
8// Only well-formed markup becomes a stretch. These stay literal text: a `{{` followed by
9// whitespace or another brace, or written as `${{` (`{{ name }}`, `${{ secrets.TOKEN }}`); a `{{`
10// that never closes; braces closed with no `|`; and a long form that reads as template syntax, a
11// single token with no space or one with `=` before its first space (`{{name|upper}}`,
12// `{{cite web|url=...}}`). An empty long form leaves the short form as plain text, and an empty
13// short form leaves the long form as plain text. A stretch used as a section label (alone on its
14// line, its short form no sentence) is written open, its long form as plain text.
15
16export type Segment = string | Stretch
17
18export type Stretch = {
19 /**
20 * Names the stretch by its position: its index among the stretches beside it, after its parents'
21 * (`0 › 1`), so no text a reply writes can make two keys collide.
22 */
23 key: string
24 short: string
25 long: Segment[] | undefined
26 isClosed: boolean
27}
28
29type Frame = { stretch: Stretch; start: number; bar: number }
30
31export const KEY_SEPARATOR = ' › '
32
33/** Line endings as the parser and the layout read them: CRLF and lone CR become LF. */
34export function normalize(text: string): string {
35 return text.replace(/\r\n?/g, '\n')
36}
37
38export function isOpenable(stretch: Stretch): boolean {
39 return stretch.isClosed && stretch.long !== undefined
40}
41
42/** Whether a stretch is drawn as a link: openable, with its short form on one line, as a link cannot span lines. */
43export function isLinkable(stretch: Stretch): boolean {
44 return isOpenable(stretch) && !stretch.short.includes('\n')
45}
46
47/**
48 * An open stretch's long form followed by its `closer`, after a space, or after `gap` when the long
49 * form ends in a line text after it would change: a rule, which then reads as plain dashes, or a code
50 * fence, which then no longer closes.
51 */
52export function closeAfter(long: string, closer: string, gap: string): string {
53 const trimmed = long.trimEnd()
54 const last = trimmed.slice(trimmed.lastIndexOf('\n') + 1)
55 const endsInBlock = isRule(last) || /^ {0,3}(?:`{3,}|~{3,})[ \t]*$/.test(last)
56
57 return endsInBlock ? `${trimmed}${gap}${closer}` : `${long} ${closer}`
58}
59
60/** Whether `line` is a rule: three or more of one of `-`, `*` and `_`, spaces between allowed. */
61export function isRule(line: string): boolean {
62 return /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)
63}
64
65/** The key of the stretch holding the one keyed `key`, or '' when no stretch holds it. */
66export function parentOf(key: string): string {
67 return key.split(KEY_SEPARATOR).slice(0, -1).join(KEY_SEPARATOR)
68}
69
70/**
71 * Where the code span opening at `i` ends: past the next run of as many backticks in the same
72 * paragraph. An unmatched run is literal backticks and ends right after itself.
73 */
74export function codeSpanEnd(text: string, i: number): number {
75 let stop = i
76 while (text[stop] === '`') stop += 1
77 const run = text.slice(i, Math.max(stop, i + 1))
78 const end = text.indexOf(run, i + run.length)
79 const blankLine = /\n[ \t]*\n/g
80 blankLine.lastIndex = i + run.length
81 const blank = blankLine.exec(text)
82 const paragraphEnd = blank === null ? text.length : blank.index
83 if (end === -1 || end > paragraphEnd) return i + run.length
84
85 return end + run.length
86}
87
88/** Whether a `{{` at `i` may open a stretch: not after `$`, and followed by a character that is neither space nor brace. */
89function opensAt(text: string, i: number): boolean {
90 if (!text.startsWith('{{', i) || text[i - 1] === '$') return false
91 const after = text[i + 2]
92
93 return after !== undefined && /[^\s{}]/.test(after)
94}
95
96/**
97 * Whether a long form, as written, reads as template syntax rather than prose: a single token with
98 * no space (`upper`, `currency`), or one with `=` before its first space (`url=...`).
99 */
100function isTemplateLong(raw: string): boolean {
101 const space = raw.search(/\s/)
102 if (space === -1) return true
103
104 return raw.slice(0, space).includes('=')
105}
106
107type Parsed = { root: Segment[]; rejected: [number, number][]; spans: [number, number][] }
108
109/**
110 * One pass. `literal` maps the start of each `{{` to be read as text to the position of the `}}`
111 * closing it (-1 for none), which is read as text too. Returns the stretches found unclosed or
112 * template-like, as [start, close], to be made literal on the next pass.
113 */
114function parseOnce(text: string, literal: ReadonlyMap<number, number>): Parsed {
115 const literalCloses = new Set([...literal.values()].filter(at => at >= 0))
116 const root: Segment[] = []
117 const stack: Frame[] = []
118 const spans: [number, number][] = []
119 const rejected: [number, number][] = []
120
121 const containerOf = (frame: Frame | undefined) => frame?.stretch.long ?? root
122 const write = (chunk: string) => {
123 if (chunk === '') return
124 const frame = stack.at(-1)
125 if (frame && frame.stretch.long === undefined) {
126 frame.stretch.short += chunk
127 return
128 }
129 const segments = containerOf(frame)
130 const last = segments.at(-1)
131 if (typeof last === 'string') segments[segments.length - 1] = last + chunk
132 else segments.push(chunk)
133 }
134 const put = (segment: Segment) => (typeof segment === 'string' ? write(segment) : containerOf(stack.at(-1)).push(segment))
135
136 let i = 0
137 while (i < text.length) {
138 const frame = stack.at(-1)
139 const isInShort = frame !== undefined && frame.stretch.long === undefined
140
141 const fenceStop = i === 0 || text[i - 1] === '\n' ? fenceEnd(text, i) : undefined
142 if (fenceStop !== undefined) {
143 write(text.slice(i, fenceStop))
144 i = fenceStop
145 continue
146 }
147
148 if (text[i] === '`') {
149 const stop = codeSpanEnd(text, i)
150 write(text.slice(i, stop))
151 i = stop
152 continue
153 }
154
155 if (text.startsWith('{{', i) && (!isInShort || (!literal.has(i) && opensAt(text, i)))) {
156 if (literal.has(i) || !opensAt(text, i)) {
157 write('{{')
158 i += 2
159 continue
160 }
161 // A short form holds no stretch: a stretch opening inside one means the outer `{{` was text.
162 if (isInShort) {
163 stack.pop()
164 containerOf(stack.at(-1)).pop()
165 write(`{{${frame.stretch.short}`)
166 }
167 const stretch: Stretch = { key: '', short: '', long: undefined, isClosed: false }
168 containerOf(stack.at(-1)).push(stretch)
169 stack.push({ stretch, start: i, bar: -1 })
170 i += 2
171 continue
172 }
173
174 if (isInShort && text[i] === '|') {
175 frame.stretch.long = []
176 frame.bar = i
177 i += 1
178 continue
179 }
180
181 if (text.startsWith('}}', i) && literalCloses.has(i)) {
182 write('}}')
183 i += 2
184 continue
185 }
186
187 if (frame && text.startsWith('}}', i)) {
188 stack.pop()
189 // The stretch is the last segment of its container: everything since went inside it.
190 containerOf(stack.at(-1)).pop()
191 const { short, long } = frame.stretch
192 const isLongEmpty = long !== undefined && long.every(part => typeof part === 'string' && part.trim() === '')
193 if (long === undefined) write(`{{${short}}}`)
194 else if (short.trim() === '') long.forEach(put)
195 else if (isLongEmpty) write(short)
196 else {
197 if (isTemplateLong(text.slice(frame.bar + 1, i))) rejected.push([frame.start, i])
198 frame.stretch.isClosed = true
199 containerOf(stack.at(-1)).push(frame.stretch)
200 spans.push([frame.start, i + 2])
201 }
202 i += 2
203 continue
204 }
205
206 write(text[i] ?? '')
207 i += 1
208 }
209 for (const open of stack) rejected.push([open.start, -1])
210
211 return { root, rejected, spans }
212}
213
214/** Parses until nothing is rejected; each rejected `{{` and its `}}` are made literal and the text read again. */
215function parseFull(input: string): { root: Segment[]; spans: [number, number][] } {
216 const text = normalize(input)
217 const literal = new Map<number, number>()
218 for (;;) {
219 const parsed = parseOnce(text, literal)
220 if (parsed.rejected.length === 0) {
221 assignKeys(parsed.root, undefined)
222 return parsed
223 }
224 for (const [start, close] of parsed.rejected) literal.set(start, close)
225 }
226}
227
228export function parse(text: string): Segment[] {
229 return openLabels(parseFull(text).root, true, true)
230}
231
232// What may stand beside a label on its line: a quote mark, a bullet or list number, a heading's
233// hashes and emphasis before it; emphasis and a colon, either way round, after it.
234const LABEL_HEAD = /^[ \t]*(?:>[ \t]*)*(?:(?:[-+*]|\d+[.)])[ \t]+)?(?:#{1,6}[ \t]+)?[*_]*[ \t]*$/
235const LABEL_TAIL = /^[ \t]*(?:[*_]*:?|:?[*_]*)[ \t]*$/
236
237/**
238 * `segments` with each stretch used as a label written open: its long form in its place, no link.
239 * A label is a stretch alone on its line (a paragraph of its own included) whose short form does
240 * not read as a sentence: a question or one ending in a colon is a label there. The start and end
241 * of the reply are line edges; a long form's are not.
242 */
243function openLabels(segments: Segment[], isAtLineStart: boolean, isAtLineEnd: boolean): Segment[] {
244 const out: Segment[] = []
245 const push = (part: Segment) => {
246 const last = out.at(-1)
247 if (typeof part === 'string' && typeof last === 'string') out[out.length - 1] = last + part
248 else out.push(part)
249 }
250 // The text between the stretch at `i` and the edge of its line, or null when another stretch or
251 // the edge of a long form lies between.
252 const headOf = (i: number): string | null => {
253 const prev = segments[i - 1]
254 if (prev === undefined) return isAtLineStart ? '' : null
255 if (typeof prev !== 'string') return null
256 if (prev.includes('\n')) return prev.slice(prev.lastIndexOf('\n') + 1)
257 return i - 1 === 0 && isAtLineStart ? prev : null
258 }
259 const tailOf = (i: number): string | null => {
260 const next = segments[i + 1]
261 if (next === undefined) return isAtLineEnd ? '' : null
262 if (typeof next !== 'string') return null
263 if (next.includes('\n')) return next.slice(0, next.indexOf('\n'))
264 return i + 1 === segments.length - 1 && isAtLineEnd ? next : null
265 }
266
267 segments.forEach((part, i) => {
268 if (typeof part === 'string' || !isOpenable(part) || part.long === undefined) {
269 push(part)
270 return
271 }
272 part.long = openLabels(part.long, false, false)
273 const head = headOf(i)
274 const tail = tailOf(i)
275 const isAlone = head !== null && tail !== null && LABEL_HEAD.test(head) && LABEL_TAIL.test(tail)
276 if (isAlone && !isSentence(part.short)) part.long.forEach(push)
277 else push(part)
278 })
279
280 return out
281}
282
283/**
284 * Whether a short form reads as a sentence: four words or more, ending in a full stop or an
285 * exclamation mark. "Why." and "Why does it fail?" are labels.
286 */
287function isSentence(short: string): boolean {
288 const text = short.trim()
289
290 return /[.!]["')\]*_]*$/.test(text) && text.split(/\s+/).length >= 4
291}
292
293function assignKeys(segments: Segment[], parent: string | undefined): void {
294 let n = 0
295 for (const segment of segments) {
296 if (typeof segment === 'string') continue
297 segment.key = parent === undefined ? `${n}` : `${parent}${KEY_SEPARATOR}${n}`
298 n += 1
299 assignKeys(segment.long ?? [], segment.key)
300 }
301}
302
303/** Where a fenced code block opening at `start` ends (past its closing line), or undefined when no fence opens there. */
304function fenceEnd(text: string, start: number): number | undefined {
305 const lineEnd = (from: number) => {
306 const end = text.indexOf('\n', from)
307 return end === -1 ? text.length : end
308 }
309 const opening = /^[ \t]*(`{3,}|~{3,})/.exec(text.slice(start, lineEnd(start)))?.[1]
310 if (opening === undefined) return undefined
311
312 for (let at = lineEnd(start) + 1; at < text.length; at = lineEnd(at) + 1) {
313 const line = text.slice(at, lineEnd(at)).trim()
314 if (line.length >= opening.length && [...line].every(c => c === opening[0])) return Math.min(lineEnd(at) + 1, text.length)
315 }
316
317 return text.length
318}
319
320/** Every stretch, outermost first, in the order the reply writes them. */
321export function stretches(segments: Segment[]): Stretch[] {
322 return segments.flatMap(segment => (typeof segment === 'string' ? [] : [segment, ...stretches(segment.long ?? [])]))
323}
324
325/** What follows the short form of a closed stretch that holds another, so the reader sees that a click goes deeper. */
326export const NESTED_MARK = '»'
327
328/** Whether a stretch's long form holds a stretch of its own that is drawn as a link, so a click on it goes deeper. */
329export function hasNested(stretch: Stretch): boolean {
330 return stretch.long !== undefined && stretches(stretch.long).some(isLinkable)
331}
332
333/** Whether `text` holds markup the parser would draw as a stretch: template syntax and code are left as text, so they do not count. */
334export function hasMarkup(text: string): boolean {
335 const plain = normalize(text)
336 return plain.includes('{{') && writeOpen(parse(plain)) !== plain
337}
338
339// The keys of a tool call's event that are the engine's, not the tool's arguments.
340const RESERVED = new Set(['tool', 'tool_use_id', 'consent'])
341
342/**
343 * The dotted path of the first string in a tool call's arguments that holds stretch markup, walking
344 * nested objects and arrays, or undefined when none does.
345 */
346export function markupIn(input: unknown, path = ''): string | undefined {
347 if (typeof input === 'string') return hasMarkup(input) ? path : undefined
348 if (Array.isArray(input)) {
349 for (const [i, item] of input.entries()) {
350 const found = markupIn(item, `${path}[${i}]`)
351 if (found !== undefined) return found
352 }
353 return undefined
354 }
355 if (typeof input !== 'object' || input === null) return undefined
356 for (const [key, value] of Object.entries(input)) {
357 if (path === '' && RESERVED.has(key)) continue
358 const found = markupIn(value, path === '' ? key : `${path}.${key}`)
359 if (found !== undefined) return found
360 }
361 return undefined
362}
363
364/** A reply with every stretch drawn as its long form. */
365export function writeOpen(parts: Segment[]): string {
366 return parts.map(part => (typeof part === 'string' ? part : part.long === undefined ? part.short : writeOpen(part.long))).join('')
367}
368
369/** A run of the reply between blank lines, and whether it holds a stretch. */
370export type Paragraph = { text: string; hasStretch: boolean }
371
372/**
373 * The reply cut at the blank lines that stand outside every stretch and code fence, so a paragraph
374 * holding a stretch can be drawn apart from the rest. Neighbouring paragraphs without one are kept
375 * together.
376 */
377export function paragraphsOf(input: string): Paragraph[] {
378 const text = normalize(input)
379 const { spans } = parseFull(text)
380 const isInsideStretch = (at: number) => spans.some(([start, end]) => at > start && at < end)
381 const pieces: string[] = []
382 let current: string[] = []
383 let fence: string | undefined
384 let offset = 0
385 for (const line of text.split('\n')) {
386 const lineStart = offset
387 offset += line.length + 1
388 const marker = /^[ \t]*(`{3,}|~{3,})/.exec(line)?.[1]
389 if (fence !== undefined) {
390 if (marker !== undefined && marker[0] === fence[0] && marker.length >= fence.length) fence = undefined
391 current.push(line)
392 continue
393 }
394 if (marker !== undefined) {
395 fence = marker
396 current.push(line)
397 continue
398 }
399 if (line.trim() === '' && !isInsideStretch(lineStart)) {
400 if (current.length > 0) pieces.push(current.join('\n'))
401 current = []
402 continue
403 }
404 current.push(line)
405 }
406 if (current.length > 0) pieces.push(current.join('\n'))
407
408 const paragraphs: Paragraph[] = []
409 for (const piece of pieces) {
410 const hasStretch = stretches(parse(piece)).length > 0
411 const last = paragraphs.at(-1)
412 if (!hasStretch && last !== undefined && !last.hasStretch) last.text += `\n\n${piece}`
413 else paragraphs.push({ text: piece, hasStretch })
414 }
415
416 return paragraphs
417}
418hooks/reply.tsx 230 lines1// Adapted from cc-stretch (https://github.com/pro-vi/cc-stretch), MIT License, Copyright (c) 2026 pro-vi.
2// See ../LICENSE-cc-stretch.
3
4import type { ClientModule, ClientSurface, TextProps } from 'claude-code'
5
6import { grow, lengthOf } from './grow'
7import { dim, isMarkup, layout, link, linkAt, stripMarkers, tint } from './layout'
8import type { Row, Run } from './layout'
9import { closeAfter, hasNested, isLinkable, KEY_SEPARATOR, NESTED_MARK, parse, stretches, writeOpen } from './parse'
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/** An open-all or close-all from the /stretch command, numbered so the instance applies each once. */
24export type Bulk = { id: number; action: 'open' | 'close' }
25
26/**
27 * The paragraph, the stretches the reader already has open in it, the tint of each, and the latest
28 * open-all or close-all aimed at its reply.
29 */
30export type ReplyProps = { text: string; open: string[]; tints: Record<string, Tint>; bulk?: Bulk }
31
32/**
33 * What the instance posts to the hooks module: the open stretches whenever they change, with the
34 * stretch the reader just opened (its short form for the note to Claude, its key for its colour),
35 * the short forms an open-all opened, and once on its first draw, `mounted`, so the hooks module
36 * knows the reply was drawn.
37 */
38export type ReplyPost = { open: string[]; opened?: { short: string; key: string }; openedAll?: string[]; mounted?: true }
39
40type Growth = { key: string; isOpening: boolean; progress: number; duration: number }
41// `bulk`: the id of the last open-all or close-all applied, or of the one standing at the first draw.
42type State = { open: string[]; growth: Growth | null; bulk: number }
43
44const FRAME_MS = 16
45
46// A drawn tree must stay within 100,000 serialized characters and 20,000 nodes, or the engine
47// unmounts the instance. Past these estimates the paragraph is drawn open as markdown instead.
48const MAX_SERIALIZED = 60_000
49const MAX_NODES = 12_000
50const MAX_MARKDOWN = 90_000
51const ANIMATE_MAX_CHARS = 4_000
52
53/** What one instance has worked out, kept so a frame of a growth does not parse or lay out again. */
54type Cache = {
55 text: string
56 segments: Segment[]
57 composed: string
58 columns: number
59 rows: Row[]
60}
61const caches = new WeakMap<object, Cache>()
62
63const Reply: ClientModule<ReplyProps, State> = (props, surface) => {
64 const { Box, Text, Markdown } = surface.elements
65 if (surface.state === undefined) {
66 surface.setState({ open: props.open, growth: null, bulk: props.bulk?.id ?? 0 })
67 surface.onPointer(event => {
68 if (event.type === 'down' && event.button === 'left') press(surface, event.x, event.y)
69 })
70 surface.every(FRAME_MS, () => tick(surface))
71 surface.post({ open: props.open, mounted: true } satisfies ReplyPost)
72 }
73 let state = surface.state ?? { open: props.open, growth: null, bulk: props.bulk?.id ?? 0 }
74 const columns = surface.columns > 0 ? surface.columns : 80
75 let cache = caches.get(surface)
76 if (cache === undefined || cache.text !== props.text) {
77 const segments = parse(stripMarkers(props.text))
78 cache = { text: props.text, segments, composed: '', columns: -1, rows: [] }
79 caches.set(surface, cache)
80 }
81 // An open-all opens every stretch of the paragraph at once, nested ones too; a close-all closes them.
82 const { bulk } = props
83 if (bulk !== undefined && bulk.id !== state.bulk) {
84 const all = stretches(cache.segments).filter(isLinkable)
85 const open = bulk.action === 'open' ? all.map(stretch => stretch.key) : []
86 const opened = all.filter(stretch => !state.open.includes(stretch.key)).map(stretch => stretch.short.trim())
87 state = { open, growth: null, bulk: bulk.id }
88 surface.setState(state)
89 surface.post(bulk.action === 'open' && opened.length > 0 ? ({ open, openedAll: opened } satisfies ReplyPost) : ({ open } satisfies ReplyPost))
90 }
91 const composed = compose(cache.segments, state, props.tints)
92 if (composed !== cache.composed || columns !== cache.columns) {
93 cache.rows = layout(composed, columns)
94 cache.composed = composed
95 cache.columns = columns
96 }
97 const { rows } = cache
98
99 if (!fitsTree(rows)) {
100 const open = writeOpen(cache.segments)
101 return <Markdown text={open.length > MAX_MARKDOWN ? `${open.slice(0, MAX_MARKDOWN)}…` : open} />
102 }
103
104 return (
105 <Box flexDirection="column">
106 {rows.map(row => (
107 <Text wrap="truncate">{row.length === 0 ? ' ' : row.map(run => (isPlain(run) ? run.text : <Text {...styleOf(run)}>{run.text}</Text>))}</Text>
108 ))}
109 </Box>
110 )
111}
112
113export default Reply
114
115/** Whether the rows, drawn, stay well inside the engine's bounds for one tree. */
116export function fitsTree(rows: Row[]): boolean {
117 let size = 40
118 let nodes = 1
119 for (const row of rows) {
120 size += 40
121 nodes += 1
122 for (const run of row) {
123 // A string child costs its text and quotes; a styled run is a node with its props as well.
124 size += JSON.stringify(run.text).length + 2
125 if (!isPlain(run)) {
126 size += JSON.stringify(styleOf(run)).length + 40
127 nodes += 1
128 }
129 }
130 if (size > MAX_SERIALIZED || nodes > MAX_NODES) return false
131 }
132
133 return true
134}
135
136/**
137 * The paragraph as the reader sees it now: closed stretches as pressable short forms, open ones in
138 * full then a «, the words each added in its tint. A closed stretch whose long form holds another
139 * ends in a dim », inside the link, so the reader sees that a click goes deeper.
140 */
141function compose(segments: Segment[], state: State, tints: Record<string, Tint>): string {
142 const write = (parts: Segment[]): string => parts.map(part => (typeof part === 'string' ? part : draw(part))).join('')
143 const draw = (stretch: Stretch): string => {
144 if (stretch.long === undefined || !stretch.isClosed) return stretch.short
145 // A short form over several lines is drawn open.
146 if (!isLinkable(stretch)) return write(stretch.long)
147 const colours = tints[stretch.key]
148 const paint = (word: string) => (colours === undefined || isMarkup(word) ? word : tint(colours.words, word))
149 if (state.growth?.key === stretch.key) return grow(stretch.short, stretch.long, easeInOut(state.growth.progress), draw, paint)
150 if (!state.open.includes(stretch.key)) return link('open', stretch.key, hasNested(stretch) ? `${stretch.short}${dim(NESTED_MARK)}` : stretch.short)
151 const closer = link('close', stretch.key, '«')
152 const long = grow(stretch.short, stretch.long, 1, draw, paint)
153 return closeAfter(long, colours === undefined ? closer : tint(colours.closer, closer), '\n')
154 }
155
156 return write(segments)
157}
158
159function press(surface: ClientSurface<State>, x: number, y: number) {
160 const state = surface.state
161 const shown = caches.get(surface)
162 if (state === undefined || shown === undefined) return
163 const target = linkAt(shown.rows, x, y)
164 if (target === undefined) return
165 const stretch = stretches(shown.segments).find(one => one.key === target.key)
166 if (stretch?.long === undefined) return
167
168 // A stretch still shrinking when another press lands is closed at once.
169 const open = state.growth !== null && !state.growth.isOpening ? closed(state.open, state.growth.key) : state.open
170 // A long paragraph opens and closes at once: laying it out every frame would cost more than a frame.
171 if (shown.text.length > ANIMATE_MAX_CHARS) {
172 const next = target.action === 'open' ? [...open.filter(key => key !== stretch.key), stretch.key] : closed(open, stretch.key)
173 surface.setState({ ...state, open: next, growth: null })
174 surface.post(
175 target.action === 'open' ? ({ open: next, opened: { short: stretch.short.trim(), key: stretch.key } } satisfies ReplyPost) : ({ open: next } satisfies ReplyPost),
176 )
177 return
178 }
179 const duration = 1000 * Math.min(1.4, 0.45 + lengthOf(stretch.long) / 70)
180 if (target.action === 'open') {
181 const next = [...open.filter(key => key !== stretch.key), stretch.key]
182 surface.setState({ ...state, open: next, growth: { key: stretch.key, isOpening: true, progress: 0, duration } })
183 surface.post({ open: next, opened: { short: stretch.short.trim(), key: stretch.key } } satisfies ReplyPost)
184 } else {
185 surface.setState({ ...state, open, growth: { key: stretch.key, isOpening: false, progress: 1, duration } })
186 }
187}
188
189function tick(surface: ClientSurface<State>) {
190 const state = surface.state
191 if (state?.growth == null) return
192 const { growth } = state
193 const progress = growth.progress + ((growth.isOpening ? 1 : -1) * FRAME_MS) / growth.duration
194 if (progress > 0 && progress < 1) {
195 surface.setState({ ...state, growth: { ...growth, progress } })
196 return
197 }
198 // A shrunk stretch, and every stretch inside it, leaves the open list as its growth ends.
199 const open = growth.isOpening ? state.open : closed(state.open, growth.key)
200 surface.setState({ ...state, open, growth: null })
201 if (!growth.isOpening) surface.post({ open } satisfies ReplyPost)
202}
203
204function closed(open: string[], key: string): string[] {
205 return open.filter(k => k !== key && !k.startsWith(`${key}${KEY_SEPARATOR}`))
206}
207
208function isPlain(run: Run): boolean {
209 return !run.bold && !run.italic && !run.underline && !run.dim && run.tint === undefined && !run.code && run.link === undefined
210}
211
212function styleOf(run: Run): TextProps {
213 return {
214 ...(run.bold && { bold: true }),
215 ...(run.italic && { italic: true }),
216 ...(run.underline && { underline: true }),
217 ...(run.dim && { dimColor: true }),
218 // A colour that is not a plain hex value is dropped rather than handed to the screen.
219 ...(run.tint !== undefined && /^#[0-9a-fA-F]{6}$/.test(run.tint) && { color: run.tint }),
220 ...(run.code && { color: 'permission' }),
221 // Underlined, in the text's own colour, marks what a press acts on, leaving colour to code and
222 // to the words a click added. A closer is tinted like them, in its stretch's unfaded colour.
223 ...(run.link && { underline: true }),
224 }
225}
226
227function easeInOut(k: number): number {
228 return k < 0.5 ? 4 * k * k * k : 1 - (-2 * k + 2) ** 3 / 2
229}
230hooks/grow.ts 143 lines1// Adapted from cc-stretch (https://github.com/pro-vi/cc-stretch), MIT License, Copyright (c) 2026 pro-vi.
2// See ../LICENSE-cc-stretch.
3
4import type { Segment, Stretch } from './parse'
5
6// A long form grows around its short form. 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/** How many words and nested stretches a growth adds, to pace it. */
61export function lengthOf(long: Segment[]): number {
62 return tokenize(long).filter(token => token.kind !== 'space').length
63}
64
65function reveal(run: Atom[], side: Side, progress: number): Atom[] {
66 // Two anchors stay a space apart from the start, as they stood in the short form.
67 const isSpace = (atom: Atom | undefined) => typeof atom === 'string' && /^\s+$/.test(atom)
68 if (side === 'between' && run.length > 1 && isSpace(run[0]) && isSpace(run.at(-1))) {
69 const middle = reveal(run.slice(1, -1), side, progress)
70 return middle.length === 0 ? [run[0] ?? ' '] : [run[0] ?? ' ', ...middle, run.at(-1) ?? ' ']
71 }
72 if (side === 'between' && isSpace(run[0])) return [run[0] ?? ' ', ...reveal(run.slice(1), side, progress)]
73 const count = Math.round(run.length * Math.min(1, Math.max(0, progress)))
74 if (side === 'after') return run.slice(0, count)
75 if (side === 'before') return run.slice(run.length - count)
76 const left = run.slice(0, Math.ceil(count / 2))
77 const right = run.slice(run.length - Math.floor(count / 2))
78 if (left.length + right.length === run.length) return [...left, ...right]
79 // The two halves meet where words are still missing: exactly one space between them.
80 const isSpaceAtom = (atom: Atom | undefined) => typeof atom === 'string' && /^\s+$/.test(atom)
81 if (isSpaceAtom(left.at(-1)) && isSpaceAtom(right[0])) return [...left, ...right.slice(1)]
82 if (!isSpaceAtom(left.at(-1)) && !isSpaceAtom(right[0]) && left.length > 0 && right.length > 0) return [...left, ' ', ...right]
83
84 return [...left, ...right]
85}
86
87function tokenize(long: Segment[]): Token[] {
88 return long.flatMap((part): Token[] =>
89 typeof part === 'string'
90 ? part
91 .split(/(\s+)/)
92 .filter(text => text !== '')
93 .map(text => ({ kind: /^\s+$/.test(text) ? 'space' : 'word', text }))
94 : [{ kind: 'nested', stretch: part }],
95 )
96}
97
98/**
99 * The token indexes of the long form that carry the short form's words, as many as can be kept in
100 * order (a longest common subsequence). A nested stretch shows its own short form, so its words
101 * count where it stands.
102 */
103function anchorsOf(short: string, tokens: Token[]): Set<number> {
104 const want = short.split(/\s+/).map(bare).filter(word => word !== '')
105 const have: { token: number; word: string }[] = []
106 tokens.forEach((token, i) => {
107 const words = token.kind === 'word' ? [token.text] : token.kind === 'nested' ? token.stretch.short.split(/\s+/) : []
108 for (const word of words.map(bare).filter(w => w !== '')) have.push({ token: i, word })
109 })
110
111 // lengths[i][j]: the longest common run of want[i..] and have[j..].
112 const lengths = Array.from({ length: want.length + 1 }, () => new Array<number>(have.length + 1).fill(0))
113 for (let i = want.length - 1; i >= 0; i--) {
114 for (let j = have.length - 1; j >= 0; j--) {
115 const row = lengths[i] ?? [], below = lengths[i + 1] ?? []
116 row[j] = want[i] === have[j]?.word ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0)
117 }
118 }
119 const anchors = new Set<number>()
120 for (let i = 0, j = 0; i < want.length && j < have.length; ) {
121 if (want[i] === have[j]?.word) {
122 anchors.add(have[j]?.token ?? -1)
123 i++
124 j++
125 } else if ((lengths[i + 1]?.[j] ?? 0) >= (lengths[i]?.[j + 1] ?? 0)) i++
126 else j++
127 }
128 // Punctuation standing on its own right after an anchor (`{{...}}.`) belongs to that anchor.
129 let last = -1
130 tokens.forEach((token, i) => {
131 if (token.kind === 'space') return
132 if (token.kind === 'word' && bare(token.text) === '' && anchors.has(last)) anchors.add(i)
133 last = i
134 })
135
136 return anchors
137}
138
139/** A word without its surrounding punctuation or markdown marks, lowercased, so `cache.` meets `**cache**`. */
140function bare(word: string): string {
141 return word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '').toLowerCase()
142}
143types/index.d.ts 25 lines1declare module 'claude-code' {
2 interface PluginState {
3 stretch: {
4 /** Per paragraph of a reply: the keys of the stretches the reader has open. */
5 open: StateFamily<string[]>
6 /** The stretches the reader opened since their last prompt: the reply's request id and the short form. */
7 seen: { requestId: string; short: string }[]
8 /** Per tool group: how the reader last set its own toggle, and the `groupsAll` id current then. It counts only while that id is still current. */
9 groups: StateFamily<{ isOpen: boolean; at: number }>
10 /** The last show all or fold all over tool groups: its number, and which way. A group with no own toggle since follows it. */
11 groupsAll: { id: number; isOpen: boolean }
12 /** Per reply: the count each open stretch was opened at (picks its colour), and the next count. */
13 order: StateFamily<{ order: Record<string, number>; count: number }>
14 /** The request id of the reply where a stretch was opened last, until the next prompt; empty for none. */
15 newest: string
16 /** Request ids of every reply drawn with stretches this session, newest last, at most 1000. */
17 drawn: string[]
18 /** Request ids of replies first drawn since the last prompt: the latest reply. */
19 latest: string[]
20 /** The last /stretch open-all or close-all: its number, which way, and the request ids of the reply it opens or closes. */
21 bulk: { id: number; action: 'open' | 'close'; requestIds: string[] }
22 }
23 }
24}
25