diff --git a/AUTO_COMPACT_FEATURE_PROPOSAL.md b/AUTO_COMPACT_FEATURE_PROPOSAL.md new file mode 100644 index 00000000..ec301103 --- /dev/null +++ b/AUTO_COMPACT_FEATURE_PROPOSAL.md @@ -0,0 +1,582 @@ +# Auto-Compact Feature Implementation Proposal for Claude Code UI + +## Executive Summary + +**Feasibility**: ✅ **HIGHLY FEASIBLE** - Implementation is straightforward with existing architecture + +The Claude CLI auto-compact feature from infra-assist can be successfully integrated into claudecodeui. The UI already tracks token usage through stream-json output, making auto-compact monitoring and triggering a natural extension. + +**Estimated Effort**: 2-3 days (16-24 hours development + testing) +**Complexity**: Medium +**Risk Level**: Low + +## Current State Analysis + +### claudecodeui Architecture + +**Frontend (React + Vite)**: +- `src/components/ClaudeStatus.jsx`: Displays token count during Claude execution +- `src/components/ChatInterface.jsx`: Handles WebSocket messaging and status updates +- Token tracking: Parses `tokens` and `token_count` from stream-json status messages +- Current display: Shows real-time token usage during processing (e.g., "⚒ 1,234 tokens") + +**Backend (Node.js + Express)**: +- `server/claude-cli.js`: Spawns Claude CLI with `--output-format stream-json` +- `server/index.js`: WebSocket server for real-time communication +- Streams Claude output events: `claude-status`, `session-created`, `claude-complete`, `claude-error` +- No current token budget monitoring or auto-compact logic + +**Key Integration Points Identified**: +1. ✅ Token data already available via stream-json format +2. ✅ WebSocket infrastructure supports real-time notifications +3. ✅ UI component structure supports status banners and alerts +4. ✅ Backend can spawn additional Claude commands (e.g., `/context save`) + +### infra-assist Auto-Compact Feature + +**Trigger Conditions**: +- Automatic trigger when remaining tokens < 30,000 (out of 200,000 total) +- Before specialist handoffs +- Before complex operations +- At end of work sessions + +**Workflow**: +1. Claude monitors system warnings: `Token usage: X/200000; Y remaining` +2. When threshold hit: Auto-executes `/context save` command +3. Notifies user: "⚡ Auto-compressed context (tokens: Y remaining) → Saved Z tokens → Continuing workflow" +4. Continues seamlessly without interruption + +**Recovery**: +- User runs `/remind me about [project]` in new session to restore context + +## Implementation Design + +### Phase 1: Token Budget Monitoring (Backend) + +**File**: `server/claude-cli.js` + +**Add Token Parsing Logic**: +```javascript +// Constants +const TOKEN_BUDGET_TOTAL = 200000; +const TOKEN_WARNING_THRESHOLD = 30000; +const TOKEN_CRITICAL_THRESHOLD = 30000; // Auto-compact trigger + +// Track token usage per session +const sessionTokenUsage = new Map(); + +function parseSystemWarnings(output) { + // Parse: Token usage: X/200000; Y remaining + const warningMatch = output.match(/Token usage: (\d+)\/(\d+); (\d+) remaining/); + if (warningMatch) { + return { + used: parseInt(warningMatch[1]), + total: parseInt(warningMatch[2]), + remaining: parseInt(warningMatch[3]) + }; + } + return null; +} + +function shouldTriggerAutoCompact(sessionId, tokenData) { + // Check if remaining tokens below critical threshold + if (tokenData.remaining < TOKEN_CRITICAL_THRESHOLD) { + // Check if we haven't auto-compacted recently (avoid loops) + const lastCompact = sessionTokenUsage.get(sessionId)?.lastCompactTime; + const now = Date.now(); + if (!lastCompact || (now - lastCompact) > 300000) { // 5 min cooldown + return true; + } + } + return false; +} +``` + +**Modify Claude Process Stream Handler**: +```javascript +claudeProcess.stdout.on('data', (data) => { + const output = data.toString(); + + // Parse token warnings + const tokenData = parseSystemWarnings(output); + if (tokenData) { + // Send token budget update to frontend + ws.send(JSON.stringify({ + type: 'token-budget-update', + data: tokenData + })); + + // Check if auto-compact should trigger + if (shouldTriggerAutoCompact(sessionId, tokenData)) { + triggerAutoCompact(sessionId, tokenData, ws); + } + } + + // ... existing stream-json parsing logic +}); +``` + +**Auto-Compact Trigger Function**: +```javascript +async function triggerAutoCompact(sessionId, tokenData, ws) { + console.log(`⚡ Auto-compact triggered for session ${sessionId}: ${tokenData.remaining} tokens remaining`); + + // Record compact time to prevent loops + const sessionData = sessionTokenUsage.get(sessionId) || {}; + sessionData.lastCompactTime = Date.now(); + sessionTokenUsage.set(sessionId, sessionData); + + // Notify frontend + ws.send(JSON.stringify({ + type: 'auto-compact-triggered', + data: { + sessionId, + remainingTokens: tokenData.remaining, + message: `⚡ Auto-compressing context (${tokenData.remaining} tokens remaining)...` + } + })); + + // Execute /context save command + // Option 1: Spawn new Claude process with /context save + // Option 2: Send instruction to current session (if supported) + + try { + const compactResult = await executeContextSave(sessionId); + + ws.send(JSON.stringify({ + type: 'auto-compact-complete', + data: { + sessionId, + tokensSaved: compactResult.tokensSaved, + message: `✅ Context compressed → Saved ${compactResult.tokensSaved} tokens → Continuing workflow` + } + })); + } catch (error) { + ws.send(JSON.stringify({ + type: 'auto-compact-error', + data: { + sessionId, + error: error.message + } + })); + } +} +``` + +### Phase 2: UI Token Budget Display (Frontend) + +**File**: `src/components/TokenBudgetIndicator.jsx` (NEW) + +```jsx +import React, { useState, useEffect } from 'react'; +import { cn } from '../lib/utils'; + +function TokenBudgetIndicator({ tokenData }) { + if (!tokenData) return null; + + const { used, total, remaining } = tokenData; + const percentage = (used / total) * 100; + + // Color coding based on remaining tokens + const getStatusColor = () => { + if (remaining < 30000) return 'text-red-500'; + if (remaining < 60000) return 'text-yellow-500'; + return 'text-green-500'; + }; + + const getBarColor = () => { + if (remaining < 30000) return 'bg-red-500'; + if (remaining < 60000) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + return ( +
+
+ Token Budget + + {remaining.toLocaleString()} remaining + +
+ +
+
+
+ +
+ + {used.toLocaleString()} / {total.toLocaleString()} tokens used + + + {percentage.toFixed(1)}% + +
+
+ ); +} + +export default TokenBudgetIndicator; +``` + +**File**: `src/components/AutoCompactNotification.jsx` (NEW) + +```jsx +import React, { useState, useEffect } from 'react'; + +function AutoCompactNotification({ notification, onDismiss }) { + const [visible, setVisible] = useState(true); + + useEffect(() => { + if (notification?.type === 'auto-compact-complete') { + // Auto-dismiss after 5 seconds for success messages + const timer = setTimeout(() => { + setVisible(false); + onDismiss?.(); + }, 5000); + return () => clearTimeout(timer); + } + }, [notification, onDismiss]); + + if (!notification || !visible) return null; + + const getNotificationStyle = () => { + switch (notification.type) { + case 'auto-compact-triggered': + return 'bg-blue-900 border-blue-500'; + case 'auto-compact-complete': + return 'bg-green-900 border-green-500'; + case 'auto-compact-error': + return 'bg-red-900 border-red-500'; + default: + return 'bg-gray-900 border-gray-500'; + } + }; + + return ( +
+
+
+

{notification.data?.message}

+ {notification.data?.tokensSaved && ( +

+ Tokens saved: {notification.data.tokensSaved.toLocaleString()} +

+ )} +
+ +
+
+ ); +} + +export default AutoCompactNotification; +``` + +**File**: `src/components/ChatInterface.jsx` (MODIFY) + +Add WebSocket event handlers for token budget and auto-compact notifications: + +```javascript +// Add state for token budget and auto-compact notifications +const [tokenBudget, setTokenBudget] = useState(null); +const [autoCompactNotification, setAutoCompactNotification] = useState(null); + +// In WebSocket message handler +useEffect(() => { + // ... existing WebSocket setup + + ws.onmessage = (event) => { + const message = JSON.parse(event.data); + + switch (message.type) { + case 'token-budget-update': + setTokenBudget(message.data); + break; + + case 'auto-compact-triggered': + case 'auto-compact-complete': + case 'auto-compact-error': + setAutoCompactNotification(message); + break; + + // ... existing cases + } + }; +}, [/* deps */]); + +// In render +return ( +
+ {/* Token Budget Indicator */} + {tokenBudget && ( +
+ +
+ )} + + {/* Auto-Compact Notification */} + setAutoCompactNotification(null)} + /> + + {/* Existing chat interface components */} + {/* ... */} +
+); +``` + +### Phase 3: Settings and Configuration + +**File**: `src/components/Settings.jsx` (MODIFY) + +Add auto-compact configuration options: + +```jsx +// In Settings component state +const [autoCompactSettings, setAutoCompactSettings] = useState({ + enabled: true, + threshold: 30000, + showNotifications: true, + autoResumeAfterCompact: true +}); + +// Settings UI +
+

Auto-Compact Settings

+ + + + + + +
+``` + +### Phase 4: Context Save Command Implementation + +**Option A: Spawn Separate Claude Process** + +```javascript +async function executeContextSave(sessionId) { + return new Promise((resolve, reject) => { + const args = [ + '--resume', sessionId, + '--output-format', 'stream-json', + '--print', '/context save' + ]; + + const compactProcess = spawnFunction('claude', args, { + cwd: projectPath, + env: process.env + }); + + let compactOutput = ''; + + compactProcess.stdout.on('data', (data) => { + compactOutput += data.toString(); + }); + + compactProcess.on('close', (code) => { + if (code === 0) { + // Parse output to extract tokens saved + const tokensSaved = parseTokensSavedFromOutput(compactOutput); + resolve({ tokensSaved }); + } else { + reject(new Error(`Context save failed with code ${code}`)); + } + }); + }); +} +``` + +**Option B: Send Command to Current Session** + +```javascript +async function executeContextSave(sessionId, currentProcess) { + // Send /context save command to stdin of current Claude process + currentProcess.stdin.write('/context save\n'); + + // Listen for completion in existing stdout handler + // This approach requires tracking compact completion in stream +} +``` + +## Implementation Phases + +### Phase 1: Backend Token Monitoring (4-6 hours) +- [ ] Add token budget parsing from system warnings +- [ ] Implement token tracking per session +- [ ] Create auto-compact trigger logic with cooldown +- [ ] Add WebSocket events for token budget updates +- [ ] Test token parsing with Claude CLI stream-json output + +### Phase 2: UI Components (4-6 hours) +- [ ] Create `TokenBudgetIndicator.jsx` component +- [ ] Create `AutoCompactNotification.jsx` component +- [ ] Integrate components into `ChatInterface.jsx` +- [ ] Add WebSocket message handlers for token events +- [ ] Style components for mobile and desktop + +### Phase 3: Context Save Execution (3-4 hours) +- [ ] Implement `executeContextSave()` function +- [ ] Choose spawn approach (separate process vs stdin) +- [ ] Parse tokens saved from compact output +- [ ] Handle errors and edge cases +- [ ] Test context save and resume workflow + +### Phase 4: Settings and Configuration (2-3 hours) +- [ ] Add auto-compact settings to Settings component +- [ ] Persist settings in localStorage +- [ ] Pass settings to backend via API +- [ ] Implement threshold configuration +- [ ] Add notification preferences + +### Phase 5: Testing and Refinement (3-4 hours) +- [ ] Test with real Claude CLI sessions +- [ ] Verify auto-compact triggers at threshold +- [ ] Test cooldown prevents loops +- [ ] Verify context resume after compact +- [ ] Test mobile and desktop UI +- [ ] Handle edge cases (network errors, process crashes) + +## Technical Challenges and Solutions + +### Challenge 1: Parsing System Warnings +**Problem**: System warnings may not be in stream-json format +**Solution**: Use regex parsing on raw stdout before JSON parsing + +### Challenge 2: Context Save Timing +**Problem**: Auto-compact must not interrupt active Claude responses +**Solution**: Queue auto-compact until `claude-complete` event, or use cooldown period + +### Challenge 3: Session Continuity +**Problem**: Context save creates new session ID +**Solution**: Track session ID changes and update frontend session reference + +### Challenge 4: Multiple Concurrent Sessions +**Problem**: UI supports multiple sessions, each may need auto-compact +**Solution**: Use `Map` to track token usage per session ID + +### Challenge 5: Token Budget Persistence +**Problem**: Token budget resets on page reload +**Solution**: Store token budget in sessionStorage or backend database + +## Success Criteria + +1. ✅ Token budget displays in UI during Claude sessions +2. ✅ Auto-compact triggers automatically when < 30,000 tokens remain +3. ✅ User receives clear notification when auto-compact occurs +4. ✅ Context successfully compresses and saves +5. ✅ User can configure auto-compact threshold in settings +6. ✅ Auto-compact has cooldown to prevent loops +7. ✅ Mobile and desktop UI both support token budget display +8. ✅ Session continues seamlessly after auto-compact + +## Benefits + +**For Users**: +- Never lose context due to token exhaustion +- Transparent token budget awareness +- Seamless long-running sessions +- Mobile-friendly token monitoring + +**For Developers**: +- Reusable token budget component +- Clean WebSocket event architecture +- Configurable thresholds +- Extensible for other automation features + +## Risks and Mitigation + +| Risk | Impact | Likelihood | Mitigation | +|------|--------|------------|------------| +| System warnings format changes | High | Low | Version-specific parsing with fallbacks | +| Auto-compact loops | Medium | Medium | Cooldown period (5 min) + tracking | +| Context save fails | High | Low | Error handling + user notification | +| Token parsing accuracy | Medium | Low | Comprehensive testing with real sessions | +| Performance impact | Low | Low | Lightweight parsing, async operations | + +## Future Enhancements + +1. **Token Budget History**: Graph showing token usage over session lifetime +2. **Predictive Auto-Compact**: ML-based prediction of when to compact +3. **Multi-Level Compression**: Configurable compression levels (light, medium, aggressive) +4. **Context Recovery UI**: Browse and restore previous compressed contexts +5. **Token Usage Analytics**: Per-project token usage statistics +6. **Shared Context**: Team collaboration with shared compressed contexts + +## Conclusion + +The auto-compact feature is **highly feasible** for claudecodeui. The existing architecture provides all necessary integration points: + +- ✅ Token data already streamed via stream-json +- ✅ WebSocket infrastructure for real-time updates +- ✅ UI component structure supports new features +- ✅ Backend can execute Claude commands + +**Recommendation**: **PROCEED WITH IMPLEMENTATION** + +This feature will significantly enhance user experience for long-running Claude sessions, especially on mobile devices where token budget awareness is critical. + +--- + +**Next Steps**: +1. Review this proposal with project stakeholders +2. Create feature branch: `feat/auto-compact-token-monitoring` +3. Begin Phase 1 implementation (backend token monitoring) +4. Iterate with user testing after Phase 2 (UI components) +5. Release as beta feature with opt-in flag +6. Gather feedback and refine before full release + +**Estimated Timeline**: 2-3 weeks (including testing and refinement) +**Priority**: Medium-High (valuable feature for power users) diff --git a/AUTO_COMPACT_IMPLEMENTATION_SUMMARY.md b/AUTO_COMPACT_IMPLEMENTATION_SUMMARY.md new file mode 100644 index 00000000..d217e976 --- /dev/null +++ b/AUTO_COMPACT_IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,249 @@ +# Auto-Compact Feature - Quick Implementation Guide + +## TL;DR + +**✅ FEASIBLE** - The Claude CLI auto-compact feature can be successfully integrated into claudecodeui. + +**Effort**: 2-3 days development +**Complexity**: Medium +**Risk**: Low + +## What is Auto-Compact? + +Auto-compact automatically compresses Claude's conversation context when token usage approaches the limit (< 30,000 remaining out of 200,000 total). This prevents context loss and enables seamless long-running sessions. + +**From infra-assist CLAUDE.md**: +``` +When tokens < 30,000: Claude AUTOMATICALLY executes /context save +→ Notifies user: "⚡ Auto-compressed context → Saved X tokens → Continuing" +→ Session continues seamlessly +``` + +## Current State of claudecodeui + +### ✅ Already Has +- Token tracking via stream-json output +- Real-time token display in UI (`ClaudeStatus.jsx`) +- WebSocket infrastructure for live updates +- Claude CLI spawning and management + +### ❌ Missing +- Token budget monitoring (total vs remaining) +- Auto-compact trigger logic +- `/context save` command execution +- User notifications for auto-compact events +- Settings for auto-compact configuration + +## Implementation Overview + +### 1. Backend Changes (server/claude-cli.js) + +**Add Token Budget Parsing**: +```javascript +// Parse system warnings from Claude output +function parseSystemWarnings(output) { + // Match: Token usage: 95000/200000; 105000 remaining + const match = output.match(/Token usage: (\d+)\/(\d+); (\d+) remaining/); + return match ? { + used: parseInt(match[1]), + total: parseInt(match[2]), + remaining: parseInt(match[3]) + } : null; +} +``` + +**Add Auto-Compact Trigger**: +```javascript +function shouldTriggerAutoCompact(tokenData) { + return tokenData.remaining < 30000; +} + +async function triggerAutoCompact(sessionId, ws) { + // Notify frontend + ws.send(JSON.stringify({ + type: 'auto-compact-triggered', + data: { message: '⚡ Auto-compressing context...' } + })); + + // Execute /context save + await executeContextSave(sessionId); + + // Notify completion + ws.send(JSON.stringify({ + type: 'auto-compact-complete', + data: { message: '✅ Context compressed → Continuing workflow' } + })); +} +``` + +### 2. Frontend Changes (src/components/) + +**New Component: TokenBudgetIndicator.jsx** +```jsx +// Display token budget with progress bar + +``` + +**New Component: AutoCompactNotification.jsx** +```jsx +// Toast notification for auto-compact events + +``` + +**Modified: ChatInterface.jsx** +```jsx +// Add WebSocket handlers for token budget and auto-compact +ws.onmessage = (event) => { + const message = JSON.parse(event.data); + switch (message.type) { + case 'token-budget-update': + setTokenBudget(message.data); + break; + case 'auto-compact-triggered': + case 'auto-compact-complete': + setAutoCompactNotification(message); + break; + } +}; +``` + +### 3. Settings (src/components/Settings.jsx) + +**Add Configuration Options**: +```jsx +
+ + + + + +
+``` + +## Key Integration Points + +| Component | Change | Purpose | +|-----------|--------|---------| +| `server/claude-cli.js` | Parse system warnings | Extract token budget from Claude output | +| `server/claude-cli.js` | Add auto-compact trigger | Execute `/context save` when threshold hit | +| `src/components/TokenBudgetIndicator.jsx` | NEW | Display token budget with progress bar | +| `src/components/AutoCompactNotification.jsx` | NEW | Toast notifications for auto-compact events | +| `src/components/ChatInterface.jsx` | Add WebSocket handlers | Receive token budget and auto-compact events | +| `src/components/Settings.jsx` | Add auto-compact settings | User configuration for thresholds | + +## Implementation Phases + +### Phase 1: Backend Token Monitoring (4-6 hours) +1. Parse system warnings for token budget +2. Track token usage per session +3. Implement auto-compact trigger with cooldown +4. Send token budget updates via WebSocket + +### Phase 2: UI Components (4-6 hours) +1. Create `TokenBudgetIndicator` component +2. Create `AutoCompactNotification` component +3. Integrate into `ChatInterface` +4. Style for mobile and desktop + +### Phase 3: Context Save Execution (3-4 hours) +1. Implement `/context save` execution +2. Parse tokens saved from output +3. Handle errors gracefully +4. Test context resume workflow + +### Phase 4: Settings (2-3 hours) +1. Add auto-compact settings UI +2. Persist settings in localStorage +3. Pass settings to backend +4. Test configuration changes + +### Phase 5: Testing (3-4 hours) +1. Test with real Claude sessions +2. Verify auto-compact at threshold +3. Test cooldown prevents loops +4. Verify mobile and desktop UI + +**Total Estimated Time**: 16-23 hours (2-3 days) + +## Testing Checklist + +- [ ] Token budget displays correctly during Claude execution +- [ ] Auto-compact triggers when < 30,000 tokens remain +- [ ] User receives notification when auto-compact occurs +- [ ] Context successfully saves and compresses +- [ ] Session continues after auto-compact +- [ ] Cooldown prevents auto-compact loops +- [ ] Settings persist across sessions +- [ ] Mobile UI displays token budget correctly +- [ ] Desktop UI displays token budget correctly +- [ ] Error handling works for failed compacts + +## Example User Experience + +**Before Auto-Compact**: +``` +User: [working on long project] +Claude: [responds... responds... responds...] +[Token limit reached - conversation stops] +User: 😞 Lost all context +``` + +**After Auto-Compact**: +``` +User: [working on long project] +Claude: [responds... responds...] +UI: ⚡ Auto-compressing context (28,000 tokens remaining)... +UI: ✅ Context compressed → Saved 85,000 tokens → Continuing workflow +Claude: [continues seamlessly] +User: 😊 No interruption! +``` + +## Files to Create/Modify + +**New Files**: +- `src/components/TokenBudgetIndicator.jsx` +- `src/components/AutoCompactNotification.jsx` +- `AUTO_COMPACT_FEATURE_PROPOSAL.md` (this document) +- `AUTO_COMPACT_IMPLEMENTATION_SUMMARY.md` (quick reference) + +**Modified Files**: +- `server/claude-cli.js` (token parsing, auto-compact logic) +- `src/components/ChatInterface.jsx` (WebSocket handlers, UI integration) +- `src/components/Settings.jsx` (auto-compact configuration) + +## Next Steps + +1. ✅ Review feature proposal and implementation plan +2. Create feature branch: `git checkout -b feat/auto-compact-token-monitoring` +3. Implement Phase 1 (backend token monitoring) +4. Implement Phase 2 (UI components) +5. Implement Phase 3 (context save execution) +6. Implement Phase 4 (settings) +7. Test thoroughly with real Claude sessions +8. Create pull request for review +9. Deploy as beta feature with opt-in flag +10. Gather user feedback and refine + +## Questions? + +See full proposal: `AUTO_COMPACT_FEATURE_PROPOSAL.md` + +**Recommendation**: **Proceed with implementation** - This feature significantly enhances claudecodeui for long-running sessions and aligns with infra-assist's proven auto-compact workflow. diff --git a/server/claude-cli.js b/server/claude-cli.js index 2e685d76..11b00600 100755 --- a/server/claude-cli.js +++ b/server/claude-cli.js @@ -9,18 +9,248 @@ const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; let activeClaudeProcesses = new Map(); // Track active processes by session ID +// Auto-compact constants +const TOKEN_BUDGET_TOTAL = 200000; +const DEFAULT_TOKEN_THRESHOLD = 30000; // Default auto-compact trigger threshold +const AUTO_COMPACT_COOLDOWN = 300000; // 5 minutes cooldown to prevent loops + +// Track token usage and auto-compact state per session +// Structure: { lastCompactTime, autoCompactEnabled, autoCompactThreshold } +const sessionTokenUsage = new Map(); + +// Track in-progress auto-compact operations to prevent double-trigger +const activeAutoCompacts = new Set(); + +/** + * Parse system warnings from Claude output for token budget information + * Example: Token usage: 95000/200000; 105000 remaining + * Example with commas: Token usage: 95,000/200,000; 105,000 remaining + * @param {string} output - Raw output string from Claude CLI + * @returns {object|null} Token data object with used, total, remaining or null if not found + */ +function parseSystemWarnings(output) { + // Strip HTML tags and ANSI codes before parsing + const clean = output.replace(/<[^>]+>/g, '').replace(/\x1B\[[0-9;]*m/g, ''); + + // Make regex more robust: handle commas in numbers, case-insensitive matching + const warningMatch = clean.match(/Token usage:\s*([\d,]+)\s*\/\s*([\d,]+);\s*([\d,]+)\s*remaining/i); + if (warningMatch) { + return { + used: parseInt(warningMatch[1].replace(/,/g, '')), + total: parseInt(warningMatch[2].replace(/,/g, '')), + remaining: parseInt(warningMatch[3].replace(/,/g, '')) + }; + } + return null; +} + +/** + * Check if auto-compact should trigger based on token threshold and cooldown + * @param {string} sessionId - Current Claude session ID + * @param {object} tokenData - Token budget data from parseSystemWarnings + * @returns {boolean} True if auto-compact should trigger + */ +function shouldTriggerAutoCompact(sessionId, tokenData) { + // Check if already compacting for this session (prevent double-trigger) + if (activeAutoCompacts.has(sessionId)) { + console.log(`⏸️ Auto-compact skipped: already in progress for session ${sessionId}`); + return false; + } + + // Get per-session settings (fallback to defaults if not set) + const sessionData = sessionTokenUsage.get(sessionId) || {}; + const autoCompactEnabled = sessionData.autoCompactEnabled !== false; // default true + const threshold = sessionData.autoCompactThreshold || DEFAULT_TOKEN_THRESHOLD; + + // Check if auto-compact is enabled for this session + if (!autoCompactEnabled) { + return false; + } + + // Check if remaining tokens below critical threshold + if (tokenData.remaining < threshold) { + // Check if we haven't auto-compacted recently (avoid loops) + const lastCompactTime = sessionData.lastCompactTime; + const now = Date.now(); + + if (!lastCompactTime || (now - lastCompactTime) > AUTO_COMPACT_COOLDOWN) { + console.log(`⚡ Auto-compact trigger conditions met: ${tokenData.remaining} tokens remaining (threshold: ${threshold}), cooldown satisfied`); + return true; + } else { + const timeSinceLastCompact = Math.floor((now - lastCompactTime) / 1000); + console.log(`⏸️ Auto-compact skipped: in cooldown period (${timeSinceLastCompact}s since last compact)`); + } + } + return false; +} + +/** + * Execute /context save command to compress conversation context + * @param {string} sessionId - Claude session ID to compact + * @returns {Promise} Result object with tokensSaved count + */ +async function executeContextSave(sessionId) { + return new Promise((resolve, reject) => { + console.log(`📦 Executing /context save for session ${sessionId}`); + + const args = [ + '--resume', sessionId, + '--output-format', 'stream-json', + '--print', + '--', + '/context save' + ]; + + // Honor CLAUDE_CLI_PATH environment variable + const claudePath = process.env.CLAUDE_CLI_PATH || 'claude'; + console.log('🔍 Using Claude CLI path for context save:', claudePath); + + const compactProcess = spawnFunction(claudePath, args, { + cwd: process.cwd(), + env: process.env + }); + + let compactOutput = ''; + + compactProcess.stdout.on('data', (data) => { + compactOutput += data.toString(); + console.log('📤 Context save output:', data.toString()); + }); + + compactProcess.stderr.on('data', (data) => { + console.error('❌ Context save error:', data.toString()); + }); + + compactProcess.on('close', (code) => { + if (code === 0) { + // Parse output to extract tokens saved + const tokensSaved = parseTokensSavedFromOutput(compactOutput); + console.log(`✅ Context save completed: ${tokensSaved !== null ? tokensSaved + ' tokens saved' : 'tokens saved unknown'}`); + resolve({ tokensSaved }); + } else { + reject(new Error(`Context save failed with exit code ${code}`)); + } + }); + + compactProcess.on('error', (error) => { + console.error('❌ Context save process error:', error); + reject(error); + }); + }); +} + +/** + * Parse tokens saved from /context save command output + * @param {string} output - Raw output from /context save command + * @returns {number|null} Tokens saved count or null if not found + */ +function parseTokensSavedFromOutput(output) { + // Look for token count in output (Claude may report this) + const tokenMatch = output.match(/Saved ([\d,]+) tokens|Compressed ([\d,]+) tokens/i); + if (tokenMatch) { + const tokensStr = tokenMatch[1] || tokenMatch[2]; + return parseInt(tokensStr.replace(/,/g, '')); + } + + // Return null if we can't determine actual tokens saved + // Don't inflate expectations with guesses + return null; +} + +/** + * Trigger auto-compact workflow: notify frontend, execute /context save, report results + * @param {string} sessionId - Claude session ID + * @param {object} tokenData - Current token budget data + * @param {object} ws - WebSocket connection to frontend + */ +async function triggerAutoCompact(sessionId, tokenData, ws) { + console.log(`⚡ Auto-compact triggered for session ${sessionId}: ${tokenData.remaining} tokens remaining`); + + // Mark as in-progress to prevent double-trigger + activeAutoCompacts.add(sessionId); + + // Record compact time to prevent loops + const sessionData = sessionTokenUsage.get(sessionId) || {}; + sessionData.lastCompactTime = Date.now(); + sessionTokenUsage.set(sessionId, sessionData); + + // Notify frontend that auto-compact is starting + ws.send(JSON.stringify({ + type: 'auto-compact-triggered', + data: { + sessionId, + remainingTokens: tokenData.remaining, + message: `⚡ Auto-compressing context (${tokenData.remaining.toLocaleString()} tokens remaining)...` + } + })); + + // Execute /context save command + try { + const compactResult = await executeContextSave(sessionId); + + // Build message based on whether we know tokens saved + const message = compactResult.tokensSaved !== null + ? `✅ Context compressed → Saved ${compactResult.tokensSaved.toLocaleString()} tokens → Continuing workflow` + : `✅ Context compressed → Continuing workflow`; + + ws.send(JSON.stringify({ + type: 'auto-compact-complete', + data: { + sessionId, + tokensSaved: compactResult.tokensSaved, + message + } + })); + } catch (error) { + console.error('❌ Auto-compact failed:', error); + ws.send(JSON.stringify({ + type: 'auto-compact-error', + data: { + sessionId, + error: error.message, + message: `❌ Auto-compact failed: ${error.message}` + } + })); + } finally { + // Remove in-progress flag after completion (success or failure) + activeAutoCompacts.delete(sessionId); + } +} + async function spawnClaude(command, options = {}, ws) { return new Promise(async (resolve, reject) => { const { sessionId, projectPath, cwd, resume, toolsSettings, permissionMode, images } = options; let capturedSessionId = sessionId; // Track session ID throughout the process let sessionCreatedSent = false; // Track if we've already sent session-created event - + // Use tools settings passed from frontend, or defaults const settings = toolsSettings || { allowedTools: [], disallowedTools: [], skipPermissions: false }; + + // Store auto-compact settings per-session (initialize or update) + const initializeSessionSettings = (sid) => { + if (!sid) return; + + const existingData = sessionTokenUsage.get(sid) || {}; + const updatedData = { + ...existingData, + autoCompactEnabled: toolsSettings?.autoCompactEnabled !== false, // default true + autoCompactThreshold: toolsSettings?.autoCompactThreshold + ? Math.max(10000, Math.min(100000, toolsSettings.autoCompactThreshold)) + : DEFAULT_TOKEN_THRESHOLD + }; + + sessionTokenUsage.set(sid, updatedData); + console.log(`🔧 Auto-compact settings for session ${sid}: enabled=${updatedData.autoCompactEnabled}, threshold=${updatedData.autoCompactThreshold}`); + }; + + // Initialize settings for existing session ID + if (capturedSessionId) { + initializeSessionSettings(capturedSessionId); + } // Build Claude CLI command - start with print/resume flags first const args = []; @@ -254,30 +484,65 @@ async function spawnClaude(command, options = {}, ws) { // Store process reference for potential abort const processKey = capturedSessionId || sessionId || Date.now().toString(); activeClaudeProcesses.set(processKey, claudeProcess); - + + // Buffer for NDJSON line-based parsing to handle chunk boundaries + let stdoutBuffer = ''; + // Handle stdout (streaming JSON responses) claudeProcess.stdout.on('data', (data) => { const rawOutput = data.toString(); - console.log('📤 Claude CLI stdout:', rawOutput); - - const lines = rawOutput.split('\n').filter(line => line.trim()); - - for (const line of lines) { + console.log('📤 Claude CLI stdout chunk:', rawOutput); + + // Append to buffer and split into complete lines + stdoutBuffer += rawOutput; + const lines = stdoutBuffer.split('\n'); + // Keep incomplete last line in buffer + stdoutBuffer = lines.pop() || ''; + + // Process each complete line + for (const lineRaw of lines) { + const line = lineRaw.trim(); + if (!line) continue; + + // Parse token warnings line-by-line + const tokenData = parseSystemWarnings(line); + if (tokenData && capturedSessionId) { + console.log(`💰 Token budget update: ${tokenData.used}/${tokenData.total} (${tokenData.remaining} remaining)`); + + // Send token budget update to frontend with sessionId + ws.send(JSON.stringify({ + type: 'token-budget-update', + data: { + ...tokenData, + sessionId: capturedSessionId + } + })); + + // Check if auto-compact should trigger (uses per-session settings) + if (shouldTriggerAutoCompact(capturedSessionId, tokenData)) { + triggerAutoCompact(capturedSessionId, tokenData, ws); + } + } + + // Try JSON parsing try { const response = JSON.parse(line); console.log('📄 Parsed JSON response:', response); - + // Capture session ID if it's in the response if (response.session_id && !capturedSessionId) { capturedSessionId = response.session_id; console.log('📝 Captured session ID:', capturedSessionId); - + + // Initialize auto-compact settings for newly captured session + initializeSessionSettings(capturedSessionId); + // Update process key with captured session ID if (processKey !== capturedSessionId) { activeClaudeProcesses.delete(processKey); activeClaudeProcesses.set(capturedSessionId, claudeProcess); } - + // Send session-created event only once for new sessions if (!sessionId && !sessionCreatedSent) { sessionCreatedSent = true; @@ -287,19 +552,21 @@ async function spawnClaude(command, options = {}, ws) { })); } } - + // Send parsed response to WebSocket ws.send(JSON.stringify({ type: 'claude-response', data: response })); } catch (parseError) { - console.log('📄 Non-JSON response:', line); - // If not JSON, send as raw text - ws.send(JSON.stringify({ - type: 'claude-output', - data: line - })); + // Not JSON; forward as raw text (unless it was a token warning) + if (!tokenData) { + console.log('📄 Non-JSON response:', line); + ws.send(JSON.stringify({ + type: 'claude-output', + data: line + })); + } } } }); @@ -316,10 +583,13 @@ async function spawnClaude(command, options = {}, ws) { // Handle process completion claudeProcess.on('close', async (code) => { console.log(`Claude CLI process exited with code ${code}`); - - // Clean up process reference + + // Clean up process reference and per-session state to prevent memory leaks const finalSessionId = capturedSessionId || sessionId || processKey; activeClaudeProcesses.delete(finalSessionId); + sessionTokenUsage.delete(finalSessionId); + activeAutoCompacts.delete(finalSessionId); + console.log(`🧹 Cleaned up session state for: ${finalSessionId}`); ws.send(JSON.stringify({ type: 'claude-complete', diff --git a/src/components/AutoCompactNotification.jsx b/src/components/AutoCompactNotification.jsx new file mode 100644 index 00000000..958f1e0a --- /dev/null +++ b/src/components/AutoCompactNotification.jsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react'; + +/** + * AutoCompactNotification - Toast notification for auto-compact events + * + * Displays notifications when: + * - Auto-compact is triggered (blue) + * - Auto-compact completes successfully (green) + * - Auto-compact fails (red) + * + * Auto-dismisses success messages after 5 seconds. + * Error messages require manual dismissal. + * + * @param {object} notification - Notification data from backend + * @param {string} notification.type - Event type ('auto-compact-triggered', 'auto-compact-complete', 'auto-compact-error') + * @param {object} notification.data - Event data + * @param {string} notification.data.message - Display message + * @param {number} notification.data.tokensSaved - Tokens saved (for complete events) + * @param {function} onDismiss - Callback when notification is dismissed + */ +function AutoCompactNotification({ notification, onDismiss }) { + const [visible, setVisible] = useState(true); + + useEffect(() => { + if (notification?.type === 'auto-compact-complete') { + // Auto-dismiss after 5 seconds for success messages + const timer = setTimeout(() => { + setVisible(false); + onDismiss?.(); + }, 5000); + return () => clearTimeout(timer); + } + }, [notification, onDismiss]); + + if (!notification || !visible) return null; + + const getNotificationStyle = () => { + switch (notification.type) { + case 'auto-compact-triggered': + return 'bg-blue-900 border-blue-500'; + case 'auto-compact-complete': + return 'bg-green-900 border-green-500'; + case 'auto-compact-error': + return 'bg-red-900 border-red-500'; + default: + return 'bg-gray-900 border-gray-500'; + } + }; + + const getIcon = () => { + switch (notification.type) { + case 'auto-compact-triggered': + return ( + + + + ); + case 'auto-compact-complete': + return ( + + + + ); + case 'auto-compact-error': + return ( + + + + ); + default: + return null; + } + }; + + return ( +
+
+
+ {/* Icon */} + {getIcon()} + + {/* Content */} +
+

{notification.data?.message}

+ {notification.data?.tokensSaved && ( +

+ Tokens saved: {notification.data.tokensSaved.toLocaleString()} +

+ )} + {notification.data?.error && ( +

+ Error: {notification.data.error} +

+ )} +
+
+ + {/* Dismiss button */} + +
+
+ ); +} + +export default AutoCompactNotification; diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 1be74815..a047e39f 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -28,6 +28,8 @@ import { useTasksSettings } from '../contexts/TasksSettingsContext'; import ClaudeStatus from './ClaudeStatus'; import { MicButton } from './MicButton.jsx'; import { api, authenticatedFetch } from '../utils/api'; +import TokenBudgetIndicator from './TokenBudgetIndicator'; +import AutoCompactNotification from './AutoCompactNotification'; // Format "Claude AI usage limit reached|" into a local time string @@ -1196,6 +1198,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [attachedImages, setAttachedImages] = useState([]); const [uploadingImages, setUploadingImages] = useState(new Map()); const [imageErrors, setImageErrors] = useState(new Map()); + const [tokenBudget, setTokenBudget] = useState(null); + const [autoCompactNotification, setAutoCompactNotification] = useState(null); const messagesEndRef = useRef(null); const textareaRef = useRef(null); const scrollContainerRef = useRef(null); @@ -2179,7 +2183,27 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess timestamp: new Date() }]); break; - + + case 'token-budget-update': + // Update token budget display + setTokenBudget(latestMessage.data); + break; + + case 'auto-compact-triggered': + case 'auto-compact-complete': + case 'auto-compact-error': { + // Display auto-compact notification only if user has enabled notifications + const savedSettings = localStorage.getItem('claude-settings'); + const showNotifications = savedSettings + ? JSON.parse(savedSettings).showAutoCompactNotifications !== false + : true; // default true + + if (showNotifications) { + setAutoCompactNotification(latestMessage); + } + break; + } + case 'cursor-system': // Handle Cursor system/init messages similar to Claude try { @@ -2750,7 +2774,17 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings'; const savedSettings = safeLocalStorage.getItem(settingsKey); if (savedSettings) { - return JSON.parse(savedSettings); + const parsed = JSON.parse(savedSettings); + // Return all saved settings including auto-compact configuration + return { + allowedTools: parsed.allowedTools || [], + disallowedTools: parsed.disallowedTools || [], + skipPermissions: parsed.skipPermissions || false, + // Auto-compact settings (Claude only) + autoCompactEnabled: parsed.autoCompactEnabled !== false, // default true + autoCompactThreshold: parsed.autoCompactThreshold || 30000, + showAutoCompactNotifications: parsed.showAutoCompactNotifications !== false // default true + }; } } catch (error) { console.error('Error loading tools settings:', error); @@ -2758,7 +2792,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess return { allowedTools: [], disallowedTools: [], - skipPermissions: false + skipPermissions: false, + // Auto-compact defaults + autoCompactEnabled: true, + autoCompactThreshold: 30000, + showAutoCompactNotifications: true }; }; @@ -2982,9 +3020,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess } `} + + {/* Auto-Compact Notification - Fixed Position */} + setAutoCompactNotification(null)} + /> +
+ {/* Token Budget Indicator - Top of Interface */} + {tokenBudget && ( +
+ +
+ )} + {/* Messages Area - Scrollable Middle Section */} -
diff --git a/src/components/Settings.jsx b/src/components/Settings.jsx index 4398cbcc..af387b61 100644 --- a/src/components/Settings.jsx +++ b/src/components/Settings.jsx @@ -28,6 +28,11 @@ function Settings({ isOpen, onClose, projects = [] }) { const [saveStatus, setSaveStatus] = useState(null); const [projectSortOrder, setProjectSortOrder] = useState('name'); + // Auto-compact settings + const [autoCompactEnabled, setAutoCompactEnabled] = useState(true); + const [autoCompactThreshold, setAutoCompactThreshold] = useState(30000); + const [showAutoCompactNotifications, setShowAutoCompactNotifications] = useState(true); + const [mcpServers, setMcpServers] = useState([]); const [showMcpForm, setShowMcpForm] = useState(false); const [editingMcpServer, setEditingMcpServer] = useState(null); @@ -334,19 +339,26 @@ function Settings({ isOpen, onClose, projects = [] }) { // Load Claude settings from localStorage const savedSettings = localStorage.getItem('claude-settings'); - + if (savedSettings) { const settings = JSON.parse(savedSettings); setAllowedTools(settings.allowedTools || []); setDisallowedTools(settings.disallowedTools || []); setSkipPermissions(settings.skipPermissions || false); setProjectSortOrder(settings.projectSortOrder || 'name'); + // Load auto-compact settings + setAutoCompactEnabled(settings.autoCompactEnabled !== false); // default true + setAutoCompactThreshold(settings.autoCompactThreshold || 30000); + setShowAutoCompactNotifications(settings.showAutoCompactNotifications !== false); // default true } else { // Set defaults setAllowedTools([]); setDisallowedTools([]); setSkipPermissions(false); setProjectSortOrder('name'); + setAutoCompactEnabled(true); + setAutoCompactThreshold(30000); + setShowAutoCompactNotifications(true); } // Load Cursor settings from localStorage @@ -402,17 +414,23 @@ function Settings({ isOpen, onClose, projects = [] }) { const saveSettings = () => { setIsSaving(true); setSaveStatus(null); - + try { + // Validate and clamp auto-compact threshold + const validatedThreshold = Math.max(10000, Math.min(100000, autoCompactThreshold || 30000)); + // Save Claude settings const claudeSettings = { allowedTools, disallowedTools, skipPermissions, projectSortOrder, + autoCompactEnabled, + autoCompactThreshold: validatedThreshold, + showAutoCompactNotifications, lastUpdated: new Date().toISOString() }; - + // Save Cursor settings const cursorSettings = { allowedCommands: cursorAllowedCommands, @@ -420,13 +438,13 @@ function Settings({ isOpen, onClose, projects = [] }) { skipPermissions: cursorSkipPermissions, lastUpdated: new Date().toISOString() }; - + // Save to localStorage localStorage.setItem('claude-settings', JSON.stringify(claudeSettings)); localStorage.setItem('cursor-tools-settings', JSON.stringify(cursorSettings)); - + setSaveStatus('success'); - + setTimeout(() => { onClose(); }, 1000); @@ -723,6 +741,109 @@ function Settings({ isOpen, onClose, projects = [] }) {
+ {/* Auto-Compact Settings */} +
+
+
+
+ + Auto-Compact Settings +
+
+ Automatically compress conversation context when token budget is low +
+
+ + {/* Enable Auto-Compact */} +
+
+
+ Enable Auto-Compact +
+
+ Automatically compress context when tokens are low +
+
+ +
+ + {/* Token Threshold */} +
+
+
+ Token Threshold +
+
+ Trigger auto-compact when remaining tokens fall below this value +
+
+
+ { + const value = parseInt(e.target.value); + // Validate and clamp value to acceptable range + if (!isNaN(value)) { + const clampedValue = Math.max(10000, Math.min(100000, value)); + setAutoCompactThreshold(clampedValue); + } + }} + disabled={!autoCompactEnabled} + className="text-sm bg-gray-50 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 rounded-lg focus:ring-blue-500 focus:border-blue-500 p-2 w-32 disabled:opacity-50 disabled:cursor-not-allowed" + /> + tokens +
+
+ Default: 30,000 tokens (recommended) +
+
+ + {/* Show Notifications */} +
+
+
+ Show Notifications +
+
+ Display notifications when auto-compact occurs +
+
+ +
+
+
+ {/* Project Sorting */}
diff --git a/src/components/TokenBudgetIndicator.jsx b/src/components/TokenBudgetIndicator.jsx new file mode 100644 index 00000000..2ba0ee87 --- /dev/null +++ b/src/components/TokenBudgetIndicator.jsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { cn } from '../lib/utils'; + +/** + * TokenBudgetIndicator - Display Claude's token budget with visual progress bar + * + * Shows: + * - Token usage (used/total) + * - Remaining tokens + * - Visual progress bar with color coding based on remaining tokens + * + * Color coding: + * - Green: > 60,000 tokens remaining (healthy) + * - Yellow: 30,000-60,000 tokens remaining (warning) + * - Red: < 30,000 tokens remaining (critical - auto-compact threshold) + * + * @param {object} tokenData - Token budget data from backend + * @param {number} tokenData.used - Tokens used in current session + * @param {number} tokenData.total - Total token budget (200,000) + * @param {number} tokenData.remaining - Remaining tokens available + */ +function TokenBudgetIndicator({ tokenData }) { + if (!tokenData) return null; + + // Sanitize and normalize token data with fallbacks + const { + used: rawUsed = 0, + total: rawTotal = 0, + remaining: rawRemaining = 0 + } = tokenData; + + const used = Number.isFinite(rawUsed) ? rawUsed : 0; + const total = Number.isFinite(rawTotal) ? rawTotal : 0; + const remaining = Number.isFinite(rawRemaining) ? rawRemaining : 0; + + // Guard against division by zero and clamp percentage to 0-100 + const rawPercentage = total > 0 ? (used / total) * 100 : 0; + const percentage = Math.max(0, Math.min(100, rawPercentage)); + + // Color coding based on remaining tokens + const getStatusColor = () => { + if (remaining < 30000) return 'text-red-500'; + if (remaining < 60000) return 'text-yellow-500'; + return 'text-green-500'; + }; + + const getBarColor = () => { + if (remaining < 30000) return 'bg-red-500'; + if (remaining < 60000) return 'bg-yellow-500'; + return 'bg-green-500'; + }; + + return ( +
+
+ Token Budget + + {remaining.toLocaleString()} remaining + +
+ +
+
+
+ +
+ + {used.toLocaleString()} / {total.toLocaleString()} tokens used + + + {percentage.toFixed(1)}% + +
+
+ ); +} + +export default TokenBudgetIndicator;