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

views/panel.ts5.1 KB
// Source-control rail and panel; rendering never starts a host request.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import { html as htm } from "convergence";
import type { Element } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Git } from "../controller.ts";
import { groups, isOpen } from "../status.ts";
import { controls } from "./controls.ts";
import { entriesView } from "./entries.ts";
import { column, row, fill, full, INSET } from "./layout.ts";

export function renderRail(ctx: EffectViewContext<object>, actions: Git["Service"]) {
  const { state } = actions;
  const count = state.status?.entries?.length ?? 0;
  const pinned = ctx.app.shared?.rightPanel === "git";
  return htm`<button id="rail-git" icon="git-branch" variant="ghost" active=${pinned} badge=${count ? String(count) : undefined}
      tooltip=${count ? `Source Control · ${count} changed` : "Source Control"} onClick=${() => ctx.run(actions.toggle)} />`;
}

// The sections that have files, with a line between two of them so each
// group's end is plain.
function divided(sections: readonly (Element | null)[]) {
  return sections.filter((s) => s !== null).map((s, i) => [i ? htm`<divider />` : null, s]);
}

export function renderPanel(ctx: EffectViewContext<object>, actions: Git["Service"]) {
  const { state } = actions;
  const { primaryButton, branchPicker, menu } = controls(ctx, actions);
  const { action, section, confirmed } = entriesView(ctx, actions);
  if (!isOpen(ctx.app)) return null;
  const workspaceId = ctx.app.workspaceId;
  if (!workspaceId)
    return htm`<div ...${column} gap="small" padding="medium"><text style="muted">Select a workspace to see its changes.</text></div>`;
  const status = state.status;
  const g = status?.isRepo ? groups(status) : { merge: [], staged: [], changes: [] };
  const total = g.merge.length + g.staged.length + g.changes.length;
  const header = htm`
      <div ...${row} gap="small" padding="small" paddingY="small">
        <div ...${row} ...${fill} gap="small" padding="small" paddingX=${INSET}>
          <text style="body" size=${13.125} weight=${600} truncate>Source Control</text>
        </div>
        ${state.busy ? htm`<div ...${row} gap="small" padding="small"><spinner /><text style="body" size="xs" tone="muted">${state.busy}</text></div>` : null}
        ${
          status?.isRepo
            ? htm`<button id="git-header-commit" icon="check" variant="ghost" small tooltip="Commit"
              disabled=${!!state.busy || !total || !state.message.trim()} onClick=${() => ctx.run(actions.commit())} />`
            : null
        }
        <button id="git-refresh" icon="rotate-cw" variant="ghost" small tooltip="Refresh" onClick=${() => ctx.run(actions.refresh())} />
        ${status?.isRepo ? menu(status, g) : null}
        <button id="git-close" icon="x" variant="ghost" small tooltip="Close" onClick=${() => ctx.run(actions.close)} />
      </div>`;
  const body = (...children: (Element | null)[]) =>
    htm`<div ...${column} ...${fill} ...${full} gap="small" padding="small" paddingX="small">${header}${children}</div>`;
  if (state.error) return body(htm`<text style="body" size="xs" tone="danger">${state.error}</text>`);
  if (!status) return body(htm`<text style="muted">Loading…</text>`);
  if (!status.isRepo) return body(htm`<text style="muted">This workspace is not in a git repository.</text>`);

  const branch = status.branch ? `"${status.branch}"` : "this branch";
  return body(
    branchPicker(status),
    htm`
        <div ...${column} gap="small" padding="small">
          <input id="git-message" value=${state.message} placeholder=${`Message (⏎ to commit on ${branch})`}
            onChange=${(e: { value: string }) => {
              state.message = e.value;
              ctx.update();
            }}
            onSubmit=${() => ctx.run(actions.commit())} />
          ${primaryButton(status, total)}
        </div>`,
    htm`
        <div ...${column} ...${fill} gap="small" padding="small" paddingY="small" overflowY="scroll">
          ${divided([section("merge", "Merge Changes", g.merge, [
            action("git-merge-stage-all", "plus", "Stage All Merge Changes", () => ctx.run(actions.stage(g.merge))),
          ]),
          section("staged", "Staged Changes", g.staged, [
            action("git-staged-unstage-all", "minus", "Unstage All Changes", () => ctx.run(actions.unstage(g.staged))),
          ]),
          section("changes", "Changes", g.changes, [
            action(
              "git-changes-discard-all",
              "undo-2",
              state.confirm === "discard:all" ? "Click again to discard all changes" : "Discard All Changes",
              () => confirmed("discard:all", actions.discard(g.changes)),
              state.confirm === "discard:all",
            ),
            action("git-changes-stage-all", "plus", "Stage All Changes", () => ctx.run(actions.stage(g.changes))),
          ])])}
          ${total ? null : htm`<div ...${column} gap="small" padding="small" paddingX=${INSET} paddingY="medium"><text style="muted">No changes. The working tree is clean.</text></div>`}
        </div>`,
  );
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.