Browse Source

fix: check config status directly in TUI

maou-shonen 3 months ago
parent
commit
1f2c9d5603
7 changed files with 130 additions and 229 deletions
  1. 25 0
      src/config/loader.test.ts
  2. 30 17
      src/config/loader.ts
  3. 2 17
      src/index.ts
  4. 7 120
      src/tui-state.test.ts
  5. 0 59
      src/tui-state.ts
  6. 44 3
      src/tui.test.ts
  7. 22 13
      src/tui.ts

+ 25 - 0
src/config/loader.test.ts

@@ -301,6 +301,31 @@ describe('onWarning callback', () => {
     expect(config).toEqual({});
   });
 
+  test('silent option suppresses console warnings', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      '{ invalid json }',
+    );
+
+    const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
+    try {
+      const warnings: ConfigLoadWarning[] = [];
+      const config = loadPluginConfig(projectDir, {
+        silent: true,
+        onWarning: (warning) => warnings.push(warning),
+      });
+
+      expect(warnings).toHaveLength(1);
+      expect(config).toEqual({});
+      expect(warnSpy).not.toHaveBeenCalled();
+    } finally {
+      warnSpy.mockRestore();
+    }
+  });
+
   test('read error calls onWarning with read-error', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');

+ 30 - 17
src/config/loader.ts

@@ -32,6 +32,11 @@ export interface LoadPluginConfigOptions {
    * The loader still falls back to defaults and continues normally.
    */
   onWarning?: (warning: ConfigLoadWarning) => void;
+
+  /**
+   * Suppress console warnings while still invoking onWarning.
+   */
+  silent?: boolean;
 }
 
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
@@ -48,7 +53,7 @@ const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
  */
 function loadConfigFromPath(
   configPath: string,
-  onWarning?: (warning: ConfigLoadWarning) => void,
+  options?: LoadPluginConfigOptions,
 ): PluginConfig | null {
   try {
     const content = fs.readFileSync(configPath, 'utf-8');
@@ -59,28 +64,32 @@ function loadConfigFromPath(
     } catch (error) {
       // Empty file or JSON parse error is treated as invalid-json
       const message = error instanceof Error ? error.message : String(error);
-      onWarning?.({
+      options?.onWarning?.({
         path: configPath,
         kind: 'invalid-json',
         message,
       });
-      console.warn(
-        `[oh-my-opencode-slim] Invalid JSON in ${configPath}:`,
-        message,
-      );
+      if (!options?.silent) {
+        console.warn(
+          `[oh-my-opencode-slim] Invalid JSON in ${configPath}:`,
+          message,
+        );
+      }
       return null;
     }
     const result = PluginConfigSchema.safeParse(rawConfig);
 
     if (!result.success) {
-      onWarning?.({
+      options?.onWarning?.({
         path: configPath,
         kind: 'invalid-schema',
         message: 'Config does not match schema',
         formatted: result.error.format(),
       });
-      console.warn(`[oh-my-opencode-slim] Invalid config at ${configPath}:`);
-      console.warn(result.error.format());
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] Invalid config at ${configPath}:`);
+        console.warn(result.error.format());
+      }
       return null;
     }
 
@@ -92,15 +101,17 @@ function loadConfigFromPath(
       'code' in error &&
       (error as NodeJS.ErrnoException).code !== 'ENOENT'
     ) {
-      onWarning?.({
+      options?.onWarning?.({
         path: configPath,
         kind: 'read-error',
         message: error.message,
       });
-      console.warn(
-        `[oh-my-opencode-slim] Error reading config from ${configPath}:`,
-        error.message,
-      );
+      if (!options?.silent) {
+        console.warn(
+          `[oh-my-opencode-slim] Error reading config from ${configPath}:`,
+          error.message,
+        );
+      }
     }
     return null;
   }
@@ -254,11 +265,11 @@ export function loadPluginConfig(
     findPluginConfigPaths(directory);
 
   let config: PluginConfig = userConfigPath
-    ? (loadConfigFromPath(userConfigPath, options?.onWarning) ?? {})
+    ? (loadConfigFromPath(userConfigPath, options) ?? {})
     : {};
 
   const projectConfig = projectConfigPath
-    ? loadConfigFromPath(projectConfigPath, options?.onWarning)
+    ? loadConfigFromPath(projectConfigPath, options)
     : null;
   if (projectConfig) {
     config = mergePluginConfigs(config, projectConfig);
@@ -292,7 +303,9 @@ export function loadPluginConfig(
         kind: 'missing-preset',
         message,
       });
-      console.warn(`[oh-my-opencode-slim] ${message}`);
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] ${message}`);
+      }
     }
   }
 

+ 2 - 17
src/index.ts

@@ -44,12 +44,7 @@ import {
   createPresetManager,
   createWebfetchTool,
 } from './tools';
-import {
-  createTuiProjectKey,
-  recordTuiAgentModel,
-  recordTuiAgentModels,
-  recordTuiConfigStatus,
-} from './tui-state';
+import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
@@ -152,17 +147,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let toolCount = 0;
 
   try {
-    let configInvalid = false;
-
-    config = loadPluginConfig(ctx.directory, {
-      onWarning: () => {
-        configInvalid = true;
-      },
-    });
-    recordTuiConfigStatus({
-      invalid: configInvalid,
-      projectKey: createTuiProjectKey(ctx.directory),
-    });
+    config = loadPluginConfig(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

+ 7 - 120
src/tui-state.test.ts

@@ -3,12 +3,9 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import {
-  createTuiProjectKey,
-  isTuiConfigInvalid,
   readTuiSnapshot,
   recordTuiAgentModel,
   recordTuiAgentModels,
-  recordTuiConfigStatus,
 } from './tui-state';
 
 let previousXdgDataHome: string | undefined;
@@ -65,106 +62,7 @@ describe('tui-state persistence', () => {
       explorer: 'openai/gpt-5.4-mini',
     });
   });
-
-  test('recordTuiConfigStatus sets configInvalid to true', () => {
-    recordTuiConfigStatus({ invalid: true });
-    expect(readTuiSnapshot().configInvalid).toBe(true);
-  });
-
-  test('recordTuiConfigStatus sets configInvalid to false', () => {
-    recordTuiConfigStatus({ invalid: true });
-    recordTuiConfigStatus({ invalid: false });
-    expect(readTuiSnapshot().configInvalid).toBe(false);
-  });
-
-  test('configInvalid defaults to false for old snapshots without the field', () => {
-    // Use suite-level tempDir; write old-format snapshot (no configInvalid field)
-    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.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');
+  test('ignores legacy config status fields in old snapshots', () => {
     const filePath = path.join(
       tempDir,
       'opencode',
@@ -178,26 +76,15 @@ describe('tui-state project-scoped configInvalid', () => {
       JSON.stringify({
         version: 1,
         updatedAt: Date.now(),
-        agentModels: {},
-        configInvalidByProject: { [projectKey]: true, invalid: 'yes' },
+        agentModels: { explorer: 'openai/gpt-5.4-mini' },
+        configInvalid: true,
+        configInvalidByProject: { old: true },
       }),
     );
 
-    expect(readTuiSnapshot().configInvalidByProject).toEqual({
-      [projectKey]: true,
+    const snapshot = readTuiSnapshot();
+    expect(snapshot.agentModels).toEqual({
+      explorer: 'openai/gpt-5.4-mini',
     });
   });
-
-  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);
-  });
 });

+ 0 - 59
src/tui-state.ts

@@ -1,4 +1,3 @@
-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,8 +6,6 @@ export interface TuiSnapshot {
   version: 1;
   updatedAt: number;
   agentModels: Record<string, string>;
-  configInvalid: boolean;
-  configInvalidByProject: Record<string, boolean>;
 }
 
 const STATE_DIR = 'oh-my-opencode-slim';
@@ -24,49 +21,11 @@ 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: {},
   };
 }
 
@@ -79,11 +38,6 @@ function parseSnapshot(value: string): TuiSnapshot {
     updatedAt:
       typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(),
     agentModels: parsed.agentModels ?? {},
-    configInvalid:
-      typeof parsed.configInvalid === 'boolean' ? parsed.configInvalid : false,
-    configInvalidByProject: parseConfigInvalidByProject(
-      parsed.configInvalidByProject,
-    ),
   };
 }
 
@@ -136,16 +90,3 @@ export function recordTuiAgentModel(input: {
     snapshot.agentModels[input.agentName] = input.model;
   });
 }
-
-export function recordTuiConfigStatus(input: {
-  invalid: boolean;
-  projectKey?: string;
-}): void {
-  updateSnapshot((snapshot) => {
-    if (input.projectKey) {
-      snapshot.configInvalidByProject[input.projectKey] = input.invalid;
-    } else {
-      snapshot.configInvalid = input.invalid;
-    }
-  });
-}

+ 44 - 3
src/tui.test.ts

@@ -1,5 +1,12 @@
 import { describe, expect, test } from 'bun:test';
-import { formatSidebarModelName, getSidebarAgentNames } from './tui';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  formatSidebarModelName,
+  getSidebarAgentNames,
+  readConfigInvalid,
+} from './tui';
 import type { TuiSnapshot } from './tui-state';
 
 function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
@@ -7,8 +14,6 @@ function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
     version: 1,
     updatedAt: 0,
     agentModels: {},
-    configInvalid: false,
-    configInvalidByProject: {},
     ...overrides,
   };
 }
@@ -54,3 +59,39 @@ describe('formatSidebarModelName', () => {
     expect(formatSidebarModelName('pending')).toBe('pending');
   });
 });
+
+describe('readConfigInvalid', () => {
+  test('detects invalid config from the current directory without persisted state', () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
+    try {
+      const projectDir = path.join(tempDir, 'project');
+      const configDir = path.join(projectDir, '.opencode');
+      fs.mkdirSync(configDir, { recursive: true });
+      fs.writeFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
+      );
+
+      expect(readConfigInvalid(projectDir)).toBe(true);
+    } finally {
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
+
+  test('returns false for valid config', () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-'));
+    try {
+      const projectDir = path.join(tempDir, 'project');
+      const configDir = path.join(projectDir, '.opencode');
+      fs.mkdirSync(configDir, { recursive: true });
+      fs.writeFileSync(
+        path.join(configDir, 'oh-my-opencode-slim.json'),
+        JSON.stringify({ agents: { oracle: { model: 'valid/model' } } }),
+      );
+
+      expect(readConfigInvalid(projectDir)).toBe(false);
+    } finally {
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
+});

+ 22 - 13
src/tui.ts

@@ -2,9 +2,8 @@ import type { TuiPluginModule } from '@opencode-ai/plugin/tui';
 import type { JSX } from '@opentui/solid';
 import { createElement, insert, setProp } from '@opentui/solid';
 import { DEFAULT_DISABLED_AGENTS, SUBAGENT_NAMES } from './config/constants';
+import { loadPluginConfig } from './config/loader';
 import {
-  createTuiProjectKey,
-  isTuiConfigInvalid,
   readTuiSnapshot,
   readTuiSnapshotAsync,
   type TuiSnapshot,
@@ -103,9 +102,9 @@ function renderSidebar(
     text: unknown;
     textMuted: unknown;
   },
-  projectKey?: string,
+  configInvalid: boolean,
 ): JSX.Element {
-  const configStatusRow = buildConfigStatusRow(snapshot, theme, projectKey);
+  const configStatusRow = buildConfigStatusRow(configInvalid, theme);
 
   return box(
     {
@@ -152,11 +151,10 @@ function renderSidebar(
 }
 
 function buildConfigStatusRow(
-  snapshot: TuiSnapshot,
+  configInvalid: boolean,
   theme: { textMuted: unknown },
-  projectKey?: string,
 ): JSX.Element | null {
-  if (!isTuiConfigInvalid(snapshot, projectKey)) return null;
+  if (!configInvalid) return null;
 
   return box(
     {
@@ -172,21 +170,32 @@ function buildConfigStatusRow(
   );
 }
 
-function resolveCurrentProjectKey(api: {
-  state?: { path?: { directory?: string } };
-}): string {
-  return createTuiProjectKey(api.state?.path?.directory ?? process.cwd());
+export function readConfigInvalid(directory: string): boolean {
+  let configInvalid = false;
+  loadPluginConfig(directory, {
+    silent: true,
+    onWarning: () => {
+      configInvalid = true;
+    },
+  });
+  return configInvalid;
 }
 
 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 configDirectory = api.state.path.directory;
+    let configInvalid = readConfigInvalid(configDirectory);
     let snapshot = readTuiSnapshot();
     const renderTimer = setInterval(async () => {
       try {
         snapshot = await readTuiSnapshotAsync();
+        const currentDirectory = api.state.path.directory;
+        if (currentDirectory !== configDirectory) {
+          configDirectory = currentDirectory;
+          configInvalid = readConfigInvalid(configDirectory);
+        }
         api.renderer.requestRender();
       } catch {
         // Ignore render errors; this is best-effort live status.
@@ -205,7 +214,7 @@ const plugin: TuiPluginModule & { id: string } = {
             snapshot,
             version,
             api.theme.current,
-            projectKey,
+            configInvalid,
           );
         },
       },