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

fix: add collapsible agents sidebar

m.seomun 2 месяцев назад
Родитель
Сommit
79ca621140
2 измененных файлов с 175 добавлено и 16 удалено
  1. 132 5
      src/tui.test.ts
  2. 43 11
      src/tui.ts

+ 132 - 5
src/tui.test.ts

@@ -1,14 +1,31 @@
-import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
 import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
-import {
+import type { TuiSnapshot } from './tui-state';
+
+type TestNode = {
+  tag: string;
+  props: Record<string, unknown>;
+  children: Array<TestNode | string | number>;
+};
+
+mock.module('@opentui/solid', () => ({
+  createElement: (tag: string): TestNode => ({ tag, props: {}, children: [] }),
+  insert: (node: TestNode, child: TestNode | string | number) => {
+    node.children.push(child);
+  },
+  setProp: (node: TestNode, key: string, value: unknown) => {
+    node.props[key] = value;
+  },
+}));
+
+const {
   getSidebarAgentNames,
   readConfigInvalid,
   splitSidebarModelId,
-  default as tuiPlugin,
-} from './tui';
-import type { TuiSnapshot } from './tui-state';
+  default: tuiPlugin,
+} = await import('./tui');
 
 function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
   return {
@@ -121,6 +138,116 @@ describe('readConfigInvalid', () => {
   });
 });
 
+function nodeText(node: TestNode | string | number): string {
+  if (typeof node !== 'object') return String(node);
+  return `${node.props.content ?? ''}${node.children.map(nodeText).join('')}`;
+}
+
+function findNode(
+  node: TestNode,
+  predicate: (node: TestNode) => boolean,
+): TestNode | undefined {
+  if (predicate(node)) return node;
+
+  for (const child of node.children) {
+    if (typeof child !== 'object') continue;
+    const match = findNode(child, predicate);
+    if (match) return match;
+  }
+}
+
+describe('tui agents dropdown', () => {
+  let originalEnv: typeof process.env;
+  let tempDir: string;
+
+  beforeEach(() => {
+    originalEnv = { ...process.env };
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-tui-dropdown-'));
+    process.env.XDG_DATA_HOME = path.join(tempDir, 'data');
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, 'config');
+
+    const stateFile = path.join(
+      process.env.XDG_DATA_HOME,
+      'opencode/storage/oh-my-opencode-slim/tui-state.json',
+    );
+    fs.mkdirSync(path.dirname(stateFile), { recursive: true });
+    fs.writeFileSync(
+      stateFile,
+      JSON.stringify({
+        version: 1,
+        updatedAt: 0,
+        agentModels: { explorer: 'openai/gpt-5.5' },
+        agentVariants: {},
+      }),
+    );
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('clicking agents header hides and shows mounted agent rows', async () => {
+    let dispose = () => {};
+    let sidebarContent: (() => TestNode) | undefined;
+
+    await tuiPlugin.tui(
+      {
+        state: { path: { directory: tempDir } },
+        lifecycle: { onDispose: (fn: () => void) => (dispose = fn) },
+        renderer: {
+          requestRender: () => {},
+        },
+        slots: {
+          register: (registration: {
+            slots: { sidebar_content: () => TestNode };
+          }) => {
+            sidebarContent = registration.slots.sidebar_content;
+          },
+        },
+        theme: { current: {} },
+      } as unknown as Parameters<typeof tuiPlugin.tui>[0],
+      {},
+      { version: 'test' } as Parameters<typeof tuiPlugin.tui>[2],
+    );
+
+    const sidebar = sidebarContent?.();
+    if (!sidebar) throw new Error('sidebar content was not registered');
+
+    const header = findNode(
+      sidebar,
+      (node) => node.tag === 'text' && nodeText(node) === '▼ Agents',
+    );
+    const toggle = findNode(
+      sidebar,
+      (node) => typeof node.props.onMouseDown === 'function',
+    );
+    const row = findNode(
+      sidebar,
+      (node) =>
+        node.tag === 'box' &&
+        node.props.visible !== undefined &&
+        nodeText(node).includes('explorer'),
+    );
+    const onMouseDown = toggle?.props.onMouseDown;
+    if (!header || typeof onMouseDown !== 'function' || !row) {
+      throw new Error('agents dropdown nodes were not rendered');
+    }
+
+    expect(row.props.visible).toBe(true);
+
+    onMouseDown();
+    expect(header.props.content).toBe('▶ Agents');
+    expect(row.props.visible).toBe(false);
+
+    onMouseDown();
+    expect(header.props.content).toBe('▼ Agents');
+    expect(row.props.visible).toBe(true);
+
+    dispose();
+  });
+});
+
 describe('tui plugin env disable', () => {
   let originalEnv: typeof process.env;
 

+ 43 - 11
src/tui.ts

@@ -162,8 +162,26 @@ function renderSidebar(
   },
   configInvalid: boolean,
   compactSidebar: boolean,
+  agentsExpanded: boolean,
+  setAgentsExpanded: (expanded: boolean) => void,
 ): JSX.Element {
   const configStatusRow = buildConfigStatusRow(configInvalid, theme);
+  const agentNames = getSidebarAgentNames(snapshot);
+  const hasAgents = agentNames.length > 0;
+  const formatAgentsHeader = (expanded: boolean) =>
+    `${hasAgents ? (expanded ? '▼ ' : '▶ ') : ''}Agents`;
+  let expanded = agentsExpanded;
+  const agentRows = agentNames.map((agentName) => {
+    const model = snapshot.agentModels[agentName] ?? 'pending';
+    const variant = snapshot.agentVariants[agentName];
+    const row = compactSidebar
+      ? compactAgentRow(agentName, model, variant, theme)
+      : agentRow(agentName, model, variant, theme);
+
+    setProp(row, 'visible', expanded);
+    return row;
+  });
+  const agentsHeader = text({ fg: theme.text }, [formatAgentsHeader(expanded)]);
 
   return box(
     {
@@ -193,17 +211,25 @@ function renderSidebar(
         ],
       ),
       configStatusRow,
-      box({ width: '100%', marginTop: 1 }, [
-        text({ fg: theme.text }, ['Agents']),
-      ]),
-      ...getSidebarAgentNames(snapshot).map((agentName) => {
-        const model = snapshot.agentModels[agentName] ?? 'pending';
-        const variant = snapshot.agentVariants[agentName];
-        if (compactSidebar) {
-          return compactAgentRow(agentName, model, variant, theme);
-        }
-        return agentRow(agentName, model, variant, theme);
-      }),
+      box(
+        {
+          width: '100%',
+          flexDirection: 'row',
+          marginTop: 1,
+          onMouseDown: hasAgents
+            ? () => {
+                expanded = !expanded;
+                setAgentsExpanded(expanded);
+                setProp(agentsHeader, 'content', formatAgentsHeader(expanded));
+                for (const row of agentRows) {
+                  setProp(row, 'visible', expanded);
+                }
+              }
+            : undefined,
+        },
+        [agentsHeader],
+      ),
+      ...agentRows,
     ],
   );
 }
@@ -254,6 +280,7 @@ const plugin: TuiPluginModule & { id: string } = {
     const version = meta.version ?? (await readPackageVersion()) ?? 'dev';
     let configDirectory = getTuiDirectory(api);
     let { configInvalid, compactSidebar } = readConfigState(configDirectory);
+    let agentsExpanded = true;
     let snapshot = readTuiSnapshot();
     const renderTimer = setInterval(async () => {
       try {
@@ -284,6 +311,11 @@ const plugin: TuiPluginModule & { id: string } = {
             api.theme.current,
             configInvalid,
             compactSidebar,
+            agentsExpanded,
+            (expanded) => {
+              agentsExpanded = expanded;
+              api.renderer.requestRender();
+            },
           );
         },
       },