From 181a7f436bfd7c500f01b5cdaf56149d219cbe2e Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Thu, 15 Jan 2026 23:32:09 -0600 Subject: [PATCH] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Installed React DevTools automatically for Electron development debugging bliss ๐Ÿงฉ - Fixed token aggregation: MAX across models to stop double-counting ๐ŸŽฏ - Corrected Claude context math by excluding cumulative cache-read tokens ๐Ÿ“‰ - Context usage now reflects latest value, not high-water mark history ๐Ÿง  - Preserved lifetime stats by archiving deleted sessions instead of purging ๐Ÿ—„๏ธ - Filtered SSH shell-integration junk from stdout for cleaner logs ๐Ÿงผ - Hardened terminal/ANSI stripping for iTerm2, VSCode, and OSC sequences ๐Ÿงน - Prevented UI freezes: truncate huge syntax-highlight previews and warn users โš ๏ธ - Skipped expensive token counting for >1MB files to keep previews snappy ๐Ÿš€ - Mobile web now renders AI responses as rich Markdown with copyable code blocks ๐Ÿ“ฑ --- package-lock.json | 128 ++++++ package.json | 2 + .../main/parsers/claude-output-parser.test.ts | 9 +- .../main/parsers/usage-aggregator.test.ts | 13 +- src/__tests__/main/process-manager.test.ts | 26 +- .../main/utils/terminalFilter.test.ts | 32 ++ .../renderer/components/FilePreview.test.tsx | 76 ++++ .../components/HistoryDetailModal.test.tsx | 14 +- .../renderer/components/MainPanel.test.tsx | 10 +- .../components/QuickActionsModal.test.tsx | 14 + .../components/TabSwitcherModal.test.tsx | 6 +- .../renderer/utils/contextExtractor.test.ts | 9 +- .../renderer/utils/contextUsage.test.ts | 72 ++-- src/__tests__/shared/stringUtils.test.ts | 65 +++ .../web/mobile/MessageHistory.test.tsx | 33 +- src/main/index.ts | 7 + src/main/ipc/handlers/agentSessions.ts | 20 +- src/main/parsers/usage-aggregator.ts | 39 +- src/main/process-manager.ts | 7 +- src/main/utils/statsCache.ts | 7 +- src/main/utils/terminalFilter.ts | 19 + src/renderer/App.tsx | 10 +- src/renderer/components/FilePreview.tsx | 51 ++- src/renderer/components/SessionList.tsx | 81 +++- src/renderer/hooks/git/useGitStatusPolling.ts | 49 ++- .../hooks/session/useBatchedSessionUpdates.ts | 16 +- src/renderer/index.html | 11 + src/renderer/utils/contextUsage.ts | 28 +- src/shared/stringUtils.ts | 64 ++- src/web/mobile/MessageHistory.tsx | 26 +- src/web/mobile/MobileMarkdownRenderer.tsx | 397 ++++++++++++++++++ vite.config.mts | 2 + 32 files changed, 1182 insertions(+), 161 deletions(-) create mode 100644 src/web/mobile/MobileMarkdownRenderer.tsx diff --git a/package-lock.json b/package-lock.json index 851d6906..789246f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "@types/archiver": "^7.0.0", "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", + "@types/electron-devtools-installer": "^2.2.5", "@types/node": "^20.10.6", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", @@ -81,6 +82,7 @@ "concurrently": "^8.2.2", "electron": "^28.1.0", "electron-builder": "^24.9.1", + "electron-devtools-installer": "^4.0.0", "electron-playwright-helpers": "^2.0.1", "electron-rebuild": "^3.2.9", "esbuild": "^0.24.2", @@ -4019,6 +4021,13 @@ "@types/trusted-types": "*" } }, + "node_modules/@types/electron-devtools-installer": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@types/electron-devtools-installer/-/electron-devtools-installer-2.2.5.tgz", + "integrity": "sha512-DhH8z0dadKuDolvH4TiW40Vp7H3VyZbOoZv98hhBaUfnxmvvcXTjkZjzw/54xvAmuG4KFzExOGAiVLg3jM2ojQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -8411,6 +8420,16 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-devtools-installer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/electron-devtools-installer/-/electron-devtools-installer-4.0.0.tgz", + "integrity": "sha512-9Tntu/jtfSn0n6N/ZI6IdvRqXpDyLQiDuuIbsBI+dL+1Ef7C8J2JwByw58P3TJiNeuqyV3ZkphpNWuZK5iSY2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "unzip-crx-3": "^0.2.0" + } + }, "node_modules/electron-playwright-helpers": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/electron-playwright-helpers/-/electron-playwright-helpers-2.0.1.tgz", @@ -10904,6 +10923,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -11936,6 +11962,52 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/katex": { "version": "0.16.25", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", @@ -12066,6 +12138,16 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/light-my-request": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", @@ -14370,6 +14452,13 @@ "integrity": "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==", "license": "MIT" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -16361,6 +16450,13 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -17871,6 +17967,31 @@ "node": ">= 4.0.0" } }, + "node_modules/unzip-crx-3": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/unzip-crx-3/-/unzip-crx-3-0.2.0.tgz", + "integrity": "sha512-0+JiUq/z7faJ6oifVB5nSwt589v1KCduqIJupNVDoWSXZtWDmjDGO3RAEOvwJ07w90aoXoP4enKsR7ecMrJtWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "^3.1.0", + "mkdirp": "^0.5.1", + "yaku": "^0.16.6" + } + }, + "node_modules/unzip-crx-3/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", @@ -19615,6 +19736,13 @@ "node": ">=10" } }, + "node_modules/yaku": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/yaku/-/yaku-0.16.7.tgz", + "integrity": "sha512-Syu3IB3rZvKvYk7yTiyl1bo/jiEFaaStrgv1V2TIJTqYPStSMQVO8EQjg/z+DRzLq/4LIIharNT3iH1hylEIRw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index b9524433..887db0ed 100644 --- a/package.json +++ b/package.json @@ -257,6 +257,7 @@ "@types/archiver": "^7.0.0", "@types/better-sqlite3": "^7.6.13", "@types/canvas-confetti": "^1.9.0", + "@types/electron-devtools-installer": "^2.2.5", "@types/node": "^20.10.6", "@types/qrcode": "^1.5.6", "@types/react": "^18.2.47", @@ -273,6 +274,7 @@ "concurrently": "^8.2.2", "electron": "^28.1.0", "electron-builder": "^24.9.1", + "electron-devtools-installer": "^4.0.0", "electron-playwright-helpers": "^2.0.1", "electron-rebuild": "^3.2.9", "esbuild": "^0.24.2", diff --git a/src/__tests__/main/parsers/claude-output-parser.test.ts b/src/__tests__/main/parsers/claude-output-parser.test.ts index 9cedf417..0ddd1326 100644 --- a/src/__tests__/main/parsers/claude-output-parser.test.ts +++ b/src/__tests__/main/parsers/claude-output-parser.test.ts @@ -249,7 +249,9 @@ describe('ClaudeOutputParser', () => { expect(parser.extractUsage(event!)).toBeNull(); }); - it('should aggregate usage from multiple models', () => { + it('should use MAX (not SUM) across multiple models for usage', () => { + // When multiple models are used in one turn, each reads the same conversation + // context from cache. Using MAX gives actual context size, SUM would double-count. const event = parser.parseJsonLine( JSON.stringify({ type: 'result', @@ -268,8 +270,9 @@ describe('ClaudeOutputParser', () => { ); const usage = parser.extractUsage(event!); - expect(usage?.inputTokens).toBe(300); - expect(usage?.outputTokens).toBe(150); + // MAX values: max(100, 200)=200, max(50, 100)=100 + expect(usage?.inputTokens).toBe(200); + expect(usage?.outputTokens).toBe(100); }); }); diff --git a/src/__tests__/main/parsers/usage-aggregator.test.ts b/src/__tests__/main/parsers/usage-aggregator.test.ts index 1c2a5b52..54be1678 100644 --- a/src/__tests__/main/parsers/usage-aggregator.test.ts +++ b/src/__tests__/main/parsers/usage-aggregator.test.ts @@ -12,7 +12,9 @@ import { } from '../../../main/parsers/usage-aggregator'; describe('aggregateModelUsage', () => { - it('should aggregate model usage from multiple models', () => { + it('should use MAX (not SUM) across models for token counts', () => { + // When multiple models are used in one turn, each reads the same conversation + // context from cache. Using MAX gives actual context size, SUM would double-count. const modelUsage: Record = { 'claude-3-5-sonnet': { inputTokens: 1000, @@ -32,10 +34,11 @@ describe('aggregateModelUsage', () => { const result = aggregateModelUsage(modelUsage, {}, 0.05); - expect(result.inputTokens).toBe(1500); - expect(result.outputTokens).toBe(750); - expect(result.cacheReadInputTokens).toBe(250); - expect(result.cacheCreationInputTokens).toBe(125); + // MAX values, not SUM: max(1000, 500)=1000, max(500, 250)=500, etc. + expect(result.inputTokens).toBe(1000); + expect(result.outputTokens).toBe(500); + expect(result.cacheReadInputTokens).toBe(200); + expect(result.cacheCreationInputTokens).toBe(100); expect(result.totalCostUsd).toBe(0.05); expect(result.contextWindow).toBe(200000); }); diff --git a/src/__tests__/main/process-manager.test.ts b/src/__tests__/main/process-manager.test.ts index a37080dc..5d5e00d9 100644 --- a/src/__tests__/main/process-manager.test.ts +++ b/src/__tests__/main/process-manager.test.ts @@ -60,7 +60,9 @@ describe('process-manager.ts', () => { }); }); - it('should aggregate tokens from multiple models', () => { + it('should use MAX (not SUM) across multiple models', () => { + // When multiple models are used in one turn, each reads the same context + // from cache. Using MAX gives actual context size, SUM would double-count. const modelUsage: Record = { 'claude-3-sonnet': { inputTokens: 1000, @@ -80,11 +82,12 @@ describe('process-manager.ts', () => { const result = aggregateModelUsage(modelUsage, {}, 0.10); + // MAX values: max(1000,500)=1000, max(500,250)=500, etc. expect(result).toEqual({ - inputTokens: 1500, - outputTokens: 750, - cacheReadInputTokens: 300, - cacheCreationInputTokens: 150, + inputTokens: 1000, + outputTokens: 500, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 100, totalCostUsd: 0.10, contextWindow: 200000, // Should use the highest context window }); @@ -125,8 +128,9 @@ describe('process-manager.ts', () => { const result = aggregateModelUsage(modelUsage); + // MAX values: max(1000,500)=1000, max(500,0)=500, max(0,100)=100 expect(result).toEqual({ - inputTokens: 1500, + inputTokens: 1000, outputTokens: 500, cacheReadInputTokens: 100, cacheCreationInputTokens: 0, @@ -318,7 +322,8 @@ describe('process-manager.ts', () => { expect(result.outputTokens).toBe(500); }); - it('should handle multi-model response (e.g., main + tool use)', () => { + it('should use MAX across multi-model response (e.g., main + tool use)', () => { + // When multiple models are used, each reads the same context. MAX avoids double-counting. const modelUsage: Record = { 'claude-3-opus': { inputTokens: 20000, @@ -328,7 +333,7 @@ describe('process-manager.ts', () => { contextWindow: 200000, }, 'claude-3-haiku': { - // Used for tool use + // Used for tool use - smaller context read inputTokens: 500, outputTokens: 100, contextWindow: 200000, @@ -337,8 +342,9 @@ describe('process-manager.ts', () => { const result = aggregateModelUsage(modelUsage, {}, 0.25); - expect(result.inputTokens).toBe(20500); - expect(result.outputTokens).toBe(3100); + // MAX values: max(20000, 500)=20000, max(3000, 100)=3000 + expect(result.inputTokens).toBe(20000); + expect(result.outputTokens).toBe(3000); expect(result.cacheReadInputTokens).toBe(15000); expect(result.cacheCreationInputTokens).toBe(2000); expect(result.totalCostUsd).toBe(0.25); diff --git a/src/__tests__/main/utils/terminalFilter.test.ts b/src/__tests__/main/utils/terminalFilter.test.ts index 5bc540e1..338db965 100644 --- a/src/__tests__/main/utils/terminalFilter.test.ts +++ b/src/__tests__/main/utils/terminalFilter.test.ts @@ -153,6 +153,38 @@ describe('terminalFilter', () => { const result = stripControlSequences(input); expect(result).toBe('pwd'); }); + + it('should remove bare ]1337; sequences chained together (SSH output)', () => { + // Real example from SSH connections - sequences without ESC prefix, chained together + const input = ']1337;RemoteHost=pedram@PedTome.local]1337;CurrentDir=/Users/pedram]1337;ShellIntegrationVersion=13;shell=zsh/opt/homebrew/bin/codex'; + const result = stripControlSequences(input); + expect(result).toBe('/opt/homebrew/bin/codex'); + }); + + it('should remove bare ]1337; sequences with BEL terminator', () => { + const input = ']1337;CurrentDir=/home/user\x07/usr/local/bin/codex'; + const result = stripControlSequences(input); + expect(result).toBe('/usr/local/bin/codex'); + }); + + it('should handle real SSH session init output', () => { + // Simulates what appears when SSH shell integration emits sequences at session start + const input = ']1337;RemoteHost=pedram@PedTome.local]1337;CurrentDir=/Users/pedram]1337;ShellIntegrationVersion=13;shell=zsh{"type":"system","subtype":"init"}'; + const result = stripControlSequences(input); + expect(result).toBe('{"type":"system","subtype":"init"}'); + }); + + it('should remove bare ]133; sequences (VSCode)', () => { + const input = ']133;A\x07prompt]133;B\x07output'; + const result = stripControlSequences(input); + expect(result).toBe('promptoutput'); + }); + + it('should remove bare ]7; sequences', () => { + const input = ']7;file:///home/user/project\x07content'; + const result = stripControlSequences(input); + expect(result).toBe('content'); + }); }); describe('other escape sequences', () => { diff --git a/src/__tests__/renderer/components/FilePreview.test.tsx b/src/__tests__/renderer/components/FilePreview.test.tsx index ac1b2119..215c4876 100644 --- a/src/__tests__/renderer/components/FilePreview.test.tsx +++ b/src/__tests__/renderer/components/FilePreview.test.tsx @@ -302,4 +302,80 @@ describe('FilePreview', () => { expect(container.firstChild).toBeNull(); }); }); + + describe('large file handling', () => { + it('shows truncation banner for files larger than 100KB', () => { + // Create content larger than LARGE_FILE_PREVIEW_LIMIT (100KB) + const largeContent = 'x'.repeat(150 * 1024); // 150KB + render( + + ); + + expect(screen.getByText(/Large file preview truncated/)).toBeInTheDocument(); + expect(screen.getByText(/Use an external editor for the full file/)).toBeInTheDocument(); + }); + + it('does not show truncation banner for small files', () => { + const smallContent = 'x'.repeat(50 * 1024); // 50KB - under threshold + render( + + ); + + expect(screen.queryByText(/Large file preview truncated/)).not.toBeInTheDocument(); + }); + + it('does not show truncation banner for markdown files (they are not truncated)', () => { + // Markdown files are rendered with ReactMarkdown, not SyntaxHighlighter + // They should not be truncated as ReactMarkdown handles large content differently + const largeMarkdown = '# Header\n'.repeat(20 * 1024); // Large markdown + render( + + ); + + expect(screen.queryByText(/Large file preview truncated/)).not.toBeInTheDocument(); + }); + + it('truncates displayed content to 100KB for syntax highlighting', () => { + const largeContent = 'y'.repeat(200 * 1024); // 200KB + render( + + ); + + // The syntax highlighter should receive truncated content + const highlighter = screen.getByTestId('syntax-highlighter'); + // Content should be truncated to 100KB (LARGE_FILE_PREVIEW_LIMIT) + expect(highlighter.textContent?.length).toBe(100 * 1024); + }); + + it('skips token counting for files larger than 1MB', async () => { + const { getEncoder } = await import('../../../renderer/utils/tokenCounter'); + + // Create content larger than LARGE_FILE_TOKEN_SKIP_THRESHOLD (1MB) + const hugeContent = 'z'.repeat(1.5 * 1024 * 1024); // 1.5MB + render( + + ); + + // Token counting should be skipped for large files + // getEncoder should not have been called for this file + // (it may have been called from previous tests, but not with this content) + // The token count state should remain null for large files + expect(screen.queryByText(/tokens/)).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx index eee8f8a2..a65f9f1b 100644 --- a/src/__tests__/renderer/components/HistoryDetailModal.test.tsx +++ b/src/__tests__/renderer/components/HistoryDetailModal.test.tsx @@ -472,8 +472,8 @@ describe('HistoryDetailModal', () => { usageStats: { inputTokens: 5000, outputTokens: 1000, - cacheReadInputTokens: 2000, - cacheCreationInputTokens: 3000, + cacheReadInputTokens: 2000, // Excluded from calculation (cumulative) + cacheCreationInputTokens: 5000, contextWindow: 100000, totalCostUsd: 0.10, }, @@ -482,8 +482,8 @@ describe('HistoryDetailModal', () => { /> ); - // Context = (inputTokens + cacheCreationInputTokens + cacheReadInputTokens) / contextWindow - // (5000 + 3000 + 2000) / 100000 = 10% + // Context = (inputTokens + cacheCreationInputTokens) / contextWindow (cacheRead excluded) + // (5000 + 5000) / 100000 = 10% expect(screen.getByText('10%')).toBeInTheDocument(); }); @@ -642,8 +642,8 @@ describe('HistoryDetailModal', () => { usageStats: { inputTokens: 74000, outputTokens: 1000, - cacheReadInputTokens: 1000, - cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 1000, // Included in calculation contextWindow: 100000, totalCostUsd: 0, }, @@ -652,7 +652,7 @@ describe('HistoryDetailModal', () => { /> ); - // (74000 + 1000 cache read) / 100000 = 75% + // (74000 + 1000 cacheCreation) / 100000 = 75% (cacheRead excluded - cumulative) expect(screen.getByText('75%')).toBeInTheDocument(); }); diff --git a/src/__tests__/renderer/components/MainPanel.test.tsx b/src/__tests__/renderer/components/MainPanel.test.tsx index 891b01dd..3b0a3c5a 100644 --- a/src/__tests__/renderer/components/MainPanel.test.tsx +++ b/src/__tests__/renderer/components/MainPanel.test.tsx @@ -1806,8 +1806,8 @@ describe('MainPanel', () => { render(); - // Context usage should be (50000 + 25000 cache read) / 200000 * 100 = 37.5% - expect(getContextColor).toHaveBeenCalledWith(38, theme); // Rounded to 38 + // Context usage should be 50000 / 200000 * 100 = 25% (cacheRead excluded - cumulative) + expect(getContextColor).toHaveBeenCalledWith(25, theme); }); }); @@ -2222,8 +2222,8 @@ describe('MainPanel', () => { usageStats: { inputTokens: 150000, outputTokens: 100000, - cacheReadInputTokens: 100000, - cacheCreationInputTokens: 0, + cacheReadInputTokens: 100000, // Excluded from calculation (cumulative) + cacheCreationInputTokens: 100000, // Included in calculation totalCostUsd: 0.05, contextWindow: 200000, }, @@ -2233,7 +2233,7 @@ describe('MainPanel', () => { render(); - // Context usage should be capped at 100 + // Context usage: (150000 + 100000) / 200000 = 125% -> capped at 100% expect(getContextColor).toHaveBeenCalledWith(100, theme); }); }); diff --git a/src/__tests__/renderer/components/QuickActionsModal.test.tsx b/src/__tests__/renderer/components/QuickActionsModal.test.tsx index 224f363c..9ba65467 100644 --- a/src/__tests__/renderer/components/QuickActionsModal.test.tsx +++ b/src/__tests__/renderer/components/QuickActionsModal.test.tsx @@ -12,6 +12,20 @@ beforeAll(() => { createPackage: vi.fn().mockResolvedValue({ success: true, path: '/tmp/test.zip' }), previewPackage: vi.fn().mockResolvedValue({ categories: [] }), }; + + // Mock localStorage for the test environment + const localStorageMock = { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), + }; + Object.defineProperty(window, 'localStorage', { + value: localStorageMock, + writable: true, + }); }); // Mock dependencies diff --git a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx index daeea226..71688182 100644 --- a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx +++ b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx @@ -557,8 +557,8 @@ describe('TabSwitcherModal', () => { usageStats: { inputTokens: 150000, outputTokens: 0, - cacheReadInputTokens: 100000, - cacheCreationInputTokens: 0, + cacheReadInputTokens: 100000, // Excluded from calculation (cumulative) + cacheCreationInputTokens: 100000, totalCostUsd: 5.00, contextWindow: 200000, }, @@ -576,7 +576,7 @@ describe('TabSwitcherModal', () => { /> ); - // (150000 + 100000) / 200000 = 125% -> capped at 100% + // (150000 + 100000) / 200000 = 125% -> capped at 100% (cacheRead excluded) expect(screen.getByText('100%')).toBeInTheDocument(); }); }); diff --git a/src/__tests__/renderer/utils/contextExtractor.test.ts b/src/__tests__/renderer/utils/contextExtractor.test.ts index 6d46e355..417610f1 100644 --- a/src/__tests__/renderer/utils/contextExtractor.test.ts +++ b/src/__tests__/renderer/utils/contextExtractor.test.ts @@ -432,7 +432,7 @@ describe('estimateTokenCount', () => { const tokens = estimateTokenCount(context); - expect(tokens).toBe(700); // input + cacheCreation + cacheRead + expect(tokens).toBe(700); // input + cacheCreation (cacheRead excluded - cumulative) }); it('should estimate from log content when no usage stats', () => { @@ -641,8 +641,8 @@ describe('calculateTotalTokens', () => { const total = calculateTotalTokens(contexts); - // input + cacheCreation + cacheRead for each context - expect(total).toBe(575); // (100+25+50) + (300+25+75) + // input + cacheCreation for each context (cacheRead excluded - cumulative) + expect(total).toBe(450); // (100+25) + (300+25) }); }); @@ -685,7 +685,8 @@ describe('getContextSummary', () => { expect(summary.totalSources).toBe(2); expect(summary.totalLogs).toBe(5); - expect(summary.estimatedTokens).toBe(475); + // (100+25) + (200+25) = 350 (cacheRead excluded - cumulative) + expect(summary.estimatedTokens).toBe(350); expect(summary.byAgent['claude-code']).toBe(1); expect(summary.byAgent['opencode']).toBe(1); }); diff --git a/src/__tests__/renderer/utils/contextUsage.test.ts b/src/__tests__/renderer/utils/contextUsage.test.ts index 1dba433d..4e0ccabb 100644 --- a/src/__tests__/renderer/utils/contextUsage.test.ts +++ b/src/__tests__/renderer/utils/contextUsage.test.ts @@ -24,27 +24,29 @@ describe('estimateContextUsage', () => { expect(result).toBe(10); }); - it('should include cacheReadInputTokens in calculation', () => { + it('should exclude cacheReadInputTokens from calculation (cumulative, not per-request)', () => { const stats = createStats({ inputTokens: 1000, outputTokens: 500, - cacheReadInputTokens: 50000, + cacheReadInputTokens: 50000, // Should be ignored + cacheCreationInputTokens: 5000, contextWindow: 100000, }); const result = estimateContextUsage(stats, 'claude-code'); - // (1000 + 0 + 50000) / 100000 = 51% -> 51% - expect(result).toBe(51); + // (1000 + 5000) / 100000 = 6% (cacheRead excluded) + expect(result).toBe(6); }); it('should cap at 100%', () => { const stats = createStats({ inputTokens: 50000, outputTokens: 50000, - cacheReadInputTokens: 150000, + cacheReadInputTokens: 150000, // Ignored + cacheCreationInputTokens: 200000, contextWindow: 200000, }); const result = estimateContextUsage(stats, 'claude-code'); - // (50000 + 50000 + 150000) / 200000 = 125% -> capped at 100% + // (50000 + 200000) / 200000 = 125% -> capped at 100% expect(result).toBe(100); }); @@ -129,21 +131,23 @@ describe('estimateContextUsage', () => { // @ts-expect-error - testing undefined case stats.cacheReadInputTokens = undefined; const result = estimateContextUsage(stats, 'claude-code'); - // (10000 + 0 + 0) / 100000 = 10% + // (10000 + 0) / 100000 = 10% expect(result).toBe(10); }); - it('should correctly calculate with large cache read tokens', () => { - // This simulates a real scenario: small new input but large cached history + it('should ignore large cache read tokens (they are cumulative, not per-request)', () => { + // Claude Code reports cacheReadInputTokens as cumulative session totals. + // They can exceed the context window, so we exclude them from calculation. const stats = createStats({ inputTokens: 500, // small new turn input outputTokens: 1000, // small response - cacheReadInputTokens: 180000, // large cached conversation + cacheReadInputTokens: 758000, // cumulative across session - should be IGNORED + cacheCreationInputTokens: 50000, // new cache this turn contextWindow: 200000, }); const result = estimateContextUsage(stats, 'claude-code'); - // (500 + 0 + 180000) / 200000 = 90% -> 90% - expect(result).toBe(90); + // (500 + 50000) / 200000 = 25% (cacheRead excluded) + expect(result).toBe(25); }); }); @@ -166,13 +170,14 @@ describe('estimateContextUsage', () => { it('should handle very large token counts', () => { const stats = createStats({ - inputTokens: 500000, + inputTokens: 250000, outputTokens: 500000, - cacheReadInputTokens: 500000, + cacheReadInputTokens: 500000, // Ignored + cacheCreationInputTokens: 250000, contextWindow: 0, }); const result = estimateContextUsage(stats, 'claude-code'); - // (500000 + 0 + 500000) / 200000 = 250% -> capped at 100% + // (250000 + 250000) / 200000 = 250% -> capped at 100% expect(result).toBe(100); }); @@ -184,7 +189,7 @@ describe('estimateContextUsage', () => { contextWindow: 0, }); const result = estimateContextUsage(stats, 'claude-code'); - // (100 + 50 + 0) / 200000 = 0.075% -> 0% + // (100 + 0) / 200000 = 0.05% -> 0% (output excluded for Claude) expect(result).toBe(0); }); }); @@ -199,25 +204,26 @@ describe('calculateContextTokens', () => { ...overrides, }); - describe('Claude agents (excludes output tokens)', () => { - it('should exclude output tokens for claude-code', () => { + describe('Claude agents (excludes output and cacheRead tokens)', () => { + it('should exclude output and cacheRead tokens for claude-code', () => { const stats = createStats(); const result = calculateContextTokens(stats, 'claude-code'); - // 10000 + 1000 + 2000 = 13000 (no output tokens) - expect(result).toBe(13000); + // 10000 + 1000 = 11000 (no output, no cacheRead) + // cacheRead is excluded because Claude Code reports it as cumulative + expect(result).toBe(11000); }); - it('should exclude output tokens for claude', () => { + it('should exclude output and cacheRead tokens for claude', () => { const stats = createStats(); const result = calculateContextTokens(stats, 'claude'); - expect(result).toBe(13000); + expect(result).toBe(11000); }); - it('should exclude output tokens when agent is undefined', () => { + it('should exclude output and cacheRead tokens when agent is undefined', () => { const stats = createStats(); const result = calculateContextTokens(stats); // Defaults to Claude behavior - expect(result).toBe(13000); + expect(result).toBe(11000); }); }); @@ -225,8 +231,8 @@ describe('calculateContextTokens', () => { it('should include output tokens for codex', () => { const stats = createStats(); const result = calculateContextTokens(stats, 'codex'); - // 10000 + 5000 + 1000 + 2000 = 18000 (includes output) - expect(result).toBe(18000); + // 10000 + 5000 + 1000 = 16000 (includes output, excludes cacheRead) + expect(result).toBe(16000); }); }); @@ -252,6 +258,20 @@ describe('calculateContextTokens', () => { const result = calculateContextTokens(stats, 'claude-code'); expect(result).toBe(10000); }); + + it('should ignore large cacheRead values (cumulative session data)', () => { + // This tests the real bug scenario: Claude Code reports cumulative cacheRead + // that exceeds context window, which would cause 100%+ display + const stats = createStats({ + inputTokens: 50000, + outputTokens: 9000, + cacheReadInputTokens: 758000, // Cumulative - should be IGNORED + cacheCreationInputTokens: 75000, + }); + const result = calculateContextTokens(stats, 'claude-code'); + // 50000 + 75000 = 125000 (cacheRead excluded) + expect(result).toBe(125000); + }); }); }); diff --git a/src/__tests__/shared/stringUtils.test.ts b/src/__tests__/shared/stringUtils.test.ts index 53716423..21392e01 100644 --- a/src/__tests__/shared/stringUtils.test.ts +++ b/src/__tests__/shared/stringUtils.test.ts @@ -126,4 +126,69 @@ describe('stripAnsiCodes', () => { expect(stripAnsiCodes('\x1b[0m\x1b[1m\x1b[32mText')).toBe('Text'); expect(stripAnsiCodes('\x1b[31m\x1b[32m\x1b[34mBlue')).toBe('Blue'); }); + + // iTerm2/SSH shell integration sequence tests + describe('iTerm2 shell integration sequences', () => { + it('should strip bare ]1337; sequences chained together (SSH output)', () => { + // Real example from SSH connections - sequences without ESC prefix, chained together + const input = ']1337;RemoteHost=pedram@PedTome.local]1337;CurrentDir=/Users/pedram]1337;ShellIntegrationVersion=13;shell=zsh/opt/homebrew/bin/codex'; + expect(stripAnsiCodes(input)).toBe('/opt/homebrew/bin/codex'); + }); + + it('should strip ]1337; sequences with ESC prefix and BEL terminator', () => { + const input = '\x1b]1337;RemoteHost=user@host\x07/usr/bin/claude'; + expect(stripAnsiCodes(input)).toBe('/usr/bin/claude'); + }); + + it('should strip ]1337;CurrentDir sequences with BEL terminator', () => { + const input = ']1337;CurrentDir=/home/user\x07/usr/local/bin/codex'; + expect(stripAnsiCodes(input)).toBe('/usr/local/bin/codex'); + }); + + it('should strip multiple chained sequences with different keys', () => { + const input = ']1337;RemoteHost=user@host]1337;CurrentDir=/home/user]1337;ShellIntegrationVersion=13;shell=bash/path/to/binary'; + expect(stripAnsiCodes(input)).toBe('/path/to/binary'); + }); + + it('should strip sequences mixed with ANSI color codes', () => { + const input = '\x1b[32m\x1b]1337;CurrentDir=/home\x07\x1b[0m/usr/bin/test'; + expect(stripAnsiCodes(input)).toBe('/usr/bin/test'); + }); + + it('should handle standalone iTerm2 sequences', () => { + // Sequence followed by nothing + const input = ']1337;RemoteHost=user@host'; + expect(stripAnsiCodes(input)).toBe(''); + }); + + it('should strip sequences with only terminators', () => { + const input = '\x1b]1337;RemoteHost=user@host\x07\x1b]1337;CurrentDir=/home\x07'; + expect(stripAnsiCodes(input)).toBe(''); + }); + + it('should handle VSCode shell integration sequences (]133;)', () => { + const input = ']133;A\x07prompt\x1b]133;B\x07output'; + expect(stripAnsiCodes(input)).toBe('promptoutput'); + }); + + it('should handle current directory sequences (]7;)', () => { + const input = ']7;file:///home/user/project\x07content'; + expect(stripAnsiCodes(input)).toBe('content'); + }); + + it('should handle sequences on multiple lines', () => { + const input = '\x1b]1337;CurrentDir=/home\x07/usr/bin/codex\n\x1b]1337;CurrentDir=/home\x07/usr/bin/claude'; + expect(stripAnsiCodes(input)).toBe('/usr/bin/codex\n/usr/bin/claude'); + }); + + it('should strip BEL character alone', () => { + expect(stripAnsiCodes('Text\x07More')).toBe('TextMore'); + }); + + it('should handle real SSH session init output', () => { + // Simulates what appears when SSH shell integration emits sequences at session start + const input = ']1337;RemoteHost=pedram@PedTome.local]1337;CurrentDir=/Users/pedram]1337;ShellIntegrationVersion=13;shell=zsh{"type":"system","subtype":"init"}'; + expect(stripAnsiCodes(input)).toBe('{"type":"system","subtype":"init"}'); + }); + }); }); diff --git a/src/__tests__/web/mobile/MessageHistory.test.tsx b/src/__tests__/web/mobile/MessageHistory.test.tsx index 13e5c76c..3f9f9b3e 100644 --- a/src/__tests__/web/mobile/MessageHistory.test.tsx +++ b/src/__tests__/web/mobile/MessageHistory.test.tsx @@ -13,14 +13,17 @@ const mockColors = { textMain: '#ffffff', bgMain: '#1a1a1a', bgSidebar: '#252525', + bgActivity: '#2a2a2a', border: '#333333', accent: '#7c3aed', accentForeground: '#ffffff', error: '#ef4444', + success: '#22c55e', }; vi.mock('../../../web/components/ThemeProvider', () => ({ useThemeColors: () => mockColors, + useTheme: () => ({ isDark: true }), })); // Mock lucide-react @@ -158,12 +161,13 @@ describe('MessageHistory', () => { it('formats timestamp with hour and minute', () => { const timestamp = new Date('2024-01-15T14:30:00').getTime(); const logs: LogEntry[] = [ - createLogEntry({ timestamp, text: 'Test' }), + createLogEntry({ timestamp, text: 'Test', source: 'user' }), ]; - render(); - // The exact format depends on locale, just check a time is present - const container = screen.getByText('Test').parentElement; - expect(container?.textContent).toMatch(/\d{1,2}:\d{2}/); + const { container } = render(); + // The exact format depends on locale, just check a time is present in the message card + // User messages are rendered as plain text, not through markdown + const messageCard = container.querySelector('[style*="padding: 10px 12px"]'); + expect(messageCard?.textContent).toMatch(/\d{1,2}:\d{2}/); }); }); @@ -666,20 +670,27 @@ describe('MessageHistory', () => { expect(messageContent).toHaveStyle({ fontFamily: 'ui-monospace, monospace' }); }); - it('uses inherit font for AI messages in ai mode', () => { + it('renders AI messages through MobileMarkdownRenderer', () => { + // AI messages (stdout in ai mode) are rendered through MobileMarkdownRenderer + // which wraps content in a paragraph element with its own styling const logs: LogEntry[] = [createLogEntry({ source: 'stdout', text: 'AI response' })]; render(); const messageContent = screen.getByText('AI response'); - expect(messageContent).toHaveStyle({ fontFamily: 'inherit' }); + // The text is wrapped in a

element by ReactMarkdown + expect(messageContent.tagName.toLowerCase()).toBe('p'); }); - it('applies error color for stderr message content', () => { + it('applies error styles to stderr message container', () => { + // Stderr messages use MobileMarkdownRenderer too, but the outer container has error styling const logs: LogEntry[] = [createLogEntry({ source: 'stderr', text: 'Error content' })]; - render(); + const { container } = render(); - const messageContent = screen.getByText('Error content'); - expect(messageContent).toHaveStyle({ color: mockColors.error }); + // The outer content div has the error color, not the inner markdown element + const messageCard = container.querySelector('[style*="padding: 10px 12px"]'); + expect(messageCard).toBeInTheDocument(); + // Verify the error label is shown + expect(screen.getByText('Error')).toBeInTheDocument(); }); }); diff --git a/src/main/index.ts b/src/main/index.ts index f381dc6a..090ae6cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -728,6 +728,13 @@ function createWindow() { // Load the app if (process.env.NODE_ENV === 'development') { + // Install React DevTools extension in development mode + import('electron-devtools-installer').then(({ default: installExtension, REACT_DEVELOPER_TOOLS }) => { + installExtension(REACT_DEVELOPER_TOOLS) + .then(() => logger.info('React DevTools extension installed', 'Window')) + .catch((err: Error) => logger.warn(`Failed to install React DevTools: ${err.message}`, 'Window')); + }).catch((err: Error) => logger.warn(`Failed to load electron-devtools-installer: ${err.message}`, 'Window')); + mainWindow.loadURL('http://localhost:5173'); // DevTools can be opened via Command-K menu instead of automatically on startup logger.info('Loading development server', 'Window'); diff --git a/src/main/ipc/handlers/agentSessions.ts b/src/main/ipc/handlers/agentSessions.ts index f836b0be..652291ff 100644 --- a/src/main/ipc/handlers/agentSessions.ts +++ b/src/main/ipc/handlers/agentSessions.ts @@ -809,19 +809,29 @@ export function registerAgentSessionsHandlers(deps?: AgentSessionsHandlerDepende discoverCodexSessionFiles(), ]); - // Build sets of current session keys for cleanup + // Build sets of current session keys for archive detection const currentClaudeKeys = new Set(claudeFiles.map((f) => f.sessionKey)); const currentCodexKeys = new Set(codexFiles.map((f) => f.sessionKey)); - // Remove deleted sessions from cache + // Mark deleted sessions as archived (preserve stats for lifetime cost tracking) for (const key of Object.keys(cache.providers['claude-code'].sessions)) { + const session = cache.providers['claude-code'].sessions[key]; if (!currentClaudeKeys.has(key)) { - delete cache.providers['claude-code'].sessions[key]; + // Source file deleted - mark as archived to preserve stats + session.archived = true; + } else if (session.archived) { + // Source file reappeared - mark as active (will be re-parsed below) + session.archived = false; } } for (const key of Object.keys(cache.providers['codex'].sessions)) { + const session = cache.providers['codex'].sessions[key]; if (!currentCodexKeys.has(key)) { - delete cache.providers['codex'].sessions[key]; + // Source file deleted - mark as archived to preserve stats + session.archived = true; + } else if (session.archived) { + // Source file reappeared - mark as active (will be re-parsed below) + session.archived = false; } } @@ -857,6 +867,7 @@ export function registerAgentSessionsHandlers(deps?: AgentSessionsHandlerDepende cache.providers['claude-code'].sessions[file.sessionKey] = { ...stats, fileMtimeMs: file.mtimeMs, + archived: false, }; processedCount++; @@ -880,6 +891,7 @@ export function registerAgentSessionsHandlers(deps?: AgentSessionsHandlerDepende cache.providers['codex'].sessions[file.sessionKey] = { ...stats, fileMtimeMs: file.mtimeMs, + archived: false, }; processedCount++; diff --git a/src/main/parsers/usage-aggregator.ts b/src/main/parsers/usage-aggregator.ts index 70364810..9b383215 100644 --- a/src/main/parsers/usage-aggregator.ts +++ b/src/main/parsers/usage-aggregator.ts @@ -153,18 +153,23 @@ export function aggregateModelUsage( } = {}, totalCostUsd: number = 0 ): UsageStats { - let aggregatedInputTokens = 0; - let aggregatedOutputTokens = 0; - let aggregatedCacheReadTokens = 0; - let aggregatedCacheCreationTokens = 0; + // Use MAX across models for context-related tokens, not SUM. + // When Claude Code uses multiple models (e.g., Haiku + Sonnet) in one turn, + // each model reads approximately the same conversation context from cache. + // Summing would double-count: Haiku reads 100k + Sonnet reads 100k = 200k (wrong!) + // MAX gives the actual context size: max(100k, 100k) = 100k (correct!) + let maxInputTokens = 0; + let maxOutputTokens = 0; + let maxCacheReadTokens = 0; + let maxCacheCreationTokens = 0; let contextWindow = 200000; // Default for Claude if (modelUsage) { for (const modelStats of Object.values(modelUsage)) { - aggregatedInputTokens += modelStats.inputTokens || 0; - aggregatedOutputTokens += modelStats.outputTokens || 0; - aggregatedCacheReadTokens += modelStats.cacheReadInputTokens || 0; - aggregatedCacheCreationTokens += modelStats.cacheCreationInputTokens || 0; + maxInputTokens = Math.max(maxInputTokens, modelStats.inputTokens || 0); + maxOutputTokens = Math.max(maxOutputTokens, modelStats.outputTokens || 0); + maxCacheReadTokens = Math.max(maxCacheReadTokens, modelStats.cacheReadInputTokens || 0); + maxCacheCreationTokens = Math.max(maxCacheCreationTokens, modelStats.cacheCreationInputTokens || 0); // Use the highest context window from any model if (modelStats.contextWindow && modelStats.contextWindow > contextWindow) { contextWindow = modelStats.contextWindow; @@ -174,18 +179,18 @@ export function aggregateModelUsage( // Fall back to top-level usage if modelUsage isn't available // This handles older CLI versions or different output formats - if (aggregatedInputTokens === 0 && aggregatedOutputTokens === 0) { - aggregatedInputTokens = usage.input_tokens || 0; - aggregatedOutputTokens = usage.output_tokens || 0; - aggregatedCacheReadTokens = usage.cache_read_input_tokens || 0; - aggregatedCacheCreationTokens = usage.cache_creation_input_tokens || 0; + if (maxInputTokens === 0 && maxOutputTokens === 0) { + maxInputTokens = usage.input_tokens || 0; + maxOutputTokens = usage.output_tokens || 0; + maxCacheReadTokens = usage.cache_read_input_tokens || 0; + maxCacheCreationTokens = usage.cache_creation_input_tokens || 0; } return { - inputTokens: aggregatedInputTokens, - outputTokens: aggregatedOutputTokens, - cacheReadInputTokens: aggregatedCacheReadTokens, - cacheCreationInputTokens: aggregatedCacheCreationTokens, + inputTokens: maxInputTokens, + outputTokens: maxOutputTokens, + cacheReadInputTokens: maxCacheReadTokens, + cacheCreationInputTokens: maxCacheCreationTokens, totalCostUsd, contextWindow, }; diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index c5cb5eed..216a5e3e 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -807,7 +807,12 @@ export class ProcessManager extends EventEmitter { logger.error('[ProcessManager] stdout error', 'ProcessManager', { sessionId, error: String(err) }); }); childProcess.stdout.on('data', (data: Buffer | string) => { - const output = data.toString(); + // Filter shell integration sequences that may appear in SSH sessions + // SSH interactive shells can emit bare OSC sequences (without ESC prefix) + // when .zshrc/.bashrc loads shell integration (iTerm2, VSCode, etc.) + // Format: ]1337;Key=Value]1337;Key=Value...actual content + let output = data.toString(); + output = stripControlSequences(output); // Debug: Log all stdout data for group chat sessions if (sessionId.includes('group-chat-')) { diff --git a/src/main/utils/statsCache.ts b/src/main/utils/statsCache.ts index f98895c5..26ebffee 100644 --- a/src/main/utils/statsCache.ts +++ b/src/main/utils/statsCache.ts @@ -122,6 +122,11 @@ export interface CachedSessionStats { sizeBytes: number; /** File modification time to detect external changes */ fileMtimeMs: number; + /** + * Whether the source JSONL file has been deleted. + * Archived sessions are preserved in cache so lifetime costs survive file cleanup. + */ + archived?: boolean; } /** @@ -140,7 +145,7 @@ export interface GlobalStatsCache { } /** Current global stats cache version. Bump to force cache invalidation. */ -export const GLOBAL_STATS_CACHE_VERSION = 2; +export const GLOBAL_STATS_CACHE_VERSION = 3; /** * Get the cache file path for global stats. diff --git a/src/main/utils/terminalFilter.ts b/src/main/utils/terminalFilter.ts index f8f2f8a4..fd73726a 100644 --- a/src/main/utils/terminalFilter.ts +++ b/src/main/utils/terminalFilter.ts @@ -100,6 +100,25 @@ export function stripControlSequences(text: string, lastCommand?: string, isTerm cleaned = cleaned.replace(/\x1b\]1337;[^\x07\x1b]*(\x07|\x1b\\)/g, ''); cleaned = cleaned.replace(/\x1b\]7;[^\x07\x1b]*(\x07|\x1b\\)/g, ''); + // Remove BARE shell integration sequences (without ESC prefix) + // SSH interactive shells emit these when .zshrc/.bashrc loads shell integration + // Format: ]1337;Key=Value]1337;Key=Value...actual content (no ESC prefix) + // Process BEL-terminated sequences first + cleaned = cleaned.replace(/\]1337;[^\x07]*\x07/g, ''); + cleaned = cleaned.replace(/\]133;[^\x07]*\x07/g, ''); + cleaned = cleaned.replace(/\]7;[^\x07]*\x07/g, ''); + // Handle chained sequences (followed by another ]) + cleaned = cleaned.replace(/\]1337;[^\]\x07\x1b]*(?=\])/g, ''); + cleaned = cleaned.replace(/\]133;[^\]\x07\x1b]*(?=\])/g, ''); + cleaned = cleaned.replace(/\]7;[^\]\x07\x1b]*(?=\])/g, ''); + // Handle last sequence in chain (ShellIntegrationVersion followed by content) + cleaned = cleaned.replace(/\]1337;ShellIntegrationVersion=[\d;a-zA-Z=]*/g, ''); + cleaned = cleaned.replace(/\]1337;(?:RemoteHost|CurrentDir|User|HostName)=[^\/\]\x07\{]*/g, ''); + // Handle sequences at end of string + cleaned = cleaned.replace(/^\]1337;[^\]\x07]*$/g, ''); + cleaned = cleaned.replace(/^\]133;[^\]\x07]*$/g, ''); + cleaned = cleaned.replace(/^\]7;[^\]\x07]*$/g, ''); + // Remove other OSC sequences by number cleaned = cleaned.replace(/\x1b\][0-9];[^\x07\x1b]*(\x07|\x1b\\)/g, ''); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3fd9876b..421ff259 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2857,8 +2857,10 @@ function MaestroConsoleInner() { } // Calculate context window usage percentage from CURRENT reported tokens. - // Claude Code reports actual context size as input + cache tokens. - // Codex/OpenCode cache tokens are subsets or not part of context size, so use input + output. + // IMPORTANT: Claude Code reports cacheReadInputTokens as CUMULATIVE session totals, + // not per-request values. Including them causes context % to exceed 100% impossibly. + // For Claude: context = inputTokens + cacheCreationInputTokens (new content only) + // For Codex: context = inputTokens + outputTokens (combined limit) const sessionForUsage = sessionsRef.current.find( s => s.id === actualSessionId ); @@ -2866,9 +2868,7 @@ function MaestroConsoleInner() { const isClaudeUsage = agentToolType === 'claude-code' || agentToolType === 'claude'; const currentContextTokens = isClaudeUsage - ? usageStats.inputTokens + - usageStats.cacheReadInputTokens + - usageStats.cacheCreationInputTokens + ? usageStats.inputTokens + usageStats.cacheCreationInputTokens : usageStats.inputTokens + usageStats.outputTokens; // Calculate context percentage, falling back to agent-specific defaults if contextWindow not provided diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index a961cb68..7cad3780 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -162,6 +162,12 @@ const formatFileSize = (bytes: number): string => { return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; }; +// Large file thresholds to prevent UI freezes +// Files larger than this will skip token counting (expensive operation) +const LARGE_FILE_TOKEN_SKIP_THRESHOLD = 1024 * 1024; // 1MB +// Files larger than this will have content truncated for syntax highlighting +const LARGE_FILE_PREVIEW_LIMIT = 100 * 1024; // 100KB for syntax highlighting + // Format date/time for display const formatDateTime = (isoString: string): string => { const date = new Date(isoString); @@ -465,6 +471,25 @@ export const FilePreview = forwardRef(funct // Any non-binary, non-image file can be edited as text const isEditableText = !isImage && !isBinary; + // Check if file is large (for performance optimizations) + // Use content length as primary check since fileStats may not be loaded yet + const isLargeFile = useMemo(() => { + if (!file?.content) return false; + return file.content.length > LARGE_FILE_TOKEN_SKIP_THRESHOLD; + }, [file?.content]); + + // For very large files, truncate content for syntax highlighting to prevent freezes + const displayContent = useMemo(() => { + if (!file?.content) return ''; + if (!isMarkdown && !isImage && !isBinary && file.content.length > LARGE_FILE_PREVIEW_LIMIT) { + return file.content.substring(0, LARGE_FILE_PREVIEW_LIMIT); + } + return file.content; + }, [file?.content, isMarkdown, isImage, isBinary]); + + // Track if content is truncated for display + const isContentTruncated = file?.content && displayContent.length < file.content.length; + // Calculate task counts for markdown files const taskCounts = useMemo(() => { if (!isMarkdown || !file?.content) return null; @@ -627,9 +652,10 @@ export const FilePreview = forwardRef(funct } }, [file?.path, sshRemoteId]); - // Count tokens when file content changes (skip for images and binary files) + // Count tokens when file content changes (skip for images, binary files, and large files) + // Large files would freeze the UI during token encoding useEffect(() => { - if (!file?.content || isImage || isBinary) { + if (!file?.content || isImage || isBinary || isLargeFile) { setTokenCount(null); return; } @@ -643,7 +669,7 @@ export const FilePreview = forwardRef(funct console.error('Failed to count tokens:', err); setTokenCount(null); }); - }, [file?.content, isImage, isBinary]); + }, [file?.content, isImage, isBinary, isLargeFile]); // Sync edit content when file changes or when entering edit mode useEffect(() => { @@ -1727,6 +1753,23 @@ export const FilePreview = forwardRef(funct ) : (

+ {/* Large file truncation banner */} + {isContentTruncated && ( +
+ + + Large file preview truncated. Showing first {formatFileSize(LARGE_FILE_PREVIEW_LIMIT)} of {formatFileSize(file.content.length)}. + Use an external editor for the full file. + +
+ )} (funct showLineNumbers PreTag="div" > - {file.content} + {displayContent}
)} diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 0b391c89..5c52d2f3 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo, memo } from 'react'; +import React, { useState, useEffect, useRef, useMemo, memo, useCallback } from 'react'; import { Wand2, Plus, Settings, ChevronRight, ChevronDown, ChevronUp, X, Keyboard, Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, FolderPlus, Info, GitBranch, Bot, Clock, @@ -959,25 +959,25 @@ function SessionListInner(props: SessionListProps) { const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null); const menuRef = useRef(null); - // Toggle bookmark for a session - const toggleBookmark = (sessionId: string) => { + // Toggle bookmark for a session - memoized to prevent SessionItem re-renders + const toggleBookmark = useCallback((sessionId: string) => { setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, bookmarked: !s.bookmarked } : s )); - }; + }, [setSessions]); - // Context menu handlers - const handleContextMenu = (e: React.MouseEvent, sessionId: string) => { + // Context menu handlers - memoized to prevent SessionItem re-renders + const handleContextMenu = useCallback((e: React.MouseEvent, sessionId: string) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, sessionId }); - }; + }, []); - const handleMoveToGroup = (sessionId: string, groupId: string) => { + const handleMoveToGroup = useCallback((sessionId: string, groupId: string) => { setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, groupId: groupId || undefined } : s )); - }; + }, [setSessions]); const handleDeleteSession = (sessionId: string) => { // Use the parent's delete handler if provided (includes proper cleanup) @@ -1182,6 +1182,49 @@ function SessionListInner(props: SessionListProps) { ); }; + // PERF: Cached callback maps to prevent SessionItem re-renders + // These Maps store stable function references keyed by session/editing ID + // The callbacks themselves are memoized, so the Map values remain stable + const selectHandlers = useMemo(() => { + const map = new Map void>(); + sessions.forEach(s => { + map.set(s.id, () => setActiveSessionId(s.id)); + }); + return map; + }, [sessions, setActiveSessionId]); + + const dragStartHandlers = useMemo(() => { + const map = new Map void>(); + sessions.forEach(s => { + map.set(s.id, () => handleDragStart(s.id)); + }); + return map; + }, [sessions, handleDragStart]); + + const contextMenuHandlers = useMemo(() => { + const map = new Map void>(); + sessions.forEach(s => { + map.set(s.id, (e: React.MouseEvent) => handleContextMenu(e, s.id)); + }); + return map; + }, [sessions, handleContextMenu]); + + const finishRenameHandlers = useMemo(() => { + const map = new Map void>(); + sessions.forEach(s => { + map.set(s.id, (newName: string) => finishRenamingSession(s.id, newName)); + }); + return map; + }, [sessions, finishRenamingSession]); + + const toggleBookmarkHandlers = useMemo(() => { + const map = new Map void>(); + sessions.forEach(s => { + map.set(s.id, () => toggleBookmark(s.id)); + }); + return map; + }, [sessions, toggleBookmark]); + // Helper component: Renders a session item with its worktree children (if any) const renderSessionWithWorktrees = ( session: Session, @@ -1223,14 +1266,14 @@ function SessionListInner(props: SessionListProps) { gitFileCount={gitFileCounts.get(session.id)} isInBatch={activeBatchSessionIds.includes(session.id)} jumpNumber={getSessionJumpNumber(session.id)} - onSelect={() => setActiveSessionId(session.id)} - onDragStart={() => handleDragStart(session.id)} + onSelect={selectHandlers.get(session.id)!} + onDragStart={dragStartHandlers.get(session.id)!} onDragOver={handleDragOver} onDrop={options.onDrop || handleDropOnUngrouped} - onContextMenu={(e) => handleContextMenu(e, session.id)} - onFinishRename={(newName) => finishRenamingSession(session.id, newName)} + onContextMenu={contextMenuHandlers.get(session.id)!} + onFinishRename={finishRenameHandlers.get(session.id)!} onStartRename={() => startRenamingSession(`${options.keyPrefix}-${session.id}`)} - onToggleBookmark={() => toggleBookmark(session.id)} + onToggleBookmark={toggleBookmarkHandlers.get(session.id)!} /> {/* Thin band below parent when worktrees exist but collapsed - click to expand */} @@ -1282,12 +1325,12 @@ function SessionListInner(props: SessionListProps) { gitFileCount={gitFileCounts.get(child.id)} isInBatch={activeBatchSessionIds.includes(child.id)} jumpNumber={getSessionJumpNumber(child.id)} - onSelect={() => setActiveSessionId(child.id)} - onDragStart={() => handleDragStart(child.id)} - onContextMenu={(e) => handleContextMenu(e, child.id)} - onFinishRename={(newName) => finishRenamingSession(child.id, newName)} + onSelect={selectHandlers.get(child.id)!} + onDragStart={dragStartHandlers.get(child.id)!} + onContextMenu={contextMenuHandlers.get(child.id)!} + onFinishRename={finishRenameHandlers.get(child.id)!} onStartRename={() => startRenamingSession(`worktree-${session.id}-${child.id}`)} - onToggleBookmark={() => toggleBookmark(child.id)} + onToggleBookmark={toggleBookmarkHandlers.get(child.id)!} /> ); })} diff --git a/src/renderer/hooks/git/useGitStatusPolling.ts b/src/renderer/hooks/git/useGitStatusPolling.ts index c68c882a..ea337f91 100644 --- a/src/renderer/hooks/git/useGitStatusPolling.ts +++ b/src/renderer/hooks/git/useGitStatusPolling.ts @@ -90,6 +90,52 @@ export interface UseGitStatusPollingOptions { const DEFAULT_POLL_INTERVAL = 30000; // 30 seconds const DEFAULT_INACTIVITY_TIMEOUT = 60000; // 60 seconds +/** + * PERF: Compare two GitStatusData objects for meaningful changes. + * Ignores lastUpdated since that always changes and would cause unnecessary re-renders. + */ +function gitStatusDataEqual(a: GitStatusData, b: GitStatusData): boolean { + return ( + a.fileCount === b.fileCount && + a.branch === b.branch && + a.remote === b.remote && + a.behind === b.behind && + a.ahead === b.ahead && + a.totalAdditions === b.totalAdditions && + a.totalDeletions === b.totalDeletions && + a.modifiedCount === b.modifiedCount && + // Compare fileChanges arrays (only present for active session) + (a.fileChanges?.length ?? 0) === (b.fileChanges?.length ?? 0) && + (a.fileChanges?.every((f, i) => { + const other = b.fileChanges?.[i]; + return other && + f.path === other.path && + f.status === other.status && + f.additions === other.additions && + f.deletions === other.deletions; + }) ?? true) + ); +} + +/** + * PERF: Compare two git status maps for meaningful changes. + * Returns true if maps are equivalent (same sessions with same data). + */ +function gitStatusMapsEqual( + oldMap: Map, + newMap: Map +): boolean { + if (oldMap.size !== newMap.size) return false; + + for (const [sessionId, newData] of newMap) { + const oldData = oldMap.get(sessionId); + if (!oldData || !gitStatusDataEqual(oldData, newData)) { + return false; + } + } + return true; +} + /** * Hook that polls git status for all git repository sessions. * @@ -263,7 +309,8 @@ export function useGitStatusPolling( } } - setGitStatusMap(newStatusMap); + // PERF: Only update state if data actually changed to prevent cascade re-renders + setGitStatusMap(prev => gitStatusMapsEqual(prev, newStatusMap) ? prev : newStatusMap); } finally { setIsLoading(false); } diff --git a/src/renderer/hooks/session/useBatchedSessionUpdates.ts b/src/renderer/hooks/session/useBatchedSessionUpdates.ts index 21a9a8fb..106e317b 100644 --- a/src/renderer/hooks/session/useBatchedSessionUpdates.ts +++ b/src/renderer/hooks/session/useBatchedSessionUpdates.ts @@ -56,10 +56,8 @@ interface SessionAccumulator { tabStatuses?: Map; // Usage stats (accumulated) usageDeltas?: Map; // key = tabId or null for session-level - // Context percentage (only last one matters, uses high water mark unless reset) + // Context percentage (always uses latest value) contextUsage?: number; - // Force context reset (bypasses high water mark) - forceContextReset?: boolean; // Tabs to mark as delivered deliveredTabs?: Set; // Cycle metrics (accumulated) @@ -354,13 +352,10 @@ export function useBatchedSessionUpdates( } } - // Apply context usage (high water mark - never decrease during a session) - // Context usage should only go down through explicit reset (e.g., after compaction) + // Apply context usage - always use the latest reported value + // Context percentage reflects CURRENT context usage from Claude, not historical max if (acc.contextUsage !== undefined) { - const newContextUsage = acc.forceContextReset - ? acc.contextUsage // Force reset bypasses high water mark - : Math.max(updatedSession.contextUsage || 0, acc.contextUsage); - updatedSession = { ...updatedSession, contextUsage: newContextUsage }; + updatedSession = { ...updatedSession, contextUsage: acc.contextUsage }; } // Apply delivered markers @@ -529,14 +524,13 @@ export function useBatchedSessionUpdates( const updateContextUsage = useCallback((sessionId: string, percentage: number) => { const acc = getAccumulator(sessionId); acc.contextUsage = percentage; - // Don't set forceContextReset - this uses high water mark behavior hasPendingRef.current = true; }, [getAccumulator]); + // resetContextUsage now does the same as updateContextUsage since we removed high water mark const resetContextUsage = useCallback((sessionId: string, percentage: number) => { const acc = getAccumulator(sessionId); acc.contextUsage = percentage; - acc.forceContextReset = true; // Bypass high water mark hasPendingRef.current = true; }, [getAccumulator]); diff --git a/src/renderer/index.html b/src/renderer/index.html index 8f29bd9c..2a1a073a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -208,6 +208,17 @@ })(); + + + + diff --git a/src/renderer/utils/contextUsage.ts b/src/renderer/utils/contextUsage.ts index dedc4931..0918da38 100644 --- a/src/renderer/utils/contextUsage.ts +++ b/src/renderer/utils/contextUsage.ts @@ -31,8 +31,13 @@ const COMBINED_CONTEXT_AGENTS: Set = new Set(['codex']); /** * Calculate total context tokens based on agent-specific semantics. * - * Claude models: Context = input + cacheCreation + cacheRead (output excluded) - * OpenAI models: Context = input + output + cacheRead (combined limit) + * IMPORTANT: Claude Code reports CUMULATIVE session tokens, not per-request tokens. + * The cacheReadInputTokens can exceed the context window because they accumulate + * across all turns in the conversation. For context pressure display, we should + * only count tokens that represent NEW context being added: + * + * Claude models: Context = input + cacheCreation (excludes cacheRead - already cached) + * OpenAI models: Context = input + output (combined limit) * * @param stats - The usage statistics containing token counts * @param agentId - The agent identifier for agent-specific calculation @@ -42,10 +47,13 @@ export function calculateContextTokens( stats: Pick, agentId?: ToolType ): number { + // For Claude: inputTokens = uncached new tokens, cacheCreationInputTokens = newly cached tokens + // cacheReadInputTokens are EXCLUDED because they represent already-cached context + // that Claude Code reports cumulatively across the session, not per-request. + // Including them would cause context % to exceed 100% impossibly. const baseTokens = stats.inputTokens + - (stats.cacheCreationInputTokens || 0) + - (stats.cacheReadInputTokens || 0); + (stats.cacheCreationInputTokens || 0); // OpenAI models have combined input+output context limits if (agentId && COMBINED_CONTEXT_AGENTS.has(agentId)) { @@ -61,14 +69,14 @@ export function calculateContextTokens( * Uses agent-specific default context window sizes for accurate estimation. * * IMPORTANT: Context calculation varies by agent: - * - Claude models: inputTokens + cacheCreationInputTokens + cacheReadInputTokens - * (output tokens are separate from context window) - * - OpenAI models (Codex): inputTokens + outputTokens + cacheReadInputTokens + * - Claude models: inputTokens + cacheCreationInputTokens + * (cacheRead excluded - cumulative, output excluded - separate limit) + * - OpenAI models (Codex): inputTokens + outputTokens * (combined context window includes both input and output) * - * The cacheReadInputTokens are critical because they represent the full - * conversation context being sent, even though they're served from cache - * for billing purposes. + * Note: cacheReadInputTokens are NOT included because Claude Code reports them + * as cumulative session totals, not per-request values. Including them would + * cause context percentage to exceed 100% impossibly. * * @param stats - The usage statistics containing token counts * @param agentId - The agent identifier for agent-specific context window size diff --git a/src/shared/stringUtils.ts b/src/shared/stringUtils.ts index f6c574f9..75cd3d9b 100644 --- a/src/shared/stringUtils.ts +++ b/src/shared/stringUtils.ts @@ -6,14 +6,17 @@ */ /** - * Strip ANSI escape codes from text + * Strip ANSI escape codes and terminal control sequences from text * * Web interfaces don't render terminal colors, so we remove ANSI codes - * for clean display. This handles standard SGR (Select Graphic Rendition) - * escape sequences commonly used for terminal coloring. + * for clean display. This handles: + * - Standard SGR (Select Graphic Rendition) escape sequences for terminal coloring + * - OSC (Operating System Command) sequences with ESC prefix + * - iTerm2/VSCode shell integration sequences (]1337;, ]133;, ]7;) + * Both with and without ESC prefix (SSH shells may emit bare sequences) * - * @param text - The input text potentially containing ANSI escape codes - * @returns The text with all ANSI escape sequences removed + * @param text - The input text potentially containing escape sequences + * @returns The text with all escape sequences removed * * @example * ```typescript @@ -24,12 +27,59 @@ * // Handle complex sequences * const text = stripAnsiCodes('\x1b[1;32mSuccess\x1b[0m'); * // Returns: 'Success' + * + * // Handle iTerm2 shell integration (common in SSH connections) + * const ssh = stripAnsiCodes(']1337;RemoteHost=user@host]1337;CurrentDir=/homeHello'); + * // Returns: 'Hello' * ``` */ export function stripAnsiCodes(text: string): string { // Matches ANSI escape sequences: ESC[ followed by params and command letter // ESC is \x1b (decimal 27), followed by [ and then zero or more params // (digits or semicolons) ending with a letter command - - return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); + let result = text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); + + // Remove OSC sequences WITH ESC prefix: ESC ] ... (BEL or ST) + // Common patterns: window title, hyperlinks, shell integration + result = result.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, ''); + + // IMPORTANT: Process BEL-terminated sequences FIRST before bare sequences + // This prevents partial matches that leave path fragments behind + // Remove bare OSC sequences terminated by BEL (\x07) + result = result.replace(/\]1337;[^\x07]*\x07/g, ''); + result = result.replace(/\]133;[^\x07]*\x07/g, ''); + result = result.replace(/\]7;[^\x07]*\x07/g, ''); + + // Remove iTerm2/VSCode shell integration sequences WITHOUT ESC prefix + // SSH interactive shells emit these when .zshrc/.bashrc loads shell integration + // Format: ]1337;Key=Value or ]133;... or ]7;... + // These appear concatenated: ]1337;RemoteHost=user@host]1337;CurrentDir=/home + // Pattern: Match ]1337;Key=Value where next char is ] or end of visible content + result = result.replace(/\]1337;[^\]\x07\x1b]*(?=\])/g, ''); + result = result.replace(/\]133;[^\]\x07\x1b]*(?=\])/g, ''); + result = result.replace(/\]7;[^\]\x07\x1b]*(?=\])/g, ''); + + // Handle the LAST sequence in a chain (not followed by another ] and no BEL) + // Content typically starts with: / (paths), { (JSON), [ (arrays), or alphanumeric + // The sequence value for ShellIntegrationVersion is: digits, semicolons, "shell=", and shell name + // Example: ]1337;ShellIntegrationVersion=13;shell=zsh/opt/homebrew/bin/codex -> /opt/homebrew/bin/codex + // Example: ]1337;ShellIntegrationVersion=13;shell=zsh{"type":"system"} -> {"type":"system"} + // Match the sequence prefix + key=value where value contains only expected chars + result = result.replace(/\]1337;ShellIntegrationVersion=[\d;a-zA-Z=]*/g, ''); + // For other keys, the value ends when we hit content start chars (/, {, [, or after certain patterns) + result = result.replace(/\]1337;(?:RemoteHost|User|HostName)=[^\/\]\x07\{]*/g, ''); + result = result.replace(/\]1337;CurrentDir=[^\]\x07\{]*(?=[\{\/]|$)/g, ''); + result = result.replace(/\]133;[A-Z](?=[\/\{])/g, ''); + result = result.replace(/\]7;[^\/\]\x07\{]*(?=[\/\{])/g, ''); + + // Handle sequences at TRUE end of string (no content follows at all) + // Only match if the sequence is the entire remaining string + result = result.replace(/^\]1337;[^\]\x07]*$/g, ''); + result = result.replace(/^\]133;[^\]\x07]*$/g, ''); + result = result.replace(/^\]7;[^\]\x07]*$/g, ''); + + // Remove BEL character itself + result = result.replace(/\x07/g, ''); + + return result; } diff --git a/src/web/mobile/MessageHistory.tsx b/src/web/mobile/MessageHistory.tsx index 88d3e7fa..d5ea1820 100644 --- a/src/web/mobile/MessageHistory.tsx +++ b/src/web/mobile/MessageHistory.tsx @@ -9,6 +9,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { ArrowDown } from 'lucide-react'; import { useThemeColors } from '../components/ThemeProvider'; import { stripAnsiCodes } from '../../shared/stringUtils'; +import { MobileMarkdownRenderer } from './MobileMarkdownRenderer'; /** Threshold for character-based truncation */ const CHAR_TRUNCATE_THRESHOLD = 500; @@ -301,19 +302,30 @@ export function MessageHistory({ {/* Message content */}
- {displayText} + {inputMode === 'terminal' || isUser ? ( + // Terminal output and user input: render as plain monospace text +
+ {displayText} +
+ ) : ( + // AI responses: render as formatted markdown + + )} {/* Show truncation indicator at end of text */} {isTruncatable && !isExpanded && ( - + {'\n'}... (tap to expand) )} diff --git a/src/web/mobile/MobileMarkdownRenderer.tsx b/src/web/mobile/MobileMarkdownRenderer.tsx new file mode 100644 index 00000000..f288832b --- /dev/null +++ b/src/web/mobile/MobileMarkdownRenderer.tsx @@ -0,0 +1,397 @@ +/** + * MobileMarkdownRenderer - Markdown rendering for mobile web interface + * + * A simplified version of the desktop MarkdownRenderer optimized for mobile. + * Features: + * - GitHub Flavored Markdown (tables, strikethrough, task lists) + * - Syntax highlighted code blocks with copy buttons + * - External link handling (opens in new tab) + * - Theme-aware styling + * + * Does NOT include: + * - Local image loading via IPC (not available in web context) + * - File tree linking (maestro-file:// protocol) + * - Frontmatter parsing (not needed for AI responses) + */ + +import React, { memo, useCallback, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { useThemeColors, useTheme } from '../components/ThemeProvider'; +import { triggerHaptic, HAPTIC_PATTERNS } from './constants'; + +/** + * Props for MobileMarkdownRenderer + */ +export interface MobileMarkdownRendererProps { + /** The markdown content to render */ + content: string; + /** Optional custom font size (default: 13px) */ + fontSize?: number; +} + +/** + * CodeBlockWithCopy - Code block with copy button for mobile + */ +interface CodeBlockWithCopyProps { + language: string; + codeContent: string; + syntaxStyle: { [key: string]: React.CSSProperties }; + bgColor: string; + borderColor: string; + textDimColor: string; + successColor: string; +} + +const CodeBlockWithCopy = memo(({ + language, + codeContent, + syntaxStyle, + bgColor, + borderColor, + textDimColor, + successColor, +}: CodeBlockWithCopyProps) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(codeContent); + setCopied(true); + triggerHaptic(HAPTIC_PATTERNS.success); + setTimeout(() => setCopied(false), 2000); + } catch { + triggerHaptic(HAPTIC_PATTERNS.error); + } + }, [codeContent]); + + // Normalize language display name + const displayLanguage = language && language !== 'text' ? language : 'code'; + + return ( +
+ {/* Code block header */} +
+ + {displayLanguage} + + +
+ + {codeContent} + +
+ ); +}); + +CodeBlockWithCopy.displayName = 'CodeBlockWithCopy'; + +/** + * MobileMarkdownRenderer component + * + * Renders markdown content with full GFM support for mobile displays. + */ +export const MobileMarkdownRenderer = memo(({ + content, + fontSize = 13, +}: MobileMarkdownRendererProps) => { + const colors = useThemeColors(); + const { isDark } = useTheme(); + const syntaxStyle = isDark ? vscDarkPlus : vs; + + return ( +
+ ( + + {children} + + ), + + // Code blocks with syntax highlighting + code: ({ inline, className, children, ...props }: any) => { + const match = (className || '').match(/language-(\w+)/); + const language = match ? match[1] : 'text'; + const codeContent = String(children).replace(/\n$/, ''); + + if (!inline && match) { + return ( + + ); + } + + // Inline code + return ( + + {children} + + ); + }, + + // Paragraphs + p: ({ children }) => ( +

{children}

+ ), + + // Headings + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + h4: ({ children }) => ( +

{children}

+ ), + h5: ({ children }) => ( +
{children}
+ ), + h6: ({ children }) => ( +
{children}
+ ), + + // Lists + ul: ({ children }) => ( +
    {children}
+ ), + ol: ({ children }) => ( +
    {children}
+ ), + li: ({ children }) => ( +
  • {children}
  • + ), + + // Blockquotes + blockquote: ({ children }) => ( +
    + {children} +
    + ), + + // Horizontal rules + hr: () => ( +
    + ), + + // Tables + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + + {children} + + ), + + // Strong and emphasis + strong: ({ children }) => ( + {children} + ), + em: ({ children }) => ( + {children} + ), + + // Strikethrough (GFM) + del: ({ children }) => ( + {children} + ), + + // Task list items (GFM) - handled by li with checkbox + input: ({ type, checked, ...props }: any) => { + if (type === 'checkbox') { + return ( + + ); + } + return ; + }, + + // Images + img: ({ src, alt }) => ( + {alt + ), + }} + > + {content} +
    +
    + ); +}); + +MobileMarkdownRenderer.displayName = 'MobileMarkdownRenderer'; + +export default MobileMarkdownRenderer; diff --git a/vite.config.mts b/vite.config.mts index d2eb3a8f..583a51e2 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -16,6 +16,8 @@ export default defineConfig(({ mode }) => ({ base: './', define: { __APP_VERSION__: JSON.stringify(appVersion), + // Explicitly define NODE_ENV for React and related packages + 'process.env.NODE_ENV': JSON.stringify(mode), }, resolve: { alias: {