## CHANGES

- 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 📱
This commit is contained in:
Pedram Amini
2026-01-15 23:32:09 -06:00
parent eed244b500
commit 181a7f436b
32 changed files with 1182 additions and 161 deletions

128
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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);
});
});

View File

@@ -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<string, ModelStats> = {
'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);
});

View File

@@ -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<string, ModelStats> = {
'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<string, ModelStats> = {
'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);

View File

@@ -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', () => {

View File

@@ -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(
<FilePreview
{...defaultProps}
file={{ name: 'large.json', content: largeContent, path: '/test/large.json' }}
/>
);
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(
<FilePreview
{...defaultProps}
file={{ name: 'small.json', content: smallContent, path: '/test/small.json' }}
/>
);
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(
<FilePreview
{...defaultProps}
file={{ name: 'large.md', content: largeMarkdown, path: '/test/large.md' }}
/>
);
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(
<FilePreview
{...defaultProps}
file={{ name: 'large.ts', content: largeContent, path: '/test/large.ts' }}
/>
);
// 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(
<FilePreview
{...defaultProps}
file={{ name: 'huge.json', content: hugeContent, path: '/test/huge.json' }}
/>
);
// 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();
});
});
});

View File

@@ -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();
});

View File

@@ -1806,8 +1806,8 @@ describe('MainPanel', () => {
render(<MainPanel {...defaultProps} activeSession={session} getContextColor={getContextColor} />);
// 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(<MainPanel {...defaultProps} activeSession={session} getContextColor={getContextColor} />);
// Context usage should be capped at 100
// Context usage: (150000 + 100000) / 200000 = 125% -> capped at 100%
expect(getContextColor).toHaveBeenCalledWith(100, theme);
});
});

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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);
});

View File

@@ -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);
});
});
});

View File

@@ -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"}');
});
});
});

View File

@@ -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(<MessageHistory logs={logs} inputMode="ai" />);
// 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(<MessageHistory logs={logs} inputMode="ai" />);
// 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(<MessageHistory logs={logs} inputMode="ai" />);
const messageContent = screen.getByText('AI response');
expect(messageContent).toHaveStyle({ fontFamily: 'inherit' });
// The text is wrapped in a <p> 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(<MessageHistory logs={logs} inputMode="ai" />);
const { container } = render(<MessageHistory logs={logs} inputMode="ai" />);
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();
});
});

View File

@@ -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');

View File

@@ -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++;

View File

@@ -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,
};

View File

@@ -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-')) {

View File

@@ -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.

View File

@@ -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, '');

View File

@@ -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

View File

@@ -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<FilePreviewHandle, FilePreviewProps>(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<FilePreviewHandle, FilePreviewProps>(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<FilePreviewHandle, FilePreviewProps>(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<FilePreviewHandle, FilePreviewProps>(funct
</div>
) : (
<div ref={codeContainerRef}>
{/* Large file truncation banner */}
{isContentTruncated && (
<div
className="px-4 py-2 flex items-center gap-2 text-sm"
style={{
backgroundColor: theme.colors.warning + '20',
borderBottom: `1px solid ${theme.colors.warning}40`,
color: theme.colors.warning
}}
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>
Large file preview truncated. Showing first {formatFileSize(LARGE_FILE_PREVIEW_LIMIT)} of {formatFileSize(file.content.length)}.
Use an external editor for the full file.
</span>
</div>
)}
<SyntaxHighlighter
language={language}
style={vscDarkPlus}
@@ -1739,7 +1782,7 @@ export const FilePreview = forwardRef<FilePreviewHandle, FilePreviewProps>(funct
showLineNumbers
PreTag="div"
>
{file.content}
{displayContent}
</SyntaxHighlighter>
</div>
)}

View File

@@ -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<HTMLDivElement>(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<string, () => void>();
sessions.forEach(s => {
map.set(s.id, () => setActiveSessionId(s.id));
});
return map;
}, [sessions, setActiveSessionId]);
const dragStartHandlers = useMemo(() => {
const map = new Map<string, () => void>();
sessions.forEach(s => {
map.set(s.id, () => handleDragStart(s.id));
});
return map;
}, [sessions, handleDragStart]);
const contextMenuHandlers = useMemo(() => {
const map = new Map<string, (e: React.MouseEvent) => 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<string, (newName: string) => 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<string, () => 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)!}
/>
);
})}

View File

@@ -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<string, GitStatusData>,
newMap: Map<string, GitStatusData>
): 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);
}

View File

@@ -56,10 +56,8 @@ interface SessionAccumulator {
tabStatuses?: Map<string, 'idle' | 'busy'>;
// Usage stats (accumulated)
usageDeltas?: Map<string | null, UsageStats>; // 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<string>;
// 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]);

View File

@@ -208,6 +208,17 @@
})();
</script>
<!-- React DevTools: connects to standalone react-devtools app (npm install -g react-devtools) -->
<!-- Only attempts connection in dev mode (Vite serves on localhost:5173) -->
<script>
if (window.location.hostname === 'localhost') {
var script = document.createElement('script');
script.src = 'http://localhost:8097';
script.async = false;
document.head.appendChild(script);
}
</script>
<script type="module" src="/main.tsx"></script>
</body>
</html>

View File

@@ -31,8 +31,13 @@ const COMBINED_CONTEXT_AGENTS: Set<ToolType> = 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<UsageStats, 'inputTokens' | 'outputTokens' | 'cacheReadInputTokens' | 'cacheCreationInputTokens'>,
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

View File

@@ -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;
}

View File

@@ -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 */}
<div
style={{
fontSize: '13px',
lineHeight: 1.5,
color: isError ? colors.error : colors.textMain,
fontFamily: inputMode === 'terminal' || isUser ? 'ui-monospace, monospace' : 'inherit',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
textAlign: 'left',
}}
>
{displayText}
{inputMode === 'terminal' || isUser ? (
// Terminal output and user input: render as plain monospace text
<div
style={{
fontSize: '13px',
lineHeight: 1.5,
fontFamily: 'ui-monospace, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{displayText}
</div>
) : (
// AI responses: render as formatted markdown
<MobileMarkdownRenderer content={displayText} />
)}
{/* Show truncation indicator at end of text */}
{isTruncatable && !isExpanded && (
<span style={{ color: colors.textDim, fontStyle: 'italic' }}>
<span style={{ color: colors.textDim, fontStyle: 'italic', fontSize: '13px' }}>
{'\n'}... (tap to expand)
</span>
)}

View File

@@ -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 (
<div
style={{
borderRadius: '8px',
overflow: 'hidden',
border: `1px solid ${borderColor}`,
margin: '8px 0',
}}
>
{/* Code block header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '4px 8px 4px 12px',
backgroundColor: bgColor,
borderBottom: `1px solid ${borderColor}`,
minHeight: '28px',
}}
>
<span
style={{
fontSize: '11px',
fontWeight: 500,
color: textDimColor,
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
{displayLanguage}
</span>
<button
onClick={handleCopy}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
borderRadius: '4px',
border: 'none',
backgroundColor: copied ? `${successColor}20` : 'transparent',
color: copied ? successColor : textDimColor,
fontSize: '11px',
fontWeight: 500,
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
aria-label={copied ? 'Copied!' : 'Copy code'}
>
{copied ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Copied
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy
</>
)}
</button>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={syntaxStyle}
customStyle={{
margin: 0,
padding: '12px',
fontSize: '12px',
lineHeight: 1.5,
backgroundColor: bgColor,
borderRadius: 0,
}}
wrapLongLines={true}
showLineNumbers={false}
>
{codeContent}
</SyntaxHighlighter>
</div>
);
});
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 (
<div
style={{
fontSize: `${fontSize}px`,
lineHeight: 1.6,
color: colors.textMain,
wordBreak: 'break-word',
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Links open in new tab
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
color: colors.accent,
textDecoration: 'underline',
}}
>
{children}
</a>
),
// 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 (
<CodeBlockWithCopy
language={language}
codeContent={codeContent}
syntaxStyle={syntaxStyle}
bgColor={colors.bgActivity}
borderColor={colors.border}
textDimColor={colors.textDim}
successColor={colors.success}
/>
);
}
// Inline code
return (
<code
{...props}
style={{
backgroundColor: colors.bgActivity,
padding: '2px 6px',
borderRadius: '4px',
fontSize: '0.9em',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
}}
>
{children}
</code>
);
},
// Paragraphs
p: ({ children }) => (
<p style={{ margin: '8px 0' }}>{children}</p>
),
// Headings
h1: ({ children }) => (
<h1 style={{ fontSize: '1.5em', fontWeight: 600, margin: '16px 0 8px', color: colors.textMain }}>{children}</h1>
),
h2: ({ children }) => (
<h2 style={{ fontSize: '1.3em', fontWeight: 600, margin: '14px 0 6px', color: colors.textMain }}>{children}</h2>
),
h3: ({ children }) => (
<h3 style={{ fontSize: '1.15em', fontWeight: 600, margin: '12px 0 4px', color: colors.textMain }}>{children}</h3>
),
h4: ({ children }) => (
<h4 style={{ fontSize: '1.05em', fontWeight: 600, margin: '10px 0 4px', color: colors.textMain }}>{children}</h4>
),
h5: ({ children }) => (
<h5 style={{ fontSize: '1em', fontWeight: 600, margin: '8px 0 4px', color: colors.textMain }}>{children}</h5>
),
h6: ({ children }) => (
<h6 style={{ fontSize: '0.95em', fontWeight: 600, margin: '8px 0 4px', color: colors.textDim }}>{children}</h6>
),
// Lists
ul: ({ children }) => (
<ul style={{ margin: '8px 0', paddingLeft: '24px', listStyleType: 'disc' }}>{children}</ul>
),
ol: ({ children }) => (
<ol style={{ margin: '8px 0', paddingLeft: '24px', listStyleType: 'decimal' }}>{children}</ol>
),
li: ({ children }) => (
<li style={{ margin: '4px 0' }}>{children}</li>
),
// Blockquotes
blockquote: ({ children }) => (
<blockquote
style={{
margin: '8px 0',
paddingLeft: '16px',
borderLeft: `3px solid ${colors.accent}`,
color: colors.textDim,
fontStyle: 'italic',
}}
>
{children}
</blockquote>
),
// Horizontal rules
hr: () => (
<hr
style={{
margin: '16px 0',
border: 'none',
borderTop: `1px solid ${colors.border}`,
}}
/>
),
// Tables
table: ({ children }) => (
<div style={{ overflowX: 'auto', margin: '8px 0' }}>
<table
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: '0.9em',
}}
>
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead style={{ backgroundColor: colors.bgActivity }}>{children}</thead>
),
th: ({ children }) => (
<th
style={{
padding: '8px 12px',
textAlign: 'left',
borderBottom: `2px solid ${colors.border}`,
fontWeight: 600,
}}
>
{children}
</th>
),
td: ({ children }) => (
<td
style={{
padding: '8px 12px',
borderBottom: `1px solid ${colors.border}`,
}}
>
{children}
</td>
),
// Strong and emphasis
strong: ({ children }) => (
<strong style={{ fontWeight: 600 }}>{children}</strong>
),
em: ({ children }) => (
<em style={{ fontStyle: 'italic' }}>{children}</em>
),
// Strikethrough (GFM)
del: ({ children }) => (
<del style={{ textDecoration: 'line-through', color: colors.textDim }}>{children}</del>
),
// Task list items (GFM) - handled by li with checkbox
input: ({ type, checked, ...props }: any) => {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={checked}
disabled
style={{
marginRight: '8px',
accentColor: colors.accent,
}}
{...props}
/>
);
}
return <input type={type} {...props} />;
},
// Images
img: ({ src, alt }) => (
<img
src={src}
alt={alt || ''}
style={{
maxWidth: '100%',
height: 'auto',
borderRadius: '4px',
margin: '8px 0',
}}
/>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
});
MobileMarkdownRenderer.displayName = 'MobileMarkdownRenderer';
export default MobileMarkdownRenderer;

View File

@@ -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: {