@xbghc/warden 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,20 +6,24 @@ with line comments you can copy back to the agent as a prompt.
6
6
  - Runs as a single local process per repository (`127.0.0.1` only, no auth, no database).
7
7
  - Diff sources: working tree, staged, working tree vs HEAD, a branch since it forked off its base (commits and
8
8
  uncommitted work together), any commit, any two refs, and git worktrees.
9
- - Side-by-side **Unstaged** and **Staged** file lists, so staging a hunk in your editor is what marks it reviewed.
9
+ - Side-by-side **Unstaged** and **Staged** file lists: staged means reviewed. Stage from the UI by dragging
10
+ over the lines you have read (or a hunk, or a file), and unstage the same way from the Staged view.
10
11
  - GitHub-style unified / side-by-side diff with syntax highlighting, collapsed file tree, lazy per-file loading, context expansion, virtual scrolling.
11
12
  - Line comments (single line or a dragged range), Markdown, edit / delete.
12
13
  - One click copies all comments as an agent-readable prompt to the clipboard.
13
14
  - Review state (viewed files, comments, local issues, todos, preferences) persists outside the repo and survives restarts.
14
15
  - Comments follow the code: they move with a hunk that gets staged, re-attach after the agent edits the file, and are cleaned up once the change is committed.
15
16
  - Auto-refresh — warden watches the repository and reloads itself when you edit, stage, commit or switch branches.
16
- - Local issues (title, Markdown body, open/closed) that link comments.
17
- - Branch-scoped todos, each copied to the clipboard on its own — one task at a time for the agent.
17
+ - Local issues (title, Markdown body, open/closed) that link comments, and branch-scoped todos, each
18
+ copied to the clipboard on its own — one task at a time for the agent. Both are task lists in the
19
+ manner of Google Tasks: add at the top, Enter for the next one, tick to file it under 已完成, drag
20
+ into your own order, edit in place, 撤消 after a delete.
18
21
  - Commit history browser: grouped by day, branch / tag labels, search by message, sha, author or
19
22
  path (each a `git log` on the server, not a filter over the rows already loaded), and a
20
23
  first-parent view that folds merged branches into their merge commits.
21
24
  - Click a line number to jump to that line in a running nvim instance (WSL2 friendly).
22
- - Read-only with respect to git: only whitelisted read sub-commands are ever executed.
25
+ - Git is touched through whitelisted read sub-commands plus exactly one write, `git apply --cached`, which is
26
+ what the stage / unstage controls run. The working tree and HEAD are never written.
23
27
 
24
28
  ## Install / run
25
29
 
@@ -80,7 +84,10 @@ everything since the branch forked off `<ref>` — the commits plus whatever is
80
84
  untracked. The diff runs against the merge base, so commits that landed on `<ref>` after the fork are
81
85
  not listed as reverted (which is what a plain `git diff <ref>` would do). The *Branch vs* field in the
82
86
  Commits panel suggests the base for you — the main worktree's branch when a sibling worktree is under
83
- review, otherwise `main` or `master` — and takes any ref. Unlike the three local views, `base` keeps its own
87
+ review, otherwise `main` or `master` — and takes any ref. Beside it the panel names the fork point (the
88
+ merge base with that ref, `GET /api/fork-point`) with how many commits the branch is ahead of it and how
89
+ many landed on the base since, and the list marks that commit with a *分叉自* chip and a rule above it:
90
+ what sits above is the branch's own work. Unlike the three local views, `base` keeps its own
84
91
  pool of comments and a commit never deletes them: the round under review is not over when the agent
85
92
  commits, so a comment whose lines changed stays *orphaned*, snippet and all, until you have checked
86
93
  the fix and delete or re-attach it.
@@ -92,8 +99,36 @@ the fix and delete or re-attach it.
92
99
  | `r` | Refresh the current target (file list + open diff, then re-anchor comments) |
93
100
  | `j` / `k` | Next / previous file, walking Unstaged then Staged and switching view at the boundary |
94
101
  | `n` / `p` | Next / previous hunk |
102
+ | `s` | Stage (Unstaged view) or unstage (Staged view) the picked lines |
95
103
  | `Ctrl+Enter` | Save the comment being edited |
96
- | `Esc` | Cancel editing / close a panel / cancel re-attach mode |
104
+ | `Esc` | Drop the picked lines / cancel editing / close a panel / cancel re-attach mode |
105
+
106
+ ## Staging
107
+
108
+ The Staged block is the "reviewed" pile, and warden can move lines into it without a trip to the
109
+ terminal. In the Unstaged view every changed line has a small box at the left edge; press it and drag
110
+ to pick a range inside one hunk (in split view a replacement is one row, so its old and new line go
111
+ together). The picked rows are outlined in ink, a bar at the bottom of the diff says how many lines
112
+ are in, and `s` or the bar's button stages them. Each hunk header has *暂存此 hunk*, the file header
113
+ and the sidebar rows have *暂存文件* / *暂存*. The Staged view has the same controls the other way
114
+ round: *取消暂存* takes lines back out of the index.
115
+
116
+ A partial pick is turned into the patch `git add -p`'s edit mode would want — the unpicked deletions
117
+ stay as context and the unpicked additions are left out (the mirror image when unstaging) — and applied
118
+ with `git apply --cached`. It never touches the working tree, so what you did not pick is still
119
+ there to stage next. Comments on the lines you staged follow them into the Staged view (see
120
+ [Re-anchoring](#re-anchoring)); the file's *viewed* flag is dropped because its diff changed. Every
121
+ page open on the worktree reloads at once.
122
+
123
+ Limits, each reported as a plain error rather than a half-applied patch:
124
+
125
+ - Only the Unstaged and Staged views stage; `all`, `base`, commits and ranges are read-only.
126
+ - Binary files and mode-only changes go whole or not at all; a mode change rides along only with *暂存文件*.
127
+ - A staged deletion can be unstaged whole, not by line: the index has nothing left to put lines back into.
128
+ - A pick that would split a file's missing trailing newline from its neighbours is refused; include them.
129
+ - The request carries the hash of the diff the pick was made on. If the file or the index moved since
130
+ (the agent kept editing), the server answers 409 and the page reloads instead of staging the wrong lines.
131
+ - If another git process holds the index lock the server retries briefly, then gives up with 409.
97
132
 
98
133
  ## Comments and export
99
134
 
@@ -170,11 +205,28 @@ refreshes by hand.
170
205
 
171
206
  Notes attached to a branch rather than to a line of code, for the things you notice while reviewing
172
207
  that do not belong in a comment. They are not deleted when you commit. They sit in the right-hand
173
- rail next to the comments, under the *Todo* tab: type a title and press Enter to add one, expand a
174
- card to read or edit its Markdown body, filter by status, and switch between the current branch
175
- and all branches.
176
-
177
- Each card has its own *复制* button, and there is deliberately no "copy all": a todo is one task to
208
+ rail next to the comments, under the *Todo* tab, as a task list that works the way Google Tasks does:
209
+
210
+ - *添加 Todo* opens an empty row at the top; type the title and press Enter, and the next row opens
211
+ right under it. Esc or Backspace on an empty row drops it.
212
+ - Titles and details are edited where they are: click into the text. Enter at the end of a title
213
+ starts the next todo under it; Backspace on an emptied title deletes the todo. The details field
214
+ (Markdown) appears while the row is open, and is rendered under the title otherwise.
215
+ - The circle strikes the todo through and files it under *已完成 (N)* at the bottom, collapsed;
216
+ open it to tick one back or *全部删除*.
217
+ - Rows are dragged into your own order by the handle at their left edge. That order is what the state
218
+ file keeps, and new todos go on top.
219
+ - Delete asks nothing; the toast offers *撤消* for a few seconds.
220
+ - The picker at the top is the list selector: the current branch, any other branch that has todos,
221
+ or all of them with the branch on each row. A new todo goes to the branch being shown.
222
+
223
+ Issues (the *Issues* tab in the top bar) are the same list: closing an issue is ticking it, a
224
+ *已关闭* section holds the closed ones, and the comments linked to an issue sit under its open row
225
+ like subtasks, each with *跳转* and *解除关联*. Tick comments in the rail and the *创建 Issue* button
226
+ opens the drawer with the add row ready and the comments attached on creation; for an existing
227
+ issue, open its row and use *关联选中的评论*.
228
+
229
+ Each row has its own *复制* button, and there is deliberately no "copy all": a todo is one task to
178
230
  hand to an agent, and the agent it goes to is already working in that branch. So the clipboard gets
179
231
  the title and the body — no branch, repo, count or numbering:
180
232
 
@@ -225,17 +277,21 @@ move into the matching scope the first time the file is read.
225
277
 
226
278
  ## Security model
227
279
 
228
- Single user, local only. The server binds to `127.0.0.1`, executes git only through
229
- `execFile('git', [...])` with an argument whitelist (`rev-parse`, `diff`, `show`, `log`, `worktree list`,
230
- `ls-files`, `status`, `merge-base`), refuses option-looking refs and any write-capable flag, and never writes into the
231
- repository. Requests that would need anything else get HTTP 400.
280
+ Single user, local only. The server binds to `127.0.0.1` and executes git only through
281
+ `execFile('git', [...])`. Reads go through an argument whitelist (`rev-parse`, `diff`, `show`, `log`,
282
+ `worktree list`, `ls-files`, `status`, `merge-base`, `rev-list`) that refuses option-looking refs and any write-capable
283
+ flag; requests that would need anything else get HTTP 400. The one write is `POST /api/targets/:key/stage`,
284
+ which runs `git apply --cached` (with `--reverse` for the Staged view) on a patch the server itself builds
285
+ from the diff it just produced — the patch is never taken from the request, only the line indices are,
286
+ and they are checked against the diff's hash first. Nothing writes to the working tree, HEAD or the
287
+ refs, and the review state lives outside the repository.
232
288
 
233
289
  ## Development
234
290
 
235
291
  ```sh
236
292
  pnpm install
237
293
  pnpm dev # API server on :4100 (tsx watch) + Vite dev server on :5173 with /api proxied
238
- pnpm test # vitest: diff parser, anchoring, comment scopes, watcher, todos, export, state, HTTP API
294
+ pnpm test # vitest: diff parser, staging patches, anchoring, comment scopes, watcher, todos, export, state, HTTP API
239
295
  pnpm typecheck
240
296
  pnpm build # dist/web (Vite) + dist/cli.js (tsup, zero runtime dependencies)
241
297
  node dist/cli.js path/to/repo
@@ -246,7 +302,7 @@ Layout:
246
302
  ```
247
303
  bin/cli.ts argument parsing, start server, open browser
248
304
  packages/shared types + target key / comment scope helpers (bundled into both sides)
249
- packages/server Hono API, git wrapper, diff parser, targets, anchoring, watcher, state, export, nvim
305
+ packages/server Hono API, git wrapper, diff parser, staging patches, targets, anchoring, watcher, state, export, nvim
250
306
  packages/web React + Vite UI (shiki highlighting, @tanstack/react-virtual, react-markdown)
251
307
  test/ API integration tests on a generated git repository
252
308
  ```
package/dist/cli.js CHANGED
@@ -2960,6 +2960,11 @@ function commentScopeKey(key) {
2960
2960
  if (!t || !isLocalTarget(t)) return key;
2961
2961
  return t.worktree ? `worktree:${t.worktree}:local` : "local";
2962
2962
  }
2963
+ function stageModeFor(t) {
2964
+ if (t.kind === "working") return "stage";
2965
+ if (t.kind === "staged") return "unstage";
2966
+ return void 0;
2967
+ }
2963
2968
  function localViewKeys(key) {
2964
2969
  const t = tryParseTargetKey(key);
2965
2970
  const wt = t?.worktree ? { worktree: t.worktree } : {};
@@ -2970,6 +2975,24 @@ function localViewKeys(key) {
2970
2975
  ];
2971
2976
  }
2972
2977
 
2978
+ // packages/shared/src/order.ts
2979
+ function insertAfter(list, item, after) {
2980
+ const idx = after === void 0 ? -1 : list.findIndex((x) => x.id === after);
2981
+ if (idx < 0) return [item, ...list];
2982
+ return [...list.slice(0, idx + 1), item, ...list.slice(idx + 1)];
2983
+ }
2984
+ function moveBefore(list, id, before) {
2985
+ const item = list.find((x) => x.id === id);
2986
+ if (!item) return null;
2987
+ if (before === id) return [...list];
2988
+ const rest = list.filter((x) => x.id !== id);
2989
+ if (before === null) return [...rest, item];
2990
+ const idx = rest.findIndex((x) => x.id === before);
2991
+ if (idx < 0) return null;
2992
+ rest.splice(idx, 0, item);
2993
+ return rest;
2994
+ }
2995
+
2973
2996
  // packages/server/src/errors.ts
2974
2997
  var HttpError = class extends Error {
2975
2998
  constructor(status, message, code) {
@@ -3026,7 +3049,7 @@ function parseCommitLog(stdout) {
3026
3049
  // packages/server/src/git.ts
3027
3050
  import { execFile } from "child_process";
3028
3051
  import { existsSync } from "fs";
3029
- var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base"]);
3052
+ var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base", "rev-list"]);
3030
3053
  var FORBIDDEN_OPTION_PREFIXES = ["--output", "--ext-diff", "--textconv", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree"];
3031
3054
  var DEFAULT_GIT_TIMEOUT_MS = 3e4;
3032
3055
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
@@ -3109,6 +3132,64 @@ async function runGit(args, opts) {
3109
3132
  );
3110
3133
  });
3111
3134
  }
3135
+ async function applyToIndex(cwd, patch, opts = {}) {
3136
+ for (let attempt = 1; ; attempt++) {
3137
+ try {
3138
+ await applyOnce(cwd, patch, opts);
3139
+ return;
3140
+ } catch (e) {
3141
+ if (!(e instanceof GitError && e.code === "index_locked") || attempt >= 3) throw e;
3142
+ await new Promise((r) => setTimeout(r, 150 * attempt));
3143
+ }
3144
+ }
3145
+ }
3146
+ async function applyOnce(cwd, patch, opts) {
3147
+ const args = ["apply", "--cached", "--whitespace=nowarn"];
3148
+ if (opts.reverse) args.push("--reverse");
3149
+ args.push("-");
3150
+ await new Promise((resolve, reject) => {
3151
+ const child = execFile(
3152
+ "git",
3153
+ args,
3154
+ {
3155
+ cwd,
3156
+ timeout: opts.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS,
3157
+ maxBuffer: 16 * 1024 * 1024,
3158
+ encoding: "utf8",
3159
+ env: { ...process.env, LC_ALL: "C", GIT_PAGER: "cat", PAGER: "cat", GIT_TERMINAL_PROMPT: "0" },
3160
+ windowsHide: true
3161
+ },
3162
+ (error, _stdout, stderr) => {
3163
+ const err = error;
3164
+ if (!err) {
3165
+ resolve();
3166
+ return;
3167
+ }
3168
+ if (err.killed || err.signal === "SIGTERM") {
3169
+ reject(new GitError("git apply timed out", null, stderr, 504, "git_timeout"));
3170
+ return;
3171
+ }
3172
+ if (err.code === "ENOENT") {
3173
+ if (!existsSync(cwd)) {
3174
+ reject(new GitError(`working directory no longer exists: ${cwd}`, null, "", 400, "unknown_worktree"));
3175
+ return;
3176
+ }
3177
+ reject(new GitError("git executable not found in PATH", null, "", 500, "git_missing"));
3178
+ return;
3179
+ }
3180
+ const code = typeof err.code === "number" ? err.code : -1;
3181
+ const first = stderr.trim().split("\n")[0] || err.message;
3182
+ if (/index\.lock/.test(stderr)) {
3183
+ reject(new GitError("the index is locked by another git process; try again", code, stderr, 409, "index_locked"));
3184
+ return;
3185
+ }
3186
+ reject(new GitError(first.replace(/^error: /, ""), code, stderr, 409, "apply_failed"));
3187
+ }
3188
+ );
3189
+ child.stdin?.on("error", () => void 0);
3190
+ child.stdin?.end(patch);
3191
+ });
3192
+ }
3112
3193
  async function refExists(cwd, ref) {
3113
3194
  try {
3114
3195
  await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { cwd });
@@ -3730,6 +3811,122 @@ function reanchorComment(comment, diff, now = (/* @__PURE__ */ new Date()).toISO
3730
3811
  };
3731
3812
  }
3732
3813
 
3814
+ // packages/server/src/patch.ts
3815
+ function quotePath(p) {
3816
+ if (!/[\s"\\\x00-\x1f\x7f]|[^\x00-\x7f]/.test(p)) return p;
3817
+ let out = '"';
3818
+ for (const byte of Buffer.from(p, "utf8")) {
3819
+ if (byte === 34) out += '\\"';
3820
+ else if (byte === 92) out += "\\\\";
3821
+ else if (byte === 10) out += "\\n";
3822
+ else if (byte === 9) out += "\\t";
3823
+ else if (byte < 32 || byte >= 127) out += "\\" + byte.toString(8).padStart(3, "0");
3824
+ else out += String.fromCharCode(byte);
3825
+ }
3826
+ return out + '"';
3827
+ }
3828
+ function cutHunk(hunk, picked, mode) {
3829
+ const out = [];
3830
+ hunk.lines.forEach((l, i) => {
3831
+ const noNewline = !!l.noNewline;
3832
+ if (l.type === "context") out.push({ ch: " ", content: l.content, noNewline });
3833
+ else if (picked.has(i)) out.push({ ch: l.type === "add" ? "+" : "-", content: l.content, noNewline });
3834
+ else if (l.type === "del" === (mode === "stage")) out.push({ ch: " ", content: l.content, noNewline });
3835
+ });
3836
+ out.forEach((l, j) => {
3837
+ if (!l.noNewline) return;
3838
+ const rest = out.slice(j + 1);
3839
+ const ok = l.ch === " " ? rest.length === 0 : l.ch === "-" ? rest.every((r) => r.ch === "+") : rest.every((r) => r.ch === "-");
3840
+ if (!ok) throw badRequest("the selection splits the missing newline at the end of the file; include the neighbouring changed lines", "newline_split");
3841
+ });
3842
+ return out;
3843
+ }
3844
+ function pickedLines(diff, selection) {
3845
+ const picked = /* @__PURE__ */ new Map();
3846
+ const changed = (h) => h.lines.map((l, i) => l.type === "context" ? -1 : i).filter((i) => i >= 0);
3847
+ if (selection === void 0) {
3848
+ diff.hunks.forEach((h, i) => picked.set(i, new Set(changed(h))));
3849
+ return picked;
3850
+ }
3851
+ if (!Array.isArray(selection)) throw badRequest("hunks must be an array", "bad_selection");
3852
+ for (const sel of selection) {
3853
+ if (!sel || !Number.isInteger(sel.index) || sel.index < 0 || sel.index >= diff.hunks.length) throw badRequest(`no such hunk: ${sel?.index}`, "bad_selection");
3854
+ const hunk = diff.hunks[sel.index];
3855
+ const set = picked.get(sel.index) ?? /* @__PURE__ */ new Set();
3856
+ picked.set(sel.index, set);
3857
+ if (sel.lines === void 0) {
3858
+ for (const i of changed(hunk)) set.add(i);
3859
+ continue;
3860
+ }
3861
+ if (!Array.isArray(sel.lines)) throw badRequest("lines must be an array of indices", "bad_selection");
3862
+ for (const i of sel.lines) {
3863
+ if (!Number.isInteger(i) || i < 0 || i >= hunk.lines.length) throw badRequest(`no such line in hunk ${sel.index}: ${i}`, "bad_selection");
3864
+ if (hunk.lines[i].type !== "context") set.add(i);
3865
+ }
3866
+ }
3867
+ return picked;
3868
+ }
3869
+ function buildStagePatch(diff, mode, selection) {
3870
+ if (diff.binary) throw badRequest("binary files cannot be staged from here; use git add", "not_stageable");
3871
+ const whole = selection === void 0;
3872
+ const picked = pickedLines(diff, selection);
3873
+ let lines = 0;
3874
+ for (const set of picked.values()) lines += set.size;
3875
+ const changedTotal = diff.hunks.reduce((n, h) => n + h.lines.filter((l) => l.type !== "context").length, 0);
3876
+ const full = lines === changedTotal;
3877
+ const modeChange = !!(diff.oldMode && diff.newMode && diff.oldMode !== diff.newMode);
3878
+ const rename2 = diff.status === "renamed" && !!diff.oldPath && diff.oldPath !== diff.path;
3879
+ const oldPath = diff.oldPath ?? diff.path;
3880
+ const newPath = diff.path;
3881
+ const gitLine = (a, b) => `diff --git ${quotePath(`a/${a}`)} ${quotePath(`b/${b}`)}`;
3882
+ const from = (p) => p === null ? "--- /dev/null" : `--- ${quotePath(`a/${p}`)}`;
3883
+ const to = (p) => p === null ? "+++ /dev/null" : `+++ ${quotePath(`b/${p}`)}`;
3884
+ const header = [];
3885
+ if (diff.status === "added" && (mode === "stage" || full)) {
3886
+ header.push(gitLine(newPath, newPath), `new file mode ${diff.newMode ?? "100644"}`);
3887
+ if (lines > 0) header.push(from(null), to(newPath));
3888
+ } else if (diff.status === "deleted" && full) {
3889
+ header.push(gitLine(oldPath, oldPath), `deleted file mode ${diff.oldMode ?? "100644"}`);
3890
+ if (lines > 0) header.push(from(oldPath), to(null));
3891
+ } else if (diff.status === "deleted" && mode === "unstage") {
3892
+ throw badRequest("a staged deletion can only be unstaged as a whole", "whole_file_only");
3893
+ } else if (rename2 && full) {
3894
+ header.push(gitLine(oldPath, newPath));
3895
+ if (whole && modeChange) header.push(`old mode ${diff.oldMode}`, `new mode ${diff.newMode}`);
3896
+ header.push(`rename from ${quotePath(oldPath)}`, `rename to ${quotePath(newPath)}`);
3897
+ if (lines > 0) header.push(from(oldPath), to(newPath));
3898
+ } else {
3899
+ header.push(gitLine(newPath, newPath));
3900
+ if (whole && modeChange) header.push(`old mode ${diff.oldMode}`, `new mode ${diff.newMode}`);
3901
+ if (lines > 0) header.push(from(newPath), to(newPath));
3902
+ }
3903
+ if (lines === 0) {
3904
+ const headerOnly = whole && (modeChange || rename2 || (diff.status === "added" || diff.status === "deleted") && diff.hunks.length === 0);
3905
+ if (!headerOnly) throw badRequest("no changed lines selected", "empty_selection");
3906
+ return { patch: header.join("\n") + "\n", lines: 0 };
3907
+ }
3908
+ let delta = 0;
3909
+ const hunks = [];
3910
+ diff.hunks.forEach((hunk, index) => {
3911
+ const set = picked.get(index);
3912
+ if (!set || set.size === 0) return;
3913
+ const out = cutHunk(hunk, set, mode);
3914
+ const oldLines = out.filter((l) => l.ch !== "+").length;
3915
+ const newLines = out.filter((l) => l.ch !== "-").length;
3916
+ const oldBegin = hunk.oldLines === 0 ? hunk.oldStart + 1 : hunk.oldStart;
3917
+ const oldStart = oldLines === 0 ? oldBegin - 1 : oldBegin;
3918
+ const newStart = newLines === 0 ? oldBegin + delta - 1 : oldBegin + delta;
3919
+ delta += newLines - oldLines;
3920
+ const text = [`@@ -${oldStart},${oldLines} +${newStart},${newLines} @@${hunk.header ? " " + hunk.header : ""}`];
3921
+ for (const l of out) {
3922
+ text.push(l.ch + l.content);
3923
+ if (l.noNewline) text.push("\");
3924
+ }
3925
+ hunks.push(text.join("\n"));
3926
+ });
3927
+ return { patch: [...header, ...hunks].join("\n") + "\n", lines };
3928
+ }
3929
+
3733
3930
  // packages/server/src/state.ts
3734
3931
  import os from "os";
3735
3932
  import path3 from "path";
@@ -4407,6 +4604,24 @@ function createApp(opts) {
4407
4604
  changedNotices.get(key)?.delete(body.path);
4408
4605
  return c.json({ viewed });
4409
4606
  });
4607
+ api.post("/targets/:key/stage", async (c) => {
4608
+ const key = c.req.param("key");
4609
+ const ctx = await targetCtx(key);
4610
+ const mode = stageModeFor(ctx.target);
4611
+ if (!mode) throw badRequest("only the working (stage) and staged (unstage) views can be staged from", "not_stageable");
4612
+ const body = await c.req.json();
4613
+ if (!body.path || typeof body.path !== "string") throw badRequest("missing path");
4614
+ if (!body.contentHash || typeof body.contentHash !== "string") throw badRequest("contentHash is required");
4615
+ if (body.hunks !== void 0 && !Array.isArray(body.hunks)) throw badRequest("hunks must be an array", "bad_selection");
4616
+ const diff = await fileDiffWithHints(ctx, body.path);
4617
+ if (!diff) throw badRequest(`file ${body.path} is not part of ${key}`, "no_diff");
4618
+ if (diff.contentHash !== body.contentHash) throw new HttpError(409, "the diff changed since it was loaded; refresh and pick the lines again", "diff_changed");
4619
+ const { patch, lines } = buildStagePatch(diff, mode, body.hunks);
4620
+ await applyToIndex(ctx.cwd, patch, { reverse: mode === "unstage" });
4621
+ void watchers.get(ctx.cwd)?.poll();
4622
+ const res = { ok: true, lines };
4623
+ return c.json(res);
4624
+ });
4410
4625
  api.get("/targets/:key/comments", async (c) => {
4411
4626
  const key = c.req.param("key");
4412
4627
  const state = await store.load();
@@ -4629,16 +4844,28 @@ function createApp(opts) {
4629
4844
  id: randomUUID(),
4630
4845
  title: body.title.trim(),
4631
4846
  body: typeof body.body === "string" ? body.body : "",
4632
- status: "open",
4847
+ status: body.status === "closed" ? "closed" : "open",
4633
4848
  commentIds: Array.isArray(body.commentIds) ? body.commentIds.filter((x) => typeof x === "string") : [],
4634
4849
  createdAt: now,
4635
4850
  updatedAt: now
4636
4851
  };
4637
4852
  await store.update((s) => {
4638
- s.issues.push(issue);
4853
+ s.issues = insertAfter(s.issues, issue, typeof body.after === "string" ? body.after : void 0);
4639
4854
  });
4640
4855
  return c.json(issue, 201);
4641
4856
  });
4857
+ api.post("/issues/:id/move", async (c) => {
4858
+ const id = c.req.param("id");
4859
+ const body = await c.req.json();
4860
+ if (body.before !== null && typeof body.before !== "string") throw badRequest("before must be an id or null");
4861
+ const issues = await store.update((s) => {
4862
+ const next = moveBefore(s.issues, id, body.before);
4863
+ if (!next) throw notFound("issue not found");
4864
+ s.issues = next;
4865
+ return s.issues;
4866
+ });
4867
+ return c.json({ issues });
4868
+ });
4642
4869
  api.patch("/issues/:id", async (c) => {
4643
4870
  const id = c.req.param("id");
4644
4871
  const body = await c.req.json();
@@ -4712,15 +4939,28 @@ function createApp(opts) {
4712
4939
  branch,
4713
4940
  title: body.title.trim(),
4714
4941
  body: typeof body.body === "string" ? body.body : "",
4715
- status: "open",
4942
+ status: body.status === "done" ? "done" : "open",
4716
4943
  createdAt: now,
4717
4944
  updatedAt: now
4718
4945
  };
4719
4946
  await store.update((s) => {
4720
- s.todos.push(todo);
4947
+ s.todos = insertAfter(s.todos, todo, typeof body.after === "string" ? body.after : void 0);
4721
4948
  });
4722
4949
  return c.json(todo, 201);
4723
4950
  });
4951
+ api.post("/todos/:id/move", async (c) => {
4952
+ const id = c.req.param("id");
4953
+ const body = await c.req.json();
4954
+ if (body.before !== null && typeof body.before !== "string") throw badRequest("before must be an id or null");
4955
+ const todos = await store.update((s) => {
4956
+ const next = moveBefore(s.todos, id, body.before);
4957
+ if (!next) throw notFound("todo not found");
4958
+ s.todos = next;
4959
+ return s.todos;
4960
+ });
4961
+ const res = { todos };
4962
+ return c.json(res);
4963
+ });
4724
4964
  api.patch("/todos/:id", async (c) => {
4725
4965
  const id = c.req.param("id");
4726
4966
  const body = await c.req.json();
@@ -4792,6 +5032,17 @@ function createApp(opts) {
4792
5032
  const res = { commits, hasMore };
4793
5033
  return c.json(res);
4794
5034
  });
5035
+ api.get("/fork-point", async (c) => {
5036
+ const base = (c.req.query("base") ?? "").trim();
5037
+ if (!isValidRef(base)) throw badRequest("invalid base ref");
5038
+ const cwd = await knownRoot(c.req.query("root") || void 0);
5039
+ if (!await refExists(cwd, base)) throw badRequest(`unknown ref: ${base}`, "unknown_ref");
5040
+ const sha = await refExists(cwd, "HEAD") ? await mergeBase(cwd, base, "HEAD") : void 0;
5041
+ if (!sha) throw badRequest(`${base} and HEAD share no history`, "no_merge_base");
5042
+ const counts = (await runGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], { cwd })).stdout.trim().split(/\s+/);
5043
+ const res = { base, sha, ahead: Number(counts[1] ?? 0), behind: Number(counts[0] ?? 0) };
5044
+ return c.json(res);
5045
+ });
4795
5046
  api.get("/events", async (c) => {
4796
5047
  const root = c.req.query("root") || repo.root;
4797
5048
  const known = root === repo.root || root === repo.commonRoot || (await worktrees()).some((w) => w.path === root);
@@ -4969,7 +5220,7 @@ async function startServer(opts) {
4969
5220
  }
4970
5221
 
4971
5222
  // bin/cli.ts
4972
- var VERSION = true ? "0.6.1" : "dev";
5223
+ var VERSION = true ? "0.7.0" : "dev";
4973
5224
  function parseArgs(argv) {
4974
5225
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
4975
5226
  for (let i = 0; i < argv.length; i++) {
@@ -0,0 +1 @@
1
+ @font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2) format("woff2"),url(/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff) format("woff")}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:500;src:url(/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2) format("woff2"),url(/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff) format("woff")}@font-face{font-family:IBM Plex Sans;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2) format("woff2"),url(/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff) format("woff")}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff) format("woff")}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:500;src:url(/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff) format("woff")}:root{--ground: #eef0f3;--surface: #ffffff;--surface-2: #f5f6f8;--surface-3: #e4e7ec;--line: #d9dde3;--line-strong: #b7bdc7;--ink: #16181d;--ink-2: #454b58;--muted: #626977;--accent: #5b47d9;--accent-strong: #4a38bf;--accent-soft: #ebe8fb;--accent-line: #c4bbf2;--add-bg: #e3f5e7;--add-bg-strong: #bfe9c9;--add-fg: #1e7a3a;--del-bg: #fce8e8;--del-bg-strong: #f5c2c2;--del-fg: #c42b2b;--warn-bg: #f6e7b8;--warn-soft: #fdf8e6;--warn-fg: #8a6100;--warn-line: #d9b64a;--r-ctl: 3px;--r-card: 6px;--ctl-h: 26px;--sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Noto Sans Mono CJK SC", monospace}*{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{font:13px/1.5 var(--sans);color:var(--ink);background:var(--ground);-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}button{height:var(--ctl-h);display:inline-flex;align-items:center;gap:6px;background:var(--surface);border:1px solid var(--line);border-radius:var(--r-ctl);padding:0 10px;cursor:pointer;white-space:nowrap}button:hover:not(:disabled){border-color:var(--line-strong);background:var(--surface-2)}button:disabled{opacity:.45;cursor:default}button.primary{background:var(--ink);border-color:var(--ink);color:#fff}button.primary:hover:not(:disabled){background:#2b2f38;border-color:#2b2f38}button.accent{background:var(--accent);border-color:var(--accent);color:#fff}button.accent:hover:not(:disabled){background:var(--accent-strong);border-color:var(--accent-strong)}button.link{height:auto;background:none;border:none;color:var(--ink-2);padding:0 4px}button.link:hover:not(:disabled){background:none;color:var(--ink);text-decoration:underline;text-underline-offset:2px}button.link.danger{color:var(--del-fg)}button.link.active{color:var(--accent);font-weight:500}button.icon{padding:0 7px}button.toggle:before{content:"";width:6px;height:6px;border-radius:50%;border:1.5px solid currentColor}button.toggle[aria-pressed=true]{border-color:var(--ink)}button.toggle[aria-pressed=true]:before{background:currentColor}input,select,textarea{height:var(--ctl-h);border:1px solid var(--line);border-radius:var(--r-ctl);padding:0 8px;background:var(--surface)}select{padding-right:4px}textarea{height:auto;width:100%;resize:vertical;font-family:var(--mono);font-size:12px;line-height:1.6;padding:6px 8px}input[type=checkbox]{height:auto;margin:0;accent-color:var(--ink)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}button:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--accent);outline-offset:1px}kbd{font-family:var(--mono);font-size:11px;background:var(--surface);border:1px solid var(--line);border-bottom-width:2px;border-radius:3px;padding:0 5px;color:var(--ink-2)}.mono{font-family:var(--mono);font-size:12px}.muted{color:var(--muted)}.small{font-size:11px}.spacer{flex:1}.add{color:var(--add-fg)}.del{color:var(--del-fg)}.check{display:inline-flex;align-items:center;gap:5px;white-space:nowrap;cursor:pointer}.badge{display:inline-flex;align-items:center;height:17px;padding:0 6px;border-radius:var(--r-ctl);font-family:var(--mono);font-size:11px;background:var(--surface-3);color:var(--ink-2);white-space:nowrap}.badge-active{background:var(--accent-soft);color:var(--accent)}.badge-exported{background:var(--surface-3);color:var(--muted)}.badge-orphaned{background:var(--warn-bg);color:var(--warn-fg)}.badge-open{background:var(--ink);color:#fff}.badge-closed{background:var(--surface-3);color:var(--muted)}.seg{display:inline-flex;gap:1px;padding:2px;background:var(--surface-3);border-radius:4px}.seg button{height:22px;padding:0 9px;background:transparent;border:1px solid transparent;border-radius:var(--r-ctl);color:var(--ink-2)}.seg button:hover:not(:disabled){background:transparent;border-color:transparent;color:var(--ink)}.seg button.active{background:var(--surface);border-color:var(--line);color:var(--ink);font-weight:500}.seg.small button{height:20px;padding:0 7px;font-size:12px}.seg button.active .tab-count{color:var(--ink)}.tabs{display:inline-flex;align-self:stretch;align-items:stretch;gap:2px}.tab{height:auto;background:none;border:none;border-radius:0;padding:0 6px;color:var(--ink-2);position:relative}.tab:hover:not(:disabled){background:none;color:var(--ink)}.tab.active{color:var(--ink);font-weight:500}.tab.active:after{content:"";position:absolute;left:6px;right:6px;bottom:-1px;height:2px;background:var(--ink)}.tab-count{font-family:var(--mono);font-size:11px;color:var(--muted)}.tab.active .tab-count{color:var(--ink)}.tabs.small .tab{padding:0 5px;font-size:12px}.error-box{color:var(--del-fg);background:var(--del-bg);padding:8px 12px;border-radius:var(--r-card);margin:8px}.empty{padding:20px 12px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6}.app{display:flex;flex-direction:column;height:100%}.body{flex:1;display:flex;min-height:0}.main{flex:1;min-width:0;display:flex;flex-direction:column;background:var(--surface)}.loading-screen,.fatal{padding:48px;text-align:center;color:var(--muted)}.fatal h1{font-family:var(--mono);font-weight:500;font-size:20px;letter-spacing:-.02em;color:var(--ink)}.placeholder{padding:48px 24px;text-align:center;color:var(--muted)}.placeholder p{margin:6px 0}.topbar{display:flex;align-items:center;gap:14px;padding:0 12px;min-height:42px;background:var(--ground);border-bottom:1px solid var(--line);flex-wrap:wrap}.brand{display:flex;align-items:center;gap:7px}.mark{width:16px;height:16px;flex:none}.wordmark{font-family:var(--mono);font-weight:500;font-size:15px;letter-spacing:-.02em;color:var(--ink)}.scope{display:flex;align-items:center;gap:8px}.repo-name{font-weight:500}.branch{font-family:var(--mono);font-size:11px;color:var(--ink-2);background:var(--surface-3);border-radius:var(--r-ctl);padding:1px 6px}.inline-form{display:inline-flex;align-items:center;gap:4px}.inline-form input{width:130px;font-family:var(--mono);font-size:12px}.topbar-right{margin-left:auto;align-self:stretch;display:flex;align-items:stretch;gap:14px}.tools{display:flex;align-items:center;gap:8px}.nvim{display:inline-flex;align-items:center;gap:4px;max-width:320px}.nvim select{max-width:260px}.nvim-one{font-family:var(--mono);font-size:12px;color:var(--ink-2)}.nvim .muted{font-size:12px}.sidebar{width:280px;min-width:200px;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--ground);resize:horizontal;overflow:hidden}.tree-block{display:flex;flex-direction:column;flex:0 1 auto;min-height:66px;min-width:0}.tree-block+.tree-block{border-top:1px solid var(--line)}.sidebar-target{flex:none;display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--line);white-space:nowrap}.sidebar-target-name{font-family:var(--mono);font-size:12px;color:var(--ink-2);min-width:0;overflow:hidden;text-overflow:ellipsis}.sidebar-target .link{font-size:12px}.block-head{display:flex;align-items:center;gap:8px;padding:8px 10px 4px;font-size:12px;white-space:nowrap}.block-title{font-weight:600}.block-n{font-family:var(--mono);font-size:11px;color:var(--muted)}.block-head .counts{font-family:var(--mono);font-size:11px;display:inline-flex;gap:4px}.block-viewed{font-family:var(--mono);font-size:11px;color:var(--muted)}.block-progress{height:2px;margin:0 10px 4px;background:var(--line);border-radius:1px;overflow:hidden}.block-progress>span{display:block;height:100%;background:var(--ink);transition:width .2s}.tree-tools{display:inline-flex;gap:2px}.tree-tools .link{font-size:11px;color:var(--muted)}.tree{flex:1 1 auto;min-height:0;overflow:auto;padding:2px 0 6px}.tree-row{display:flex;align-items:center;gap:6px;padding:0 10px;height:24px;cursor:pointer;white-space:nowrap;-webkit-user-select:none;user-select:none}.tree-row:hover{background:var(--surface-3)}.tree-row.active{background:var(--surface);box-shadow:inset 2px 0 var(--ink)}.tree-row.viewed .name{color:var(--muted)}.tree-row .name{overflow:hidden;text-overflow:ellipsis}.tree-row.dir .name{font-weight:500;color:var(--ink-2)}.chev{display:inline-block;width:12px;font-size:10px;color:var(--muted);transition:transform .1s}.chev.open{transform:rotate(90deg)}.tree-row .counts{margin-left:auto;font-family:var(--mono);font-size:11px;display:inline-flex;gap:4px}.tree-row .count{font-family:var(--mono);font-size:11px}.viewed-box{margin:0}.status{font-family:var(--mono);font-size:11px;font-weight:500;width:14px;text-align:center}.status-added{color:var(--add-fg)}.status-modified{color:var(--warn-fg)}.status-deleted{color:var(--del-fg)}.status-renamed{color:var(--ink-2)}.changed{font-size:11px;background:var(--warn-bg);color:var(--warn-fg);border-radius:var(--r-ctl);padding:0 5px}.cc{font-family:var(--mono);font-size:11px;background:var(--accent-soft);color:var(--accent);border-radius:var(--r-ctl);padding:0 5px}.diff-panel{flex:1;display:flex;flex-direction:column;min-height:0;position:relative}.file-head{display:flex;align-items:center;gap:10px;padding:0 12px;min-height:38px;border-bottom:1px solid var(--line);background:var(--surface);flex-wrap:wrap}.file-head .path{font-family:var(--mono);font-size:12px}.file-head .path .dir{color:var(--muted)}.file-head .path .base{font-weight:500}.file-head .counts{font-family:var(--mono);font-size:11px;display:inline-flex;gap:4px}.file-head .check{font-size:12px}.diff-scroll{flex:1;overflow:auto;font-family:var(--mono);font-size:12px;line-height:20px;tab-size:4;background:var(--surface)}.diff-scroll.selecting,.diff-scroll.picking{-webkit-user-select:none;user-select:none;cursor:row-resize}.diff-scroll.reattaching .add-btn{background:var(--warn-fg)}.vrow{will-change:transform}.row{display:flex;min-height:20px}.row.line,.cell{position:relative}.pin{position:absolute;right:0;top:0;bottom:0;width:3px;background:var(--accent)}.pin.muted{background:var(--line-strong)}.pill{position:absolute;right:10px;top:2px;height:16px;min-width:16px;padding:0 5px;justify-content:center;border:none;border-radius:8px;background:var(--accent);color:#fff;font-family:var(--sans);font-size:11px;line-height:16px}.pill:hover:not(:disabled){background:var(--accent-strong);border-color:transparent}.pill.muted{background:var(--line-strong)}.row.hunk-head{align-items:center;background:var(--surface-2);color:var(--muted);font-size:11.5px;padding:3px 12px 3px 128px;border-top:1px solid var(--line);border-bottom:1px solid var(--line);white-space:pre-wrap}.hunk-text{flex:1;min-width:0}.hunk-stage{font-family:var(--sans);font-size:11px;color:var(--muted);white-space:nowrap}.stagec{flex:0 0 18px;position:relative;-webkit-user-select:none;user-select:none}.stage-box{position:absolute;left:4px;top:4px;width:12px;height:12px;padding:0;border:1.5px solid var(--ink-2);border-radius:2px;background:var(--surface);opacity:0;cursor:pointer;z-index:1}.stage-box:hover:not(:disabled){background:var(--surface-3);border-color:var(--ink)}.row:hover .stage-box,.cell:hover .stage-box,.stage-box.on{opacity:1}.stage-box.on,.stage-box.on:hover:not(:disabled){background:var(--ink);border-color:var(--ink)}.stage-box.on:after{content:"";position:absolute;left:3px;top:0;width:3px;height:6px;border:solid #fff;border-width:0 1.5px 1.5px 0;transform:rotate(45deg)}.row.line.picked,.cell.picked{box-shadow:inset 3px 0 var(--ink)}.row.line.add.picked,.cell.add.picked{background:var(--add-bg-strong)}.row.line.del.picked,.cell.del.picked{background:var(--del-bg-strong)}.stage-bar{position:absolute;left:50%;bottom:16px;transform:translate(-50%);display:flex;align-items:center;gap:8px;padding:6px 8px 6px 12px;background:var(--surface);border:1px solid var(--line-strong);border-radius:var(--r-card);box-shadow:0 6px 20px #16181d24;font-size:12px;white-space:nowrap;z-index:3}.stage-bar-count{margin-right:4px}.stage-bar kbd{margin-left:2px}.stage-bar .primary kbd{background:#ffffff29;border-color:#ffffff4d;color:#fff}.tree-row .stage-file{display:none;font-size:11px;padding:0 2px}.tree-row:hover .stage-file{display:inline-flex}.row.gap-row{background:var(--surface-2);justify-content:center;gap:16px;padding:2px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);font-family:var(--sans);font-size:12px}.gap-row .link{color:var(--muted)}.row.attach{background:var(--surface-2);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:6px 12px 6px 60px;font-family:var(--sans);font-size:13px;display:block}.gut{flex:0 0 48px;text-align:right;padding-right:6px;font-size:11px;color:var(--muted);cursor:pointer;-webkit-user-select:none;user-select:none}.gut:hover{color:var(--ink)}.addc{flex:0 0 18px;position:relative}.add-btn{position:absolute;left:0;top:2px;width:16px;height:16px;padding:0;justify-content:center;line-height:16px;border-radius:var(--r-ctl);background:var(--accent);color:#fff;border:none;font-weight:600;font-size:13px;opacity:0;cursor:pointer;z-index:1}.add-btn:hover:not(:disabled){background:var(--accent-strong);border-color:transparent}.row:hover .add-btn,.cell:hover .add-btn{opacity:1}.add-btn.static{position:static;display:inline-flex;opacity:1;width:16px;height:16px;font-size:11px;vertical-align:-3px}.marker{flex:0 0 14px;text-align:center;-webkit-user-select:none;user-select:none;color:var(--muted)}.code{flex:1;min-width:0;white-space:pre-wrap;word-break:break-all;padding-right:40px}.nonl{color:var(--del-fg);margin-left:6px}.row.line.add,.cell.add{background:var(--add-bg)}.row.line.del,.cell.del{background:var(--del-bg)}.row.line.add .marker{color:var(--add-fg)}.row.line.del .marker{color:var(--del-fg)}.row.expanded,.row.pair.expanded .cell{background:var(--surface);color:var(--muted)}.row.line.selected,.cell.selected{background:var(--accent-soft)!important}.row.line.focused,.cell.focused{background:var(--accent-soft)!important;box-shadow:inset 3px 0 var(--accent)}.row.line.flash,.cell.flash{animation:flash 1.6s ease-out}@keyframes flash{0%{background:var(--accent-line)}to{background:transparent}}.row.pair{display:grid;grid-template-columns:1fr 1fr}.cell{display:flex;min-width:0;border-left:1px solid var(--line)}.cell:first-child{border-left:none}.cell.empty{background:var(--surface-2)}.diff-list.split .row.hunk-head{padding-left:78px}.comment-card{position:relative;background:var(--surface);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:var(--r-card);padding:7px 10px 8px}.comment-card.status-exported{border-left-color:var(--line-strong)}.comment-card.status-orphaned{border-color:var(--warn-line);border-left-style:dashed;background:var(--warn-soft)}.comment-card.selected{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.comment-head{display:flex;align-items:center;gap:8px;min-height:20px;font-size:12px;color:var(--ink-2);flex-wrap:wrap}.comment-head .mono{font-size:11px}.comment-head .time{font-size:11px;color:var(--muted)}.card-actions{position:absolute;right:6px;top:5px;display:inline-flex;align-items:center;gap:2px;padding-left:8px;border-radius:var(--r-ctl);background:inherit;opacity:0;transition:opacity .1s}.card-actions .link{font-size:12px}.comment-card:hover .card-actions,.comment-card:focus-within .card-actions,.comment-card.reattaching .card-actions{opacity:1}.comment-editor{background:var(--surface);border:1px solid var(--accent);border-left-width:3px;border-radius:var(--r-card);padding:8px 10px;max-width:900px}.comment-editor-head{font-family:var(--mono);font-size:11px;color:var(--muted);margin-bottom:6px}.comment-editor textarea{border-color:var(--line)}.comment-editor-actions{display:flex;gap:6px;margin-top:8px}.comment-editor .primary{background:var(--accent);border-color:var(--accent)}.comment-editor .primary:hover:not(:disabled){background:var(--accent-strong);border-color:var(--accent-strong)}.snippet{font-family:var(--mono);font-size:11.5px;line-height:18px;background:var(--surface-2);border-radius:var(--r-ctl);padding:4px 8px;margin:6px 0;overflow-x:auto;white-space:pre}.snippet .ln{display:inline-block;width:40px;color:var(--muted);text-align:right;margin-right:10px;-webkit-user-select:none;user-select:none}.md{font-size:13px}.md p{margin:4px 0}.md pre{background:var(--surface-2);padding:6px 8px;border-radius:var(--r-ctl);overflow-x:auto}.md code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:0 3px;border-radius:3px}.md pre code{background:none;padding:0}.md ul,.md ol{padding-left:20px;margin:4px 0}.md blockquote{margin:4px 0;padding-left:10px;border-left:3px solid var(--line);color:var(--muted)}.commits-panel{flex:1;display:flex;flex-direction:column;min-height:0}.commits-panel[hidden]{display:none}.commits-tools{display:flex;align-items:center;gap:8px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--ground);flex-wrap:wrap}.commits-tools input{width:140px}.commits-tools input.search{width:260px}.commits-tools input.path{font-size:12px}.commits-count{margin-left:auto;font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap}.commits-compare{display:flex;align-items:center;gap:20px;padding:6px 12px;border-bottom:1px solid var(--line);background:var(--ground);flex-wrap:wrap}.compare-label{font-size:12px;color:var(--ink-2);margin-right:2px}.commits-list{flex:1;overflow:auto;background:var(--surface)}.commit-day{position:sticky;top:0;z-index:1;display:flex;align-items:center;height:24px;padding:0 12px;background:var(--ground);border-bottom:1px solid var(--line);color:var(--ink-2);font-size:12px;font-weight:500}.commit-row{display:flex;align-items:center;gap:10px;height:30px;padding:0 12px;border-bottom:1px solid var(--surface-3);cursor:pointer;white-space:nowrap}.commit-row:hover{background:var(--surface-2)}.commit-row.active{background:var(--surface-2);box-shadow:inset 2px 0 var(--ink)}.commit-row .sha{flex:0 0 auto;min-width:7ch;color:var(--ink-2)}.commit-row .subject{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}.commit-row .subject .pr{margin-left:6px;color:var(--muted);font-family:var(--mono);font-size:12px}.commit-row .refs{display:inline-flex;gap:4px;flex:0 1 auto;min-width:0;overflow:hidden}.ref{display:inline-block;max-width:180px;height:17px;padding:0 7px;border:1px solid var(--line-strong);border-radius:9px;color:var(--ink-2);font-family:var(--mono);font-size:11px;line-height:15px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ref-tag{background:var(--surface-3);border-color:var(--surface-3)}.ref-head{background:var(--ink);border-color:var(--ink);color:#fff}.ref-more{border-style:dashed;color:var(--muted)}.ref-fork{border-style:dashed;border-color:var(--ink-2);color:var(--ink)}.commit-row.fork{border-top:1px dashed var(--line-strong)}.fork-note{margin-left:6px;font-size:12px;white-space:nowrap}.fork-note .link{padding:0;font-size:12px}.commit-row .author{flex:0 0 110px;overflow:hidden;text-overflow:ellipsis;color:var(--ink-2);font-size:12px}.commit-row .time{flex:0 0 40px;text-align:right;color:var(--muted);font-family:var(--mono);font-size:11px}.commits-foot{display:flex;justify-content:center;align-items:center;min-height:44px;font-size:12px}.issues-drawer{width:440px;border-left:1px solid var(--line);display:flex;flex-direction:column;background:var(--ground);min-height:0}.drawer-head{display:flex;align-items:center;gap:10px;padding:0 10px;min-height:36px;border-bottom:1px solid var(--line)}.drawer-title{font-weight:600}.rail-list.tasks{padding:4px 0 12px;gap:0}.task-add{height:34px;margin:0 4px 2px;padding:0 10px 0 24px;justify-content:flex-start;gap:10px;border:none;border-radius:var(--r-card);background:none;color:var(--ink-2);font-weight:500}.task-add:hover:not(:disabled){background:var(--surface-3);border-color:transparent;color:var(--ink)}.task-add-plus{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;background:var(--ink);color:#fff;font-size:15px;line-height:1}.task-list{display:flex;flex-direction:column}.task-row{position:relative;display:flex;align-items:flex-start;gap:6px;margin:0 4px;padding:5px 10px 5px 4px;border-radius:var(--r-card)}.task-row:hover,.task-row.open{background:var(--surface)}.task-row.open{box-shadow:0 0 0 1px var(--line)}.task-grip{flex:0 0 14px;height:20px;display:inline-flex;align-items:center;justify-content:center;color:var(--muted);cursor:grab;visibility:hidden}.task-row:hover .task-grip{visibility:visible}.task-list.dragging .task-grip{visibility:hidden}.task-check{position:relative;flex:0 0 18px;width:18px;height:18px;margin-top:1px;padding:0;border:1.5px solid var(--ink-2);border-radius:50%;background:var(--surface)}.task-check:after{content:"";position:absolute;left:5px;top:1.5px;width:4px;height:8px;border:solid var(--ink);border-width:0 1.5px 1.5px 0;transform:rotate(45deg);opacity:0}.task-check:hover:not(:disabled){background:var(--surface);border-color:var(--ink)}.task-check:hover:not(:disabled):after{opacity:.45}.task-row.done .task-check,.task-row.completing .task-check{background:var(--ink);border-color:var(--ink)}.task-row.done .task-check:after,.task-row.completing .task-check:after{border-color:#fff;opacity:1}.task-row.draft .task-check{border-style:dashed;border-color:var(--muted)}.task-main{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.task-line{display:flex;align-items:flex-start;gap:6px}.task-ta{width:100%;min-width:0;height:auto;padding:0;border:none;border-radius:0;background:transparent;resize:none;overflow:hidden;font-family:var(--sans);line-height:20px}.task-ta:focus{box-shadow:none}.task-title{flex:1;font-size:13px}.task-body{font-size:12px;line-height:18px;color:var(--ink-2)}.task-details{font-size:12px;line-height:18px;color:var(--muted);cursor:text}.task-details .md{font-size:12px}.task-details .md p{margin:0 0 2px}.task-row.done .task-title,.task-row.completing .task-title{text-decoration:line-through;color:var(--muted)}.task-row.completing{opacity:.55;transition:opacity .3s}.task-row .badge{margin-top:2px}.task-count{font-family:var(--mono);font-size:11px;line-height:20px;color:var(--muted);white-space:nowrap}.task-actions{display:inline-flex;align-items:center;gap:2px;margin-left:auto;line-height:20px;white-space:nowrap;visibility:hidden}.task-actions .link{font-size:12px}.task-row:hover .task-actions,.task-row:focus-within .task-actions,.task-sub:hover .task-actions{visibility:visible}.task-row.dragging{opacity:.4}.task-row.drop-before{box-shadow:0 -2px 0 0 var(--ink)}.task-row.drop-after{box-shadow:0 2px 0 0 var(--ink)}.task-empty{padding:14px 20px;color:var(--muted);font-size:12px}.task-add-hint{padding:0 10px 6px 46px;font-size:12px;color:var(--muted)}.task-done{margin-top:8px;padding-top:4px;border-top:1px solid var(--line)}.task-done-head{display:flex;align-items:center;min-height:28px;padding:0 14px 0 10px}.task-done-toggle{height:auto;padding:0 4px;gap:6px;border:none;background:none;color:var(--ink-2);font-weight:500}.task-done-toggle:hover:not(:disabled){background:none;border-color:transparent;color:var(--ink)}.task-list-pick{max-width:220px}.task-subs{display:flex;flex-direction:column;gap:4px;margin-top:6px}.task-sub{display:flex;align-items:flex-start;gap:8px;padding:4px 6px;border-radius:var(--r-ctl);background:var(--surface-2);font-size:12px}.task-sub-dot{flex:0 0 8px;width:8px;height:8px;margin-top:6px;border-radius:50%;background:var(--accent)}.task-sub.status-exported .task-sub-dot{background:var(--line-strong)}.task-sub.status-orphaned .task-sub-dot{background:var(--warn-line)}.task-sub-main{flex:1;min-width:0}.task-sub-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.task-sub-head .mono{font-size:11px}.task-sub-body{color:var(--muted);white-space:pre-wrap;max-height:54px;overflow:hidden}.task-sub-add{align-self:flex-start;font-size:12px}.task-sub-none{font-size:12px;color:var(--muted)}.toast{position:fixed;bottom:20px;left:50%;transform:translate(-50%);background:var(--ink);color:#fff;padding:8px 16px;border-radius:var(--r-card);box-shadow:0 6px 20px #16181d40;z-index:100;max-width:80vw}.toast-error{background:var(--del-fg)}.toast-action{height:22px;margin-left:14px;padding:0 8px;background:#ffffff24;border-color:#ffffff59;color:#fff;font-weight:500}.toast-action:hover:not(:disabled){background:#ffffff42;border-color:#ffffff80}.rail{width:360px;min-width:300px;border-left:1px solid var(--line);display:flex;flex-direction:column;background:var(--ground);min-height:0}.rail-head{display:flex;align-items:center;gap:10px;padding:0 10px;min-height:36px;border-bottom:1px solid var(--line)}.rail-tools{display:flex;align-items:center;gap:8px;padding:0 10px;min-height:34px;border-bottom:1px solid var(--line);font-size:12px}.rail-notice{padding:6px 10px;background:var(--warn-bg);color:var(--warn-fg);border-bottom:1px solid var(--warn-line);display:flex;gap:8px;align-items:center;font-size:12px}.rail-notice .link{color:var(--warn-fg)}.rail-list{flex:1;overflow:auto;padding:10px;display:flex;flex-direction:column;gap:8px}.rail-slot{display:flex;flex-direction:column;gap:8px}.rail-foot{display:flex;flex-direction:column;gap:8px;padding:10px;border-top:1px solid var(--line);background:var(--ground)}.rail-foot-row{display:flex;align-items:center;gap:10px}.send{height:30px;padding:0 14px;background:var(--accent);border-color:var(--accent);color:#fff;font-weight:500}.send:hover:not(:disabled){background:var(--accent-strong);border-color:var(--accent-strong)}.send-n{font-family:var(--mono);font-size:11px;font-weight:400;line-height:16px;padding:0 5px;border-radius:var(--r-ctl);background:#ffffff38}.rail .comment-card{cursor:pointer}.rail .comment-card.focused{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}.comment-head .file{color:var(--muted);max-width:120px;overflow:hidden;text-overflow:ellipsis}.rail .comment-editor{max-width:none}.sidebar.dual{overflow:hidden}@media(prefers-reduced-motion:reduce){.chev,.card-actions,.task-row.completing,.block-progress>span{transition:none}}