mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-19 07:54:38 +00:00
fix(cli): preserve table ANSI color across wrapped lines (#4050)
Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
This commit is contained in:
parent
e59c7b8b68
commit
dc7a90c4ac
4 changed files with 677 additions and 1 deletions
67
.qwen/e2e-tests/table-wrap-ansi-highlight.md
Normal file
67
.qwen/e2e-tests/table-wrap-ansi-highlight.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue