From 99f5dc7c2cf4e24d1efce91bf722a47766d5e1da Mon Sep 17 00:00:00 2001 From: star Date: Sat, 11 Jul 2026 08:12:58 +0800 Subject: [PATCH 1/2] fix(tui): resolve shell carriage-return redraws --- .../fix-shell-carriage-return-redraw.md | 5 +++ apps/kimi-code/src/tui/utils/shell-output.ts | 33 +++++++++++++++---- .../test/tui/utils/shell-output.test.ts | 5 +-- 3 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-shell-carriage-return-redraw.md diff --git a/.changeset/fix-shell-carriage-return-redraw.md b/.changeset/fix-shell-carriage-return-redraw.md new file mode 100644 index 0000000000..581f504a5e --- /dev/null +++ b/.changeset/fix-shell-carriage-return-redraw.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix shell progress output that redraws the same line with carriage returns. diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts index 3a482feb73..8234aa30b0 100644 --- a/apps/kimi-code/src/tui/utils/shell-output.ts +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -17,9 +17,28 @@ const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g; // save/restore cursor (ESC 7 / ESC 8), full reset (ESC c), etc. Runs after the // CSI/OSC patterns, so it only catches sequences they didn't already consume. const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g; -// C0 control characters except \n (0x0A) and \t (0x09): NUL, BEL, \b, \r, … -// plus a lone ESC (0x1B) that wasn't part of a sequence recognised above. -const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u001B\u001C-\u001F]/g; +// C0 control characters except \n (0x0A), \t (0x09), and \r (0x0D): NUL, +// BEL, \b, … plus a lone ESC (0x1B) that wasn't part of a sequence recognised +// above. Carriage returns are resolved separately because command progress +// redraws use them to overwrite the current line. +const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u000C\u000E-\u001B\u001C-\u001F]/g; + +function resolveCarriageReturns(text: string): string { + if (!text.includes('\r')) return text; + return text + .split('\n') + .map((line) => { + if (!line.includes('\r')) return line; + const parts = line.split('\r'); + let out = parts[0] ?? ''; + for (let i = 1; i < parts.length; i++) { + const over = parts[i] ?? ''; + out = over.length >= out.length ? over : over + out.slice(over.length); + } + return out; + }) + .join('\n'); +} /** * Strip every terminal control sequence from captured command output so it is @@ -32,13 +51,13 @@ export function sanitizeShellOutput(text: string): string { if (typeof text !== 'string') return ''; if (text.length === 0) return text; try { - return text + const withoutEscapes = text .replace(OSC_PATTERN, '') .replace(CSI_PATTERN, '') - .replace(ESC_SINGLE_PATTERN, '') - .replace(C0_CONTROL_PATTERN, ''); + .replace(ESC_SINGLE_PATTERN, ''); + return resolveCarriageReturns(withoutEscapes).replace(C0_CONTROL_PATTERN, ''); } catch { - return text.replace(C0_CONTROL_PATTERN, ''); + return resolveCarriageReturns(text).replace(C0_CONTROL_PATTERN, ''); } } diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts index e7a724b43a..d66f6ccbb4 100644 --- a/apps/kimi-code/test/tui/utils/shell-output.test.ts +++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts @@ -38,8 +38,9 @@ describe('sanitizeShellOutput', () => { expect(sanitizeShellOutput(link)).toBe('click here'); }); - it('strips carriage returns (spinner redraw)', () => { - expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame1frame2frame3'); + it('resolves carriage-return redraws before rendering', () => { + expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame3'); + expect(sanitizeShellOutput('Hello World\rHi')).toBe('Hillo World'); expect(sanitizeShellOutput('line\r\nnext')).toBe('line\nnext'); }); From ff70fc9005eb889d4a19c989befcaa0e909cf6db Mon Sep 17 00:00:00 2001 From: star Date: Sat, 11 Jul 2026 09:11:44 +0800 Subject: [PATCH 2/2] fix(tui): honor erase-line shell redraws --- apps/kimi-code/src/tui/utils/shell-output.ts | 45 +++++++++++++++---- .../test/tui/utils/shell-output.test.ts | 2 + 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/tui/utils/shell-output.ts b/apps/kimi-code/src/tui/utils/shell-output.ts index 8234aa30b0..2fae3eedf1 100644 --- a/apps/kimi-code/src/tui/utils/shell-output.ts +++ b/apps/kimi-code/src/tui/utils/shell-output.ts @@ -11,6 +11,8 @@ import { currentTheme } from '#/tui/theme'; // ESC [ — colours, cursor moves, clear, and // private modes such as ESC[?1049h (alt screen) / ESC[?25l (hide cursor). const CSI_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*[@-~]/g; +const ERASE_IN_LINE_PATTERN = /\u001B\[[0-9:;<=>?]*[ -/]*K/g; +const ERASE_IN_LINE_MARKER = '\uE000'; // ESC ] … or ESC ] … ESC \ — window titles and OSC 8 hyperlinks. const OSC_PATTERN = /\u001B\][\s\S]*?(?:\u0007|\u001B\\)/g; // ESC (and ESC ) — charset/keypad selection, @@ -24,22 +26,44 @@ const ESC_SINGLE_PATTERN = /\u001B(?:[ -/][0-~]|[0-~])/g; const C0_CONTROL_PATTERN = /[\u0000-\u0008\u000B-\u000C\u000E-\u001B\u001C-\u001F]/g; function resolveCarriageReturns(text: string): string { - if (!text.includes('\r')) return text; + if (!text.includes('\r') && !text.includes(ERASE_IN_LINE_MARKER)) return text; return text .split('\n') .map((line) => { - if (!line.includes('\r')) return line; - const parts = line.split('\r'); - let out = parts[0] ?? ''; - for (let i = 1; i < parts.length; i++) { - const over = parts[i] ?? ''; - out = over.length >= out.length ? over : over + out.slice(over.length); + if (!line.includes('\r') && !line.includes(ERASE_IN_LINE_MARKER)) return line; + let out = ''; + let cursor = 0; + for (let i = 0; i < line.length;) { + const char = line[i]; + if (char === '\r') { + cursor = 0; + i++; + continue; + } + if (char === ERASE_IN_LINE_MARKER) { + out = out.slice(0, cursor); + i++; + continue; + } + const nextControl = nextControlIndex(line, i); + const chunk = line.slice(i, nextControl); + out = out.slice(0, cursor) + chunk + out.slice(cursor + chunk.length); + cursor += chunk.length; + i = nextControl; } return out; }) .join('\n'); } +function nextControlIndex(line: string, start: number): number { + let index = start; + while (index < line.length && line[index] !== '\r' && line[index] !== ERASE_IN_LINE_MARKER) { + index++; + } + return index; +} + /** * Strip every terminal control sequence from captured command output so it is * safe to render via pi-tui (which does not sanitize on its own). @@ -53,11 +77,14 @@ export function sanitizeShellOutput(text: string): string { try { const withoutEscapes = text .replace(OSC_PATTERN, '') + .replace(ERASE_IN_LINE_PATTERN, ERASE_IN_LINE_MARKER) .replace(CSI_PATTERN, '') .replace(ESC_SINGLE_PATTERN, ''); - return resolveCarriageReturns(withoutEscapes).replace(C0_CONTROL_PATTERN, ''); + return resolveCarriageReturns(withoutEscapes) + .replace(C0_CONTROL_PATTERN, '') + .replaceAll(ERASE_IN_LINE_MARKER, ''); } catch { - return resolveCarriageReturns(text).replace(C0_CONTROL_PATTERN, ''); + return resolveCarriageReturns(text).replace(C0_CONTROL_PATTERN, '').replaceAll(ERASE_IN_LINE_MARKER, ''); } } diff --git a/apps/kimi-code/test/tui/utils/shell-output.test.ts b/apps/kimi-code/test/tui/utils/shell-output.test.ts index d66f6ccbb4..817d3c43bf 100644 --- a/apps/kimi-code/test/tui/utils/shell-output.test.ts +++ b/apps/kimi-code/test/tui/utils/shell-output.test.ts @@ -41,6 +41,8 @@ describe('sanitizeShellOutput', () => { it('resolves carriage-return redraws before rendering', () => { expect(sanitizeShellOutput('frame1\rframe2\rframe3')).toBe('frame3'); expect(sanitizeShellOutput('Hello World\rHi')).toBe('Hillo World'); + expect(sanitizeShellOutput(`Downloading 100%\rDone${ESC}[K`)).toBe('Done'); + expect(sanitizeShellOutput(`Downloading 100%\r${ESC}[KDone`)).toBe('Done'); expect(sanitizeShellOutput('line\r\nnext')).toBe('line\nnext'); });