feat: cherry-pick upstream CollapsibleOutputSection + TriggerMatcher regex cache

Cherry-picked from upstream:
- 516d0f6b: CollapsibleOutputSection with markdown preview toggle
- e51c1fd1: cache compiled regexes in TriggerMatcher (perf)
This commit is contained in:
iliya 2026-03-25 13:58:38 +02:00
parent 7324b5236d
commit 4946a65a7b
6 changed files with 150 additions and 34 deletions

View file

@ -11,6 +11,43 @@
import { type ContentBlock, type ParsedMessage } from '@main/types';
import { createSafeRegExp } from '@main/utils/regexValidation';
// =============================================================================
// Regex Cache
// =============================================================================
const MAX_CACHE_SIZE = 500;
/**
* Module-level cache for compiled RegExp objects.
* Key: `${pattern}\0${flags}` (null byte separator avoids collisions).
* Value: compiled RegExp, or null if the pattern is invalid/dangerous.
*/
const regexCache = new Map<string, RegExp | null>();
/**
* Returns a cached RegExp for the given pattern and flags.
* Compiles and caches on first access; returns null for invalid patterns.
* Cache is bounded to MAX_CACHE_SIZE entries (oldest evicted first via Map insertion order).
*/
function getCachedRegex(pattern: string, flags: string): RegExp | null {
const key = `${pattern}\0${flags}`;
if (regexCache.has(key)) {
return regexCache.get(key) ?? null;
}
// Evict oldest entries when cache is full
if (regexCache.size >= MAX_CACHE_SIZE) {
const firstKey = regexCache.keys().next().value;
if (firstKey !== undefined) {
regexCache.delete(firstKey);
}
}
const regex = createSafeRegExp(pattern, flags);
regexCache.set(key, regex);
return regex;
}
// =============================================================================
// Pattern Matching
// =============================================================================
@ -18,9 +55,10 @@ import { createSafeRegExp } from '@main/utils/regexValidation';
/**
* Checks if content matches a pattern.
* Uses validated regex to prevent ReDoS attacks.
* Regex objects are cached to avoid recompilation on repeated calls.
*/
export function matchesPattern(content: string, pattern: string): boolean {
const regex = createSafeRegExp(pattern, 'i');
const regex = getCachedRegex(pattern, 'i');
if (!regex) {
// Pattern is invalid or potentially dangerous, reject match
return false;
@ -31,6 +69,7 @@ export function matchesPattern(content: string, pattern: string): boolean {
/**
* Checks if content matches any of the ignore patterns.
* Uses validated regex to prevent ReDoS attacks.
* Regex objects are cached to avoid recompilation on repeated calls.
*/
export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean {
if (!ignorePatterns || ignorePatterns.length === 0) {
@ -38,7 +77,7 @@ export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]
}
for (const pattern of ignorePatterns) {
const regex = createSafeRegExp(pattern, 'i');
const regex = getCachedRegex(pattern, 'i');
if (regex?.test(content)) {
return true;
}

View file

@ -0,0 +1,57 @@
/**
* CollapsibleOutputSection
*
* Reusable component that wraps tool output in a collapsed-by-default section.
* Shows a clickable header with label, StatusDot, and chevron toggle.
*/
import React, { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { type ItemStatus, StatusDot } from '../BaseItem';
interface CollapsibleOutputSectionProps {
status: ItemStatus;
children: React.ReactNode;
/** Label shown in the header (default: "Output") */
label?: string;
}
export const CollapsibleOutputSection: React.FC<CollapsibleOutputSectionProps> = ({
status,
children,
label = 'Output',
}) => {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div>
<button
type="button"
className="mb-1 flex items-center gap-2 text-xs"
style={{ color: 'var(--tool-item-muted)', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => setIsExpanded((prev) => !prev)}
>
{isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
{label}
<StatusDot status={status} />
</button>
{isExpanded && (
<div
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
style={{
backgroundColor: 'var(--code-bg)',
border: '1px solid var(--code-border)',
color:
status === 'error'
? 'var(--tool-result-error-text)'
: 'var(--color-text-secondary)',
}}
>
{children}
</div>
)}
</div>
);
};

View file

@ -6,8 +6,9 @@
import React from 'react';
import { type ItemStatus, StatusDot } from '../BaseItem';
import { type ItemStatus } from '../BaseItem';
import { CollapsibleOutputSection } from './CollapsibleOutputSection';
import { renderInput, renderOutput } from './renderHelpers';
import type { LinkedToolItem } from '@renderer/types/groups';
@ -37,30 +38,11 @@ export const DefaultToolViewer: React.FC<DefaultToolViewerProps> = ({ linkedTool
</div>
</div>
{/* Output Section */}
{/* Output Section — Collapsed by default */}
{!linkedTool.isOrphaned && linkedTool.result && (
<div>
<div
className="mb-1 flex items-center gap-2 text-xs"
style={{ color: 'var(--tool-item-muted)' }}
>
Output
<StatusDot status={status} />
</div>
<div
className="max-h-96 overflow-auto rounded p-3 font-mono text-xs"
style={{
backgroundColor: 'var(--code-bg)',
border: '1px solid var(--code-border)',
color:
status === 'error'
? 'var(--tool-result-error-text)'
: 'var(--color-text-secondary)',
}}
>
{renderOutput(linkedTool.result.content)}
</div>
</div>
<CollapsibleOutputSection status={status}>
{renderOutput(linkedTool.result.content)}
</CollapsibleOutputSection>
)}
</>
);

View file

@ -6,7 +6,7 @@
import React from 'react';
import { CodeBlockViewer } from '@renderer/components/chat/viewers';
import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers';
import type { LinkedToolItem } from '@renderer/types/groups';
@ -54,12 +54,49 @@ export const ReadToolViewer: React.FC<ReadToolViewerProps> = ({ linkedTool }) =>
? startLine + limit - 1
: undefined;
const isMarkdownFile = /\.mdx?$/i.test(filePath);
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code');
return (
<CodeBlockViewer
fileName={filePath}
content={content}
startLine={startLine}
endLine={endLine}
/>
<div className="space-y-2">
{isMarkdownFile && (
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setViewMode('code')}
className="rounded px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: viewMode === 'code' ? 'var(--tag-bg)' : 'transparent',
color: viewMode === 'code' ? 'var(--tag-text)' : 'var(--color-text-muted)',
border: '1px solid var(--tag-border)',
}}
>
Code
</button>
<button
type="button"
onClick={() => setViewMode('preview')}
className="rounded px-2 py-1 text-xs transition-colors"
style={{
backgroundColor: viewMode === 'preview' ? 'var(--tag-bg)' : 'transparent',
color: viewMode === 'preview' ? 'var(--tag-text)' : 'var(--color-text-muted)',
border: '1px solid var(--tag-border)',
}}
>
Preview
</button>
</div>
)}
{isMarkdownFile && viewMode === 'preview' ? (
<MarkdownViewer content={content} label="Markdown Preview" copyable />
) : (
<CodeBlockViewer
fileName={filePath}
content={content}
startLine={startLine}
endLine={endLine}
/>
)}
</div>
);
};

View file

@ -21,7 +21,7 @@ export const WriteToolViewer: React.FC<WriteToolViewerProps> = ({ linkedTool })
const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || '';
const isCreate = toolUseResult?.type === 'create';
const isMarkdownFile = /\.mdx?$/i.test(filePath);
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code');
return (
<div className="space-y-2">

View file

@ -4,6 +4,7 @@
* Exports all specialized tool viewer components.
*/
export { CollapsibleOutputSection } from './CollapsibleOutputSection';
export { DefaultToolViewer } from './DefaultToolViewer';
export { EditToolViewer } from './EditToolViewer';
export { ReadToolViewer } from './ReadToolViewer';