Official

git

Built-in git UI (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/git@0.2.0

Permissions in 0.2.0

  • Run git commands git.writeHighRuns any git command.Stage, commit, switch branches and sync with the remote
  • Read chats chats.readMediumReads your transcripts and chat lists.Refresh when an agent finishes a turn
  • Read git status and diffs git.readLowReads git status and diffs.Show the repository's status and diffs, and follow its changes
  • Show panels ui.slotsLowShows views in the listed parts of the window.Show the source control button and panelSlots: railright

Files

tabs.test.ts8.7 KB
// Git opens diffs and files through the convergence/tabs service. Real
// activation with a canned tabs client: opening calls the service, and
// without a provider the repository actions still run.
import assert from "node:assert/strict";
import { test } from "node:test";
import * as z from "zod";
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));

function fakeApi(withTabs: boolean) {
  let state: AppState = { workspaceId: "w1", shared: { rightPanel: "git" } };
  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 tabsCalls: { method: string; params: unknown }[] = [];
  let dirty = true;
  const status = {
    branch: "main",
    upstream: null,
    ahead: 0,
    behind: 0,
    isRepo: true,
    entries: [{ path: "src/a.ts", staged: " ", unstaged: "M" }],
  };
  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 });
    switch (method) {
      case "host/git.watch":
        return {};
      case "host/git.status":
        return status;
      case "host/git.diff":
        return { diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new" };
      case "host/git.run":
        return { stdout: "", stderr: "", code: 0 };
      default:
        throw new Error(`Unexpected host method ${method}`);
    }
  };
  const tabsClient = {
    open: Object.assign(async (params: unknown) => ({ id: "tab" }), {
      // The Effect adapter calls through `result`, never the direct call.
      result: async (params: unknown) => {
        tabsCalls.push({ method: "open", params });
        return { ok: true as const, value: { id: "tab" } };
      },
    }),
    close: Object.assign(async () => null, {
      result: async (params: unknown) => {
        tabsCalls.push({ method: "close", params });
        return { ok: true as const, value: null };
      },
    }),
    active: Object.assign(async () => null, {
      result: async () => ({ ok: true as const, value: null }),
    }),
    on: () => () => {},
  };
  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: "git",
      id: "convergence/git",
      ui,
      services: {
        provide: async () => ({ emit: () => {}, remove: () => {} }),
        get: (name: string) => {
          if (name === "convergence/tabs" && withTabs) return tabsClient;
          throw new Error(`no provider for ${name}`);
        },
        available: (name: string) => name === "convergence/tabs" && withTabs,
        when: (name: string, fn: (client: unknown) => void) => {
          fn(name === "convergence/tabs" && withTabs ? tabsClient : undefined);
          return () => {};
        },
      },
      host: {
        ...broker.api.host,
        call,
        kernel: call,
        git: Object.fromEntries(
          ["status", "watch", "run", "diff"].map((name) => [
            name,
            (params: unknown) => call(`host/git.${name}`, params),
          ]),
        ),
      },
      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: () => {},
      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: "git",
          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("git did not settle");
  };
  const nodeSchema = z.object({ id: z.string().optional(), children: z.array(z.unknown()).optional() }).passthrough();
  function found(tree: unknown): Record<string, unknown>[] {
    if (Array.isArray(tree)) return tree.flatMap(found);
    const parsed = nodeSchema.safeParse(tree);
    if (!parsed.success) return [];
    return [
      parsed.data,
      ...(parsed.data.children ?? []).flatMap(found),
      ...(Array.isArray(parsed.data.items) ? parsed.data.items.flatMap(found) : []),
    ];
  }
  const invoke = (id: string, event = "onClick", payload: unknown = {}) => {
    const node = trees.flatMap((view) => found(view)).find((node) => node.id === id);
    assert.ok(node, `Missing ${id}`);
    const handler = z.custom<(payload: unknown) => unknown>((value) => typeof value === "function").parse(node[event]);
    handler(payload);
  };
  // A file's hover button in a section's tree, by its icon.
  const action = (side: string, path: string, icon: string) => {
    const tree = trees.flatMap((view) => found(view)).find((node) => node.id === `git-tree-${side}`);
    assert.ok(tree, `Missing git-tree-${side}`);
    const file = z
      .array(z.object({ path: z.string(), actions: z.array(z.object({ icon: z.string(), onClick: z.function() })) }))
      .parse(tree["files"])
      .find((f) => f.path === path);
    const button = file?.actions.find((a) => a.icon === icon);
    assert.ok(button, `Missing ${icon} of ${path}`);
    button.onClick();
  };
  return { api, calls, tabsCalls, update, settle, invoke, action, state: () => state };
}

test("diffs and files open through the tabs service", async () => {
  const fake = fakeApi(true);
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    fake.action("changes", "src/a.ts", "file");
    await fake.settle();
    assert.deepEqual(fake.tabsCalls, [
      { method: "open", params: { kind: "file", workspaceId: "w1", path: "src/a.ts" } },
    ]);
    fake.tabsCalls.length = 0;
    fake.invoke("git-tree-changes", "onSelect", { id: "src/a.ts", folder: false });
    await fake.settle();
    assert.ok(fake.calls.some((call) => call.method === "host/git.diff"));
    assert.deepEqual(fake.tabsCalls, [
      {
        method: "open",
        params: {
          kind: "diff",
          workspaceId: "w1",
          path: "src/a.ts",
          diff: "--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n-old\n+new",
        },
      },
    ]);
  } finally {
    await dispose();
  }
});

test("without the tabs plugin diffs still load and nothing opens", async () => {
  const fake = fakeApi(false);
  const dispose = activate(fake.api);
  try {
    await fake.settle();
    fake.invoke("git-tree-changes", "onSelect", { id: "src/a.ts", folder: false });
    await fake.settle();
    assert.ok(fake.calls.some((call) => call.method === "host/git.diff"));
    assert.deepEqual(fake.tabsCalls, []);
    assert.equal(fake.state().shared?.openTab, undefined);
  } finally {
    await dispose();
  }
});

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <313.4 KB4 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.