Walkthrough: Project Brief
Build a panel that reads Git status and adds a project summary to the chat draft.
This walkthrough builds Project Brief, one of the two example add-ons in the CodeMux repository. It adds:
- A right-panel view that shows the project name, branch and changed files.
- A command, Open Project Brief, that opens the panel.
- A composer action, Add project brief, that adds a short summary to the chat draft.
- A setting and a saved preference.
It uses the same public SDK as any other add-on and has no special access. Set up your tools first with Build an add-on.
1. Create the project
npx --yes --package ~/codemux-sdk/codemux-plugin-cli-1.0.0.tgz \
codemux-plugin init project-brief --id acme.project-brief
cd project-brief
npm install ~/codemux-sdk/codemux-plugin-sdk-1.0.0.tgz \
~/codemux-sdk/codemux-plugin-cli-1.0.0.tgzThe example itself uses the ID codemux.project-brief. Use your own publisher
instead. codemux belongs to the CodeMux project, and catalog review checks that
a publisher controls its source repository.
2. Declare what the add-on needs
CodeMux reads manifest.json before it runs any of your code. The install review
comes from it, and every ID you register in code has to be declared here first.
Replace the permissions, contributes and settings in the starter manifest:
{
"permissions": ["workspace.read", "git.read", "composer.append"],
"contributes": {
"commands": [
{ "id": "open", "title": "Open Project Brief", "requiresWorkspace": true }
],
"panels": [
{ "id": "brief", "title": "Project Brief", "icon": "file-text" }
],
"composerActions": [
{ "id": "insert", "title": "Add project brief", "icon": "file-text" }
],
"composerViews": []
},
"settings": [
{
"id": "include-files",
"type": "boolean",
"label": "Include changed filenames",
"default": true
}
]
}workspace.readlets the add-on get the open project's name. It never gets a file path.git.readlets it get a read-only Git summary.composer.appendlets it add text to the end of the chat draft. It can't read or send the draft.include-filesappears as a switch on the add-on's page (Configure / Permissions in Settings → Add-ons).
Keep "http": [] and "credentials": []. This add-on doesn't use the network.
Every array in the manifest has to be present, even when it's empty. The
Manifest reference lists every field.
3. Load project data
Replace src/index.tsx. Start with definePlugin and a helper that reads the
workspace and Git summary:
import {
definePlugin, Stack, Text, Heading, Button, List, Checkbox,
useState, useEffect, PluginError,
type ContextHandle, type ViewProps, type Workspace, type GitSummary,
} from "@codemux/plugin-sdk";
const message = (error: unknown) =>
error instanceof Error ? error.message : "Project information is unavailable";
export default definePlugin({
activate(ctx) {
// Notifications are limited to three per minute; a rejected one is dropped.
const notify = (text: string) => ctx.ui.notify(text).catch(() => {});
async function load(context: ContextHandle) {
const workspace = await ctx.workspace.current(context);
if (!workspace) throw new Error("Open a local project to prepare a brief.");
let git: GitSummary | null = null;
try {
git = await ctx.git.summary(context);
} catch (error) {
if (!(error instanceof PluginError) || error.code !== "NOT_A_GIT_REPO")
throw error;
}
return { workspace, git };
}
// ...the next steps go here
},
});Every host call takes a ContextHandle, which tells CodeMux which project the
call is for. You never create one yourself. CodeMux passes one to views, command
handlers and UI events. Always pass along the handle you were given.
Host calls reject with a PluginError that has a stable code. A project that
isn't a Git repository is normal, so load catches NOT_A_GIT_REPO and passes
every other error on. See Errors for all codes.
4. Add text to the draft
async function insert(
context: ContextHandle,
data?: { workspace: Workspace; git: GitSummary | null },
) {
const { workspace, git } = data ?? (await load(context));
const settings = await ctx.settings.get();
const lines = [
`Project: ${workspace.name}`,
git ? `Branch: ${git.branch ?? "detached HEAD"}` : "Not a Git repository",
];
if (git) {
lines.push(
`Changes: ${git.staged} staged, ${git.unstaged} unstaged, ${git.untracked} untracked, ${git.conflicts} conflicts`,
);
if (settings["include-files"] === true && git.paths.length)
lines.push(
`Changed paths: ${git.paths.slice(0, 20).join(", ")}${git.paths.length > 20 || git.truncated ? " …" : ""}`,
);
}
await ctx.composer.appendText(context, lines.join("\n"));
}composer.appendText adds text after whatever the user has already typed. It
needs a live user interaction: the handle from a click or a command expires after
10 seconds and works only once. Waiting on other calls doesn't extend it, so keep
the work before the append short.
settings.get() returns every declared setting, with defaults for the ones the
user hasn't changed.
5. Build the panel
A view is a Preact component. Use the SDK's components and hooks. Plain HTML elements don't render.
function Brief({ context }: ViewProps) {
const [data, setData] = useState<{ workspace: Workspace; git: GitSummary | null } | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(true);
useEffect(() => {
let live = true;
load(context)
.then((value) => { if (live) setData(value); })
.catch((e) => { if (live) setError(message(e)); })
.finally(() => { if (live) setLoading(false); });
ctx.storage
.get({ scope: "global" }, "show-paths")
.then((value) => { if (live && typeof value === "boolean") setExpanded(value); })
.catch(() => {});
return () => { live = false; };
}, [context]);
return (
<Stack spacing="md">
<Heading>Project Brief</Heading>
<Text color="muted">Local context, ready for your draft.</Text>
{loading ? (
<Text>Reading project…</Text>
) : data ? (
<Stack spacing="sm">
<Heading level={3}>{data.workspace.name}</Heading>
<Text>{data.git ? `Branch: ${data.git.branch ?? "detached HEAD"}` : "Not a Git repository"}</Text>
{data.git && (
<>
<Text>{`${data.git.staged} staged · ${data.git.unstaged} unstaged · ${data.git.untracked} untracked · ${data.git.conflicts} conflicts`}</Text>
<Checkbox
label="Show changed paths"
checked={expanded}
onChange={async (event) => {
setExpanded(event.value === true);
try {
await ctx.storage.set({ scope: "global" }, "show-paths", event.value === true);
} catch (e) {
setError(`Preference not saved: ${message(e)}`);
}
}}
/>
{expanded &&
(data.git.paths.length
? <List items={data.git.paths} />
: <Text color="muted">Working tree is clean.</Text>)}
</>
)}
</Stack>
) : null}
{error && <Text color="danger">{error}</Text>}
<Stack direction="horizontal">
<Button
disabled={loading}
onPress={async (event) => {
setLoading(true);
setError("");
try { setData(await load(event.context)); }
catch (e) { setError(message(e)); }
finally { setLoading(false); }
}}
>
Refresh
</Button>
<Button
disabled={!data || loading}
onPress={async (event) => {
try { await insert(event.context, data!); setError(""); }
catch (e) { setError(message(e)); }
}}
>
Add to draft
</Button>
</Stack>
</Stack>
);
}A few things to note:
- Re-run effects when
contextchanges. When the user switches projects, CodeMux unmounts the view and mounts it again with a new handle. Theliveflag stops a late result from an old project from showing up. - Use
event.contextin event handlers. It has the fresh interaction thatappendTextneeds. The view's owncontextdoesn't. - Catch errors where they happen and show them. An error thrown synchronously,
or a rejection that isn't a
PluginError, stops the add-on until the user chooses Retry. - Save the preference with
storage. The add-on stops after 60 seconds with no open view, and memory is cleared when it does. Storage is private to the add-on and survives restarts.
6. Register the contributions
Register a callback for every ID you declared, all inside activate:
ctx.panels.register("brief", (props) => <Brief {...props} />);
ctx.commands.register("open", async (context) => {
try {
await ctx.panels.open("brief", context);
} catch (error) {
await notify(message(error));
}
});
ctx.composerActions.register("insert", async (context) => {
try {
await insert(context);
} catch (error) {
await notify(message(error));
}
});Activation fails if you register an ID that isn't declared, register the same ID
twice, or leave a declared ID unregistered. Commands and composer actions don't
get UI, so they report problems with ui.notify.
7. Build and try it
npm run build && npm run check && npm run packImport the .cmxaddon in Settings → Add-ons, check the three permissions in
the review, and choose Install & enable. Then:
- Open a local project and run Open Project Brief from the command palette, or open the panel from the right panel's + menu.
- Choose Add to draft. The summary is added to the end of the chat draft, and the text already there stays.
- In the chat composer's attachment menu, choose Add-ons → Add project brief.
- In Settings → Add-ons, choose Configure / Permissions on Project Brief, turn off Include changed filenames, choose Save settings, and add the brief again. The file list is left out.
While you work on it, use npm run dev with Developer mode
so the add-on reloads when you save.
Going further: Issue Companion
The second example, Issue Companion, covers the parts Project Brief leaves out:
- Network access. It declares
https://api.github.comwithGETand callsctx.http.fetch. CodeMux sends only requests to origins you declared. - Credentials. It declares an optional
github-tokenbearer credential. The user enters the token in Settings. CodeMux adds it to requests, and the add-on never sees it. - Links. Its Open in browser button calls
ctx.links.open, which needs theexternal.openpermission. - Composer views. Its composer action opens the same issue list above the
composer with
ctx.composerViews.open.
"http": [
{ "origin": "https://api.github.com", "methods": ["GET"], "credential": "github-token" }
],
"credentials": [
{ "id": "github-token", "label": "GitHub token (optional)", "origin": "https://api.github.com", "type": "bearer" }
]const response = await ctx.http.fetch(context, {
origin: "https://api.github.com",
path: `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues?state=open&per_page=20`,
method: "GET",
headers: { Accept: "application/vnd.github+json" },
});
if (response.status !== 200) { /* handle 401, 403, 404 and rate limits */ }
const issues = JSON.parse(response.body);An HTTP error status still resolves, so check response.status. http.fetch
rejects with a PluginError only when CodeMux refuses or can't finish the
request, for example with NETWORK_DENIED, CREDENTIAL_REQUIRED,
RESOURCE_LIMIT or TIMEOUT.