mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 00:21:21 +00:00
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:
49
BACKBURNER.md
Normal file
49
BACKBURNER.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const searchModeDropdownRef = useRef<HTMLDivElement>(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
|
||||
useEffect(() => {
|
||||
@@ -508,6 +557,14 @@ export function AgentSessionsBrowser({
|
||||
{formatSize(stats.totalSize)}
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
|
||||
@@ -157,11 +157,9 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) {
|
||||
|
||||
{/* Header with CWD and controls */}
|
||||
<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={{
|
||||
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'
|
||||
backgroundColor: theme.colors.bgSidebar
|
||||
}}
|
||||
>
|
||||
<span className="opacity-50">{session.cwd}</span>
|
||||
|
||||
@@ -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 (
|
||||
<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-1 relative border rounded-lg bg-opacity-50 flex flex-col" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent text-sm outline-none p-3 resize-none min-h-[2.5rem] max-h-[8rem] scrollbar-thin"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
placeholder={session.inputMode === 'terminal' ? "Run shell command..." : "Ask Claude..."}
|
||||
<div className="flex items-start">
|
||||
{/* Terminal mode prefix */}
|
||||
{isTerminalMode && (
|
||||
<span
|
||||
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}
|
||||
onFocus={onInputFocus}
|
||||
onChange={e => {
|
||||
@@ -232,12 +247,13 @@ export function InputArea(props: InputAreaProps) {
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
rows={1}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center px-2 pb-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
{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\/[^\/]+/, '~') || '~'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -107,6 +107,8 @@ export function MainPanel(props: MainPanelProps) {
|
||||
|
||||
// Tunnel tooltip hover state
|
||||
const [tunnelTooltipOpen, setTunnelTooltipOpen] = useState(false);
|
||||
// Context window tooltip hover state
|
||||
const [contextTooltipOpen, setContextTooltipOpen] = useState(false);
|
||||
|
||||
// Handler for input focus - select session in sidebar
|
||||
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'}`}>
|
||||
{activeSession.isGitRepo ? 'GIT' : 'LOCAL'}
|
||||
</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 className="relative">
|
||||
@@ -270,9 +263,32 @@ export function MainPanel(props: MainPanelProps) {
|
||||
theme={theme}
|
||||
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 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>
|
||||
<div className="w-24 h-1.5 rounded-full mt-1 overflow-hidden" style={{ backgroundColor: theme.colors.border }}>
|
||||
<div
|
||||
@@ -283,6 +299,74 @@ export function MainPanel(props: MainPanelProps) {
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
<button onClick={() => setAboutModalOpen(true)} className="p-2 rounded hover:bg-white/5" title="About Maestro">
|
||||
|
||||
@@ -101,6 +101,42 @@ export function Scratchpad({
|
||||
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) {
|
||||
const textarea = e.currentTarget;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
|
||||
@@ -520,6 +520,16 @@ export function SessionList(props: SessionListProps) {
|
||||
</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 }}>
|
||||
<Folder className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">{session.cwd}</span>
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { AgentConfig, Theme, Shortcut, ShellInfo } from '../types';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -133,7 +138,9 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
if (!isOpen) return;
|
||||
|
||||
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);
|
||||
|
||||
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 }}>
|
||||
<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}>
|
||||
Shortcuts
|
||||
<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>
|
||||
)}
|
||||
|
||||
{activeTab === 'llm' && (
|
||||
{activeTab === 'llm' && FEATURE_FLAGS.LLM_SETTINGS && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs font-bold opacity-70 uppercase mb-2">LLM Provider</label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface SlashCommand {
|
||||
command: string;
|
||||
description: string;
|
||||
terminalOnly?: boolean; // Only show this command in terminal mode
|
||||
execute: (context: SlashCommandContext) => void;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,7 @@ export const slashCommands: SlashCommand[] = [
|
||||
{
|
||||
command: '/jump',
|
||||
description: 'Jump to CWD in file tree',
|
||||
terminalOnly: true, // Only available in terminal mode
|
||||
execute: (context: SlashCommandContext) => {
|
||||
const { activeSessionId, sessions, setSessions, setRightPanelOpen, setActiveRightTab, setActiveFocus } = context;
|
||||
|
||||
|
||||
@@ -60,6 +60,16 @@ export interface WorkLogItem {
|
||||
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 {
|
||||
id: string;
|
||||
groupId?: string;
|
||||
@@ -73,6 +83,8 @@ export interface Session {
|
||||
workLog: WorkLogItem[];
|
||||
scratchPadContent: string;
|
||||
contextUsage: number;
|
||||
// Usage statistics from AI responses
|
||||
usageStats?: UsageStats;
|
||||
inputMode: 'terminal' | 'ai';
|
||||
// Dual-process PIDs: each session has both AI and terminal processes
|
||||
aiPid: number;
|
||||
|
||||
Reference in New Issue
Block a user