diff --git a/BACKBURNER.md b/BACKBURNER.md new file mode 100644 index 00000000..df7f1bff --- /dev/null +++ b/BACKBURNER.md @@ -0,0 +1,49 @@ +# Backburner Features + +This document tracks dormant features that are implemented but disabled via feature flags. These features may be re-enabled in future releases. + +## LLM Settings Panel + +**Status:** Disabled +**Feature Flag:** `FEATURE_FLAGS.LLM_SETTINGS` in `src/renderer/components/SettingsModal.tsx` +**Disabled Date:** 2024-11-26 + +### Description + +The LLM Settings panel provides configuration options for connecting to various LLM providers directly from Maestro. This feature was designed to enable a built-in AI assistant for the scratchpad or other future AI-powered features within the application. + +### Supported Providers + +- **OpenRouter** - API proxy supporting multiple models +- **Anthropic** - Direct Claude API access +- **Ollama** - Local LLM inference + +### Configuration Options + +- LLM Provider selection +- Model slug/identifier +- API key (stored locally) +- Connection test functionality + +### Files Involved + +- `src/renderer/components/SettingsModal.tsx` - Main settings UI with LLM tab +- Settings stored in electron-store: `llmProvider`, `modelSlug`, `apiKey` + +### Re-enabling + +To re-enable this feature: + +1. Open `src/renderer/components/SettingsModal.tsx` +2. Find the `FEATURE_FLAGS` constant at the top of the file +3. Set `LLM_SETTINGS: true` + +```typescript +const FEATURE_FLAGS = { + LLM_SETTINGS: true, // LLM provider configuration (OpenRouter, Anthropic, Ollama) +}; +``` + +### Reason for Disabling + +Currently not in use as Maestro focuses on managing external AI coding agents (Claude Code, etc.) rather than providing built-in LLM functionality. May be re-enabled when there's a use case for direct LLM integration within Maestro. diff --git a/CLAUDE.md b/CLAUDE.md index ddc58686..ff772719 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,7 @@ All process operations go through IPC handlers in `src/main/index.ts`: Events are emitted back to renderer via: - `process:data` - Stdout/stderr output - `process:exit` - Process exit code +- `process:usage` - Usage statistics from AI responses (tokens, cost, context window) ### Session Model @@ -134,6 +135,7 @@ Each "session" is a unified abstraction running **two processes simultaneously** - `inputMode` - Input routing mode ('terminal' or 'ai') - `aiPid` - Process ID for the AI agent process - `terminalPid` - Process ID for the terminal process +- `usageStats` - Token usage and cost statistics from AI responses (optional) This dual-process model allows seamless switching between AI and terminal modes without restarting processes. Input is routed to the appropriate process based on `inputMode`. @@ -226,6 +228,7 @@ Maestro implements an extensible slash command system in `src/renderer/slashComm export interface SlashCommand { command: string; // The command string (e.g., "/clear") description: string; // Human-readable description + terminalOnly?: boolean; // Only show in terminal mode (optional) execute: (context: SlashCommandContext) => void; // Command handler } ``` @@ -236,15 +239,17 @@ export interface SlashCommand { - Keyboard navigation with arrow keys, Tab/Enter to select - Commands receive execution context (activeSessionId, sessions, setSessions, currentMode) - Commands can modify session state, trigger actions, or interact with IPC +- Commands can be mode-specific using `terminalOnly: true` flag **Adding new commands:** 1. Add new entry to `slashCommands` array in `src/renderer/slashCommands.ts` 2. Implement `execute` function with desired behavior -3. Command automatically appears in autocomplete +3. Optionally set `terminalOnly: true` to restrict to terminal mode +4. Command automatically appears in autocomplete (filtered by mode) **Current commands:** - `/clear` - Clears output history for current mode (AI or terminal) -- `/jump` - Jumps to current working directory in file tree, expanding parent folders and focusing the tree +- `/jump` - Jumps to current working directory in file tree (terminal mode only) ### Layer Stack System diff --git a/README.md b/README.md index 2df4a9c7..217a8291 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Download the latest release for your platform from the [Releases](https://github - 📝 **Scratchpad** - Built-in markdown editor with live preview - ⚡ **Slash Commands** - Extensible command system with autocomplete - 🌐 **Remote Access** - Built-in web server with optional ngrok/Cloudflare tunneling +- 💰 **Cost Tracking** - Real-time token usage and cost tracking per session > **Note**: Maestro currently supports Claude Code only. Support for other agentic coding tools may be added in future releases based on community demand. @@ -84,6 +85,7 @@ Each session shows a color-coded status indicator: | Go to History Tab | `Cmd+Shift+H` | `Ctrl+Shift+H` | | Go to Scratchpad | `Cmd+Shift+S` | `Ctrl+Shift+S` | | Toggle Markdown Raw/Preview | `Cmd+E` | `Ctrl+E` | +| Insert Checkbox (Scratchpad) | `Cmd+L` | `Ctrl+L` | ### Input & Output @@ -130,7 +132,7 @@ Maestro includes an extensible slash command system with autocomplete: | Command | Description | |---------|-------------| | `/clear` | Clear the output history for the current mode | -| `/jump` | Jump to current working directory in file tree | +| `/jump` | Jump to current working directory in file tree (terminal mode only) | Type `/` in the input area to open the autocomplete menu, use arrow keys to navigate, and press `Tab` or `Enter` to select. diff --git a/src/main/index.ts b/src/main/index.ts index ea558727..354ecefc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -999,5 +999,18 @@ function setupProcessListeners() { processManager.on('command-exit', (sessionId: string, code: number) => { mainWindow?.webContents.send('process:command-exit', sessionId, code); }); + + // Handle usage statistics from AI responses + processManager.on('usage', (sessionId: string, usageStats: { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; + }) => { + console.log('[IPC] Forwarding process:usage to renderer:', { sessionId, usageStats }); + mainWindow?.webContents.send('process:usage', sessionId, usageStats); + }); } } diff --git a/src/main/preload.ts b/src/main/preload.ts index 876028bf..2eb69670 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -95,6 +95,19 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.on('process:command-exit', handler); return () => ipcRenderer.removeListener('process:command-exit', handler); }, + // Usage statistics listener for AI responses + onUsage: (callback: (sessionId: string, usageStats: { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; + }) => void) => { + const handler = (_: any, sessionId: string, usageStats: any) => callback(sessionId, usageStats); + ipcRenderer.on('process:usage', handler); + return () => ipcRenderer.removeListener('process:usage', handler); + }, }, // Git API @@ -207,6 +220,14 @@ export interface MaestroAPI { onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onCommandExit: (callback: (sessionId: string, code: number) => void) => () => void; + onUsage: (callback: (sessionId: string, usageStats: { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + totalCostUsd: number; + contextWindow: number; + }) => void) => () => void; }; git: { status: (cwd: string) => Promise; diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index cbc23e80..58e0ec06 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -102,7 +102,8 @@ export class ProcessManager extends EventEmitter { if (hasImages && prompt) { // Use stream-json mode for images - prompt will be sent via stdin - finalArgs = [...args, '--input-format', 'stream-json', '--output-format', 'stream-json', '-p']; + // Note: --verbose is required when using --print with --output-format=stream-json + finalArgs = [...args, '--verbose', '--input-format', 'stream-json', '--output-format', 'stream-json', '-p']; } else if (prompt) { // Regular batch mode - prompt as CLI arg // The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --) @@ -368,12 +369,38 @@ export class ProcessManager extends EventEmitter { this.emit('session-id', sessionId, jsonResponse.session_id); } + // Extract and emit usage statistics + if (jsonResponse.usage || jsonResponse.total_cost_usd !== undefined) { + const usage = jsonResponse.usage || {}; + // Extract context window from modelUsage (first model found) + let contextWindow = 200000; // Default for Claude + if (jsonResponse.modelUsage) { + const firstModel = Object.values(jsonResponse.modelUsage)[0] as any; + if (firstModel?.contextWindow) { + contextWindow = firstModel.contextWindow; + } + } + + const usageStats = { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheReadInputTokens: usage.cache_read_input_tokens || 0, + cacheCreationInputTokens: usage.cache_creation_input_tokens || 0, + totalCostUsd: jsonResponse.total_cost_usd || 0, + contextWindow + }; + + console.log('[ProcessManager] Emitting usage stats:', usageStats); + this.emit('usage', sessionId, usageStats); + } + // Emit full response for debugging console.log('[ProcessManager] Batch mode JSON response:', { sessionId, hasResult: !!jsonResponse.result, hasSessionId: !!jsonResponse.session_id, - sessionIdValue: jsonResponse.session_id + sessionIdValue: jsonResponse.session_id, + hasCost: jsonResponse.total_cost_usd !== undefined }); } catch (error) { console.error('[ProcessManager] Failed to parse JSON response:', error); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f0173074..b2fcdd45 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -468,20 +468,27 @@ export default function MaestroConsole() { setSessions(prev => prev.map(s => { if (s.id !== actualSessionId) return s; - // Route to correct log array based on which process exited - const targetLogKey = isFromAi ? 'aiLogs' : 'shellLogs'; - const processType = isFromAi ? 'AI agent' : 'Terminal'; + // For AI agent exits, just update state without adding log entry + // For terminal exits, show the exit code + if (isFromAi) { + return { + ...s, + state: 'idle' as SessionState + }; + } + + // Terminal exit - show exit code const exitLog: LogEntry = { id: generateId(), timestamp: Date.now(), source: 'system', - text: `${processType} process exited with code ${code}` + text: `Terminal process exited with code ${code}` }; return { ...s, state: 'idle' as SessionState, - [targetLogKey]: [...s[targetLogKey], exitLog] + shellLogs: [...s.shellLogs, exitLog] }; })); }); @@ -572,6 +579,40 @@ export default function MaestroConsole() { })); }); + // Handle usage statistics from AI responses + const unsubscribeUsage = window.maestro.process.onUsage((sessionId: string, usageStats) => { + console.log('[onUsage] Received usage stats:', usageStats, 'for session:', sessionId); + + // Parse sessionId to get actual session ID (handles -ai suffix) + let actualSessionId: string; + if (sessionId.endsWith('-ai')) { + actualSessionId = sessionId.slice(0, -3); + } else { + actualSessionId = sessionId; + } + + setSessions(prev => prev.map(s => { + if (s.id !== actualSessionId) return s; + + // Calculate total tokens used for context percentage + const totalTokens = usageStats.inputTokens + usageStats.outputTokens + + usageStats.cacheReadInputTokens + usageStats.cacheCreationInputTokens; + const contextPercentage = Math.min(Math.round((totalTokens / usageStats.contextWindow) * 100), 100); + + // Accumulate cost if there's already usage stats + const existingCost = s.usageStats?.totalCostUsd || 0; + + return { + ...s, + contextUsage: contextPercentage, + usageStats: { + ...usageStats, + totalCostUsd: existingCost + usageStats.totalCostUsd + } + }; + })); + }); + // Cleanup listeners on unmount return () => { unsubscribeData(); @@ -579,6 +620,7 @@ export default function MaestroConsole() { unsubscribeSessionId(); unsubscribeStderr(); unsubscribeCommandExit(); + unsubscribeUsage(); }; }, []); diff --git a/src/renderer/components/AgentSessionsBrowser.tsx b/src/renderer/components/AgentSessionsBrowser.tsx index 91ce2fc1..43c0cd93 100644 --- a/src/renderer/components/AgentSessionsBrowser.tsx +++ b/src/renderer/components/AgentSessionsBrowser.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { Search, Clock, MessageSquare, HardDrive, Play, ChevronLeft, Loader2, Plus, X, List, Database, BarChart3, ChevronDown, User, Bot } from 'lucide-react'; +import { Search, Clock, MessageSquare, HardDrive, Play, ChevronLeft, Loader2, Plus, X, List, Database, BarChart3, ChevronDown, User, Bot, DollarSign } from 'lucide-react'; import type { Theme, Session, LogEntry } from '../types'; +import { useLayerStack } from '../contexts/LayerStackContext'; +import { MODAL_PRIORITIES } from '../constants/modalPriorities'; type SearchMode = 'title' | 'user' | 'assistant' | 'all'; @@ -67,6 +69,53 @@ export function AgentSessionsBrowser({ const messagesContainerRef = useRef(null); const searchModeDropdownRef = useRef(null); const searchTimeoutRef = useRef(null); + const layerIdRef = useRef(); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const viewingSessionRef = useRef(viewingSession); + viewingSessionRef.current = viewingSession; + + const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); + + // Register layer on mount for Escape key handling + useEffect(() => { + layerIdRef.current = registerLayer({ + type: 'modal', + priority: MODAL_PRIORITIES.AGENT_SESSIONS, + blocksLowerLayers: true, + capturesFocus: true, + focusTrap: 'lenient', + ariaLabel: 'Agent Sessions Browser', + onEscape: () => { + if (viewingSessionRef.current) { + setViewingSession(null); + setMessages([]); + } else { + onCloseRef.current(); + } + }, + }); + + return () => { + if (layerIdRef.current) { + unregisterLayer(layerIdRef.current); + } + }; + }, [registerLayer, unregisterLayer]); + + // Update handler when viewingSession changes + useEffect(() => { + if (layerIdRef.current) { + updateLayerHandler(layerIdRef.current, () => { + if (viewingSessionRef.current) { + setViewingSession(null); + setMessages([]); + } else { + onCloseRef.current(); + } + }); + } + }, [viewingSession, updateLayerHandler]); // Load sessions on mount useEffect(() => { @@ -508,6 +557,14 @@ export function AgentSessionsBrowser({ {formatSize(stats.totalSize)} + {activeSession?.usageStats && activeSession.usageStats.totalCostUsd > 0 && ( +
+ + + ${activeSession.usageStats.totalCostUsd.toFixed(2)} + +
+ )} {stats.oldestSession && (
diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index 1cf5d1b2..f06e54db 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -157,11 +157,9 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) { {/* Header with CWD and controls */}
{session.cwd} diff --git a/src/renderer/components/InputArea.tsx b/src/renderer/components/InputArea.tsx index 1a4dfff3..b6d8186b 100644 --- a/src/renderer/components/InputArea.tsx +++ b/src/renderer/components/InputArea.tsx @@ -5,6 +5,7 @@ import type { Session, Theme } from '../types'; interface SlashCommand { command: string; description: string; + terminalOnly?: boolean; } interface InputAreaProps { @@ -50,10 +51,14 @@ export function InputArea(props: InputAreaProps) { toggleInputMode, processInput, handleInterrupt, onInputFocus } = props; - // Filter slash commands based on input - const filteredSlashCommands = slashCommands.filter(cmd => - cmd.command.toLowerCase().startsWith(inputValue.toLowerCase()) - ); + // Filter slash commands based on input and current mode + const isTerminalMode = session.inputMode === 'terminal'; + const filteredSlashCommands = slashCommands.filter(cmd => { + // Check if command is only available in terminal mode + if (cmd.terminalOnly && !isTerminalMode) return false; + // Check if command matches input + return cmd.command.toLowerCase().startsWith(inputValue.toLowerCase()); + }); return (
@@ -204,11 +209,21 @@ export function InputArea(props: InputAreaProps) {
-