SLOPSHOPPER

stretch

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

rowsguardcommandprompt
v0.3.1MITupdated 2026-10-19iamJeb/claude/plugins/stretch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stretch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /stretch ⎿ stretch: stretch is on, and a short reply is 150 words at most. Usage: /stretch on | off | open-all | close-all | words ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

stretch

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

A reply lands short, a click opens a phrase in place, a click on the chevron folds it back

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

What you get

  • Replies that read complete at their shortest, with the depth folded behind phrases you click.
  • Click a phrase to open it in place, click « to fold it back. A » on a phrase means a click goes deeper.
  • An open all and close all line under any reply with several stretches, with a count of how many are open.
  • Claude is told which stretches you opened, and repeats what you skipped when it matters.
  • Finished tool calls fold to one line, with show, show all, fold and fold all beside them.
  • /stretch off and /stretch on, /stretch open-all and /stretch close-all, /stretch words N.
  • Nothing that leaves the chat carries the markup: the plugin refuses a tool call that holds a stretch.
  • Two /config settings: the short reply length and whether tool groups fold.

How it works

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.

Good to know

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.

The /stretch command

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

Settings

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.

Install

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.

Update

Refresh the marketplace, update the plugin, then restart Claude Code:

claude plugin marketplace update iamjeb-claude
claude plugin update stretch@iamjeb-claude

Remove

/plugin uninstall stretch

Changes

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.

Source 8 files
hooks/register.tsx 537 lines
1// 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}
537
hooks/guide.ts 72 lines
1// 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.`
72
hooks/layout.ts 478 lines
1// 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}
478
hooks/links.tsx 75 lines
1import 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}
75
hooks/parse.ts 418 lines
1// 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}
418
hooks/reply.tsx 230 lines
1// 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}
230
hooks/grow.ts 143 lines
1// 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}
143
types/index.d.ts 25 lines
1declare 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