From dc7a90c4ac96030aede4b99e82184254dc67a11e Mon Sep 17 00:00:00 2001 From: ChiGao Date: Tue, 12 May 2026 16:09:39 +0800 Subject: [PATCH] fix(cli): preserve table ANSI color across wrapped lines (#4050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 秦奇 --- .qwen/e2e-tests/table-wrap-ansi-highlight.md | 67 +++ .../table-inline-code-wrap-regression.ts | 387 ++++++++++++++++++ .../cli/src/ui/utils/TableRenderer.test.tsx | 129 ++++++ packages/cli/src/ui/utils/TableRenderer.tsx | 95 ++++- 4 files changed, 677 insertions(+), 1 deletion(-) create mode 100644 .qwen/e2e-tests/table-wrap-ansi-highlight.md create mode 100644 integration-tests/terminal-capture/table-inline-code-wrap-regression.ts diff --git a/.qwen/e2e-tests/table-wrap-ansi-highlight.md b/.qwen/e2e-tests/table-wrap-ansi-highlight.md new file mode 100644 index 000000000..5c7532d95 --- /dev/null +++ b/.qwen/e2e-tests/table-wrap-ansi-highlight.md @@ -0,0 +1,67 @@ +# Table Inline-Code Wrap ANSI Highlight E2E + +## Problem + +Markdown tables render inline code as ANSI-colored strings before wrapping cell +content. In narrow terminals, `wrap-ansi` can split a truecolor inline-code span +without re-opening its foreground color on the continuation line, so long table +names lose their code highlight after wrapping. + +## Scenario + +- Script: + `integration-tests/terminal-capture/table-inline-code-wrap-regression.ts` +- Trigger: a fake OpenAI server returns a fixed markdown table containing a long + inline-code table name. +- Terminal: `100x32`, real `node dist/cli.js`, OpenAI-compatible auth pointed at + the local fake server. +- Metric: every raw ANSI occurrence of the wrapped table-name suffix + `244650615` must have an active `38;2` foreground color, and the final screen + must contain the suffix without containing the full table name on one line. + +## Commands + +```bash +cd /Users/gawain/Documents/codebase/opensource/qwen-code-table-wrap-ansi-highlight + +cd packages/cli && npx vitest run src/ui/utils/TableRenderer.test.tsx + +cd /Users/gawain/Documents/codebase/opensource/qwen-code-table-wrap-ansi-highlight +npm run build && npm run typecheck && npm run bundle + +QWEN_TUI_E2E_OUT=/tmp/qwen-table-wrap-ansi/fixed \ + npx tsx integration-tests/terminal-capture/table-inline-code-wrap-regression.ts + +QWEN_TUI_E2E_REPO=/Users/gawain/Documents/codebase/opensource/qwen-code-table-wrap-ansi-highlight-base \ +QWEN_TUI_E2E_OUT=/tmp/qwen-table-wrap-ansi/base \ +QWEN_TUI_E2E_EXPECT_PASS=false \ + npx tsx integration-tests/terminal-capture/table-inline-code-wrap-regression.ts +``` + +## Results + +| Branch | Expected | wrapped | continuationOccurrences | colored | uncolored | Result | +| --- | --- | --- | ---: | ---: | ---: | --- | +| `origin/main` base worktree | failure-first reproduction | true | 1 | 0 | 1 | reproduced | +| `fix/table-wrap-ansi-highlight` | strict pass | true | 1 | 1 | 0 | passed | + +## Artifacts + +- Base summary: `/tmp/qwen-table-wrap-ansi/base/summary.json` +- Base raw ANSI: `/tmp/qwen-table-wrap-ansi/base/raw.ansi.log` +- Base screenshot: `/tmp/qwen-table-wrap-ansi/base/table-inline-code-wrap.png` +- Fixed summary: `/tmp/qwen-table-wrap-ansi/fixed/summary.json` +- Fixed raw ANSI: `/tmp/qwen-table-wrap-ansi/fixed/raw.ansi.log` +- Fixed screenshot: `/tmp/qwen-table-wrap-ansi/fixed/table-inline-code-wrap.png` + +What this proves: + +- The unfixed table renderer emits the wrapped table-name continuation without a + code foreground color. +- The fixed table renderer emits the same continuation with active truecolor + foreground while preserving the final rendered table. + +What this does not prove: + +- It does not validate non-table inline code or fenced code blocks; those use + Ink React `` rendering instead of the table ANSI-string path. diff --git a/integration-tests/terminal-capture/table-inline-code-wrap-regression.ts b/integration-tests/terminal-capture/table-inline-code-wrap-regression.ts new file mode 100644 index 000000000..c18fb8d6a --- /dev/null +++ b/integration-tests/terminal-capture/table-inline-code-wrap-regression.ts @@ -0,0 +1,387 @@ +#!/usr/bin/env npx tsx +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import type { AddressInfo } from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { TerminalCapture } from './terminal-capture.js'; + +const TERMINAL_COLS = 100; +const TERMINAL_ROWS = 32; +const TABLE_NAME = + 'deleted_t_spark_odps_sql_type_system2_test_view_more_times_expand_view_f44c82c06096_244650615'; +const TABLE_NAME_SUFFIX = '244650615'; +const PROMPT_TEXT = 'Render the table inline-code wrap regression fixture.'; +const MARKDOWN_RESPONSE = [ + '已找到您有权限的 1 张表:', + '', + '| 表名 | 生命周期 | 备注 |', + '| --- | --- | --- |', + `| \`${TABLE_NAME}\` | N/A | 测试视图 |`, + '', + 'REGRESSION_TABLE_DONE', +].join('\n'); + +type FakeServer = { + baseUrl: string; + close: () => Promise; + getRequestCount: () => number; +}; + +type Summary = { + repoRoot: string; + outputDir: string; + requestCount: number; + rawBytes: number; + finalScreenLines: number; + continuationOccurrences: number; + coloredContinuationOccurrences: number; + uncoloredContinuationOccurrences: number; + continuationForegrounds: Array; + finalScreenWrappedTableName: boolean; + pass: boolean; + expectedPass: boolean; + screenshots: string[]; +}; + +function sendJson(res: ServerResponse, body: unknown): void { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +function sendStream(res: ServerResponse, chunks: unknown[]): void { + res.writeHead(200, { + 'cache-control': 'no-cache, no-transform', + connection: 'keep-alive', + 'content-type': 'text/event-stream; charset=utf-8', + }); + for (const chunk of chunks) { + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + } + res.write('data: [DONE]\n\n'); + res.end(); +} + +function chatCompletionId(): string { + return `chatcmpl-table-wrap-${Date.now()}`; +} + +function streamWrap( + id: string, + delta: Record, + finishReason: string | null, + usage?: Record, +): Record { + return { + id, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: 'dummy', + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}), + }; +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolveRead, rejectRead) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => resolveRead(Buffer.concat(chunks).toString('utf8'))); + req.on('error', rejectRead); + }); +} + +async function startFakeOpenAIServer(): Promise { + let requestCount = 0; + const server = createServer(async (req, res) => { + if (req.method !== 'POST' || !req.url?.endsWith('/chat/completions')) { + res.writeHead(404); + res.end('not found'); + return; + } + + requestCount += 1; + const body = await readRequestBody(req); + const parsed = JSON.parse(body) as { stream?: boolean }; + const id = chatCompletionId(); + const usage = { + prompt_tokens: 24, + completion_tokens: 16, + total_tokens: 40, + }; + + if (parsed.stream) { + sendStream(res, [ + streamWrap(id, { role: 'assistant' }, null), + streamWrap(id, { content: MARKDOWN_RESPONSE }, null), + streamWrap(id, {}, 'stop', usage), + ]); + return; + } + + sendJson(res, { + id, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: 'dummy', + choices: [ + { + index: 0, + message: { role: 'assistant', content: MARKDOWN_RESPONSE }, + finish_reason: 'stop', + }, + ], + usage, + }); + }); + + await new Promise((resolveListen) => { + server.listen(0, '127.0.0.1', resolveListen); + }); + + const address = server.address() as AddressInfo | null; + if (!address) { + throw new Error('failed to start fake OpenAI server'); + } + + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => + new Promise((resolveClose) => { + server.close(() => resolveClose()); + }), + getRequestCount: () => requestCount, + }; +} + +function qwenArgs(baseUrl: string): string[] { + return [ + 'dist/cli.js', + '--no-chat-recording', + '--approval-mode', + 'yolo', + '--auth-type', + 'openai', + '--openai-api-key', + 'dummy', + '--openai-base-url', + baseUrl, + '--model', + 'dummy', + ]; +} + +function updateForeground( + currentForeground: string | undefined, + paramsText: string, +): string | undefined { + const params = + paramsText.length > 0 + ? paramsText.split(';').map((param) => Number(param)) + : [0]; + let foreground = currentForeground; + + for (let index = 0; index < params.length; index++) { + const code = params[index]; + if (code === 0 || code === 39) { + foreground = undefined; + } else if ( + typeof code === 'number' && + ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) + ) { + foreground = String(code); + } else if (code === 38) { + const mode = params[index + 1]; + if (mode === 5 && Number.isFinite(params[index + 2])) { + foreground = `38;5;${params[index + 2]}`; + index += 2; + } else if ( + mode === 2 && + Number.isFinite(params[index + 2]) && + Number.isFinite(params[index + 3]) && + Number.isFinite(params[index + 4]) + ) { + foreground = `38;2;${params[index + 2]};${params[index + 3]};${params[index + 4]}`; + index += 4; + } + } + } + + return foreground; +} + +function foregroundsAtOccurrences(raw: string, needle: string): string[] { + const foregrounds: string[] = []; + let foreground: string | undefined; + let index = 0; + + while (index < raw.length) { + if (raw.startsWith(needle, index)) { + foregrounds.push(foreground ?? ''); + index += needle.length; + continue; + } + + if (raw[index] === '\x1b' && raw[index + 1] === '[') { + const sgrEnd = raw.indexOf('m', index + 2); + if (sgrEnd !== -1) { + const paramsText = raw.slice(index + 2, sgrEnd); + if (/^[0-9;]*$/.test(paramsText)) { + foreground = updateForeground(foreground, paramsText); + index = sgrEnd + 1; + continue; + } + index += 1; + continue; + } + } + + index += 1; + } + + return foregrounds; +} + +async function main(): Promise { + const scriptDir = dirname(fileURLToPath(import.meta.url)); + const defaultRepoRoot = resolve(scriptDir, '../..'); + const repoRoot = resolve(process.env['QWEN_TUI_E2E_REPO'] ?? defaultRepoRoot); + const outputDir = resolve( + process.env['QWEN_TUI_E2E_OUT'] ?? + join(tmpdir(), 'qwen-table-wrap-ansi', basename(repoRoot)), + ); + const expectedPass = process.env['QWEN_TUI_E2E_EXPECT_PASS'] !== 'false'; + + if (existsSync(outputDir)) { + rmSync(outputDir, { recursive: true }); + } + mkdirSync(outputDir, { recursive: true }); + + const fakeServer = await startFakeOpenAIServer(); + const homeDir = join(outputDir, 'home'); + mkdirSync(homeDir, { recursive: true }); + + const env: NodeJS.ProcessEnv = { + ...process.env, + FORCE_COLOR: '1', + HOME: homeDir, + NODE_NO_WARNINGS: '1', + QWEN_CODE_DISABLE_SYNCHRONIZED_OUTPUT: '1', + QWEN_CODE_NO_RELAUNCH: '1', + QWEN_SANDBOX: 'false', + TERM: 'xterm-256color', + USERPROFILE: homeDir, + }; + delete env['NO_COLOR']; + delete env['QWEN_CODE_SIMPLE']; + for (const key of [ + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'ALL_PROXY', + 'all_proxy', + ]) { + delete env[key]; + } + + const terminal = await TerminalCapture.create({ + chrome: false, + cols: TERMINAL_COLS, + cwd: repoRoot, + env, + fontSize: 14, + outputDir, + rows: TERMINAL_ROWS, + theme: 'github-dark', + title: 'table inline-code wrap regression', + }); + + const screenshots: string[] = []; + try { + await terminal.spawn('node', qwenArgs(fakeServer.baseUrl)); + await terminal.waitFor('Type your message', { timeout: 30000 }); + await terminal.type(PROMPT_TEXT, { delay: 12, slow: true }); + await terminal.idle(400, 4000); + await terminal.type('\n'); + await terminal.waitFor(TABLE_NAME_SUFFIX, { timeout: 30000 }); + await terminal.waitForAndIdle('REGRESSION_TABLE_DONE', { + stableMs: 1000, + timeout: 30000, + }); + + screenshots.push(await terminal.capture('table-inline-code-wrap.png')); + screenshots.push( + await terminal.captureFull('table-inline-code-wrap-full.png'), + ); + + const raw = terminal.getRawOutput(); + const finalScreen = await terminal.getScreenText(); + const foregrounds = foregroundsAtOccurrences(raw, TABLE_NAME_SUFFIX); + const coloredContinuationOccurrences = foregrounds.filter((foreground) => + foreground.startsWith('38;2;'), + ).length; + const uncoloredContinuationOccurrences = + foregrounds.length - coloredContinuationOccurrences; + const finalScreenWrappedTableName = + finalScreen.includes(TABLE_NAME_SUFFIX) && + !finalScreen.includes(TABLE_NAME); + const pass = + fakeServer.getRequestCount() > 0 && + finalScreenWrappedTableName && + foregrounds.length > 0 && + uncoloredContinuationOccurrences === 0; + + writeFileSync(join(outputDir, 'raw.ansi.log'), raw); + writeFileSync(join(outputDir, 'final-screen.txt'), finalScreen); + + const summary: Summary = { + repoRoot, + outputDir, + requestCount: fakeServer.getRequestCount(), + rawBytes: raw.length, + finalScreenLines: finalScreen.split('\n').length, + continuationOccurrences: foregrounds.length, + coloredContinuationOccurrences, + uncoloredContinuationOccurrences, + continuationForegrounds: foregrounds.map((foreground) => + foreground.length > 0 ? foreground : null, + ), + finalScreenWrappedTableName, + pass, + expectedPass, + screenshots, + }; + writeFileSync( + join(outputDir, 'summary.json'), + `${JSON.stringify(summary, null, 2)}\n`, + ); + + console.log(JSON.stringify(summary, null, 2)); + if (pass !== expectedPass) { + throw new Error( + `Expected pass=${expectedPass} but observed pass=${pass}. ` + + `See ${join(outputDir, 'summary.json')}`, + ); + } + } finally { + await terminal.close(); + await fakeServer.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index b5cc235f8..259e62b4f 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -41,6 +41,85 @@ describe('', () => { expect(new Set(widths).size).toBe(1); }; + const foregroundAtText = ( + output: string, + text: string, + ): string | undefined => { + const line = output + .split('\n') + .find((candidate) => stripAnsi(candidate).includes(text)); + expect(line, `Expected rendered output to contain "${text}"`).toBeDefined(); + + const textIndex = line!.indexOf(text); + expect(textIndex).toBeGreaterThanOrEqual(0); + + let foreground: string | undefined; + let searchIndex = 0; + while (searchIndex < textIndex) { + const sgrStart = line!.indexOf('\u001b[', searchIndex); + if (sgrStart === -1 || sgrStart >= textIndex) { + break; + } + const sgrEnd = line!.indexOf('m', sgrStart + 2); + if (sgrEnd === -1 || sgrEnd >= textIndex) { + break; + } + const paramsText = line!.slice(sgrStart + 2, sgrEnd); + if (!/^[0-9;]*$/.test(paramsText)) { + searchIndex = sgrStart + 1; + continue; + } + const params = + paramsText.length > 0 + ? paramsText.split(';').map((param) => Number(param)) + : [0]; + + for (let index = 0; index < params.length; index++) { + const code = params[index]; + if (code === 0 || code === 39) { + foreground = undefined; + } else if ( + typeof code === 'number' && + ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) + ) { + foreground = String(code); + } else if (code === 38) { + const mode = params[index + 1]; + if (mode === 5 && Number.isFinite(params[index + 2])) { + foreground = `38;5;${params[index + 2]}`; + index += 2; + } else if ( + mode === 2 && + Number.isFinite(params[index + 2]) && + Number.isFinite(params[index + 3]) && + Number.isFinite(params[index + 4]) + ) { + foreground = `38;2;${params[index + 2]};${params[index + 3]};${params[index + 4]}`; + index += 4; + } + } + } + searchIndex = sgrEnd + 1; + } + + return foreground; + }; + + const expectWrappedContinuation = ( + output: string, + wholeText: string, + continuationText: string, + ) => { + expect(stripAnsi(output)).not.toContain(wholeText); + const continuationLine = output + .split('\n') + .find((candidate) => stripAnsi(candidate).includes(continuationText)); + expect( + continuationLine, + `Expected rendered output to wrap before "${continuationText}"`, + ).toBeDefined(); + }; + it('renders a basic table with borders', () => { const output = renderTable(['Name', 'Value'], [['foo', 'bar']]); @@ -316,6 +395,56 @@ describe('', () => { expectAllLinesToHaveSameVisibleWidth(output); }); + it('preserves truecolor inline-code foreground across wrapped lines', () => { + const tableName = + 'deleted_t_spark_odps_sql_type_system2_test_view_more_times_expand_view_f44c82c06096_244650615'; + const output = renderTable(['表名'], [[`\`${tableName}\``]], 64); + + expect(output).toContain('244650615'); + expectWrappedContinuation(output, tableName, '244650615'); + expect(foregroundAtText(output, '244650615')).toMatch(/^38;2;/); + expectAllLinesToHaveSameVisibleWidth(output); + }); + + it('preserves 256-color foreground across wrapped lines', () => { + const output = renderTable( + ['Color'], + [['\u001b[38;5;45mabcdefghijklmnopqrstuvwxyz0123456789\u001b[39m']], + 24, + ); + + expectWrappedContinuation( + output, + 'abcdefghijklmnopqrstuvwxyz0123456789', + 'qrstuvwxyz012345', + ); + expect(foregroundAtText(output, 'qrstuvwxyz012345')).toBe('38;5;45'); + expectAllLinesToHaveSameVisibleWidth(output); + }); + + it('does not preserve foreground after an explicit reset', () => { + const output = renderTable( + ['Color'], + [['\u001b[38;5;45mcolored\u001b[0m reset']], + 18, + ); + + expect(foregroundAtText(output, 'reset')).toBeUndefined(); + expectAllLinesToHaveSameVisibleWidth(output); + }); + + it('does not preserve foreground after an explicit foreground reset', () => { + const output = renderTable( + ['Color'], + [['\u001b[38;5;45mcolored\u001b[39m reset']], + 18, + ); + + expectWrappedContinuation(output, 'colored reset', 'reset'); + expect(foregroundAtText(output, 'reset')).toBeUndefined(); + expectAllLinesToHaveSameVisibleWidth(output); + }); + it('handles ANSI + CJK mixed width without losing content', () => { const green = '\u001b[32m中文ABC\u001b[0m'; const output = renderTable(['列1', '列2'], [[green, '普通文本']], 40); diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 623ed99bf..1ad5323f0 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -73,6 +73,7 @@ const INK_COLOR_TO_ANSI: Record = { }; const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; +const ESC = '\x1b'; /** Get raw ANSI foreground color escape (without reset) for re-application */ function getColorCode(color: string): string { @@ -114,6 +115,98 @@ function recolorAfterResets(text: string, colorCode: string): string { .join(fullReset + colorCode); } +function updateActiveForeground( + activeForeground: string, + paramsText: string, +): string { + const params = + paramsText.length > 0 + ? paramsText.split(';').map((param) => Number(param)) + : [0]; + let foreground = activeForeground; + + for (let index = 0; index < params.length; index++) { + const code = params[index]; + if (!Number.isFinite(code)) { + continue; + } + + if (code === 0 || code === 39) { + foreground = ''; + } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + foreground = `\x1b[${code}m`; + } else if (code === 38) { + const mode = params[index + 1]; + if (mode === 5 && Number.isFinite(params[index + 2])) { + foreground = `\x1b[38;5;${params[index + 2]}m`; + index += 2; + } else if ( + mode === 2 && + Number.isFinite(params[index + 2]) && + Number.isFinite(params[index + 3]) && + Number.isFinite(params[index + 4]) + ) { + foreground = `\x1b[38;2;${params[index + 2]};${params[index + 3]};${params[index + 4]}m`; + index += 4; + } + } + } + + return foreground; +} + +function readSgrSequence( + text: string, + index: number, +): { sequence: string; paramsText: string; endIndex: number } | null { + if (text[index] !== ESC || text[index + 1] !== '[') { + return null; + } + const endIndex = text.indexOf('m', index + 2); + if (endIndex === -1) { + return null; + } + const paramsText = text.slice(index + 2, endIndex); + if (!/^[0-9;]*$/.test(paramsText)) { + return null; + } + return { + sequence: text.slice(index, endIndex + 1), + paramsText, + endIndex, + }; +} + +function preserveForegroundAcrossLineBreaks(text: string): string { + let activeForeground = ''; + let result = ''; + let lastIndex = 0; + let index = 0; + + while (index < text.length) { + const sgr = readSgrSequence(text, index); + if (!sgr) { + index += 1; + continue; + } + + const segment = text.slice(lastIndex, index); + result += activeForeground + ? segment.replace(/\n/g, `\x1b[39m\n${activeForeground}`) + : segment; + result += sgr.sequence; + activeForeground = updateActiveForeground(activeForeground, sgr.paramsText); + index = sgr.endIndex + 1; + lastIndex = index; + } + + const segment = text.slice(lastIndex); + result += activeForeground + ? segment.replace(/\n/g, `\x1b[39m\n${activeForeground}`) + : segment; + return result; +} + /** ANSI text formatting helpers (always produce escape codes, unlike chalk) */ const ansiFmt = { bold: (t: string) => `\x1b[1m${t}\x1b[22m`, @@ -252,7 +345,7 @@ function wrapText( trim: false, wordWrap: true, }); - const lines = wrapped.split('\n'); + const lines = preserveForegroundAcrossLineBreaks(wrapped).split('\n'); // Trim trailing empty lines (wrap-ansi artifacts) but preserve internal ones while (lines.length > 1 && lines[lines.length - 1]!.length === 0) { lines.pop();