mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## CHANGES
- Added user-configurable context window settings for agents 🎯 - Improved error detection to only parse JSON events, not text 🛡️ - Fixed stderr routing for AI processes to correct tabs 🔧 - Added context window support for Codex with model detection 🧮 - Hide context usage widget when window size is zero 👻 - Added number input type for agent configuration options 🔢 - Strip ANSI codes from stderr for cleaner output 🧹 - Added hint for enabling loop mode with documents 💡 - Display correct provider name in input placeholder text ✏️ - Improved network error patterns to reduce false positives 🎯
This commit is contained in:
@@ -143,6 +143,42 @@ interface AgentCapabilities {
|
||||
| `supportsSessionId` | Session ID pill | Pill hidden |
|
||||
| `supportsResultMessages` | Show only final result | Shows all messages |
|
||||
|
||||
### Context Window Configuration
|
||||
|
||||
For agents where context window size varies by model (like OpenCode or Codex), Maestro provides a user-configurable setting:
|
||||
|
||||
**Configuration Location:** Settings → Agent Configuration → Context Window Size
|
||||
|
||||
**How It Works:**
|
||||
1. **Parser-reported value:** If the agent reports `contextWindow` in JSON output, that value takes priority
|
||||
2. **User configuration:** If the parser doesn't report context window, the user-configured value is used
|
||||
3. **Hidden when zero:** If no value is configured (0), the context usage widget is hidden entirely
|
||||
|
||||
**Agent-Specific Behavior:**
|
||||
|
||||
| Agent | Default Context Window | Notes |
|
||||
|-------|----------------------|-------|
|
||||
| Claude Code | 200,000 | Always reported in JSON output |
|
||||
| Codex | 200,000 | Default for GPT-5.x models; user can override in settings |
|
||||
| OpenCode | 128,000 | Default for common models (GPT-4, etc.); user can override in settings |
|
||||
|
||||
**Adding Context Window Config to an Agent:**
|
||||
|
||||
```typescript
|
||||
// In agent-detector.ts, add to configOptions:
|
||||
configOptions: [
|
||||
{
|
||||
key: 'contextWindow',
|
||||
type: 'number',
|
||||
label: 'Context Window Size',
|
||||
description: 'Maximum context window size in tokens. Required for context usage display.',
|
||||
default: 128000, // Set a sane default for the agent's typical model
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
The value is passed to `ProcessManager.spawn()` and used when emitting usage stats if the parser doesn't provide a context window value.
|
||||
|
||||
### Starting Point: All False
|
||||
|
||||
When adding a new agent, start with all capabilities set to `false`:
|
||||
|
||||
@@ -118,11 +118,11 @@ const PROVIDERS: ProviderConfig[] = [
|
||||
prompt,
|
||||
],
|
||||
parseSessionId: (output: string) => {
|
||||
// Codex outputs thread_id in JSON lines (turn.started events)
|
||||
// Codex outputs thread_id in thread.started events
|
||||
for (const line of output.split('\n')) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.type === 'turn.started' && json.thread_id) {
|
||||
if (json.type === 'thread.started' && json.thread_id) {
|
||||
return json.thread_id;
|
||||
}
|
||||
} catch { /* ignore non-JSON lines */ }
|
||||
@@ -130,21 +130,15 @@ const PROVIDERS: ProviderConfig[] = [
|
||||
return null;
|
||||
},
|
||||
parseResponse: (output: string) => {
|
||||
// Codex outputs agent_message events with text
|
||||
// Codex outputs item.completed events with item.type === 'agent_message'
|
||||
const responses: string[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.type === 'agent_message') {
|
||||
// agent_message can have content array or direct text
|
||||
if (json.content && Array.isArray(json.content)) {
|
||||
for (const item of json.content) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
responses.push(item.text);
|
||||
}
|
||||
}
|
||||
} else if (json.text) {
|
||||
responses.push(json.text);
|
||||
if (json.type === 'item.completed' && json.item?.type === 'agent_message') {
|
||||
// agent_message item has text field directly
|
||||
if (json.item.text) {
|
||||
responses.push(json.item.text);
|
||||
}
|
||||
}
|
||||
} catch { /* ignore non-JSON lines */ }
|
||||
@@ -181,25 +175,25 @@ const PROVIDERS: ProviderConfig[] = [
|
||||
prompt,
|
||||
],
|
||||
parseSessionId: (output: string) => {
|
||||
// OpenCode outputs session_id in run.started events
|
||||
// OpenCode outputs sessionID in events (step_start, text, step_finish)
|
||||
for (const line of output.split('\n')) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.type === 'run.started' && json.session_id) {
|
||||
return json.session_id;
|
||||
if (json.sessionID) {
|
||||
return json.sessionID;
|
||||
}
|
||||
} catch { /* ignore non-JSON lines */ }
|
||||
}
|
||||
return null;
|
||||
},
|
||||
parseResponse: (output: string) => {
|
||||
// OpenCode outputs text events
|
||||
// OpenCode outputs text events with part.text
|
||||
const responses: string[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.type === 'text' && json.text) {
|
||||
responses.push(json.text);
|
||||
if (json.type === 'text' && json.part?.text) {
|
||||
responses.push(json.part.text);
|
||||
}
|
||||
} catch { /* ignore non-JSON lines */ }
|
||||
}
|
||||
@@ -239,8 +233,12 @@ function runProvider(
|
||||
cwd,
|
||||
env: { ...process.env },
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Close stdin immediately to signal EOF (prevents processes waiting for input)
|
||||
proc.stdin?.end();
|
||||
|
||||
proc.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
@@ -347,8 +347,9 @@ describe('ClaudeOutputParser', () => {
|
||||
expect(parser.detectErrorFromLine('Hello, how can I help you?')).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect auth errors', () => {
|
||||
const error = parser.detectErrorFromLine('Error: Invalid API key');
|
||||
it('should detect auth errors from JSON', () => {
|
||||
const line = JSON.stringify({ type: 'error', message: 'Invalid API key' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('auth_expired');
|
||||
expect(error?.agentId).toBe('claude-code');
|
||||
@@ -356,31 +357,27 @@ describe('ClaudeOutputParser', () => {
|
||||
expect(error?.timestamp).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should detect token exhaustion errors', () => {
|
||||
const error = parser.detectErrorFromLine('Error: context is too long');
|
||||
it('should detect token exhaustion errors from JSON', () => {
|
||||
const line = JSON.stringify({ error: 'context is too long' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('token_exhaustion');
|
||||
});
|
||||
|
||||
it('should detect rate limit errors', () => {
|
||||
const error = parser.detectErrorFromLine('Rate limit exceeded');
|
||||
it('should detect rate limit errors from JSON', () => {
|
||||
const line = JSON.stringify({ type: 'error', message: 'Rate limit exceeded' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('rate_limited');
|
||||
});
|
||||
|
||||
it('should detect network errors', () => {
|
||||
const error = parser.detectErrorFromLine('Connection failed');
|
||||
it('should detect network errors from JSON', () => {
|
||||
const line = JSON.stringify({ error: 'Connection failed' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should extract error from JSON error messages', () => {
|
||||
const jsonLine = JSON.stringify({ type: 'error', message: 'Invalid API key' });
|
||||
const error = parser.detectErrorFromLine(jsonLine);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('auth_expired');
|
||||
});
|
||||
|
||||
it('should extract error from JSON error field', () => {
|
||||
const jsonLine = JSON.stringify({ error: 'rate limit exceeded' });
|
||||
const error = parser.detectErrorFromLine(jsonLine);
|
||||
@@ -388,8 +385,15 @@ describe('ClaudeOutputParser', () => {
|
||||
expect(error?.type).toBe('rate_limited');
|
||||
});
|
||||
|
||||
it('should NOT detect errors from plain text (only JSON)', () => {
|
||||
// Plain text errors should come through stderr or exit codes, not stdout
|
||||
expect(parser.detectErrorFromLine('Error: Invalid API key')).toBeNull();
|
||||
expect(parser.detectErrorFromLine('Rate limit exceeded')).toBeNull();
|
||||
expect(parser.detectErrorFromLine('Connection failed')).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve the original line in raw data', () => {
|
||||
const line = 'Error: Invalid API key';
|
||||
const line = JSON.stringify({ type: 'error', message: 'Invalid API key' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error?.raw?.errorLine).toBe(line);
|
||||
});
|
||||
|
||||
@@ -419,33 +419,33 @@ describe('CodexOutputParser', () => {
|
||||
expect(parser.detectErrorFromLine(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should detect authentication errors', () => {
|
||||
const error = parser.detectErrorFromLine('invalid api key');
|
||||
it('should detect authentication errors from JSON', () => {
|
||||
const line = JSON.stringify({ type: 'error', error: 'invalid api key' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('auth_expired');
|
||||
expect(error?.agentId).toBe('codex');
|
||||
});
|
||||
|
||||
it('should detect rate limit errors', () => {
|
||||
const error = parser.detectErrorFromLine('rate limit exceeded');
|
||||
it('should detect rate limit errors from JSON', () => {
|
||||
const line = JSON.stringify({ error: 'rate limit exceeded' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('rate_limited');
|
||||
});
|
||||
|
||||
it('should detect token exhaustion errors', () => {
|
||||
const error = parser.detectErrorFromLine('maximum tokens exceeded');
|
||||
it('should detect token exhaustion errors from JSON', () => {
|
||||
const line = JSON.stringify({ type: 'error', error: 'maximum tokens exceeded' });
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('token_exhaustion');
|
||||
});
|
||||
|
||||
it('should detect JSON error messages', () => {
|
||||
const line = JSON.stringify({
|
||||
type: 'error',
|
||||
error: 'rate limit exceeded',
|
||||
});
|
||||
const error = parser.detectErrorFromLine(line);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error?.type).toBe('rate_limited');
|
||||
it('should NOT detect errors from plain text (only JSON)', () => {
|
||||
// Plain text errors should come through stderr or exit codes, not stdout
|
||||
expect(parser.detectErrorFromLine('invalid api key')).toBeNull();
|
||||
expect(parser.detectErrorFromLine('rate limit exceeded')).toBeNull();
|
||||
expect(parser.detectErrorFromLine('maximum tokens exceeded')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-error lines', () => {
|
||||
|
||||
@@ -55,6 +55,87 @@ describe('error-patterns', () => {
|
||||
expect(OPENCODE_ERROR_PATTERNS).toBeDefined();
|
||||
expect(Object.keys(OPENCODE_ERROR_PATTERNS).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
describe('network_error patterns', () => {
|
||||
it('should match "connection failed"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection failed');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "connection refused"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection refused');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "connection error"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection error');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "connection timed out"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection timed out');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "ECONNREFUSED"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'Error: ECONNREFUSED');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "ETIMEDOUT"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'Error: ETIMEDOUT');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "request timed out"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'request timed out');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should match "network error"', () => {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'network error occurred');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.type).toBe('network_error');
|
||||
});
|
||||
|
||||
it('should NOT match normal text containing "connection" as part of a word or phrase', () => {
|
||||
// These are false positive cases that should NOT trigger errors
|
||||
const falsePositives = [
|
||||
'Retry Connection',
|
||||
'I will establish a connection',
|
||||
'the connection is healthy',
|
||||
'check the connection string',
|
||||
'database connection pool',
|
||||
];
|
||||
|
||||
for (const text of falsePositives) {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, text);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should NOT match normal text containing "timeout" as part of a phrase', () => {
|
||||
// These are false positive cases that should NOT trigger errors
|
||||
const falsePositives = [
|
||||
'set timeout to 30',
|
||||
'the timeout value is',
|
||||
'default timeout setting',
|
||||
'with a timeout of 5 seconds',
|
||||
];
|
||||
|
||||
for (const text of falsePositives) {
|
||||
const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, text);
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CODEX_ERROR_PATTERNS', () => {
|
||||
|
||||
@@ -228,7 +228,7 @@ describe('InputArea', () => {
|
||||
});
|
||||
render(<InputArea {...props} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Talking to MySession powered by Claude')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Talking to MySession powered by Claude Code')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows attach image button in AI mode when agent supports image input', () => {
|
||||
|
||||
@@ -1652,8 +1652,8 @@ describe('MainPanel', () => {
|
||||
|
||||
render(<MainPanel {...defaultProps} activeSession={session} />);
|
||||
|
||||
// Should render without crashing
|
||||
expect(screen.getByText('Context Window')).toBeInTheDocument();
|
||||
// Should render without crashing - Context Window widget is hidden when contextWindow is not configured
|
||||
expect(screen.queryByText('Context Window')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle missing git status from context gracefully', async () => {
|
||||
@@ -1717,7 +1717,7 @@ describe('MainPanel', () => {
|
||||
});
|
||||
|
||||
describe('Context usage calculation edge cases', () => {
|
||||
it('should handle zero context window', () => {
|
||||
it('should hide context widget when context window is zero', () => {
|
||||
const session = createSession({
|
||||
aiTabs: [{
|
||||
id: 'tab-1',
|
||||
@@ -1739,8 +1739,8 @@ describe('MainPanel', () => {
|
||||
|
||||
render(<MainPanel {...defaultProps} activeSession={session} />);
|
||||
|
||||
// Should render without crashing
|
||||
expect(screen.getByText('Context Window')).toBeInTheDocument();
|
||||
// Context Window widget should be hidden when contextWindow is 0 (not configured)
|
||||
expect(screen.queryByText('Context Window')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should cap context usage at 100%', () => {
|
||||
|
||||
@@ -473,7 +473,7 @@ describe('TabSwitcherModal', () => {
|
||||
});
|
||||
|
||||
describe('getContextPercentage', () => {
|
||||
it('returns 0 when no usageStats', () => {
|
||||
it('hides context gauge when no usageStats', () => {
|
||||
const tab = createTestTab({ usageStats: undefined });
|
||||
|
||||
renderWithLayerStack(
|
||||
@@ -488,11 +488,11 @@ describe('TabSwitcherModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show 0%
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
// Context gauge should not be rendered when contextWindow is not configured
|
||||
expect(screen.queryByText(/^\d+%$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns 0 when contextWindow is 0', () => {
|
||||
it('hides context gauge when contextWindow is 0', () => {
|
||||
const tab = createTestTab({
|
||||
usageStats: {
|
||||
inputTokens: 1000,
|
||||
@@ -516,7 +516,8 @@ describe('TabSwitcherModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
// Context gauge should not be rendered when contextWindow is 0
|
||||
expect(screen.queryByText(/^\d+%$/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates correct percentage', () => {
|
||||
|
||||
@@ -82,6 +82,16 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'
|
||||
readOnlyArgs: ['--sandbox', 'read-only'], // Read-only/plan mode
|
||||
yoloModeArgs: ['--dangerously-bypass-approvals-and-sandbox'], // Full access mode
|
||||
workingDirArgs: (dir: string) => ['-C', dir], // Set working directory
|
||||
// Agent-specific configuration options shown in UI
|
||||
configOptions: [
|
||||
{
|
||||
key: 'contextWindow',
|
||||
type: 'number',
|
||||
label: 'Context Window Size',
|
||||
description: 'Maximum context window size in tokens. Required for context usage display. Common values: 128000 (o4-mini), 200000 (o3).',
|
||||
default: 200000, // Default for GPT-5.x models
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gemini-cli',
|
||||
@@ -126,6 +136,13 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'
|
||||
return [];
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'contextWindow',
|
||||
type: 'number',
|
||||
label: 'Context Window Size',
|
||||
description: 'Maximum context window size in tokens. Required for context usage display. Varies by model (e.g., 200000 for Claude, 128000 for GPT-4).',
|
||||
default: 128000, // Default for common models (GPT-4, etc.)
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -225,12 +225,20 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
|
||||
...(config.prompt && { prompt: config.prompt.length > 500 ? config.prompt.substring(0, 500) + '...' : config.prompt })
|
||||
});
|
||||
|
||||
// Get contextWindow from agent config (for agents like OpenCode/Codex that need user configuration)
|
||||
// Falls back to the agent's configOptions default (e.g., 200000 for Codex, 128000 for OpenCode)
|
||||
const agentConfig = agentConfigsStore.get('configs', {})[config.toolType] || {};
|
||||
const contextWindowOption = agent?.configOptions?.find(opt => opt.key === 'contextWindow');
|
||||
const contextWindowDefault = contextWindowOption?.default ?? 0;
|
||||
const contextWindow = typeof agentConfig.contextWindow === 'number' ? agentConfig.contextWindow : contextWindowDefault;
|
||||
|
||||
const result = processManager.spawn({
|
||||
...config,
|
||||
args: finalArgs,
|
||||
requiresPty: agent?.requiresPty,
|
||||
prompt: config.prompt,
|
||||
shell: shellToUse
|
||||
shell: shellToUse,
|
||||
contextWindow, // Pass configured context window to process manager
|
||||
});
|
||||
|
||||
logger.info(`Process spawned successfully`, LOG_CONTEXT, {
|
||||
|
||||
@@ -220,6 +220,15 @@ export class ClaudeOutputParser implements AgentOutputParser {
|
||||
|
||||
/**
|
||||
* Detect an error from a line of agent output
|
||||
*
|
||||
* IMPORTANT: Only detect errors from structured JSON error events, not from
|
||||
* arbitrary text content. Pattern matching on conversational text leads to
|
||||
* false positives (e.g., AI discussing "timeout" triggers timeout error).
|
||||
*
|
||||
* Error detection sources (in order of reliability):
|
||||
* 1. Structured JSON: { type: "error", message: "..." } or { error: "..." }
|
||||
* 2. stderr output (handled separately by process-manager)
|
||||
* 3. Non-zero exit code (handled by detectErrorFromExit)
|
||||
*/
|
||||
detectErrorFromLine(line: string): AgentError | null {
|
||||
// Skip empty lines
|
||||
@@ -227,23 +236,32 @@ export class ClaudeOutputParser implements AgentOutputParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse as JSON first to check for error messages in structured output
|
||||
let textToCheck = line;
|
||||
// Only detect errors from structured JSON error events
|
||||
// Do NOT pattern match on arbitrary text - it causes false positives
|
||||
let errorText: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// Check for error type messages
|
||||
if (parsed.type === 'error' && parsed.message) {
|
||||
textToCheck = parsed.message;
|
||||
errorText = parsed.message;
|
||||
} else if (parsed.error) {
|
||||
textToCheck = typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error);
|
||||
errorText = typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error);
|
||||
}
|
||||
// If no error field in JSON, this is normal output - don't check it
|
||||
} catch {
|
||||
// Not JSON, check the raw line
|
||||
// Not JSON - skip pattern matching entirely
|
||||
// Errors should come through structured JSON, stderr, or exit codes
|
||||
// Pattern matching on arbitrary text causes false positives
|
||||
}
|
||||
|
||||
// If no error text was extracted, no error to detect
|
||||
if (!errorText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match against error patterns
|
||||
const patterns = getErrorPatterns(this.agentId);
|
||||
const match = matchErrorPattern(patterns, textToCheck);
|
||||
const match = matchErrorPattern(patterns, errorText);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
|
||||
@@ -24,6 +24,95 @@
|
||||
import type { ToolType, AgentError } from '../../shared/types';
|
||||
import type { AgentOutputParser, ParsedEvent } from './agent-output-parser';
|
||||
import { getErrorPatterns, matchErrorPattern } from './error-patterns';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
/**
|
||||
* Known OpenAI model context window sizes (in tokens)
|
||||
* Source: https://platform.openai.com/docs/models
|
||||
*/
|
||||
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
||||
// GPT-4o family
|
||||
'gpt-4o': 128000,
|
||||
'gpt-4o-mini': 128000,
|
||||
'gpt-4o-2024-05-13': 128000,
|
||||
'gpt-4o-2024-08-06': 128000,
|
||||
'gpt-4o-2024-11-20': 128000,
|
||||
// o1/o3/o4 reasoning models
|
||||
'o1': 200000,
|
||||
'o1-mini': 128000,
|
||||
'o1-preview': 128000,
|
||||
'o3': 200000,
|
||||
'o3-mini': 200000,
|
||||
'o4-mini': 200000,
|
||||
// GPT-4 Turbo
|
||||
'gpt-4-turbo': 128000,
|
||||
'gpt-4-turbo-preview': 128000,
|
||||
'gpt-4-1106-preview': 128000,
|
||||
// GPT-4 (original)
|
||||
'gpt-4': 8192,
|
||||
'gpt-4-32k': 32768,
|
||||
// GPT-5 family (Codex default)
|
||||
'gpt-5': 200000,
|
||||
'gpt-5.1': 200000,
|
||||
'gpt-5.1-codex-max': 200000,
|
||||
// Default fallback
|
||||
'default': 128000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the context window size for a given model
|
||||
*/
|
||||
function getModelContextWindow(model: string): number {
|
||||
// Try exact match first
|
||||
if (MODEL_CONTEXT_WINDOWS[model]) {
|
||||
return MODEL_CONTEXT_WINDOWS[model];
|
||||
}
|
||||
// Try prefix match (e.g., "gpt-4o-2024-11-20" matches "gpt-4o")
|
||||
for (const [prefix, size] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
|
||||
if (model.startsWith(prefix)) {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
return MODEL_CONTEXT_WINDOWS['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Codex configuration from ~/.codex/config.toml
|
||||
* Returns the model name and context window override if set
|
||||
*/
|
||||
function readCodexConfig(): { model?: string; contextWindow?: number } {
|
||||
try {
|
||||
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const result: { model?: string; contextWindow?: number } = {};
|
||||
|
||||
// Simple TOML parsing for the fields we care about
|
||||
// model = "gpt-5.1"
|
||||
const modelMatch = content.match(/^\s*model\s*=\s*"([^"]+)"/m);
|
||||
if (modelMatch) {
|
||||
result.model = modelMatch[1];
|
||||
}
|
||||
|
||||
// model_context_window = 128000
|
||||
const windowMatch = content.match(/^\s*model_context_window\s*=\s*(\d+)/m);
|
||||
if (windowMatch) {
|
||||
result.contextWindow = parseInt(windowMatch[1], 10);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
// Config file doesn't exist or can't be read - use defaults
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw message structure from Codex JSON output
|
||||
@@ -73,6 +162,19 @@ interface CodexUsage {
|
||||
export class CodexOutputParser implements AgentOutputParser {
|
||||
readonly agentId: ToolType = 'codex';
|
||||
|
||||
// Cached context window - read once from config
|
||||
private contextWindow: number;
|
||||
private model: string;
|
||||
|
||||
constructor() {
|
||||
// Read config once at initialization
|
||||
const config = readCodexConfig();
|
||||
this.model = config.model || 'gpt-5.1-codex-max';
|
||||
|
||||
// Priority: 1) explicit model_context_window in config, 2) lookup by model name
|
||||
this.contextWindow = config.contextWindow || getModelContextWindow(this.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single JSON line from Codex output
|
||||
*
|
||||
@@ -270,10 +372,16 @@ export class CodexOutputParser implements AgentOutputParser {
|
||||
return {
|
||||
inputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
// Note: For OpenAI/Codex, cached_input_tokens is a SUBSET of input_tokens (already included)
|
||||
// Unlike Claude where cache tokens are separate and need to be added to get total context.
|
||||
// We still report cacheReadTokens for display purposes (shows cache efficiency).
|
||||
// Context calculations should use inputTokens + outputTokens, not add cache tokens again.
|
||||
cacheReadTokens: cachedInputTokens,
|
||||
// Note: Codex doesn't report cache creation tokens
|
||||
cacheCreationTokens: 0,
|
||||
// Note: costUsd omitted - Codex doesn't provide cost and pricing varies by model
|
||||
// Context window from Codex config (~/.codex/config.toml) or model lookup table
|
||||
contextWindow: this.contextWindow,
|
||||
// Store reasoning tokens separately for UI display
|
||||
reasoningTokens: reasoningOutputTokens,
|
||||
};
|
||||
@@ -314,6 +422,15 @@ export class CodexOutputParser implements AgentOutputParser {
|
||||
|
||||
/**
|
||||
* Detect an error from a line of agent output
|
||||
*
|
||||
* IMPORTANT: Only detect errors from structured JSON error events, not from
|
||||
* arbitrary text content. Pattern matching on conversational text leads to
|
||||
* false positives (e.g., AI discussing "timeout" triggers timeout error).
|
||||
*
|
||||
* Error detection sources (in order of reliability):
|
||||
* 1. Structured JSON: { type: "error", error: "..." } or { error: "..." }
|
||||
* 2. stderr output (handled separately by process-manager)
|
||||
* 3. Non-zero exit code (handled by detectErrorFromExit)
|
||||
*/
|
||||
detectErrorFromLine(line: string): AgentError | null {
|
||||
// Skip empty lines
|
||||
@@ -321,26 +438,35 @@ export class CodexOutputParser implements AgentOutputParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse as JSON first to check for error messages in structured output
|
||||
let textToCheck = line;
|
||||
// Only detect errors from structured JSON error events
|
||||
// Do NOT pattern match on arbitrary text - it causes false positives
|
||||
let errorText: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// Check for error type messages
|
||||
if (parsed.type === 'error' && parsed.error) {
|
||||
textToCheck = parsed.error;
|
||||
errorText = parsed.error;
|
||||
} else if (parsed.error) {
|
||||
textToCheck =
|
||||
errorText =
|
||||
typeof parsed.error === 'string'
|
||||
? parsed.error
|
||||
: JSON.stringify(parsed.error);
|
||||
}
|
||||
// If no error field in JSON, this is normal output - don't check it
|
||||
} catch {
|
||||
// Not JSON, check the raw line
|
||||
// Not JSON - skip pattern matching entirely
|
||||
// Errors should come through structured JSON, stderr, or exit codes
|
||||
// Pattern matching on arbitrary text causes false positives
|
||||
}
|
||||
|
||||
// If no error text was extracted, no error to detect
|
||||
if (!errorText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match against error patterns
|
||||
const patterns = getErrorPatterns(this.agentId);
|
||||
const match = matchErrorPattern(patterns, textToCheck);
|
||||
const match = matchErrorPattern(patterns, errorText);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
|
||||
@@ -257,15 +257,26 @@ export const OPENCODE_ERROR_PATTERNS: AgentErrorPatterns = {
|
||||
|
||||
network_error: [
|
||||
{
|
||||
pattern: /connection/i,
|
||||
// More specific patterns to avoid false positives from normal output
|
||||
pattern: /connection\s*(failed|refused|error|reset|closed|timed?\s*out)/i,
|
||||
message: 'Connection error. Check your network.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
pattern: /timeout/i,
|
||||
pattern: /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND/i,
|
||||
message: 'Network error. Check your connection.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
pattern: /request\s+timed?\s*out|timed?\s*out\s+waiting/i,
|
||||
message: 'Request timed out.',
|
||||
recoverable: true,
|
||||
},
|
||||
{
|
||||
pattern: /network\s+(error|failure|unavailable)/i,
|
||||
message: 'Network error occurred. Please check your connection.',
|
||||
recoverable: true,
|
||||
},
|
||||
],
|
||||
|
||||
agent_crashed: [
|
||||
|
||||
@@ -254,6 +254,15 @@ export class OpenCodeOutputParser implements AgentOutputParser {
|
||||
|
||||
/**
|
||||
* Detect an error from a line of agent output
|
||||
*
|
||||
* IMPORTANT: Only detect errors from structured JSON error events, not from
|
||||
* arbitrary text content. Pattern matching on conversational text leads to
|
||||
* false positives (e.g., AI discussing "timeout" triggers timeout error).
|
||||
*
|
||||
* Error detection sources (in order of reliability):
|
||||
* 1. Structured JSON: { error: "message" } or { type: "error", message: "..." }
|
||||
* 2. stderr output (handled separately by process-manager)
|
||||
* 3. Non-zero exit code (handled by detectErrorFromExit)
|
||||
*/
|
||||
detectErrorFromLine(line: string): AgentError | null {
|
||||
// Skip empty lines
|
||||
@@ -261,23 +270,32 @@ export class OpenCodeOutputParser implements AgentOutputParser {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse as JSON first to check for error messages in structured output
|
||||
let textToCheck = line;
|
||||
// Only detect errors from structured JSON error events
|
||||
// Do NOT pattern match on arbitrary text - it causes false positives
|
||||
let errorText: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
// OpenCode uses an 'error' field for errors
|
||||
if (parsed.error) {
|
||||
textToCheck = parsed.error;
|
||||
errorText = parsed.error;
|
||||
} else if (parsed.type === 'error' && parsed.message) {
|
||||
textToCheck = parsed.message;
|
||||
errorText = parsed.message;
|
||||
}
|
||||
// If no error field in JSON, this is normal output - don't check it
|
||||
} catch {
|
||||
// Not JSON, check the raw line
|
||||
// Not JSON - skip pattern matching entirely
|
||||
// Errors should come through structured JSON, stderr, or exit codes
|
||||
// Pattern matching on arbitrary text causes false positives
|
||||
}
|
||||
|
||||
// If no error text was extracted, no error to detect
|
||||
if (!errorText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match against error patterns
|
||||
const patterns = getErrorPatterns(this.agentId);
|
||||
const match = matchErrorPattern(patterns, textToCheck);
|
||||
const match = matchErrorPattern(patterns, errorText);
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as pty from 'node-pty';
|
||||
import { stripControlSequences } from './utils/terminalFilter';
|
||||
import { stripControlSequences, stripAllAnsiCodes } from './utils/terminalFilter';
|
||||
import { logger } from './utils/logger';
|
||||
import { getOutputParser, type ParsedEvent, type AgentOutputParser } from './parsers';
|
||||
import { aggregateModelUsage } from './parsers/usage-aggregator';
|
||||
@@ -48,6 +48,7 @@ interface ProcessConfig {
|
||||
prompt?: string; // For batch mode agents like Claude (passed as CLI argument)
|
||||
shell?: string; // Shell to use for terminal sessions (e.g., 'zsh', 'bash', 'fish')
|
||||
images?: string[]; // Base64 data URLs for images (passed via stream-json input)
|
||||
contextWindow?: number; // Configured context window size (0 or undefined = not configured, hide UI)
|
||||
}
|
||||
|
||||
interface ManagedProcess {
|
||||
@@ -70,6 +71,7 @@ interface ManagedProcess {
|
||||
stderrBuffer?: string; // Buffer for accumulating stderr output (for error detection)
|
||||
stdoutBuffer?: string; // Buffer for accumulating stdout output (for error detection at exit)
|
||||
streamedText?: string; // Buffer for accumulating streamed text from partial events (OpenCode, Codex)
|
||||
contextWindow?: number; // Configured context window size (0 or undefined = not configured)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +141,7 @@ export class ProcessManager extends EventEmitter {
|
||||
* Spawn a new process for a session
|
||||
*/
|
||||
spawn(config: ProcessConfig): { pid: number; success: boolean } {
|
||||
const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, images } = config;
|
||||
const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, images, contextWindow } = config;
|
||||
|
||||
// For batch mode with images, use stream-json mode and send message via stdin
|
||||
// For batch mode without images, append prompt to args with -- separator
|
||||
@@ -348,6 +350,7 @@ export class ProcessManager extends EventEmitter {
|
||||
outputParser,
|
||||
stderrBuffer: '', // Initialize stderr buffer for error detection at exit
|
||||
stdoutBuffer: '', // Initialize stdout buffer for error detection at exit
|
||||
contextWindow, // User-configured context window size (0 = not configured)
|
||||
};
|
||||
|
||||
this.processes.set(sessionId, managedProcess);
|
||||
@@ -422,13 +425,15 @@ export class ProcessManager extends EventEmitter {
|
||||
const usage = outputParser.extractUsage(event);
|
||||
if (usage) {
|
||||
// Map parser's usage format to UsageStats
|
||||
// For contextWindow: prefer parser-reported value, then configured value, then 0 (means not available)
|
||||
// A value of 0 signals the UI to hide context usage display
|
||||
const usageStats = {
|
||||
inputTokens: usage.inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
cacheReadInputTokens: usage.cacheReadTokens || 0,
|
||||
cacheCreationInputTokens: usage.cacheCreationTokens || 0,
|
||||
totalCostUsd: usage.costUsd || 0,
|
||||
contextWindow: usage.contextWindow || 200000,
|
||||
contextWindow: usage.contextWindow || managedProcess.contextWindow || 0,
|
||||
reasoningTokens: usage.reasoningTokens,
|
||||
};
|
||||
this.emit('usage', sessionId, usageStats);
|
||||
@@ -541,7 +546,12 @@ export class ProcessManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('data', sessionId, `[stderr] ${stderrData}`);
|
||||
// Strip ANSI codes and only emit if there's actual content
|
||||
const cleanedStderr = stripAllAnsiCodes(stderrData).trim();
|
||||
if (cleanedStderr) {
|
||||
// Emit to separate 'stderr' event for AI processes (consistent with runCommand)
|
||||
this.emit('stderr', sessionId, cleanedStderr);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,25 @@ export function stripControlSequences(text: string, lastCommand?: string, isTerm
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ALL ANSI escape codes from text (including color codes).
|
||||
* This is more aggressive than stripControlSequences and removes everything.
|
||||
* Use this for stderr from AI agents where we don't want any formatting.
|
||||
*/
|
||||
export function stripAllAnsiCodes(text: string): string {
|
||||
// Remove all ANSI escape sequences including color codes
|
||||
// Format: ESC [ ... m (SGR sequences for colors/styles)
|
||||
// Format: ESC [ ... other letters (cursor, scrolling, etc.)
|
||||
// Format: ESC ] ... BEL/ST (OSC sequences)
|
||||
return text
|
||||
.replace(/\x1b\[[0-9;]*m/g, '') // SGR color/style codes
|
||||
.replace(/\x1b\[[\d;]*[A-Za-z]/g, '') // Other CSI sequences
|
||||
.replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC sequences
|
||||
.replace(/\x1b[()][AB012]/g, '') // Character set selection
|
||||
.replace(/\x07/g, '') // BEL character
|
||||
.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1A\x1C-\x1F]/g, ''); // Other control chars
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a line is likely a command echo in terminal output
|
||||
* This helps identify when the terminal is echoing back the command the user typed
|
||||
|
||||
@@ -1318,16 +1318,38 @@ export default function MaestroConsole() {
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle stderr from runCommand (BATCHED - separate from stdout)
|
||||
// Handle stderr from processes (BATCHED - separate from stdout)
|
||||
// Supports both AI processes (sessionId format: {id}-ai-{tabId}) and terminal commands (plain sessionId)
|
||||
const unsubscribeStderr = window.maestro.process.onStderr((sessionId: string, data: string) => {
|
||||
// runCommand uses plain session ID (no suffix)
|
||||
const actualSessionId = sessionId;
|
||||
|
||||
// Filter out empty stderr (only whitespace)
|
||||
if (!data.trim()) return;
|
||||
|
||||
// Use batched append for stderr (isStderr = true)
|
||||
batchedUpdater.appendLog(actualSessionId, null, false, data, true);
|
||||
// Parse sessionId to determine which process this is from
|
||||
// Same logic as onData handler
|
||||
let actualSessionId: string;
|
||||
let tabIdFromSession: string | undefined;
|
||||
let isFromAi = false;
|
||||
|
||||
const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/);
|
||||
if (aiTabMatch) {
|
||||
actualSessionId = aiTabMatch[1];
|
||||
tabIdFromSession = aiTabMatch[2];
|
||||
isFromAi = true;
|
||||
} else if (sessionId.includes('-batch-')) {
|
||||
// Ignore batch task stderr
|
||||
return;
|
||||
} else {
|
||||
// Plain session ID = runCommand (terminal commands)
|
||||
actualSessionId = sessionId;
|
||||
}
|
||||
|
||||
if (isFromAi && tabIdFromSession) {
|
||||
// AI process stderr - route to the correct tab as a system log entry
|
||||
batchedUpdater.appendLog(actualSessionId, tabIdFromSession, true, `[stderr] ${data}`, false);
|
||||
} else {
|
||||
// Terminal command stderr - route to shell logs
|
||||
batchedUpdater.appendLog(actualSessionId, null, false, data, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle command exit from runCommand
|
||||
|
||||
@@ -303,6 +303,42 @@ export function AgentSelectionPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{option.type === 'number' && (
|
||||
<div className="p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
|
||||
<div className="mb-2">
|
||||
<div className="font-medium" style={{ color: theme.colors.textMain }}>
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="text-xs opacity-50 mt-0.5" style={{ color: theme.colors.textDim }}>
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={agentConfigs[selectedAgent.id]?.[option.key] ?? option.default}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value === '' ? 0 : parseInt(e.target.value, 10);
|
||||
const newConfig = {
|
||||
...agentConfigs[selectedAgent.id],
|
||||
[option.key]: isNaN(value) ? 0 : value
|
||||
};
|
||||
setAgentConfigs(prev => ({
|
||||
...prev,
|
||||
[selectedAgent.id]: newConfig
|
||||
}));
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Only persist on blur to avoid excessive writes
|
||||
const currentConfig = agentConfigs[selectedAgent.id] || {};
|
||||
window.maestro.agents.setConfig(selectedAgent.id, currentConfig);
|
||||
}}
|
||||
placeholder={option.default?.toString() || '0'}
|
||||
min={0}
|
||||
className="w-full p-2 rounded border bg-transparent outline-none text-sm font-mono"
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -571,6 +571,13 @@ export function DocumentsPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint for enabling loop mode */}
|
||||
{documents.length === 1 && (
|
||||
<p className="mt-1.5 text-xs text-center" style={{ color: theme.colors.textDim }}>
|
||||
You can enable loops with two or more documents
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Missing Documents Warning */}
|
||||
{hasMissingDocs && (
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { TabCompletionSuggestion, TabCompletionFilter } from '../hooks/useT
|
||||
import { ThinkingStatusPill } from './ThinkingStatusPill';
|
||||
import { ExecutionQueueIndicator } from './ExecutionQueueIndicator';
|
||||
import { useAgentCapabilities } from '../hooks/useAgentCapabilities';
|
||||
import { getProviderDisplayName } from '../utils/sessionValidation';
|
||||
|
||||
interface SlashCommand {
|
||||
command: string;
|
||||
@@ -545,7 +546,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
|
||||
ref={inputRef}
|
||||
className={`flex-1 bg-transparent text-sm outline-none ${isTerminalMode ? 'pl-1.5' : 'pl-3'} pt-3 pr-3 resize-none min-h-[2.5rem] scrollbar-thin`}
|
||||
style={{ color: theme.colors.textMain, maxHeight: '7rem' }}
|
||||
placeholder={isTerminalMode ? "Run shell command..." : `Talking to ${session.name} powered by Claude`}
|
||||
placeholder={isTerminalMode ? "Run shell command..." : `Talking to ${session.name} powered by ${getProviderDisplayName(session.toolType)}`}
|
||||
value={inputValue}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
|
||||
@@ -640,8 +640,8 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Context Window Widget with Tooltip - only show for tabs with agent session and if agent supports usage stats */}
|
||||
{activeSession.inputMode === 'ai' && activeTab?.agentSessionId && hasCapability('supportsUsageStats') && (
|
||||
{/* Context Window Widget with Tooltip - only show when context window is configured and agent supports usage stats */}
|
||||
{activeSession.inputMode === 'ai' && activeTab?.agentSessionId && hasCapability('supportsUsageStats') && (activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
|
||||
<div
|
||||
className="flex flex-col items-end mr-2 relative cursor-pointer"
|
||||
onMouseEnter={() => {
|
||||
@@ -744,32 +744,35 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-2 mt-2" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Tokens</span>
|
||||
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.accent }}>
|
||||
{(
|
||||
(activeTab?.usageStats?.inputTokens ?? 0) +
|
||||
(activeTab?.usageStats?.outputTokens ?? 0)
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
{/* Context usage section - only shown when contextWindow is configured */}
|
||||
{(activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
|
||||
<div className="border-t pt-2 mt-2" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Tokens</span>
|
||||
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.accent }}>
|
||||
{(
|
||||
(activeTab?.usageStats?.inputTokens ?? 0) +
|
||||
(activeTab?.usageStats?.outputTokens ?? 0)
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Size</span>
|
||||
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.textMain }}>
|
||||
{activeTab.usageStats.contextWindow.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Usage</span>
|
||||
<span
|
||||
className="text-xs font-mono font-bold"
|
||||
style={{ color: getContextColor(activeTabContextUsage, theme) }}
|
||||
>
|
||||
{activeTabContextUsage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Size</span>
|
||||
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.textMain }}>
|
||||
{(activeTab?.usageStats?.contextWindow ?? 200000).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Usage</span>
|
||||
<span
|
||||
className="text-xs font-mono font-bold"
|
||||
style={{ color: getContextColor(activeTabContextUsage, theme) }}
|
||||
>
|
||||
{activeTabContextUsage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -578,10 +578,12 @@ export function TabSwitcherModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context Gauge */}
|
||||
<div className="flex-shrink-0">
|
||||
<ContextGauge percentage={contextPct} theme={theme} />
|
||||
</div>
|
||||
{/* Context Gauge - only show when context window is configured */}
|
||||
{(tab.usageStats?.contextWindow ?? 0) > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<ContextGauge percentage={contextPct} theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -96,12 +96,13 @@ function normalizeDirectory(dir: string): string {
|
||||
/**
|
||||
* Get a human-readable display name for a provider/tool type.
|
||||
*/
|
||||
function getProviderDisplayName(toolType: ToolType): string {
|
||||
export function getProviderDisplayName(toolType: ToolType): string {
|
||||
const displayNames: Record<ToolType, string> = {
|
||||
'claude-code': 'Claude Code',
|
||||
'claude': 'Claude',
|
||||
'aider': 'Aider',
|
||||
'opencode': 'OpenCode',
|
||||
'codex': 'Codex',
|
||||
'terminal': 'Terminal'
|
||||
};
|
||||
return displayNames[toolType] || toolType;
|
||||
|
||||
Reference in New Issue
Block a user