Browse Source

Harden Divoom prompt display states

Alvin Unreal 3 months ago
parent
commit
b93dfdff30
6 changed files with 383 additions and 41 deletions
  1. 19 5
      docs/divoom.md
  2. 1 0
      scripts/verify-release-artifact.ts
  3. BIN
      src/divoom/input.gif
  4. 221 20
      src/divoom/manager.test.ts
  5. 106 15
      src/divoom/manager.ts
  6. 36 1
      src/index.ts

+ 19 - 5
docs/divoom.md

@@ -14,15 +14,18 @@ When enabled, the plugin sends bundled GIFs as OpenCode changes state:
 
 | OpenCode state | Divoom display |
 |----------------|----------------|
-| Plugin loaded / orchestrator waiting for user input | `intro.gif` |
+| Plugin loaded | `intro.gif` |
 | Orchestrator is busy planning or working directly | `orchestrator.gif` |
 | A delegated agent starts | that agent's GIF |
 | Multiple agents run in parallel | first delegated agent keeps the display |
 | Delegated agents finish but orchestrator is still working | `orchestrator.gif` |
 | Orchestrator becomes idle again | `intro.gif` |
+| Permission prompt or question needs a reply | `input.gif` |
 
 Bundled GIFs currently cover `orchestrator`, `explorer`, `librarian`, `oracle`,
-`designer`, `fixer`, and `council`.
+`designer`, `fixer`, `council`, `input`, and `intro`. You can configure
+`divoom.gifs.input` to customize user-input waits; if `input.gif` is not present
+yet, the plugin falls back to `intro.gif`.
 
 ## Prerequisites
 
@@ -74,11 +77,21 @@ Before blaming OpenCode, verify the Divoom sender works directly:
   --fps 8 \
   --speed 125 \
   --max-frames 24 \
-  --posterize-bits 3
+  --posterize-bits 3 \
+  --out-dir ~/.local/share/opencode/storage/oh-my-opencode-slim/divoom/captures
 ```
 
+**Note:** The sender must support the `--out-dir` flag. This requires a recent
+version of the Divoom MiniToo sender (the plugin uses this for temporary
+processing files).
+
 If that updates the display, the OpenCode integration should work once enabled.
 
+**Output directory path:** The plugin writes temporary processing files to
+`$XDG_DATA_HOME/opencode/storage/oh-my-opencode-slim/divoom/captures` when
+`XDG_DATA_HOME` is set to a non-empty absolute path. Otherwise it falls back to
+`~/.local/share/opencode/storage/oh-my-opencode-slim/divoom/captures`.
+
 ## Enable in oh-my-opencode-slim
 
 Open your plugin config:
@@ -105,8 +118,9 @@ For one-off runs, you can enable Divoom without changing your config:
 OH_MY_OPENCODE_SLIM_DIVOOM=1 opencode
 ```
 
-Accepted truthy values are `1`, `true`, `yes`, and `on`. If `divoom.enabled`
-is explicitly set in config, the config value wins over the environment variable.
+Accepted truthy values are `1`, `true`, `yes`, and `on`. The environment
+variable force-enables Divoom for that run, even if `divoom.enabled` is `false`
+in config.
 
 ## Tunable settings
 

+ 1 - 0
scripts/verify-release-artifact.ts

@@ -32,6 +32,7 @@ const packagedRequiredFiles = [
   'dist/divoom/designer.gif',
   'dist/divoom/explorer.gif',
   'dist/divoom/fixer.gif',
+  'dist/divoom/input.gif',
   'dist/divoom/intro.gif',
   'dist/divoom/librarian.gif',
   'dist/divoom/oracle.gif',

BIN
src/divoom/input.gif


+ 221 - 20
src/divoom/manager.test.ts

@@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
 import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import path from 'node:path';
-import { DivoomManager, type DivoomSenderCall } from './manager';
+import {
+  DivoomManager,
+  type DivoomSenderCall,
+  getDivoomOutDir,
+} from './manager';
 
 function createGifAssets(dir: string, names: string[]): void {
   for (const name of names) {
@@ -16,11 +20,15 @@ describe('DivoomManager', () => {
   let pythonPath: string;
   let scriptPath: string;
   let originalDivoomEnv: string | undefined;
+  let originalXdgDataHome: string | undefined;
 
   beforeEach(() => {
     originalDivoomEnv = process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
+    originalXdgDataHome = process.env.XDG_DATA_HOME;
     delete process.env.OH_MY_OPENCODE_SLIM_DIVOOM;
     tempDir = mkdtempSync(path.join(tmpdir(), 'divoom-test-'));
+    // Set XDG_DATA_HOME to a temp path to avoid writing to real user data directory
+    process.env.XDG_DATA_HOME = path.join(tempDir, 'xdg-data');
     calls = [];
     pythonPath = path.join(tempDir, 'python');
     scriptPath = path.join(tempDir, 'divoom_send.py');
@@ -31,6 +39,7 @@ describe('DivoomManager', () => {
       'orchestrator.gif',
       'explorer.gif',
       'fixer.gif',
+      'input.gif',
       'oracle.gif',
     ]);
   });
@@ -41,6 +50,11 @@ describe('DivoomManager', () => {
     } else {
       process.env.OH_MY_OPENCODE_SLIM_DIVOOM = originalDivoomEnv;
     }
+    if (originalXdgDataHome === undefined) {
+      delete process.env.XDG_DATA_HOME;
+    } else {
+      process.env.XDG_DATA_HOME = originalXdgDataHome;
+    }
     rmSync(tempDir, { recursive: true, force: true });
   });
 
@@ -104,14 +118,16 @@ describe('DivoomManager', () => {
     ]);
   });
 
-  test('explicit config disabled wins over env var', async () => {
+  test('env var force-enables even when config disables', async () => {
     process.env.OH_MY_OPENCODE_SLIM_DIVOOM = 'true';
     const manager = createManager({ enabled: false });
 
     manager.onPluginLoad();
     await manager.flush();
 
-    expect(calls).toHaveLength(0);
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'intro.gif'),
+    ]);
   });
 
   test('shows task agent then orchestrator after a single task', async () => {
@@ -190,6 +206,122 @@ describe('DivoomManager', () => {
     ]);
   });
 
+  test('explicit question requests show input until work resumes', async () => {
+    const manager = createManager();
+
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onUserInputRequired({ sessionId: 'parent', requestId: 'q-1' });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'parent', requestId: 'q-1' });
+    await manager.flush();
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'orchestrator.gif'),
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'orchestrator.gif'),
+    ]);
+  });
+
+  test('child prompt restores delegated agent after reply', async () => {
+    const manager = createManager();
+
+    manager.onTaskStart({
+      parentSessionId: 'parent',
+      callId: 'call-1',
+      args: { subagent_type: 'explorer' },
+    });
+    await manager.flush();
+    manager.onUserInputRequired({ sessionId: 'child', requestId: 'p-1' });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'child', requestId: 'p-1' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'explorer.gif'),
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'explorer.gif'),
+    ]);
+  });
+
+  test('overlapping prompts keep input until all resolve', async () => {
+    const manager = createManager();
+
+    manager.onUserInputRequired({ sessionId: 'one', requestId: 'p-1' });
+    await manager.flush();
+    manager.onUserInputRequired({ sessionId: 'two', requestId: 'p-2' });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'one', requestId: 'p-1' });
+    await manager.flush();
+    manager.onUserInputResolved({ sessionId: 'two', requestId: 'p-2' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('session deletion clears pending input and rerenders', async () => {
+    const manager = createManager();
+
+    manager.onUserInputRequired({ sessionId: 'child', requestId: 'p-1' });
+    await manager.flush();
+    manager.onSessionDeleted({ sessionId: 'child' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'input.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('orchestrator deletion clears busy display', async () => {
+    const manager = createManager();
+
+    manager.onOrchestratorStatus({
+      sessionId: 'parent',
+      status: 'busy',
+      isOrchestrator: true,
+    });
+    await manager.flush();
+    manager.onSessionDeleted({ sessionId: 'parent', isOrchestrator: true });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'orchestrator.gif'),
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
+  test('pending user input falls back to intro when input gif is absent', async () => {
+    rmSync(path.join(tempDir, 'input.gif'));
+    const manager = createManager();
+
+    manager.onUserInputRequired({ sessionId: 'parent', requestId: 'q-1' });
+    await manager.flush();
+
+    expect(calls.map((call) => call.args[1])).toEqual([
+      path.join(tempDir, 'intro.gif'),
+    ]);
+  });
+
   test('keeps first agent visible for parallel tasks', async () => {
     const manager = createManager();
 
@@ -263,23 +395,25 @@ describe('DivoomManager', () => {
     });
     await manager.flush();
 
-    expect(calls[0]).toEqual({
-      command: customPython,
-      args: [
-        customScript,
-        customGif,
-        '--size',
-        '64',
-        '--fps',
-        '12',
-        '--speed',
-        '250',
-        '--max-frames',
-        '10',
-        '--posterize-bits',
-        '4',
-      ],
-    });
+    expect(calls[0].command).toBe(customPython);
+    expect(calls[0].args).toHaveLength(14);
+    expect(calls[0].args[0]).toBe(customScript);
+    expect(calls[0].args[1]).toBe(customGif);
+    expect(calls[0].args.slice(2, 13)).toEqual([
+      '--size',
+      '64',
+      '--fps',
+      '12',
+      '--speed',
+      '250',
+      '--max-frames',
+      '10',
+      '--posterize-bits',
+      '4',
+      '--out-dir',
+    ]);
+    // Verify out-dir is absolute (last arg)
+    expect(path.isAbsolute(calls[0].args[13])).toBe(true);
   });
 
   test('drops stale queued sends and keeps latest requested gif', async () => {
@@ -323,4 +457,71 @@ describe('DivoomManager', () => {
 
     expect(calls).toHaveLength(0);
   });
+
+  test('out-dir is absolute and independent of process.cwd', async () => {
+    const originalCwd = process.cwd();
+    const tempCwd = mkdtempSync(path.join(tmpdir(), 'divoom-cwd-test-'));
+
+    try {
+      process.chdir(tempCwd);
+      const manager = createManager();
+
+      manager.onPluginLoad();
+      await manager.flush();
+
+      expect(calls).toHaveLength(1);
+      const outDirArg = calls[0].args[calls[0].args.length - 1];
+      // Must be absolute, not relative to cwd
+      expect(path.isAbsolute(outDirArg)).toBe(true);
+      // Must not start with the temp cwd
+      expect(outDirArg.startsWith(tempCwd)).toBe(false);
+      // Must contain the expected path segments
+      expect(outDirArg).toContain('divoom');
+      expect(outDirArg).toContain('captures');
+    } finally {
+      process.chdir(originalCwd);
+      rmSync(tempCwd, { recursive: true, force: true });
+    }
+  });
+
+  test('empty XDG_DATA_HOME falls back to homedir/.local/share', async () => {
+    // Set empty XDG_DATA_HOME
+    process.env.XDG_DATA_HOME = '';
+    const homeDir = path.join(tempDir, 'home');
+    const outDirArg = getDivoomOutDir(homeDir);
+    // Must be absolute
+    expect(path.isAbsolute(outDirArg)).toBe(true);
+    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
+    // Must contain the expected path segments
+    expect(outDirArg).toContain('divoom');
+    expect(outDirArg).toContain('captures');
+  });
+
+  test('relative XDG_DATA_HOME falls back to homedir/.local/share', async () => {
+    // Set relative XDG_DATA_HOME (should be rejected)
+    process.env.XDG_DATA_HOME = 'relative/path/to/data';
+    const homeDir = path.join(tempDir, 'home');
+    const outDirArg = getDivoomOutDir(homeDir);
+    // Must be absolute
+    expect(path.isAbsolute(outDirArg)).toBe(true);
+    // Must not start with the relative path
+    expect(outDirArg.startsWith('relative')).toBe(false);
+    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
+    // Must contain the expected path segments
+    expect(outDirArg).toContain('divoom');
+    expect(outDirArg).toContain('captures');
+  });
+
+  test('whitespace-only XDG_DATA_HOME falls back to homedir/.local/share', async () => {
+    // Set whitespace-only XDG_DATA_HOME (should be rejected after trim)
+    process.env.XDG_DATA_HOME = '   ';
+    const homeDir = path.join(tempDir, 'home');
+    const outDirArg = getDivoomOutDir(homeDir);
+    // Must be absolute
+    expect(path.isAbsolute(outDirArg)).toBe(true);
+    expect(outDirArg).toStartWith(path.join(tempDir, 'home'));
+    // Must contain the expected path segments
+    expect(outDirArg).toContain('divoom');
+    expect(outDirArg).toContain('captures');
+  });
 });

+ 106 - 15
src/divoom/manager.ts

@@ -1,5 +1,6 @@
 import { spawn } from 'node:child_process';
-import { existsSync } from 'node:fs';
+import { existsSync, mkdirSync } from 'node:fs';
+import * as os from 'node:os';
 import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import type { DivoomConfig } from '../config';
@@ -27,6 +28,7 @@ const AGENT_GIFS: Record<string, string> = {
   designer: 'designer.gif',
   explorer: 'explorer.gif',
   fixer: 'fixer.gif',
+  input: 'input.gif',
   intro: 'intro.gif',
   librarian: 'librarian.gif',
   oracle: 'oracle.gif',
@@ -82,10 +84,32 @@ function isEnvEnabled(value: string | undefined): boolean {
   return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
 }
 
+function inputKey(sessionId: string, requestId: string): string {
+  return `${sessionId}:${requestId}`;
+}
+
+export function getDivoomOutDir(homeDir = os.homedir()): string {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const baseDir =
+    xdg && xdg.length > 0 && path.isAbsolute(xdg)
+      ? xdg
+      : path.join(homeDir, '.local', 'share');
+  return path.join(
+    baseDir,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'divoom',
+    'captures',
+  );
+}
+
 export class DivoomManager {
   private assetDir: string | null;
   private config: DivoomConfig;
   private parentStates = new Map<string, ParentState>();
+  private pendingUserInputs = new Set<string>();
+  private orchestratorBusy = false;
   private latestRequestedGifPath?: string;
   private lastGifPath?: string;
   private sendQueue = Promise.resolve();
@@ -98,7 +122,9 @@ export class DivoomManager {
     this.config = {
       ...DEFAULT_DIVOOM_CONFIG,
       ...config,
-      enabled: config?.enabled ?? isEnvEnabled(process.env[DIVOOM_ENABLE_ENV]),
+      enabled: isEnvEnabled(process.env[DIVOOM_ENABLE_ENV])
+        ? true
+        : (config?.enabled ?? false),
       gifs: config?.gifs,
     };
     this.assetDir = options.assetDir ?? resolveAssetDir();
@@ -125,11 +151,13 @@ export class DivoomManager {
     const state = this.getParentState(input.parentSessionId);
     const wasIdle = state.activeCalls.size === 0;
     state.activeCalls.set(input.callId, agent);
+    this.orchestratorBusy = true;
 
-    if (!wasIdle || state.displayedAgent) return;
+    if (wasIdle && !state.displayedAgent) {
+      state.displayedAgent = agent;
+    }
 
-    state.displayedAgent = agent;
-    this.show(agent);
+    this.render();
   }
 
   onTaskEnd(input: { parentSessionId?: string; callId?: string }): void {
@@ -139,10 +167,25 @@ export class DivoomManager {
     if (!state) return;
 
     state.activeCalls.delete(input.callId);
-    if (state.activeCalls.size > 0) return;
+    if (state.activeCalls.size === 0) {
+      this.parentStates.delete(input.parentSessionId);
+    }
+
+    this.render();
+  }
+
+  onUserInputRequired(input: { sessionId?: string; requestId?: string }): void {
+    if (!input.sessionId || !input.requestId) return;
 
-    this.parentStates.delete(input.parentSessionId);
-    this.show('orchestrator');
+    this.pendingUserInputs.add(inputKey(input.sessionId, input.requestId));
+    this.render();
+  }
+
+  onUserInputResolved(input: { sessionId?: string; requestId?: string }): void {
+    if (!input.sessionId || !input.requestId) return;
+
+    this.pendingUserInputs.delete(inputKey(input.sessionId, input.requestId));
+    this.render();
   }
 
   onOrchestratorStatus(input: {
@@ -152,22 +195,50 @@ export class DivoomManager {
   }): void {
     if (!input.sessionId || !input.isOrchestrator) return;
 
-    const state = this.parentStates.get(input.sessionId);
     if (input.status === 'busy') {
-      if (state && state.activeCalls.size > 0) return;
-      this.show('orchestrator');
+      this.orchestratorBusy = true;
+      this.render();
       return;
     }
 
     if (input.status === 'idle') {
+      this.orchestratorBusy = false;
       this.parentStates.delete(input.sessionId);
-      this.show('intro');
+      this.render();
     }
   }
 
-  onSessionDeleted(sessionId?: string): void {
+  onSessionDeleted(input: {
+    sessionId?: string;
+    isOrchestrator?: boolean;
+  }): void {
+    const sessionId = input.sessionId;
     if (!sessionId) return;
+    if (input.isOrchestrator) this.orchestratorBusy = false;
     this.parentStates.delete(sessionId);
+    for (const key of this.pendingUserInputs) {
+      if (key.startsWith(`${sessionId}:`)) {
+        this.pendingUserInputs.delete(key);
+      }
+    }
+    this.render();
+  }
+
+  private render(): void {
+    if (this.pendingUserInputs.size > 0) {
+      this.show('input');
+      return;
+    }
+
+    const activeAgent = Array.from(this.parentStates.values()).find(
+      (state) => state.displayedAgent && state.activeCalls.size > 0,
+    )?.displayedAgent;
+    if (activeAgent) {
+      this.show(activeAgent);
+      return;
+    }
+
+    this.show(this.orchestratorBusy ? 'orchestrator' : 'intro');
   }
 
   private getParentState(parentSessionId: string): ParentState {
@@ -191,17 +262,35 @@ export class DivoomManager {
 
     const fileName =
       this.config.gifs?.[agent] ?? AGENT_GIFS[agent] ?? AGENT_GIFS.orchestrator;
-    const gifPath = path.isAbsolute(fileName)
+    const requestedGifPath = path.isAbsolute(fileName)
       ? fileName
       : path.join(this.assetDir, fileName);
+    const fallbackGifPath = path.join(this.assetDir, AGENT_GIFS.intro);
+    const gifPath = existsSync(requestedGifPath)
+      ? requestedGifPath
+      : agent === 'input'
+        ? fallbackGifPath
+        : requestedGifPath;
     if (!existsSync(gifPath)) {
-      log('[divoom] gif not found', { agent, gifPath });
+      log('[divoom] gif not found', { agent, gifPath: requestedGifPath });
       return;
     }
 
     if (gifPath === this.latestRequestedGifPath) return;
     this.latestRequestedGifPath = gifPath;
 
+    const outDir = getDivoomOutDir();
+    try {
+      mkdirSync(outDir, { recursive: true });
+    } catch (error) {
+      this.clearLatestIfCurrent(gifPath);
+      log('[divoom] output directory not writable', {
+        outDir,
+        error: String(error),
+      });
+      return;
+    }
+
     const call = {
       command: this.config.python,
       args: [
@@ -217,6 +306,8 @@ export class DivoomManager {
         String(this.config.maxFrames),
         '--posterize-bits',
         String(this.config.posterizeBits),
+        '--out-dir',
+        outDir,
       ],
     };
 

+ 36 - 1
src/index.ts

@@ -737,6 +737,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
             sessionID?: string;
           };
           sessionID?: string;
+          id?: string;
+          requestID?: string;
           status?: { type: string };
         };
       };
@@ -796,6 +798,33 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
+      if (
+        event.type === 'permission.asked' ||
+        event.type === 'question.asked'
+      ) {
+        const props = event.properties as
+          | { sessionID?: string; id?: string }
+          | undefined;
+        divoomManager.onUserInputRequired({
+          sessionId: props?.sessionID,
+          requestId: props?.id,
+        });
+      }
+
+      if (
+        event.type === 'permission.replied' ||
+        event.type === 'question.replied' ||
+        event.type === 'question.rejected'
+      ) {
+        const props = event.properties as
+          | { sessionID?: string; requestID?: string }
+          | undefined;
+        divoomManager.onUserInputResolved({
+          sessionId: props?.sessionID,
+          requestId: props?.requestID,
+        });
+      }
+
       if (input.event.type === 'session.status') {
         const props = input.event.properties as
           | { sessionID?: string; status?: { type?: string } }
@@ -814,7 +843,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         const props = input.event.properties as
           | { info?: { id?: string }; sessionID?: string }
           | undefined;
-        divoomManager.onSessionDeleted(props?.info?.id ?? props?.sessionID);
+        const sessionID = props?.info?.id ?? props?.sessionID;
+        divoomManager.onSessionDeleted({
+          sessionId: sessionID,
+          isOrchestrator: sessionID
+            ? sessionAgentMap.get(sessionID) === 'orchestrator'
+            : false,
+        });
       }
 
       if (input.event.type === 'session.deleted') {