Official

tabs

The tabbed detail pane beside the chat: opened files and diffs (JavaScript UI plugin).

The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/tabs@0.2.0

Permissions in 0.2.0

  • Read files fs.readMediumReads files in the listed places.Open workspace files in tabsPlaces: the open workspace
  • Read chats chats.readMediumReads your transcripts and chat lists.Reload the open file when an agent finishes a turn
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the tabbed pane beside the chatSlots: detail

Files

service.test.ts8.2 KB
// The convergence/tabs service methods and the legacy shared requests.
// Real activation against a fake service bus: calls go through the provided
// implementation, events through its handle, legacy requests through app state.
import assert from "node:assert/strict";
import { test } from "node:test";
import type {
  Api,
  AppState,
  AppStatePatch,
  HostEvent,
  RenderResult,
  SlotOptions,
  ViewContext,
} from "convergence";
import { ui } from "convergence";
import { testLayer } from "convergence/effect/testing";
import { activate } from "./main.ts";

const tick = () => new Promise<void>((resolve) => setImmediate(resolve));
const stable = (value: unknown) =>
  JSON.stringify(value, (_key, item: unknown) => (typeof item === "function" ? "@handler" : item));

type ServiceImpl = Record<string, (params: never, ctx: unknown) => Promise<unknown>>;
type Emitted = { event: string; payload: unknown };

function fakeApi() {
  let state: AppState = {};
  const appListeners = new Set<(state: AppState) => void>();
  const views: { render: (ctx: ViewContext<object>) => RenderResult; active: boolean }[] = [];
  const listeners = new Map<string, Set<(event: HostEvent) => unknown>>();
  const calls: { method: string; params: unknown }[] = [];
  const notices: string[] = [];
  let dirty = true;
  const provided = new Map<string, ServiceImpl>();
  const emitted: Emitted[] = [];
  const update = (patch: AppStatePatch) => {
    state = {
      ...state,
      ...patch,
      workspaceId: patch.workspaceId === null ? undefined : (patch.workspaceId ?? state.workspaceId),
      chatId: patch.chatId === null ? undefined : (patch.chatId ?? state.chatId),
      route: patch.route ?? state.route,
      shared: { ...state.shared, ...patch.shared },
    };
    dirty = true;
    for (const listener of appListeners) listener(state);
  };
  const call = async (method: string, params: unknown) => {
    calls.push({ method, params });
    if (method === "host/fs.read") return { text: `# ${(params as { path: string }).path}\nbody` };
    throw new Error(`Unexpected host method ${method}`);
  };
  const broker = testLayer();
  // This assertion describes a local fake; unused API methods fail rather than impersonating external replies.
  const api = new Proxy(
    {
      ...broker.api,
      plugin: "tabs",
      id: "convergence/tabs",
      ui,
      services: {
        provide: async (def: { name: string }, impl: ServiceImpl) => {
          provided.set(def.name, impl);
          return {
            emit: (event: string, payload: unknown) => {
              emitted.push({ event, payload });
            },
            remove: () => {
              provided.delete(def.name);
            },
          };
        },
        get: (name: string) => {
          throw new Error(`no provider for ${name}`);
        },
        available: () => false,
        when: () => () => {},
      },
      host: { ...broker.api.host, call, kernel: call },
      app: {
        state: () => state,
        update,
        share: (key: string, value: unknown) => update({ shared: { [key]: value } }),
        onChange: (fn: (state: AppState) => void) => {
          appListeners.add(fn);
          return () => {
            appListeners.delete(fn);
          };
        },
      },
      slot: (slot: string, render: (ctx: ViewContext<object>) => RenderResult, _options?: SlotOptions) => {
        const view = { render, active: true };
        views.push(view);
        dirty = true;
        return {
          id: views.length,
          update: () => {
            dirty = true;
          },
          remove: () => {
            view.active = false;
          },
        };
      },
      on: (name: string, fn: (event: HostEvent) => unknown) => {
        const subscribers = listeners.get(name) ?? new Set();
        listeners.set(name, subscribers);
        subscribers.add(fn);
        return () => {
          subscribers.delete(fn);
        };
      },
      command: () => ({ remove() {} }),
      notify: (message: string) => {
        notices.push(message);
      },
      focus: () => {},
    },
    {
      get(target, key, receiver) {
        if (Reflect.has(target, key)) return Reflect.get(target, key, receiver);
        throw new Error(`Unexpected API ${String(key)}`);
      },
    },
  ) as unknown as Api;
  const render = () =>
    views
      .filter((view) => view.active)
      .map((view) =>
        view.render({
          state: {},
          app: state,
          plugin: "tabs",
          update: () => {
            dirty = true;
          },
        }),
      );
  let trees: RenderResult[] = [];
  const settle = async () => {
    for (let round = 0; round < 50; round++) {
      await tick();
      if (!dirty) {
        await tick();
        if (!dirty) return;
      }
      dirty = false;
      trees = render();
    }
    throw new Error("tabs did not settle");
  };
  const ctx = { caller: { plugin: "test/client", id: "test/client" }, signal: AbortSignal.timeout(5000) };
  return {
    api,
    calls,
    notices,
    callsReset: () => {
      calls.length = 0;
    },
    emitted,
    provided,
    update,
    settle,
    trees: () => stable(trees),
    state: () => state,
    callService: (method: string, params: unknown) =>
      provided.get("convergence/tabs")?.[method]?.(params as never, ctx),
    emit: (event: HostEvent) => {
      for (const name of [event.event, "*"]) for (const fn of listeners.get(name) ?? []) fn(event);
    },
  };
}

test("the tabs service opens, reports and closes tabs without shared writes", async () => {
  const fake = fakeApi();
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    assert.ok(fake.provided.has("convergence/tabs"));
    assert.deepEqual(await fake.callService("active", {}), null);
    assert.deepEqual(await fake.callService("open", { kind: "file", workspaceId: "w1", path: "a.ts" }), {
      id: "file:w1:a.ts",
    });
    await fake.settle();
    assert.deepEqual(fake.emitted, [{ event: "activeChanged", payload: { kind: "file", path: "a.ts" } }]);
    // The visible tab left the shared keys behind: nothing announces through them.
    assert.equal(fake.state().shared?.activeTab, undefined);
    assert.equal(fake.state().shared?.activeTabPath, undefined);
    assert.deepEqual(await fake.callService("active", {}), { kind: "file", path: "a.ts" });
    assert.ok(fake.trees().includes("file:w1:a.ts"));
    fake.callsReset();
    assert.deepEqual(await fake.callService("close", { kind: "file", path: "a.ts" }), null);
    await fake.settle();
    assert.deepEqual(fake.emitted.at(-1), { event: "activeChanged", payload: null });
    assert.deepEqual(await fake.callService("active", {}), null);
    assert.ok(!fake.trees().includes("file:w1:a.ts"));
  } finally {
    await dispose();
  }
});

test("the tabs service opens view tabs and ignores unknown closes", async () => {
  const fake = fakeApi();
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    assert.deepEqual(
      await fake.callService("open", {
        kind: "view",
        workspaceId: "w1",
        path: "agents",
        title: "Agents",
        icon: "network",
        view: 3,
      }),
      { id: "view:w1:agents" },
    );
    await fake.settle();
    assert.ok(fake.trees().includes("Agents"));
    const seen = fake.emitted.length;
    assert.deepEqual(await fake.callService("close", { kind: "diff", path: "missing.ts" }), null);
    assert.equal(fake.emitted.length, seen);
  } finally {
    await dispose();
  }
});

test("legacy shared open and close requests still work", async () => {
  const fake = fakeApi();
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    fake.update({ shared: { openTab: { kind: "diff", workspaceId: "w1", path: "b.ts", diff: "@@\n", nonce: 1 } } });
    await fake.settle();
    assert.equal(fake.state().shared?.openTab, null);
    assert.deepEqual(fake.emitted, [{ event: "activeChanged", payload: { kind: "diff", path: "b.ts" } }]);
    assert.ok(fake.trees().includes("diff:w1:b.ts"));
    fake.update({ shared: { closeTab: { kind: "diff", path: "b.ts", nonce: 2 } } });
    await fake.settle();
    assert.equal(fake.state().shared?.closeTab, null);
    assert.deepEqual(fake.emitted.at(-1), { event: "activeChanged", payload: null });
  } finally {
    await dispose();
  }
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <318.8 KB3 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.