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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 18.8 KB | 3 permissions | Listed |
No comments yet.