Official
terminal
Built-in integrated terminal (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/terminal@0.2.0
Permissions in 0.2.0
Take care. This plugin asks for permissions that can do anything your account can. The app asks you to hold Enable for two seconds or to type the plugin's name before it turns on.
Files
view.ts6.7 KB
// Terminal trees preserve the rail, fly-out, tab menus and session grid from the JavaScript UI.
// Typed factories keep formatting from introducing HTM whitespace nodes; handlers use scoped effects.
import { h } from "convergence";
import type { DivProps, IconName, SpacingStep, Terminal } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { Controller } from "./controller.ts";
import { activeTerminal, forWorkspace, terminalInPane } from "./state.ts";
const TAB_MAX_WIDTH = 200;
const column = { display: "flex", flexDirection: "column" } satisfies DivProps;
const row = { display: "flex", flexDirection: "row", alignItems: "center" } satisfies DivProps;
const fill = { flexGrow: 1, flexShrink: 1, flexBasis: "0%", minWidth: 0, minHeight: 0 } satisfies DivProps;
const full = { width: "100%", height: "100%" } satisfies DivProps;
const HOVER = { background: "hover" } satisfies DivProps;
const clickable = { group: "hover", cursor: "pointer" } satisfies DivProps;
const keep = { flexGrow: 0, flexShrink: 0, flexBasis: "auto" } satisfies DivProps;
function centered(padding: SpacingStep, text: string) {
return h(
"div",
{ ...column, ...fill, ...full, marginX: "auto", gap: "small", padding },
h("text", { style: "muted" }, text),
);
}
export function renderRail(controller: Controller, ctx: EffectViewContext<object>) {
if (!controller.state.ready) return null;
const running = forWorkspace(controller.state.terminals, ctx.app.workspaceId).filter(
(terminal) => terminal.running,
).length;
return h("button", {
id: "rail-terminal",
icon: "square-terminal",
variant: "ghost",
active: Boolean(terminalInPane(controller.state.activeTab)),
badge: running > 1 ? String(running) : undefined,
tooltip: "Terminal",
onClick: () => {},
});
}
export function renderFlyOut(controller: Controller, ctx: EffectViewContext<object>) {
const { state, update } = controller;
if (!state.ready || ctx.app.shared?.rightPanelPreview !== "terminal") return null;
const workspaceId = ctx.app.workspaceId;
if (!workspaceId) return centered("medium", "Open a workspace to use the terminal.");
const mine = forWorkspace(state.terminals, workspaceId);
const active = activeTerminal(state, workspaceId);
if (!active) return centered("medium", "Starting…");
const menuAction = (id: string, icon: IconName, label: string, onClick: () => void) =>
h("button", { id, icon, label, variant: "ghost", small: true, onClick });
function renderTab(terminal: Terminal, active: boolean) {
if (state.renaming === terminal.id)
return h(
"div",
{
...row,
padding: "small",
paddingX: "small",
background: "muted",
borderRadius: "sm",
width: TAB_MAX_WIDTH,
flexShrink: 0,
},
h("input", {
id: `terminal-rename-${terminal.id}`,
value: terminal.title,
ghost: true,
submitOnEnter: true,
onSubmit: (event) => ctx.run(controller.rename(terminal, event.value)),
}),
);
return h(
"popover",
{
id: `terminal-menu-${terminal.id}`,
open: state.menu === terminal.id,
width: 190,
onDismiss: () => {
state.menu = null;
update();
},
},
h(
"div",
{
...row,
...clickable,
id: `terminal-tab-${terminal.id}`,
gap: "small",
padding: "small",
paddingX: "small",
paddingY: "small",
borderRadius: "sm",
maxWidth: TAB_MAX_WIDTH,
background: active ? "accent" : undefined,
hover: active ? undefined : HOVER,
onClick: () => {
state.activeByWorkspace[String(terminal.workspaceId)] = terminal.id;
update();
},
onSecondaryClick: () => {
state.menu = terminal.id;
update();
},
},
h("icon", { name: "square-terminal", small: true, tone: active ? "foreground" : "muted" }),
h(
"text",
{
style: "body",
size: "xs",
bold: active,
tone: terminal.running ? (active ? "foreground" : "muted") : "danger",
truncate: true,
},
terminal.title,
),
h("button", {
id: `terminal-close-${terminal.id}`,
icon: "x",
variant: "ghost",
small: true,
tooltip: "Close terminal",
onClick: () => ctx.run(controller.close(terminal)),
}),
),
h(
"div",
{ ...column, padding: "small" },
menuAction(`terminal-rename-action-${terminal.id}`, "pencil", "Rename terminal", () =>
ctx.run(controller.startRename(terminal)),
),
menuAction(`terminal-popout-action-${terminal.id}`, "maximize", "Open in a tab", () =>
ctx.run(controller.popOut(terminal)),
),
menuAction(`terminal-close-action-${terminal.id}`, "x", "Close terminal", () =>
ctx.run(controller.close(terminal)),
),
),
);
}
return h(
"div",
{ ...column, ...fill, ...full, padding: "small" },
h(
"div",
{ ...row, gap: "small", padding: "small", paddingX: "small", paddingY: "small" },
h(
"div",
{
...row,
...fill,
id: "terminal-tabs",
reveal: Math.max(
0,
mine.findIndex((terminal) => terminal.id === active.id),
),
gap: "small",
padding: "small",
overflowX: "scroll",
},
mine.map((terminal) => h("div", { key: terminal.id, ...keep }, renderTab(terminal, terminal.id === active.id))),
h(
"div",
{ key: "new", ...keep },
h("button", {
id: "terminal-new",
icon: "plus",
variant: "ghost",
small: true,
tooltip: "New terminal",
onClick: () => ctx.run(controller.create(active.workspaceId)),
}),
),
),
!active.running
? h("text", { style: "body", size: "xs", tone: "muted" }, `exited ${active.exitCode ?? 0}`)
: null,
h("button", {
id: "terminal-popout",
icon: "maximize",
variant: "ghost",
small: true,
tooltip: "Open in a tab",
onClick: () => ctx.run(controller.popOut(active)),
}),
),
terminalInPane(state.activeTab) === active.id
? centered("large", "This terminal is open in the tabbed pane.")
: h(
"div",
{ ...column, ...fill, ...full, gap: "small", padding: "small", paddingX: "small" },
h("terminal", { terminal: active.id }),
),
);
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 8.7 KB | 3 permissions | Listed |
No comments yet.