SLOPSHOPPER

branch-notes

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

newpanebandcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · branch-notes
│ ┃ Branch notes ✕ › fix the failing auth test and add an audit log call │ ┃ The Encela app isn't running. It applies │ ┃ this when it starts. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /todo │ ⎿ branch-notes: The Encela app isn't running. It applies this when │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Branch notes
The Encela app isn't running. It applies this when it starts.
README

Encela

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.

English · 한국어 · 中文

Latest release Platform License

[!NOTE] Early development. Windows comes first; Linux and macOS are planned.

<!-- Screenshot: main window (document composer) — e.g. docs/images/composer.png -->

Why

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:

  • See what your AI tool reads in a project, and from where.
  • Build instruction files by snapping blocks together instead of writing them from scratch.
  • Work with git and GitHub without leaving the app, and keep a to-do list for every branch.
  • Dock a slim sidebar next to your terminal or editor so it is always one glance away.

Features at a glance

ScreenWhat you can do
🧩 Document composerDrag blocks onto a canvas and write CLAUDE.md / AGENTS.md, rules, skills, subagents, hooks and PLANS.md
🗺️ Settings mapSee every instruction, rule, skill, subagent, command, MCP server and setting your tool loads, with its scope and status
📄 Project docsOpen a folder and edit its AI config files in the app; get told when they change on disk
📚 LibraryKeep 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
⏰ AutomationsRun 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 serversList, add, edit, remove and health-check MCP servers; paste a JSON snippet or claude mcp add command to import
🌿 WorkBranches, worktrees, diffs, commit / push / pull, pull requests, CI status, merge-conflict helper
📝 NotesNotes and to-dos for each repository and branch, synced across your devices
📌 SidebarA narrow panel that docks next to the window you are using and follows it

Supported tools

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 CodeCodex
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✅—

Install

  1. Download the latest *-setup.exe from Releases.
  2. Run it. The installer is not code-signed yet, so Windows SmartScreen may warn you: choose More info → Run anyway.
  3. The app checks for updates at launch and every 6 hours and offers Update and restart.

You also need

  • Claude Code and/or Codex installed, if you want the app to work with them. The app runs the claude / codex command you already have; it never asks for your login.
  • Git for the Work screen.

Getting started

  1. Pick a project. Open the Settings map and choose a project folder. You now see what your tool currently loads for that project, by scope.
  2. Open the Document composer, drag a few blocks onto the canvas, connect them to an output such as CLAUDE.md, and check the preview on the right.
  3. Write to project. You get a preview of every file change before anything is written.
  4. Switch to sidebar when you start coding. Press <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>Space</kbd> to show or hide it, or use the tray icon to get the main window back.

A closer look

🧩 Document composer

<!-- 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"]
  • About 100 ready-made blocks: the app's own, plus license-checked blocks from popular open-source collections (obra/superpowers, mattpocock/skills, anthropics/skills, wshobson/agents, VoltAgent subagents, awesome-cursorrules and more). Source, commit and license are kept with every block; see THIRD_PARTY_NOTICES.md.
  • Expected effect: each block shows whether it tends to raise or lower tokens, performance and quality (−2 to +2). The canvas adds up the blocks you placed and shows performance, quality and token use as bars.
  • Click a block to read what it makes the AI do and see its full text. Double-click a block or output to preview the real file.
  • Subagents: pick the model (Auto, Fable, Opus, Sonnet, Haiku). Hooks: set the event and command, merged into your settings after you confirm.
  • Plans: write a 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.
  • Safe writes: the app only manages its own marked section of CLAUDE.md and never silently overwrites a file it did not write.
  • Write without cloning: send the result straight to a GitHub repository as a commit on a branch or a new pull request.
  • Undo / redo, team vs personal outputs (CLAUDE.md vs CLAUDE.local.md), and "Get latest" to refresh third-party blocks from their source.

🗺️ Settings map

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…).

📄 Project docs · 📚 Library

  • Project docs finds 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.
  • Library keeps the skills and subagents you reuse. Add one to a project or remove it again with one click.
  • GitHub extensions: license-checked open-source Claude Code extensions (REA, superpowers, compound-engineering, agent-skills, wshobson/agents, VoltAgent subagents) installed with your own claude command.

⏰ Automations

  • Pick a time (daily, on weekdays) or an interval, a model, the tools it may use, and an email address for the result.
  • In the document composer, connect instruction, rule, skill and subagent blocks to an Automation output: they become the run's instructions and subagents (e.g. a scraper subagent on Haiku).
  • Runs use your own 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.
  • Results are mailed through an email MCP server connected to Claude Code (e.g. Gmail). Claude Code can also list, create and run automations through the app's MCP server.

🔌 MCP servers

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.

🌿 Work (git · GitHub)

<!-- Screenshot: Work screen with the flow card — e.g. docs/images/work.png -->

  • A flow card shows where the branch is: commit → push → pull request → CI, with a button for the next step.
  • Changes with per-file staging, history with diffs, branches and worktrees.
  • Commit message drafts written by your own claude or codex CLI.
  • Sign in to GitHub to see pull requests, CI and issues, create pull requests, and get desktop notifications when CI finishes or a PR is merged or closed.
  • Pick from your GitHub repositories: open the local clone if you have one; otherwise browse branches, commits and PRs, edit files and commit directly on GitHub, or clone.
  • A conflict helper walks through each conflicting block (current / incoming / both).

📝 Notes & sync

  • Notes and to-dos per repository and branch. When you switch branches in the terminal, the notes follow.
  • Optional sync across devices (notes, composer documents, library) with an app account: email, Google or GitHub. Sync is currently open to approved accounts only. If two devices edit the same thing, the newer one wins and the other is kept as a copy.

🔗 Inside Claude Code: branch-notes mod

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.

📌 Sidebar

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.

Privacy & security

  • The app never reads, copies or stores your Claude login (~/.claude/.credentials.json or tokens). AI features run the claude / codex command you installed, unchanged.
  • GitHub and account tokens are kept in the OS credential store (Windows Credential Manager). Git gets the GitHub token only as an environment variable for a single command.
  • Settings files are written read-merge-write: keys the app does not know are kept.
  • Nothing is synced unless you sign in and turn sync on.

For developers

Requirements

  • Node.js 22+ and pnpm 10 (corepack enable)
  • Rust 1.90+ (rustup)
  • Windows: Microsoft C++ Build Tools ("Desktop development with C++") and WebView2 (included in Windows 11)
  • Linux: libwebkit2gtk-4.1-dev libxdo-dev libayatana-appindicator3-dev librsvg2-dev

See 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 .
PathWhat'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

CI (.github/workflows/ci.yml) lints and builds on Linux and builds the Windows installer. Every version merged to main becomes a GitHub Release.

License

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.

Source 3 files
hooks/register.tsx 267 lines
1import 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};
267
hooks/core.ts 247 lines
1// 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}
247
types/index.d.ts 30 lines
1export 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