Parcourir la source

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Ç il y a 3 semaines
Parent
commit
c0989d670b
5 fichiers modifiés avec 147 ajouts et 27 suppressions
  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(() => [