Branch notes and to-dos from the Encela app, inside Claude Code

A desktop companion for AI coding tools: build your Claude Code and Codex setup visually, manage git work, and keep notes per branch, right next to your terminal.
[!NOTE] Early development. Windows comes first; Linux and macOS are planned.
<!-- Screenshot: main window (document composer) — e.g. docs/images/composer.png -->
AI coding tools are configured through a pile of files: CLAUDE.md, AGENTS.md, rules, skills, subagents, hooks, MCP servers, settings in three different scopes. It is hard to see which ones are actually in effect, and writing them by hand is slow.
Encela puts all of that in one window:
| Screen | What you can do |
|---|---|
| 🧩 Document composer | Drag blocks onto a canvas and write CLAUDE.md / AGENTS.md, rules, skills, subagents, hooks and PLANS.md |
| 🗺️ Settings map | See every instruction, rule, skill, subagent, command, MCP server and setting your tool loads, with its scope and status |
| 📄 Project docs | Open a folder and edit its AI config files in the app; get told when they change on disk |
| 📚 Library | Keep your favorite skills and subagents in one place and switch them on or off per project; install popular open-source extensions from GitHub (plugins, MCP servers) in one click |
| ⏰ Automations | Run your claude on a schedule (e.g. every morning: collect news with a Haiku subagent and mail it to you); build one in the composer or ask Claude Code to |
| 🔌 MCP servers | List, add, edit, remove and health-check MCP servers; paste a JSON snippet or claude mcp add command to import |
| 🌿 Work | Branches, worktrees, diffs, commit / push / pull, pull requests, CI status, merge-conflict helper |
| 📝 Notes | Notes and to-dos for each repository and branch, synced across your devices |
| 📌 Sidebar | A narrow panel that docks next to the window you are using and follows it |
Each project uses one tool. The app detects it from the files in the project (you can override it), and every screen follows that choice.
| Claude Code | Codex | |
|---|---|---|
| Settings map, library, project docs | ✅ | ✅ |
| Document composer | ✅ | ✅ (instructions go to AGENTS.md; Codex has no rules folder) |
| MCP servers | ✅ (with connection check) | ✅ (no connection check) |
| Worktrees, AI commit message draft | ✅ | ✅ |
| Branch-notes mod inside the tool | ✅ | — |
*-setup.exe from Releases.You also need
claude / codex command you already have; it never asks for your login.CLAUDE.md, and check the preview on the right.<!-- Screenshot: composer canvas with the "Expected effect" panel — e.g. docs/images/composer-ratings.png -->
Build AI instruction files like a node graph.
flowchart LR
A["Blocks<br/>instructions · rules · skills<br/>subagents · hooks · plans"] --> B["Canvas<br/>connect, reorder,<br/>switch on/off"]
B --> C["Outputs<br/>CLAUDE.md · AGENTS.md · rules<br/>skills · agents · hooks · PLANS.md"]
C --> D["Preview → write to project<br/>or commit to GitHub"]
THIRD_PARTY_NOTICES.md.PLANS.md with goals, sub-goals and steps (write text, hand off to a subagent, use a skill, ask the user), each marked done / in progress / not yet.CLAUDE.md and never silently overwrites a file it did not write.CLAUDE.md vs CLAUDE.local.md), and "Get latest" to refresh third-party blocks from their source.Shows everything the tool reads in the selected project: instructions, rules, skills, subagents, commands, MCP servers and settings, grouped by scope (personal / project / local) with a status (in use, conditional, turned off, shadowed, broken JSON…).
CLAUDE.md (including in subfolders), AGENTS.md, rules, skills, agents, commands and settings files in a folder and lets you edit them in the app.claude command.claude -p while the app or its tray icon is running; a run missed while the app was closed runs once within a day. Results, cost and errors are kept per run, with a desktop notification.One screen for project (.mcp.json), local and personal servers: check if they connect, add / edit / remove them, toggle project servers, or install a recommended server. Secrets are masked, and the app suggests ${VARIABLE} references instead of pasting keys into files. Personal Claude Code servers are handled through your own claude mcp command.
<!-- Screenshot: Work screen with the flow card — e.g. docs/images/work.png -->
claude or codex CLI.Install the branch-notes mod from Settings (Claude Code 2.1.287 or later). Claude Code then shows this branch's to-dos, PR and CI in one line above the prompt, adds /todo, /memo and /notes, and lets Claude read your notes while it works. Recommended community mods (token-weather, blast-radius, replay-theater, claude-hud) can be installed from Settings too.
Docks next to the window you are using (Windows Terminal, VS Code, Claude desktop, Codex) and follows it. Settings: magnet on/off, always on top, show/hide shortcut, theme, language (English / Korean), text size.
~/.claude/.credentials.json or tokens). AI features run the claude / codex command you installed, unchanged.Requirements
corepack enable)rustup)libwebkit2gtk-4.1-dev libxdo-dev libayatana-appindicator3-dev librsvg2-devSee also Tauri prerequisites.
pnpm install
pnpm tauri dev # run the app in development mode
pnpm lint # Biome lint + format check
pnpm build # type check + frontend build
pnpm tauri build # build the installer
Rust checks (in src-tauri/):
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
Claude Code mod checks (in claude-mod/branch-notes/):
claude plugin validate .
claude plugin test .
| Path | What's there |
|---|---|
src/ | React 19 + TypeScript frontend (workspace/ main window, sidebar/ sidebar, features/ shared panels) |
src-tauri/ | Rust backend (Tauri 2). tools/ has one adapter per AI tool, catalog/ the composer blocks |
claude-mod/ | The Claude Code mod (talks to the app through files in <app data>/bridge) |
firebase/ | Security rules for sync |
docs/ | Plan, research notes, setup guides, test checklists |
docs/PLAN.md: decisions and roadmapdocs/research.md: researched facts with sourcesdocs/sync-setup.md: setting up sync (Firebase)docs/release-setup.md: releases and app updatesdocs/testing/: manual test checklistsCI (.github/workflows/ci.yml) lints and builds on Linux and builds the Windows installer. Every version merged to main becomes a GitHub Release.
Apache-2.0. Licenses of bundled third-party blocks are listed in THIRD_PARTY_NOTICES.md.
This project is not affiliated with, sponsored or endorsed by Anthropic or OpenAI. Claude is a trademark of Anthropic, PBC. Codex and ChatGPT are trademarks of OpenAI.
hooks/register.tsx 267 lines1import type { EngineInterface, Register } from "claude-code";
2import { atom, read, update } from "claude-code";
3import {
4 applyLocally,
5 bandText,
6 contextText,
7 parseExport,
8 parseTodo,
9 type Request,
10 repoKeyFor,
11 requestName,
12 STALE_MS,
13 todoList,
14 type View,
15 words,
16} from "./core";
17
18const APP_ID = "io.github.alsrb060303collab.aimanagement";
19const PANE = "branch-notes";
20/** How often the app's export and the checked-out branch are read again. */
21const REFRESH_MS = 5000;
22
23const view = atom({ plugin: "branch-notes", key: "view" } as const, null);
24const isHidden = atom({ plugin: "branch-notes", key: "isHidden" } as const, false);
25
26/** `<app data>/bridge`, where the app writes `state.json` and reads `inbox/`. */
27async function bridgeDir($: EngineInterface): Promise<string | null> {
28 const override = await $.env.get("AIM_BRIDGE_DIR");
29 if (override) return override;
30 const appData = await $.env.get("APPDATA");
31 if (appData) return `${appData}\\${APP_ID}\\bridge`;
32 const home = await $.env.get("HOME");
33 if (!home) return null;
34 const mac = `${home}/Library/Application Support/${APP_ID}/bridge`;
35 if (await $.fs.exists(mac)) return mac;
36 const xdg = (await $.env.get("XDG_DATA_HOME")) || `${home}/.local/share`;
37 return `${xdg}/${APP_ID}/bridge`;
38}
39
40const sep = (dir: string) => (dir.includes("\\") ? "\\" : "/");
41
42async function git($: EngineInterface, args: string[]): Promise<string | null> {
43 try {
44 const out = await $.process.run(["git", ...args]);
45 return out.exitCode === 0 ? out.stdout.trim() || null : null;
46 } catch {
47 return null;
48 }
49}
50
51/** Reads the app's export and finds this session's repository and branch in it. */
52async function load($: EngineInterface): Promise<View | null> {
53 const dir = await bridgeDir($);
54 if (!dir) return null;
55 let text: string;
56 try {
57 text = (await $.fs.read(`${dir}${sep(dir)}state.json`)) as string;
58 } catch {
59 return null;
60 }
61 const exp = parseExport(text);
62 if (!exp) return null;
63 const now = await $.clock.now();
64 const toplevel = await git($, ["rev-parse", "--show-toplevel"]);
65 if (!toplevel) {
66 return {
67 appRunning: now - exp.heartbeat < STALE_MS,
68 locale: exp.locale,
69 repoKey: null,
70 branch: "",
71 data: null,
72 };
73 }
74 const remote =
75 (await git($, ["remote", "get-url", "origin"])) ??
76 (await git($, ["config", "--get-regexp", "^remote\\..*\\.url$"]))?.split(/\s+/)[1] ??
77 null;
78 const common = await git($, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
79 const main = common ? common.replace(/[\\/]\.git[\\/]?$/, "") : toplevel;
80 const repoKey = repoKeyFor(exp, remote, main);
81 const branch = (await git($, ["symbolic-ref", "--quiet", "--short", "HEAD"])) ?? "";
82 return {
83 appRunning: now - exp.heartbeat < STALE_MS,
84 locale: exp.locale,
85 repoKey,
86 branch,
87 data: (repoKey && exp.repos[repoKey]?.[branch]) || null,
88 };
89}
90
91async function refresh($: EngineInterface) {
92 const next = await load($);
93 const prev = await read($, view);
94 if (JSON.stringify(next) !== JSON.stringify(prev)) await update($, view, () => next);
95}
96
97/** Leaves a request for the app and shows it at once. */
98async function send($: EngineInterface, request: Request): Promise<boolean> {
99 const dir = await bridgeDir($);
100 if (!dir) return false;
101 const s = sep(dir);
102 try {
103 await $.fs.write(
104 `${dir}${s}inbox${s}${requestName(await $.clock.now(), Math.random())}`,
105 JSON.stringify(request),
106 );
107 } catch {
108 return false;
109 }
110 await update($, view, (v) => (v ? applyLocally(v, request) : v));
111 return true;
112}
113
114export const register: Register = (on) => {
115 on("session.start", async ($, e, next) => {
116 await $.command.register({
117 name: "todo",
118 description: "Branch to-dos (Encela): /todo, /todo <text>, /todo done <n>",
119 });
120 await $.command.register({
121 name: "memo",
122 description: "Branch note (Encela): /memo, /memo <text>",
123 });
124 await $.command.register({
125 name: "notes",
126 description: "Open the branch notes pane (Encela)",
127 });
128 await refresh($);
129 $.clock.every(REFRESH_MS, () => void refresh($));
130 return next(e);
131 });
132
133 // The branch can change between turns (git switch in the session).
134 on("turn.complete", async ($, e, next) => {
135 await refresh($);
136 return next(e);
137 });
138
139 on("command.run", { command: "todo" }, async ($, e) => {
140 await refresh($);
141 const v = await read($, view);
142 const w = words(v?.locale ?? "en");
143 if (!v?.repoKey) return { text: v ? w.noRepo : w.noApp };
144 const cmd = parseTodo(e.args);
145 const tail = v.appRunning ? "" : `\n${w.noApp}`;
146 switch (cmd.kind) {
147 case "help":
148 return { text: w.todoHelp };
149 case "list":
150 return { text: `${todoList(v)}\n\n${w.todoHelp}` };
151 case "add":
152 if (
153 !(await send($, { op: "addTodo", repoKey: v.repoKey, branch: v.branch, text: cmd.text }))
154 ) {
155 return { text: w.noApp };
156 }
157 return { text: w.added(cmd.text) + tail };
158 default: {
159 const todo = v.data?.todos[cmd.index - 1];
160 if (!todo) return { text: w.noSuch(cmd.index) };
161 const request: Request =
162 cmd.kind === "rm"
163 ? { op: "deleteTodo", id: todo.id }
164 : { op: "setDone", id: todo.id, done: cmd.kind === "done" };
165 if (!(await send($, request))) return { text: w.noApp };
166 const said = cmd.kind === "rm" ? w.removed : cmd.kind === "done" ? w.done : w.undone;
167 return { text: said(todo.text) + tail };
168 }
169 }
170 });
171
172 on("command.run", { command: "memo" }, async ($, e) => {
173 await refresh($);
174 const v = await read($, view);
175 const w = words(v?.locale ?? "en");
176 if (!v?.repoKey) return { text: v ? w.noRepo : w.noApp };
177 const text = e.args.trim();
178 if (!text) return { text: v.data?.note.trim() ? v.data.note : `${w.empty}\n${w.memoHelp}` };
179 if (!(await send($, { op: "appendNote", repoKey: v.repoKey, branch: v.branch, text }))) {
180 return { text: w.noApp };
181 }
182 return { text: w.noted + (v.appRunning ? "" : `\n${w.noApp}`) };
183 });
184
185 on("command.run", { command: "notes" }, async ($) => {
186 const v = await read($, view);
187 await $.ui.open({ id: PANE, title: words(v?.locale ?? "en").paneTitle });
188 await update($, isHidden, () => false);
189 return {};
190 });
191
192 // One line above the prompt: the branch, to-dos left, the next one, the PR.
193 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
194 const v = await read($, view);
195 const text = v ? bandText(v) : null;
196 if (!v || !text || e.props.hasSurvey || (await read($, isHidden))) return next(e);
197 const { Box, Button, Text } = $.ui.resolve(e);
198 return (
199 <Box>
200 <Text dimColor wrap="truncate">
201 {text}{" "}
202 </Text>
203 <Button
204 key="open"
205 label={words(v.locale).paneTitle}
206 dimColor
207 onPress={() => void $.ui.open({ id: PANE, title: words(v.locale).paneTitle })}
208 />
209 <Button key="hide" label="×" dimColor onPress={() => update($, isHidden, () => true)} />
210 </Box>
211 );
212 });
213
214 // The pane: the note, and the to-dos to tick.
215 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
216 const { Box, Button, Markdown, Text } = $.ui.resolve(e);
217 const v = await read($, view);
218 const w = words(v?.locale ?? "en");
219 if (!v?.repoKey) return <Text dimColor>{v ? w.noRepo : w.noApp}</Text>;
220 const d = v.data;
221 return (
222 <Box flexDirection="column">
223 <Text bold>⎇ {v.branch || "-"}</Text>
224 {!v.appRunning && <Text dimColor>{w.noApp}</Text>}
225 {!d && <Text dimColor>{w.empty}</Text>}
226 {d?.note.trim() ? (
227 <Box flexDirection="column">
228 <Text bold>{w.noteTitle}</Text>
229 <Markdown key="note" text={d.note} />
230 </Box>
231 ) : null}
232 {d?.todos.length ? (
233 <Box flexDirection="column">
234 <Text bold>{w.todosTitle}</Text>
235 {d.todos.map((t, i) => (
236 <Button
237 key={`todo-${t.id}`}
238 plain
239 dimColor={t.done}
240 onPress={() => void send($, { op: "setDone", id: t.id, done: !t.done })}
241 >
242 {`${i + 1}. ${t.done ? "☑" : "☐"} ${t.text}`}
243 </Button>
244 ))}
245 </Box>
246 ) : null}
247 <Text dimColor>{w.todoHelp}</Text>
248 </Box>
249 );
250 });
251
252 // Claude reads the branch's note and to-dos with the system prompt.
253 on("prompt.compose", async ($, e, next) => {
254 const result = await next(e);
255 const v = await read($, view);
256 const text = v ? contextText(v) : null;
257 if (!text) return result;
258 return {
259 ...result,
260 sections: [
261 ...result.sections,
262 { id: "branch-notes:context", text, scope: "session" as const },
263 ],
264 };
265 });
266};
267hooks/core.ts 247 lines1// The mod's logic without the engine: reading the app's export, finding this
2// session's repository in it, wording, and parsing commands. Kept in sync
3// with src-tauri/src/bridge.rs (the export and the requests) and
4// src-tauri/src/memo/repo.rs (`normalize_remote`).
5
6import type { BranchData, View } from "../types";
7
8export type { BranchData, GitHubSummary, Todo, View } from "../types";
9
10export type Export = {
11 version: number;
12 heartbeat: number;
13 locale: "ko" | "en";
14 paths: Record<string, string>;
15 repos: Record<string, Record<string, BranchData>>;
16};
17
18/** The heartbeat is rewritten every 30 seconds while the app runs. */
19export const STALE_MS = 2 * 60 * 1000;
20
21/** `https://github.com/Owner/Repo.git` → `github.com/owner/repo`, as the app does. */
22export function normalizeRemote(raw: string): string {
23 let url = raw.trim().replace(/\/+$/, "");
24 if (url.endsWith(".git")) url = url.slice(0, -4);
25 let rest: string;
26 const scheme = url.indexOf("://");
27 if (scheme >= 0) {
28 let after = url.slice(scheme + 3);
29 const at = after.lastIndexOf("@");
30 if (at >= 0) after = after.slice(at + 1);
31 const slash = after.indexOf("/");
32 const host = (slash >= 0 ? after.slice(0, slash) : after).split(":")[0];
33 rest = `${host}/${slash >= 0 ? after.slice(slash + 1) : ""}`;
34 } else {
35 const colon = url.indexOf(":");
36 const userHost = colon > 1 ? url.slice(0, colon) : "";
37 if (colon > 1 && !/[/\\]/.test(userHost)) {
38 const at = userHost.lastIndexOf("@");
39 const host = at >= 0 ? userHost.slice(at + 1) : userHost;
40 rest = `${host}/${url.slice(colon + 1).replace(/^\/+/, "")}`;
41 } else {
42 return `file:${url.replace(/\\/g, "/")}`;
43 }
44 }
45 return rest.replace(/\/+$/, "").toLowerCase();
46}
47
48/** Paths compared the way Windows and the app's canonical paths can differ. */
49export function samePath(a: string, b: string): boolean {
50 const norm = (p: string) =>
51 p
52 .replace(/^path:/, "")
53 .replace(/\\/g, "/")
54 .replace(/^\/\/\?\//, "")
55 .replace(/\/+$/, "")
56 .toLowerCase();
57 return norm(a) === norm(b);
58}
59
60/**
61 * The app's key for a repository: its normalized remote, or, without one,
62 * whatever the app filed the main worktree's folder under.
63 */
64export function repoKeyFor(
65 exp: Export,
66 remote: string | null,
67 mainWorktree: string | null,
68): string | null {
69 if (remote) return normalizeRemote(remote);
70 if (!mainWorktree) return null;
71 for (const [pathKey, key] of Object.entries(exp.paths)) {
72 if (samePath(pathKey, mainWorktree)) return key;
73 }
74 return `path:${mainWorktree}`;
75}
76
77export function parseExport(text: string): Export | null {
78 try {
79 const value = JSON.parse(text);
80 if (value?.version !== 1 || typeof value.repos !== "object") return null;
81 return value as Export;
82 } catch {
83 return null;
84 }
85}
86
87const WORDS = {
88 ko: {
89 left: (open: number, total: number) => `할 일 ${open}/${total} 남음`,
90 allDone: "할 일 모두 완료",
91 next: "다음",
92 noApp: "Encela 앱이 꺼져 있어요. 켜면 반영돼요.",
93 added: (text: string) => `할 일 추가: ${text}`,
94 noted: "메모에 추가했어요.",
95 done: (text: string) => `완료: ${text}`,
96 undone: (text: string) => `다시 열림: ${text}`,
97 removed: (text: string) => `삭제: ${text}`,
98 noSuch: (n: number) => `${n}번 할 일이 없어요.`,
99 noRepo: "이 폴더는 git 저장소가 아니거나 아직 앱에서 연 적이 없어요.",
100 empty: "이 브랜치에는 메모와 할 일이 없어요.",
101 todoHelp:
102 "/todo 할 일 추가 · /todo done 2 완료 · /todo undo 2 다시 열기 · /todo rm 2 삭제 · /todo 목록",
103 memoHelp: "/memo 내용 → 메모에 한 줄 추가 · /memo → 메모 보기",
104 noteTitle: "메모",
105 todosTitle: "할 일",
106 paneTitle: "브랜치 메모",
107 pr: "PR",
108 context: (branch: string) =>
109 `사용자가 이 저장소의 '${branch}' 브랜치에 남긴 메모와 할 일이에요 (Encela 앱). 작업 방향을 정할 때 참고하고, 끝낸 일은 사용자에게 /todo done 번호 로 체크하라고 알려 주세요.`,
110 },
111 en: {
112 left: (open: number, total: number) => `${open}/${total} to-dos left`,
113 allDone: "All to-dos done",
114 next: "Next",
115 noApp: "The Encela app isn't running. It applies this when it starts.",
116 added: (text: string) => `Added to-do: ${text}`,
117 noted: "Added to the note.",
118 done: (text: string) => `Done: ${text}`,
119 undone: (text: string) => `Reopened: ${text}`,
120 removed: (text: string) => `Removed: ${text}`,
121 noSuch: (n: number) => `There's no to-do #${n}.`,
122 noRepo: "This folder isn't a git repository, or the app hasn't opened it yet.",
123 empty: "No note or to-dos on this branch.",
124 todoHelp: "/todo text adds · /todo done 2 · /todo undo 2 · /todo rm 2 · /todo lists them",
125 memoHelp: "/memo text adds a line to the note · /memo shows it",
126 noteTitle: "Note",
127 todosTitle: "To-dos",
128 paneTitle: "Branch notes",
129 pr: "PR",
130 context: (branch: string) =>
131 `The user's note and to-dos for the '${branch}' branch of this repository (from the Encela app). Use them to steer the work, and tell the user to tick finished items with /todo done <number>.`,
132 },
133};
134
135export const words = (locale: "ko" | "en") => WORDS[locale] ?? WORDS.en;
136
137/** The band above the prompt, or `null` when there's nothing to show. */
138export function bandText(view: View): string | null {
139 const d = view.data;
140 if (!d) return null;
141 const w = words(view.locale);
142 const parts: string[] = [`⎇ ${view.branch || "-"}`];
143 const open = d.todos.filter((t) => !t.done);
144 if (d.todos.length) {
145 parts.push(open.length ? w.left(open.length, d.todos.length) : w.allDone);
146 if (open[0]) parts.push(`${w.next}: ${open[0].text}`);
147 }
148 const g = d.github;
149 if (g?.prNumber) {
150 let ci = "";
151 if (g.failed) ci += ` ✗${g.failed}`;
152 if (g.pending) ci += ` …${g.pending}`;
153 if (g.passed) ci += ` ✓${g.passed}`;
154 parts.push(`${w.pr} #${g.prNumber} ${g.prState ?? ""}${ci}`.trim());
155 }
156 if (parts.length === 1 && !d.note.trim()) return null;
157 return parts.join(" · ");
158}
159
160/** The section added to the system prompt, or `null` when there's nothing. */
161export function contextText(view: View, maxChars = 2000): string | null {
162 const d = view.data;
163 if (!d || (!d.note.trim() && !d.todos.length)) return null;
164 const w = words(view.locale);
165 const lines = [w.context(view.branch)];
166 if (d.note.trim()) lines.push("", `## ${w.noteTitle}`, d.note.trim());
167 if (d.todos.length) {
168 lines.push("", `## ${w.todosTitle}`);
169 d.todos.forEach((t, i) => {
170 lines.push(`${i + 1}. [${t.done ? "x" : " "}] ${t.text}`);
171 });
172 }
173 const text = lines.join("\n");
174 return text.length > maxChars ? `${text.slice(0, maxChars - 1)}…` : text;
175}
176
177/** The numbered list `/todo` prints. */
178export function todoList(view: View): string {
179 const d = view.data;
180 const w = words(view.locale);
181 if (!d?.todos.length) return w.empty;
182 return d.todos.map((t, i) => `${i + 1}. ${t.done ? "☑" : "☐"} ${t.text}`).join("\n");
183}
184
185export type TodoCommand =
186 | { kind: "list" }
187 | { kind: "help" }
188 | { kind: "add"; text: string }
189 | { kind: "done" | "undo" | "rm"; index: number };
190
191export function parseTodo(args: string): TodoCommand {
192 const text = args.trim();
193 if (!text) return { kind: "list" };
194 if (text === "help" || text === "?") return { kind: "help" };
195 const m = /^(done|undo|rm|완료|취소|삭제)\s+(\d+)$/i.exec(text);
196 if (m) {
197 const verb = (m[1] ?? "").toLowerCase();
198 const kind =
199 verb === "done" || verb === "완료"
200 ? "done"
201 : verb === "undo" || verb === "취소"
202 ? "undo"
203 : "rm";
204 return { kind, index: Number(m[2]) };
205 }
206 return { kind: "add", text };
207}
208
209/** A request file for the app's inbox (src-tauri/src/bridge.rs `Request`). */
210export type Request =
211 | { op: "addTodo"; repoKey: string; branch: string; text: string }
212 | { op: "setDone"; id: string; done: boolean }
213 | { op: "deleteTodo"; id: string }
214 | { op: "appendNote"; repoKey: string; branch: string; text: string };
215
216/** A file name that sorts in arrival order and doesn't collide. */
217export function requestName(now: number, random: number): string {
218 return `${String(now).padStart(15, "0")}-${Math.floor(random * 1e9)}.json`;
219}
220
221/** Applies a request to the view right away, before the app confirms it. */
222export function applyLocally(view: View, request: Request): View {
223 const d = view.data ?? { note: "", todos: [] };
224 let next: BranchData = d;
225 switch (request.op) {
226 case "addTodo":
227 next = {
228 ...d,
229 todos: [...d.todos, { id: `pending-${d.todos.length}`, text: request.text, done: false }],
230 };
231 break;
232 case "setDone":
233 next = {
234 ...d,
235 todos: d.todos.map((t) => (t.id === request.id ? { ...t, done: request.done } : t)),
236 };
237 break;
238 case "deleteTodo":
239 next = { ...d, todos: d.todos.filter((t) => t.id !== request.id) };
240 break;
241 case "appendNote":
242 next = { ...d, note: d.note.trim() ? `${d.note.trimEnd()}\n${request.text}` : request.text };
243 break;
244 }
245 return { ...view, data: next };
246}
247types/index.d.ts 30 lines1export type Todo = { id: string; text: string; done: boolean };
2
3export type GitHubSummary = {
4 prNumber: number | null;
5 prTitle: string | null;
6 prState: string | null;
7 prUrl: string | null;
8 passed: number;
9 failed: number;
10 pending: number;
11};
12
13export type BranchData = { note: string; todos: Todo[]; github?: GitHubSummary };
14
15/** What the mod shows: this session's repository and branch, and its memo. */
16export type View = {
17 /** The app's export was found and is recent. */
18 appRunning: boolean;
19 locale: "ko" | "en";
20 repoKey: string | null;
21 branch: string;
22 data: BranchData | null;
23};
24
25declare module "claude-code" {
26 interface PluginState {
27 "branch-notes": { view: View | null; isHidden: boolean };
28 }
29}
30