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

fix reactive TUI activity rendering

Replace plain sidebar snapshot redraws with Solid reactive state so agent activity changes after slot mount become visible and animate.
Erman HAVUÇ 3 недель назад
Родитель
Сommit
c0989d670b
5 измененных файлов с 147 добавлено и 27 удалено
  1. 1 0
      bunfig.toml
  2. 1 1
      package.json
  3. 2 2
      src/codemap.md
  4. 99 1
      src/tui.test.ts
  5. 44 23
      src/tui.ts

+ 1 - 0
bunfig.toml

@@ -1,2 +1,3 @@
 [test]
 root = "./src"
+preload = ["@opentui/solid/preload"]

+ 1 - 1
package.json

@@ -55,7 +55,7 @@
   ],
   "scripts": {
     "clean:dist": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
-    "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external @opentui/core --external @opentui/solid --external jsdom --external zod",
+    "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external @opentui/core --external @opentui/solid --external solid-js --external jsdom --external zod",
     "build:v2": "bun build src/index.ts --outfile dist/server.js --target node --format esm --external jsdom",
     "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external jsdom --external zod",
     "build": "bun run clean:dist && bun run build:plugin && bun run build:v2 && bun run build:cli && tsc --emitDeclarationOnly && bun run generate-schema",

+ 2 - 2
src/codemap.md

@@ -71,8 +71,8 @@ OpenCode Core → Plugin Initialization (index.ts)
 3. **Config Validation**: Checks if current directory has valid plugin config
 4. **Snapshot Loading**: Reads agent models, variants, and per-session activity
    from `tui-state.ts`
-5. **Live Updates**: Refreshes persisted state every 1000ms and requests
-   160ms animation frames only while agents are active
+5. **Live Updates**: Refreshes persisted state every 1000ms and reactively
+   advances 160ms animation frames only while agents are active
 6. **Tmux registration**: Refreshes the active session-to-`TMUX_PANE`
    registration for parent-aware child-pane routing
 7. **Sidebar Rendering**: Renders sidebar with:

+ 99 - 1
src/tui.test.ts

@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import { RGBA } from '@opentui/core';
+import { testRender } from '@opentui/solid';
 import { readTmuxPane } from './multiplexer/tmux-pane-registry';
 import {
   type ActiveTmuxPaneRegistration,
@@ -16,7 +17,13 @@ import {
   syncTmuxPaneRegistration,
   default as tuiPlugin,
 } from './tui';
-import type { TuiSnapshot } from './tui-state';
+import {
+  recordTuiAgentActivity,
+  recordTuiAgentModels,
+  type TuiSnapshot,
+} from './tui-state';
+
+const ACTIVITY_FRAME_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
 
 function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
   return {
@@ -77,6 +84,97 @@ describe('tui sidebar agents', () => {
   });
 });
 
+describe('live TUI activity rendering', () => {
+  test('updates a mounted v1 sidebar when an agent becomes active', async () => {
+    const root = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-spinner-live-'));
+    const projectDir = path.join(root, 'project');
+    const originalDataHome = process.env.XDG_DATA_HOME;
+    const disposers: Array<() => void> = [];
+    let slotPlugin: { slots: { sidebar_content: () => unknown } } | undefined;
+    let setup: Awaited<ReturnType<typeof testRender>> | undefined;
+
+    try {
+      fs.mkdirSync(projectDir, { recursive: true });
+      process.env.XDG_DATA_HOME = path.join(root, 'data');
+      recordTuiAgentModels(
+        { agentModels: { explorer: 'openai/gpt-5.6-luna-fast' } },
+        projectDir,
+      );
+
+      await tuiPlugin.tui(
+        {
+          state: { path: { directory: projectDir } },
+          route: { current: { name: 'home' } },
+          lifecycle: {
+            onDispose: (callback: () => void) => {
+              disposers.push(callback);
+              return () => {};
+            },
+          },
+          renderer: { requestRender: () => {} },
+          slots: {
+            register: (plugin: typeof slotPlugin) => {
+              slotPlugin = plugin;
+              return 'activity-test-slot';
+            },
+          },
+          theme: {
+            current: {
+              accent: '#22c55e',
+              background: '#111111',
+              borderActive: '#555555',
+              text: '#ffffff',
+              textMuted: '#aaaaaa',
+            },
+          },
+        } as Parameters<typeof tuiPlugin.tui>[0],
+        {},
+        { version: 'test' } as Parameters<typeof tuiPlugin.tui>[2],
+      );
+
+      setup = await testRender(
+        () => slotPlugin?.slots.sidebar_content() as never,
+        { width: 52, height: 14 },
+      );
+      await setup.renderOnce();
+      expect(setup.captureCharFrame()).not.toMatch(ACTIVITY_FRAME_PATTERN);
+
+      recordTuiAgentActivity(
+        {
+          sessionID: 'explorer-session',
+          agentName: 'explorer',
+          active: true,
+        },
+        projectDir,
+      );
+      await Bun.sleep(1_100);
+      await setup.renderOnce();
+
+      const firstFrame = setup
+        .captureCharFrame()
+        .match(ACTIVITY_FRAME_PATTERN)?.[0];
+      expect(firstFrame).toBeDefined();
+
+      await Bun.sleep(200);
+      await setup.renderOnce();
+      const nextFrame = setup
+        .captureCharFrame()
+        .match(ACTIVITY_FRAME_PATTERN)?.[0];
+      expect(nextFrame).toBeDefined();
+      expect(nextFrame).not.toBe(firstFrame);
+    } finally {
+      setup?.renderer.destroy();
+      for (const dispose of disposers) dispose();
+      fs.rmSync(root, { recursive: true, force: true });
+      if (originalDataHome === undefined) {
+        delete process.env.XDG_DATA_HOME;
+      } else {
+        process.env.XDG_DATA_HOME = originalDataHome;
+      }
+    }
+  });
+});
+
 describe('splitSidebarModelId', () => {
   test('splits provider from model at the first slash', () => {
     expect(splitSidebarModelId('openai/gpt-5.6-fast')).toEqual({

+ 44 - 23
src/tui.ts

@@ -6,6 +6,7 @@ import type {
 import { type ColorInput, parseColor, RGBA } from '@opentui/core';
 import type { JSX } from '@opentui/solid';
 import { createElement, insert, setProp } from '@opentui/solid';
+import { createSignal } from 'solid-js';
 import { DEFAULT_DISABLED_AGENTS, SUBAGENT_NAMES } from './config/constants';
 import { loadPluginConfig } from './config/loader';
 import {
@@ -87,6 +88,12 @@ function box(props: Record<string, unknown>, children: Child[] = []) {
   return element('box', props, children);
 }
 
+function reactiveElement(render: () => JSX.Element): JSX.Element {
+  const root = box({ width: '100%', flexDirection: 'column' });
+  insert(root, render);
+  return root;
+}
+
 function getTuiDirectory(api: {
   state?: { path?: { directory?: string } };
 }): string {
@@ -352,10 +359,10 @@ function renderSidebar(
   },
   configInvalid: boolean,
   compactSidebar: boolean,
+  now = Date.now(),
 ): JSX.Element {
   const configStatusRow = buildConfigStatusRow(configInvalid, theme);
   const activeAgents = getActiveSidebarAgentNames(snapshot);
-  const now = Date.now();
   return box(
     {
       width: '100%',
@@ -518,7 +525,10 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
   const version = (await readPackageVersion()) ?? 'dev';
   let configDirectory = ctx.location?.directory ?? process.cwd();
   let { configInvalid, compactSidebar } = readConfigState(configDirectory);
-  let snapshot = readTuiSnapshot(configDirectory);
+  const [snapshot, setSnapshot] = createSignal(
+    readTuiSnapshot(configDirectory),
+  );
+  const [animationNow, setAnimationNow] = createSignal(Date.now());
   const tmuxRegistration: ActiveTmuxPaneRegistration = {
     ownerPid: process.pid,
     lastRecordedAt: 0,
@@ -530,32 +540,36 @@ async function setup(ctx: V2TuiContext): Promise<undefined | (() => void)> {
     try {
       const currentDirectory = ctx.location?.directory ?? process.cwd();
       syncTmuxPaneRegistration(ctx.ui.router.current(), tmuxRegistration);
-      snapshot = await readTuiSnapshotAsync(currentDirectory);
+      const nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
       if (disposed) return;
       if (currentDirectory !== configDirectory) {
         configDirectory = currentDirectory;
         ({ configInvalid, compactSidebar } = readConfigState(configDirectory));
       }
+      setSnapshot(nextSnapshot);
       ctx.renderer.requestRender();
     } catch {
       // Ignore render errors; this is best-effort live status.
     }
   }, 1000);
   const animationTimer = setInterval(() => {
-    if (!disposed && Object.keys(snapshot.activeSessions).length > 0) {
-      ctx.renderer.requestRender();
+    if (!disposed && Object.keys(snapshot().activeSessions).length > 0) {
+      setAnimationNow(Date.now());
     }
   }, ACTIVITY_FRAME_MS);
 
   const disposeSlot = ctx.ui.slot({
     append: 'sidebar.content',
     render: () =>
-      renderSidebar(
-        snapshot,
-        version,
-        v2ThemeView(ctx.theme),
-        configInvalid,
-        compactSidebar,
+      reactiveElement(() =>
+        renderSidebar(
+          snapshot(),
+          version,
+          v2ThemeView(ctx.theme),
+          configInvalid,
+          compactSidebar,
+          animationNow(),
+        ),
       ),
   });
 
@@ -612,7 +626,10 @@ const plugin: TuiDualContractModule = {
     const version = meta.version ?? (await readPackageVersion()) ?? 'dev';
     let configDirectory = getTuiDirectory(api);
     let { configInvalid, compactSidebar } = readConfigState(configDirectory);
-    let snapshot = readTuiSnapshot(configDirectory);
+    const [snapshot, setSnapshot] = createSignal(
+      readTuiSnapshot(configDirectory),
+    );
+    const [animationNow, setAnimationNow] = createSignal(Date.now());
     const tmuxRegistration: ActiveTmuxPaneRegistration = {
       ownerPid: process.pid,
       lastRecordedAt: 0,
@@ -622,20 +639,21 @@ const plugin: TuiDualContractModule = {
       try {
         const currentDirectory = getTuiDirectory(api);
         syncTmuxPaneRegistration(api.route.current, tmuxRegistration);
-        snapshot = await readTuiSnapshotAsync(currentDirectory);
+        const nextSnapshot = await readTuiSnapshotAsync(currentDirectory);
         if (currentDirectory !== configDirectory) {
           configDirectory = currentDirectory;
           ({ configInvalid, compactSidebar } =
             readConfigState(configDirectory));
         }
+        setSnapshot(nextSnapshot);
         api.renderer.requestRender();
       } catch {
         // Ignore render errors; this is best-effort live status.
       }
     }, 1000);
     const animationTimer = setInterval(() => {
-      if (Object.keys(snapshot.activeSessions).length > 0) {
-        api.renderer.requestRender();
+      if (Object.keys(snapshot().activeSessions).length > 0) {
+        setAnimationNow(Date.now());
       }
     }, ACTIVITY_FRAME_MS);
 
@@ -649,12 +667,15 @@ const plugin: TuiDualContractModule = {
       order: 900,
       slots: {
         sidebar_content() {
-          return renderSidebar(
-            snapshot,
-            version,
-            api.theme.current,
-            configInvalid,
-            compactSidebar,
+          return reactiveElement(() =>
+            renderSidebar(
+              snapshot(),
+              version,
+              api.theme.current,
+              configInvalid,
+              compactSidebar,
+              animationNow(),
+            ),
           );
         },
       },
@@ -668,10 +689,10 @@ const plugin: TuiDualContractModule = {
     if (api.command) {
       const snapshotRef: { snapshot: TuiSnapshot } = {
         get snapshot() {
-          return snapshot;
+          return snapshot();
         },
         set snapshot(value: TuiSnapshot) {
-          snapshot = value;
+          setSnapshot(value);
         },
       };
       const disposeCommands = api.command.register(() => [