fix(cli): preserve table ANSI color across wrapped lines (#4050)

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
This commit is contained in:
ChiGao 2026-05-12 16:09:39 +08:00 committed by GitHub
parent e59c7b8b68
commit dc7a90c4ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 677 additions and 1 deletions

View file

@ -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 `<Text color=...>` rendering instead of the table ANSI-string path.

View file

@ -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<void>;
getRequestCount: () => number;
};
type Summary = {
repoRoot: string;
outputDir: string;
requestCount: number;
rawBytes: number;
finalScreenLines: number;
continuationOccurrences: number;
coloredContinuationOccurrences: number;
uncoloredContinuationOccurrences: number;
continuationForegrounds: Array<string | null>;
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<string, unknown>,
finishReason: string | null,
usage?: Record<string, unknown>,
): Record<string, unknown> {
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<string> {
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<FakeServer> {
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<void>((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<void>((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<void> {
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);
});

View file

@ -41,6 +41,85 @@ describe('<TableRenderer />', () => {
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('<TableRenderer />', () => {
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);

View file

@ -73,6 +73,7 @@ const INK_COLOR_TO_ANSI: Record<string, number> = {
};
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();