mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
128
package-lock.json
generated
128
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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-')) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)!}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
397
src/web/mobile/MobileMarkdownRenderer.tsx
Normal file
397
src/web/mobile/MobileMarkdownRenderer.tsx
Normal 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;
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user