Просмотр исходного кода

feat(tui): position the sidebar slot by plugin-list index

The sidebar section order now follows slim's index in the host's
effective plugin list (api.tuiConfig.plugin): index 0 renders at 110
(right after the host's context section, above most third-party
plugins) and each later index adds 100. Tuples [spec, options] are
matched, scoped npm packages never match by basename, and an absent
spec keeps the historic default (900). v1 hosts only: the v2 slot
claim API has no order parameter.
dhaern 1 день назад
Родитель
Сommit
02c4e3bcca
2 измененных файлов с 176 добавлено и 1 удалено
  1. 129 0
      src/tui.test.ts
  2. 47 1
      src/tui.ts

+ 129 - 0
src/tui.test.ts

@@ -17,6 +17,7 @@ import {
   isRefreshCurrent,
   readCompactSidebar,
   readConfigInvalid,
+  resolveSidebarSlotOrder,
   splitSidebarModelId,
   syncTmuxPaneRegistration,
   default as tuiPlugin,
@@ -841,3 +842,131 @@ describe('dual-contract plugin module', () => {
     }
   });
 });
+
+describe('resolveSidebarSlotOrder', () => {
+  const NAME = 'oh-my-opencode-slim';
+
+  test('index 0 lands at 110, right after the host context section', () => {
+    expect(resolveSidebarSlotOrder([`file:///w/${NAME}`], NAME)).toBe(110);
+  });
+
+  test('later indexes map to later bands of 100', () => {
+    expect(
+      resolveSidebarSlotOrder(
+        ['@cortexkit/opencode-magic-context@0.42.4', `file:///w/${NAME}`],
+        NAME,
+      ),
+    ).toBe(210);
+  });
+
+  test('falls back to 900 when the list is missing or not an array', () => {
+    expect(resolveSidebarSlotOrder(undefined, NAME)).toBe(900);
+    expect(resolveSidebarSlotOrder(null, NAME)).toBe(900);
+    expect(resolveSidebarSlotOrder('not-a-list', NAME)).toBe(900);
+  });
+
+  test('falls back to 900 when the spec is absent from the list', () => {
+    expect(
+      resolveSidebarSlotOrder(['@cortexkit/opencode-magic-context'], NAME),
+    ).toBe(900);
+  });
+
+  test('matches npm specs with versions', () => {
+    expect(
+      resolveSidebarSlotOrder(['other-plugin', `${NAME}@2.2.20`], NAME),
+    ).toBe(210);
+  });
+
+  test('matches [spec, options] tuple entries the installer generates', () => {
+    expect(
+      resolveSidebarSlotOrder(
+        [
+          ['@cortexkit/opencode-magic-context@0.42.4', {}],
+          [`file:///home/raxxor/workspace/${NAME}`, { flag: true }],
+        ],
+        NAME,
+      ),
+    ).toBe(210);
+  });
+
+  test('does not match a scoped package sharing the basename', () => {
+    expect(resolveSidebarSlotOrder([`@other/${NAME}`, 'unrelated'], NAME)).toBe(
+      900,
+    );
+  });
+
+  test('file:// specs with a trailing slash still match', () => {
+    expect(resolveSidebarSlotOrder([`file:///w/${NAME}/`], NAME)).toBe(110);
+  });
+
+  test('non-string and malformed entries are skipped without shifting index', () => {
+    expect(
+      resolveSidebarSlotOrder(
+        [{ not: 'a spec' }, 42, [''], `file:///w/${NAME}`],
+        NAME,
+      ),
+    ).toBe(410);
+  });
+
+  test('v1 registration wires tuiConfig.plugin into the slot order', async () => {
+    const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-v1-'));
+    try {
+      const captured: { order?: number }[] = [];
+      await tuiPlugin.tui(
+        {
+          state: { path: { directory: projectDir } },
+          route: { current: { name: 'home' } },
+          lifecycle: { onDispose: () => () => {} },
+          renderer: { requestRender: () => {} },
+          slots: {
+            register: (plugin: { order?: number }) => {
+              captured.push({ order: plugin.order });
+              return 'test-slot';
+            },
+          },
+          tuiConfig: {
+            plugin: [
+              '@cortexkit/opencode-magic-context@0.42.4',
+              'file:///home/raxxor/workspace/oh-my-opencode-slim',
+            ],
+          },
+          theme: { current: {} },
+        } as unknown as Parameters<typeof tuiPlugin.tui>[0],
+        {},
+        { version: 'test' } as Parameters<typeof tuiPlugin.tui>[2],
+      );
+
+      expect(captured[0]?.order).toBe(210);
+    } finally {
+      fs.rmSync(projectDir, { recursive: true, force: true });
+    }
+  });
+
+  test('v1 registration falls back to 900 without tuiConfig', async () => {
+    const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-v1-'));
+    try {
+      const captured: { order?: number }[] = [];
+      await tuiPlugin.tui(
+        {
+          state: { path: { directory: projectDir } },
+          route: { current: { name: 'home' } },
+          lifecycle: { onDispose: () => () => {} },
+          renderer: { requestRender: () => {} },
+          slots: {
+            register: (plugin: { order?: number }) => {
+              captured.push({ order: plugin.order });
+              return 'test-slot';
+            },
+          },
+          theme: { current: {} },
+        } as unknown as Parameters<typeof tuiPlugin.tui>[0],
+        {},
+        { version: 'test' } as Parameters<typeof tuiPlugin.tui>[2],
+      );
+
+      expect(captured[0]?.order).toBe(900);
+    } finally {
+      fs.rmSync(projectDir, { recursive: true, force: true });
+    }
+  });
+});

+ 47 - 1
src/tui.ts

@@ -1,3 +1,4 @@
+import * as path from 'node:path';
 import type {
   TuiCommand,
   TuiPlugin,
@@ -676,6 +677,51 @@ export function readCompactSidebar(directory: string): boolean {
   return readConfigState(directory).compactSidebar;
 }
 
+const DEFAULT_SIDEBAR_SLOT_ORDER = 900;
+
+/** Extract the spec string from a plugin-list entry: `"spec"` or `[spec, options]`. */
+function pluginSpecOf(entry: unknown): string | undefined {
+  if (typeof entry === 'string') return entry;
+  if (Array.isArray(entry) && typeof entry[0] === 'string') return entry[0];
+  return undefined;
+}
+
+/**
+ * Position slim's sidebar section according to its index in the host's
+ * effective plugin list (`tuiConfig.plugin`): index 0 → 110 (right after
+ * the host's context section, above most third-party plugins), each later
+ * index one band of 100 later. This only moves slim's own slot; other
+ * plugins retain their own order, and no relative ordering with them is
+ * guaranteed. v1 hosts only — the v2 slot claim API has no order
+ * parameter. When the spec is absent or the list is unavailable, the
+ * historic default (900) applies.
+ */
+export function resolveSidebarSlotOrder(
+  pluginList: unknown,
+  pluginName: string,
+): number {
+  if (!Array.isArray(pluginList)) return DEFAULT_SIDEBAR_SLOT_ORDER;
+  const index = pluginList.findIndex((entry) => {
+    const spec = pluginSpecOf(entry);
+    if (spec === undefined) return false;
+    if (spec === pluginName) return true;
+    if (spec.startsWith('file://')) {
+      // Filesystem checkout: match by exact path or basename. A trailing
+      // slash is tolerated; directory names are taken literally.
+      const stripped = spec.replace(/^file:\/\//, '');
+      return stripped === pluginName || path.basename(stripped) === pluginName;
+    }
+    // npm spec: strip a trailing @version (never contains a slash). A
+    // scoped package (@scope/name) is a different package and must not
+    // match by basename.
+    const stripped = spec.replace(/@[^/]*$/, '');
+    if (stripped.startsWith('@')) return false;
+    return stripped === pluginName;
+  });
+  if (index === -1) return DEFAULT_SIDEBAR_SLOT_ORDER;
+  return 110 + index * 100;
+}
+
 // Mirrors the OpenCode v2 TUI context surface (dist/tui/context.d.ts);
 // declared locally because the pinned @opencode-ai/plugin dep ships v1
 // types only.
@@ -907,7 +953,7 @@ const plugin: TuiDualContractModule = {
     });
 
     api.slots.register({
-      order: 900,
+      order: resolveSidebarSlotOrder(api.tuiConfig?.plugin, PLUGIN_NAME),
       slots: {
         sidebar_content() {
           return reactiveElement(() =>