feat: add usage stats tracking, UI polish, and LLM settings feature flag

- Add real-time token usage and cost tracking from Claude Code responses
  - New UsageStats type with tokens, cost, and context window
  - Context window tooltip shows detailed token breakdown
  - Cost displayed in main panel header and session list

- Improve input area UX with $ prefix for terminal mode

- Add Cmd+L shortcut to insert markdown checkbox in Scratchpad

- Add terminalOnly flag for slash commands (/jump is terminal-only)

- Disable LLM Settings panel behind feature flag (documented in BACKBURNER.md)

- Fix AgentSessionsBrowser to use layer stack for Escape handling

- Update README with cost tracking feature and keyboard shortcut
- Update CLAUDE.md with process:usage event and usageStats session field
This commit is contained in:
Pedram Amini
2025-11-26 01:42:30 -06:00
parent 70a7662b8c
commit c30c053e0f
16 changed files with 422 additions and 39 deletions

49
BACKBURNER.md Normal file
View File

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

View File

@@ -123,6 +123,7 @@ All process operations go through IPC handlers in `src/main/index.ts`:
Events are emitted back to renderer via: Events are emitted back to renderer via:
- `process:data` - Stdout/stderr output - `process:data` - Stdout/stderr output
- `process:exit` - Process exit code - `process:exit` - Process exit code
- `process:usage` - Usage statistics from AI responses (tokens, cost, context window)
### Session Model ### Session Model
@@ -134,6 +135,7 @@ Each "session" is a unified abstraction running **two processes simultaneously**
- `inputMode` - Input routing mode ('terminal' or 'ai') - `inputMode` - Input routing mode ('terminal' or 'ai')
- `aiPid` - Process ID for the AI agent process - `aiPid` - Process ID for the AI agent process
- `terminalPid` - Process ID for the terminal 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`. 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 { export interface SlashCommand {
command: string; // The command string (e.g., "/clear") command: string; // The command string (e.g., "/clear")
description: string; // Human-readable description description: string; // Human-readable description
terminalOnly?: boolean; // Only show in terminal mode (optional)
execute: (context: SlashCommandContext) => void; // Command handler execute: (context: SlashCommandContext) => void; // Command handler
} }
``` ```
@@ -236,15 +239,17 @@ export interface SlashCommand {
- Keyboard navigation with arrow keys, Tab/Enter to select - Keyboard navigation with arrow keys, Tab/Enter to select
- Commands receive execution context (activeSessionId, sessions, setSessions, currentMode) - Commands receive execution context (activeSessionId, sessions, setSessions, currentMode)
- Commands can modify session state, trigger actions, or interact with IPC - Commands can modify session state, trigger actions, or interact with IPC
- Commands can be mode-specific using `terminalOnly: true` flag
**Adding new commands:** **Adding new commands:**
1. Add new entry to `slashCommands` array in `src/renderer/slashCommands.ts` 1. Add new entry to `slashCommands` array in `src/renderer/slashCommands.ts`
2. Implement `execute` function with desired behavior 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:** **Current commands:**
- `/clear` - Clears output history for current mode (AI or terminal) - `/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 ### Layer Stack System

View File

@@ -31,6 +31,7 @@ Download the latest release for your platform from the [Releases](https://github
- 📝 **Scratchpad** - Built-in markdown editor with live preview - 📝 **Scratchpad** - Built-in markdown editor with live preview
-**Slash Commands** - Extensible command system with autocomplete -**Slash Commands** - Extensible command system with autocomplete
- 🌐 **Remote Access** - Built-in web server with optional ngrok/Cloudflare tunneling - 🌐 **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. > **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 History Tab | `Cmd+Shift+H` | `Ctrl+Shift+H` |
| Go to Scratchpad | `Cmd+Shift+S` | `Ctrl+Shift+S` | | Go to Scratchpad | `Cmd+Shift+S` | `Ctrl+Shift+S` |
| Toggle Markdown Raw/Preview | `Cmd+E` | `Ctrl+E` | | Toggle Markdown Raw/Preview | `Cmd+E` | `Ctrl+E` |
| Insert Checkbox (Scratchpad) | `Cmd+L` | `Ctrl+L` |
### Input & Output ### Input & Output
@@ -130,7 +132,7 @@ Maestro includes an extensible slash command system with autocomplete:
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `/clear` | Clear the output history for the current mode | | `/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. Type `/` in the input area to open the autocomplete menu, use arrow keys to navigate, and press `Tab` or `Enter` to select.

View File

@@ -999,5 +999,18 @@ function setupProcessListeners() {
processManager.on('command-exit', (sessionId: string, code: number) => { processManager.on('command-exit', (sessionId: string, code: number) => {
mainWindow?.webContents.send('process:command-exit', sessionId, code); 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);
});
} }
} }

View File

@@ -95,6 +95,19 @@ contextBridge.exposeInMainWorld('maestro', {
ipcRenderer.on('process:command-exit', handler); ipcRenderer.on('process:command-exit', handler);
return () => ipcRenderer.removeListener('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 // Git API
@@ -207,6 +220,14 @@ export interface MaestroAPI {
onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void; onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void;
onStderr: (callback: (sessionId: string, data: string) => void) => () => void; onStderr: (callback: (sessionId: string, data: string) => void) => () => void;
onCommandExit: (callback: (sessionId: string, code: number) => 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: { git: {
status: (cwd: string) => Promise<string>; status: (cwd: string) => Promise<string>;

View File

@@ -102,7 +102,8 @@ export class ProcessManager extends EventEmitter {
if (hasImages && prompt) { if (hasImages && prompt) {
// Use stream-json mode for images - prompt will be sent via stdin // 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) { } else if (prompt) {
// Regular batch mode - prompt as CLI arg // Regular batch mode - prompt as CLI arg
// The -- ensures prompt is treated as positional arg, not a flag (even if it starts with --) // 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); 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 // Emit full response for debugging
console.log('[ProcessManager] Batch mode JSON response:', { console.log('[ProcessManager] Batch mode JSON response:', {
sessionId, sessionId,
hasResult: !!jsonResponse.result, hasResult: !!jsonResponse.result,
hasSessionId: !!jsonResponse.session_id, hasSessionId: !!jsonResponse.session_id,
sessionIdValue: jsonResponse.session_id sessionIdValue: jsonResponse.session_id,
hasCost: jsonResponse.total_cost_usd !== undefined
}); });
} catch (error) { } catch (error) {
console.error('[ProcessManager] Failed to parse JSON response:', error); console.error('[ProcessManager] Failed to parse JSON response:', error);

View File

@@ -468,20 +468,27 @@ export default function MaestroConsole() {
setSessions(prev => prev.map(s => { setSessions(prev => prev.map(s => {
if (s.id !== actualSessionId) return s; if (s.id !== actualSessionId) return s;
// Route to correct log array based on which process exited // For AI agent exits, just update state without adding log entry
const targetLogKey = isFromAi ? 'aiLogs' : 'shellLogs'; // For terminal exits, show the exit code
const processType = isFromAi ? 'AI agent' : 'Terminal'; if (isFromAi) {
return {
...s,
state: 'idle' as SessionState
};
}
// Terminal exit - show exit code
const exitLog: LogEntry = { const exitLog: LogEntry = {
id: generateId(), id: generateId(),
timestamp: Date.now(), timestamp: Date.now(),
source: 'system', source: 'system',
text: `${processType} process exited with code ${code}` text: `Terminal process exited with code ${code}`
}; };
return { return {
...s, ...s,
state: 'idle' as SessionState, 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 // Cleanup listeners on unmount
return () => { return () => {
unsubscribeData(); unsubscribeData();
@@ -579,6 +620,7 @@ export default function MaestroConsole() {
unsubscribeSessionId(); unsubscribeSessionId();
unsubscribeStderr(); unsubscribeStderr();
unsubscribeCommandExit(); unsubscribeCommandExit();
unsubscribeUsage();
}; };
}, []); }, []);

View File

@@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; 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 type { Theme, Session, LogEntry } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
type SearchMode = 'title' | 'user' | 'assistant' | 'all'; type SearchMode = 'title' | 'user' | 'assistant' | 'all';
@@ -67,6 +69,53 @@ export function AgentSessionsBrowser({
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const searchModeDropdownRef = useRef<HTMLDivElement>(null); const searchModeDropdownRef = useRef<HTMLDivElement>(null);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null); const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const layerIdRef = useRef<string>();
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 // Load sessions on mount
useEffect(() => { useEffect(() => {
@@ -508,6 +557,14 @@ export function AgentSessionsBrowser({
{formatSize(stats.totalSize)} {formatSize(stats.totalSize)}
</span> </span>
</div> </div>
{activeSession?.usageStats && activeSession.usageStats.totalCostUsd > 0 && (
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4" style={{ color: theme.colors.success }} />
<span className="text-xs font-medium font-mono" style={{ color: theme.colors.success }}>
${activeSession.usageStats.totalCostUsd.toFixed(2)}
</span>
</div>
)}
{stats.oldestSession && ( {stats.oldestSession && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="w-4 h-4" style={{ color: theme.colors.textDim }} /> <Clock className="w-4 h-4" style={{ color: theme.colors.textDim }} />

View File

@@ -157,11 +157,9 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) {
{/* Header with CWD and controls */} {/* Header with CWD and controls */}
<div <div
className="sticky top-0 z-10 flex items-center justify-between text-xs font-bold pt-4 pb-2 mb-2 -mx-4 px-4" className="sticky top-0 z-10 flex items-center justify-between text-xs font-bold pt-4 pb-2 mb-2"
style={{ style={{
backgroundColor: theme.colors.bgSidebar, backgroundColor: theme.colors.bgSidebar
borderLeft: activeFocus === 'right' && activeRightTab === 'files' ? `1px solid ${theme.colors.accent}` : 'none',
borderRight: activeFocus === 'right' && activeRightTab === 'files' ? `1px solid ${theme.colors.accent}` : 'none'
}} }}
> >
<span className="opacity-50">{session.cwd}</span> <span className="opacity-50">{session.cwd}</span>

View File

@@ -5,6 +5,7 @@ import type { Session, Theme } from '../types';
interface SlashCommand { interface SlashCommand {
command: string; command: string;
description: string; description: string;
terminalOnly?: boolean;
} }
interface InputAreaProps { interface InputAreaProps {
@@ -50,10 +51,14 @@ export function InputArea(props: InputAreaProps) {
toggleInputMode, processInput, handleInterrupt, onInputFocus toggleInputMode, processInput, handleInterrupt, onInputFocus
} = props; } = props;
// Filter slash commands based on input // Filter slash commands based on input and current mode
const filteredSlashCommands = slashCommands.filter(cmd => const isTerminalMode = session.inputMode === 'terminal';
cmd.command.toLowerCase().startsWith(inputValue.toLowerCase()) 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 ( return (
<div className="relative p-4 border-t" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgSidebar }}> <div className="relative p-4 border-t" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgSidebar }}>
@@ -204,11 +209,21 @@ export function InputArea(props: InputAreaProps) {
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-1 relative border rounded-lg bg-opacity-50 flex flex-col" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}> <div className="flex-1 relative border rounded-lg bg-opacity-50 flex flex-col" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
<textarea <div className="flex items-start">
ref={inputRef} {/* Terminal mode prefix */}
className="w-full bg-transparent text-sm outline-none p-3 resize-none min-h-[2.5rem] max-h-[8rem] scrollbar-thin" {isTerminalMode && (
style={{ color: theme.colors.textMain }} <span
placeholder={session.inputMode === 'terminal' ? "Run shell command..." : "Ask Claude..."} className="text-sm font-mono font-bold select-none pl-3 pt-3"
style={{ color: theme.colors.accent }}
>
$
</span>
)}
<textarea
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] max-h-[8rem] scrollbar-thin`}
style={{ color: theme.colors.textMain }}
placeholder={isTerminalMode ? "Run shell command..." : "Ask Claude..."}
value={inputValue} value={inputValue}
onFocus={onInputFocus} onFocus={onInputFocus}
onChange={e => { onChange={e => {
@@ -232,12 +247,13 @@ export function InputArea(props: InputAreaProps) {
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={e => e.preventDefault()} onDragOver={e => e.preventDefault()}
rows={1} rows={1}
/> />
</div>
<div className="flex justify-between items-center px-2 pb-2"> <div className="flex justify-between items-center px-2 pb-2">
<div className="flex gap-1 items-center"> <div className="flex gap-1 items-center">
{session.inputMode === 'terminal' && ( {session.inputMode === 'terminal' && (
<div className="text-[10px] font-mono opacity-50 px-2" style={{ color: theme.colors.textDim }}> <div className="text-xs font-mono opacity-60 px-2" style={{ color: theme.colors.textDim }}>
{(session.shellCwd || session.cwd)?.replace(/^\/Users\/[^\/]+/, '~') || '~'} {(session.shellCwd || session.cwd)?.replace(/^\/Users\/[^\/]+/, '~') || '~'}
</div> </div>
)} )}

View File

@@ -107,6 +107,8 @@ export function MainPanel(props: MainPanelProps) {
// Tunnel tooltip hover state // Tunnel tooltip hover state
const [tunnelTooltipOpen, setTunnelTooltipOpen] = useState(false); const [tunnelTooltipOpen, setTunnelTooltipOpen] = useState(false);
// Context window tooltip hover state
const [contextTooltipOpen, setContextTooltipOpen] = useState(false);
// Handler for input focus - select session in sidebar // Handler for input focus - select session in sidebar
const handleInputFocus = () => { const handleInputFocus = () => {
@@ -198,15 +200,6 @@ export function MainPanel(props: MainPanelProps) {
<span className={`text-xs px-2 py-0.5 rounded-full border ${activeSession.isGitRepo ? 'border-orange-500/30 text-orange-500 bg-orange-500/10' : 'border-blue-500/30 text-blue-500 bg-blue-500/10'}`}> <span className={`text-xs px-2 py-0.5 rounded-full border ${activeSession.isGitRepo ? 'border-orange-500/30 text-orange-500 bg-orange-500/10' : 'border-blue-500/30 text-blue-500 bg-blue-500/10'}`}>
{activeSession.isGitRepo ? 'GIT' : 'LOCAL'} {activeSession.isGitRepo ? 'GIT' : 'LOCAL'}
</span> </span>
{activeSession.inputMode === 'ai' && activeSession.claudeSessionId && (
<span
className="text-[10px] font-mono font-bold px-1.5 py-0.5 rounded"
style={{ backgroundColor: theme.colors.accent + '20', color: theme.colors.accent }}
title={`Session ID: ${activeSession.claudeSessionId}`}
>
{activeSession.claudeSessionId.split('-')[0].toUpperCase()}
</span>
)}
</div> </div>
<div className="relative"> <div className="relative">
@@ -270,9 +263,32 @@ export function MainPanel(props: MainPanelProps) {
theme={theme} theme={theme}
onViewDiff={handleViewGitDiff} onViewDiff={handleViewGitDiff}
/> />
{/* Session ID - moved after Git Status */}
{activeSession.inputMode === 'ai' && activeSession.claudeSessionId && (
<span
className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full border"
style={{ backgroundColor: theme.colors.accent + '20', color: theme.colors.accent, borderColor: theme.colors.accent + '30' }}
title={`Session ID: ${activeSession.claudeSessionId}`}
>
{activeSession.claudeSessionId.split('-')[0].toUpperCase()}
</span>
)}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex flex-col items-end mr-2"> {/* Cost Tracker - styled as pill */}
{activeSession.inputMode === 'ai' && activeSession.usageStats && activeSession.usageStats.totalCostUsd > 0 && (
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded-full border border-green-500/30 text-green-500 bg-green-500/10">
${activeSession.usageStats.totalCostUsd.toFixed(2)}
</span>
)}
{/* Context Window Widget with Tooltip */}
<div
className="flex flex-col items-end mr-2 relative cursor-pointer"
onMouseEnter={() => setContextTooltipOpen(true)}
onMouseLeave={() => setContextTooltipOpen(false)}
>
<span className="text-[10px] font-bold uppercase" style={{ color: theme.colors.textDim }}>Context Window</span> <span className="text-[10px] font-bold uppercase" style={{ color: theme.colors.textDim }}>Context Window</span>
<div className="w-24 h-1.5 rounded-full mt-1 overflow-hidden" style={{ backgroundColor: theme.colors.border }}> <div className="w-24 h-1.5 rounded-full mt-1 overflow-hidden" style={{ backgroundColor: theme.colors.border }}>
<div <div
@@ -283,6 +299,74 @@ export function MainPanel(props: MainPanelProps) {
}} }}
/> />
</div> </div>
{/* Context Window Tooltip */}
{contextTooltipOpen && activeSession.inputMode === 'ai' && activeSession.usageStats && (
<div
className="absolute top-full right-0 mt-2 w-64 border rounded-lg p-3 shadow-xl z-50"
style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}
onMouseEnter={() => setContextTooltipOpen(true)}
onMouseLeave={() => setContextTooltipOpen(false)}
>
<div className="text-[10px] uppercase font-bold mb-3" style={{ color: theme.colors.textDim }}>Context Details</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-xs" style={{ color: theme.colors.textDim }}>Input Tokens</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{activeSession.usageStats.inputTokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs" style={{ color: theme.colors.textDim }}>Output Tokens</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{activeSession.usageStats.outputTokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs" style={{ color: theme.colors.textDim }}>Cache Read</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{activeSession.usageStats.cacheReadInputTokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs" style={{ color: theme.colors.textDim }}>Cache Write</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{activeSession.usageStats.cacheCreationInputTokens.toLocaleString()}
</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 }}>Total Tokens</span>
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.accent }}>
{(
activeSession.usageStats.inputTokens +
activeSession.usageStats.outputTokens +
activeSession.usageStats.cacheReadInputTokens +
activeSession.usageStats.cacheCreationInputTokens
).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 }}>
{activeSession.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(activeSession.contextUsage, theme) }}
>
{activeSession.contextUsage}%
</span>
</div>
</div>
</div>
</div>
)}
</div> </div>
<button onClick={() => setAboutModalOpen(true)} className="p-2 rounded hover:bg-white/5" title="About Maestro"> <button onClick={() => setAboutModalOpen(true)} className="p-2 rounded hover:bg-white/5" title="About Maestro">

View File

@@ -101,6 +101,42 @@ export function Scratchpad({
return; return;
} }
// Command-L to insert a markdown checkbox
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
e.preventDefault();
e.stopPropagation();
const textarea = e.currentTarget;
const cursorPos = textarea.selectionStart;
const textBeforeCursor = content.substring(0, cursorPos);
const textAfterCursor = content.substring(cursorPos);
// Check if we're at the start of a line or have text before
const lastNewline = textBeforeCursor.lastIndexOf('\n');
const lineStart = lastNewline === -1 ? 0 : lastNewline + 1;
const textOnCurrentLine = textBeforeCursor.substring(lineStart);
let newContent: string;
let newCursorPos: number;
if (textOnCurrentLine.length === 0) {
// At start of line, just insert checkbox
newContent = textBeforeCursor + '- [ ] ' + textAfterCursor;
newCursorPos = cursorPos + 6; // "- [ ] " is 6 chars
} else {
// In middle of line, insert newline then checkbox
newContent = textBeforeCursor + '\n- [ ] ' + textAfterCursor;
newCursorPos = cursorPos + 7; // "\n- [ ] " is 7 chars
}
onChange(newContent);
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
}
}, 0);
return;
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
const textarea = e.currentTarget; const textarea = e.currentTarget;
const cursorPos = textarea.selectionStart; const cursorPos = textarea.selectionStart;

View File

@@ -520,6 +520,16 @@ export function SessionList(props: SessionListProps) {
</div> </div>
)} )}
{/* Session Cost */}
{session.usageStats && session.usageStats.totalCostUsd > 0 && (
<div className="flex items-center justify-between text-[10px] pt-1">
<span style={{ color: theme.colors.textDim }}>Session Cost</span>
<span className="font-mono font-bold" style={{ color: theme.colors.success }}>
${session.usageStats.totalCostUsd.toFixed(2)}
</span>
</div>
)}
<div className="flex items-center gap-1.5 text-[10px] font-mono pt-1" style={{ color: theme.colors.textDim }}> <div className="flex items-center gap-1.5 text-[10px] font-mono pt-1" style={{ color: theme.colors.textDim }}>
<Folder className="w-3 h-3 shrink-0" /> <Folder className="w-3 h-3 shrink-0" />
<span className="truncate">{session.cwd}</span> <span className="truncate">{session.cwd}</span>

View File

@@ -4,6 +4,11 @@ import type { AgentConfig, Theme, Shortcut, ShellInfo } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext'; import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { MODAL_PRIORITIES } from '../constants/modalPriorities';
// Feature flags - set to true to enable dormant features
const FEATURE_FLAGS = {
LLM_SETTINGS: false, // LLM provider configuration (OpenRouter, Anthropic, Ollama)
};
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
@@ -133,7 +138,9 @@ export function SettingsModal(props: SettingsModalProps) {
if (!isOpen) return; if (!isOpen) return;
const handleTabNavigation = (e: KeyboardEvent) => { const handleTabNavigation = (e: KeyboardEvent) => {
const tabs: Array<'general' | 'llm' | 'shortcuts' | 'theme' | 'network'> = ['general', 'llm', 'shortcuts', 'theme', 'network']; const tabs: Array<'general' | 'llm' | 'shortcuts' | 'theme' | 'network'> = FEATURE_FLAGS.LLM_SETTINGS
? ['general', 'llm', 'shortcuts', 'theme', 'network']
: ['general', 'shortcuts', 'theme', 'network'];
const currentIndex = tabs.indexOf(activeTab); const currentIndex = tabs.indexOf(activeTab);
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '[') { if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === '[') {
@@ -499,7 +506,9 @@ export function SettingsModal(props: SettingsModalProps) {
<div className="flex border-b" style={{ borderColor: theme.colors.border }}> <div className="flex border-b" style={{ borderColor: theme.colors.border }}>
<button onClick={() => setActiveTab('general')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'general' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>General</button> <button onClick={() => setActiveTab('general')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'general' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>General</button>
<button onClick={() => setActiveTab('llm')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'llm' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>LLM</button> {FEATURE_FLAGS.LLM_SETTINGS && (
<button onClick={() => setActiveTab('llm')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'llm' ? 'border-indigo-500' : 'border-transparent'}`} tabIndex={-1}>LLM</button>
)}
<button onClick={() => setActiveTab('shortcuts')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'shortcuts' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1}> <button onClick={() => setActiveTab('shortcuts')} className={`px-6 py-4 text-sm font-bold border-b-2 ${activeTab === 'shortcuts' ? 'border-indigo-500' : 'border-transparent'} flex items-center gap-2`} tabIndex={-1}>
Shortcuts Shortcuts
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}> <span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}>
@@ -1133,7 +1142,7 @@ export function SettingsModal(props: SettingsModalProps) {
</div> </div>
)} )}
{activeTab === 'llm' && ( {activeTab === 'llm' && FEATURE_FLAGS.LLM_SETTINGS && (
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<label className="block text-xs font-bold opacity-70 uppercase mb-2">LLM Provider</label> <label className="block text-xs font-bold opacity-70 uppercase mb-2">LLM Provider</label>

View File

@@ -1,6 +1,7 @@
export interface SlashCommand { export interface SlashCommand {
command: string; command: string;
description: string; description: string;
terminalOnly?: boolean; // Only show this command in terminal mode
execute: (context: SlashCommandContext) => void; execute: (context: SlashCommandContext) => void;
} }
@@ -42,6 +43,7 @@ export const slashCommands: SlashCommand[] = [
{ {
command: '/jump', command: '/jump',
description: 'Jump to CWD in file tree', description: 'Jump to CWD in file tree',
terminalOnly: true, // Only available in terminal mode
execute: (context: SlashCommandContext) => { execute: (context: SlashCommandContext) => {
const { activeSessionId, sessions, setSessions, setRightPanelOpen, setActiveRightTab, setActiveFocus } = context; const { activeSessionId, sessions, setSessions, setRightPanelOpen, setActiveRightTab, setActiveFocus } = context;

View File

@@ -60,6 +60,16 @@ export interface WorkLogItem {
relatedFiles?: number; relatedFiles?: number;
} }
// Usage statistics from Claude Code CLI
export interface UsageStats {
inputTokens: number;
outputTokens: number;
cacheReadInputTokens: number;
cacheCreationInputTokens: number;
totalCostUsd: number;
contextWindow: number; // e.g., 200000 for Claude
}
export interface Session { export interface Session {
id: string; id: string;
groupId?: string; groupId?: string;
@@ -73,6 +83,8 @@ export interface Session {
workLog: WorkLogItem[]; workLog: WorkLogItem[];
scratchPadContent: string; scratchPadContent: string;
contextUsage: number; contextUsage: number;
// Usage statistics from AI responses
usageStats?: UsageStats;
inputMode: 'terminal' | 'ai'; inputMode: 'terminal' | 'ai';
// Dual-process PIDs: each session has both AI and terminal processes // Dual-process PIDs: each session has both AI and terminal processes
aiPid: number; aiPid: number;