diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 488f7bf..88ffafa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -157,7 +157,7 @@ "name": "cc-dazzle", "source": "./plugins/cc-dazzle", "description": "Draws Claude Code's transcript in Catppuccin Mocha: boxed code blocks, box tables, Mermaid diagrams, diff cards in Claude's colors, compact tool rows and timed prompts. Optional stretchtext replies. Claude Code only.", - "version": "1.7.1" + "version": "1.8.0" } ] } diff --git a/docs/upstreams.md b/docs/upstreams.md index 499a819..e21b194 100644 --- a/docs/upstreams.md +++ b/docs/upstreams.md @@ -4,16 +4,34 @@ Plugins here that copy code from another project, and the last upstream version | Plugin | Upstream | Copied from | Last checked | Checked on | |---|---|---|---|---| -| cc-dazzle | [NahumLitvin/prismantis](https://github.com/NahumLitvin/prismantis) | fork `vdsmon/prismantis` 0.15.19, which split from upstream at 0.15.1 (`af067b6`) | 0.31.0 (`354e11d`) | 2026-10-19 | -| cc-dazzle (stretchtext) | [pro-vi/cc-stretch](https://github.com/pro-vi/cc-stretch) | 0.3.3 (`4466e11`) | 0.5.1 (`9aa56da`) | 2026-10-19 | +| cc-dazzle | [NahumLitvin/prismantis](https://github.com/NahumLitvin/prismantis) | fork `vdsmon/prismantis` 0.15.19, which split from upstream at 0.15.1 (`af067b6`) | 0.32.1 (`b90c326`) | 2026-10-20 | +| cc-dazzle (stretchtext) | [pro-vi/cc-stretch](https://github.com/pro-vi/cc-stretch) | 0.3.3 (`4466e11`) | 0.5.2 (`1de5f05`) | 2026-10-20 | To list what is new since the last check: ```console $ git clone https://github.com/NahumLitvin/prismantis && cd prismantis -$ git log --oneline 354e11d..HEAD +$ git log --oneline b90c326..HEAD ``` +## prismantis 0.31.0 to 0.32.1 + +Ported: a line holding only an HTML-like tag (``) drawn dim on its own (#228); gantt bars that start on a half cell and end on an eighth (#224); from the diff-card work (#234), the no-newline marker as its own row, a dim `...` between hunks, each hunk highlighted apart with removed lines plain, and a node allowance that keeps an opened card under Claude Code's 20000-node ceiling; new, deleted, emptied and renamed files drawn as `Created`, `Deleted` and `Renamed` cards, an Edit that makes a file and a Write preview included, and a write held for review left to Claude Code (#236); clickable paths in replies, tool rows and edit card headers, with a `file://` link kept only when its label shows its own path and programs and launchers never linked (#238, #240); and the redraw when the html copy check lands (#238). While porting the gantt bars, cc-dazzle's gantt charts turned out never to fit under about 90 columns, because the box frame takes 4 more columns than upstream's; `mermaid.tsx` now sizes them for it. + +Left out on purpose: + +- A click anywhere on a folded card opens it (#236): it needs each card redrawn as one padded Text inside a Button. The `+N more lines` and `show N lines` buttons do the same. A good next port. +- A wrapped diff line repeating its mark on each row (#234): it needs the surface width split into rows by hand; lines wrap under their code as before. +- Diff colors from the theme, the darker light-theme greens, `diffStyle` and the preview hover band (#234, #236): cc-dazzle draws diffs in Claude Code's own colors, Mocha only. +- The WSL `file://wsl.localhost` rewrite (#238): cc-dazzle dropped Linux and Windows clipboard paths. Paths in stretchtext replies do not link yet: `stretch/layout.ts` draws them. +- Figures (#171, #243, #248), including figure role markup outside figures: cc-dazzle has no figure prompt, so Claude never writes that markup. + +## cc-stretch 0.5.1 to 0.5.2 + +Ported: the words before a lone period, or any stretch with no words of its own, open its stretch; links act when the button comes up, so a drag from a link selects; a double-click on lead-in words still copies the word; the note to Claude names a wordless stretch by its lead-in words; a code span or markdown link never pairs across a pressable phrase's marks. `parse.ts`, `grow.ts` and `guide.ts` stay upstream's, unchanged. + +Left out on purpose: the bench and film scripts and docs, which never ship. + ## prismantis 0.24.0 to 0.31.0 Ported: hover copy buttons that cover only their own cells (#170), the 8-bit control character filter with the escape-sequence filter it extends (#188, #98), code drawn through the app's own Code element off the terminal (#176), Lua highlighting (#216), one color and a legend name per xychart series (#225), and pie and gantt charts (#177, #221), with the pie always round where it fits (upstream's `pieStyle` default). Both vendor files and `hooks/charts.ts` are upstream's, unchanged. diff --git a/plugins/cc-dazzle/.claude-plugin/plugin.json b/plugins/cc-dazzle/.claude-plugin/plugin.json index e1ffe03..4a09e65 100644 --- a/plugins/cc-dazzle/.claude-plugin/plugin.json +++ b/plugins/cc-dazzle/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc-dazzle", - "version": "1.7.1", + "version": "1.8.0", "description": "Draws Claude Code's transcript in Catppuccin Mocha: boxed code blocks, box tables, Mermaid diagrams, diff cards in Claude's colors, compact tool rows and timed prompts. Optional stretchtext replies.", "author": { "name": "Victor De Simone", diff --git a/plugins/cc-dazzle/README.md b/plugins/cc-dazzle/README.md index 0327dc3..672eac3 100644 --- a/plugins/cc-dazzle/README.md +++ b/plugins/cc-dazzle/README.md @@ -17,10 +17,11 @@ Mods that draw the same row do not stack, so turn off any other mod that draws r - **Replies:** a blank line before each part, headings in lavender with a heavy rule under H1, boxed tables with a double line under the header, `[ ]` and `[✓]` task lists, and version numbers and file paths in color. Each color has one job: mauve marks who is talking, red marks only failures. - **Code:** a rounded box with the language and a copy button above it, Prism colors for 25 languages, and `console` blocks with the `$` lines colored as commands. Their copy button copies the commands only. In the desktop app, VS Code and the phone app, code goes through the app's own code element, so indentation holds; `console` blocks there take the app's colors. -- **Mermaid:** flowcharts, charts and other diagrams drawn as box art. Each series of a chart gets its own color, and the legend shows its name. A pie chart is a round circle with a legend (one bar per slice past seven slices or in a narrow terminal), and a gantt chart puts its tasks on a shared time axis. A diagram that does not fit shows its source with a note. +- **Mermaid:** flowcharts, charts and other diagrams drawn as box art. Each series of a chart gets its own color, and the legend shows its name. A pie chart is a round circle with a legend (one bar per slice past seven slices or in a narrow terminal), and a gantt chart puts its tasks on a shared time axis, its bars ending on an eighth of a cell. A diagram that does not fit shows its source with a note. - **Copy buttons:** `⧉ copy` on code, lists and quotes. Tables get `⧉ md`, `⧉ art` and, on macOS outside SSH, `⧉ html`, which pastes as a real table in Docs, Notion or email. - **Tool calls:** one dim row each (`Ran gh pr view 12`, `Read ~/src/app.ts`) under a bold `└─`. Bash and MCP output in columns shows as a boxed table with a row count and the run time. -- **Edits:** a diff card in Claude Code's colors, with the changed words marked, code colored by file type, and long diffs folded. +- **Edits:** a diff card in Claude Code's colors, with the changed words marked, added and unchanged lines colored by file type, a dim `...` between hunks, and long diffs folded. A new file shows `Created (N lines)` over its first 10 lines, a file a shell command deletes is one `Deleted (N lines)` line that opens to its lines, and a file moved unchanged is one `Renamed → ` line. +- **Paths:** in the terminal, from Claude Code 2.1.289, file paths in replies, tool rows and edit cards open on click. A `file://` link opens only when its label shows the path it opens, and programs and launchers never open. - **Your prompts:** a rounded box with the send time (`14:32`, or `Oct 10 14:32` for another day). A prompt you send while Claude works stays gray until Claude reads it. - **Slash-command output:** drawn like a reply, unless it has terminal color codes. @@ -34,6 +35,7 @@ Off by default. Turn it on in `/config` (cc-dazzle, **Stretchtext replies**); th When it is on, Claude writes each reply short, with `{{short|long}}` stretches. A stretch draws as its short form, underlined. Click it and the long form grows in place around the words you already read, with a grey `▎` in the left margin beside the lines that hold the new words; click `«` to shrink it back. Stretches can nest. With your next prompt, Claude is told which stretches you opened. +- A stretch with no words of its own, such as a lone period, opens from the words before it. - Clicks work in the fullscreen terminal (`/tui fullscreen`) and in the desktop app. Elsewhere every stretch shows open, so nothing is hidden. - From the keyboard, in the fullscreen terminal: alt+↓ or ctrl+↓ puts a red letter on each closed stretch in the last three replies on screen; type the letter to open it. alt+↑ or ctrl+↑ does the same for the `«` closers. Any other key puts the letters away. Clicking the reply's `●` does what alt+↓ does. - In a paragraph with a stretch, drag to select text or double-click a word, path or URL; the selection goes to the clipboard. Links and bare URLs there are clickable. @@ -49,4 +51,4 @@ When it is on, Claude writes each reply short, with `{{short|long}}` stretches. ## Credits -Most of the code comes from [prismantis](https://github.com/NahumLitvin/prismantis) by Nahum Litvin, through the fork `vdsmon/prismantis` at 0.15.19. Upstream prismantis stays a source of ideas: they are copied here by hand. The stretchtext code comes from [cc-stretch](https://github.com/pro-vi/cc-stretch) 0.3.3 by pro-vi, with fixes and features from releases up to 0.5.1, after Ted Nelson's stretchtext. `hooks/vendor/` holds frozen builds of [Prism](https://github.com/PrismJS/prism) 1.30.0 and [beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid) 1.1.3. See `NOTICE` for the licenses. +Most of the code comes from [prismantis](https://github.com/NahumLitvin/prismantis) by Nahum Litvin, through the fork `vdsmon/prismantis` at 0.15.19. Upstream prismantis stays a source of ideas: they are copied here by hand. The stretchtext code comes from [cc-stretch](https://github.com/pro-vi/cc-stretch) 0.3.3 by pro-vi, with fixes and features from releases up to 0.5.2, after Ted Nelson's stretchtext. `hooks/vendor/` holds frozen builds of [Prism](https://github.com/PrismJS/prism) 1.30.0 and [beautiful-mermaid](https://github.com/lukilabs/beautiful-mermaid) 1.1.3. See `NOTICE` for the licenses. diff --git a/plugins/cc-dazzle/hooks/blocks.tsx b/plugins/cc-dazzle/hooks/blocks.tsx index ae64b5f..c4f8bd6 100644 --- a/plugins/cc-dazzle/hooks/blocks.tsx +++ b/plugins/cc-dazzle/hooks/blocks.tsx @@ -1,6 +1,7 @@ import type { ElementTable, RenderElement } from 'claude-code' import { tableHtml, tableText } from './html' +import { drawable } from './links' import type { Block, Inline } from './markdown' import { displayText, inlineText } from './markdown' import { graphemes, hasLink, longestWord, mostLines, remember, sliceInline, width, wrapInline, wrapRanges } from './text' @@ -9,43 +10,6 @@ import { ALERT_COLOR } from './theme' import type { PrismToken } from './vendor/prism.js' import { languages, tokenize } from './vendor/prism.js' -const isHyperlink = (href: string, http: boolean, editor: boolean): boolean => - (http ? /^https?:/i : /^(https:|http:\/\/localhost(:\d+)?([/?#]|$))/i).test(href) || (editor && /^vscode(-insiders)?:\/\/file\//i.test(href)) - -const plainLink = (text: string, href: string): string => { - const target = href.replace(/^mailto:/i, '') - return text === target || `http://${text}` === target ? target : `${text} (${target})` -} - -const unlink = (nodes: Inline[], http: boolean, editor: boolean): Inline[] => - nodes.map(n => (n.kind === 'link' && !isHyperlink(n.href, http, editor) ? { kind: 'text', text: plainLink(n.text, n.href) } : 'children' in n ? { ...n, children: unlink(n.children, http, editor) } : n)) - -const unlinkBlock = (block: Block, http: boolean, editor: boolean): Block => { - const plain = (nodes: Inline[]) => unlink(nodes, http, editor) - switch (block.kind) { - case 'heading': - case 'paragraph': - return { ...block, inline: plain(block.inline) } - case 'list': - return { ...block, items: block.items.map(item => ({ ...item, inline: plain(item.inline) })) } - case 'table': - return { ...block, header: block.header.map(plain), rows: block.rows.map(r => r.map(plain)) } - case 'notes': - return { ...block, notes: block.notes.map(note => ({ ...note, inline: plain(note.inline) })) } - default: - return block - } -} - -const unlinked = new WeakMap() - -// A link Claude Code cannot open draws as its text and target, so nothing looks clickable that is not. -const drawable = (block: Block, http: boolean, editor: boolean): Block => { - const cached = unlinked.get(block) ?? [] - unlinked.set(block, cached) - return (cached[+http + 2 * +editor] ??= unlinkBlock(block, http, editor)) -} - const noteHover = (style: Style, note: number | undefined) => note === undefined || style.noteKey === undefined ? {} : { hover: { scope: `fn:${style.noteKey}:${note}`, inverse: true } } @@ -72,7 +36,7 @@ const renderInline = (el: ElementTable, style: Style, nodes: Inline[], keyBase: case 'number': return {n.text} case 'path': - return {n.text} + return {n.href === undefined ? n.text : } case 'dim': return {n.text} case 'footnote': @@ -496,7 +460,7 @@ const renderList = (el: ElementTable, style: Style, block: Extract string), key: string, label?: string, html?: () => string) => RenderElement | null export type Drawn = Map -export type Fold = (id: string, hidden: number, key: string) => { folded: boolean; element: RenderElement } | null +export type Fold = (id: string, hidden: number, key: string, label?: string) =>{ folded: boolean; element: RenderElement } | null export type Tip = (note: number) => (() => void) | undefined export const instanceKey = (id: string): string => { @@ -563,7 +527,7 @@ export const renderBlocks = (el: ElementTable, style: Style, blocks: Block[], co return { first, second, html } }) const beside = (b: number) => (hover || !buttons[b]!.first ? columns : columns - reserved(buttons[b]!.first) - 2) - const shown = blocks.map(block => drawable(block, style.httpLinks === true, style.editorLinks === true)) + const shown = blocks.map(block => drawable(block, style)) const rendered = shown.map((block, b) => { const key = `b${b}` switch (block.kind) { diff --git a/plugins/cc-dazzle/hooks/charts.ts b/plugins/cc-dazzle/hooks/charts.ts index 6ffe9e8..0d1e2bc 100644 --- a/plugins/cc-dazzle/hooks/charts.ts +++ b/plugins/cc-dazzle/hooks/charts.ts @@ -136,6 +136,17 @@ const stamp = (t: number, span: number, sameYear: boolean) => { return span < 2 * DAY ? iso.slice(11, 16) : sameYear ? iso.slice(5, 10) : iso.slice(0, 10) } +const EIGHTHS = ' ▏▎▍▌▋▊▉' + +const fineBar = (from: number, to: number, fallback: number) => { + const half = from % 1 >= 0.25 && from % 1 < 0.75 + const start = half ? Math.floor(from) + 1 : Math.round(from) + const eighths = Math.round(to * 8) + const whole = Math.floor(eighths / 8) - start + if (whole < 1) return ' '.repeat(fallback) + '█' + return ' '.repeat(start - (half ? 1 : 0)) + (half ? '▐' : '') + '█'.repeat(whole) + (eighths % 8 > 1 ? EIGHTHS[eighths % 8]! : '') +} + export const ganttText = (source: string, ascii: boolean, columns: number): string | null => { const [first = '', ...rest] = body(source) if (!/^gantt$/i.test(first)) return null @@ -200,8 +211,8 @@ export const ganttText = (source: string, ascii: boolean, columns: number): stri const lines = rows.map(r => { if (!('name' in r)) return r.section const a = Math.min(col(r.start), chart - 1) - const cells = r.milestone ? (ascii ? '*' : '◆') : (ascii ? '#' : '█').repeat(Math.max(1, col(r.end) - a)) - return `${pad(` ${r.name}`, labelWidth)} ${' '.repeat(a)}${cells}` + const cells = r.milestone ? (ascii ? '*' : '◆') : ascii ? '#'.repeat(Math.max(1, col(r.end) - a)) : null + return `${pad(` ${r.name}`, labelWidth)} ${cells === null ? fineBar(((r.start - min) / span) * chart, ((r.end - min) / span) * chart, a) : ' '.repeat(a) + cells}` }) return [...(title ? [title.trim()] : []), lead + labels.join('').trimEnd(), lead + axis.join(''), ...lines].join('\n') } diff --git a/plugins/cc-dazzle/hooks/file-links.test.tsx b/plugins/cc-dazzle/hooks/file-links.test.tsx new file mode 100644 index 0000000..22f05cf --- /dev/null +++ b/plugins/cc-dazzle/hooks/file-links.test.tsx @@ -0,0 +1,448 @@ +import type { On } from 'claude-code' +import type { Engine } from 'claude-code/testing' +import { expect, mock, test } from 'claude-code/testing' + +import { renderBlocks } from './blocks' +import type { TerminalEnv } from './links' +import { drawsHyperlinks } from './links' +import { parse } from './markdown' +import { THEME as t } from './theme' + +// Clickable paths and file links (prismantis #238), the label rule and the launcher rule (#240), and +// the html check redraw, each with its upstream test. WSL's wsl.localhost links are left out. + +const hl = { numbers: true, paths: true } + +const reply = (text: string, columns = 100) => ({ + plugin: 'cc-dazzle', + surface: 'terminal' as const, + component: 'AssistantMessage' as const, + props: { text, isFirstOfReply: true }, + viewport: { columns, rows: 40 }, +}) + +const claudeAt = (on: On, base: string) => on('session.version', () => ({ value: { version: base, base } })) + +// Claude Code 2.1.287 and 2.1.288 refuse a file: href and throw out the whole render. +const linksFiles = (mounting: Promise<{ unmount: () => Promise }>): Promise => + mounting.then(async ui => (await ui.unmount(), true), () => false) + +const PATHS = 'Open /srv/app/main.ts, ~/notes/todo.md or ./docs/guide.md.\n\n- Line 12 of ~/notes/list.md:12\n\n| file | rows |\n|---|---|\n| /srv/data/table.csv | 4 |' + +const PATH_TEXTS = ['/srv/app/main.ts', '~/notes/todo.md', './docs/guide.md.', '~/notes/list.md', '/srv/data/table.csv'] + +const pathColored = async (ui: { findAll: (query: { type: 'Text'; text: RegExp }) => Promise<{ props: { color?: unknown } }[]> }) => { + for (const text of PATH_TEXTS) { + const found = await ui.findAll({ type: 'Text', text: new RegExp(`^${text.replace(/[.]/g, '\\.')}$`) }) + if (!found.some(n => n.props.color === t.path)) return false + } + return true +} + +const filesAt = (on: On, base: string | undefined, env: Record = {}) => { + if (base) claudeAt(on, base) + mock.env(on, { FORCE_HYPERLINK: '1', HOME: '/home/me', ...env }) + on('session.root', () => ({ value: '/work/app' })) +} + +const withoutLinks = (node: unknown): unknown => { + if (node === null || typeof node !== 'object') return node + const { type, props, children } = node as { type?: string; props?: Record; children?: unknown[] } + if (type === 'Link') return props?.label + const { key: _key, ...rest } = props ?? {} + return { type, props: rest, children: children?.map(withoutLinks) } +} + +const hrefs = async (ui: { findAll: (query: { type: 'Link' }) => Promise<{ props: { href?: unknown } }[]> }) => (await ui.findAll({ type: 'Link' })).map(l => String(l.props.href)) + +const SPECIAL = /[.*+?^${}()|[\]\\]/g + +test('paths in a paragraph, a list and a table link to their files', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply(PATHS)) + expect(await hrefs(ui)).toEqual(['/srv/app/main.ts', '/home/me/notes/todo.md', '/work/app/docs/guide.md', '/home/me/notes/list.md', '/srv/data/table.csv'].map(path => `file://${path}`)) + expect(await pathColored(ui)).toBe(true) + await ui.unmount() +}) + +test('file:// markdown links labelled with their path become hyperlinks', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply('[/home/me/notes.md](file:///home/me/notes.md) [/srv/x.log](file://localhost/srv/x.log) [C:\\Users\\me\\x.txt](file:///C:/Users/me/x.txt) [\\\\server\\share\\x.txt](file://server/share/x.txt)')) + expect(await hrefs(ui)).toEqual(['file:///home/me/notes.md', 'file://localhost/srv/x.log', 'file:///C:/Users/me/x.txt', 'file://server/share/x.txt']) + expect((await ui.find({ type: 'Text', text: /^\/home\/me\/notes\.md$/ }))?.props.color).toBe(t.link) + await ui.unmount() +}) + +const LABELS: [label: string, href: string, linked: boolean][] = [ + ['/tmp/notes.md', 'file:///tmp/notes.md', true], + ['file:///tmp/notes.md', 'file:///tmp/notes.md', true], + ['FILE://localhost/tmp/notes.md', 'file:///tmp/notes.md', true], + ['/tmp/notes.md.', 'file:///tmp/notes.md', true], + ['~/notes/todo.md', 'file:///home/me/notes/todo.md', true], + ['./docs/guide.md', 'file:///work/app/docs/guide.md', true], + ['../shared/a.txt', 'file:///work/shared/a.txt', true], + ['/tmp/a b.md', 'file:///tmp/a%20b.md', true], + ['/home/me/', 'file:///home/me', true], + ['/tmp/a.html', 'file:///tmp/a.html#s', true], + ['C:\\Users\\me\\x.txt', 'file:///C:/Users/me/x.txt', true], + ['\\\\server\\share\\x.txt', 'file://server/share/x.txt', true], + ['notes', 'file:///tmp/notes.md', false], + ['notes.md', 'file:///tmp/notes.md', false], + ['the docs', 'file:///tmp/setup.command', false], + ['/tmp/notes.md', 'file:///tmp/setup.command', false], + ['/tmp/notes.md', 'file:///tmp/notes.md/../setup.command', false], + ['~/notes.md', 'file:///tmp/notes.md', false], + ['/srv/share/x.txt', 'file://srv/share/x.txt', false], + ['/tmp/a.html#s', 'file:///tmp/a.html#s', false], + ['/tmp/a%20b.md', 'file:///tmp/a%20b.md', false], +] + +for (const surface of ['terminal', 'desktop'] as const) { + test(`a file:// link stays a link only when its label shows its own path, on ${surface}`, async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount({ ...reply(LABELS.map(([label, href]) => `[${label}](${href})`).join(' and '), 400), surface }) + const linked = LABELS.filter(([, , links]) => links && surface === 'terminal') + expect(await hrefs(ui)).toEqual(linked.map(([, href]) => href)) + const missing: string[] = [] + for (const [label, href] of LABELS.filter(row => !linked.includes(row))) { + const text = label === href ? href : `${label} (${href})` + if (!(await ui.find({ type: 'Text', text: new RegExp(`^${text.replace(SPECIAL, '\\$&')}$`) }))) missing.push(text) + } + expect(missing).toEqual([]) + await ui.unmount() + }) +} + +test('a home folder with a space is percent-encoded in the file link', async ($, on) => { + filesAt(on, '2.1.289', { HOME: '/home/Mara Schwartz' }) + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply('Settings live in ~/.claude/settings.json and ../shared/a+b@2.txt')) + expect(await hrefs(ui)).toEqual(['file:///home/Mara%20Schwartz/.claude/settings.json', 'file:///work/shared/a+b@2.txt']) + await ui.unmount() +}) + +for (const [where, surface, base, env] of [ + ['on Claude Code 2.1.288, which refuses file hyperlinks', 'terminal', '2.1.288', {}], + ['when the Claude Code version is unknown', 'terminal', undefined, {}], + ['on desktop', 'desktop', '2.1.289', {}], + ['in a terminal that draws no hyperlinks', 'terminal', '2.1.289', { FORCE_HYPERLINK: '0' }], + ['in a session over SSH', 'terminal', '2.1.289', { SSH_CONNECTION: '10.0.0.2 52814 10.0.0.9 22' }], +] as const) { + test(`paths stay plain colored text ${where}`, async ($, on) => { + filesAt(on, base, env) + const ui = await $.ui.mount({ ...reply(PATHS), surface }) + expect(await ui.findAll({ type: 'Link' })).toHaveLength(0) + expect(await pathColored(ui)).toBe(true) + await ui.unmount() + }) +} + +test('a terminal that draws no hyperlinks still links file:// markdown links', async ($, on) => { + filesAt(on, '2.1.289', { FORCE_HYPERLINK: '0', TERM_PROGRAM: 'Apple_Terminal' }) + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply('[~/notes.md](file:///home/me/notes.md) and ~/notes/todo.md')) + expect(await hrefs(ui)).toEqual(['file:///home/me/notes.md']) + await ui.unmount() +}) + +for (const env of [{ SSH_CONNECTION: '10.0.0.2 52814 10.0.0.9 22' }, { SSH_TTY: '/dev/pts/3' }] as Record[]) { + test(`file:// links draw as plain text in a session over SSH (${Object.keys(env)[0]})`, async ($, on) => { + filesAt(on, '2.1.289', env) + const ui = await $.ui.mount(reply('[/home/me/notes.md](file:///home/me/notes.md) and [docs](https://example.com)')) + expect(await hrefs(ui)).toEqual(['https://example.com']) + expect(await ui.find({ type: 'Text', text: /^\/home\/me\/notes\.md \(file:\/\/\/home\/me\/notes\.md\)$/ })).toBeDefined() + await ui.unmount() + }) +} + +test('file links on desktop draw as plain text, as before', async ($, on) => { + filesAt(on, '2.1.289') + const ui = await $.ui.mount({ ...reply('[/home/me/notes.md](file:///home/me/notes.md)'), surface: 'desktop' }) + expect(await ui.findAll({ type: 'Link' })).toHaveLength(0) + expect(await ui.find({ type: 'Text', text: /^\/home\/me\/notes\.md \(file:\/\/\/home\/me\/notes\.md\)$/ })).toBeDefined() + await ui.unmount() +}) + +for (const columns of [120, 34]) { + test(`linked paths keep every width, wrap and table column at ${columns} columns`, async ($, on) => { + const source = `${PATHS}\n\n| a long path column | n |\n|---|---|\n| /srv/very/long/folder/name/that/wraps/file.txt | 1 |` + const plain = { theme: t, htmlCopy: null, httpLinks: true, home: '/home/me', cwd: '/work/app' } + const blocks = parse(source, hl) + let drawnPlain: unknown + let drawnLinked: unknown + on('ui.render', ($, e) => { + const el = $.ui.resolve(e) + drawnPlain = renderBlocks(el, plain, blocks, columns) + drawnLinked = renderBlocks(el, { ...plain, fileLinks: true, pathLinks: true }, blocks, columns) + return engine + }) + const ui = await $.ui.mount(reply('')) + await ui.unmount() + const count = (node: unknown): number => (node === null || typeof node !== 'object' ? 0 : ((node as { type?: string }).type === 'Link' ? 1 : 0) + ((node as { children?: unknown[] }).children ?? []).reduce((n, c) => n + count(c), 0)) + expect(count({ children: drawnPlain })).toBe(0) + expect(count({ children: drawnLinked }) > PATH_TEXTS.length).toBe(true) + expect(JSON.stringify(withoutLinks({ children: drawnLinked }))).toBe(JSON.stringify(withoutLinks({ children: drawnPlain }))) + }) +} + +test('a path or link longer than Claude Code draws stays text, and the reply still draws', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const long = `/${'a'.repeat(60)}`.repeat(35) + const web = `https://example.com/${'b'.repeat(2050)}` + const wide = `https://example.com/${'文'.repeat(700)}` + const ui = await $.ui.mount(reply(`see ${long} here\n\n${web}\n\n[wide](${wide}) and ~/short.md`)) + expect(await hrefs(ui)).toEqual(['file:///home/me/short.md']) + expect(await ui.find({ type: 'Text', text: /^wide \(https:\/\/example\.com\/文+\)$/ })).toBeDefined() + await ui.unmount() +}) + +test('a line holding only a tag is never drawn as a path, so it never links', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply('\n- First point, see /tmp/example.\n')) + expect(await hrefs(ui)).toEqual(['file:///tmp/example']) + expect(await ui.find({ type: 'Text', text: /^<\/tldr>$/ })).toBeDefined() + await ui.unmount() +}) + +test('paths link in the terminals Claude Code draws hyperlinks in, and only those', () => { + const cases: [TerminalEnv, boolean][] = [ + [{}, false], + [{ FORCE_HYPERLINK: '1' }, true], + [{ FORCE_HYPERLINK: '0', WT_SESSION: 'x' }, false], + [{ WT_SESSION: '' }, true], + [{ TERM_PROGRAM: 'tmux', TERM_PROGRAM_VERSION: '3.3a' }, false], + [{ TERM_PROGRAM: 'tmux', TERM_PROGRAM_VERSION: '3.4' }, true], + [{ TERM_PROGRAM: 'vscode', TERM_PROGRAM_VERSION: '1.71.2' }, false], + [{ TERM_PROGRAM: 'vscode', TERM_PROGRAM_VERSION: '1.72.0' }, true], + [{ TERM_PROGRAM: 'vscode', TERM_PROGRAM_VERSION: '1.5.0' }, false], + [{ TERM_PROGRAM: 'vscode', TERM_PROGRAM_VERSION: '0.4.1' }, false], + [{ TERM_PROGRAM: 'vscode' }, false], + [{ TERM_PROGRAM: 'WezTerm', TERM_PROGRAM_VERSION: '20240203-110809-5046fc22' }, true], + [{ TERM_PROGRAM: 'WezTerm', TERM_PROGRAM_VERSION: '20200101-000000-0' }, false], + [{ TERM_PROGRAM: 'iTerm.app', TERM_PROGRAM_VERSION: '3.0.0' }, true], + [{ TERM_PROGRAM: 'ghostty' }, true], + [{ TERM_PROGRAM: 'Apple_Terminal' }, false], + [{ VTE_VERSION: '7600' }, true], + [{ VTE_VERSION: '4600' }, false], + [{ VTE_VERSION: '0.50.0' }, false], + [{ LC_TERMINAL: 'iTerm2' }, true], + [{ LC_TERMINAL: 'Konsole' }, false], + [{ TERMINAL_EMULATOR: 'JetBrains-JediTerm' }, true], + [{ TERM: 'xterm-kitty' }, true], + [{ TERM: 'alacritty' }, true], + [{ TERM: 'xterm-256color' }, false], + ] + for (const [env, linked] of cases) expect([env, drawsHyperlinks(env)]).toEqual([env, linked]) +}) + +for (const [version, linked] of [['1.5.0', false], ['1.95.3', true]] as const) { + test(`a VS Code terminal at ${version} ${linked ? 'links' : 'does not link'} paths, as Claude Code decides`, async ($, on) => { + filesAt(on, '2.1.289', { FORCE_HYPERLINK: '', TERM_PROGRAM: 'vscode', TERM_PROGRAM_VERSION: version }) + if (linked && !(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply('Open ~/notes/todo.md')) + expect(await hrefs(ui)).toEqual(linked ? ['file:///home/me/notes/todo.md'] : []) + await ui.unmount() + }) +} + +test('the link check reads the environment once, however many replies draw', async ($, on) => { + claudeAt(on, '2.1.289') + const reads: string[] = [] + const env: Record = { FORCE_HYPERLINK: '1', HOME: '/home/me' } + on('env.get', (_, e) => { + reads.push(e.name) + return { value: env[e.name] } + }) + for (const text of ['one', 'two', 'three']) { + const ui = await $.ui.mount(reply(text)) + await ui.unmount() + } + for (const name of ['FORCE_HYPERLINK', 'TERM_PROGRAM_VERSION', 'LC_TERMINAL', 'TERMINAL_EMULATOR', 'SSH_TTY']) expect([name, reads.filter(n => n === name).length]).toEqual([name, 1]) +}) + +const PROGRAMS = [ + '/Applications/Calculator.app', '/tmp/run/x.command', '/tmp/run/x.exe', '/tmp/run/x.bat', '/tmp/run/x.cmd', '/tmp/run/x.ps1', '/tmp/run/x.vbs', '/tmp/run/x.js', + '/tmp/run/x.hta', '/tmp/run/x.lnk', '/tmp/run/x.desktop', '/tmp/run/x.sh', '/tmp/run/x.msi', '/tmp/run/x.scr', '/tmp/run/x.jar', '/tmp/run/x.py', '/tmp/run/x.url', + '/tmp/run/x.AppImage', '/tmp/run/X.EXE', '/tmp/run/Setup.Command', '/tmp/run/x.Sh', '/tmp/run/x.pdf.exe', '/Applications/Calculator.app/', '/Applications/Calculator.app/Contents/MacOS/Calculator', +] + +const DOCUMENTS = ['/tmp/doc/a.ts', '/tmp/doc/a.md', '/tmp/doc/a.json', '/tmp/doc/a.png', '/tmp/doc/a.txt', '/tmp/doc/a.html', '/tmp/doc/a.exe.txt', '/tmp/doc/scripts/', '/tmp/chart.js/README.md'] + +const toolCall = (tool: string, input: unknown, id: string) => ({ tool_use_id: id, tool, input, isRunning: false, isErrored: false, isInterrupted: false }) + +const linkPairs = async (ui: { findAll: (query: { type: 'Link' }) => Promise<{ props: { label?: unknown; href?: unknown } }[]> }) => + (await ui.findAll({ type: 'Link' })).map(l => [l.props.label, l.props.href]) + +for (const surface of ['terminal', 'desktop'] as const) { + test(`paths to programs and launchers stay plain colored text on ${surface}, while documents link`, async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount({ ...reply([...PROGRAMS, ...DOCUMENTS].join(' and '), 1000), surface }) + expect((await ui.findAll({ type: 'Link' })).map(l => l.props.label)).toEqual(surface === 'terminal' ? DOCUMENTS : []) + for (const path of PROGRAMS) expect([path, (await ui.find({ type: 'Text', text: new RegExp(`^${path.replace(/[.]/g, '\\$&')}$`) }))?.props.color]).toEqual([path, t.path]) + await ui.unmount() + }) + + test(`file:// links to programs and launchers draw as text on ${surface}, whatever their case, slash, query, fragment or encoding`, async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const programs = ['file:///tmp/x.exe?y=1', 'file:///tmp/x.EXE#top', 'file:///Applications/Calculator.app/', 'file:///tmp/x%2Ecommand', 'file:///C:/x.exe.', 'file:///C:/x.exe::$DATA', 'file:///tmp/x.exe%00.txt', 'file://server/share/setup.msi'] + const documents = ['file:///tmp/a.html#x.exe', 'file:///tmp/report.md?open=x.exe', 'file:///tmp/x.exe.txt'] + const ui = await $.ui.mount({ ...reply([...programs, ...documents].map(href => `[${href}](${href})`).join(' and '), 1000), surface }) + expect(await hrefs(ui)).toEqual(surface === 'terminal' ? documents : []) + for (const href of programs) expect([href, await ui.find({ type: 'Text', text: new RegExp(`^${href.replace(/[.?$]/g, '\\$&')}$`) }) !== undefined]).toEqual([href, true]) + await ui.unmount() + }) +} + +const TARGETS: [href: string, linkedWithFileLinks: boolean][] = [ + ['https://a.dev', true], + ['file:///Applications/Calculator.app', false], + ['FILE:///C:/Windows/System32/cmd.exe', false], + ['file://evil.example/share/x.exe', false], + ['file:///tmp/notes.md', true], + ['smb://host/share', false], + ['ms-msdt:/id', false], + ['javascript:alert(1)', false], +] + +const everyBlock = (links: string) => `${links}\n\n> ${links}\n\n> [!NOTE]\n> ${links}\n\n- x\n - ${links}\n\n| a |\n|---|\n| ${links} |\n\nn[^a]\n\n[^a]: ${links}` + +const targetLinks = TARGETS.map(([href]) => `[${href}](${href})`).join(' ') + +test('only web links in a reply are clickable, in every block, while file links are off', async $ => { + const ui = await $.ui.mount(reply(everyBlock(targetLinks))) + const found = await hrefs(ui) + expect(found.length).toBe(6) + expect(found.filter(h => h !== 'https://a.dev')).toEqual([]) + await ui.unmount() +}) + +test('with file links on, only web links and file links labelled with their own address are clickable, as written, in every block', async ($, on) => { + claudeAt(on, '2.1.289') + mock.env(on, { FORCE_HYPERLINK: '1', HOME: '/home/me' }) + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply(everyBlock(targetLinks))) + const allowed = TARGETS.filter(([, linked]) => linked).map(([href]) => href) + expect(await hrefs(ui)).toEqual(Array.from({ length: 6 }, () => allowed).flat()) + await ui.unmount() +}) + +test('with file links on, a file link named anything but its own path draws as text with its target, in every block', async ($, on) => { + claudeAt(on, '2.1.289') + mock.env(on, { FORCE_HYPERLINK: '1', HOME: '/home/me' }) + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const ui = await $.ui.mount(reply(everyBlock(TARGETS.map(([href], i) => `[t${i}](${href})`).join(' ')))) + expect(await hrefs(ui)).toEqual(Array.from({ length: 6 }, () => 'https://a.dev')) + expect(await ui.find({ type: 'Text', text: /^t1 \(file:\/\/\/Applications\/Calculator\.app\)$/ })).toBeDefined() + await ui.unmount() +}) + +const toolLinks = async ($: Engine, tool: string, input: unknown, surface: 'terminal' | 'desktop' = 'terminal') => { + const ui = await $.ui.mount({ plugin: 'cc-dazzle', surface, component: 'ToolUse', props: toolCall(tool, input, `t-${tool}-${surface}`) }) + const links = await linkPairs(ui) + await ui.unmount() + return links +} + +test('file paths in tool rows link to the whole file', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + expect(await toolLinks($, 'Read', { file_path: '/work/app/src/main.ts' })).toEqual([['src/main.ts', 'file:///work/app/src/main.ts']]) + expect(await toolLinks($, 'Edit', { file_path: '/home/me/notes/todo.md' })).toEqual([['~/notes/todo.md', 'file:///home/me/notes/todo.md']]) + expect(await toolLinks($, 'Grep', { pattern: 'TODO', path: 'src' })).toEqual([['src', 'file:///work/app/src']]) + expect(await toolLinks($, 'Glob', { pattern: '*.ts' })).toEqual([]) + expect(await toolLinks($, 'Bash', { command: 'cat /srv/app/main.ts' })).toEqual([]) + expect(await toolLinks($, 'mcp__github__get_file_contents', { owner: 'o', repo: 'r', path: 'README.md' })).toEqual([]) +}) + +test('a tool group links the path of its last call', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const calls = [toolCall('Read', { file_path: '/work/app/a.ts' }, 'g-1'), toolCall('Read', { file_path: '/work/app/b.ts' }, 'g-2')] + const ui = await $.ui.mount({ plugin: 'cc-dazzle', surface: 'terminal', component: 'ToolGroup', props: { calls, isActive: false, isExpanded: false } }) + expect(await linkPairs(ui)).toEqual([['b.ts', 'file:///work/app/b.ts']]) + await ui.unmount() +}) + +test('Windows paths in tool rows link with forward slashes, and UNC paths name their server', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + expect(await toolLinks($, 'Grep', { pattern: 'x', path: 'src\\hooks' })).toEqual([['src\\hooks', 'file:///work/app/src/hooks']]) + expect(await toolLinks($, 'Read', { file_path: 'C:\\Users\\me\\x.ts' })).toEqual([['C:\\Users\\me\\x.ts', 'file:///C:/Users/me/x.ts']]) + expect(await toolLinks($, 'Read', { file_path: '\\\\server\\share\\x.txt' })).toEqual([['\\\\server\\share\\x.txt', 'file://server/share/x.txt']]) +}) + +test('a tool path whose link would pass 2048 characters stays text, and the row still draws', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + expect(await toolLinks($, 'Read', { file_path: `/work/app/${'文档/'.repeat(110)}说明.md` })).toEqual([]) + expect(await toolLinks($, 'Read', { file_path: '/work/app/a b#c?d%e é.ts' })).toEqual([['a b#c?d%e é.ts', 'file:///work/app/a%20b%23c%3Fd%25e%20%C3%A9.ts']]) +}) + +for (const [where, surface, base, env] of [ + ['on desktop', 'desktop', '2.1.289', {}], + ['on Claude Code 2.1.288', 'terminal', '2.1.288', {}], + ['in a terminal that draws no hyperlinks', 'terminal', '2.1.289', { FORCE_HYPERLINK: '0' }], + ['in a session over SSH', 'terminal', '2.1.289', { SSH_TTY: '/dev/pts/3' }], +] as const) { + test(`tool rows keep plain paths ${where}`, async ($, on) => { + filesAt(on, base, env) + const ui = await $.ui.mount({ plugin: 'cc-dazzle', surface, component: 'ToolUse', props: toolCall('Read', { file_path: '/work/app/src/main.ts' }, 'p-1') }) + expect(await ui.findAll({ type: 'Link' })).toHaveLength(0) + expect((await ui.find({ type: 'Text', text: /^src\/main\.ts$/ }))?.props.color).toBe(t.path) + await ui.unmount() + }) +} + +for (const surface of ['terminal', 'desktop'] as const) { + test(`tool rows keep programs and launchers as plain colored text on ${surface}`, async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + expect(await toolLinks($, 'Read', { file_path: '/work/app/install.sh' }, surface)).toEqual([]) + expect(await toolLinks($, 'Edit', { file_path: 'C:\\Users\\me\\run.PS1' }, surface)).toEqual([]) + expect(await toolLinks($, 'Read', { file_path: '\\\\server\\share\\setup.exe' }, surface)).toEqual([]) + expect(await toolLinks($, 'Grep', { pattern: 'x', path: '/Applications/Calculator.app/' }, surface)).toEqual([]) + expect(await toolLinks($, 'Read', { file_path: '/work/app/src/app.ts' }, surface)).toEqual(surface === 'terminal' ? [['src/app.ts', 'file:///work/app/src/app.ts']] : []) + }) +} + +test('shell edit cards link their file headers, and never a program', async ($, on) => { + filesAt(on, '2.1.289') + if (!(await linksFiles($.ui.mount(reply('[/x](file:///x)'))))) return + const edit = (path: string) => ({ stdout: '', stderr: '', interrupted: false, bashEditDiff: { files: [{ filePath: path, hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-one', '+two'] }] }], moreFiles: 0 } }) + const card = (id: string, path: string) => $.ui.mount({ plugin: 'cc-dazzle', surface: 'terminal', component: 'ToolResult', props: { tool_use_id: id, tool: 'Bash', output: edit(path), isErrored: false } }) + const ui = await card('be-1', '/work/app/src/app.ts') + expect(await linkPairs(ui)).toEqual([['src/app.ts', 'file:///work/app/src/app.ts']]) + expect((await ui.find({ type: 'Text', text: /^src\/app\.ts $/ }))?.props.color).toBe(t.path) + await ui.unmount() + const script = await card('be-2', '/work/app/build.sh') + expect(await script.findAll({ type: 'Link' })).toHaveLength(0) + expect((await script.find({ type: 'Text', text: /^build\.sh $/ }))?.props.color).toBe(t.path) + await script.unmount() +}) + +test('a table drawn before the html check lands gains ⧉ html when it lands', async ($, on) => { + let release = () => {} + const held = new Promise(resolve => { release = resolve }) + mock.env(on, {}) + on('fs.stat', async (_, e, next) => { + if (!e.path.replace(/\\/g, '/').endsWith('/usr/bin/osascript')) return next(e) + await held + return { value: { kind: 'file' as const, size: 0, mtimeMs: 0, isLink: false } } + }) + on('session.start', () => ({ cwd: '/tmp' })) + const ui = await $.ui.mount(reply('| Name | Value |\n|--|--:|\n| api | 2 |')) + const labels = async () => (await ui.findAll({ type: 'Button' })).map(button => button.props.label) + const started = $.session.start({ cwd: '/tmp', surface: 'terminal', isInteractive: true }) + expect(await labels()).toEqual(['⧉ md', '⧉ art']) + release() + await started + expect(await labels()).toEqual(['⧉ md', '⧉ art', '⧉ html']) + await ui.unmount() +}) diff --git a/plugins/cc-dazzle/hooks/files.test.tsx b/plugins/cc-dazzle/hooks/files.test.tsx new file mode 100644 index 0000000..daf33e7 --- /dev/null +++ b/plugins/cc-dazzle/hooks/files.test.tsx @@ -0,0 +1,249 @@ +import type { On } from 'claude-code' +import { expect, mock, test } from 'claude-code/testing' + +import { CLAUDE_DIFF, THEME as t } from './theme' +import { bashEdits, diffLines } from './tools' + +// Diff and file cards ported from upstream prismantis 0.32.0 (#234, #236), each with its upstream test. + +const engine = (on: On) => + on('ui.render', ($, e) => { + const { Text } = $.ui.resolve(e) + return engine + }) + +const result = (tool: string, output: unknown, id = 'x1') => + ({ plugin: 'cc-dazzle', surface: 'terminal' as const, component: 'ToolResult' as const, requestId: id, props: { tool_use_id: id, tool, output, isErrored: false }, viewport: { columns: 100, rows: 40 } }) + +type Drawn = { type?: string; props?: Record; children?: unknown[] } +const nodeCount = (n: unknown): number => + n == null || n === false ? 0 : Array.isArray(n) ? n.reduce((a: number, c) => a + nodeCount(c), 0) : typeof n !== 'object' ? 1 : 1 + nodeCount((n as Drawn).children) + +const rowsOf = async (ui: { findAll: (q: object) => Promise<{ key?: string; text?: string }[]> }) => + (await ui.findAll({ type: 'Box' })).filter(b => /^d\d+$/.test(b.key ?? '')).map(b => b.text) + +const twoHunks = [ + { oldStart: 4, newStart: 4, oldLines: 3, newLines: 3, lines: [' const a = 3', '-const b = 4', '+const b = 5', ' const c = 6'] }, + { oldStart: 50, newStart: 50, oldLines: 3, newLines: 3, lines: [' const x = 50', '-const y = 51', '+const y = 52', ' const z = 52'] }, +] + +test('separate hunks are parted by a dim ... row, in edit and shell edit cards', async ($, on) => { + engine(on) + const bash = { stdout: '', stderr: '', interrupted: false, bashEditDiff: { files: [{ filePath: '/tmp/far.ts', hunks: twoHunks }], moreFiles: 0 } } + for (const [tool, output] of [['Edit', { filePath: '/tmp/far.ts', structuredPatch: twoHunks }], ['Bash', bash]] as const) { + const ui = await $.ui.mount(result(tool, output)) + const drawn = (await ui.findAll({})).filter(n => /^d\d+$/.test(n.key ?? '') || (n.type === 'Text' && /^\.\.\.$/.test(n.text))) + expect(drawn.map(n => n.key ?? n.text)).toEqual(['d0', 'd1', 'd2', 'd3', '...', 'd4', 'd5', 'd6', 'd7']) + expect(drawn[4]!.props.dimColor).toBe(true) + expect(drawn[5]!.text).toMatch(/^50 const x = 50$/) + await ui.unmount() + } + const one = await $.ui.mount(result('Edit', { filePath: '/tmp/far.ts', structuredPatch: twoHunks.slice(0, 1) })) + expect((await one.findAll({ type: 'Text', text: /^\.\.\.$/ })).length).toBe(0) + await one.unmount() +}) + +const noNewline = { filePath: '/tmp/version.py', structuredPatch: [{ oldStart: 1, newStart: 1, lines: [' NAME = "tank"', '-VERSION = "1.0"', '\\ No newline at end of file', '+VERSION = "1.1"', '\\ No newline at end of file'] }] } + +test('a diff keeps the no-newline marker as a row of its own that numbers nothing', () => { + expect(diffLines(noNewline)?.map(r => `${r.num}${r.mark}${r.text}`)).toEqual(['1 NAME = "tank"', '2-VERSION = "1.0"', '0\\ No newline at end of file', '2+VERSION = "1.1"', '0\\ No newline at end of file']) +}) + +test('the no-newline marker draws plain and untinted, with no number or mark, and the lines around it still pair up for word marks', async ($, on) => { + engine(on) + const ui = await $.ui.mount(result('Edit', noNewline)) + const rows = (await ui.findAll({ type: 'Box' })).filter(b => /^d\d+$/.test(b.key ?? '')) + expect(rows.map(r => r.text)).toEqual(['1 NAME = "tank"', '2 - VERSION = "1.0"', ' No newline at end of file', '2 + VERSION = "1.1"', ' No newline at end of file']) + expect(rows.map(r => r.props.backgroundColor)).toEqual([undefined, CLAUDE_DIFF.dark.delBg, undefined, CLAUDE_DIFF.dark.addBg, undefined]) + const note = await ui.find({ type: 'Text', text: /^ No newline at end of file$/ }) + expect(note?.props.color).toBe(t.codeText) + expect(note?.props.dimColor).not.toBe(true) + expect((await ui.find({ type: 'Text', text: /^1$/, props: { backgroundColor: CLAUDE_DIFF.dark.addWord } }))).toBeDefined() + await ui.unmount() +}) + +test('no-newline markers do not count as lines toward the fold', async ($, on) => { + engine(on) + const marker = '\\ No newline at end of file' + const added = (n: number) => Array.from({ length: n }, (_, i) => `+line ${i + 1}`) + const fits = await $.ui.mount(result('Edit', { filePath: '/tmp/n.txt', structuredPatch: [{ oldStart: 1, newStart: 1, lines: [...added(20), marker] }] })) + expect(await fits.find({ key: 'fold' })).toBeUndefined() + expect(await fits.find({ type: 'Text', text: /^ No newline at end of file$/ })).toBeDefined() + await fits.unmount() + const long = await $.ui.mount(result('Edit', { filePath: '/tmp/n.txt', structuredPatch: [{ oldStart: 1, newStart: 1, lines: [...added(25), marker] }] })) + expect((await long.find({ key: 'fold' }))?.props.label).toBe('+5 more lines') + expect(await long.find({ type: 'Text', text: /^line 20$/ })).toBeDefined() + expect(await long.find({ type: 'Text', text: /^line 21$/ })).toBeUndefined() + await long.unmount() +}) + +test('a comment spanning lines keeps its color on every added and context line, removed lines staying plain', async ($, on) => { + engine(on) + const ui = await $.ui.mount(result('Edit', { filePath: '/tmp/a.ts', structuredPatch: [{ oldStart: 1, newStart: 1, lines: [' /* open', '+new const', ' close */', '-old const'] }] })) + expect((await ui.find({ type: 'Text', text: /^new const$/ }))?.props.color).toBe(t.codeComment) + expect((await ui.find({ type: 'Text', text: /^close \*\/$/ }))?.props.color).toBe(t.codeComment) + expect((await ui.find({ type: 'Text', text: /^old const$/ }))?.props.color).toBe(t.codeText) + await ui.unmount() +}) + +test('separate hunks are highlighted apart, so one hunk\'s open string cannot color the next', async ($, on) => { + engine(on) + const hunks = [{ oldStart: 1, newStart: 1, lines: ['+const s = `open'] }, { oldStart: 40, newStart: 40, lines: ['+const n = 1'] }] + const ui = await $.ui.mount(result('Edit', { filePath: '/tmp/a.ts', structuredPatch: hunks })) + expect((await ui.find({ type: 'Text', text: /^const$/ }))?.props.color).toBe(t.codeFlag) + expect((await ui.findAll({ type: 'Text', text: /^const$/ })).length).toBe(2) + await ui.unmount() +}) + +const dense = (n: number) => Array.from({ length: n }, (_, i) => `+{${Array.from({ length: 12 }, (_, k) => `"k${k}": ${i * 12 + k}`).join(', ')}}`) + +test('an opened card of dense lines stays under the engine\'s 20000-node ceiling, drawing the lines past its node allowance plain', async ($, on) => { + engine(on) + const ui = await $.ui.mount(result('Edit', { filePath: '/tmp/data.json', structuredPatch: [{ oldStart: 1, newStart: 1, lines: dense(450) }] })) + await ui.press({ key: 'fold' }) + expect(await ui.find({ type: 'Text', text: /^engine$/ })).toBeUndefined() + expect(nodeCount(await ui.drawn())).toBeLessThan(20000) + expect(await ui.find({ type: 'Text', text: /^"k0"$/ })).toBeDefined() + expect((await ui.find({ type: 'Text', text: /^\{"k0": 4788, / }))?.children).toHaveLength(1) + await ui.unmount() +}) + +test('five opened shell edit cards of dense lines share one node allowance and stay under the ceiling', async ($, on) => { + engine(on) + const short = Array.from({ length: 450 }, (_, i) => `+[${Array.from({ length: 12 }, (_, k) => (i + k) % 10).join(',')}]`) + const files = Array.from({ length: 5 }, (_, f) => ({ filePath: `/tmp/d${f}.json`, hunks: [{ oldStart: 1, newStart: 1, oldLines: 0, newLines: 450, lines: short }] })) + const ui = await $.ui.mount(result('Bash', { stdout: '', stderr: '', interrupted: false, bashEditDiff: { files, moreFiles: 0 } })) + for (let f = 0; f < 5; f++) await ui.press({ key: `fold${f}` }) + expect(await ui.find({ type: 'Text', text: /^engine$/ })).toBeUndefined() + expect(nodeCount(await ui.drawn())).toBeLessThan(20000) + await ui.unmount() +}) + +test('carriage returns and other control characters never reach a diff row or a preview', async ($, on) => { + engine(on) + const ui = await $.ui.mount(result('Edit', { filePath: '/tmp/crlf.txt', structuredPatch: [{ oldStart: 1, newStart: 1, lines: ['-a\r', '+b\u001b[31m\r'] }] })) + expect(await rowsOf(ui)).toEqual(['1 - a', '1 + b']) + await ui.unmount() + const write = await $.ui.mount(result('Write', { type: 'create', filePath: '/tmp/crlf.txt', content: 'one\r\ntwo\u0007\r\n', structuredPatch: [], originalFile: null })) + expect(await rowsOf(write)).toEqual(['1 one', '2 two']) + await write.unmount() +}) + +const ts = (n: number) => Array.from({ length: n }, (_, i) => `export const n${i + 1} = ${i + 1}`) +const writeNew = (filePath: string, body: string[]) => ({ type: 'create', filePath, content: body.length ? `${body.join('\n')}\n` : '', structuredPatch: [], originalFile: null }) + +test('a file Write creates shows its first 10 lines as numbered, highlighted code with no diff marks, and opens to the rest', async ($, on) => { + engine(on) + const ui = await $.ui.mount(result('Write', writeNew('/tmp/new.ts', ts(25)))) + expect(await ui.find({ type: 'Text', text: /^Created \/tmp\/new\.ts \(25 lines\)$/ })).toBeDefined() + const rows = await rowsOf(ui) + expect(rows).toHaveLength(10) + expect(rows[0]).toBe(' 1 export const n1 = 1') + expect((await ui.findAll({ type: 'Box' })).filter(b => /^d\d+$/.test(b.key ?? '')).every(b => b.props.backgroundColor === undefined)).toBe(true) + expect((await ui.find({ type: 'Text', text: /^export$/ }))?.props.color).toBe(t.codeFlag) + expect((await ui.find({ key: 'fold' }))?.props.label).toBe('+15 more lines') + await ui.press({ key: 'fold' }) + expect(await rowsOf(ui)).toHaveLength(25) + await ui.unmount() +}) + +test('an empty file Write creates says so, with no lines and no button', async ($, on) => { + engine(on) + const ui = await $.ui.mount(result('Write', writeNew('/tmp/empty.ts', []))) + expect(await ui.find({ type: 'Text', text: /^Created \/tmp\/empty\.ts \(empty\)$/ })).toBeDefined() + expect(await rowsOf(ui)).toEqual([]) + expect(await ui.find({ type: 'Button' })).toBeUndefined() + await ui.unmount() +}) + +test('a write or edit held for review goes to the engine', async ($, on) => { + engine(on) + for (const [tool, output] of [['Write', { ...writeNew('/tmp/held.ts', ts(3)), staged: true }], ['Edit', { filePath: '/tmp/a.ts', structuredPatch: twoHunks, staged: true }]] as const) { + const ui = await $.ui.mount(result(tool, output)) + expect(await ui.find({ type: 'Text', text: /^engine$/ })).toBeDefined() + await ui.unmount() + } +}) + +const editMade = (filePath: string, body: string[]) => + ({ filePath, oldString: '', newString: body.join('\n'), originalFile: null as string | null, structuredPatch: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: body.length, lines: body.map(l => `+${l}`) }] }) + +test('an Edit that makes a file is a new file with added lines, also with no newline at its end', async ($, on) => { + engine(on) + const made = editMade('/tmp/made.ts', ['a', 'b']) + made.structuredPatch[0]!.lines.push('\\ No newline at end of file') + const ui = await $.ui.mount(result('Edit', made)) + expect(await ui.find({ type: 'Text', text: /^Created \/tmp\/made\.ts \(2 lines\)$/ })).toBeDefined() + expect(await rowsOf(ui)).toEqual(['1 + a', '2 + b']) + expect(await ui.find({ type: 'Text', text: /^\+2$/ })).toBeUndefined() + await ui.unmount() +}) + +test('an Edit into a file that had text stays a diff card, even when it only adds lines', async ($, on) => { + engine(on) + const ui = await $.ui.mount(result('Edit', { ...editMade('/tmp/kept.ts', ['b']), oldString: 'a', originalFile: 'a\n' })) + expect(await ui.find({ type: 'Text', text: /^\+1$/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^Created / })).toBeUndefined() + await ui.unmount() +}) + +const madeFile = (name: string, body: string[]) => + ({ filePath: `/home/demo/app/${name}`, hunks: body.length ? [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: body.length, lines: body.map(l => `+${l}`) }] : [], created: true as const }) +const removedFile = (name: string, body: string[]) => + ({ filePath: `/home/demo/app/${name}`, hunks: body.length ? [{ oldStart: 1, oldLines: body.length, newStart: 0, newLines: 0, lines: body.map(l => `-${l}`) }] : [], deleted: true as const }) +const shellFiles = (...files: unknown[]) => ({ stdout: '', stderr: '', interrupted: false, bashEditDiff: { files, moreFiles: 0 } }) +const inDemoApp = (on: On) => { + mock.env(on, { HOME: '/home/demo' }) + on('session.root', () => ({ value: '/home/demo/app' })) +} +const numbered = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`) + +test('shell edits tell new, removed and renamed files apart from changed ones', () => { + const kinds = (...files: unknown[]) => bashEdits(shellFiles(...files))?.files.map(f => (f.kind === 'renamed' ? `${f.path} → ${f.to}` : `${f.kind} ${f.path}`)) + expect(kinds(removedFile('a.txt', ['one', 'two']), madeFile('b.txt', ['one', 'two']))).toEqual(['/home/demo/app/a.txt → /home/demo/app/b.txt']) + expect(kinds(madeFile('b.txt', ['one']), removedFile('a.txt', ['one']))).toEqual(['/home/demo/app/a.txt → /home/demo/app/b.txt']) + expect(kinds(removedFile('a.txt', ['one']), madeFile('b.txt', ['one', 'two']))).toEqual(['deleted /home/demo/app/a.txt', 'created /home/demo/app/b.txt']) + expect(kinds(removedFile('a.txt', ['one']), madeFile('b.txt', ['one']), madeFile('c.txt', ['one']))).toEqual(['/home/demo/app/a.txt → /home/demo/app/b.txt', 'created /home/demo/app/c.txt']) + expect(kinds(madeFile('empty.txt', []), { filePath: '/home/demo/app/src/app.ts', hunks: twoHunks })).toEqual(['created /home/demo/app/empty.txt', 'modified /home/demo/app/src/app.ts']) + expect(bashEdits(shellFiles(removedFile('old.txt', ['alpha', 'beta'])))?.files).toEqual([{ kind: 'deleted', path: '/home/demo/app/old.txt', lines: ['alpha', 'beta'] }]) + expect(kinds(removedFile('pkg_a/__init__.py', []), madeFile('pkg_b/__init__.py', []))).toEqual(['deleted /home/demo/app/pkg_a/__init__.py', 'created /home/demo/app/pkg_b/__init__.py']) +}) + +test('a file a shell command creates shows its first 10 lines as added diff lines under a Created header', async ($, on) => { + engine(on) + inDemoApp(on) + const ui = await $.ui.mount(result('Bash', shellFiles(madeFile('notes.py', numbered(14))))) + expect(await ui.find({ type: 'Text', text: /^engine$/ })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: /^Created notes\.py \(14 lines\)$/ })).toBeDefined() + const rows = await rowsOf(ui) + expect(rows).toHaveLength(10) + expect(rows[0]).toBe(' 1 + line 1') + expect((await ui.find({ key: 'fold0' }))?.props.label).toBe('+4 more lines') + await ui.press({ key: 'fold0' }) + expect(await rowsOf(ui)).toHaveLength(14) + await ui.unmount() +}) + +test('a file a shell command deletes is one Deleted line that opens to its removed lines', async ($, on) => { + engine(on) + inDemoApp(on) + const ui = await $.ui.mount(result('Bash', shellFiles(removedFile('old.txt', ['alpha', 'beta'])))) + expect(await ui.find({ type: 'Text', text: /^Deleted old\.txt \(2 lines\)$/ })).toBeDefined() + expect(await rowsOf(ui)).toEqual([]) + expect((await ui.find({ key: 'fold0' }))?.props.label).toBe('show 2 lines') + await ui.press({ key: 'fold0' }) + expect(await rowsOf(ui)).toEqual(['1 - alpha', '2 - beta']) + expect((await ui.find({ key: 'fold0' }))?.props.label).toBe('show less') + await ui.unmount() +}) + +test('a file moved unchanged is one Renamed line, an empty new file says so, and changed files beside them keep their card', async ($, on) => { + engine(on) + inDemoApp(on) + const ui = await $.ui.mount(result('Bash', shellFiles(removedFile('a.txt', ['one']), madeFile('b/a.txt', ['one']), madeFile('empty.txt', []), { filePath: '/home/demo/app/src/far.ts', hunks: twoHunks }))) + expect(await ui.find({ type: 'Text', text: /^Renamed a\.txt → b\/a\.txt$/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^Created empty\.txt \(empty\)$/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^src\/far\.ts $/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^engine$/ })).toBeUndefined() + await ui.unmount() +}) diff --git a/plugins/cc-dazzle/hooks/links.test.tsx b/plugins/cc-dazzle/hooks/links.test.tsx index 1ad3f3b..3d55029 100644 --- a/plugins/cc-dazzle/hooks/links.test.tsx +++ b/plugins/cc-dazzle/hooks/links.test.tsx @@ -23,10 +23,11 @@ const engine = (on: On) => return engine }) -test('only http and https links become terminal hyperlinks', async ($, on) => { +test('only http, https and file links become terminal hyperlinks', async ($, on) => { claudeAt(on, '2.1.289') - const ui = await $.ui.mount(reply('[docs](https://example.com) [dev](http://localhost:3000) [plain](http://example.com) [mail](mailto:a@example.com) [run](javascript:alert(1)) [disk](file:///etc/passwd)')) - expect((await ui.findAll({ type: 'Link' })).map(l => l.props.href)).toEqual(['https://example.com', 'http://localhost:3000', 'http://example.com']) + mock.env(on, {}) + const ui = await $.ui.mount(reply('[docs](https://example.com) [dev](http://localhost:3000) [plain](http://example.com) [mail](mailto:a@example.com) [run](javascript:alert(1)) [/etc/passwd](file:///etc/passwd)')) + expect((await ui.findAll({ type: 'Link' })).map(l => l.props.href)).toEqual(['https://example.com', 'http://localhost:3000', 'http://example.com', 'file:///etc/passwd']) expect(await ui.find({ type: 'Text', text: /^run \(javascript:alert\(1\)\)$/ })).toBeDefined() await ui.unmount() }) @@ -124,10 +125,10 @@ const shellEdit = (id: string, output: unknown, isErrored = false, surface: 'ter ({ plugin: 'cc-dazzle', surface, component: 'ToolResult' as const, props: { tool_use_id: id, tool: 'Bash', output, isErrored } }) const said = (text: string) => ({ type: 'Text' as const, text: `(${text})` }) -test('shell edits read one diff per file and a count of files not shown', () => { - expect(bashEdits(bashEdit('', 3))?.files.map(f => [f.path, f.rows.map(r => `${r.num}${r.mark}${r.text}`)])).toEqual([ +test('shell edits read a diff per changed file, the lines of each new file, and a count of files not shown', () => { + expect(bashEdits(bashEdit('', 3))?.files.map(f => (f.kind === 'modified' ? [f.path, f.rows.map(r => `${r.num}${r.mark}${r.text}`)] : f))).toEqual([ ['/home/demo/app/src/app.ts', ['4 const a = 1', '5-const b = 2', '5+const b = 3', '6+const c = 4']], - ['/home/demo/app/NOTES.md', ['1+one', '2+two']], + { kind: 'created', path: '/home/demo/app/NOTES.md', lines: ['one', 'two'] }, ]) expect(bashEdits(bashEdit('', 3))?.more).toBe(3) expect(bashEdits({ stdout: 'x' })).toBeUndefined() @@ -137,13 +138,15 @@ test('shell edits read one diff per file and a count of files not shown', () => expect(withoutBashEdits(bashEdit('hi'))).toEqual({ stdout: 'hi', stderr: '', interrupted: false }) }) -test('shell edits draw the same diff card as the Edit tool, one per file', async ($, on) => { +test('shell edits draw the same diff card as the Edit tool, one per changed file', async ($, on) => { engine(on) mock.env(on, { HOME: '/home/demo' }) on('session.root', () => ({ value: '/home/demo/app' })) const ui = await $.ui.mount(shellEdit('s-1', bashEdit('', 2))) expect((await ui.find({ type: 'Text', text: /^src\/app\.ts $/ }))?.props.color).toBe(t.path) - expect((await ui.find({ type: 'Text', text: /^NOTES\.md $/ }))?.props.color).toBe(t.path) + expect(await ui.find({ type: 'Text', text: /^Created NOTES\.md \(2 lines\)$/ })).toBeDefined() + expect((await ui.find({ type: 'Text', text: /^NOTES\.md$/ }))?.props.color).toBe(t.path) + expect((await ui.find({ type: 'Box', text: /^2 \+ two$/ }))?.props.backgroundColor).toBe(CLAUDE_DIFF.dark.addBg) expect((await ui.find({ type: 'Text', text: /^\+2$/ }))?.props.color).toBe(CLAUDE_DIFF.dark.addMark) expect((await ui.find({ type: 'Text', text: /^−1$/ }))?.props.color).toBe(CLAUDE_DIFF.dark.delMark) expect((await ui.find({ type: 'Text', text: /^… \+2 more files$/ }))?.props.dimColor).toBe(true) diff --git a/plugins/cc-dazzle/hooks/links.ts b/plugins/cc-dazzle/hooks/links.ts new file mode 100644 index 0000000..08027ee --- /dev/null +++ b/plugins/cc-dazzle/hooks/links.ts @@ -0,0 +1,182 @@ +import type { Block, Inline } from './markdown' +import type { Style } from './theme' + +// Which links and paths draw as clickable hyperlinks (prismantis #238, #240). A click on a file link +// hands its target to the operating system, outside Claude Code's permission prompts. + +type Reach = Pick + +export type TerminalEnv = Partial> + +const HYPERLINK_TERMINALS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2', 'WarpTerminal'] + +const versionOf = (value = ''): number[] => { + const short = /^(\d{1,2})(\d{2})$/.exec(value) + return short ? [0, Number(short[1])] : value.split('.').map(n => Number.parseInt(n, 10)) +} + +const atLeast = (value: string | undefined, major: number, minor: number): boolean => { + const [a = Number.NaN, b = Number.NaN] = versionOf(value) + return a > major || (a === major && b >= minor) +} + +const programLinks = (program: string, version: string | undefined): boolean => + HYPERLINK_TERMINALS.includes(program) + || (program === 'WezTerm' && atLeast(version, 20200620, 0)) + || (program === 'vscode' && atLeast(version, 1, 72)) + || (program === 'tmux' && atLeast(version, 3, 4)) + +// A copy of Claude Code 2.1.303's own rules. Elsewhere the engine draws a Link as its text plus the +// URL, which would lengthen every path, so bare paths link only where these say hyperlinks draw. +export const drawsHyperlinks = (env: TerminalEnv): boolean => { + if (env.FORCE_HYPERLINK) return Number.parseInt(env.FORCE_HYPERLINK, 10) !== 0 + const term = env.TERM ?? '' + const vte = env.VTE_VERSION + return env.WT_SESSION !== undefined + || programLinks(env.TERM_PROGRAM ?? '', env.TERM_PROGRAM_VERSION) + || HYPERLINK_TERMINALS.includes(env.LC_TERMINAL ?? '') + || (vte !== undefined && vte !== '0.50.0' && atLeast(vte, 0, 50)) + || term === 'alacritty' || term.includes('kitty') + || env.TERMINAL_EMULATOR === 'JetBrains-JediTerm' +} + +// Opening one of these may run it, so a file href to one never links. +const LAUNCHERS = new Set([ + 'app', 'command', 'tool', 'terminal', 'pkg', 'mpkg', 'mobileconfig', 'prefpane', 'workflow', 'action', 'fileloc', 'inetloc', 'webloc', + 'exe', 'com', 'scr', 'pif', 'cpl', 'msi', 'msp', 'msu', 'msix', 'msixbundle', 'appx', 'appxbundle', 'appinstaller', 'application', 'appref-ms', 'vsix', + 'bat', 'cmd', 'ps1', 'psc1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', 'wsc', 'hta', 'chm', 'msc', 'reg', 'xll', 'wsb', 'diagcab', 'ahk', 'au3', + 'lnk', 'url', 'scf', 'settingcontent-ms', + 'desktop', 'sh', 'bash', 'zsh', 'csh', 'ksh', 'run', 'appimage', 'deb', 'rpm', 'snap', 'flatpak', 'flatpakref', + 'jar', 'jnlp', 'py', 'pyw', 'pyz', 'pyzw', 'pyc', 'pl', 'rb', 'rbw', +]) + +const unescaped = (text: string): string => text.replace(/%([0-9a-f]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))) + +const extensionOf = (name: string): string => /\.([^.]*)$/.exec(name.replace(/[. ]+$/, ''))?.[1] ?? '' + +// Splitting the last name at ':' and control characters catches an NTFS stream (x.exe::$DATA) and a NUL (x.exe%00.txt). +const launches = (href: string): boolean => { + const path = unescaped(href.replace(/[?#].*$/, '')).toLowerCase().replace(/[/\\]+$/, '') + const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1) + return /\.app[/\\]/.test(path) || name.split(/[:\u0000-\u001f]/).some(part => LAUNCHERS.has(extensionOf(part))) +} + +const isHyperlink = (href: string, reach: Reach): boolean => + (reach.httpLinks ? /^https?:/i : /^(https:|http:\/\/localhost(:\d+)?([/?#]|$))/i).test(href) + || (reach.editorLinks === true && /^vscode(-insiders)?:\/\/file\//i.test(href)) + || (reach.fileLinks === true && /^file:/i.test(href) && !launches(href)) + +const plainLink = (text: string, href: string): string => { + const target = href.replace(/^mailto:/i, '') + return text === target || `http://${text}` === target ? target : `${text} (${target})` +} + +// Claude Code throws out the whole render past this many percent-encoded characters. +const LINK_MAX = 2048 + +const utf8Bytes = (cp: number): number => (cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4) + +const encodedLength = (href: string): number => + [...href].reduce((length, ch) => { + const cp = ch.codePointAt(0)! + return length + (cp > 0x20 && cp < 0x7f ? 1 : 3 * utf8Bytes(cp)) + }, 0) + +const absolutePath = (reach: Reach, path: string): string | undefined => { + const base = path.startsWith('~') ? reach.home : path.startsWith('.') ? reach.cwd : '/' + if (!base) return undefined + const parts: string[] = [] + for (const part of `${base.replace(/\\/g, '/').replace(/\/+$/, '')}/${path.replace(/^~/, '').replace(/(?<=[^/.])\.+$/, '')}`.split('/')) { + if (part === '..') parts.pop() + else if (part !== '.' && part !== '') parts.push(part) + } + return `/${parts.join('/')}${path.endsWith('/') ? '/' : ''}` +} + +const pathHref = (reach: Reach, path: string): string | undefined => { + const target = absolutePath(reach, path) + if (target === undefined || !reach.fileLinks) return undefined + const href = `file://${encodeURI(target).replace(/[?#]/g, encodeURIComponent)}` + return href.length <= LINK_MAX && !launches(href) ? href : undefined +} + +// The href comes from the tool input's whole path, not the shortened text the row shows. +export const toolHref = (reach: Reach, path: string): string | undefined => { + if (!reach.pathLinks || !reach.fileLinks) return undefined + const forward = path.replace(/\\/g, '/') + if (/^[a-z]:\//i.test(forward)) return pathHref(reach, forward) + if (!forward.startsWith('//')) return pathHref(reach, /^[/~.]/.test(forward) ? forward : `./${forward}`) + const href = `file:${encodeURI(forward).replace(/[?#]/g, encodeURIComponent)}` + return href.length <= LINK_MAX && !launches(href) ? href : undefined +} + +// Only Claude Code's own file tools: an MCP tool's path can name a file somewhere else, such as a GitHub repo. +const FILE_TOOLS = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Grep', 'Glob']) + +export const toolPath = (tool: string | undefined, path: string | undefined): string | undefined => (tool !== undefined && FILE_TOOLS.has(tool) ? path : undefined) + +const decoded = (text: string): string | undefined => { + try { + return decodeURIComponent(text) + } catch { + return undefined + } +} + +const linkPath = (href: string): string | undefined => decoded(href.replace(/^file:(\/\/(localhost)?(?=\/))?/i, '').replace(/[?#].*$/, '')) + +const labelPath = (reach: Reach, label: string): string | undefined => { + if (/^file:/i.test(label)) return linkPath(label) + if (/^([a-z]:[\\/]|\\\\)/i.test(label)) return label.replace(/\\/g, '/').replace(/^(?=[a-z]:)/i, '/') + if (label.startsWith('/')) return label + return /^(~|\.{1,2})\//.test(label) ? absolutePath(reach, label) : undefined +} + +const withoutSlash = (path: string | undefined): string | undefined => path?.replace(/(?<=.)\/+$/, '') + +// A markdown link shows only its label, so a file link stays clickable only when the label is the path it opens. +const hidesFile = (reach: Reach, n: Extract): boolean => { + const label = n.text.trim().replace(/[.,;:!?]+$/, '') + if (!/^file:/i.test(n.href) || label === n.href) return false + const shown = withoutSlash(labelPath(reach, label)) + return shown === undefined || shown !== withoutSlash(linkPath(n.href)) +} + +// A link Claude Code cannot open draws as its text and target, so nothing looks clickable that is not. +const drawnLink = (n: Extract, reach: Reach): Inline => + !isHyperlink(n.href, reach) || encodedLength(n.href) > LINK_MAX || hidesFile(reach, n) ? { kind: 'text', text: plainLink(n.text, n.href) } : n + +const drawnPath = (n: Extract, reach: Reach): Inline => { + const href = reach.pathLinks ? pathHref(reach, n.text) : undefined + return href === undefined ? n : { ...n, href } +} + +const hyperlinks = (nodes: Inline[], reach: Reach): Inline[] => + nodes.map(n => (n.kind === 'link' ? drawnLink(n, reach) : n.kind === 'path' ? drawnPath(n, reach) : 'children' in n ? { ...n, children: hyperlinks(n.children, reach) } : n)) + +const hyperlinkBlock = (block: Block, reach: Reach): Block => { + const drawn = (nodes: Inline[]) => hyperlinks(nodes, reach) + switch (block.kind) { + case 'heading': + case 'paragraph': + return { ...block, inline: drawn(block.inline) } + case 'list': + return { ...block, items: block.items.map(item => ({ ...item, inline: drawn(item.inline) })) } + case 'table': + return { ...block, header: block.header.map(drawn), rows: block.rows.map(r => r.map(drawn)) } + case 'notes': + return { ...block, notes: block.notes.map(note => ({ ...note, inline: drawn(note.inline) })) } + default: + return block + } +} + +const hyperlinked = new WeakMap>() + +// Hrefs go on once per block, before wrapping, so each wrapped piece of a path carries the whole path. +export const drawable = (block: Block, reach: Reach): Block => { + const cached = hyperlinked.get(block) ?? {} + hyperlinked.set(block, cached) + const key = `${+!!reach.httpLinks}${+!!reach.editorLinks}${+!!reach.fileLinks}${reach.pathLinks ? `\0${reach.home}\0${reach.cwd}` : ''}` + return (cached[key] ??= hyperlinkBlock(block, reach)) +} diff --git a/plugins/cc-dazzle/hooks/markdown.ts b/plugins/cc-dazzle/hooks/markdown.ts index 75d9c04..c5f43c8 100644 --- a/plugins/cc-dazzle/hooks/markdown.ts +++ b/plugins/cc-dazzle/hooks/markdown.ts @@ -7,7 +7,7 @@ export type Inline = | { kind: 'math'; text: string } | { kind: 'link'; text: string; href: string } | { kind: 'number'; text: string } - | { kind: 'path'; text: string } + | { kind: 'path'; text: string; href?: string } | { kind: 'dim'; text: string } | { kind: 'footnote'; text: string; note?: number } @@ -41,6 +41,7 @@ const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/ const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/ const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+-]*)/ const NOTE_DEF = /^\[\^([^\]\s]+)\]:\s*(.*)$/ +const TAG_LINE = /^\s*<\/?[a-z][\w-]*>\s*$/i const SUPERSCRIPT = '⁰¹²³⁴⁵⁶⁷⁸⁹' const splitRow = (line: string): string[] => { @@ -177,7 +178,7 @@ const numberNotes = (blocks: Block[], defs: Map, hl: Highlight): } // Whole escape sequences go, not just the escape byte, so their payload never becomes a link. -const CONTROL = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]?|[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g +export const CONTROL = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]?|[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g export const parse = (raw: string, hl: Highlight): Block[] => { const source = raw.replace(CONTROL, '') @@ -262,6 +263,11 @@ const parseBlocks = (source: string, hl: Highlight, depth = 0): Block[] => { while (/^\s{2,}\S/.test(at(i + 1))) i++ continue } + if (TAG_LINE.test(line)) { + flush(i) + add({ kind: 'paragraph', inline: [{ kind: 'dim', text: line.trim() }] }, i, i + 1) + continue + } const heading = /^(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/.exec(line) if (heading) { flush(i) diff --git a/plugins/cc-dazzle/hooks/mermaid.tsx b/plugins/cc-dazzle/hooks/mermaid.tsx index b3b51a2..5d61ebc 100644 --- a/plugins/cc-dazzle/hooks/mermaid.tsx +++ b/plugins/cc-dazzle/hooks/mermaid.tsx @@ -68,10 +68,11 @@ export const unsupportedKind = (source: string): string | undefined => { // Pies and gantts are drawn here, not by beautiful-mermaid. A pie is round while each slice can take // its own diagram hue, and falls back to one bar per slice past that or in a narrow terminal. +// ganttText leaves 2 columns spare for upstream's frame; this box needs 6. const OWN: Record ChartArt | null> = { pie: (source, columns) => pieText(source, false, columns, DIAGRAM_HUES.length), gantt: (source, columns) => { - const text = ganttText(source, false, columns) + const text = ganttText(source, false, columns - 4) return text === null ? null : { text } }, } @@ -161,8 +162,18 @@ const draw = (source: string, columns: number): string | null => { }) } -const LINE = /[─-╿◇]/ +const LINE = /[─-╿◇▏]/ const ARROW = /[►◄▲▼]/ +const PART = /[▐▏▎▍▌▋▊▉]/ + +const barStart = (row: string[], c: number): number => { + const ch = row[c] ?? '' + const solid = BLOCK.test(ch) ? ch : PART.test(ch) && row[c + 1] === '█' ? '█' : PART.test(ch) && row[c - 1] === '█' ? '█' : '' + if (!solid) return -1 + let start = BLOCK.test(ch) ? c : row[c + 1] === '█' ? c + 1 : c - 1 + while (row[start - 1] === solid) start-- + return start +} const paint = (art: string, style: Style, series?: number[][]): (string | undefined)[][] => { const t = style.theme @@ -212,11 +223,9 @@ const paint = (art: string, style: Style, series?: number[][]): (string | undefi grid.forEach((row, r) => row.forEach((ch, c) => { if (color[r]![c] !== undefined) return - if (BLOCK.test(ch)) { - let start = c - while (row[start - 1] === ch) start-- - color[r]![c] = barColor(bars.indexOf(start)) - } else if (ch === '·') color[r]![c] = t.rule + const start = barStart(row, c) + if (start >= 0) color[r]![c] = barColor(bars.indexOf(start)) + else if (ch === '·') color[r]![c] = t.rule else if (ARROW.test(ch)) color[r]![c] = t.accent else if (LINE.test(ch)) color[r]![c] = t.diagram else if (/^\d+[┤┼]/.test(row.slice(c).join(''))) color[r]![c] = t.number diff --git a/plugins/cc-dazzle/hooks/register.tsx b/plugins/cc-dazzle/hooks/register.tsx index 182aa73..2eec7ad 100644 --- a/plugins/cc-dazzle/hooks/register.tsx +++ b/plugins/cc-dazzle/hooks/register.tsx @@ -6,6 +6,8 @@ import { instanceKey, noteTip, renderBlocks } from './blocks' import type { ClipboardBackend } from './clipboard' import { clipboardCommand } from './clipboard' import { clockText, promptKey, sentAt, withRecord } from './clock' +import type { TerminalEnv } from './links' +import { drawsHyperlinks } from './links' import type { Block } from './markdown' import { parse } from './markdown' import { boxArt, chartSeries, inlineMarks, mermaidArt, shortenEdgeLabels, unsupportedKind } from './mermaid' @@ -54,17 +56,41 @@ const htmlBackend = async ($: EngineInterface): Promise return helper?.kind === 'file' ? 'macos' : null } -type Reach = { http: boolean; editor: boolean; marks: boolean } +type Reach = { http: boolean; editor: boolean; marks: boolean; files: boolean; paths: boolean } type Probes = { htmlCopy?: Promise; reach?: Promise } -// What this Claude Code release opens: plain http links, VS Code file links and clickable footnote marks. -const reachOf = ($: EngineInterface, probes: Probes): Promise => - (probes.reach ??= $.session.version().then(v => ({ http: takesHttpLinks(v.base), editor: takesEditorLinks(v.base), marks: takesClickableMarks(v.base) }), () => ({ http: false, editor: false, marks: false }))) +const terminalEnv = async ($: EngineInterface): Promise => ({ + FORCE_HYPERLINK: await $.env.get('FORCE_HYPERLINK'), + TERM_PROGRAM: await $.env.get('TERM_PROGRAM'), + TERM_PROGRAM_VERSION: await $.env.get('TERM_PROGRAM_VERSION'), + TERM: await $.env.get('TERM'), + WT_SESSION: await $.env.get('WT_SESSION'), + LC_TERMINAL: await $.env.get('LC_TERMINAL'), + VTE_VERSION: await $.env.get('VTE_VERSION'), + TERMINAL_EMULATOR: await $.env.get('TERMINAL_EMULATOR'), +}) +const overSsh = async ($: EngineInterface): Promise => Boolean(await $.env.get('SSH_CONNECTION') || await $.env.get('SSH_TTY')) + +// What this Claude Code release opens: plain http links, VS Code file links, clickable footnote marks +// and file links, read once. Over SSH a file link would open a file on the terminal's computer. +const readReach = async ($: EngineInterface, style: Style): Promise => { + const base = await $.session.version().then(v => v.base, () => undefined) + const http = takesHttpLinks(base) + if (http) await locate($, style) + const files = http && !(await overSsh($).catch(() => true)) + return { http, editor: takesEditorLinks(base), marks: takesClickableMarks(base), files, paths: files && drawsHyperlinks(await terminalEnv($).catch(() => ({}))) } +} + +const reachOf = ($: EngineInterface, probes: Probes, style: Style): Promise => (probes.reach ??= readReach($, style)) + +// Replies drawn before the check lands, as on resume, redraw to gain ⧉ html. const probe = async ($: EngineInterface, style: Style, probes: Probes): Promise => { probes.htmlCopy ??= htmlBackend($) + const before = style.htmlCopy style.htmlCopy = await probes.htmlCopy + if (style.htmlCopy !== before) $.ui.invalidate('ui.render') } const copyTable = async ($: EngineInterface, html: string, text: string): Promise => { @@ -74,10 +100,10 @@ const copyTable = async ($: EngineInterface, html: string, text: string): Promis return result.exitCode === 0 ? null : result.stderr.trim() || command.failure } -const folder = ($: EngineInterface, el: ReturnType, open?: string[]): Fold => (id, hidden, key) => { +const folder = ($: EngineInterface, el: ReturnType, open?: string[]): Fold => (id, hidden, key, label) => { if (!open) return null const folded = !open.includes(id) - return { folded, element: update($, unfolded, ids => (folded ? [...ids, id].slice(-500) : ids.filter(x => x !== id)))} /> } + return { folded, element: update($, unfolded, ids => (folded ? [...ids, id].slice(-500) : ids.filter(x => x !== id)))} /> } } const drawMarkdown = ($: EngineInterface, el: ReturnType, style: Style, blocks: ReturnType, columns: number, open?: string[], notes?: { id: string; press: boolean }): RenderElement[] => { @@ -143,7 +169,7 @@ export const register: Register = (on, options) => { const parsed = new Map>() const parseCached = (text: string) => remember(parsed, text, () => parse(text, { numbers: true, paths: true })) const forSurface = (base: Style, surface: RenderSurface, reach?: Reach): Style => { - const drawn = surface === 'terminal' ? (reach?.http || reach?.editor ? { ...base, httpLinks: reach.http, editorLinks: reach.editor } : base) : { ...base, htmlCopy: null, nativeCode: true } + const drawn = surface === 'terminal' ? (reach?.http || reach?.editor ? { ...base, httpLinks: reach.http, editorLinks: reach.editor, fileLinks: reach.files, pathLinks: reach.paths } : base) : { ...base, htmlCopy: null, nativeCode: true } // Off the terminal, Text is proportional and collapses spaces, so code goes to the app's own Code element. // The phone app has no pointer to hover with. return surface === 'mobile' && drawn.copyButtonStyle === 'hover' ? { ...drawn, copyButtonStyle: 'inline' } : drawn @@ -175,7 +201,7 @@ export const register: Register = (on, options) => { for (const call of e.props.calls) if (call.tool_use_id) markExpanded(call.tool_use_id) return next(e) } - const row = renderToolGroup($.ui.resolve(e), style, e.props.calls, e.props.isActive) + const row = renderToolGroup($.ui.resolve(e), forSurface(style, e.surface, await reachOf($, probes, style)), e.props.calls, e.props.isActive) const found = e.props.calls.flatMap(call => { const table = call.isRunning || call.isErrored || call.isInterrupted ? null : toolTable(call.tool, call.output) return table === null ? [] : [{ call, table }] @@ -195,15 +221,16 @@ export const register: Register = (on, options) => { on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { await locate($, style) noteCommand(e.props.tool_use_id, e.props.tool, e.props.input) - if (!expandedCalls.has(e.props.tool_use_id)) return renderToolRow($.ui.resolve(e), style, e.props) + if (!expandedCalls.has(e.props.tool_use_id)) return renderToolRow($.ui.resolve(e), forSurface(style, e.surface, await reachOf($, probes, style)), e.props) const shell = e.props.tool === 'Bash' || e.props.tool === 'PowerShell' return shell && typeof e.props.output !== 'string' ? renderExpandedShell($.ui.resolve(e), style, e.props) : next(e) }) on('ui.render', { component: 'ToolResult' }, async ($, e, next) => { if ((e.props.tool === 'Edit' || e.props.tool === 'Write') && !e.props.isErrored) { + await locate($, style) const el = $.ui.resolve(e) - return renderDiffCard(el, style, e.props.output, folder($, el, await read($, unfolded)), e.props.tool_use_id) ?? next(e) + return renderDiffCard(el, forSurface(style, e.surface, await reachOf($, probes, style)), e.props.output, folder($, el, await read($, unfolded)), e.props.tool_use_id) ?? next(e) } const edits = e.props.tool === 'Bash' && !e.props.isErrored ? bashEdits(e.props.output) : undefined if (edits) { @@ -213,7 +240,7 @@ export const register: Register = (on, options) => { const above = hasShellOutput(output) ? await next({ ...e, props: { ...e.props, output } }) : null firstEdit ??= e.props.tool_use_id const notes = editNotes(edits, firstEdit === e.props.tool_use_id, shellCommands.get(e.props.tool_use_id)) - const cards = renderBashEdits(el, style, edits, folder($, el, await read($, unfolded)), e.props.tool_use_id, above !== null, notes) + const cards = renderBashEdits(el, forSurface(style, e.surface, await reachOf($, probes, style)), edits, folder($, el, await read($, unfolded)), e.props.tool_use_id, above !== null, notes) return above === null ? cards : {above}{cards} } if (!e.props.isErrored) { @@ -258,7 +285,7 @@ export const register: Register = (on, options) => { const el = $.ui.resolve(e) const { Box } = el const open = await read($, unfolded) - return {drawMarkdown($, el, forSurface(style, e.surface, await reachOf($, probes)), blocks, columnsOf(e.viewport), open)} + return {drawMarkdown($, el, forSurface(style, e.surface, await reachOf($, probes, style)), blocks, columnsOf(e.viewport), open)} }) on('ui.render', { component: 'UserMessage' }, async ($, e, next) => { @@ -275,7 +302,7 @@ export const register: Register = (on, options) => { const el = $.ui.resolve(e) const { Box, Text } = el const open = await read($, unfolded) - const reach = await reachOf($, probes) + const reach = await reachOf($, probes, style) const notes = { id: e.requestId, press: reach.marks } const drawStyle = forSurface(style, e.surface, reach) const draw = (text: string) => drawMarkdown($, el, drawStyle, parseCached(text), columnsOf(e.viewport), open, notes) diff --git a/plugins/cc-dazzle/hooks/stretch-features.test.tsx b/plugins/cc-dazzle/hooks/stretch-features.test.tsx index 3727348..e74a6d1 100644 --- a/plugins/cc-dazzle/hooks/stretch-features.test.tsx +++ b/plugins/cc-dazzle/hooks/stretch-features.test.tsx @@ -1,12 +1,13 @@ import type { On, PromptChangeInput, PromptEditInput, SessionMessage } from 'claude-code' -import { describe, expect, test } from 'claude-code/testing' +import { describe, expect, mock, test } from 'claude-code/testing' import type { Engine } from 'claude-code/testing' import { THEME as t } from './theme' -import { layout, widthOf } from './stretch/layout' +import { layout, leadInOf, link, linkAt, namesOf, widthOf, withLeadIns } from './stretch/layout' +import { parse } from './stretch/parse' -// Features ported from upstream cc-stretch 0.4.0 to 0.5.1: links, selecting text and keyboard -// letters in a paragraph that holds a stretch, each with its upstream test. Pointer cells here are +// Features ported from upstream cc-stretch 0.4.0 to 0.5.2: links, selecting text, keyboard letters +// and lead-in words in a paragraph that holds a stretch, each with its upstream test. Pointer cells here are // the text's; `at` adds the paragraph's two-cell gutter. const REPLY = [ @@ -228,6 +229,104 @@ describe('selecting text', () => { ]) await ui.unmount() }) + + test('a drag that starts on a short form or on lead-in words selects and opens nothing', ON, async ($, on) => { + const copies = clipboard(on) + const ui = await mountReply($, FULLSCREEN, 'It {{merged|merged 3 days ago}} and shipped 2 days ago{{.|, after review.}}') + await drag(ui, { x: 3, y: 0 }, { x: 8, y: 0 }) + await drag(ui, { x: 14, y: 0 }, { x: 22, y: 0 }) + await ui.advance(2000) + expect(copies).toEqual(['merged', 'shipped 2']) + expect(await textIn(ui)).toBe('It merged and shipped 2 days ago.') + await ui.unmount() + }) + + test('a double-click on lead-in words copies the word and opens nothing', ON, async ($, on) => { + const copies = clipboard(on) + const ui = await mountReply($, FULLSCREEN, 'Edit hooks/reply.tsx first{{.|, then rerun the tests.}}') + await doubleClick(ui, { x: 8, y: 0 }) + await ui.advance(2000) + expect(copies).toEqual(['hooks/reply.tsx']) + expect(await textIn(ui)).toBe('Edit hooks/reply.tsx first.') + await ui.unmount() + }) +}) + +describe('lead-in words', () => { + test('finds the words a link with none of its own is pressed on, back to the start of their sentence', ON, async () => { + const leadIn = (text: string) => { + const found = leadInOf(text) + return found && text.slice(found.from, found.to) + } + expect(leadIn('It merged 3 days ago')).toBe('It merged 3 days ago') + expect(leadIn('- **#212**: merged 3 days ago')).toBe('**#212**: merged 3 days ago') + expect(leadIn('`npm test` exited 0')).toBe('`npm test` exited 0') + expect(leadIn('3. **Stop the workers**')).toBe('**Stop the workers**') + expect(leadIn('It failed. A test ')).toBe('A test') + // A comma, a semicolon, a colon or a dash inside the sentence does not end the words. + expect(leadIn('Retries wait 200, 400 and 800 ms')).toBe('Retries wait 200, 400 and 800 ms') + expect(leadIn('It built; the tests: all passed')).toBe('It built; the tests: all passed') + expect(leadIn('one, two — three four')).toBe('one, two — three four') + expect(leadIn('测试通过,已合并')).toBe('测试通过,已合并') + expect(leadIn('测试通过。已合并')).toBe('已合并') + expect(leadIn('他说:“测试都通过了。”然后我们合并了')).toBe('然后我们合并了') + expect(leadIn('测试通过了!)然后合并了')).toBe('然后合并了') + // A bold phrase is taken whole, unless markup or a sentence's end is inside it. + expect(leadIn('The **cache key** is stale')).toBe('The **cache key** is stale') + expect(leadIn('See **[docs](https://x.y)** now')).toBe('now') + expect(leadIn('So **it failed. Then it** passed')).toBe('passed') + expect(leadIn('✅ Tests pass')).toBe('Tests pass') + expect(leadIn('See [docs](https://x.y)')).toBeUndefined() + expect(leadIn('Use `**` a b**')).toBeUndefined() + expect(leadIn('- ')).toBeUndefined() + }) + + test('marks the lead-in words of a link with none of its own to open it, and leaves them where a mark would add a place to break', ON, async () => { + const marked = (text: string, isParagraphEnd = true) => + withLeadIns(parse(text), () => true, isParagraphEnd) + .filter(part => typeof part === 'string') + .join('|') + expect(marked('It merged{{.|, and passed.}}')).toBe(link('leadIn', '.', 'It merged')) + expect(marked('Before {{|hidden}} after')).toBe(`${link('leadIn', '', 'Before')} | after`) + // An empty short form glued to a period: the period is marked too, so the words and it stay one piece. + expect(marked('exited 0{{|, so no errors}}. Tests')).toBe(`${link('leadIn', '', 'exited 0')}|${link('leadIn', '', '.')} Tests`) + expect(marked('exited 0{{|, so no errors}}word')).toBe('exited 0|word') + expect(marked('exited 0{{|, so no errors}}', false)).toBe('exited 0') + expect(marked('- {{.|item}}')).toBe('- ') + expect(marked('-\td{{.|, and more}}')).toBe('-\td') + // A code span closed on this line but opened on the line before reads as unclosed to `layout`. + expect(marked('x ``a\nb`` c{{.|d}}')).toBe('x ``a\nb`` c') + // A sentence that starts in another stretch lends only the words after it, apart from it by a space, + // so the two links never draw as one; words glued to that stretch's text lend nothing. + expect(marked('Run {{the tests|the unit tests}} then ship{{.|, after review.}}')).toBe(`Run | ${link('leadIn', '.', 'then ship')}`) + expect(marked('Run {{the tests|the unit tests}}-shipped{{.|, after review.}}')).toBe('Run |-shipped') + // Punctuation glued to an empty short form that runs on into the next stretch is joined to what that stretch draws. + expect(marked('So the suite passed{{|, all 84}}.{{|, twice}}” Then it shipped.')).toBe('So the suite passed|.|” Then it shipped.') + }) + + test('names each stretch drawn as a link by its short form, and a wordless one with its lead-in words as drawn', ON, async () => { + const names = namesOf(parse('It shipped{{.|, and passed.}} It is approved{{.|, but blocked.}} The check exited 0{{|, so no errors}}. {{Done.|Done in `ci.ts`.}}')) + expect([...names.values()]).toEqual(['It shipped.', 'It is approved.', 'The check exited 0.', 'Done.']) + expect([...namesOf(parse('It shipped {{.|, and passed.}}')).values()]).toEqual(['It shipped .']) + }) + + test('draws the lead-in words of a link with none of its own as a run of their own, so rows break where they would without them', ON, async () => { + const text = (leadIn: string) => `so then it shipped 3 days ${leadIn}${link('open', 'k', '.')} Then more words follow.` + for (const width of [10, 13, 17, 21]) { + const plain = layout(text('ago'), width) + const pressed = layout(text(link('leadIn', 'k', 'ago')), width) + expect(pressed.map(row => row.map(run => run.text).join(''))).toEqual(plain.map(row => row.map(run => run.text).join(''))) + } + const rows = layout(`It ${link('leadIn', 'k', 'landed')}${link('open', 'k', '.')}`, 30) + expect(linkAt(rows, 4, 0)).toEqual({ action: 'open', key: 'k', isLeadIn: true }) + expect(linkAt(rows, 9, 0)).toEqual({ action: 'open', key: 'k' }) + }) + + test('never lets a code span or a markdown link run over a pressable phrase, as half of one drawn by a growing long form would', ON, async () => { + const shown = (text: string) => layout(text, 80).map(row => row.map(run => run.text).join('')).join('\n') + expect(shown(`Run \`npm then ${link('open', 'k', 'ship `main`')}.`)).toBe('Run `npm then ship main.') + expect(shown(`See [the ${link('leadIn', 'k', 'docs')}${link('open', 'k', '.')} [x](https://x.y)`)).toBe('See [the docs. x') + }) }) describe('keyboard', () => { @@ -384,4 +483,161 @@ describe('keyboard', () => { await older.unmount() await newer.unmount() }) + + test('a stretch with an empty short form opens from the letter on the words before it', ON, async ($, on) => { + const text = 'Before {{|hidden words}} after {{shown|more}}.' + engine(on, [text]) + const ui = await mountReply($, FULLSCREEN, text) + await down(ui) + expect(await letters(ui)).toEqual(['a', 's']) + await type($, 'a') + await ui.advance(2000) + expect(await textIn(ui)).toContain('Before hidden words « after shown.') + await ui.unmount() + }) + + test('a stretch with an empty short form and no words before it draws no link and opens from no letter', ON, async ($, on) => { + const text = '{{|hidden words}} after {{shown|more}}.' + engine(on, [text]) + const ui = await mountReply($, FULLSCREEN, text) + await down(ui) + expect(await letters(ui)).toEqual(['s']) + expect(await type($, 'a')).toBe('') + await ui.unmount() + }) +}) + +describe('lead-in words on screen', () => { + test('a link with no words of its own is pressed on the words before it, which stay where they are', ON, async $ => { + const text = '- **#212 Move sessions to Redis**: merged 3 days ago{{.|, and the e2e run passed.}}' + const ui = await mountReply($, FULLSCREEN, text) + expect(await textIn(ui)).toBe('• #212 Move sessions to Redis: merged 3 days ago.') + const pieces = await ui.findAll({ type: 'Text', in: FIRST }) + expect(pieces.filter(piece => piece.props.bold === true).every(piece => piece.props.underline === true)).toBe(true) + expect(pieces.find(piece => piece.text.startsWith(': merged'))?.props).toMatchObject({ underline: true }) + expect(pieces.find(piece => piece.text === '• ')?.props).not.toHaveProperty('underline') + await pressOn(ui, 'merged') + await ui.advance(2000) + expect(await textIn(ui)).toBe('• #212 Move sessions to Redis: merged 3 days ago, and the e2e run passed. «') + await ui.unmount() + }) + + test('an empty short form joined to the text after it opens from the words before it and that text', ON, async $ => { + const text = 'The type check exited 0{{|, so it found no errors}}. Tests passed too.' + const ui = await mountReply($, FULLSCREEN, text) + expect(await textIn(ui)).toBe('The type check exited 0. Tests passed too.') + const pieces = await ui.findAll({ type: 'Text', in: FIRST }) + expect(pieces.filter(piece => piece.props.underline === true).map(piece => piece.text).join('')).toBe('The type check exited 0.') + await pressOn(ui, 'exited') + await ui.advance(2000) + expect(await textIn(ui)).toBe('The type check exited 0, so it found no errors «. Tests passed too.') + await ui.unmount() + }) + + test('a click on lead-in words opens their stretch once a second press can no longer make a double-click', ON, async $ => { + const ui = await mountReply($, FULLSCREEN, 'It merged 3 days ago{{.|, and the e2e run passed.}}') + await pressOn(ui, 'merged') + await ui.advance(400) + expect(await textIn(ui)).toBe('It merged 3 days ago.') + await ui.advance(2000) + expect(await textIn(ui)).toBe('It merged 3 days ago, and the e2e run passed. «') + await ui.unmount() + }) + + test('two quick clicks on the lead-in words of two links open both, also on the row below at the same column', ON, async ($, on) => { + const copies = clipboard(on) + const ui = await mountReply($, FULLSCREEN, 'It merged 3 days ago{{.|, and passed.}} The second one is approved{{.|, but blocked.}}') + await pressOn(ui, 'merged') + await pressOn(ui, 'approved') + await ui.advance(3000) + expect(await textIn(ui)).toBe('It merged 3 days ago, and passed. « The second one is approved, but blocked. «') + await ui.unmount() + const rows = await mountReply($, FULLSCREEN, 'It merged 3 days ago{{.|, and passed.}}\nIt failed 2 days ago{{.|, and reran.}}', 'reply-2') + expect(await textIn(rows)).toBe('It merged 3 days ago.\nIt failed 2 days ago.') + await rows.pointer({ type: 'down', ...at({ x: 4, y: 0 }), button: 'left', in: FIRST }) + await rows.pointer({ type: 'up', ...at({ x: 4, y: 0 }), button: 'left', in: FIRST }) + await rows.pointer({ type: 'down', ...at({ x: 4, y: 1 }), button: 'left', in: FIRST }) + await rows.pointer({ type: 'up', ...at({ x: 4, y: 1 }), button: 'left', in: FIRST }) + await rows.advance(3000) + expect(await textIn(rows)).toBe('It merged 3 days ago, and passed. «\nIt failed 2 days ago, and reran. «') + expect(copies).toEqual([]) + await rows.unmount() + }) + + test('a short form opens as the button comes up, not as it goes down', ON, async $ => { + const ui = await mountReply($, FULLSCREEN, 'It {{merged|merged 3 days ago}}.') + await ui.pointer({ type: 'down', ...at({ x: 4, y: 0 }), button: 'left', in: FIRST }) + await ui.advance(2000) + expect(await textIn(ui)).toBe('It merged.') + await ui.pointer({ type: 'up', ...at({ x: 4, y: 0 }), button: 'left', in: FIRST }) + await ui.advance(2000) + expect(await textIn(ui)).toBe('It merged 3 days ago «.') + await ui.unmount() + }) + + test('while another stretch grows, lead-in words stay drawn and a click on them opens their stretch', ON, async $ => { + const text = 'The type check exited 0{{|, so it found no errors}}. {{The fix is one line.|The fix is one line in `ci/cache.ts`, where the key hashes the lockfile.}}' + const ui = await mountReply($, FULLSCREEN, text) + const underlined = async () => + (await ui.findAll({ type: 'Text', in: FIRST })).filter(piece => piece.props.underline === true).map(piece => piece.text).join('') + expect(await underlined()).toBe('The type check exited 0.The fix is one line.') + await pressOn(ui, 'The fix') + await ui.advance(100) + expect(await underlined()).toContain('The type check exited 0.') + await pressOn(ui, 'exited') + await ui.advance(3000) + expect(await textIn(ui)).toContain('The type check exited 0, so it found no errors «.') + expect(await textIn(ui)).toContain('hashes the lockfile. «') + await ui.unmount() + }) + + test('half a code span drawn by a growing long form never draws the marks of a link after it as text', ON, async $ => { + const text = '{{Run it.|Run `npm run test:unit -- --watch` once.}} Then {{merge `main`|merge `main` into it}}{{.|, after review.}}' + const ui = await mountReply($, FULLSCREEN, text) + await pressOn(ui, 'Run it.') + for (let frame = 0; frame < 120; frame += 1) { + await ui.advance(16) + expect(await textIn(ui)).not.toMatch(/[﷐-﷔]/) + } + expect(await textIn(ui)).toBe('Run npm run test:unit -- --watch once. « Then merge main.') + await ui.unmount() + }) + + test('the note names a lone period by the words before it, and the one left closed as well', ON, async ($, on) => { + const text = 'The PR merged 3 days ago{{.|, and the e2e run passed.}} The second one is approved{{.|, but a failing e2e test blocks it.}}' + mock.clock(on, { now: 0 }) + mock.env(on, {}) + on('session.messages', () => ({ value: [{ role: 'assistant', text, toolUses: [] }] })) + let context: readonly string[] | undefined + on('prompt.submit', ($, e) => { + context = e.context + return { text: e.text, context: e.context } + }) + const ui = await mountReply($, FULLSCREEN, text) + await pressOn(ui, 'merged') + await ui.advance(2000) + await $.prompt.submit({ text: 'ok', wait: false, origin: { kind: 'composer' } }) + expect(context).toEqual([ + 'Stretchtext: since your last reply the reader opened "The PR merged 3 days ago.". They did not open "The second one is approved.".', + ]) + await ui.unmount() + }) + + test('the note tells apart two stretches written alike after different sentences', ON, async ($, on) => { + const text = 'It merged{{.|, and passed.}} It shipped{{.|, and passed.}}' + mock.clock(on, { now: 0 }) + mock.env(on, {}) + on('session.messages', () => ({ value: [{ role: 'assistant', text, toolUses: [] }] })) + let context: readonly string[] | undefined + on('prompt.submit', ($, e) => { + context = e.context + return { text: e.text, context: e.context } + }) + const ui = await mountReply($, FULLSCREEN, text) + await pressOn(ui, 'merged') + await ui.advance(2000) + await $.prompt.submit({ text: 'ok', wait: false, origin: { kind: 'composer' } }) + expect(context).toEqual(['Stretchtext: since your last reply the reader opened "It merged.". They did not open "It shipped.".']) + await ui.unmount() + }) }) diff --git a/plugins/cc-dazzle/hooks/stretch/index.tsx b/plugins/cc-dazzle/hooks/stretch/index.tsx index 42984bd..90c21ff 100644 --- a/plugins/cc-dazzle/hooks/stretch/index.tsx +++ b/plugins/cc-dazzle/hooks/stretch/index.tsx @@ -5,15 +5,17 @@ import { GUIDE } from './guide' import { closeAfter, codeSpan, hideLongForms, isLinkable, isOpenable, KEY_SEPARATOR, newStreamState, paragraphsOf, parse, stretches } from './parse' import type { Segment, StreamState, Stretch } from './parse' import { THEME } from '../theme' -import { FRESH, targetsIn } from './layout' -import type { Action } from './layout' +import { FRESH, namesOf, openedOf, targetsIn } from './layout' +import type { Action, OpenedStretch } from './layout' import type { ReplyPost, ReplyProps, Tint } from './reply' -// Stretchtext, ported from cc-stretch 0.3.3 (pro-vi/cc-stretch) with features up to 0.5.1. A reply's `{{short|long}}` stretches +// Stretchtext, ported from cc-stretch 0.3.3 (pro-vi/cc-stretch) with features up to 0.5.2. A reply's `{{short|long}}` stretches // draw as their short forms; a press grows one in place and « shrinks it back. const openStretches = atom({ plugin: 'cc-dazzle', key: 'stretchOpen' } as const, []) -const seen = atom({ plugin: 'cc-dazzle', key: 'stretchSeen' } as const, []) +// Kept under a shape tag: before cc-stretch 0.5.2's names it held short forms, and as of Claude Code +// 2.1.304 plugin state outlives a reload of the plugin's code; under the tag those read as absent. +const seen = atom({ plugin: 'cc-dazzle', key: 'stretchSeen' } as const, [], { shape: 'opened-stretches' }) const stretchOrder = atom({ plugin: 'cc-dazzle', key: 'stretchOrder' } as const, { order: {}, count: 0 }) const newestReply = atom({ plugin: 'cc-dazzle', key: 'stretchNewest' } as const, '') const askedStretch = atom({ plugin: 'cc-dazzle', key: 'stretchAsk' } as const, null) @@ -68,17 +70,24 @@ export function openText(text: string): string { */ export async function stretchNote($: EngineInterface): Promise { const opened = await read($, seen) - // A stretch that is not a link is drawn open, so it is neither opened nor unopened. - const written = stretches(parse(lastReply(await $.session.messages()))) - .filter(isLinkable) - .map(stretch => stretch.short.trim()) - const unopened = written.filter(short => !opened.includes(short)) + // A stretch that is not a link is drawn open, so it is neither opened nor unopened. Each is named + // as its paragraph draws it, where a lone period is named with the words before it. + const written = paragraphsOf(lastReply(await $.session.messages())) + .filter(paragraph => paragraph.hasStretch) + .flatMap(({ text }) => { + const parts = parse(text) + const names = namesOf(parts) + return stretches(parts) + .filter(isLinkable) + .map(stretch => openedOf(stretch, names)) + }) + const unopened = written.filter(stretch => !opened.some(was => isSameStretch(was, stretch))) await update($, seen, () => []) await update($, newestReply, () => '') await update($, lettering, () => null) if (opened.length === 0 && unopened.length === 0) return undefined - const quote = (list: string[]) => list.map(s => `"${s}"`).join(', ') + const quote = (list: OpenedStretch[]) => list.map(stretch => `"${stretch.name}"`).join(', ') return opened.length > 0 ? `Stretchtext: since your last reply the reader opened ${quote(opened)}.${unopened.length > 0 ? ` They did not open ${quote(unopened)}.` : ''}` : NO_CLICK @@ -176,7 +185,7 @@ export function registerStretch(on: On): void { if (post === undefined) return next(e) await update($, memberOf(openStretches, { requestId: `${e.requestId}#${e.element}` }), () => post.open) const { opened } = post - if (opened !== undefined) await update($, seen, list => (list.includes(opened.short) ? list : [...list, opened.short])) + if (opened !== undefined) await update($, seen, list => (list.some(was => isSameStretch(was, opened)) ? list : [...list, { source: opened.source, name: opened.name }])) // Each open stretch keeps the count it was opened at, across the reply's paragraphs. The newest // is the open stretch with the reply's last count. @@ -285,8 +294,8 @@ export async function stretchBody($: EngineInterface, e: RenderInput & { props: } if (action !== 'open') return await update($, open, list => (list.includes(key) ? list : [...list, key])) - const short = stretch.short.trim() - await update($, seen, list => (list.includes(short) ? list : [...list, short])) + const opened = openedOf(stretch, namesOf(parts)) + await update($, seen, list => (list.some(was => isSameStretch(was, opened)) ? list : [...list, opened])) } return { body: [ void press(link.href)} />], framed: false } } @@ -396,6 +405,10 @@ function writeOpen(parts: Segment[]): string { return parts.map(part => (typeof part === 'string' ? part : part.long === undefined ? part.short : writeOpen(part.long))).join('') } +function isSameStretch(a: OpenedStretch, b: OpenedStretch): boolean { + return a.source === b.source && a.name === b.name +} + /** A Client's post of text to copy, if it is one. */ function copyOf(data: unknown): string | undefined { if (typeof data !== 'object' || data === null || !('copy' in data)) return undefined @@ -406,10 +419,10 @@ function replyPostOf(data: unknown): ReplyPost | undefined { if (typeof data !== 'object' || data === null || !('open' in data) || !Array.isArray(data.open)) return undefined const open = data.open.filter((key): key is string => typeof key === 'string') const opened: unknown = 'opened' in data ? data.opened : undefined - if (typeof opened !== 'object' || opened === null || !('short' in opened) || !('key' in opened)) return { open } - const { short, key } = opened + if (typeof opened !== 'object' || opened === null || !('name' in opened) || !('source' in opened) || !('key' in opened)) return { open } + const { name, source, key } = opened - return typeof short === 'string' && typeof key === 'string' ? { open, opened: { short, key } } : { open } + return typeof name === 'string' && typeof source === 'string' && typeof key === 'string' ? { open, opened: { name, source, key } } : { open } } /** The text of the model's last reply: its rows after the person's last prompt. */ diff --git a/plugins/cc-dazzle/hooks/stretch/layout.ts b/plugins/cc-dazzle/hooks/stretch/layout.ts index a1fcfe1..3722201 100644 --- a/plugins/cc-dazzle/hooks/stretch/layout.ts +++ b/plugins/cc-dazzle/hooks/stretch/layout.ts @@ -4,7 +4,7 @@ import { NUMBER, PATH } from '../markdown' import { THEME } from '../theme' -import { closesFence, drawnAs, isRule, openingFence } from './parse' +import { closesFence, codeSpan, drawnAs, isLinkable, isRule, openingFence, stretches } from './parse' import type { Segment, Stretch } from './parse' /** What a press on a pressable phrase does to its stretch. */ @@ -21,7 +21,12 @@ export type Run = { tint?: string color?: string kind?: 'number' | 'path' - link?: { action: Action; key: string } + /** + * The stretch a press acts on, and how. `isLeadIn` marks lead-in words: the words written just before + * a link with no words of its own, and the punctuation glued after an empty short form, which a press + * opens too. They stay a run apart from the link, so rows break where they would without it. + */ + link?: { action: Action; key: string; isLeadIn?: true } /** Where a markdown link or a bare URL goes, drawn as a hyperlink. */ href?: string } @@ -48,6 +53,7 @@ export function continuationOf(row: Row): Continuation | undefined { const START = '\ufdd0' const SEP = '\ufdd1' const END = '\ufdd2' +const PHRASE_MARK = /[\ufdd0-\ufdd2]/ const TINT = '\ufdd3' const TINT_END = '\ufdd4' @@ -70,6 +76,12 @@ const NO_START = /[\p{Pe}\p{Pf},.:;!?,。、;:?!…·ぁぃぅぇぉ const NO_END = /[\p{Ps}\p{Pi}]/u const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'word' }) +// The marks that start a markdown block on a line, as lead-in words read them. +const TABLE_ROW = /^\s*\|/ +const HEADING = /^#{1,6}\s+(.*)$/ +const QUOTE = /^>\s?(.*)$/ +const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/ + /** A line's text without its tint marks, and the tint of each character left. */ type Line = { text: string; tints: (string | undefined)[] } @@ -80,14 +92,256 @@ export function tint(colour: string, word: string): string { /** Whether a word is markdown syntax rather than text: a bullet, a list number, a rule, a fence, a pipe, a heading mark. */ export function isMarkup(word: string): boolean { - return !/[\p{L}\p{N}]/u.test(word) || /^\d+[.)]$/.test(word) || openingFence(word) !== undefined + return !HAS_WORDS.test(word) || /^\d+[.)]$/.test(word) || openingFence(word) !== undefined } -/** A pressable phrase for composed text: drawn as `label`, a press does `action` to stretch `key`. */ -export function link(action: Action, key: string, label: string): string { +/** + * A pressable phrase for composed text: drawn as `label`, a press does `action` to stretch `key`; + * `leadIn` marks lead-in words, which open it. + */ +export function link(action: Action | 'leadIn', key: string, label: string): string { return `${START}${action}${SEP}${key}${SEP}${label}${END}` } +/** Punctuation that ends a sentence at the end of a word, before any closing quote or bracket. */ +const SENTENCE_END = /[.!?]["'”’)\]]*$/ +/** Sentence-ending punctuation of scripts that write no space after it, so it can stand inside a word. */ +const UNSPACED_SENTENCE_END = /[。!?](?=[^。!?]*$)/u +/** Markup a pressed phrase would cut in two: emphasis, a markdown link, HTML, a URL, a table's pipe. */ +const CUT_MARKUP = /[*[\]<>|~]|:\/\/|(?:^|[^\p{L}\p{N}])_|_(?:$|[^\p{L}\p{N}])/u +/** A bold phrase that ends a piece of a line, with any punctuation after it. */ +const BOLD_END = /(? { + let at = k - 1 + while (at >= 0 && pieces[at]?.isSpace) at -= 1 + return at + } + const last = previous(pieces.length) + const end = last < 0 ? 0 : (pieces[last]?.at ?? 0) + (pieces[last]?.text.length ?? 0) + let from: number | undefined + for (let k = last; k >= 0; k = previous(k)) { + const piece = pieces[k] + if (piece === undefined) break + if (!piece.isCode && CUT_MARKUP.test(piece.text)) { + // A bold phrase is taken whole when it starts a piece outside a code span, holds words and no + // other markup, and no sentence ends inside it. + const bold = BOLD_END.exec(line.slice(0, piece.at + piece.text.length)) + const start = bold === null ? -1 : pieces.findIndex(p => p.at === bold.index && !p.isCode) + const inner = bold?.[1] ?? '' + const isBoldWhole = + start !== -1 && start <= k && HAS_WORDS.test(inner) && !CUT_MARKUP.test(inner) && + pieces.slice(start, k).every(p => p.isSpace || p.isCode || !SENTENCE_END.test(p.text)) + if (!isBoldWhole) break + k = start + from = pieces[k]?.at + } else { + // The line's own indent, list or heading mark. + if (piece.at < floor) break + const cut = piece.isCode ? null : UNSPACED_SENTENCE_END.exec(piece.text) + if (cut !== null) { + // The words start where a row may break after the full stop: a closing quote or bracket stays with it. + let at = 0 + for (const unit of breakables(piece.text)) { + if (at >= cut.index + cut[0].length) break + at += unit.length + } + if (HAS_WORDS.test(piece.text.slice(at))) from = piece.at + at + break + } + from = piece.at + } + const before = pieces[previous(k)] + if (before !== undefined && !before.isCode && SENTENCE_END.test(before.text)) break + } + if (from === undefined || from < floor) return undefined + // A leading emoji, arrow or bullet is not a word to press. + for (const piece of pieces) { + if (piece.at < from || piece.isSpace) continue + if (piece.at >= end || piece.isCode || HAS_WORDS.test(piece.text)) break + from = piece.at + piece.text.length + } + while (from < end && line[from] === ' ') from += 1 + const charBefore = line[from - 1] ?? ' ' + const firstChar = line[from] ?? '' + const lastChar = line[end - 1] ?? '' + if (/\s/.test(firstChar + lastChar) || (from > floor && /[^\S ]/.test(charBefore))) return undefined + return HAS_WORDS.test(line.slice(from, end)) ? { from: lineStart + from, to: lineStart + end } : undefined +} + +/** Roughly what `stretch` draws: its short form, closed or as text, else its long form and a «. */ +function drawnText(stretch: Stretch, isShut: (stretch: Stretch) => boolean): string { + const long = () => (stretch.long ?? []).map(part => (typeof part === 'string' ? part : drawnText(part, isShut))).join('') + switch (drawnAs(stretch)) { + case 'text': + return stretch.short + case 'open': + return long() + case 'link': + return isShut(stretch) ? stretch.short : `${long()} «` + } +} + +/** A short form whose lead-in words open it too: none at all, or only punctuation or symbols. */ +const WORDLESS = /^[\p{P}\p{S}]*$/u + +/** + * `parts` with the lead-in words of each closed link that has no words of its own, such as a lone + * period, marked by `link('leadIn', …)` to open it as well; `isShut` says which links are drawn closed, and + * `isParagraphEnd` whether nothing is drawn after `parts`. The text stays as written. Where an empty + * short form joins its lead-in words to the text after it, a row cannot break between the two: that + * text's first piece is marked too when it is only punctuation, such as the sentence's period, so the + * marks add no place to break; otherwise the words are left. + */ +export function withLeadIns(parts: Segment[], isShut: (stretch: Stretch) => boolean, isParagraphEnd: boolean): Segment[] { + const { leadIns, tails } = leadInsOf(parts, isShut, isParagraphEnd) + return parts.map((part, i) => { + if (typeof part !== 'string') return part + let marked = '' + let at = 0 + for (const span of [tails.get(i), leadIns.get(i)]) { + if (span === undefined) continue + marked += part.slice(at, span.from) + link('leadIn', span.key, part.slice(span.from, span.to)) + at = span.to + } + return marked + part.slice(at) + }) +} + +/** + * What a note to Claude calls each stretch of `parts` drawn as a link, by key: its short form, and for + * one with no words of its own its lead-in words and the punctuation glued after it too, as drawn with + * every stretch closed, so lone periods after different sentences get different names. + */ +export function namesOf(parts: Segment[]): Map { + const names = new Map() + const walk = (segments: Segment[], isParagraphEnd: boolean) => { + const { leadIns, tails } = leadInsOf(segments, () => true, isParagraphEnd) + segments.forEach((part, i) => { + if (typeof part === 'string') return + if (drawnAs(part) === 'open') walk(part.long ?? [], false) + if (drawnAs(part) !== 'link') return + const before = segments[i - 1] + const after = segments[i + 1] + const leadIn = leadIns.get(i - 1) + const tail = tails.get(i + 1) + // The lead-in words and the spaces after them, as drawn up to the link. + const leadInText = leadIn !== undefined && typeof before === 'string' ? before.slice(leadIn.from) : '' + const glued = tail !== undefined && typeof after === 'string' ? after.slice(0, tail.to) : '' + names.set(part.key, `${leadInText}${part.short}${glued}`.trim()) + for (const nested of stretches(part.long ?? [])) if (isLinkable(nested)) names.set(nested.key, nested.short.trim()) + }) + } + walk(parts, true) + return names +} + +/** + * A stretch the reader opened, for the note to Claude: as written (`sourceOf`) and by its name + * (`namesOf`). Two stretches written alike after the same words are the same to the reader too. + */ +export type OpenedStretch = { source: string; name: string } + +/** `stretch` as a note to Claude names it, `names` being `namesOf` the parts it was parsed from. */ +export function openedOf(stretch: Stretch, names: ReadonlyMap): OpenedStretch { + return { source: sourceOf(stretch), name: names.get(stretch.key) ?? stretch.short.trim() } +} + +/** + * A stretch as written, braces and all, the same wherever it is parsed from, a paragraph (in the + * terminal) or the whole reply (in the desktop app), where its key, named among its siblings, can + * differ. + */ +function sourceOf(stretch: Stretch): string { + if (stretch.long === undefined) return `{{${stretch.short}}}` + return `{{${stretch.short}|${stretch.long.map(part => (typeof part === 'string' ? part : sourceOf(part))).join('')}}}` +} + +/** Where some text in composed parts is marked: from and to, in that text, and the stretch it opens. */ +type Marked = { from: number; to: number; key: string } + +/** + * Where `withLeadIns` marks words in `parts`, by index of the text that holds them: the lead-in words + * before a wordless closed link, and the punctuation an empty short form is glued to (its tail), with + * the key each opens. Lead-in words the tail of the stretch before them overlaps are left out. + */ +function leadInsOf( + parts: Segment[], + isShut: (stretch: Stretch) => boolean, + isParagraphEnd: boolean, +): { leadIns: Map; tails: Map } { + const leadIns = new Map() + const tails = new Map() + // What `parts` draw before index `i` on the same line: the words are found on the whole line, so a + // list mark, a code span or a `[` drawn by an earlier stretch is seen too. + const drawnBefore = (i: number): string => { + let line = '' + for (let j = i - 1; j >= 0; j--) { + const part = parts[j] + const drawn = part === undefined ? '' : typeof part === 'string' ? part : drawnText(part, isShut) + line = drawn + line + if (drawn.includes('\n')) break + } + return line.slice(line.lastIndexOf('\n') + 1) + } + parts.forEach((part, i) => { + const next = parts[i + 1] + if (typeof part !== 'string' || next === undefined || typeof next === 'string' || drawnAs(next) !== 'link' || !WORDLESS.test(next.short) || !isShut(next)) return + const before = part.includes('\n') ? '' : drawnBefore(i) + const found = leadInOf(before + part) + if (found === undefined || found.to <= before.length) return + const leadIn = { from: found.from - before.length, to: found.to - before.length } + // A sentence that starts before this text, in or before another stretch, lends only the words + // after that stretch, apart from it by a space, so the two links never draw as one. + if (leadIn.from < 0) { + leadIn.from = /^ */.exec(part)?.[0].length ?? 0 + if (leadIn.from === 0 || !HAS_WORDS.test(part.slice(leadIn.from, leadIn.to))) return + } + // Words that start the text right after a stretch drawn without a run of its own (one that is only + // text, or an empty short form) are joined to that stretch's text: marking them would add a break. + const previous = parts[i - 1] + if (leadIn.from === 0 && previous !== undefined && (typeof previous === 'string' || drawnAs(previous) !== 'link' || previous.short === '')) return + const after = parts[i + 2] + if (leadIn.to === part.length && next.short === '' && (after !== undefined || !isParagraphEnd) && !(typeof after === 'string' && /^[ \n]/.test(after))) { + const glued = typeof after === 'string' ? (/^[^ \n]+/.exec(after)?.[0] ?? '') : '' + if (!/^[\p{P}\p{S}]+$/u.test(glued) || /[`*_~[\]<>|\\]/.test(glued)) return + // Punctuation that runs on into the next stretch is joined to whatever that stretch draws: marking it would add a break. + if (typeof after === 'string' && glued.length === after.length && (parts[i + 3] !== undefined || !isParagraphEnd)) return + tails.set(i + 2, { from: 0, to: glued.length, key: next.key }) + } + if (leadIn.from >= (tails.get(i)?.to ?? 0)) leadIns.set(i, { ...leadIn, key: next.key }) + }) + return { leadIns, tails } +} + /** * The stretches `action` acts on among those drawn, with the stretches in `open` open: to open, those * drawn as links with every stretch around them open; to close, those open, whose « is drawn. @@ -289,7 +543,8 @@ function inline(text: string, tints: Line['tints'], isBold = false): Run[] { const first = ch === START ? text.indexOf(SEP, i + 1) : -1 const second = first === -1 ? -1 : text.indexOf(SEP, first + 1) if (second !== -1) { - pressable = { action: text.slice(i + 1, first) === 'close' ? 'close' : 'open', key: text.slice(first + 1, second) } + const action = text.slice(i + 1, first) + pressable = { action: action === 'close' ? 'close' : 'open', key: text.slice(first + 1, second), ...(action === 'leadIn' && { isLeadIn: true }) } i = second + 1 continue } @@ -301,7 +556,9 @@ function inline(text: string, tints: Line['tints'], isBold = false): Run[] { if (ch === '`') { const fence = /^`+/.exec(text.slice(i))?.[0] ?? '`' const end = text.indexOf(fence, i + fence.length) - if (end !== -1) { + // A code span never runs over a pressable phrase's marks: half of one, as a growing long form + // draws it, would pair with a backtick after them and draw the marks as text. + if (end !== -1 && !PHRASE_MARK.test(text.slice(i, end))) { emit(text.slice(i + fence.length, end), i + fence.length, { code: true }) i = end + fence.length continue @@ -317,7 +574,7 @@ function inline(text: string, tints: Line['tints'], isBold = false): Run[] { i += 1 continue } - const markdownLink = /^\[([^\]\n]+)\]\(([^)\s]+)\)/.exec(text.slice(i)) + const markdownLink = /^\[([^\]\n\ufdd0-\ufdd2]+)\]\(([^)\s]+)\)/.exec(text.slice(i)) if (markdownLink) { const label = markdownLink[1] ?? '' const href = markdownLink[2] ?? '' @@ -491,6 +748,6 @@ function isSameStyle(a: Run, b: Run): boolean { return ( a.bold === b.bold && a.italic === b.italic && a.code === b.code && a.underline === b.underline && a.dim === b.dim && a.tint === b.tint && a.color === b.color && a.kind === b.kind && a.href === b.href && - a.link?.action === b.link?.action && a.link?.key === b.link?.key + a.link?.action === b.link?.action && a.link?.key === b.link?.key && a.link?.isLeadIn === b.link?.isLeadIn ) } diff --git a/plugins/cc-dazzle/hooks/stretch/reply.tsx b/plugins/cc-dazzle/hooks/stretch/reply.tsx index 2da173a..5255f1f 100644 --- a/plugins/cc-dazzle/hooks/stretch/reply.tsx +++ b/plugins/cc-dazzle/hooks/stretch/reply.tsx @@ -2,8 +2,8 @@ import type { ClientModule, ClientPointerEvent, ClientSurface, TextProps } from import { grow, lengthOf } from './grow' import { THEME } from '../theme' -import { continuationOf, FRESH, isMarkup, layout, link, linkAt, targetsIn, tint, widthOf } from './layout' -import type { Action, Row, Run } from './layout' +import { continuationOf, FRESH, isMarkup, layout, link, linkAt, namesOf, openedOf, targetsIn, tint, widthOf, withLeadIns } from './layout' +import type { Action, OpenedStretch, Row, Run } from './layout' import { closeAfter, isLinkable, KEY_SEPARATOR, parse } from './parse' import type { Segment, Stretch } from './parse' @@ -34,9 +34,9 @@ const GUTTER = 2 /** * What the instance posts to the hooks module when the open stretches change, with the stretch the - * reader just opened: its short form for the note to Claude, its key for its colour. + * reader just opened: as the note to Claude names it, and its key, for its colour. */ -export type ReplyPost = { open: string[]; opened?: { short: string; key: string } } +export type ReplyPost = { open: string[]; opened?: OpenedStretch & { key: string } } /** What the instance posts to the hooks module when the reader selects its text: the text, for the clipboard. */ export type CopyPost = { copy: string } @@ -51,9 +51,11 @@ type Growth = { key: string; isOpening: boolean; progress: number; duration: num type Cell = { x: number; y: number } /** * Text the reader dragged across: from the cell the press went down on to the one under the pointer. - * A word picked by a double-click covers both its ends, even when they are the same cell. + * A word picked by a double-click covers both its ends, even when they are the same cell. `link` is + * the link the press went down on, which its release acts on unless the pointer `hasMoved` to another + * cell first. */ -type Selection = { anchor: Cell; head: Cell; isDragging: boolean; isWord?: true } +type Selection = { anchor: Cell; head: Cell; isDragging: boolean; isWord?: true; link?: Run['link']; hasMoved?: true } /** * One cell a double-click looks through for a word: what it shows, and where. `startsApart` marks * the first cell of a row that no word runs onto from the row before. @@ -68,6 +70,8 @@ const FRAME_MS = 16 // so a paragraph that holds a stretch answers like the paragraphs around it. const DOUBLE_CLICK_MS = 500 const DOUBLE_CLICK_CELLS = 1 +// Lead-in words open once a second press can no longer make a double-click of the first. +const DOUBLE_CLICK_FRAMES = Math.ceil(DOUBLE_CLICK_MS / FRAME_MS) // What a double-click takes as one word: Claude Code 2.1.303's own word characters, and `#` as well, // so that `#12806` comes out whole. const WORD = /[\p{L}\p{N}_/.\-+~\\#]/u @@ -84,10 +88,15 @@ const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) const drawn = new WeakMap() // The last ask each instance was handed, acted on at its next frame rather than while drawing. const asks = new WeakMap() -// The last press each instance saw, where and when it went down, so the next press soon after and -// close by makes a double-click. It is dropped when its release lands on another cell, so two quick -// drags from one cell stay two drags, and when a stretch opens or closes. -const presses = new WeakMap() +// The last press each instance saw, where and when it went down, and the stretch whose lead-in words +// it went down on, if any, so the next press soon after, close by and on the same words makes a +// double-click. It is dropped when its release lands on another cell, so two quick drags from one +// cell stay two drags, and when a stretch opens or closes. +const presses = new WeakMap() +// A click on lead-in words, per instance: the stretch it opens, the frames left before a second press +// can no longer make it a double-click, and whether the button is up. A double-click there picks a +// word, as it does in any text, and a drag from there selects. +const pendingOpens = new WeakMap() const Reply: ClientModule = (props, surface) => { const { Box, Link, Text } = surface.elements @@ -138,7 +147,12 @@ function textColumns(surface: { columns: number }): number { * full then a «, the words each added in its tint. */ function compose(segments: Segment[], state: State, tints: Record): string { - const write = (parts: Segment[]): string => parts.map(part => (typeof part === 'string' ? part : draw(part))).join('') + // The lead-in words of a link with no words of its own open it too, also while another stretch + // grows: `layout` never lets half a code span or link drawn by that growth run over their marks. + const write = (parts: Segment[], isParagraphEnd = false): string => + withLeadIns(parts, stretch => !state.open.includes(stretch.key), isParagraphEnd) + .map(part => (typeof part === 'string' ? part : draw(part))) + .join('') const draw = (stretch: Stretch): string => { if (stretch.long === undefined || !stretch.isClosed) return stretch.short // A short form over several lines is drawn open, or as written when its long form is empty. @@ -152,12 +166,13 @@ function compose(segments: Segment[], state: State, tints: Record) return closeAfter(long, colours === undefined ? closer : tint(colours.closer, closer), '\n') } - return write(segments) + return write(segments, true) } /** - * The rows with each letter drawn over the first character of what a press on its stretch acts on, - * its short form or its «, so nothing on screen moves; the rest of that text is marked. + * The rows with each letter drawn over the first character of what a press on its stretch acts on: + * its lead-in words if it has them, else its short form, or its «, so nothing on screen moves; the + * rest of that text is marked. */ function lettered(rows: Row[], letters: Letters | null): DrawnRun[][] { if (letters === null) return rows @@ -181,11 +196,13 @@ function lettered(rows: Row[], letters: Letters | null): DrawnRun[][] { } /** - * A press on a short form or a « opens or closes its stretch. As of Claude Code 2.1.302 a Client - * holds the pointer from a press until its release and the transcript selects nothing meanwhile, so a - * drag from anywhere else selects here: the cells it crosses are highlighted, and on release their - * text goes to the clipboard. A double-click selects here too, taking the URL or word under it at - * once. `event` is in the text's cells, the gutter left out. + * A click on a short form or a « opens or closes its stretch as the button comes up, and a click on + * lead-in words opens it once no second press has made a double-click. As of Claude Code 2.1.302 + * a Client holds the pointer from a press until its release and the transcript selects nothing + * meanwhile, so a drag from anywhere, a link included, selects here: the cells it crosses are + * highlighted, and on release their text goes to the clipboard. A double-click selects here too, + * taking the URL or word under it at once, except on a short form or a «, which the first click acted + * on. `event` is in the text's cells, the gutter left out. */ function pointer(surface: ClientSurface, event: ClientPointerEvent) { const state = surface.state @@ -194,7 +211,7 @@ function pointer(surface: ClientSurface, event: ClientPointerEvent) { const { selection } = state if (event.type === 'down' && event.button === 'left') { const target = linkAt(shown.rows, event.x, event.y) - if (target !== undefined && openOrClose(surface, target.action, target.key)) return + const leadInKey = target?.isLeadIn === true ? target.key : undefined const cell = clamped(shown.rows, event) const at = performance.now() const last = presses.get(surface) @@ -202,17 +219,25 @@ function pointer(surface: ClientSurface, event: ClientPointerEvent) { last !== undefined && at - last.at < DOUBLE_CLICK_MS && Math.abs(cell.x - last.x) <= DOUBLE_CLICK_CELLS && - Math.abs(cell.y - last.y) <= DOUBLE_CLICK_CELLS - const word = isDouble ? wordAt(shown.laid, cell) : undefined - presses.set(surface, { ...cell, at }) + Math.abs(cell.y - last.y) <= DOUBLE_CLICK_CELLS && + last.key === leadInKey + // A press that makes no double-click of a click on lead-in words lets that click open its stretch + // first, so two quick clicks on two stretches open both. + if (pendingOpens.get(surface)?.isReleased === true && !isDouble) openPending(surface) + const latest = surface.state ?? state + // A double-click picks a word in text and in lead-in words, not on a short form or a «. + const word = isDouble && (target === undefined || leadInKey !== undefined) ? wordAt(shown.laid, cell) : undefined + presses.set(surface, { ...cell, at, ...(leadInKey !== undefined && { key: leadInKey }) }) if (word !== undefined) { + pendingOpens.delete(surface) const picked: Selection = { ...word, isDragging: false, isWord: true } - surface.setState({ ...state, selection: picked }) + surface.setState({ ...latest, selection: picked }) const text = textIn(shown.laid, picked) if (text !== '') surface.post({ copy: text } satisfies CopyPost) return } - surface.setState({ ...state, selection: { anchor: cell, head: cell, isDragging: true } }) + if (leadInKey !== undefined) pendingOpens.set(surface, { key: leadInKey, framesLeft: DOUBLE_CLICK_FRAMES, isReleased: false }) + surface.setState({ ...latest, selection: { anchor: cell, head: cell, isDragging: true, ...(target !== undefined && { link: target }) } }) return } // A selection left on screen goes once the pointer moves off the paragraph: as of Claude Code @@ -224,10 +249,21 @@ function pointer(surface: ClientSurface, event: ClientPointerEvent) { if (selection?.isDragging !== true) return if (event.type === 'move') { const head = clamped(shown.rows, event) - if (head.x !== selection.head.x || head.y !== selection.head.y) surface.setState({ ...state, selection: { ...selection, head } }) + if (head.x === selection.head.x && head.y === selection.head.y) return + surface.setState({ ...state, selection: { ...selection, head, hasMoved: true } }) return } if (event.type !== 'up') return + const { link: pressed } = selection + // A press that moved to another cell was a drag, which selects; one that did not is a click on its link. + if (pressed !== undefined && selection.hasMoved !== true) { + surface.setState({ ...state, selection: null }) + // A click on lead-in words opens at a frame once a double-click can no longer follow. + const pending = pendingOpens.get(surface) + if (pressed.isLeadIn !== true) openOrClose(surface, pressed.action, pressed.key) + else if (pending !== undefined) pending.isReleased = true + return + } const text = textIn(shown.laid, selection) if (text !== '' || selection.anchor.x !== selection.head.x || selection.anchor.y !== selection.head.y) presses.delete(surface) // A click (a press that moved nowhere) takes a selection down, and so does a release off the @@ -423,7 +459,7 @@ function openOrClose(surface: ClientSurface, action: Action, key: string, if (action === 'open') { const next = [...open.filter(k => k !== stretch.key), stretch.key] surface.setState({ open: next, growth: { key: stretch.key, isOpening: true, progress: 0, duration }, asked: acted, selection: null }) - surface.post({ open: next, opened: { short: stretch.short.trim(), key: stretch.key } } satisfies ReplyPost) + surface.post({ open: next, opened: { ...openedOf(stretch, namesOf(shown.segments)), key: stretch.key } } satisfies ReplyPost) } else { surface.setState({ open, growth: { key: stretch.key, isOpening: false, progress: 1, duration }, asked: acted, selection: null }) } @@ -432,7 +468,17 @@ function openOrClose(surface: ClientSurface, action: Action, key: string, return true } +/** Opens the stretch a click on lead-in words is waiting to open; false when it could not. */ +function openPending(surface: ClientSurface): boolean { + const pending = pendingOpens.get(surface) + pendingOpens.delete(surface) + return pending !== undefined && openOrClose(surface, 'open', pending.key) +} + function tick(surface: ClientSurface) { + const pending = pendingOpens.get(surface) + if (pending !== undefined) pending.framesLeft -= 1 + if (pending !== undefined && pending.framesLeft <= 0 && pending.isReleased && openPending(surface)) return const state = surface.state const ask = asks.get(surface) if (state !== undefined && ask !== undefined && ask.n > state.asked) { diff --git a/plugins/cc-dazzle/hooks/theme.ts b/plugins/cc-dazzle/hooks/theme.ts index d7255e0..eb23e87 100644 --- a/plugins/cc-dazzle/hooks/theme.ts +++ b/plugins/cc-dazzle/hooks/theme.ts @@ -37,5 +37,7 @@ export type Style = { nativeCode?: boolean httpLinks?: boolean editorLinks?: boolean + fileLinks?: boolean + pathLinks?: boolean noteKey?: string } diff --git a/plugins/cc-dazzle/hooks/tools.test.tsx b/plugins/cc-dazzle/hooks/tools.test.tsx index bfaccfe..5817c13 100644 --- a/plugins/cc-dazzle/hooks/tools.test.tsx +++ b/plugins/cc-dazzle/hooks/tools.test.tsx @@ -100,8 +100,8 @@ test('a .ts file diff gets Prism colors', async ($, on) => { test('a long diff folds behind a more-lines button that unfolds it through the unfolded state', async ($, on) => { engine(on) - const content = Array.from({ length: 25 }, (_, i) => `line ${i + 1}`).join('\n') - const ui = await $.ui.mount(result('Write', { type: 'create', filePath: '/tmp/new.txt', content }, 'w1')) + const patch = { filePath: '/tmp/new.txt', structuredPatch: [{ oldStart: 1, newStart: 1, lines: Array.from({ length: 25 }, (_, i) => `+line ${i + 1}`) }] } + const ui = await $.ui.mount(result('Edit', patch, 'w1')) expect(await ui.find({ type: 'Text', text: /^line 20$/ })).toBeDefined() expect(await ui.find({ type: 'Text', text: /^line 21$/ })).toBeUndefined() expect((await ui.find({ key: 'fold' }))?.props.label).toBe('+5 more lines') @@ -109,7 +109,7 @@ test('a long diff folds behind a more-lines button that unfolds it through the u expect(await ui.find({ type: 'Text', text: /^line 25$/ })).toBeDefined() expect((await ui.find({ key: 'fold' }))?.props.label).toBe('show less') await ui.unmount() - const again = await $.ui.mount(result('Write', { type: 'create', filePath: '/tmp/new.txt', content }, 'w1')) + const again = await $.ui.mount(result('Edit', patch, 'w1')) expect(await again.find({ type: 'Text', text: /^line 25$/ })).toBeDefined() expect((await again.find({ key: 'fold' }))?.props.label).toBe('show less') await again.unmount() diff --git a/plugins/cc-dazzle/hooks/tools.tsx b/plugins/cc-dazzle/hooks/tools.tsx index 8a89b6b..56beb75 100644 --- a/plugins/cc-dazzle/hooks/tools.tsx +++ b/plugins/cc-dazzle/hooks/tools.tsx @@ -2,6 +2,8 @@ import type { ElementTable, RenderElement } from 'claude-code' import type { Fold, Segment } from './blocks' import { codeLine, highlightRows, isShellLang } from './blocks' +import { toolHref, toolPath } from './links' +import { CONTROL } from './markdown' import type { Style, Theme } from './theme' import { CLAUDE_DIFF } from './theme' @@ -53,16 +55,34 @@ export const shortPath = (style: Places, path: string): string => { const PATH_FIELDS = ['file_path', 'notebook_path', 'path'] -const targetOf = (style: Style, input: unknown, fields: string[]): { target?: string; isPathField: boolean } => { +const targetOf = (style: Style, input: unknown, fields: string[]): { target?: string; path?: string } => { const target = field(input, ...fields) - const isPathField = target !== undefined && PATH_FIELDS.some(k => field(input, k) === target) - return { target: isPathField ? shortPath(style, target!) : target, isPathField } + const path = target !== undefined && PATH_FIELDS.some(k => field(input, k) === target) ? target : undefined + return { target: path === undefined ? target : shortPath(style, path), path } +} + +// The short text stays the label, so widths and truncation are unchanged; the href opens the whole path. +const linkedPath = (el: ElementTable, style: Style, text: string, path: string | undefined): string | RenderElement => { + const href = path === undefined ? undefined : toolHref(style, path) + return href === undefined ? text : } const DIFF_LINES = 20 const DIFF_MAX = 400 +// Claude Code draws its own diff once a tool result's tree passes 20000 nodes, so highlighting and +// word marks spend at most this many nodes over the plain rows, shared by every card of one result. +const DIFF_EXTRA = 5000 +const PREVIEW_LINES = 10 -type DiffLine = { mark: '+' | '-' | ' '; num: number; text: string } +type DiffLine = { mark: '+' | '-' | ' ' | '\\'; num: number; text: string; hunk: number } + +type Budget = { left: number } + +const spend = (budget: Budget, cost: number): boolean => { + if (budget.left < cost) return false + budget.left -= cost + return true +} const HEX = /^#[0-9a-f]{6}$/i @@ -76,28 +96,42 @@ const lines = (value: unknown): string[] => (typeof value === 'string' && value export const diffLines = (output: unknown): DiffLine[] | undefined => { if (output === null || typeof output !== 'object') return undefined - const out = output as { type?: unknown; content?: unknown; structuredPatch?: unknown } - if (out.type === 'create' && typeof out.content === 'string') return lines(out.content).map((text, i) => ({ mark: '+', num: i + 1, text })) + const out = output as { structuredPatch?: unknown } if (!Array.isArray(out.structuredPatch) || out.structuredPatch.length === 0) return undefined const rows: DiffLine[] = [] - for (const hunk of out.structuredPatch as { oldStart?: unknown; newStart?: unknown; lines?: unknown }[]) { + for (const [h, hunk] of (out.structuredPatch as { oldStart?: unknown; newStart?: unknown; lines?: unknown }[]).entries()) { if (typeof hunk?.oldStart !== 'number' || typeof hunk.newStart !== 'number' || !Array.isArray(hunk.lines)) return undefined - let [o, n] = [hunk.oldStart, hunk.newStart] - for (const l of hunk.lines) { - if (typeof l !== 'string' || l.startsWith('\\')) continue - const mark = l[0] === '+' || l[0] === '-' ? l[0] : ' ' - let num: number - if (mark === '-') num = o++ - else { - if (mark === ' ') o++ - num = n++ - } - rows.push({ mark, num, text: l.slice(1) }) + rows.push(...hunkRows(hunk.lines, hunk.oldStart, hunk.newStart, h)) + } + return rows +} + +// A "\ No newline at end of file" marker keeps a row of its own, numbered 0 and counted as no line. +const hunkRows = (lines: unknown[], oldStart: number, newStart: number, hunk: number): DiffLine[] => { + const rows: DiffLine[] = [] + let [o, n] = [oldStart, newStart] + for (const l of lines) { + if (typeof l !== 'string') continue + const mark = (l[0] === '+' || l[0] === '-' || l[0] === '\\' ? l[0] : ' ') as DiffLine['mark'] + let num = 0 + if (mark === '-') num = o++ + else if (mark !== '\\') { + if (mark === ' ') o++ + num = n++ } + rows.push({ mark, num, text: l.slice(1).replace(CONTROL, ''), hunk }) } return rows } +const realLines = (rows: DiffLine[]): number => rows.filter(r => r.mark !== '\\').length + +const firstLines = (rows: DiffLine[], count: number): DiffLine[] => { + let [end, seen] = [0, 0] + while (end < rows.length && (rows[end]!.mark === '\\' || seen < count)) if (rows[end++]!.mark !== '\\') seen++ + return rows.slice(0, end) +} + const EXT_LANGS: Record = { py: 'python', ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'tsx', js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'jsx', json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml', go: 'go', rs: 'rust', rb: 'ruby', java: 'java', kt: 'kotlin', kts: 'kotlin', @@ -113,6 +147,14 @@ export const diffLang = (path: unknown): string | undefined => { return ext === undefined ? undefined : EXT_LANGS[ext] } +// Removed and added runs pair up in order within a hunk, across a no-newline marker between them. +const runOf = (rows: readonly DiffLine[], from: number, mark: '+' | '-'): number[] => { + const at: number[] = [] + const hunk = rows[from]?.hunk + for (let i = from; i < rows.length && rows[i]!.hunk === hunk && (rows[i]!.mark === mark || rows[i]!.mark === '\\'); i++) if (rows[i]!.mark === mark) at.push(i) + return at +} + export const changedRanges = (rows: readonly DiffLine[]): ([number, number] | undefined)[] => { const ranges: ([number, number] | undefined)[] = rows.map(() => undefined) let i = 0 @@ -121,10 +163,11 @@ export const changedRanges = (rows: readonly DiffLine[]): ([number, number] | un i++ continue } - const dels: number[] = [] - while (i < rows.length && rows[i]!.mark === '-') dels.push(i++) - const adds: number[] = [] - while (i < rows.length && rows[i]!.mark === '+') adds.push(i++) + const dels = runOf(rows, i, '-') + i = dels.at(-1)! + 1 + while (rows[i]?.mark === '\\') i++ + const adds = rows[i]?.hunk === rows[dels[0]!]!.hunk ? runOf(rows, i, '+') : [] + if (adds.length) i = adds.at(-1)! + 1 for (let k = 0; k < Math.min(dels.length, adds.length); k++) { const a = rows[dels[k]!]!.text const b = rows[adds[k]!]!.text @@ -159,54 +202,122 @@ const markRange = (segs: readonly Segment[], range: [number, number] | undefined return out } -const renderClaudeDiff = (el: ElementTable, style: Style, output: unknown, rows: DiffLine[], shown: DiffLine[], gutter: number): RenderElement[] => { +// Each hunk's new side is highlighted as one text, so a comment or string spanning lines keeps its +// color and one hunk's open string cannot color the next. Removed lines stay plain, as in Claude Code. +const paintRows = (style: Style, rows: readonly DiffLine[], lang: string | undefined): (Segment[] | undefined)[] => { + const out: (Segment[] | undefined)[] = rows.map(() => undefined) + if (lang === undefined || isShellLang(lang)) return out + for (const hunk of new Set(rows.map(r => r.hunk))) { + const at = rows.flatMap((r, i) => (r.hunk === hunk && (r.mark === ' ' || r.mark === '+') ? [i] : [])) + const painted = highlightRows(style, at.map(i => rows[i]!.text), lang) + if (painted) at.forEach((i, k) => (out[i] = painted[k])) + } + return out +} + +const renderClaudeDiff = (el: ElementTable, style: Style, path: string | undefined, rows: DiffLine[], shown: DiffLine[], budget: Budget): RenderElement[] => { const { Box, Text } = el const t = style.theme const c = isDark(t.codeText) ? CLAUDE_DIFF.dark : CLAUDE_DIFF.light - const lang = diffLang((output as { filePath?: unknown } | null)?.filePath) - const painted = lang === undefined || isShellLang(lang) ? null : highlightRows(style, shown.map(r => r.text), lang) + const gutter = String(Math.max(0, ...shown.map(r => r.num))).length + const painted = paintRows(style, shown, diffLang(path)) const ranges = changedRanges(rows).slice(0, shown.length) - return shown.map((r, i) => { + return shown.flatMap((r, i) => { + const gap = i > 0 && r.hunk !== shown[i - 1]!.hunk ? [...] : [] + if (r.mark === '\\') return [...gap, {' '.repeat(gutter + 3)}{r.text}] const bg = r.mark === '+' ? c.addBg : r.mark === '-' ? c.delBg : undefined const word = r.mark === '+' ? c.addWord : c.delWord const mark = r.mark === '+' ? c.addMark : r.mark === '-' ? c.delMark : c.number - const segs = markRange(painted?.[i] ?? [{ text: r.text, color: t.codeText, italic: false }], ranges[i]) - return ( + const plain: Segment = { text: r.text, color: t.codeText, italic: false } + const marked = markRange(painted[i] ?? [plain], ranges[i]) + const segs = marked.length > 1 && !spend(budget, 2 * marked.length) ? [{ ...plain, hit: false }] : marked + const one = segs.length === 1 && !segs[0]!.hit ? segs[0]! : undefined + return [ + ...gap, - {`${String(r.num).padStart(gutter)} `} - {`${r.mark === ' ' ? ' ' : r.mark} `} - - {segs.length === 0 ? ' ' : segs.map((s, k) => {s.text})} - - - ) + {`${String(r.num).padStart(gutter)} ${r.mark} `} + {one || segs.length === 0 ? ( + {one?.text || ' '} + ) : ( + + {segs.map((s, k) => {s.text})} + + )} + , + ] }) } -const diffBox = (el: ElementTable, style: Style, output: unknown, rows: DiffLine[], fold: Fold, id: string, key: string, title?: string): RenderElement => { +const diffBox = (el: ElementTable, style: Style, path: string | undefined, rows: DiffLine[], fold: Fold, id: string, key: string, budget: Budget, title?: string): RenderElement => { const { Box, Text } = el const t = style.theme const c = isDark(t.codeText) ? CLAUDE_DIFF.dark : CLAUDE_DIFF.light const added = rows.filter(r => r.mark === '+').length const removed = rows.filter(r => r.mark === '-').length - const toggle = rows.length > DIFF_LINES ? fold?.(id, rows.length - DIFF_LINES, `fold${key}`) : null - const shown = rows.slice(0, toggle?.folded === false ? DIFF_MAX : DIFF_LINES) - const gutter = String(Math.max(0, ...shown.map(r => r.num))).length + const n = realLines(rows) + const toggle = n > DIFF_LINES ? fold?.(id, n - DIFF_LINES, `fold${key}`) : null + const shown = firstLines(rows, toggle?.folded === false ? DIFF_MAX : DIFF_LINES) + const hidden = n - realLines(shown) return ( - {title === undefined ? null : {`${title} `}} + {title === undefined ? null : {linkedPath(el, style, title, path)} } {`+${added}`} {`−${removed}`} - {renderClaudeDiff(el, style, output, rows, shown, gutter)} - {rows.length > shown.length && toggle?.folded !== true ? {`… +${rows.length - shown.length} lines`} : null} + {renderClaudeDiff(el, style, path, rows, shown, budget)} + {hidden > 0 && toggle?.folded !== true ? {`… +${hidden} lines`} : null} + {toggle?.element ?? null} + + ) +} + +type WholeFile = { kind: 'created' | 'deleted'; path: string; lines: string[] } + +export type FileChange = { kind: 'modified'; path: string; rows: DiffLine[] } | WholeFile | { kind: 'renamed'; path: string; to: string } + +const lineCount = (n: number): string => (n === 0 ? 'empty' : `${n} ${n === 1 ? 'line' : 'lines'}`) + +// A new file shows its first 10 lines and a deleted one only its header; either opens to 400 lines. +// Unmarked rows are Write's preview: numbered code with no diff marks, as Claude Code draws it. +const wholeCard = (el: ElementTable, style: Style, file: WholeFile, fold: Fold, id: string, key: string, budget: Budget, marked: boolean): RenderElement => { + const { Box, Text } = el + const t = style.theme + const n = file.lines.length + const created = file.kind === 'created' + const toggle = created ? (n > PREVIEW_LINES ? fold?.(id, n - PREVIEW_LINES, `fold${key}`) : null) : n > 0 ? fold?.(id, n, `fold${key}`, `show ${lineCount(n)}`) : null + const shown = Math.min(n, toggle?.folded === false ? DIFF_MAX : created ? PREVIEW_LINES : 0) + const rest = n > shown && (toggle ? !toggle.folded : created) + const head = ( + + {created ? 'Created ' : 'Deleted '} + {linkedPath(el, style, shortPath(style, file.path), file.path)} + {` (${lineCount(n)})`} + + ) + if (shown === 0) return {head}{toggle?.element ?? null} + const mark = !marked ? ' ' : created ? '+' : '-' + const rows = file.lines.slice(0, shown).map((text, i): DiffLine => ({ mark, num: i + 1, text, hunk: 0 })) + return ( + + {head} + {renderClaudeDiff(el, style, file.path, rows, rows, budget)} + {rest ? {`… +${n - shown} lines`} : null} {toggle?.element ?? null} ) } +const renamedLine = ({ Text }: ElementTable, style: Style, from: string, to: string, key: string): RenderElement => ( + + {'Renamed '} + {shortPath(style, from)} + {' → '} + {shortPath(style, to)} + +) + const diffRow = ({ Box, Text }: ElementTable, cards: RenderElement[], below: boolean): RenderElement => ( {below ? ' ' : ` ${TREE_MARK}`.padEnd(5)} @@ -214,14 +325,32 @@ const diffRow = ({ Box, Text }: ElementTable, cards: RenderElement[], below: boo ) +// An Edit with an empty old_string on a new or empty file is how Claude Code creates a file with Edit. +const editedIntoBeing = (output: unknown, rows: DiffLine[]): WholeFile | undefined => { + const out = output as { filePath?: unknown; oldString?: unknown; originalFile?: unknown } + const fresh = out.oldString === '' && (out.originalFile === null || out.originalFile === '') && rows.every(r => r.mark === '+' || r.mark === '\\') + return fresh && typeof out.filePath === 'string' ? { kind: 'created', path: out.filePath, lines: rows.filter(r => r.mark === '+').map(r => r.text) } : undefined +} + +const written = (output: unknown): WholeFile | undefined => { + const out = output as { type?: unknown; filePath?: unknown; content?: unknown } + return out.type === 'create' && typeof out.filePath === 'string' && typeof out.content === 'string' ? { kind: 'created', path: out.filePath, lines: lines(out.content.replace(CONTROL, '')) } : undefined +} + +// A write held for review goes to Claude Code, so its "Staged for your review" line stays. export const renderDiffCard = (el: ElementTable, style: Style, output: unknown, fold: Fold, id: string): RenderElement | undefined => { + if (output === null || typeof output !== 'object' || (output as { staged?: unknown }).staged === true) return undefined + const budget = { left: DIFF_EXTRA } + const write = written(output) + if (write) return diffRow(el, [wholeCard(el, style, write, fold, id, '', budget, false)], false) const rows = diffLines(output) - return rows === undefined ? undefined : diffRow(el, [diffBox(el, style, output, rows, fold, id, '')], false) + if (rows === undefined) return undefined + const made = editedIntoBeing(output, rows) + const path = (output as { filePath?: unknown }).filePath + return diffRow(el, [made ? wholeCard(el, style, made, fold, id, '', budget, true) : diffBox(el, style, typeof path === 'string' ? path : undefined, rows, fold, id, '', budget)], false) } -type EditedFile = { path: string; rows: DiffLine[] } - -export type BashEdits = { files: EditedFile[]; more: number; shared: boolean } +export type BashEdits = { files: FileChange[]; more: number; shared: boolean } export const EDIT_NOTES = { first: 'every file that changed while this ran, by this command or anything else: a view of the changes, not an audit of the command', @@ -236,13 +365,43 @@ export const bashEdits = (output: unknown): BashEdits | undefined => { if (diff === null || typeof diff !== 'object') return undefined const { files, moreFiles, shared } = diff as { files?: unknown; moreFiles?: unknown; shared?: unknown } if (!Array.isArray(files) || files.length === 0) return undefined - const edited: EditedFile[] = [] - for (const file of files as ({ filePath?: unknown; hunks?: unknown } | null)[]) { - const rows = diffLines({ structuredPatch: file?.hunks }) - if (typeof file?.filePath !== 'string' || rows === undefined) return undefined - edited.push({ path: file.filePath, rows }) + const changed = files.map(fileChange) + if (!changed.every((f): f is FileChange => f !== undefined)) return undefined + return { files: renamed(changed), more: typeof moreFiles === 'number' && moreFiles > 0 ? moreFiles : 0, shared: shared === true } +} + +// Claude Code flags the files a command created and deleted, and sends an empty file with no hunks. +const fileChange = (entry: unknown): FileChange | undefined => { + const file = entry as { filePath?: unknown; hunks?: unknown; created?: unknown; deleted?: unknown } | null + const kind = file?.created === true ? 'created' : file?.deleted === true ? 'deleted' : 'modified' + const rows = kind !== 'modified' && Array.isArray(file?.hunks) && file.hunks.length === 0 ? [] : diffLines({ structuredPatch: file?.hunks }) + if (typeof file?.filePath !== 'string' || rows === undefined) return undefined + if (kind === 'modified') return { kind, path: file.filePath, rows } + const mark = kind === 'created' ? '+' : '-' + return { kind, path: file.filePath, lines: rows.filter(r => r.mark === mark).map(r => r.text) } +} + +// Empty files never pair, so `rm a/__init__.py; touch b/__init__.py` does not read as a rename. +const pairable = (file: FileChange): file is WholeFile => (file.kind === 'created' || file.kind === 'deleted') && file.lines.length > 0 + +const sameLines = (a: string[], b: string[]): boolean => a.length === b.length && a.every((line, i) => line === b[i]) + +// A move reaches bashEditDiff as a deleted file and a created file with the same lines. +const renamed = (files: FileChange[]): FileChange[] => { + const paired = new Set() + const out: FileChange[] = [] + for (const file of files) { + if (paired.has(file)) continue + const twin = pairable(file) ? files.find((other): other is WholeFile => !paired.has(other) && pairable(other) && other.kind !== file.kind && sameLines(other.lines, file.lines)) : undefined + if (!twin) { + out.push(file) + continue + } + paired.add(file).add(twin) + const [from, to] = file.kind === 'deleted' ? [file, twin] : [twin, file] + out.push({ kind: 'renamed', path: from.path, to: to.path }) } - return { files: edited, more: typeof moreFiles === 'number' && moreFiles > 0 ? moreFiles : 0, shared: shared === true } + return out } export const withoutBashEdits = (output: unknown): unknown => { @@ -257,7 +416,11 @@ export const hasShellOutput = (output: unknown): boolean => { } export const renderBashEdits = (el: ElementTable, style: Style, edits: BashEdits, fold: Fold, id: string, below = false, notes: readonly string[] = []): RenderElement => { - const cards = edits.files.map((file, i) => diffBox(el, style, { filePath: file.path }, file.rows, fold, `${id}:${i}`, String(i), shortPath(style, file.path))) + const budget = { left: DIFF_EXTRA } + const cards = edits.files.map((file, i) => + file.kind === 'modified' ? diffBox(el, style, file.path, file.rows, fold, `${id}:${i}`, String(i), budget, shortPath(style, file.path)) + : file.kind === 'renamed' ? renamedLine(el, style, file.path, file.to, String(i)) + : wholeCard(el, style, file, fold, `${id}:${i}`, String(i), budget, true)) const more = edits.more ? [{`… +${edits.more} more ${edits.more === 1 ? 'file' : 'files'}`}] : [] const said = notes.map((note, i) => {`(${note})`}) return diffRow(el, [...cards, ...more, ...said], below) @@ -278,15 +441,15 @@ export const renderToolRow = (el: ElementTable, style: Style, row: ToolRow): Ren const t = style.theme const isShell = row.tool === 'Bash' || row.tool === 'PowerShell' const verb = VERBS[row.tool] ?? row.tool.replace(/^mcp__([^_]+)__/, '$1 ') - const found = isShell ? { target: field(row.input, 'command')?.split('\n')[0], isPathField: false } : targetOf(style, row.input, TARGET_FIELDS) + const found: { target?: string; path?: string } = isShell ? { target: field(row.input, 'command')?.split('\n')[0] } : targetOf(style, row.input, TARGET_FIELDS) const target = found.target const dot = statusColor(t, row) - const isPath = target !== undefined && (found.isPathField || /^(~|\.{0,2}\/|[A-Za-z]:\\)/.test(target)) + const isPath = target !== undefined && (found.path !== undefined || /^(~|\.{0,2}\/|[A-Za-z]:\\)/.test(target)) return toolLayout(el, dot, ( {verb} {target === undefined ? null : } - {target === undefined ? null : isShell ? codeLine(el, style, target, 'bash', 'cmd') : {target}} + {target === undefined ? null : isShell ? codeLine(el, style, target, 'bash', 'cmd') : {linkedPath(el, style, target, toolPath(row.tool, found.path))}} {row.isInterrupted ? interrupted : row.isErrored ? failed : null} )) @@ -364,13 +527,15 @@ export const renderToolGroup = (el: ElementTable, style: Style, calls: readonly const running = isActive && calls.some(c => c.isRunning) const dot = failed ? t.error : running ? t.accent : t.success const last = calls[calls.length - 1] - const lastTarget = last ? targetOf(style, last.input, ['command', ...TARGET_FIELDS]).target?.split('\n')[0] : undefined + const lastFound = last ? targetOf(style, last.input, ['command', ...TARGET_FIELDS]) : {} + const lastTarget = lastFound.target?.split('\n')[0] + const lastLink = lastTarget ? linkedPath(el, style, lastTarget, toolPath(last?.tool, lastFound.path)) : undefined const summary = groupSummary(calls) return toolLayout(el, dot, ( {summary} {failed ? {` · ${failed} failed`} : null} - {lastTarget ? {` · last: ${lastTarget}`} : null} + {lastLink === undefined ? null : {typeof lastLink === 'string' ? ` · last: ${lastLink}` : [' · last: ', lastLink]}} )) } diff --git a/plugins/cc-dazzle/hooks/upstream.test.tsx b/plugins/cc-dazzle/hooks/upstream.test.tsx index cbd6006..dcb9f5b 100644 --- a/plugins/cc-dazzle/hooks/upstream.test.tsx +++ b/plugins/cc-dazzle/hooks/upstream.test.tsx @@ -6,6 +6,7 @@ import type { Block } from './markdown' import { parse } from './markdown' import { inlineMarks, mermaidText } from './mermaid' import { width } from './text' +import { THEME } from './theme' // Fixes ported from upstream prismantis 0.15.1 to 0.19.0, each with its upstream test. @@ -281,6 +282,33 @@ test('a gantt chart places each task on a shared time axis', () => { expect(art[2]).toBe('Build') }) +test('a line holding only a tag draws dim on its own, never as a path or joined to the paragraph next to it', () => { + const blocks = parse('Answer text.\n\n\n- First point, see /tmp/example.\n\nA closing sentence.\n', hl) + expect(blocks.map(b => b.kind)).toEqual(['paragraph', 'paragraph', 'list', 'paragraph', 'paragraph']) + expect(blocks[1]).toMatchObject({ inline: [{ kind: 'dim', text: '' }], raw: '' }) + expect(blocks[3]?.raw).toBe('A closing sentence.') + expect(blocks[4]).toMatchObject({ inline: [{ kind: 'dim', text: '' }], raw: '' }) + expect(parse('a bold word\n
', hl).map(b => b.kind === 'paragraph' && b.inline.some(n => n.kind === 'dim'))).toEqual([false]) +}) + +const FINE_GANTT = 'gantt\n dateFormat YYYY-MM-DD\n section Build\n Two days :a, 2026-10-01, 2d\n Two and a bit :b, 2026-10-01, 2.4d\n Two and a half :c, 2026-10-01, 2.5d\n Rest :d, after a, 5d' + +test('gantt bars end on an eighth of a cell and start on a half, so close durations draw apart', () => { + const art = mermaidText(FINE_GANTT, 84)!.split('\n') + const bar = (name: string) => art.find(l => l.trimStart().startsWith(name))!.slice(18) + expect(new Set(['Two days', 'Two and a bit', 'Two and a half'].map(bar)).size).toBe(3) + expect(bar('Two days')).toMatch(/^█+[▎▍▌▋▊▉]$/) + expect(bar('Rest').trimStart()).toMatch(/^▐█+$/) +}) + +test('a gantt bar end drawn in part of a cell takes its bar color, while axis ticks keep theirs', async $ => { + const ui = await $.ui.mount(reply('```mermaid\n' + FINE_GANTT + '\n```', 84)) + expect(await ui.find({ type: 'Text', text: /^█+[▎▍▌▋▊▉]$/ })).toBeDefined() + expect(await ui.find({ type: 'Text', text: /^▐█+$/ })).toBeDefined() + expect((await ui.find({ type: 'Text', text: /^▏(─+▏)+─*$/ }))?.props.color).toBe(THEME.diagram) + await ui.unmount() +}) + test('pie and gantt sources the chart cannot read show a failure note, not a broken chart', async $ => { for (const source of ['pie\n "a" : -1', 'pie\n "a" : 0', 'gantt\n Task : 3d', 'gantt\n dateFormat X\n Task : 1, 2', 'gantt\n Task : after nope, 1d']) { expect(mermaidText(source, 80)).toBeNull() @@ -290,6 +318,13 @@ test('pie and gantt sources the chart cannot read show a failure note, not a bro } }) +test('a gantt chart fits an 80-column terminal', async $ => { + const ui = await $.ui.mount(reply('```mermaid\n' + FINE_GANTT + '\n```', 80)) + expect(await ui.find({ type: 'Text', text: /terminal is/ })).toBeUndefined() + expect(await ui.find({ type: 'Text', text: /^█+/ })).toBeDefined() + await ui.unmount() +}) + test('pie and gantt charts shrink to a narrow terminal', () => { const pie = 'pie\n "A very long slice label that goes on" : 3\n "b" : 1' const gantt = 'gantt\n dateFormat HH:mm\n Deploy : 10:00, 45m\n Verify : 30m' diff --git a/plugins/cc-dazzle/types/index.d.ts b/plugins/cc-dazzle/types/index.d.ts index 8ce586a..d865698 100644 --- a/plugins/cc-dazzle/types/index.d.ts +++ b/plugins/cc-dazzle/types/index.d.ts @@ -11,8 +11,12 @@ declare module 'claude-code' { took: StateFamily /** Per paragraph of a reply drawn by a stretch Client: the keys of the stretches open in it. */ stretchOpen: StateFamily - /** Short forms of the stretches the reader opened since their last prompt. */ - stretchSeen: string[] + /** + * The stretches the reader opened since their last prompt: each as written and by the name the + * note to Claude gives it, which together tell apart two with the same short form after + * different words. + */ + stretchSeen: Shaped<{ source: string; name: string }[]> /** Per reply: the count each open stretch was opened at, which picks its colour, and the next count. */ stretchOrder: StateFamily<{ order: Record; count: number }> /** The request id of the reply in which a stretch was opened last, until the next prompt. */