diff --git a/packages/void/src/cli/output.ts b/packages/void/src/cli/output.ts index 609fa03bb..5dc9bb7e2 100644 --- a/packages/void/src/cli/output.ts +++ b/packages/void/src/cli/output.ts @@ -98,6 +98,21 @@ export function createSpinner( }; } +/** The URL as the box title shows it: whole, or its longest start cut with "…". */ +function shownUrl(chunk: string, url: string): string | undefined { + if (chunk.includes(url)) { + return url; + } + const plain = stripVTControlCharacters(chunk); + for (let end = url.length - 1; end > 0; end--) { + const label = `${url.slice(0, end)}…`; + if (plain.includes(label)) { + return label; + } + } + return undefined; +} + /** Narrowest terminal the shared box renderer can draw a note in, guide bar included. */ const MIN_NOTE_COLUMNS = 6; @@ -127,7 +142,10 @@ export function note(...args: Parameters): void { // Insert the link after layout: the renderer counts OSC 8 destinations // toward the title width and can otherwise truncate a URL that fits. if (header && urlTitle) { - chunk = chunk.replace(` ${urlTitle} `, ` ${formatUrl(urlTitle)} `); + const label = shownUrl(chunk, urlTitle); + if (label) { + chunk = chunk.replace(label, () => terminalLink(label, urlTitle, { fallback: false })); + } } header = false; return output.write(chunk); diff --git a/packages/void/test/integration/terminal-links.test.ts b/packages/void/test/integration/terminal-links.test.ts index b8b48409f..cbe85f07c 100644 --- a/packages/void/test/integration/terminal-links.test.ts +++ b/packages/void/test/integration/terminal-links.test.ts @@ -5,13 +5,13 @@ import { describe, expect, it } from 'vite-plus/test'; const outputModule = new URL('../../src/cli/output.ts', import.meta.url).href; const url = 'https://app.example.com/hello?name=void&mode=preview#content'; -function render(hyperlinks: boolean): { url: string; named: string; box: string } { +function render(hyperlinks: boolean, columns = 80): { url: string; named: string; box: string } { const script = ` import { formatLink, formatUrl, note } from ${JSON.stringify(outputModule)}; const url = ${JSON.stringify(url)}; let box = ''; note('Static Site — 1 asset', url, { - output: { columns: 80, write(chunk) { box += chunk; return true; } }, + output: { columns: ${columns}, write(chunk) { box += chunk; return true; } }, }); console.log(JSON.stringify({ url: formatUrl(url), named: formatLink('Dashboard', url), box })); `; @@ -31,15 +31,16 @@ console.log(JSON.stringify({ url: formatUrl(url), named: formatLink('Dashboard', } describe('terminal links (real capability detection)', () => { - it('emits OSC 8 links with the exact destination and keeps box padding outside the link', () => { - const output = render(true); + it.each([40, 80])('keeps the complete link destination in a %s-column box title', (columns) => { + const output = render(true, columns); // ansi-escapes uses BEL to delimit OSC 8 control sequences. const opening = `\u001b]8;;${url}\u0007`; const closing = '\u001b]8;;\u0007'; expect(output.url).toBe(`${opening}${url}${closing}`); expect(output.named).toBe(`${opening}Dashboard${closing}`); - expect(output.box).toContain(` ${opening}${url}${closing} `); - expect(stripVTControlCharacters(output.box).split('\n')[0]).toContain(` ${url} `); + const label = columns === 40 ? `${url.slice(0, 34)}…` : url; + expect(output.box).toContain(` ${opening}${label}${closing}`); + expect(stripVTControlCharacters(output.box).split('\n')[0]).toContain(` ${label}`); }); it('prints each URL once as plain text when hyperlinks are unsupported or output is redirected', () => {