From e7a22a257fdeba653cd72bb70bc2c960885e8d20 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 05:44:36 -0600 Subject: [PATCH 01/72] MAESTRO: Fix token tracking to use modelUsage for accurate context window The Claude Code CLI JSON output has two different token count sections: - `usage`: Shows billable/new tokens only (excludes cache hits) - `modelUsage`: Contains per-model breakdown with actual context tokens Previously, we were reading from `usage.input_tokens` which showed only new tokens (e.g., 2 tokens) instead of the full context (e.g., 3,241 tokens). This fix aggregates token counts from modelUsage to properly track: - Total input tokens in context (including cache reads) - Total output tokens - Cache read and creation tokens - Context window size from each model The fix applies to both batch mode JSON parsing and stream-json mode. Falls back to top-level usage if modelUsage isn't available for backwards compatibility with older CLI versions. --- src/main/process-manager.ts | 81 ++++++++++++++++++++++++++++--------- 1 file changed, 63 insertions(+), 18 deletions(-) diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index 86d40db0e..81ea0d1bf 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -313,22 +313,42 @@ export class ProcessManager extends EventEmitter { this.emit('session-id', sessionId, msg.session_id); } // Extract usage statistics from stream-json messages (typically in 'result' type) - if (msg.usage || msg.total_cost_usd !== undefined) { + // Note: We need to aggregate token counts from modelUsage for accurate context window tracking + if (msg.modelUsage || msg.usage || msg.total_cost_usd !== undefined) { const usage = msg.usage || {}; - // Extract context window from modelUsage if present + + // Aggregate token counts from modelUsage for accurate context tracking + let aggregatedInputTokens = 0; + let aggregatedOutputTokens = 0; + let aggregatedCacheReadTokens = 0; + let aggregatedCacheCreationTokens = 0; let contextWindow = 200000; // Default for Claude + if (msg.modelUsage) { - const firstModel = Object.values(msg.modelUsage)[0] as any; - if (firstModel?.contextWindow) { - contextWindow = firstModel.contextWindow; + for (const modelStats of Object.values(msg.modelUsage) as any[]) { + aggregatedInputTokens += modelStats.inputTokens || 0; + aggregatedOutputTokens += modelStats.outputTokens || 0; + aggregatedCacheReadTokens += modelStats.cacheReadInputTokens || 0; + aggregatedCacheCreationTokens += modelStats.cacheCreationInputTokens || 0; + if (modelStats.contextWindow && modelStats.contextWindow > contextWindow) { + contextWindow = modelStats.contextWindow; + } } } + // Fall back to top-level usage if modelUsage isn't available + if (aggregatedInputTokens === 0 && aggregatedOutputTokens === 0) { + aggregatedInputTokens = usage.input_tokens || 0; + aggregatedOutputTokens = usage.output_tokens || 0; + aggregatedCacheReadTokens = usage.cache_read_input_tokens || 0; + aggregatedCacheCreationTokens = usage.cache_creation_input_tokens || 0; + } + 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, + inputTokens: aggregatedInputTokens, + outputTokens: aggregatedOutputTokens, + cacheReadInputTokens: aggregatedCacheReadTokens, + cacheCreationInputTokens: aggregatedCacheCreationTokens, totalCostUsd: msg.total_cost_usd || 0, contextWindow }; @@ -394,22 +414,47 @@ export class ProcessManager extends EventEmitter { } // Extract and emit usage statistics - if (jsonResponse.usage || jsonResponse.total_cost_usd !== undefined) { + // Note: We need to aggregate token counts from modelUsage for accurate context window tracking + // The top-level usage object shows billable/new tokens, not total context tokens + if (jsonResponse.modelUsage || jsonResponse.usage || jsonResponse.total_cost_usd !== undefined) { const usage = jsonResponse.usage || {}; - // Extract context window from modelUsage (first model found) + + // Aggregate token counts from modelUsage for accurate context tracking + // modelUsage contains per-model breakdown with actual context tokens (including cache hits) + let aggregatedInputTokens = 0; + let aggregatedOutputTokens = 0; + let aggregatedCacheReadTokens = 0; + let aggregatedCacheCreationTokens = 0; let contextWindow = 200000; // Default for Claude + if (jsonResponse.modelUsage) { - const firstModel = Object.values(jsonResponse.modelUsage)[0] as any; - if (firstModel?.contextWindow) { - contextWindow = firstModel.contextWindow; + for (const modelStats of Object.values(jsonResponse.modelUsage) as any[]) { + // inputTokens in modelUsage includes the full context (not just new tokens) + aggregatedInputTokens += modelStats.inputTokens || 0; + aggregatedOutputTokens += modelStats.outputTokens || 0; + aggregatedCacheReadTokens += modelStats.cacheReadInputTokens || 0; + aggregatedCacheCreationTokens += modelStats.cacheCreationInputTokens || 0; + // Use the highest context window from any model + if (modelStats.contextWindow && modelStats.contextWindow > contextWindow) { + contextWindow = modelStats.contextWindow; + } } } + // Fall back to top-level usage if modelUsage isn't available + // This handles older CLI versions or different output formats + if (aggregatedInputTokens === 0 && aggregatedOutputTokens === 0) { + aggregatedInputTokens = usage.input_tokens || 0; + aggregatedOutputTokens = usage.output_tokens || 0; + aggregatedCacheReadTokens = usage.cache_read_input_tokens || 0; + aggregatedCacheCreationTokens = usage.cache_creation_input_tokens || 0; + } + 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, + inputTokens: aggregatedInputTokens, + outputTokens: aggregatedOutputTokens, + cacheReadInputTokens: aggregatedCacheReadTokens, + cacheCreationInputTokens: aggregatedCacheCreationTokens, totalCostUsd: jsonResponse.total_cost_usd || 0, contextWindow }; From e417433573313dd27f324092537966d13daf2549 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 05:46:36 -0600 Subject: [PATCH 02/72] MAESTRO: Disable scratchpad Run button when AI agent is thinking The Run button in the scratchpad panel is now greyed out when the session state is 'busy' or 'connecting', preventing users from starting batch runs while the agent may be making changes. --- src/renderer/components/RightPanel.tsx | 1 + src/renderer/components/Scratchpad.tsx | 13 +++++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 5c1876dff..13e3dc0b7 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -234,6 +234,7 @@ export const RightPanel = forwardRef(function batchRunState={batchRunState} onOpenBatchRunner={onOpenBatchRunner} onStopBatchRun={onStopBatchRun} + sessionState={session.state} /> )} diff --git a/src/renderer/components/Scratchpad.tsx b/src/renderer/components/Scratchpad.tsx index 93a5d1263..627ab01df 100644 --- a/src/renderer/components/Scratchpad.tsx +++ b/src/renderer/components/Scratchpad.tsx @@ -4,7 +4,7 @@ import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Eye, Edit, Play, Square, HelpCircle, Loader2 } from 'lucide-react'; -import type { BatchRunState } from '../types'; +import type { BatchRunState, SessionState } from '../types'; import { AutoRunnerHelpModal } from './AutoRunnerHelpModal'; import { MermaidRenderer } from './MermaidRenderer'; @@ -26,6 +26,8 @@ interface ScratchpadProps { batchRunState?: BatchRunState; onOpenBatchRunner?: () => void; onStopBatchRun?: () => void; + // Session state for disabling Run when agent is busy + sessionState?: SessionState; } export function Scratchpad({ @@ -39,9 +41,11 @@ export function Scratchpad({ onStateChange, batchRunState, onOpenBatchRunner, - onStopBatchRun + onStopBatchRun, + sessionState }: ScratchpadProps) { const isLocked = batchRunState?.isRunning || false; + const isAgentBusy = sessionState === 'busy' || sessionState === 'connecting'; const isStopping = batchRunState?.isStopping || false; const [mode, setMode] = useState<'edit' | 'preview'>(initialMode); const [helpModalOpen, setHelpModalOpen] = useState(false); @@ -283,13 +287,14 @@ export function Scratchpad({ ) : ( - {/* Bookmark */} + {/* Star */} {/* Rename */} From be95eee936cb9b4de1e746720e822e234cc054a7 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 06:57:07 -0600 Subject: [PATCH 26/72] MAESTRO: Fix session renaming to persist in Claude session list view When renaming a session from the main panel's session pill, the name was only being saved to local settings (namedClaudeSessions) but not to the backend storage (claudeSessionOriginsStore). This caused renamed sessions to not show their names in the Claude session list view. Now calls window.maestro.claude.updateSessionName() to persist the name to the backend, matching the behavior of renaming from AgentSessionsBrowser. --- src/renderer/components/MainPanel.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index c7f90f465..2e3497612 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -262,9 +262,11 @@ export function MainPanel(props: MainPanelProps) { // Rename current session const saveSessionName = useCallback(async () => { - if (!activeSession?.claudeSessionId) return; + if (!activeSession?.claudeSessionId || !activeSession?.cwd) return; const sessionId = activeSession.claudeSessionId; const name = sessionPillRenameValue.trim(); + + // Update local state for immediate UI feedback const newNamed = { ...namedSessions }; if (name) { newNamed[sessionId] = name; @@ -274,12 +276,20 @@ export function MainPanel(props: MainPanelProps) { setNamedSessions(newNamed); setSessionPillRenaming(false); setSessionPillRenameValue(''); + try { + // Save to backend storage so it shows in Claude session list view + await window.maestro.claude.updateSessionName( + activeSession.cwd, + sessionId, + name + ); + // Also save to local settings for quick lookup in main panel await window.maestro.settings.set('namedClaudeSessions', newNamed); } catch (error) { console.error('Failed to save session name:', error); } - }, [activeSession?.claudeSessionId, sessionPillRenameValue, namedSessions]); + }, [activeSession?.claudeSessionId, activeSession?.cwd, sessionPillRenameValue, namedSessions]); // Close session pill overlay when clicking outside (mainly for rename mode) useEffect(() => { From ac54d4400cef81d780ebb9ee67fb40b95c8e2e8a Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 07:00:11 -0600 Subject: [PATCH 27/72] MAESTRO: Reset activeClaudeSessionId when opening Claude sessions browser When opening the Claude sessions browser from the button, keyboard shortcut, or quick actions modal, the activeClaudeSessionId is now reset to null. This ensures the browser always opens to the list view rather than auto-jumping to a previously viewed session's details pane. The auto-jump behavior is preserved only when explicitly navigating to a specific session (e.g., from the recent sessions overlay), where activeClaudeSessionId is intentionally set before opening the browser. --- src/renderer/App.tsx | 2 ++ src/renderer/components/MainPanel.tsx | 6 +++++- src/renderer/components/QuickActionsModal.tsx | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index c6ace3803..03bb10fcb 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1805,6 +1805,7 @@ export default function MaestroConsole() { else if (isShortcut(e, 'agentSessions')) { e.preventDefault(); if (activeSession?.toolType === 'claude-code') { + setActiveClaudeSessionId(null); setAgentSessionsOpen(true); } } @@ -3532,6 +3533,7 @@ export default function MaestroConsole() { setProcessMonitorOpen={setProcessMonitorOpen} setActiveRightTab={setActiveRightTab} setAgentSessionsOpen={setAgentSessionsOpen} + setActiveClaudeSessionId={setActiveClaudeSessionId} setGitDiffPreview={setGitDiffPreview} setGitLogOpen={setGitLogOpen} startFreshSession={() => { diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index 2e3497612..b55d3f704 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -824,7 +824,10 @@ export function MainPanel(props: MainPanelProps) { onMouseLeave={() => setSessionsTooltipOpen(false)} > - {/* Delete button for user commands in terminal mode */} - {log.source === 'user' && isTerminal && onDeleteLog && ( + {/* Delete button for user messages (both AI and terminal modes) */} + {log.source === 'user' && onDeleteLog && ( deleteConfirmLogIdRef.current === log.id ? (
Delete? @@ -966,7 +966,7 @@ export const TerminalOutput = forwardRef((p onClick={() => { setDeleteConfirmLogId(log.id); setDeleteConfirmTrigger(t => t + 1); }} className="p-1.5 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100 transition-opacity" style={{ color: theme.colors.textDim }} - title="Delete command and output" + title={isAIMode ? "Delete message and response" : "Delete command and output"} > From 5e95cbade4b9ac527afd2575c69cb8c57011ba18 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 07:19:22 -0600 Subject: [PATCH 32/72] MAESTRO: Show leading bookmark icon only for bookmarked sessions Previously, sessions displayed a Tag icon when they had custom names. Changed to show a filled Bookmark icon only when session.bookmarked is true. Removed the unused hasCustomName function and Tag import. --- src/renderer/components/SessionList.tsx | 58 +++---------------------- 1 file changed, 7 insertions(+), 51 deletions(-) diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 8f13cfe1d..004e87d30 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -2,27 +2,13 @@ import React, { useState, useEffect, useRef } from 'react'; import { Wand2, Plus, Settings, ChevronRight, ChevronDown, Activity, X, Keyboard, Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, FileText, GitBranch, Bot, Clock, - ScrollText, Cpu, Menu, Bookmark, Tag + ScrollText, Cpu, Menu, Bookmark } from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import type { Session, Group, Theme, Shortcut } from '../types'; import { getStatusColor, getContextColor, formatActiveTime } from '../utils/theme'; import { gitService } from '../services/git'; -// Default agent names - sessions with these names are considered "unnamed" -const DEFAULT_AGENT_NAMES = [ - 'Claude Code', - 'Aider (Gemini)', - 'Qwen Coder', - 'CLI Terminal', - 'Terminal', -]; - -// Check if a session has a custom (user-defined) name -const hasCustomName = (session: Session): boolean => { - return !DEFAULT_AGENT_NAMES.includes(session.name); -}; - // Strip leading emojis from a string for alphabetical sorting // Matches common emoji patterns at the start of the string const stripLeadingEmojis = (str: string): string => { @@ -567,8 +553,8 @@ export function SessionList(props: SessionListProps) { className="flex items-center gap-1.5" onDoubleClick={() => startRenamingSession(session.id)} > - {hasCustomName(session) && ( - + {session.bookmarked && ( + )} startRenamingSession(session.id)} > - {hasCustomName(session) && ( - + {session.bookmarked && ( + )}
- {/* Bookmark toggle */} - {/* Git Dirty Indicator (only in wide mode) */} {leftSidebarOpen && session.isGitRepo && gitFileCounts.has(session.id) && gitFileCounts.get(session.id)! > 0 && (
@@ -1085,8 +1056,8 @@ export function SessionList(props: SessionListProps) { className="flex items-center gap-1.5" onDoubleClick={() => startRenamingSession(session.id)} > - {hasCustomName(session) && ( - + {session.bookmarked && ( + )}
- {/* Bookmark toggle */} - {/* Git Dirty Indicator (only in wide mode) */} {leftSidebarOpen && session.isGitRepo && gitFileCounts.has(session.id) && gitFileCounts.get(session.id)! > 0 && (
From 18bb3eb5610fc92505dcd6d51cd61f86ab62d93a Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 07:23:45 -0600 Subject: [PATCH 33/72] MAESTRO: Enable CMD+SHIFT+[] to cycle within bookmarks folder when open Lifted bookmarksCollapsed state from SessionList.tsx to App.tsx so keyboard shortcut handler can access it. When the bookmarks folder is expanded and the current session is bookmarked, CMD+SHIFT+[ and CMD+SHIFT+] now cycle only through bookmarked sessions, treating bookmarks like any other folder. --- src/renderer/App.tsx | 27 ++++++++++++++++++++++++- src/renderer/components/SessionList.tsx | 6 +++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 26fb977cc..b959144bd 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -117,6 +117,7 @@ export default function MaestroConsole() { const [rightPanelOpen, setRightPanelOpen] = useState(true); const [activeRightTab, setActiveRightTab] = useState('files'); const [activeFocus, setActiveFocus] = useState('main'); + const [bookmarksCollapsed, setBookmarksCollapsed] = useState(false); // File Explorer State const [previewFile, setPreviewFile] = useState<{name: string; content: string; path: string} | null>(null); @@ -1819,7 +1820,7 @@ export default function MaestroConsole() { }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [shortcuts, activeFocus, activeRightTab, sessions, selectedSidebarIndex, activeSessionId, quickActionOpen, settingsModalOpen, shortcutsHelpOpen, newInstanceModalOpen, aboutModalOpen, processMonitorOpen, logViewerOpen, createGroupModalOpen, confirmModalOpen, renameInstanceModalOpen, renameGroupModalOpen, activeSession, previewFile, fileTreeFilter, fileTreeFilterOpen, gitDiffPreview, gitLogOpen, lightboxImage, hasOpenLayers, hasOpenModal, visibleSessions, sortedSessions, groups]); + }, [shortcuts, activeFocus, activeRightTab, sessions, selectedSidebarIndex, activeSessionId, quickActionOpen, settingsModalOpen, shortcutsHelpOpen, newInstanceModalOpen, aboutModalOpen, processMonitorOpen, logViewerOpen, createGroupModalOpen, confirmModalOpen, renameInstanceModalOpen, renameGroupModalOpen, activeSession, previewFile, fileTreeFilter, fileTreeFilterOpen, gitDiffPreview, gitLogOpen, lightboxImage, hasOpenLayers, hasOpenModal, visibleSessions, sortedSessions, groups, bookmarksCollapsed, leftSidebarOpen]); // Sync selectedSidebarIndex with activeSessionId // IMPORTANT: Only sync when activeSessionId changes, NOT when sortedSessions changes @@ -1861,6 +1862,28 @@ export default function MaestroConsole() { // --- ACTIONS --- const cycleSession = (dir: 'next' | 'prev') => { + // Check if we should cycle within the bookmarks folder + // This happens when: sidebar is open, bookmarks folder is open, and current session is bookmarked + const activeSession = sessions.find(s => s.id === activeSessionId); + const bookmarkedSessions = sortedSessions.filter(s => s.bookmarked); + const shouldCycleInBookmarks = leftSidebarOpen && + !bookmarksCollapsed && + activeSession?.bookmarked && + bookmarkedSessions.length > 0; + + if (shouldCycleInBookmarks) { + // Cycle only through bookmarked sessions + const currentIndex = bookmarkedSessions.findIndex(s => s.id === activeSessionId); + let nextIndex; + if (dir === 'next') { + nextIndex = currentIndex === bookmarkedSessions.length - 1 ? 0 : currentIndex + 1; + } else { + nextIndex = currentIndex === 0 ? bookmarkedSessions.length - 1 : currentIndex - 1; + } + setActiveSessionId(bookmarkedSessions[nextIndex].id); + return; + } + // When left sidebar is collapsed, cycle through ALL sessions (groups not visible) // When left sidebar is open, only cycle through visible sessions (not in collapsed groups) const visibleSessions = leftSidebarOpen @@ -3696,6 +3719,8 @@ export default function MaestroConsole() { isLiveMode={isLiveMode} webInterfaceUrl={webInterfaceUrl} toggleGlobalLive={toggleGlobalLive} + bookmarksCollapsed={bookmarksCollapsed} + setBookmarksCollapsed={setBookmarksCollapsed} setActiveFocus={setActiveFocus} setActiveSessionId={setActiveSessionId} setLeftSidebarOpen={setLeftSidebarOpen} diff --git a/src/renderer/components/SessionList.tsx b/src/renderer/components/SessionList.tsx index 004e87d30..4b7e9d2e1 100644 --- a/src/renderer/components/SessionList.tsx +++ b/src/renderer/components/SessionList.tsx @@ -50,6 +50,10 @@ interface SessionListProps { webInterfaceUrl: string | null; toggleGlobalLive: () => void; + // Bookmarks folder state (lifted from component to App.tsx for keyboard shortcut access) + bookmarksCollapsed: boolean; + setBookmarksCollapsed: (collapsed: boolean) => void; + // Handlers setActiveFocus: (focus: string) => void; setActiveSessionId: (id: string) => void; @@ -86,6 +90,7 @@ export function SessionList(props: SessionListProps) { leftSidebarWidthState, activeFocus, selectedSidebarIndex, editingGroupId, editingSessionId, draggingSessionId, shortcuts, isLiveMode, webInterfaceUrl, toggleGlobalLive, + bookmarksCollapsed, setBookmarksCollapsed, setActiveFocus, setActiveSessionId, setLeftSidebarOpen, setLeftSidebarWidthState, setShortcutsHelpOpen, setSettingsModalOpen, setSettingsTab, setAboutModalOpen, setLogViewerOpen, setProcessMonitorOpen, toggleGroup, handleDragStart, handleDragOver, handleDropOnGroup, handleDropOnUngrouped, @@ -97,7 +102,6 @@ export function SessionList(props: SessionListProps) { const [sessionFilter, setSessionFilter] = useState(''); const [sessionFilterOpen, setSessionFilterOpen] = useState(false); const [ungroupedCollapsed, setUngroupedCollapsed] = useState(false); - const [bookmarksCollapsed, setBookmarksCollapsed] = useState(false); const [preFilterGroupStates, setPreFilterGroupStates] = useState>(new Map()); const [menuOpen, setMenuOpen] = useState(false); const [liveOverlayOpen, setLiveOverlayOpen] = useState(false); From c103c3981abbcfe22634bf9630fa3264a77d995b Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 07:26:31 -0600 Subject: [PATCH 34/72] MAESTRO: Fix Git log viewer search functionality Re-implemented working search in GitLogViewer with proper focus handling: - Added search input bar with / key shortcut to focus - Filter commits by message, author, hash, or ref - Fixed focus stealing issue by using ref-based initial focus instead of inline ref callback - Added isSearchFocused state to prevent j/k navigation while typing - Escape clears search when focused, closes modal when search is empty - Shows filtered count with "X of Y commits" when filtering - Added X button to clear search with proper mousedown handling --- src/renderer/components/GitLogViewer.tsx | 170 +++++++++++++++++++---- 1 file changed, 142 insertions(+), 28 deletions(-) diff --git a/src/renderer/components/GitLogViewer.tsx b/src/renderer/components/GitLogViewer.tsx index 2b738e2f4..fa8d76d6b 100644 --- a/src/renderer/components/GitLogViewer.tsx +++ b/src/renderer/components/GitLogViewer.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; -import { GitCommit, GitBranch, Tag } from 'lucide-react'; +import { GitCommit, GitBranch, Tag, Search, X } from 'lucide-react'; import type { Theme } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -29,9 +29,14 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { const [selectedIndex, setSelectedIndex] = useState(0); const [selectedCommitDiff, setSelectedCommitDiff] = useState(null); const [loadingDiff, setLoadingDiff] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [isSearchFocused, setIsSearchFocused] = useState(false); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + const containerRef = useRef(null); + const searchInputRef = useRef(null); + const hasInitialFocus = useRef(false); const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); const layerIdRef = useRef(); @@ -39,6 +44,26 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + // Filter entries based on search query + const filteredEntries = useMemo(() => { + if (!searchQuery.trim()) { + return entries; + } + + const query = searchQuery.toLowerCase(); + return entries.filter(entry => + entry.subject.toLowerCase().includes(query) || + entry.author.toLowerCase().includes(query) || + entry.hash.toLowerCase().includes(query) || + entry.refs.some(ref => ref.toLowerCase().includes(query)) + ); + }, [entries, searchQuery]); + + // Reset selected index when filtered entries change + useEffect(() => { + setSelectedIndex(0); + }, [searchQuery]); + // Load git log on mount useEffect(() => { const loadLog = async () => { @@ -75,10 +100,10 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { // Auto-load diff for selected commit useEffect(() => { - if (entries.length > 0 && entries[selectedIndex]) { - loadCommitDiff(entries[selectedIndex].hash); + if (filteredEntries.length > 0 && filteredEntries[selectedIndex]) { + loadCommitDiff(filteredEntries[selectedIndex].hash); } - }, [selectedIndex, entries, loadCommitDiff]); + }, [selectedIndex, filteredEntries, loadCommitDiff]); // Register with layer stack useEffect(() => { @@ -106,6 +131,14 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { } }, [updateLayerHandler]); + // Initial focus on container (only once) + useEffect(() => { + if (!hasInitialFocus.current && containerRef.current) { + containerRef.current.focus(); + hasInitialFocus.current = true; + } + }, []); + // Scroll selected item into view useEffect(() => { const selectedItem = itemRefs.current[selectedIndex]; @@ -120,16 +153,41 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { // Handle keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Focus search with / key (when not already focused on an input) + if (e.key === '/' && !isSearchFocused) { + e.preventDefault(); + searchInputRef.current?.focus(); + return; + } + + // Handle Escape when search is focused + if (e.key === 'Escape' && isSearchFocused) { + if (searchQuery) { + e.preventDefault(); + e.stopPropagation(); + setSearchQuery(''); + return; + } + // If no search query, blur the input and let the modal handle Escape + searchInputRef.current?.blur(); + return; + } + + // Don't handle navigation keys when search is focused + if (isSearchFocused) { + return; + } + // Navigate with arrow keys and j/k if (e.key === 'ArrowDown' || e.key === 'j') { e.preventDefault(); - setSelectedIndex(prev => Math.min(prev + 1, entries.length - 1)); + setSelectedIndex(prev => Math.min(prev + 1, filteredEntries.length - 1)); } else if (e.key === 'ArrowUp' || e.key === 'k') { e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 1, 0)); } else if (e.key === 'PageDown') { e.preventDefault(); - setSelectedIndex(prev => Math.min(prev + 10, entries.length - 1)); + setSelectedIndex(prev => Math.min(prev + 10, filteredEntries.length - 1)); } else if (e.key === 'PageUp') { e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 10, 0)); @@ -138,13 +196,13 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { setSelectedIndex(0); } else if (e.key === 'End') { e.preventDefault(); - setSelectedIndex(entries.length - 1); + setSelectedIndex(filteredEntries.length - 1); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [entries.length]); + }, [filteredEntries.length, isSearchFocused, searchQuery]); // Format date for display - time for today, full date for older commits const formatDate = (dateStr: string) => { @@ -223,6 +281,19 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { return stats.length > 0 ? stats : null; }, [selectedCommitDiff]); + const handleSearchFocus = useCallback(() => { + setIsSearchFocused(true); + }, []); + + const handleSearchBlur = useCallback(() => { + setIsSearchFocused(false); + }, []); + + const handleClearSearch = useCallback(() => { + setSearchQuery(''); + searchInputRef.current?.focus(); + }, []); + return (
e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Git Log Viewer" tabIndex={-1} - ref={(el) => el?.focus()} > {/* Header */}
- {entries.length} commits + {filteredEntries.length}{searchQuery && ` of ${entries.length}`} commits
- +
+ + Press / to search + + +
+
+ + {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + onFocus={handleSearchFocus} + onBlur={handleSearchBlur} + className="flex-1 bg-transparent text-sm outline-none border-none" + style={{ + color: theme.colors.textMain, + caretColor: theme.colors.accent, + }} + /> + {searchQuery && ( + + )}
{/* Content */} @@ -279,15 +390,15 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) {

{error}

- ) : entries.length === 0 ? ( + ) : filteredEntries.length === 0 ? (

- No commits found + {searchQuery ? 'No matching commits' : 'No commits found'}

) : (
- {entries.map((entry, index) => ( + {filteredEntries.map((entry, index) => (
itemRefs.current[index] = el} @@ -359,7 +470,7 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { {/* Right side: Commit details & diff */}
- {entries[selectedIndex] && ( + {filteredEntries[selectedIndex] && (
{/* Commit header */}
@@ -367,14 +478,14 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { className="text-lg font-semibold mb-2" style={{ color: theme.colors.textMain }} > - {entries[selectedIndex].subject} + {filteredEntries[selectedIndex].subject}
- {entries[selectedIndex].hash} + {filteredEntries[selectedIndex].hash} - {entries[selectedIndex].author} - {new Date(entries[selectedIndex].date).toLocaleString()} + {filteredEntries[selectedIndex].author} + {new Date(filteredEntries[selectedIndex].date).toLocaleString()}
@@ -483,13 +594,16 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) { ↑↓ or j/k navigate + + / search + Esc close
- {entries.length > 0 && ( + {filteredEntries.length > 0 && ( - Commit {selectedIndex + 1} of {entries.length} + Commit {selectedIndex + 1} of {filteredEntries.length} )}
From 8a17c37c1c0c5adb3f1fae680c9e5a7ee3d37eb5 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 07:31:12 -0600 Subject: [PATCH 35/72] MAESTRO: Add search functionality to scratchpad - Press '/' in preview mode to open search - Press CMD+F in edit mode to open search - Search shows match count and allows navigation with Enter/Shift+Enter - In preview mode, displays raw text with highlighted matches when searching - In edit mode, scrolls to match position and selects matched text - Safe DOM-based highlighting using createElement/textContent --- src/renderer/components/Scratchpad.tsx | 376 +++++++++++++++++++++---- 1 file changed, 327 insertions(+), 49 deletions(-) diff --git a/src/renderer/components/Scratchpad.tsx b/src/renderer/components/Scratchpad.tsx index e4769b137..72238aa00 100644 --- a/src/renderer/components/Scratchpad.tsx +++ b/src/renderer/components/Scratchpad.tsx @@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import { Eye, Edit, Play, Square, HelpCircle, Loader2, Image, X } from 'lucide-react'; +import { Eye, Edit, Play, Square, HelpCircle, Loader2, Image, X, Search, ChevronUp, ChevronDown } from 'lucide-react'; import type { BatchRunState, SessionState } from '../types'; import { AutoRunnerHelpModal } from './AutoRunnerHelpModal'; import { MermaidRenderer } from './MermaidRenderer'; @@ -130,6 +130,69 @@ function AttachmentImage({ ); } +// Component for displaying search-highlighted content using safe DOM methods +function SearchHighlightedContent({ + content, + searchQuery, + currentMatchIndex, + theme +}: { + content: string; + searchQuery: string; + currentMatchIndex: number; + theme: any; +}) { + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) return; + + // Clear existing content + ref.current.textContent = ''; + + if (!searchQuery.trim()) { + ref.current.textContent = content; + return; + } + + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedQuery})`, 'gi'); + const parts = content.split(regex); + let matchIndex = 0; + + parts.forEach((part) => { + if (part.toLowerCase() === searchQuery.toLowerCase()) { + // This is a match - create a highlighted mark element + const mark = document.createElement('mark'); + mark.className = 'search-match'; + mark.textContent = part; + mark.style.padding = '0 2px'; + mark.style.borderRadius = '2px'; + if (matchIndex === currentMatchIndex) { + mark.style.backgroundColor = theme.colors.accent; + mark.style.color = '#fff'; + } else { + mark.style.backgroundColor = '#ffd700'; + mark.style.color = '#000'; + } + ref.current!.appendChild(mark); + matchIndex++; + } else { + // Regular text - create a text node + ref.current!.appendChild(document.createTextNode(part)); + } + }); + }, [content, searchQuery, currentMatchIndex, theme.colors.accent]); + + return ( +
+ ); +} + // Image preview thumbnail for staged images in edit mode function ImagePreview({ src, @@ -206,6 +269,13 @@ export function Scratchpad({ const [lightboxImage, setLightboxImage] = useState(null); const [attachmentsList, setAttachmentsList] = useState([]); const [attachmentPreviews, setAttachmentPreviews] = useState>(new Map()); + // Search state + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const [totalMatches, setTotalMatches] = useState(0); + const matchElementsRef = useRef([]); + const searchInputRef = useRef(null); const textareaRef = useRef(null); const previewRef = useRef(null); const containerRef = useRef(null); @@ -288,6 +358,113 @@ export function Scratchpad({ } }; + // Open search function + const openSearch = useCallback(() => { + setSearchOpen(true); + setTimeout(() => searchInputRef.current?.focus(), 0); + }, []); + + // Close search function + const closeSearch = useCallback(() => { + setSearchOpen(false); + setSearchQuery(''); + setCurrentMatchIndex(0); + setTotalMatches(0); + matchElementsRef.current = []; + // Refocus appropriate element + if (mode === 'edit' && textareaRef.current) { + textareaRef.current.focus(); + } else if (mode === 'preview' && previewRef.current) { + previewRef.current.focus(); + } + }, [mode]); + + // Update match count when search query changes + useEffect(() => { + if (searchQuery.trim()) { + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedQuery, 'gi'); + const matches = content.match(regex); + const count = matches ? matches.length : 0; + setTotalMatches(count); + if (count > 0 && currentMatchIndex >= count) { + setCurrentMatchIndex(0); + } + } else { + setTotalMatches(0); + setCurrentMatchIndex(0); + } + }, [searchQuery, content]); + + // Navigate to next search match + const goToNextMatch = useCallback(() => { + if (totalMatches === 0) return; + const nextIndex = (currentMatchIndex + 1) % totalMatches; + setCurrentMatchIndex(nextIndex); + }, [currentMatchIndex, totalMatches]); + + // Navigate to previous search match + const goToPrevMatch = useCallback(() => { + if (totalMatches === 0) return; + const prevIndex = (currentMatchIndex - 1 + totalMatches) % totalMatches; + setCurrentMatchIndex(prevIndex); + }, [currentMatchIndex, totalMatches]); + + // Scroll to current match + useEffect(() => { + if (!searchOpen || !searchQuery.trim() || totalMatches === 0) return; + + // Find the current match element and scroll to it + const container = mode === 'edit' ? textareaRef.current : previewRef.current; + if (!container) return; + + // For preview mode, find and scroll to the highlighted match + if (mode === 'preview') { + const marks = previewRef.current?.querySelectorAll('mark.search-match'); + if (marks && marks.length > 0 && currentMatchIndex >= 0 && currentMatchIndex < marks.length) { + marks.forEach((mark, i) => { + const el = mark as HTMLElement; + if (i === currentMatchIndex) { + el.style.backgroundColor = theme.colors.accent; + el.style.color = '#fff'; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + el.style.backgroundColor = '#ffd700'; + el.style.color = '#000'; + } + }); + } + } else if (mode === 'edit') { + // For edit mode, find the match position in the text and scroll + const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escapedQuery, 'gi'); + let matchCount = 0; + let match; + let matchPosition = -1; + + while ((match = regex.exec(content)) !== null) { + if (matchCount === currentMatchIndex) { + matchPosition = match.index; + break; + } + matchCount++; + } + + if (matchPosition >= 0 && textareaRef.current) { + // Calculate approximate scroll position based on character position + const textarea = textareaRef.current; + const textBeforeMatch = content.substring(0, matchPosition); + const lineCount = (textBeforeMatch.match(/\n/g) || []).length; + const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 20; + const scrollTarget = Math.max(0, lineCount * lineHeight - textarea.clientHeight / 2); + textarea.scrollTop = scrollTarget; + + // Also select the match text + textarea.setSelectionRange(matchPosition, matchPosition + searchQuery.length); + } + } + }, [currentMatchIndex, searchOpen, searchQuery, totalMatches, mode, content, theme.colors.accent]); + // Handle image paste const handlePaste = useCallback(async (e: React.ClipboardEvent) => { if (isLocked || !sessionId) return; @@ -416,6 +593,14 @@ export function Scratchpad({ return; } + // Command-F to open search in edit mode + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + openSearch(); + return; + } + // Command-L to insert a markdown checkbox if ((e.metaKey || e.ctrlKey) && e.key === 'l') { e.preventDefault(); @@ -517,6 +702,11 @@ export function Scratchpad({ e.preventDefault(); toggleMode(); } + // CMD+F to open search (works in both modes from container) + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + openSearch(); + } }} > {/* Mode Toggle */} @@ -645,6 +835,72 @@ export function Scratchpad({
)} + {/* Search Bar */} + {searchOpen && ( +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeSearch(); + } else if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + goToNextMatch(); + } else if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + goToPrevMatch(); + } + }} + placeholder={mode === 'edit' ? "Search... (Enter: next, Shift+Enter: prev)" : "Search... (press '/' to open, Enter: next)"} + className="flex-1 bg-transparent outline-none text-sm" + style={{ color: theme.colors.textMain }} + autoFocus + /> + {searchQuery.trim() && ( + <> + + {totalMatches > 0 ? `${currentMatchIndex + 1}/${totalMatches}` : 'No matches'} + + + + + )} + +
+ )} + {/* Content Area */}
{mode === 'edit' ? ( @@ -677,6 +933,18 @@ export function Scratchpad({ e.stopPropagation(); toggleMode(); } + // '/' to open search in preview mode + if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + e.stopPropagation(); + openSearch(); + } + // CMD+F to open search + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + e.stopPropagation(); + openSearch(); + } }} onScroll={handlePreviewScroll} style={{ @@ -746,54 +1014,64 @@ export function Scratchpad({ margin-left: -1.5em; } `} - { - const match = (className || '').match(/language-(\w+)/); - const language = match ? match[1] : 'text'; - const codeContent = String(children).replace(/\n$/, ''); - - // Handle mermaid code blocks - if (!inline && language === 'mermaid') { - return ; - } - - return !inline && match ? ( - - {codeContent} - - ) : ( - - {children} - - ); - }, - img: ({ src, alt, ...props }: any) => ( - - ) - }} - > - {content || '*No content yet. Switch to Edit mode to start writing.*'} - + {searchOpen && searchQuery.trim() ? ( + // When searching, show raw text with highlights for easy search navigation + + ) : ( + { + const match = (className || '').match(/language-(\w+)/); + const language = match ? match[1] : 'text'; + const codeContent = String(children).replace(/\n$/, ''); + + // Handle mermaid code blocks + if (!inline && language === 'mermaid') { + return ; + } + + return !inline && match ? ( + + {codeContent} + + ) : ( + + {children} + + ); + }, + img: ({ src, alt, ...props }: any) => ( + + ) + }} + > + {content || '*No content yet. Switch to Edit mode to start writing.*'} + + )}
)}
From a297201062c17e4d2fb14dd9a75e6283f76305cb Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Fri, 28 Nov 2025 07:33:19 -0600 Subject: [PATCH 36/72] MAESTRO: Add template variables dropdown to Auto Runner prompt Added the same expandable template variables documentation to the Batch Runner modal that exists in the AI Commands settings panel. Users can now see and reference all 20 available template variables (session, project, date/time, git, context) when customizing the Auto Runner prompt. --- src/renderer/components/BatchRunnerModal.tsx | 51 +++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/BatchRunnerModal.tsx b/src/renderer/components/BatchRunnerModal.tsx index 4b388e350..409735d9e 100644 --- a/src/renderer/components/BatchRunnerModal.tsx +++ b/src/renderer/components/BatchRunnerModal.tsx @@ -1,8 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; -import { X, RotateCcw, Play } from 'lucide-react'; +import { X, RotateCcw, Play, Variable, ChevronDown, ChevronRight } from 'lucide-react'; import type { Theme } from '../types'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; +import { TEMPLATE_VARIABLES } from '../utils/templateVariables'; // Default batch processing prompt export const DEFAULT_BATCH_PROMPT = `CRITICAL: You must complete EXACTLY ONE task and then exit. Do not attempt multiple tasks. @@ -55,6 +56,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { const { theme, onClose, onGo, initialPrompt, showConfirmation } = props; const [prompt, setPrompt] = useState(initialPrompt || DEFAULT_BATCH_PROMPT); + const [variablesExpanded, setVariablesExpanded] = useState(false); const textareaRef = useRef(null); const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); @@ -158,9 +160,54 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) { Reset
-
+
Use $$SCRATCHPAD$$ as placeholder for the scratchpad file path
+ + {/* Template Variables Documentation */} +
+ + {variablesExpanded && ( +
+

+ Use these variables in your prompt. They will be replaced with actual values at runtime. +

+
+ {TEMPLATE_VARIABLES.map(({ variable, description }) => ( +
+ + {variable} + + + {description} + +
+ ))} +
+
+ )} +