Browse Source

fix(grep): restore inline match count metadata

dhaern 4 months ago
parent
commit
707158ef04

+ 70 - 0
src/hooks/grep-render-metadata/index.test.ts

@@ -0,0 +1,70 @@
+import { describe, expect, test } from 'bun:test';
+import {
+  createGrepRenderMetadataHook,
+  parseGrepSummary,
+} from './index';
+
+describe('grep render metadata hook', () => {
+  test('parses match summary lines', () => {
+    expect(
+      parseGrepSummary(
+        'Pattern: alpha\nPath: src\n\nFound 7 matches across 3 files.\n',
+      ),
+    ).toEqual({ matches: 7, files: 3 });
+  });
+
+  test('parses files_with_matches summary lines', () => {
+    expect(parseGrepSummary('Found 4 matching files.\n\nsrc/a.ts')).toEqual({
+      matches: 4,
+      files: 4,
+    });
+  });
+
+  test('hydrates final output metadata for grep tools', async () => {
+    const hook = createGrepRenderMetadataHook();
+    const output: { title?: unknown; output: unknown; metadata?: unknown } = {
+      title: '',
+      output:
+        'Pattern: alpha\nPath: src\n\nFound 2 matches across 1 file.\n\nsrc/example.ts\n      1: alpha',
+      metadata: { truncated: false },
+    };
+
+    await hook['tool.execute.after'](
+      {
+        tool: 'grep',
+        args: { pattern: 'alpha' },
+      },
+      output,
+    );
+
+    expect(output.title).toBe('alpha');
+    expect(output.metadata).toEqual({
+      truncated: false,
+      matches: 2,
+      files: 1,
+    });
+  });
+
+  test('ignores non-grep tools', async () => {
+    const hook = createGrepRenderMetadataHook();
+    const output = {
+      title: '',
+      output: 'Found 2 matches across 1 file.',
+      metadata: {},
+    };
+
+    await hook['tool.execute.after'](
+      {
+        tool: 'read',
+        args: { pattern: 'alpha' },
+      },
+      output,
+    );
+
+    expect(output).toEqual({
+      title: '',
+      output: 'Found 2 matches across 1 file.',
+      metadata: {},
+    });
+  });
+});

+ 82 - 0
src/hooks/grep-render-metadata/index.ts

@@ -0,0 +1,82 @@
+import { sanitizeTitle } from '../../tools/grep/path-utils';
+
+const FILE_SUMMARY_RE = /^Found (\d+) matching file(?:s)?\.$/;
+const MATCH_SUMMARY_RE =
+  /^Found (\d+)(?: total)? match(?:es)? across (\d+) file(?:s)?\.$/;
+const NO_RESULTS_RE = /^(?:No matches found\.|No files found\.|No visible (?:results|files) were collected before the search stopped\.)$/;
+const MTIME_NO_VISIBLE_RE =
+  /^(?:mtime (?:sorting|replay|discovery) could not produce visible results after discovering \d+ candidate file(?:s)?\.|Search stopped during mtime (?:sorting|replay|discovery) after discovering \d+ candidate file(?:s)? before replay produced visible results\.)$/;
+
+interface ToolExecuteAfterInput {
+  tool: string;
+  args?: {
+    pattern?: unknown;
+  };
+}
+
+interface ToolExecuteAfterOutput {
+  title?: unknown;
+  output: unknown;
+  metadata?: unknown;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null;
+}
+
+export function parseGrepSummary(output: string): {
+  matches: number;
+  files: number;
+} | null {
+  const lines = output.split(/\r?\n/, 6);
+  for (const line of lines) {
+    const fileMatch = FILE_SUMMARY_RE.exec(line);
+    if (fileMatch) {
+      const files = Number.parseInt(fileMatch[1] ?? '0', 10);
+      return { matches: files, files };
+    }
+
+    const matchSummary = MATCH_SUMMARY_RE.exec(line);
+    if (matchSummary) {
+      return {
+        matches: Number.parseInt(matchSummary[1] ?? '0', 10),
+        files: Number.parseInt(matchSummary[2] ?? '0', 10),
+      };
+    }
+  }
+
+  const firstLine = lines.find((line) => line.length > 0);
+  if (firstLine && (NO_RESULTS_RE.test(firstLine) || MTIME_NO_VISIBLE_RE.test(firstLine))) {
+    return { matches: 0, files: 0 };
+  }
+
+  return null;
+}
+
+export function createGrepRenderMetadataHook() {
+  return {
+    'tool.execute.after': async (
+      input: ToolExecuteAfterInput,
+      output: ToolExecuteAfterOutput,
+    ): Promise<void> => {
+      if (input.tool.toLowerCase() !== 'grep') return;
+      if (typeof output.output !== 'string') return;
+
+      const counts = parseGrepSummary(output.output);
+      const metadata = isRecord(output.metadata) ? output.metadata : {};
+      if (counts) {
+        metadata.matches = counts.matches;
+        metadata.files = counts.files;
+      }
+      output.metadata = metadata;
+
+      if (
+        (typeof output.title !== 'string' || output.title.length === 0) &&
+        typeof input.args?.pattern === 'string' &&
+        input.args.pattern.length > 0
+      ) {
+        output.title = sanitizeTitle(input.args.pattern);
+      }
+    },
+  };
+}

+ 1 - 0
src/hooks/index.ts

@@ -4,6 +4,7 @@ export { createAutoUpdateCheckerHook } from './auto-update-checker';
 export { createChatHeadersHook } from './chat-headers';
 export { createDelegateTaskRetryHook } from './delegate-task-retry';
 export { createFilterAvailableSkillsHook } from './filter-available-skills';
+export { createGrepRenderMetadataHook } from './grep-render-metadata';
 export {
   ForegroundFallbackManager,
   isRateLimitError,

+ 14 - 0
src/index.ts

@@ -10,6 +10,7 @@ import {
   createChatHeadersHook,
   createDelegateTaskRetryHook,
   createFilterAvailableSkillsHook,
+  createGrepRenderMetadataHook,
   createJsonErrorRecoveryHook,
   createPhaseReminderHook,
   createPostFileToolNudgeHook,
@@ -170,6 +171,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const applyPatchHook = createApplyPatchHook(ctx);
   // Initialize JSON parse error recovery hook
   const jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
+  const grepRenderMetadataHook = createGrepRenderMetadataHook();
 
   // Initialize foreground fallback manager for runtime model switching
   const foregroundFallback = new ForegroundFallbackManager(
@@ -619,6 +621,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     // Post-tool hooks: retry guidance for delegation errors + file-tool nudge
     'tool.execute.after': async (input, output) => {
+      await grepRenderMetadataHook['tool.execute.after'](
+        input as {
+          tool: string;
+          args?: { pattern?: unknown };
+        },
+        output as {
+          title?: unknown;
+          output: unknown;
+          metadata?: unknown;
+        },
+      );
+
       await delegateTaskRetryHook['tool.execute.after'](
         input as { tool: string },
         output as { output: unknown },