From 17d42e79312697cffa0d36612e8bd02441a4698d Mon Sep 17 00:00:00 2001 From: webdevcody Date: Mon, 19 Jan 2026 17:38:21 -0500 Subject: [PATCH] feat: enhance ANSI code stripping in ClaudeUsageService - Improved the stripAnsiCodes method to handle various ANSI escape sequences, including CSI, OSC, and single-character sequences. - Added logic to manage backspaces and explicitly strip known "Synchronized Output" and "Window Title" garbage. - Updated tests to cover new functionality, ensuring robust handling of complex terminal outputs and control characters. This enhancement improves the reliability of text processing in terminal environments. --- .../src/services/claude-usage-service.ts | 64 ++++++++++++++++--- .../services/claude-usage-service.test.ts | 53 +++++++++++++++ 2 files changed, 109 insertions(+), 8 deletions(-) diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts index aebed98b..aa8afc1c 100644 --- a/apps/server/src/services/claude-usage-service.ts +++ b/apps/server/src/services/claude-usage-service.ts @@ -468,10 +468,41 @@ export class ClaudeUsageService { /** * Strip ANSI escape codes from text + * Handles CSI, OSC, and other common ANSI sequences */ private stripAnsiCodes(text: string): string { + // First strip ANSI sequences (colors, etc) and handle CR // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[A-Za-z]/g, ''); + let clean = text + // CSI sequences: ESC [ ... (letter or @) + .replace(/\x1B\[[0-9;?]*[A-Za-z@]/g, '') + // OSC sequences: ESC ] ... terminated by BEL, ST, or another ESC + .replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)?/g, '') + // Other ESC sequences: ESC (letter) + .replace(/\x1B[A-Za-z]/g, '') + // Carriage returns: replace with newline to avoid concatenation + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); + + // Handle backspaces (\x08) by applying them + // If we encounter a backspace, remove the character before it + while (clean.includes('\x08')) { + clean = clean.replace(/[^\x08]\x08/, ''); + clean = clean.replace(/^\x08+/, ''); + } + + // Explicitly strip known "Synchronized Output" and "Window Title" garbage + // even if ESC is missing (seen in some environments) + clean = clean + .replace(/\[\?2026[hl]/g, '') // CSI ? 2026 h/l + .replace(/\]0;[^\x07]*\x07/g, '') // OSC 0; Title BEL + .replace(/\]0;.*?(\[\?|$)/g, ''); // OSC 0; Title ... (unterminated or hit next sequence) + + // Strip remaining non-printable control characters (except newline \n) + // ASCII 0-8, 11-31, 127 + clean = clean.replace(/[\x00-\x08\x0B-\x1F\x7F]/g, ''); + + return clean; } /** @@ -550,7 +581,7 @@ export class ClaudeUsageService { sectionLabel: string, type: string ): { percentage: number; resetTime: string; resetText: string } { - let percentage = 0; + let percentage: number | null = null; let resetTime = this.getDefaultResetTime(type); let resetText = ''; @@ -564,7 +595,7 @@ export class ClaudeUsageService { } if (sectionIndex === -1) { - return { percentage, resetTime, resetText }; + return { percentage: 0, resetTime, resetText }; } // Look at the lines following the section header (within a window of 5 lines) @@ -572,7 +603,8 @@ export class ClaudeUsageService { for (const line of searchWindow) { // Extract percentage - only take the first match (avoid picking up next section's data) - if (percentage === 0) { + // Use null to track "not found" since 0% is a valid percentage (100% left = 0% used) + if (percentage === null) { const percentMatch = line.match(/(\d{1,3})\s*%\s*(left|used|remaining)/i); if (percentMatch) { const value = parseInt(percentMatch[1], 10); @@ -584,18 +616,31 @@ export class ClaudeUsageService { // Extract reset time - only take the first match if (!resetText && line.toLowerCase().includes('reset')) { - resetText = line; + // Only extract the part starting from "Resets" (or "Reset") to avoid garbage prefixes + const match = line.match(/(Resets?.*)$/i); + // If regex fails despite 'includes', likely a complex string issues - verify match before using line + // Only fallback to line if it's reasonably short/clean, otherwise skip it to avoid showing garbage + if (match) { + resetText = match[1]; + } } } // Parse the reset time if we found one if (resetText) { + // Clean up resetText: remove percentage info if it was matched on the same line + // e.g. "46%used Resets5:59pm" -> " Resets5:59pm" + resetText = resetText.replace(/(\d{1,3})\s*%\s*(left|used|remaining)/i, '').trim(); + + // Ensure space after "Resets" if missing (e.g. "Resets5:59pm" -> "Resets 5:59pm") + resetText = resetText.replace(/(resets?)(\d)/i, '$1 $2'); + resetTime = this.parseResetTime(resetText, type); // Strip timezone like "(Asia/Dubai)" from the display text resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim(); } - return { percentage, resetTime, resetText }; + return { percentage: percentage ?? 0, resetTime, resetText }; } /** @@ -624,7 +669,7 @@ export class ClaudeUsageService { } // Try to parse simple time-only format: "Resets 11am" or "Resets 3pm" - const simpleTimeMatch = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); + const simpleTimeMatch = text.match(/resets\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i); if (simpleTimeMatch) { let hours = parseInt(simpleTimeMatch[1], 10); const minutes = simpleTimeMatch[2] ? parseInt(simpleTimeMatch[2], 10) : 0; @@ -649,8 +694,11 @@ export class ClaudeUsageService { } // Try to parse date format: "Resets Dec 22 at 8pm" or "Resets Jan 15, 3:30pm" + // The regex explicitly matches only valid 3-letter month abbreviations to avoid + // matching words like "Resets" when there's no space separator. + // Optional "resets\s*" prefix handles cases with or without space after "Resets" const dateMatch = text.match( - /([A-Za-z]{3,})\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i + /(?:resets\s*)?(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})(?:\s+at\s+|\s*,?\s*)(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i ); if (dateMatch) { const monthName = dateMatch[1]; diff --git a/apps/server/tests/unit/services/claude-usage-service.test.ts b/apps/server/tests/unit/services/claude-usage-service.test.ts index 07ad13c9..7901192c 100644 --- a/apps/server/tests/unit/services/claude-usage-service.test.ts +++ b/apps/server/tests/unit/services/claude-usage-service.test.ts @@ -124,6 +124,59 @@ describe('claude-usage-service.ts', () => { expect(result).toBe('Plain text'); }); + + it('should strip OSC sequences (window title, etc.)', () => { + const service = new ClaudeUsageService(); + // OSC sequence to set window title: ESC ] 0 ; title BEL + const input = '\x1B]0;Claude Code\x07Regular text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Regular text'); + }); + + it('should strip DEC private mode sequences', () => { + const service = new ClaudeUsageService(); + // DEC private mode sequences like ESC[?2026h and ESC[?2026l + const input = '\x1B[?2026lClaude Code\x1B[?2026h more text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Claude Code more text'); + }); + + it('should handle complex terminal output with mixed escape sequences', () => { + const service = new ClaudeUsageService(); + // Simulate the garbled output seen in the bug: "[?2026l ]0;❇ Claude Code [?2026h" + // This contains OSC (set title) and DEC private mode sequences + const input = + '\x1B[?2026l\x1B]0;❇ Claude Code\x07\x1B[?2026hCurrent session 0%used Resets3am'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Current session 0%used Resets3am'); + }); + + it('should strip single character escape sequences', () => { + const service = new ClaudeUsageService(); + // ESC c is the reset terminal command + const input = '\x1BcReset text'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + expect(result).toBe('Reset text'); + }); + + it('should remove control characters but preserve newlines and tabs', () => { + const service = new ClaudeUsageService(); + // BEL character (\x07) should be stripped, but the word "Bell" is regular text + const input = 'Line 1\nLine 2\tTabbed\x07 with bell'; + // @ts-expect-error - accessing private method for testing + const result = service.stripAnsiCodes(input); + + // BEL is stripped, newlines and tabs preserved + expect(result).toBe('Line 1\nLine 2\tTabbed with bell'); + }); }); describe('parseResetTime', () => {