I'm ready to analyze GitHub project changes and create an exciting update summary! However, I don't see any input provided after "INPUT:" in your message.

Please share the changelog, commit history, release notes, or any other information about what has changed in the GitHub project since the last release, and I'll create a clean, exciting CHANGES section with 10-word bullets and emojis as requested.
This commit is contained in:
Pedram Amini
2025-12-15 23:20:11 -06:00
parent fa2e087b76
commit 2685db7e6b
12 changed files with 643 additions and 18 deletions

80
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "maestro",
"version": "0.8.3",
"version": "0.8.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maestro",
"version": "0.8.3",
"version": "0.8.5",
"hasInstallScript": true,
"license": "AGPL 3.0",
"dependencies": {
@@ -37,6 +37,7 @@
"react-syntax-highlighter": "^16.1.0",
"react-virtuoso": "^4.15.0",
"rehype-raw": "^7.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"ws": "^8.16.0"
},
@@ -9354,6 +9355,36 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-frontmatter": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-frontmatter/-/mdast-util-frontmatter-2.0.1.tgz",
"integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"escape-string-regexp": "^5.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-extension-frontmatter": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
@@ -9698,6 +9729,35 @@
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-extension-frontmatter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-frontmatter/-/micromark-extension-frontmatter-2.0.0.tgz",
"integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==",
"license": "MIT",
"dependencies": {
"fault": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-frontmatter/node_modules/fault": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
"integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
@@ -12102,6 +12162,22 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-frontmatter": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-5.0.0.tgz",
"integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-frontmatter": "^2.0.0",
"micromark-extension-frontmatter": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",

View File

@@ -154,6 +154,7 @@
"react-syntax-highlighter": "^16.1.0",
"react-virtuoso": "^4.15.0",
"rehype-raw": "^7.0.0",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"ws": "^8.16.0"
},

View File

@@ -21,6 +21,7 @@ import { GitDiffViewer } from './components/GitDiffViewer';
import { GitLogViewer } from './components/GitLogViewer';
import { BatchRunnerModal, DEFAULT_BATCH_PROMPT } from './components/BatchRunnerModal';
import { TabSwitcherModal } from './components/TabSwitcherModal';
import { FileSearchModal, type FlatFileItem } from './components/FileSearchModal';
import { PromptComposerModal } from './components/PromptComposerModal';
import { ExecutionQueueBrowser } from './components/ExecutionQueueBrowser';
import { StandingOvationOverlay } from './components/StandingOvationOverlay';
@@ -326,6 +327,9 @@ export default function MaestroConsole() {
// Tab Switcher Modal State
const [tabSwitcherOpen, setTabSwitcherOpen] = useState(false);
// Fuzzy File Search Modal State
const [fuzzyFileSearchOpen, setFuzzyFileSearchOpen] = useState(false);
// Prompt Composer Modal State
const [promptComposerOpen, setPromptComposerOpen] = useState(false);
const [renameGroupId, setRenameGroupId] = useState<string | null>(null);
@@ -4197,7 +4201,7 @@ export default function MaestroConsole() {
setRenameTabModalOpen, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab,
setFileTreeFilterOpen, isShortcut, isTabShortcut, handleNavBack, handleNavForward, toggleUnreadFilter,
setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage, setMarkdownEditMode,
toggleTabStar, setPromptComposerOpen, openWizardModal, rightPanelRef,
toggleTabStar, setPromptComposerOpen, openWizardModal, rightPanelRef, setFuzzyFileSearchOpen,
// Navigation handlers from useKeyboardNavigation hook
handleSidebarNavigation, handleTabNavigation, handleEnterToActivate, handleEscapeInMain
};
@@ -5491,6 +5495,38 @@ export default function MaestroConsole() {
/>
)}
{/* --- FUZZY FILE SEARCH MODAL --- */}
{fuzzyFileSearchOpen && activeSession && (
<FileSearchModal
theme={theme}
fileTree={filteredFileTree}
shortcut={shortcuts.fuzzyFileSearch}
onFileSelect={(file: FlatFileItem, ancestorPaths: string[]) => {
// Expand all ancestor folders so the file is visible
setSessions(prev => prev.map(s => {
if (s.id !== activeSessionId) return s;
const newExpanded = new Set([...s.fileExplorerExpanded, ...ancestorPaths]);
return { ...s, fileExplorerExpanded: Array.from(newExpanded) };
}));
// Find the file index in the flattened tree to set selection
// We need to compute this after folders are expanded
setTimeout(() => {
// Set focus to right panel and files tab
setActiveRightTab('files');
setActiveFocus('right');
// Preview the file if it's not a folder
if (!file.isFolder && activeSession) {
const absolutePath = `${activeSession.fullPath}/${file.fullPath}`;
handleFileClick({ name: file.name, type: 'file' }, absolutePath, activeSession);
}
}, 50);
}}
onClose={() => setFuzzyFileSearchOpen(false)}
/>
)}
{/* --- PROMPT COMPOSER MODAL --- */}
<PromptComposerModal
isOpen={promptComposerOpen}

View File

@@ -12,6 +12,8 @@ import { MermaidRenderer } from './MermaidRenderer';
import { getEncoding } from 'js-tiktoken';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
import { remarkFileLinks } from '../utils/remarkFileLinks';
import remarkFrontmatter from 'remark-frontmatter';
import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
import type { FileNode } from '../hooks/useFileExplorer';
interface FileStats {
@@ -1278,6 +1280,8 @@ export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdow
<ReactMarkdown
remarkPlugins={[
remarkGfm,
remarkFrontmatter,
remarkFrontmatterTable,
remarkHighlight,
...(fileTree && fileTree.length > 0 && cwd !== undefined
? [[remarkFileLinks, { fileTree, cwd }] as any]

View File

@@ -0,0 +1,336 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Search, File, Folder } from 'lucide-react';
import type { Theme, Shortcut } from '../types';
import { fuzzyMatchWithScore } from '../utils/search';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
interface FileNode {
name: string;
type: 'file' | 'folder';
children?: FileNode[];
}
/** Flattened file item for the search list */
export interface FlatFileItem {
name: string;
fullPath: string;
isFolder: boolean;
depth: number;
}
interface FileSearchModalProps {
theme: Theme;
fileTree: FileNode[];
shortcut?: Shortcut;
onFileSelect: (item: FlatFileItem, ancestorPaths: string[]) => void;
onClose: () => void;
}
/**
* Get ancestor folder paths for a file path.
* e.g., "src/renderer/components/Button.tsx" => ["src", "src/renderer", "src/renderer/components"]
*/
function getAncestorPaths(filePath: string): string[] {
const parts = filePath.split('/');
const paths: string[] = [];
for (let i = 1; i < parts.length; i++) {
paths.push(parts.slice(0, i).join('/'));
}
return paths;
}
/**
* Recursively flatten the entire file tree (ignoring expansion state).
* Returns all files and folders with their full paths.
*/
function flattenEntireTree(nodes: FileNode[], currentPath = '', depth = 0): FlatFileItem[] {
const result: FlatFileItem[] = [];
for (const node of nodes) {
const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;
result.push({
name: node.name,
fullPath,
isFolder: node.type === 'folder',
depth,
});
if (node.type === 'folder' && node.children) {
result.push(...flattenEntireTree(node.children, fullPath, depth + 1));
}
}
return result;
}
/**
* Fuzzy File Search Modal - Quick navigation to any file in the file tree.
* Supports fuzzy search, arrow key navigation, and Cmd+1-9,0 quick select.
*/
export function FileSearchModal({
theme,
fileTree,
shortcut,
onFileSelect,
onClose
}: FileSearchModalProps) {
const [search, setSearch] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [firstVisibleIndex, setFirstVisibleIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const selectedItemRef = useRef<HTMLButtonElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const layerIdRef = useRef<string>();
const onCloseRef = useRef(onClose);
// Keep onClose ref up to date
useEffect(() => {
onCloseRef.current = onClose;
});
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
// Register layer on mount
useEffect(() => {
layerIdRef.current = registerLayer({
type: 'modal',
priority: MODAL_PRIORITIES.FUZZY_FILE_SEARCH,
blocksLowerLayers: true,
capturesFocus: true,
focusTrap: 'strict',
ariaLabel: 'Fuzzy File Search',
onEscape: () => onCloseRef.current()
});
return () => {
if (layerIdRef.current) {
unregisterLayer(layerIdRef.current);
}
};
}, [registerLayer, unregisterLayer]);
// Update handler when onClose changes
useEffect(() => {
if (layerIdRef.current) {
updateLayerHandler(layerIdRef.current, () => {
onCloseRef.current();
});
}
}, [updateLayerHandler]);
// Focus input on mount
useEffect(() => {
const timer = setTimeout(() => inputRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, []);
// Flatten the entire tree (all files, regardless of expansion state)
const allFiles = useMemo(() => {
return flattenEntireTree(fileTree);
}, [fileTree]);
// Filter and sort files based on search query
const filteredFiles = useMemo(() => {
if (!search.trim()) {
// No search - show all files sorted alphabetically by path
return [...allFiles].sort((a, b) => a.fullPath.localeCompare(b.fullPath));
}
// Fuzzy search on both name and full path
const results = allFiles.map(file => {
const nameResult = fuzzyMatchWithScore(file.name, search);
const pathResult = fuzzyMatchWithScore(file.fullPath, search);
const bestScore = Math.max(nameResult.score, pathResult.score);
const matches = nameResult.matches || pathResult.matches;
return { file, score: bestScore, matches };
});
return results
.filter(r => r.matches)
.sort((a, b) => b.score - a.score)
.map(r => r.file);
}, [allFiles, search]);
// Reset selection when search changes
useEffect(() => {
setSelectedIndex(0);
setFirstVisibleIndex(0);
}, [search]);
// Scroll selected item into view
useEffect(() => {
selectedItemRef.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}, [selectedIndex]);
// Track scroll position to determine which items are visible
const handleScroll = () => {
if (scrollContainerRef.current) {
const scrollTop = scrollContainerRef.current.scrollTop;
const itemHeight = 40; // Approximate height of each item
const visibleIndex = Math.floor(scrollTop / itemHeight);
setFirstVisibleIndex(visibleIndex);
}
};
const handleItemSelect = (file: FlatFileItem) => {
const ancestors = getAncestorPaths(file.fullPath);
onFileSelect(file, ancestors);
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 1, filteredFiles.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (filteredFiles[selectedIndex]) {
handleItemSelect(filteredFiles[selectedIndex]);
}
} else if (e.metaKey && ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'].includes(e.key)) {
e.preventDefault();
// 1-9 map to positions 1-9, 0 maps to position 10
const number = e.key === '0' ? 10 : parseInt(e.key);
// Cap firstVisibleIndex so hotkeys always work for the last 10 items
const maxFirstIndex = Math.max(0, filteredFiles.length - 10);
const effectiveFirstIndex = Math.min(firstVisibleIndex, maxFirstIndex);
const targetIndex = effectiveFirstIndex + number - 1;
if (filteredFiles[targetIndex]) {
handleItemSelect(filteredFiles[targetIndex]);
}
}
};
// Get the directory part of a path (everything before the last /)
const getDirectory = (fullPath: string): string => {
const lastSlash = fullPath.lastIndexOf('/');
return lastSlash > 0 ? fullPath.substring(0, lastSlash) : '';
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-16 z-[9999] animate-in fade-in duration-100">
<div
role="dialog"
aria-modal="true"
aria-label="Fuzzy File Search"
tabIndex={-1}
className="w-[600px] rounded-xl shadow-2xl border overflow-hidden flex flex-col max-h-[500px] outline-none"
style={{ backgroundColor: theme.colors.bgActivity, borderColor: theme.colors.border }}
>
{/* Search Header */}
<div className="p-4 border-b flex items-center gap-3" style={{ borderColor: theme.colors.border }}>
<Search className="w-5 h-5" style={{ color: theme.colors.textDim }} />
<input
ref={inputRef}
className="flex-1 bg-transparent outline-none text-lg placeholder-opacity-50"
placeholder="Search files..."
style={{ color: theme.colors.textMain }}
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
/>
<div className="flex items-center gap-2">
{shortcut && (
<span className="text-xs font-mono opacity-60" style={{ color: theme.colors.textDim }}>
{formatShortcutKeys(shortcut.keys)}
</span>
)}
<div
className="px-2 py-0.5 rounded text-xs font-bold"
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.textDim }}
>
ESC
</div>
</div>
</div>
{/* File List */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="overflow-y-auto py-2 scrollbar-thin flex-1"
>
{filteredFiles.map((file, i) => {
const isSelected = i === selectedIndex;
// Calculate dynamic number badge
const maxFirstIndex = Math.max(0, filteredFiles.length - 10);
const effectiveFirstIndex = Math.min(firstVisibleIndex, maxFirstIndex);
const distanceFromFirstVisible = i - effectiveFirstIndex;
const showNumber = distanceFromFirstVisible >= 0 && distanceFromFirstVisible < 10;
const numberBadge = distanceFromFirstVisible === 9 ? 0 : distanceFromFirstVisible + 1;
const directory = getDirectory(file.fullPath);
return (
<button
key={file.fullPath}
ref={isSelected ? selectedItemRef : null}
onClick={() => handleItemSelect(file)}
className="w-full text-left px-4 py-2 flex items-center gap-3 hover:bg-opacity-10"
style={{
backgroundColor: isSelected ? theme.colors.accent : 'transparent',
color: isSelected ? theme.colors.accentForeground : theme.colors.textMain
}}
>
{/* Number Badge */}
{showNumber ? (
<div
className="flex-shrink-0 w-5 h-5 rounded flex items-center justify-center text-xs font-bold"
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.textDim }}
>
{numberBadge}
</div>
) : (
<div className="flex-shrink-0 w-5 h-5" />
)}
{/* File/Folder Icon */}
{file.isFolder ? (
<Folder className="w-4 h-4 flex-shrink-0" style={{ color: isSelected ? theme.colors.accentForeground : theme.colors.warning }} />
) : (
<File className="w-4 h-4 flex-shrink-0" style={{ color: isSelected ? theme.colors.accentForeground : theme.colors.textDim }} />
)}
{/* File Info */}
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">{file.name}</span>
{directory && (
<span
className="text-[10px] truncate"
style={{ color: isSelected ? theme.colors.accentForeground : theme.colors.textDim, opacity: 0.7 }}
>
{directory}
</span>
)}
</div>
</button>
);
})}
{filteredFiles.length === 0 && (
<div className="px-4 py-4 text-center opacity-50 text-sm" style={{ color: theme.colors.textDim }}>
{search ? 'No files match your search' : 'No files to search'}
</div>
)}
</div>
{/* Footer with stats */}
<div
className="px-4 py-2 border-t text-xs flex items-center justify-between"
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
>
<span>{filteredFiles.length} files</span>
<span> navigate Enter select 1-9 quick select</span>
</div>
</div>
</div>
);
}

View File

@@ -7,6 +7,8 @@ import { Clipboard, Loader2, ImageOff } from 'lucide-react';
import type { Theme } from '../types';
import type { FileNode } from '../hooks/useFileExplorer';
import { remarkFileLinks } from '../utils/remarkFileLinks';
import remarkFrontmatter from 'remark-frontmatter';
import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
// ============================================================================
// LocalImage - Loads local images via IPC
@@ -199,7 +201,11 @@ interface MarkdownRendererProps {
export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '', fileTree, cwd, projectRoot, onFileClick }: MarkdownRendererProps) => {
// Memoize remark plugins to avoid recreating on every render
const remarkPlugins = useMemo(() => {
const plugins: any[] = [remarkGfm];
const plugins: any[] = [
remarkGfm,
remarkFrontmatter,
remarkFrontmatterTable,
];
// Add remarkFileLinks if we have file tree for relative paths,
// OR if we have projectRoot for absolute paths (even with empty file tree)
if ((fileTree && fileTree.length > 0 && cwd !== undefined) || projectRoot) {

View File

@@ -1810,7 +1810,18 @@ export function SessionList(props: SessionListProps) {
) : (
/* SIDEBAR CONTENT: SKINNY MODE */
<div className="flex-1 flex flex-col items-center py-4 gap-2 overflow-y-auto overflow-x-visible no-scrollbar">
{sortedSessions.map(session => (
{sortedSessions.map(session => {
const isInBatch = activeBatchSessionIds.includes(session.id);
const hasUnreadTabs = session.aiTabs?.some(tab => tab.hasUnread);
// Sessions in Auto Run mode should show yellow/warning color
const effectiveStatusColor = isInBatch
? theme.colors.warning
: (session.toolType === 'claude' && !session.claudeSessionId
? undefined // Will use border style instead
: getStatusColor(session.state, theme));
const shouldPulse = session.state === 'busy' || isInBatch;
return (
<div
key={session.id}
onClick={() => setActiveSessionId(session.id)}
@@ -1818,15 +1829,25 @@ export function SessionList(props: SessionListProps) {
className={`group relative w-8 h-8 rounded-full flex items-center justify-center cursor-pointer transition-all ${activeSessionId === session.id ? 'ring-2' : 'hover:bg-white/10'}`}
style={{ ringColor: theme.colors.accent }}
>
<div
className={`w-3 h-3 rounded-full ${session.state === 'busy' ? 'animate-pulse' : ''}`}
style={
session.toolType === 'claude' && !session.claudeSessionId
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
: { backgroundColor: getStatusColor(session.state, theme) }
}
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
/>
<div className="relative">
<div
className={`w-3 h-3 rounded-full ${shouldPulse ? 'animate-pulse' : ''}`}
style={
session.toolType === 'claude' && !session.claudeSessionId && !isInBatch
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
: { backgroundColor: effectiveStatusColor }
}
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
/>
{/* Unread Notification Badge */}
{activeSessionId !== session.id && hasUnreadTabs && (
<div
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: theme.colors.error }}
title="Unread messages"
/>
)}
</div>
{/* Hover Tooltip for Skinny Mode */}
<div
@@ -1915,7 +1936,8 @@ export function SessionList(props: SessionListProps) {
</div>
</div>
</div>
))}
);
})}
</div>
)}

View File

@@ -77,6 +77,9 @@ export const MODAL_PRIORITIES = {
/** Quick actions command palette (Cmd+K) */
QUICK_ACTION: 700,
/** Fuzzy file search modal (Cmd+G) */
FUZZY_FILE_SEARCH: 690,
/** Agent sessions browser (Cmd+Shift+L) */
AGENT_SESSIONS: 680,

View File

@@ -34,6 +34,7 @@ export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
toggleTabStar: { id: 'toggleTabStar', label: 'Toggle Tab Star', keys: ['Meta', 'Shift', 's'] },
openPromptComposer: { id: 'openPromptComposer', label: 'Open Prompt Composer', keys: ['Meta', 'Shift', 'p'] },
openWizard: { id: 'openWizard', label: 'New Agent Wizard', keys: ['Meta', 'Shift', 'n'] },
fuzzyFileSearch: { id: 'fuzzyFileSearch', label: 'Fuzzy File Search', keys: ['Meta', 'g'] },
};
// Non-editable shortcuts (displayed in help but not configurable)

View File

@@ -319,9 +319,11 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
}
}
// Check if we're in read-only mode for the log entry
// Check if we're in read-only mode for the log entry (tab setting OR Auto Run without worktree)
const activeTabForEntry = currentMode === 'ai' ? getActiveTab(activeSession) : null;
const isReadOnlyEntry = activeTabForEntry?.readOnlyMode === true;
const currentBatchState = getBatchState(activeSession.id);
const isAutoRunReadOnly = currentBatchState.isRunning && !currentBatchState.worktreeActive;
const isReadOnlyEntry = activeTabForEntry?.readOnlyMode === true || isAutoRunReadOnly;
const newEntry: LogEntry = {
id: generateId(),
@@ -522,7 +524,10 @@ export function useInputProcessing(deps: UseInputProcessingDeps): UseInputProces
const freshActiveTab = getActiveTab(freshSession);
const tabClaudeSessionId = freshActiveTab?.claudeSessionId;
const isNewSession = !tabClaudeSessionId;
const isReadOnly = activeBatchRunState.isRunning || freshActiveTab?.readOnlyMode;
// Check CURRENT session's Auto Run state (not any session's) and respect worktree bypass
const currentSessionBatchState = getBatchState(activeSessionId);
const isAutoRunReadOnly = currentSessionBatchState.isRunning && !currentSessionBatchState.worktreeActive;
const isReadOnly = isAutoRunReadOnly || freshActiveTab?.readOnlyMode;
// Filter out --dangerously-skip-permissions when read-only mode is active
// (it would override --permission-mode plan)

View File

@@ -186,6 +186,7 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
else if (ctx.isShortcut(e, 'goToFiles')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('files'); ctx.setActiveFocus('right'); }
else if (ctx.isShortcut(e, 'goToHistory')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('history'); ctx.setActiveFocus('right'); }
else if (ctx.isShortcut(e, 'goToAutoRun')) { e.preventDefault(); ctx.setRightPanelOpen(true); ctx.handleSetActiveRightTab('autorun'); ctx.setActiveFocus('right'); }
else if (ctx.isShortcut(e, 'fuzzyFileSearch')) { e.preventDefault(); if (ctx.activeSession) ctx.setFuzzyFileSearchOpen(true); }
else if (ctx.isShortcut(e, 'openImageCarousel')) {
e.preventDefault();
if (ctx.stagedImages.length > 0) {

View File

@@ -0,0 +1,134 @@
/**
* remarkFrontmatterTable - A remark plugin that transforms YAML frontmatter into a styled metadata table.
*
* Requires remark-frontmatter to be used first to parse the frontmatter into a YAML AST node.
* This plugin then transforms that node into an HTML table for display.
*
* Example input:
* ---
* share_note_link: https://example.com
* share_note_updated: 2025-05-19T13:15:43-05:00
* ---
*
* Output: A compact two-column table with key/value pairs, styled with smaller font.
*/
import { visit } from 'unist-util-visit';
import type { Root } from 'mdast';
/**
* Parse simple YAML key-value pairs from frontmatter content.
* Handles basic YAML syntax (key: value on each line).
*/
function parseYamlKeyValues(yamlContent: string): Array<{ key: string; value: string }> {
const lines = yamlContent.split('\n');
const entries: Array<{ key: string; value: string }> = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
// Match key: value pattern
const colonIndex = trimmed.indexOf(':');
if (colonIndex > 0) {
const key = trimmed.substring(0, colonIndex).trim();
let value = trimmed.substring(colonIndex + 1).trim();
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
entries.push({ key, value });
}
}
return entries;
}
/**
* Check if a value looks like a URL
*/
function isUrl(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://');
}
/**
* Escape HTML special characters
*/
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Generate HTML table from frontmatter entries
*/
function generateTableHtml(entries: Array<{ key: string; value: string }>): string {
if (entries.length === 0) return '';
const rows = entries.map(({ key, value }) => {
const escapedKey = escapeHtml(key);
let valueHtml: string;
if (isUrl(value)) {
// Render URLs as clickable links
const escapedUrl = escapeHtml(value);
// Truncate long URLs for display
const displayUrl = value.length > 50 ? value.substring(0, 47) + '...' : value;
valueHtml = `<a href="${escapedUrl}" style="color: inherit; text-decoration: underline;" title="${escapedUrl}">${escapeHtml(displayUrl)}</a>`;
} else {
valueHtml = escapeHtml(value);
}
return `<tr>
<td style="padding: 2px 8px 2px 0; font-weight: 500; white-space: nowrap; vertical-align: top;">${escapedKey}</td>
<td style="padding: 2px 0; word-break: break-word;">${valueHtml}</td>
</tr>`;
}).join('\n');
return `<div class="frontmatter-table" style="font-size: 0.75em; opacity: 0.7; margin-bottom: 1em; padding: 8px; border-radius: 4px; background: rgba(128,128,128,0.1);">
<table style="border-collapse: collapse; width: 100%;">
<tbody>
${rows}
</tbody>
</table>
</div>`;
}
/**
* The remark plugin - transforms YAML frontmatter nodes into HTML tables
*/
export function remarkFrontmatterTable() {
return (tree: Root) => {
visit(tree, 'yaml', (node: any, index, parent) => {
if (!parent || index === undefined) return;
const yamlContent = node.value;
const entries = parseYamlKeyValues(yamlContent);
if (entries.length === 0) {
// No valid entries, remove the node entirely
parent.children.splice(index, 1);
return index;
}
const tableHtml = generateTableHtml(entries);
// Replace the YAML node with an HTML node
const htmlNode = {
type: 'html',
value: tableHtml,
};
parent.children.splice(index, 1, htmlNode);
return index + 1;
});
};
}
export default remarkFrontmatterTable;