Explorar el Código

Merge pull request #443 from maou-shonen/feat/tui-invalid-config-warning

Show invalid config status in the TUI sidebar
Alvin hace 3 meses
padre
commit
876687a5fa
Se han modificado 7 ficheros con 463 adiciones y 22 borrados
  1. 2 0
      docs/configuration.md
  2. 5 1
      src/config/index.ts
  3. 210 0
      src/config/loader.test.ts
  4. 96 14
      src/config/loader.ts
  5. 25 0
      src/tui-state.test.ts
  6. 69 6
      src/tui.test.ts
  7. 56 1
      src/tui.ts

+ 2 - 0
docs/configuration.md

@@ -15,6 +15,8 @@ Complete reference for all configuration files and options in oh-my-opencode-sli
 
 > **💡 JSONC recommended:** Use the `.jsonc` extension to add comments and trailing commas. If both `.jsonc` and `.json` exist, `.jsonc` takes precedence.
 
+If OmO-slim detects an invalid plugin config for the current project, the TUI sidebar shows a warning. Run `oh-my-opencode-slim doctor` from your project root for full diagnostics.
+
 ---
 
 ## Prompt Overriding

+ 5 - 1
src/config/index.ts

@@ -1,5 +1,9 @@
 export * from './constants';
 export * from './council-schema';
-export { deepMerge, loadAgentPrompt, loadPluginConfig } from './loader';
+export {
+  deepMerge,
+  loadAgentPrompt,
+  loadPluginConfig,
+} from './loader';
 export * from './schema';
 export { getAgentOverride, getCustomAgentNames } from './utils';

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

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
+import type { ConfigLoadWarning } from './loader';
 import { loadAgentPrompt, loadPluginConfig } from './loader';
 
 // Test deepMerge indirectly through loadPluginConfig behavior
@@ -239,6 +240,215 @@ describe('loadPluginConfig', () => {
   });
 });
 
+describe('onWarning callback', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onwarning-test-'));
+    originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
+    process.env.XDG_CONFIG_HOME = tempDir;
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('invalid schema calls onWarning with invalid-schema', () => {
+    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'),
+      JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.path).toBe(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+    );
+    expect(warnings[0]?.message).toBe('Config does not match schema');
+    expect(config).toEqual({});
+  });
+
+  test('invalid JSON calls onWarning with invalid-json', () => {
+    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 warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-json');
+    expect(warnings[0]?.path).toBe(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+    );
+    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');
+    const configPath = path.join(projectConfigDir, 'oh-my-opencode-slim.json');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(configPath, JSON.stringify({}));
+
+    const originalReadFileSync = fs.readFileSync;
+    const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
+      ...args: Parameters<typeof fs.readFileSync>
+    ) => {
+      const [filePath] = args;
+      if (filePath === configPath) {
+        const error = new Error('Permission denied') as NodeJS.ErrnoException;
+        error.code = 'EACCES';
+        throw error;
+      }
+
+      return originalReadFileSync(...args);
+    }) as typeof fs.readFileSync);
+
+    try {
+      const warnings: ConfigLoadWarning[] = [];
+      const config = loadPluginConfig(projectDir, {
+        onWarning: (warning) => warnings.push(warning),
+      });
+
+      expect(warnings).toHaveLength(1);
+      expect(warnings[0]?.kind).toBe('read-error');
+      expect(warnings[0]?.path).toBe(configPath);
+      expect(warnings[0]?.message).toBe('Permission denied');
+      expect(config).toEqual({});
+    } finally {
+      readSpy.mockRestore();
+    }
+  });
+
+  test('missing preset calls onWarning with missing-preset', () => {
+    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'),
+      JSON.stringify({
+        preset: 'nonexistent',
+        presets: { other: { oracle: { model: 'other' } } },
+        agents: { oracle: { model: 'root' } },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('missing-preset');
+    expect(warnings[0]?.message).toContain('Preset "nonexistent" not found');
+    expect(config.agents?.oracle?.model).toBe('root');
+  });
+
+  test('silent: true on missing preset still calls onWarning but not console.warn', () => {
+    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'),
+      JSON.stringify({
+        preset: 'nonexistent',
+        presets: { other: { oracle: { model: 'other' } } },
+        agents: { oracle: { model: 'root' } },
+      }),
+    );
+
+    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(warnings[0]?.kind).toBe('missing-preset');
+      expect(config.agents?.oracle?.model).toBe('root');
+      expect(warnSpy).not.toHaveBeenCalled();
+    } finally {
+      warnSpy.mockRestore();
+    }
+  });
+
+  test('valid config does not call onWarning', () => {
+    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'),
+      JSON.stringify({ agents: { oracle: { model: 'valid/model' } } }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(0);
+    expect(config.agents?.oracle?.model).toBe('valid/model');
+  });
+
+  test('no options object does not break loadPluginConfig', () => {
+    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'),
+      JSON.stringify({ agents: { oracle: { model: 'model' } } }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('model');
+  });
+});
+
 describe('deepMerge behavior', () => {
   let tempDir: string;
   let userConfigDir: string;

+ 96 - 14
src/config/loader.ts

@@ -4,6 +4,41 @@ import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
 import { type PluginConfig, PluginConfigSchema } from './schema';
 
+/**
+ * Warning kinds produced during config loading.
+ */
+export type ConfigLoadWarningKind =
+  | 'invalid-json'
+  | 'invalid-schema'
+  | 'read-error'
+  | 'missing-preset';
+
+/**
+ * A warning emitted while loading plugin configuration.
+ */
+export interface ConfigLoadWarning {
+  path: string;
+  kind: ConfigLoadWarningKind;
+  message: string;
+  formatted?: unknown;
+}
+
+/**
+ * Options for loadPluginConfig.
+ */
+export interface LoadPluginConfigOptions {
+  /**
+   * Called with a warning whenever config loading produces a non-fatal issue.
+   * 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';
 
 /**
@@ -13,18 +48,48 @@ const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
  * Logs warnings for validation errors and unexpected read errors.
  *
  * @param configPath - Absolute path to the config file
+ * @param onWarning - Optional callback for warnings
  * @returns Validated config object, or null if loading failed
  */
-function loadConfigFromPath(configPath: string): PluginConfig | null {
+function loadConfigFromPath(
+  configPath: string,
+  options?: LoadPluginConfigOptions,
+): PluginConfig | null {
   try {
     const content = fs.readFileSync(configPath, 'utf-8');
     // Use stripJsonComments to support JSONC format (comments and trailing commas)
-    const rawConfig = JSON.parse(stripJsonComments(content));
+    let rawConfig: unknown;
+    try {
+      rawConfig = JSON.parse(stripJsonComments(content));
+    } catch (error) {
+      // Empty file or JSON parse error is treated as invalid-json
+      const message = error instanceof Error ? error.message : String(error);
+      options?.onWarning?.({
+        path: configPath,
+        kind: 'invalid-json',
+        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) {
-      console.warn(`[oh-my-opencode-slim] Invalid config at ${configPath}:`);
-      console.warn(result.error.format());
+      options?.onWarning?.({
+        path: configPath,
+        kind: 'invalid-schema',
+        message: 'Config does not match schema',
+        formatted: result.error.format(),
+      });
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] Invalid config at ${configPath}:`);
+        console.warn(result.error.format());
+      }
       return null;
     }
 
@@ -36,10 +101,17 @@ function loadConfigFromPath(configPath: string): PluginConfig | null {
       'code' in error &&
       (error as NodeJS.ErrnoException).code !== 'ENOENT'
     ) {
-      console.warn(
-        `[oh-my-opencode-slim] Error reading config from ${configPath}:`,
-        error.message,
-      );
+      options?.onWarning?.({
+        path: configPath,
+        kind: 'read-error',
+        message: error.message,
+      });
+      if (!options?.silent) {
+        console.warn(
+          `[oh-my-opencode-slim] Error reading config from ${configPath}:`,
+          error.message,
+        );
+      }
     }
     return null;
   }
@@ -182,18 +254,22 @@ export function deepMerge<T extends Record<string, unknown>>(
  * deep-merged, while top-level arrays are replaced entirely by project config.
  *
  * @param directory - Project directory to search for .opencode config
+ * @param options - Optional load options including onWarning callback
  * @returns Merged plugin configuration (empty object if no configs found)
  */
-export function loadPluginConfig(directory: string): PluginConfig {
+export function loadPluginConfig(
+  directory: string,
+  options?: LoadPluginConfigOptions,
+): PluginConfig {
   const { userConfigPath, projectConfigPath } =
     findPluginConfigPaths(directory);
 
   let config: PluginConfig = userConfigPath
-    ? (loadConfigFromPath(userConfigPath) ?? {})
+    ? (loadConfigFromPath(userConfigPath, options) ?? {})
     : {};
 
   const projectConfig = projectConfigPath
-    ? loadConfigFromPath(projectConfigPath)
+    ? loadConfigFromPath(projectConfigPath, options)
     : null;
   if (projectConfig) {
     config = mergePluginConfigs(config, projectConfig);
@@ -221,9 +297,15 @@ export function loadPluginConfig(directory: string): PluginConfig {
       const availablePresets = config.presets
         ? Object.keys(config.presets).join(', ')
         : 'none';
-      console.warn(
-        `[oh-my-opencode-slim] Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`,
-      );
+      const message = `Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`;
+      options?.onWarning?.({
+        path: projectConfigPath ?? userConfigPath ?? '',
+        kind: 'missing-preset',
+        message,
+      });
+      if (!options?.silent) {
+        console.warn(`[oh-my-opencode-slim] ${message}`);
+      }
     }
   }
 

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

@@ -62,4 +62,29 @@ describe('tui-state persistence', () => {
       explorer: 'openai/gpt-5.4-mini',
     });
   });
+  test('ignores legacy config status fields in old snapshots', () => {
+    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: { explorer: 'openai/gpt-5.4-mini' },
+        configInvalid: true,
+        configInvalidByProject: { old: true },
+      }),
+    );
+
+    const snapshot = readTuiSnapshot();
+    expect(snapshot.agentModels).toEqual({
+      explorer: 'openai/gpt-5.4-mini',
+    });
+  });
 });

+ 69 - 6
src/tui.test.ts

@@ -1,12 +1,20 @@
-import { describe, expect, test } from 'bun:test';
-import { formatSidebarModelName, getSidebarAgentNames } from './tui';
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+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(agentModels: TuiSnapshot['agentModels']): TuiSnapshot {
+function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
   return {
     version: 1,
     updatedAt: 0,
-    agentModels,
+    agentModels: {},
+    ...overrides,
   };
 }
 
@@ -14,8 +22,10 @@ describe('tui sidebar agents', () => {
   test('hides disabled agents when models are persisted explicitly', () => {
     const agentNames = getSidebarAgentNames(
       createSnapshot({
-        explorer: 'openai/gpt-5.4-mini',
-        fixer: 'openai/gpt-5.4-mini',
+        agentModels: {
+          explorer: 'openai/gpt-5.4-mini',
+          fixer: 'openai/gpt-5.4-mini',
+        },
       }),
     );
 
@@ -49,3 +59,56 @@ describe('formatSidebarModelName', () => {
     expect(formatSidebarModelName('pending')).toBe('pending');
   });
 });
+
+describe('readConfigInvalid', () => {
+  let originalEnv: typeof process.env;
+  let configHome: string;
+
+  beforeEach(() => {
+    originalEnv = { ...process.env };
+    // Isolate from real user config and env presets
+    delete process.env.OPENCODE_CONFIG_DIR;
+    delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
+    configHome = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-env-'));
+    process.env.XDG_CONFIG_HOME = configHome;
+  });
+
+  afterEach(() => {
+    fs.rmSync(configHome, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  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 });
+    }
+  });
+});

+ 56 - 1
src/tui.ts

@@ -2,6 +2,7 @@ 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 {
   readTuiSnapshot,
   readTuiSnapshotAsync,
@@ -9,6 +10,7 @@ import {
 } from './tui-state';
 
 const PLUGIN_NAME = 'oh-my-opencode-slim';
+const CONFIG_WARNING_COLOR = 'orange';
 const FALLBACK_SIDEBAR_AGENTS = SUBAGENT_NAMES.filter(
   (agent) =>
     agent !== 'councillor' &&
@@ -64,6 +66,12 @@ function truncate(value: string, max = 24): string {
   return value.length > max ? `${value.slice(0, max - 1)}…` : value;
 }
 
+function getTuiDirectory(api: {
+  state?: { path?: { directory?: string } };
+}): string {
+  return api.state?.path?.directory ?? process.cwd();
+}
+
 export function formatSidebarModelName(model: string): string {
   const lastSlash = model.lastIndexOf('/');
   return lastSlash === -1 ? model : model.slice(lastSlash + 1);
@@ -101,7 +109,10 @@ function renderSidebar(
     text: unknown;
     textMuted: unknown;
   },
+  configInvalid: boolean,
 ): JSX.Element {
+  const configStatusRow = buildConfigStatusRow(configInvalid, theme);
+
   return box(
     {
       width: '100%',
@@ -129,6 +140,7 @@ function renderSidebar(
           text({ fg: theme.textMuted }, [`v${version}`]),
         ],
       ),
+      configStatusRow,
       box({ width: '100%', marginTop: 1 }, [
         text({ fg: theme.text }, ['Agents']),
       ]),
@@ -145,14 +157,52 @@ function renderSidebar(
   );
 }
 
+function buildConfigStatusRow(
+  configInvalid: boolean,
+  theme: { textMuted: unknown },
+): JSX.Element | null {
+  if (!configInvalid) return null;
+
+  return box(
+    {
+      width: '100%',
+      flexDirection: 'column',
+      marginTop: 1,
+      marginBottom: 1,
+    },
+    [
+      text({ fg: CONFIG_WARNING_COLOR }, ['Config invalid']),
+      text({ fg: theme.textMuted }, ['Run doctor for details']),
+    ],
+  );
+}
+
+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';
+    let configDirectory = getTuiDirectory(api);
+    let configInvalid = readConfigInvalid(configDirectory);
     let snapshot = readTuiSnapshot();
     const renderTimer = setInterval(async () => {
       try {
         snapshot = await readTuiSnapshotAsync();
+        const currentDirectory = getTuiDirectory(api);
+        if (currentDirectory !== configDirectory) {
+          configDirectory = currentDirectory;
+          configInvalid = readConfigInvalid(configDirectory);
+        }
         api.renderer.requestRender();
       } catch {
         // Ignore render errors; this is best-effort live status.
@@ -167,7 +217,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,
+            configInvalid,
+          );
         },
       },
     });