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/entries.ts4.7 KB
// Status rows, section folds and their explicit two-press discard affordance.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import * as Effect from "effect/Effect";
import { html as htm } from "convergence";
import type { Element } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Git } from "../controller.ts";
import { TONE } from "../status.ts";
import type { Entry, Section } from "../status.ts";
import { column, row, panelRow, hoverActions } from "./layout.ts";

// A status letter as the tree's status.
const STATUS: Readonly<Record<string, string>> = {
  M: "modified",
  T: "modified",
  A: "added",
  D: "deleted",
  R: "renamed",
  C: "renamed",
  U: "untracked",
  "!": "conflict",
};

export function entriesView(ctx: EffectViewContext<object>, actions: Git["Service"]) {
  const { state } = actions;
  function confirmed(key: string, effect: Effect.Effect<unknown, unknown>) {
    if (state.confirm === key) {
      state.confirm = null;
      ctx.run(effect);
    } else {
      state.confirm = key;
      ctx.update();
    }
  }
  function action(id: string, icon: string, tooltip: string, onClick: () => void, danger = false) {
    return htm`<button id=${id} icon=${icon} variant=${danger ? "danger" : "ghost"} small tooltip=${tooltip} disabled=${!!state.busy} onClick=${onClick} />`;
  }

  // A changed file's hover buttons: open, then stage or unstage, and a
  // discard that asks twice.
  function fileActions(e: Entry) {
    const key = `${e.side}:${e.path}`;
    const buttons: { icon: string; tooltip: string; onClick: () => void }[] = [];
    if (e.letter !== "D")
      buttons.push({ icon: "file", tooltip: "Open File", onClick: () => ctx.run(actions.openFile(e)) });
    if (e.side === "staged") {
      buttons.push({ icon: "minus", tooltip: "Unstage Changes", onClick: () => ctx.run(actions.unstage([e])) });
      return buttons;
    }
    if (e.side === "changes") {
      const armed = state.confirm === `discard:${key}`;
      buttons.push({
        icon: "undo-2",
        tooltip: armed
          ? e.letter === "U"
            ? "Click again to delete the file"
            : "Click again to discard"
          : "Discard Changes",
        onClick: () => confirmed(`discard:${key}`, actions.discard([e])),
      });
    }
    buttons.push({
      icon: "plus",
      tooltip: e.side === "merge" ? "Mark Resolved (Stage)" : "Stage Changes",
      onClick: () => ctx.run(actions.stage([e])),
    });
    return buttons;
  }

  // A section's files as the kernel's file tree: folders open, each file
  // with its status (the name's colour and its letter) and buttons.
  function fileTree(key: Section, entries: readonly Entry[]) {
    const byPath = new Map(entries.map((e) => [e.path, e]));
    const files = entries.map((e) => ({
      path: e.path,
      label: e.origPath ? `${e.origPath.split("/").pop()} → ${e.path.split("/").pop()}` : undefined,
      status: STATUS[e.letter] ?? "modified",
      badge: e.letter,
      actions: fileActions(e).map((action) => ({ ...action, disabled: !!state.busy })),
    }));
    return htm`<tree id=${`git-tree-${key}`} fit openFolders files=${files} onSelect=${(ev: {
      id: string;
      folder: boolean;
    }) => {
      const e = byPath.get(ev.id);
      if (!ev.folder && e) ctx.run(actions.openDiff(e));
    }} />`;
  }

  function section(key: Section, title: string, entries: readonly Entry[], buttons: Element[]) {
    if (!entries.length) return null;
    const open = !state.collapsed[key];
    const toggle = () => {
      state.collapsed[key] = open;
      ctx.update();
    };
    // The header sits on the tree's grid (its title where the folders'
    // icons start) but reads as a heading: a taller row, small bold
    // capitals in the muted tone, the count and the chevron at the end.
    // While the pointer is on it the buttons take the count's place, ending
    // where it ends, and the chevron stays in view.
    return htm`
      <div ...${column} gap=${0} padding="small">
        <div ...${panelRow} id=${`git-section-${key}`} height=${30} gap=${6} onClick=${toggle}>
          <text style="section" size=${11} weight=${700} tone="muted" truncate>${title}</text>
          <spacer />
          <div ...${row} position="relative" height="100%">
            <div ...${hoverActions} right=${0} stopPropagation>${buttons}</div>
            <div groupHover=${{ group: "hover", visibility: "hidden" }}><text style="body" size=${9.84} tone="muted">${entries.length}</text></div>
          </div>
          <icon name=${open ? "tree-chevron-down" : "tree-chevron-right"} tone="muted" />
        </div>
        ${open ? fileTree(key, entries) : null}
      </div>`;
  }

  return { action, section, confirmed };
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.