Ver Fonte

fix: scope TUI config status by project

maou-shonen há 3 meses atrás
pai
commit
32d168ee3f
5 ficheiros alterados com 183 adições e 6 exclusões
  1. 5 1
      src/index.ts
  2. 107 0
      src/tui-state.test.ts
  3. 51 2
      src/tui-state.ts
  4. 1 0
      src/tui.test.ts
  5. 19 3
      src/tui.ts

+ 5 - 1
src/index.ts

@@ -45,6 +45,7 @@ import {
   createWebfetchTool,
 } from './tools';
 import {
+  createTuiProjectKey,
   recordTuiAgentModel,
   recordTuiAgentModels,
   recordTuiConfigStatus,
@@ -158,7 +159,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         configInvalid = true;
       },
     });
-    recordTuiConfigStatus({ invalid: configInvalid });
+    recordTuiConfigStatus({
+      invalid: configInvalid,
+      projectKey: createTuiProjectKey(ctx.directory),
+    });
 
     // Safety net: if a runtime preset was set via /preset command and
     // OpenCode ever fully re-runs the plugin function (not just the

+ 107 - 0
src/tui-state.test.ts

@@ -3,6 +3,8 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import {
+  createTuiProjectKey,
+  isTuiConfigInvalid,
   readTuiSnapshot,
   recordTuiAgentModel,
   recordTuiAgentModels,
@@ -94,3 +96,108 @@ describe('tui-state persistence', () => {
     expect(snapshot.configInvalid).toBe(false);
   });
 });
+
+describe('tui-state project-scoped configInvalid', () => {
+  test('recordTuiConfigStatus with projectKey sets configInvalidByProject', () => {
+    const projectKey = createTuiProjectKey('/tmp/project-a');
+    recordTuiConfigStatus({ invalid: true, projectKey });
+
+    const snapshot = readTuiSnapshot();
+    expect(snapshot.configInvalidByProject[projectKey]).toBe(true);
+    expect(snapshot.configInvalid).toBe(false); // legacy unchanged
+  });
+
+  test('recordTuiConfigStatus with projectKey false clears that key', () => {
+    const projectKey = createTuiProjectKey('/tmp/project-b');
+    recordTuiConfigStatus({ invalid: true, projectKey });
+    recordTuiConfigStatus({ invalid: false, projectKey });
+
+    const snapshot = readTuiSnapshot();
+    expect(snapshot.configInvalidByProject[projectKey]).toBe(false);
+  });
+
+  test('different project keys do not overwrite each other', () => {
+    const keyA = createTuiProjectKey('/tmp/project-c');
+    const keyB = createTuiProjectKey('/tmp/project-d');
+    recordTuiConfigStatus({ invalid: true, projectKey: keyA });
+    recordTuiConfigStatus({ invalid: false, projectKey: keyB });
+
+    const snapshot = readTuiSnapshot();
+    expect(snapshot.configInvalidByProject[keyA]).toBe(true);
+    expect(snapshot.configInvalidByProject[keyB]).toBe(false);
+  });
+
+  test('isTuiConfigInvalid uses configInvalidByProject when projectKey given', () => {
+    const projectKey = createTuiProjectKey('/tmp/project-e');
+    recordTuiConfigStatus({ invalid: true, projectKey });
+
+    const snapshot = readTuiSnapshot();
+    expect(isTuiConfigInvalid(snapshot, projectKey)).toBe(true);
+    expect(
+      isTuiConfigInvalid(snapshot, createTuiProjectKey('/tmp/other')),
+    ).toBe(false);
+  });
+
+  test('isTuiConfigInvalid falls back to legacy configInvalid when no projectKey', () => {
+    recordTuiConfigStatus({ invalid: true }); // no projectKey = legacy
+    const snapshot = readTuiSnapshot();
+    expect(isTuiConfigInvalid(snapshot)).toBe(true);
+    expect(isTuiConfigInvalid(snapshot, undefined)).toBe(true);
+  });
+
+  test('old snapshot without configInvalidByProject defaults to empty object', () => {
+    const filePath = path.join(
+      tempDir,
+      'opencode',
+      'storage',
+      'oh-my-opencode-slim',
+      'tui-state.json',
+    );
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+    fs.writeFileSync(
+      filePath,
+      JSON.stringify({ version: 1, updatedAt: Date.now(), agentModels: {} }),
+    );
+
+    const snapshot = readTuiSnapshot();
+    expect(snapshot.configInvalidByProject).toEqual({});
+  });
+
+  test('malformed configInvalidByProject entries are ignored', () => {
+    const projectKey = createTuiProjectKey('/tmp/project-f');
+    const filePath = path.join(
+      tempDir,
+      'opencode',
+      'storage',
+      'oh-my-opencode-slim',
+      'tui-state.json',
+    );
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+    fs.writeFileSync(
+      filePath,
+      JSON.stringify({
+        version: 1,
+        updatedAt: Date.now(),
+        agentModels: {},
+        configInvalidByProject: { [projectKey]: true, invalid: 'yes' },
+      }),
+    );
+
+    expect(readTuiSnapshot().configInvalidByProject).toEqual({
+      [projectKey]: true,
+    });
+  });
+
+  test('createTuiProjectKey produces consistent hash for same path', () => {
+    const key1 = createTuiProjectKey('/tmp/same');
+    const key2 = createTuiProjectKey('/tmp/same');
+    expect(key1).toBe(key2);
+    expect(key1).toHaveLength(16);
+  });
+
+  test('createTuiProjectKey produces different hash for different paths', () => {
+    const key1 = createTuiProjectKey('/tmp/diff1');
+    const key2 = createTuiProjectKey('/tmp/diff2');
+    expect(key1).not.toBe(key2);
+  });
+});

+ 51 - 2
src/tui-state.ts

@@ -1,3 +1,4 @@
+import * as crypto from 'node:crypto';
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
@@ -7,6 +8,7 @@ export interface TuiSnapshot {
   updatedAt: number;
   agentModels: Record<string, string>;
   configInvalid: boolean;
+  configInvalidByProject: Record<string, boolean>;
 }
 
 const STATE_DIR = 'oh-my-opencode-slim';
@@ -22,12 +24,49 @@ export function getTuiStatePath(): string {
   return path.join(dataDir(), 'opencode', 'storage', STATE_DIR, STATE_FILE);
 }
 
+/**
+ * Create a normalized project key from a directory path.
+ * Uses SHA-256 hash of the resolved absolute path, truncated to 16 hex chars.
+ */
+export function createTuiProjectKey(directory: string): string {
+  const resolved = path.resolve(directory);
+  return crypto
+    .createHash('sha256')
+    .update(resolved)
+    .digest('hex')
+    .slice(0, 16);
+}
+
+function parseConfigInvalidByProject(value: unknown): Record<string, boolean> {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+
+  return Object.fromEntries(
+    Object.entries(value).filter(([, invalid]) => typeof invalid === 'boolean'),
+  ) as Record<string, boolean>;
+}
+
+/**
+ * Determine whether config is invalid for a given project.
+ * When projectKey is provided, checks configInvalidByProject[projectKey].
+ * Falls back to legacy snapshot.configInvalid when no projectKey is given.
+ */
+export function isTuiConfigInvalid(
+  snapshot: TuiSnapshot,
+  projectKey?: string,
+): boolean {
+  if (projectKey) {
+    return snapshot.configInvalidByProject[projectKey] ?? false;
+  }
+  return snapshot.configInvalid;
+}
+
 function emptySnapshot(): TuiSnapshot {
   return {
     version: 1,
     updatedAt: Date.now(),
     agentModels: {},
     configInvalid: false,
+    configInvalidByProject: {},
   };
 }
 
@@ -42,6 +81,9 @@ function parseSnapshot(value: string): TuiSnapshot {
     agentModels: parsed.agentModels ?? {},
     configInvalid:
       typeof parsed.configInvalid === 'boolean' ? parsed.configInvalid : false,
+    configInvalidByProject: parseConfigInvalidByProject(
+      parsed.configInvalidByProject,
+    ),
   };
 }
 
@@ -95,8 +137,15 @@ export function recordTuiAgentModel(input: {
   });
 }
 
-export function recordTuiConfigStatus(input: { invalid: boolean }): void {
+export function recordTuiConfigStatus(input: {
+  invalid: boolean;
+  projectKey?: string;
+}): void {
   updateSnapshot((snapshot) => {
-    snapshot.configInvalid = input.invalid;
+    if (input.projectKey) {
+      snapshot.configInvalidByProject[input.projectKey] = input.invalid;
+    } else {
+      snapshot.configInvalid = input.invalid;
+    }
   });
 }

+ 1 - 0
src/tui.test.ts

@@ -8,6 +8,7 @@ function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
     updatedAt: 0,
     agentModels: {},
     configInvalid: false,
+    configInvalidByProject: {},
     ...overrides,
   };
 }

+ 19 - 3
src/tui.ts

@@ -3,6 +3,8 @@ import type { JSX } from '@opentui/solid';
 import { createElement, insert, setProp } from '@opentui/solid';
 import { DEFAULT_DISABLED_AGENTS, SUBAGENT_NAMES } from './config/constants';
 import {
+  createTuiProjectKey,
+  isTuiConfigInvalid,
   readTuiSnapshot,
   readTuiSnapshotAsync,
   type TuiSnapshot,
@@ -101,8 +103,9 @@ function renderSidebar(
     text: unknown;
     textMuted: unknown;
   },
+  projectKey?: string,
 ): JSX.Element {
-  const configStatusRow = buildConfigStatusRow(snapshot, theme);
+  const configStatusRow = buildConfigStatusRow(snapshot, theme, projectKey);
 
   return box(
     {
@@ -151,8 +154,9 @@ function renderSidebar(
 function buildConfigStatusRow(
   snapshot: TuiSnapshot,
   theme: { textMuted: unknown },
+  projectKey?: string,
 ): JSX.Element | null {
-  if (!snapshot.configInvalid) return null;
+  if (!isTuiConfigInvalid(snapshot, projectKey)) return null;
 
   return box(
     {
@@ -168,10 +172,17 @@ function buildConfigStatusRow(
   );
 }
 
+function resolveCurrentProjectKey(api: {
+  state?: { path?: { directory?: string } };
+}): string {
+  return createTuiProjectKey(api.state?.path?.directory ?? process.cwd());
+}
+
 const plugin: TuiPluginModule & { id: string } = {
   id: `${PLUGIN_NAME}:tui`,
   tui: async (api, _options, meta) => {
     const version = meta.version ?? (await readPackageVersion()) ?? 'dev';
+    const projectKey = resolveCurrentProjectKey(api);
     let snapshot = readTuiSnapshot();
     const renderTimer = setInterval(async () => {
       try {
@@ -190,7 +201,12 @@ const plugin: TuiPluginModule & { id: string } = {
       order: 900,
       slots: {
         sidebar_content() {
-          return renderSidebar(snapshot, version, api.theme.current);
+          return renderSidebar(
+            snapshot,
+            version,
+            api.theme.current,
+            projectKey,
+          );
         },
       },
     });