diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index f1ab17aec..d3bc17e6d 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -230,8 +230,9 @@ $ wt switch --create feature-auth Starlight and Expressive Code create the frame and copy button. The Worktrunk plugin highlights `console` commands as Bash, renders `$ ` as a prompt, and -makes mixed blocks copy only their commands. Comment lines and blank recipe -separators remain copyable; captured output does not. Snapshot-backed output +makes blocks copy their commands rather than their output. A block with one +command copies it with its comment lines and blank recipe separators; a block +with several gives each command its own copy button. Snapshot-backed output gets its exact ANSI roles from the generated style manifest, while hand-written output uses a conservative marker fallback. Generated Clap help fences carry the `wt-command-reference` marker, which the plugin expands into semantic diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 44506ba59..948986189 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -65,6 +65,8 @@ export default defineConfig({ ThemeSelect: './src/components/ThemeSelect.astro', }, expressiveCode: { + // Shell examples render as plain code, without terminal window chrome. + defaultProps: { frame: 'code' }, plugins: [pluginWorktrunkTerminal()], themes: [worktrunkDarkCodeTheme, worktrunkLightCodeTheme], useStarlightUiThemeColors: true, diff --git a/docs/src/content/docs/faq.md b/docs/src/content/docs/faq.md index 4ac9ebed4..9c901eea0 100644 --- a/docs/src/content/docs/faq.md +++ b/docs/src/content/docs/faq.md @@ -69,10 +69,10 @@ Not natively — stacked-branch workflows are a large design space, so Worktrunk Stash the changes, create the worktree, then pop: -```console -$ git stash push -u # -u also stashes untracked files -$ wt switch --create feature # new branch off the default branch -$ git stash pop # changes reappear in the new worktree +```bash +git stash push -u # -u also stashes untracked files +wt switch --create feature # new branch off the default branch +git stash pop # changes reappear in the new worktree ``` The stash lives in the shared `.git` directory, so it's reachable from the new worktree. The original branch is left clean. diff --git a/docs/src/content/docs/step.md b/docs/src/content/docs/step.md index d7c1ff340..e7cebe52b 100644 --- a/docs/src/content/docs/step.md +++ b/docs/src/content/docs/step.md @@ -425,10 +425,10 @@ $ wt step diff | delta Equivalent to: -```console -$ cp "$(git rev-parse --git-dir)/index" /tmp/idx -$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . -$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) +```bash +cp "$(git rev-parse --git-dir)/index" /tmp/idx +GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . +GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) ``` `git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified. diff --git a/docs/src/plugins/worktrunk-terminal.mjs b/docs/src/plugins/worktrunk-terminal.mjs index 94b1da77a..4b898ff45 100644 --- a/docs/src/plugins/worktrunk-terminal.mjs +++ b/docs/src/plugins/worktrunk-terminal.mjs @@ -81,24 +81,6 @@ function copyControl(text, className) { }; } -function removeTitlelessHeader(blockAst) { - blockAst.children = blockAst.children.filter((child) => { - if ( - child?.type !== 'element' - || child.tagName !== 'figcaption' - || !child.properties?.className?.includes('header') - ) return true; - - const title = child.children?.find((element) => ( - element?.type === 'element' - && element.properties?.className?.includes('title') - )); - return Boolean( - title?.children?.some((element) => element.type !== 'text' || element.value.trim()), - ); - }); -} - function markerTone(marker) { if (/^[+↑⇡✓]/u.test(marker)) return 'positive'; if (/^[-↓⇣✗]/u.test(marker)) return 'negative'; @@ -353,90 +335,90 @@ function renderCommandReference(lineAst, text) { } /** - * Keeps terminal examples as ordinary Markdown while removing untitled frame - * chrome, adding command prompts, and copying commands rather than output. + * Keeps terminal examples as ordinary Markdown while adding command prompts + * and copying commands rather than output. */ export function pluginWorktrunkTerminal() { return { name: 'Worktrunk terminal prompts', baseStyles: ` - .expressive-code .frame.is-terminal .ec-line.wt-command .code::before { + .expressive-code .frame .ec-line.wt-command .code::before { content: '$ '; color: var(--wt-ink-muted); user-select: none; } - .expressive-code .frame.is-terminal .ec-line.wt-output .code { + .expressive-code .frame .ec-line.wt-output .code { color: var(--wt-terminal-ink); } - .expressive-code .frame.is-terminal .ec-line.wt-copyable .code { + .expressive-code .frame .ec-line.wt-copyable .code { color: var(--wt-terminal-dim); } - .expressive-code .frame.is-terminal .wt-positive { + .expressive-code .frame .wt-positive { color: var(--sl-color-green-high); font-weight: 650; } - .expressive-code .frame.is-terminal .wt-negative { + .expressive-code .frame .wt-negative { color: var(--sl-color-red-high); font-weight: 650; } - .expressive-code .frame.is-terminal .wt-warning { + .expressive-code .frame .wt-warning { color: var(--sl-color-orange-high); font-weight: 650; } - .expressive-code .frame.is-terminal .wt-terminal-red { + .expressive-code .frame .wt-terminal-red { color: var(--wt-terminal-red); } - .expressive-code .frame.is-terminal .wt-terminal-green { + .expressive-code .frame .wt-terminal-green { color: var(--wt-terminal-green); } - .expressive-code .frame.is-terminal .wt-terminal-yellow { + .expressive-code .frame .wt-terminal-yellow { color: var(--wt-terminal-yellow); } - .expressive-code .frame.is-terminal .wt-terminal-blue { + .expressive-code .frame .wt-terminal-blue { color: var(--wt-terminal-blue); } - .expressive-code .frame.is-terminal .wt-terminal-magenta { + .expressive-code .frame .wt-terminal-magenta { color: var(--wt-terminal-magenta); } - .expressive-code .frame.is-terminal .wt-terminal-cyan { + .expressive-code .frame .wt-terminal-cyan { color: var(--wt-terminal-cyan); } - .expressive-code .frame.is-terminal .wt-terminal-gray { + .expressive-code .frame .wt-terminal-gray { color: var(--wt-ink-muted); } - .expressive-code .frame.is-terminal .wt-terminal-gutter { + .expressive-code .frame .wt-terminal-gutter { display: inline-block; background: var(--wt-terminal-gutter); } - .expressive-code .frame.is-terminal .wt-terminal-bold { + .expressive-code .frame .wt-terminal-bold { font-weight: 600; } - .expressive-code .frame.is-terminal .wt-terminal-dim { + .expressive-code .frame .wt-terminal-dim { color: var(--wt-terminal-dim); opacity: 1; } - .expressive-code .frame.is-terminal .wt-terminal-red.wt-terminal-dim { + .expressive-code .frame .wt-terminal-red.wt-terminal-dim { color: color-mix(in srgb, var(--wt-terminal-red) 62%, var(--wt-terminal-dim)); } - .expressive-code .frame.is-terminal .wt-terminal-green.wt-terminal-dim { + .expressive-code .frame .wt-terminal-green.wt-terminal-dim { color: color-mix(in srgb, var(--wt-terminal-green) 62%, var(--wt-terminal-dim)); } - .expressive-code .frame.is-terminal .wt-terminal-yellow.wt-terminal-dim { + .expressive-code .frame .wt-terminal-yellow.wt-terminal-dim { color: color-mix(in srgb, var(--wt-terminal-yellow) 62%, var(--wt-terminal-dim)); } - .expressive-code .frame.is-terminal .wt-terminal-blue.wt-terminal-dim { + .expressive-code .frame .wt-terminal-blue.wt-terminal-dim { color: color-mix(in srgb, var(--wt-terminal-blue) 62%, var(--wt-terminal-dim)); } - .expressive-code .frame.is-terminal .wt-terminal-magenta.wt-terminal-dim { + .expressive-code .frame .wt-terminal-magenta.wt-terminal-dim { color: color-mix(in srgb, var(--wt-terminal-magenta) 62%, var(--wt-terminal-dim)); } - .expressive-code .frame.is-terminal .wt-terminal-cyan.wt-terminal-dim { + .expressive-code .frame .wt-terminal-cyan.wt-terminal-dim { color: color-mix(in srgb, var(--wt-terminal-cyan) 62%, var(--wt-terminal-dim)); } - .expressive-code .frame.is-terminal .wt-terminal-italic { + .expressive-code .frame .wt-terminal-italic { font-style: italic; } - .expressive-code .frame.is-terminal .wt-terminal-underline { + .expressive-code .frame .wt-terminal-underline { text-decoration: underline; text-underline-offset: 0.14em; } @@ -446,8 +428,21 @@ export function pluginWorktrunkTerminal() { .expressive-code .frame.wt-line-copies .ec-line.wt-command .code { padding-inline-end: calc(2rem + var(--ec-codePadInl)); } + /* A line's control never outgrows its line, so adjacent controls can't overlap. */ .expressive-code .frame .wt-line-copy { - inset-block-start: 0; + inset-block: 0; + } + .expressive-code .frame .wt-line-copy button { + align-self: flex-start; + max-height: 100%; + } + /* Expressive Code sizes the icon from the button's height; keep its desktop size. */ + .expressive-code .frame .wt-line-copy button::after { + margin: 0; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: 1.05rem; + mask-size: 1.05rem; } @media (hover: hover) { .expressive-code .frame:hover .wt-line-copy button:not(:hover) { @@ -456,11 +451,6 @@ export function pluginWorktrunkTerminal() { .expressive-code .frame .ec-line:hover .wt-line-copy button:not(:hover) { opacity: 0.75; } - /* The block control and the first line's sit in the same corner, so - hand the corner to whichever line the pointer is on. */ - .expressive-code .frame.wt-line-copies:has(.ec-line:hover) > .copy button:not(:hover) { - opacity: 0; - } } .expressive-code .frame.wt-command-reference .wt-help-heading { color: var(--wt-copper); @@ -506,7 +496,6 @@ export function pluginWorktrunkTerminal() { if (codeBlock.language === 'console') { consoleBlocks.add(codeBlock); codeBlock.language = 'bash'; - codeBlock.props.frame = 'terminal'; return; } if (codeBlock.metaOptions.value(commandReferenceMeta) === true) { @@ -530,6 +519,10 @@ export function pluginWorktrunkTerminal() { copyableLines.add(lineIndex); } } + // Per-line copy controls sit at the end of their line, so a block with + // several commands wraps rather than scrolling them out of view. Captured + // output keeps its columns, so a block carrying it never wraps. + if (commandLines.size > 1 && !hasOutput) codeBlock.props.wrap = true; const outputLines = lines .map((line, lineIndex) => ({ line, lineIndex })) .filter(({ lineIndex }) => ( @@ -543,7 +536,6 @@ export function pluginWorktrunkTerminal() { terminalBlocks.set(codeBlock, { commandLines, copyableLines, - hasOutput, recordedByLine, }); }, @@ -562,9 +554,8 @@ export function pluginWorktrunkTerminal() { : 'wt-output'; addClass(renderData.lineAst, className); const text = line?.text ?? codeBlock.getLines()[lineIndex].text; - // A block listing several commands is as often a menu of alternatives - // as a recipe, and nothing in the markup tells them apart — so each - // command line gets its own control alongside the block's. + // Most blocks listing several commands are menus of alternatives, so + // each command line gets its own control in place of the block's. if (className === 'wt-command' && terminal.commandLines.size > 1) { renderData.lineAst.children ??= []; renderData.lineAst.children.push(copyControl(text, 'wt-line-copy')); @@ -576,7 +567,6 @@ export function pluginWorktrunkTerminal() { } }, postprocessRenderedBlock({ codeBlock, renderData }) { - removeTitlelessHeader(renderData.blockAst); if (commandReferenceBlocks.has(codeBlock)) { addClass(renderData.blockAst, 'wt-command-reference'); // Generated `--help` output is reference material — its copy button @@ -585,14 +575,12 @@ export function pluginWorktrunkTerminal() { return; } const terminal = terminalBlocks.get(codeBlock); - if (!terminal) { - if (renderData.blockAst.properties?.className?.includes('is-terminal')) { - addClass(renderData.blockAst, 'wt-commands-only'); - } + if (!terminal) return; + if (terminal.commandLines.size > 1) { + addClass(renderData.blockAst, 'wt-line-copies'); + removeCopyControl(renderData.blockAst); return; } - if (!terminal.hasOutput) addClass(renderData.blockAst, 'wt-commands-only'); - if (terminal.commandLines.size > 1) addClass(renderData.blockAst, 'wt-line-copies'); if (terminal.commandLines.size === 0 && terminal.copyableLines.size === 0) { removeCopyControl(renderData.blockAst); return; diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css index 5a5870b29..49073a649 100644 --- a/docs/src/styles/custom.css +++ b/docs/src/styles/custom.css @@ -570,21 +570,18 @@ header.header { -webkit-overflow-scrolling: touch; } +/* Captured command output keeps its columns and scrolls; other code wraps. */ @media (max-width: 42rem) { - .expressive-code .frame:not(.is-terminal) pre, - .expressive-code .frame.is-terminal.wt-commands-only pre { + .expressive-code .frame:not(:has(.wt-output)) pre { overflow-x: clip; } - .expressive-code .frame:not(.is-terminal) .ec-line, - .expressive-code .frame:not(.is-terminal) .code, - .expressive-code .frame.is-terminal.wt-commands-only .ec-line, - .expressive-code .frame.is-terminal.wt-commands-only .code { + .expressive-code .frame:not(:has(.wt-output)) .ec-line, + .expressive-code .frame:not(:has(.wt-output)) .code { min-width: 0; } - .expressive-code .frame:not(.is-terminal) .code, - .expressive-code .frame.is-terminal.wt-commands-only .code { + .expressive-code .frame:not(:has(.wt-output)) .code { white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/docs/tests/browser-site.test.mjs b/docs/tests/browser-site.test.mjs index cb26f7f25..e3e6240dc 100644 --- a/docs/tests/browser-site.test.mjs +++ b/docs/tests/browser-site.test.mjs @@ -120,7 +120,7 @@ test('mobile pages stay viewport-bound while code remains readable', { timeout: )); return contrast(effective, background); }); - const fixedTerminal = [...document.querySelectorAll('.frame.is-terminal')] + const fixedTerminal = [...document.querySelectorAll('.expressive-code .frame')] .filter((frame) => frame.querySelector('.wt-output')) .map((frame) => frame.querySelector('pre')) .find((pre) => pre.scrollWidth > pre.clientWidth + 1); @@ -133,10 +133,6 @@ test('mobile pages stay viewport-bound while code remains readable', { timeout: frames: [...document.querySelectorAll('.expressive-code .frame')].map((frame) => { const pre = frame.querySelector('pre'); return { - className: frame.className, - managedTerminal: Boolean( - frame.querySelector('.wt-command, .wt-output, .wt-copyable'), - ), hasOutput: Boolean(frame.querySelector('.wt-output')), frameLeft: frame.getBoundingClientRect().left, frameRight: frame.getBoundingClientRect().right, @@ -164,17 +160,7 @@ test('mobile pages stay viewport-bound while code remains readable', { timeout: frame.frameRight <= layout.viewportWidth + 1, `${theme} ${width}px ${route} code ends offscreen`, ); - const terminal = frame.className.includes('is-terminal'); - const commandsOnly = frame.className.includes('wt-commands-only'); - if (terminal) { - assert.equal( - commandsOnly, - !frame.hasOutput, - `${theme} ${width}px ${route} misclassifies a terminal block`, - ); - } - const wraps = !terminal || commandsOnly; - if (wraps) { + if (!frame.hasOutput) { assert.ok( frame.preScrollWidth <= frame.preClientWidth + 1, `${theme} ${width}px ${route} wrappable code still scrolls horizontally`, @@ -213,7 +199,7 @@ test('desktop code examples fit the content column', { timeout: 60_000 }, async const rect = block.getBoundingClientRect(); return { left: rect.left, right: rect.right, text: block.textContent.trim().slice(0, 60) }; }), - terminals: [...document.querySelectorAll('.frame.is-terminal')] + terminals: [...document.querySelectorAll('.expressive-code .frame')] .filter((frame) => frame.querySelector('.wt-output')) .map((frame) => { const pre = frame.querySelector('pre'); @@ -245,6 +231,62 @@ test('desktop code examples fit the content column', { timeout: 60_000 }, async } }); +test('copy buttons sit in view on the line they copy without overlapping', { timeout: 60_000 }, async () => { + const browser = await webkit.launch(); + try { + for (const { width, touch } of [{ width: 393, touch: true }, { width: 1376, touch: false }]) { + const page = await browser.newPage({ + viewport: { width, height: 900 }, + hasTouch: touch, + isMobile: touch, + }); + for (const route of await sitemapRoutes()) { + await page.goto(`${baseUrl}${route}`, { waitUntil: 'domcontentloaded' }); + const frames = await page.evaluate(() => { + for (const details of document.querySelectorAll('.sl-markdown-content details')) details.open = true; + return [...document.querySelectorAll('.expressive-code .frame')].map((frame) => { + const code = frame.querySelector('pre').getBoundingClientRect(); + return { + text: frame.textContent.trim().slice(0, 60), + codeLeft: code.left, + codeRight: code.right, + buttons: [...frame.querySelectorAll('.copy button')].map((button) => { + // A block's control belongs on its first line. + const line = (button.closest('.ec-line') ?? frame.querySelector('.ec-line')) + .getBoundingClientRect(); + const { top, bottom, left, right } = button.getBoundingClientRect(); + return { top, bottom, left, right, lineTop: line.top, lineBottom: line.bottom }; + }), + }; + }); + }); + const label = (frame) => `${route} "${frame.text}" at ${width}px`; + for (const frame of frames) { + for (const [index, button] of frame.buttons.entries()) { + const center = (button.top + button.bottom) / 2; + assert.ok( + center >= button.lineTop && center <= button.lineBottom, + `${label(frame)}: copy button is off its line`, + ); + assert.ok( + button.left >= frame.codeLeft - 1 && button.right <= frame.codeRight + 1, + `${label(frame)}: copy button is scrolled out of sight`, + ); + assert.ok( + frame.buttons.slice(index + 1) + .every((other) => other.top >= button.bottom - 0.5 || other.bottom <= button.top + 0.5), + `${label(frame)}: copy buttons overlap`, + ); + } + } + } + await page.close(); + } + } finally { + await browser.close(); + } +}); + test('code artifacts keep their visual hierarchy in both themes', async () => { const browser = await webkit.launch(); try { @@ -380,7 +422,7 @@ test('code artifacts keep their visual hierarchy in both themes', async () => { document.documentElement.dataset.theme = selectedTheme; }, theme); const listStyles = await page.evaluate(() => { - const frame = [...document.querySelectorAll('.frame.is-terminal')] + const frame = [...document.querySelectorAll('.expressive-code .frame')] .find((candidate) => candidate.textContent.includes('feature-api')); const normal = frame.querySelector('.wt-output .code'); const dim = frame.querySelector('.wt-terminal-dim:not(.wt-terminal-red)'); diff --git a/docs/tests/built-site.test.mjs b/docs/tests/built-site.test.mjs index c89d74ec6..cbbbd9652 100644 --- a/docs/tests/built-site.test.mjs +++ b/docs/tests/built-site.test.mjs @@ -365,7 +365,7 @@ test('output-only console blocks do not expose copy controls', async () => { let outputOnlyBlocks = 0; for (const page of renderedPages) { const html = await readFile(page, 'utf8'); - for (const match of html.matchAll(/
([\s\S]*?)<\/figure>/g)) { + for (const match of html.matchAll(/
([\s\S]*?)<\/figure>/g)) { const frame = match[1]; if (!/class="ec-line wt-output"/.test(frame)) continue; if (/class="ec-line wt-(?:command|copyable)"/.test(frame)) continue; @@ -381,7 +381,7 @@ test('command-bearing console blocks emit command-only copy payloads', async () let perLineBlocks = 0; for (const page of renderedPages) { const html = await readFile(page, 'utf8'); - for (const match of html.matchAll(/
([\s\S]*?)<\/figure>/g)) { + for (const match of html.matchAll(/
([\s\S]*?)<\/figure>/g)) { const frame = match[1]; const lines = [...frame.matchAll( /
([\s\S]*?)<\/div>/g, @@ -393,26 +393,25 @@ test('command-bearing console blocks emit command-only copy payloads', async () commandBearingBlocks += 1; // The block control carries the bare `copy` class; per-line controls add - // `wt-line-copy`, so this anchors on the block's own payload. + // `wt-line-copy`. const encodedPayload = frame.match( /
[\s\S]*?]*\bdata-code="([^"]*)"/, )?.[1]; - assert.notEqual(encodedPayload, undefined, `${page} is missing a terminal copy payload`); - assert.equal(renderedText(encodedPayload), expected.join('\u007f'), `${page} copies captured output`); - - // A block listing several commands is as often a menu of alternatives as - // a recipe, so each command line offers its own payload alongside the - // block's. const commands = lines .filter((line) => line[1] === 'command') .map((line) => renderedText(line[2]).replace(/\n$/u, '')); const perLine = [...frame.matchAll( /
[\s\S]*?]*\bdata-code="([^"]*)"/g, )].map((line) => renderedText(line[1])); + // Most blocks listing several commands are menus of alternatives, so + // each command line offers its own payload in place of the block's. if (commands.length > 1) { perLineBlocks += 1; + assert.equal(encodedPayload, undefined, `${page} offers a block copy beside per-line copies`); assert.deepEqual(perLine, commands, `${page} per-line copy payloads do not match its commands`); } else { + assert.notEqual(encodedPayload, undefined, `${page} is missing a terminal copy payload`); + assert.equal(renderedText(encodedPayload), expected.join('\u007f'), `${page} copies captured output`); assert.deepEqual(perLine, [], `${page} adds per-line copy to a single-command block`); } } diff --git a/docs/tests/plugins.test.mjs b/docs/tests/plugins.test.mjs index 158867f6e..bf44a56be 100644 --- a/docs/tests/plugins.test.mjs +++ b/docs/tests/plugins.test.mjs @@ -365,7 +365,7 @@ test('shell command roles preserve syntax outside the styled grammar', () => { assert.equal(shellCommandSegments(source).map(({ text }) => text).join(''), source); }); -test('console blocks separate copyable recipes from output', () => { +test('console blocks give each of several commands its own copy control', () => { const lines = ['# Recent', '$ wt list', '', '# Failed', '$ wt list --full'].map((text) => ({ text, editText(start, end, replacement) { @@ -377,41 +377,54 @@ test('console blocks separate copyable recipes from output', () => { prepareCodeBlock(plugin, codeBlock); assert.equal(codeBlock.language, 'bash'); - assert.equal(codeBlock.props.frame, 'terminal'); - const classes = lines.map((_, lineIndex) => { + const lineAsts = lines.map((_, lineIndex) => { const renderData = { lineAst: { properties: {} } }; plugin.hooks.postprocessRenderedLine({ codeBlock, lineIndex, renderData }); - return renderData.lineAst.properties.className; + return renderData.lineAst; }); assert.deepEqual( lines.map((line) => line.text), ['# Recent', 'wt list', '', '# Failed', 'wt list --full'], ); - assert.deepEqual(classes, [ + assert.deepEqual(lineAsts.map((lineAst) => lineAst.properties.className), [ ['wt-copyable'], ['wt-command'], ['wt-copyable'], ['wt-copyable'], ['wt-command'], ]); + assert.deepEqual( + lineAsts.map((lineAst) => lineAst.children?.[0].children[1].properties['data-code']), + [undefined, 'wt list', undefined, undefined, 'wt list --full'], + ); - const copyButton = { type: 'element', tagName: 'button', properties: { 'data-code': 'stale' } }; const renderData = { blockAst: { - children: [{ - type: 'element', - properties: { className: ['copy'] }, - children: [copyButton], - }], + properties: { className: ['frame'] }, + children: [{ type: 'element', properties: { className: ['copy'] }, children: [] }], }, }; plugin.hooks.postprocessRenderedBlock({ codeBlock, renderData }); - assert.equal( - copyButton.properties['data-code'], - '# Recent\u007fwt list\u007f\u007f# Failed\u007fwt list --full', - ); + assert.deepEqual(renderData.blockAst.children, [], 'the block keeps its own copy control'); +}); + +test('console blocks wrap several commands but never captured output', () => { + const wraps = (texts) => { + const lines = texts.map((text) => ({ + text, + editText(start, end, replacement) { + this.text = this.text.slice(0, start) + replacement + this.text.slice(end); + }, + })); + const codeBlock = { language: 'console', getLines: () => lines }; + prepareCodeBlock(pluginWorktrunkTerminal(), codeBlock); + return codeBlock.props.wrap; + }; + assert.equal(wraps(['$ wt list', '$ wt list --full']), true); + assert.equal(wraps(['$ wt list', '$ wt list --full', 'output']), undefined); + assert.equal(wraps(['$ wt list']), undefined); }); test('console output and its blank lines stay out of copied commands', () => { @@ -451,26 +464,6 @@ test('console output and its blank lines stay out of copied commands', () => { assert.equal(copyButton.properties['data-code'], 'wt list\u007f# shell comment'); }); -test('shell snippets use the command-only terminal classifier', () => { - const codeBlock = { language: 'bash', getLines: () => [] }; - const blockAst = { - type: 'element', - tagName: 'figure', - properties: { className: ['frame', 'is-terminal'] }, - children: [], - }; - const plugin = pluginWorktrunkTerminal(); - - plugin.hooks.preprocessCode({ codeBlock }); - plugin.hooks.postprocessRenderedBlock({ codeBlock, renderData: { blockAst } }); - - assert.deepEqual(blockAst.properties.className, [ - 'frame', - 'is-terminal', - 'wt-commands-only', - ]); -}); - test('console output retains state-color semantics without ANSI in Markdown', () => { assert.deepEqual( semanticOutputSegments('@ feat +54 -5 ↑4 ↓1 ⇡3 ? ✓ done'), diff --git a/plugins/worktrunk/skills/worktrunk/reference/faq.md b/plugins/worktrunk/skills/worktrunk/reference/faq.md index a4cb371ff..cc04d9e9f 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/faq.md +++ b/plugins/worktrunk/skills/worktrunk/reference/faq.md @@ -65,10 +65,10 @@ Not natively — stacked-branch workflows are a large design space, so Worktrunk Stash the changes, create the worktree, then pop: -```console -$ git stash push -u # -u also stashes untracked files -$ wt switch --create feature # new branch off the default branch -$ git stash pop # changes reappear in the new worktree +```bash +git stash push -u # -u also stashes untracked files +wt switch --create feature # new branch off the default branch +git stash pop # changes reappear in the new worktree ``` The stash lives in the shared `.git` directory, so it's reachable from the new worktree. The original branch is left clean. diff --git a/plugins/worktrunk/skills/worktrunk/reference/step.md b/plugins/worktrunk/skills/worktrunk/reference/step.md index 1faee0d55..eee45c739 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/step.md +++ b/plugins/worktrunk/skills/worktrunk/reference/step.md @@ -413,10 +413,10 @@ $ wt step diff | delta Equivalent to: -```console -$ cp "$(git rev-parse --git-dir)/index" /tmp/idx -$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . -$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) +```bash +cp "$(git rev-parse --git-dir)/index" /tmp/idx +GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . +GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) ``` `git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified. diff --git a/skills/worktrunk/reference/faq.md b/skills/worktrunk/reference/faq.md index a4cb371ff..cc04d9e9f 100644 --- a/skills/worktrunk/reference/faq.md +++ b/skills/worktrunk/reference/faq.md @@ -65,10 +65,10 @@ Not natively — stacked-branch workflows are a large design space, so Worktrunk Stash the changes, create the worktree, then pop: -```console -$ git stash push -u # -u also stashes untracked files -$ wt switch --create feature # new branch off the default branch -$ git stash pop # changes reappear in the new worktree +```bash +git stash push -u # -u also stashes untracked files +wt switch --create feature # new branch off the default branch +git stash pop # changes reappear in the new worktree ``` The stash lives in the shared `.git` directory, so it's reachable from the new worktree. The original branch is left clean. diff --git a/skills/worktrunk/reference/step.md b/skills/worktrunk/reference/step.md index 1faee0d55..eee45c739 100644 --- a/skills/worktrunk/reference/step.md +++ b/skills/worktrunk/reference/step.md @@ -413,10 +413,10 @@ $ wt step diff | delta Equivalent to: -```console -$ cp "$(git rev-parse --git-dir)/index" /tmp/idx -$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . -$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) +```bash +cp "$(git rev-parse --git-dir)/index" /tmp/idx +GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . +GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) ``` `git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified. diff --git a/src/cli/step.rs b/src/cli/step.rs index 8bb961036..4917f91b7 100644 --- a/src/cli/step.rs +++ b/src/cli/step.rs @@ -293,10 +293,10 @@ $ wt step diff | delta Equivalent to: -```console -$ cp "$(git rev-parse --git-dir)/index" /tmp/idx -$ GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . -$ GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) +```bash +cp "$(git rev-parse --git-dir)/index" /tmp/idx +GIT_INDEX_FILE=/tmp/idx git add --intent-to-add . +GIT_INDEX_FILE=/tmp/idx git diff $(git merge-base HEAD $(wt config state default-branch)) ``` `git diff` ignores untracked files. `git add --intent-to-add .` registers them in the index without staging their content, making them visible to `git diff`. This runs against a copy of the real index so the original is never modified.