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

views.ts5.3 KB
// The detail pane's strip and content trees, with effectful actions only in handlers.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import { ui, html as htm } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Tabs } from "./controller.ts";
import { activeTab, extension, LANGUAGES, tabName } from "./tabs.ts";
import type { Tab } from "./tabs.ts";

// Every tab strip in the app caps a tab at this width (DESIGN.md, "Tabs").
const TAB_MAX_WIDTH = 200;

// Layout for the templates below: a flex column, a row that centres its
// children across, a child that takes the free space (and may shrink below
// its content), the whole of the parent, the hover wash, the pointer of
// something clickable, and a child of a sideways strip that keeps its width.
const column = { display: "flex", flexDirection: "column" };
const row = { display: "flex", flexDirection: "row", alignItems: "center" };
const fill = { flexGrow: 1, flexShrink: 1, flexBasis: "0%", minWidth: 0, minHeight: 0 };
const full = { width: "100%", height: "100%" };
const HOVER = { background: "hover" };
const clickable = { group: "hover", cursor: "pointer" };
const keep = { flexGrow: 0, flexShrink: 0, flexBasis: "auto" };

export function renderDetail(ctx: EffectViewContext<object>, actions: Tabs["Service"]) {
  const { state } = actions;
  // One line of tabs that scrolls sideways when they outgrow the pane.
  function renderStrip() {
    const reveal = Math.max(
      0,
      state.tabs.findIndex((t) => t.id === state.active),
    );
    return htm`
      <div ...${row} id="detail-tabs" reveal=${reveal} gap="small" padding="small" paddingX="small" paddingY="small" overflowX="scroll">
        ${state.tabs.map((t) => htm`<div ...${keep}>${renderTab(t)}</div>`)}
      </div>`;
  }

  function renderTab(t: Tab) {
    const active = t.id === state.active;
    const icon =
      t.kind === "diff"
        ? "git-compare"
        : t.kind === "terminal"
          ? "square-terminal"
          : t.kind === "view"
            ? (t.icon ?? "app-window")
            : "file";
    // A long title (a subagent's task) is cut at the tab's width.
    return htm`
      <div ...${row} ...${clickable} gap="small" padding="small" paddingX="small" paddingY="small" borderRadius="sm" maxWidth=${TAB_MAX_WIDTH}
        background=${active ? "accent" : undefined} hover=${active ? undefined : HOVER}
        onClick=${() => ctx.run(actions.select(t))}>
        <icon name=${icon} small tone=${active ? "foreground" : "muted"} />
        <text style="body" size="xs" bold=${active} tone=${active ? "foreground" : "muted"} truncate>${tabName(t)}</text>
        <button id=${`close-${t.id}`} icon="x" variant="ghost" small tooltip="Close tab" onClick=${() => ctx.run(actions.close(t.id))} />
      </div>`;
  }

  function renderContent(tab: Tab) {
    if (tab.kind === "terminal") {
      return htm`<div ...${column} ...${fill} ...${full} gap="small" padding="small" paddingX="small"><terminal terminal=${tab.path} /></div>`;
    }
    if (tab.kind === "view") return htm`<view view=${tab.view} />`;
    const content = state.contents[tab.id];
    if (!content) return htm`<empty>Loading…</empty>`;
    if (content.error) {
      return htm`<div ...${column} gap="small" padding="medium"><text style="body" size="xs" tone="danger">${content.error}</text></div>`;
    }
    if (tab.kind === "diff") {
      return htm`
        <div ...${column} ...${fill} gap="small" padding="medium" overflowY="scroll">
          ${content.diff ? ui.diff(content.diff, tab.path) : htm`<text style="muted">No changes.</text>`}
        </div>`;
    }
    const ext = extension(tab.path);
    if (ext === "md" && tab.preview) {
      return htm`<div ...${column} ...${fill} gap="small" padding="large" overflowY="scroll"><markdown id=${`md-${tab.id}`}>${content.text}</markdown></div>`;
    }
    return htm`<editor id=${`file-${tab.id}`} text=${content.text} language=${LANGUAGES[ext] ?? "text"} readOnly />`;
  }

  const tab = activeTab(state);
  if (!tab) return null;
  const ext = extension(tab.path);
  const markdown = tab.kind === "file" && ext === "md";
  return htm`
      <div ...${column} ...${fill} ...${full} padding="small">
        ${renderStrip()}
        ${
          tab.kind === "terminal" || tab.kind === "view"
            ? null
            : htm`
          <div ...${row} gap="small" padding="small" paddingX="medium" paddingY="small">
            <text style="mono" tone="muted" truncate>${tab.path}</text>
            <spacer />
            ${
              markdown
                ? htm`
              <div ...${row} gap="small" padding="small">
                <button id=${`preview-${tab.id}`} icon="eye" label="Preview" variant="ghost" small active=${tab.preview}
                  onClick=${() => {
                    tab.preview = true;
                    ctx.update();
                  }} />
                <button id=${`editor-${tab.id}`} icon="code" label="Editor" variant="ghost" small active=${!tab.preview}
                  onClick=${() => {
                    tab.preview = false;
                    ctx.update();
                  }} />
              </div>`
                : null
            }
          </div>`
        }
        <div ...${column} ...${fill} gap="small" padding="small">${renderContent(tab)}</div>
      </div>`;
}

Versions

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

Reviews and comments

0 threads · 0 reviews

No comments yet.