@xbghc/warden 0.8.0 → 0.9.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
@@ -68,25 +68,29 @@ inside a removed worktree falls back to the working tree on the next load. The c
68
68
  flags kept under that worktree's key stay in the state file and come back if a worktree is created
69
69
  at the same path again.
70
70
 
71
- There is no target switcher in the UI: the sidebar's two blocks *are* the working tree, a commit is
72
- picked from the *Commits* panel, and the *Branch vs* and *Range* fields at the top of that panel open
73
- the other two. The sidebar names whatever is under review in its block header and puts a
71
+ The sidebar is the navigation, and the only navigation: its three tabs — *变更*, *提交*, *Worktree* —
72
+ each fill the left column with the controls for what the middle is showing, and the top bar carries
73
+ none of it. There is no target switcher: the *变更* tab's two blocks *are* the working tree, a commit
74
+ is picked from the *提交* list, and the *审阅整条分支* and *对比两个 ref* forms below that tab's filters
75
+ open the other two. The sidebar names whatever is under review in its block header and puts a
74
76
  *返回工作区* link above it.
75
77
 
76
78
  `working`, `staged` and `all` are the three **local views** of one worktree. While any of them is
77
- selected the sidebar shows two blocks — Unstaged (`working`) and Staged (`staged`) — instead of a
79
+ selected the sidebar shows two blocks — 未暂存 (`working`) and 已暂存 (`staged`) — instead of a
78
80
  single tree, and clicking a file switches to the view it belongs to. A file that is only partly
79
- staged appears in both. Switching between the three keeps your comments, the draft you are typing
80
- and the current selection; only the diff is reloaded. `all` has no control of its own (a saved
81
- `lastTarget` can still restore it; *Branch vs* `@` shows the same diff). Commit, range and `base`
82
- targets keep the single tree.
81
+ staged appears in both. Above them sits the one figure in the app: how many of the files under review
82
+ are fully staged, which is what "staged means reviewed" amounts to. Switching between the three keeps
83
+ your comments, the draft you are typing and the current selection; only the diff is reloaded. `all`
84
+ has no control of its own (a saved `lastTarget` can still restore it; *审阅整条分支* against `@` shows
85
+ the same diff). Commit, range and `base` targets keep the single tree, and their figure counts 已读
86
+ instead, since nothing can be staged there.
83
87
 
84
88
  `base:<ref>` is for a branch a coding agent has been working on, committing as it goes: one tree with
85
89
  everything since the branch forked off `<ref>` — the commits plus whatever is still uncommitted or
86
90
  untracked. The diff runs against the merge base, so commits that landed on `<ref>` after the fork are
87
- not listed as reverted (which is what a plain `git diff <ref>` would do). The *Branch vs* field in the
88
- Commits panel suggests the base for you — the main worktree's branch when a sibling worktree is under
89
- review, otherwise `main` or `master` — and takes any ref. Beside it the panel names the fork point (the
91
+ not listed as reverted (which is what a plain `git diff <ref>` would do). The *相对于* field under
92
+ *审阅整条分支* suggests the base for you — the main worktree's branch when a sibling worktree is under
93
+ review, otherwise `main` or `master` — and takes any ref. Under it the form names the fork point (the
90
94
  merge base with that ref, `GET /api/fork-point`) with how many commits the branch is ahead of it and how
91
95
  many landed on the base since, and the list marks that commit with a *分叉自* chip and a rule above it:
92
96
  what sits above is the branch's own work. Unlike the three local views, `base` keeps its own
@@ -96,10 +100,10 @@ the fix and delete or re-attach it.
96
100
 
97
101
  ## Worktrees
98
102
 
99
- Agents do their best work each in a worktree of its own, and the *Worktrees* tab in the top bar is
103
+ Agents do their best work each in a worktree of its own, and the sidebar's *Worktree* tab is
100
104
  where those are made and taken down without a trip to the terminal:
101
105
 
102
- - *新建 worktree* takes a branch and a base. A name that is not a branch yet becomes one from the
106
+ - *新建 worktree*, the form in the sidebar, takes a branch and a base. A name that is not a branch yet becomes one from the
103
107
  base (`main` or `master` unless you say otherwise; any ref goes) — `git worktree add -b <branch>
104
108
  <path> <base>`; an existing branch is checked out as it is, unless another worktree already has it.
105
109
  The path is suggested as a sibling of the main worktree named `<repo>-<branch>` (slashes become
@@ -108,11 +112,12 @@ where those are made and taken down without a trip to the terminal:
108
112
  - Each row names the checkout, its branch, and whether it is clean or how many paths `git status`
109
113
  reports. *查看* switches the review to it (the kind of target carries over, as with the selector
110
114
  in the top bar), *复制路径* is for the agent's prompt.
111
- - *删除* runs `git worktree remove`, never with `--force` on its own: a worktree with uncommitted
112
- changes is not removed until you confirm in the row, since those changes go with it, and whatever
113
- else git refuses without `--force` is put to you the same way. With *一并删除已合并的分支* ticked
114
- the branch goes too, by `git branch -d`: one that is not merged is kept and the toast says why. The
115
- comments and viewed flags kept under the worktree's key stay in the state file, as before.
115
+ - *删除* always asks first, in the row it would remove, and runs `git worktree remove` without
116
+ `--force`: a worktree with uncommitted changes is not removed until you confirm again, since those
117
+ changes go with it, and whatever else git refuses without `--force` is put to you the same way.
118
+ *一并删除分支* is offered in that same confirmation, ticked by default, and takes the branch by
119
+ `git branch -d`: one that is not merged is kept and the toast says why. The comments and viewed
120
+ flags kept under the worktree's key stay in the state file, as before.
116
121
  - A worktree whose directory was deleted behind git's back is listed struck through; *清理* drops that
117
122
  one entry (`git worktree remove` handles it; nothing is pruned wholesale).
118
123
 
@@ -125,7 +130,7 @@ where those are made and taken down without a trip to the terminal:
125
130
  | `n` / `p` | Next / previous hunk |
126
131
  | `s` | Stage (Unstaged view) or unstage (Staged view) the picked lines |
127
132
  | `Ctrl+Enter` | Save the comment being edited |
128
- | `Esc` | Drop the picked lines / cancel editing / close a panel / cancel re-attach mode |
133
+ | `Esc` | Drop the picked lines / cancel editing / cancel re-attach mode / shut the rail / back to 变更 |
129
134
 
130
135
  ## Staging
131
136
 
@@ -156,12 +161,18 @@ Limits, each reported as a plain error rather than a half-applied patch:
156
161
 
157
162
  ## Comments and export
158
163
 
159
- Press the `+` that appears next to a line (drag to cover several lines) and write Markdown.
164
+ Press the `+` that appears next to a line (drag to cover several lines) and write Markdown. What you
165
+ write lands in the right-hand rail, which is shut until it has something to hold: it costs 360px of
166
+ the code column, which is most of it once the diff is side by side on a laptop. Writing a comment,
167
+ focusing one or re-attaching one opens it; so does the *评论* switch at the right of the top bar,
168
+ which carries the count and turns violet while any comment is still waiting to go back to the agent.
169
+ `Esc` shuts it again.
170
+
160
171
  Each comment belongs to the `old` or `new` side of the diff and has a status:
161
172
 
162
- - `active` — not yet exported
163
- - `exported` — copied at least once (excluded from "copy all" unless *含已导出* is checked)
164
- - `orphaned` — the code it referred to no longer exists in the current diff
173
+ - `active` (待导出) — not yet exported
174
+ - `exported` (已导出) — copied at least once (excluded from "copy all" unless *含已导出* is checked)
175
+ - `orphaned` (已失联) — the code it referred to no longer exists in the current diff
165
176
 
166
177
  "复制评论" copies every active comment of the current target; each comment also has a "复制此条" button.
167
178
  The clipboard format is fixed so an agent can read it directly:
@@ -211,7 +222,7 @@ them deletes on a moved HEAD.
211
222
  Comment markers in the diff belong to one view; the rail's *全部* tab lists the whole pool and
212
223
  *此文件* lists every comment on the open file regardless of which view it currently sits in.
213
224
 
214
- "Viewed" is per view, so a half-staged file can be marked read on one side and not the other. It is
225
+ *已读* is per view, so a half-staged file can be marked read on one side and not the other. It is
215
226
  bound to a hash of the file's diff; when the diff changes the flag is dropped and the file is marked
216
227
  *已变化*.
217
228
 
@@ -229,9 +240,9 @@ refreshes by hand.
229
240
 
230
241
  Notes attached to a branch rather than to a line of code, for the things you notice while reviewing
231
242
  that do not belong in a comment. They are not deleted when you commit. They sit in the right-hand
232
- rail next to the comments, under the *Todo* tab, as a task list that works the way Google Tasks does:
243
+ rail next to the comments, under the *待办* tab, as a task list that works the way Google Tasks does:
233
244
 
234
- - *添加 Todo* opens an empty row at the top; type the title and press Enter, and the next row opens
245
+ - *添加待办* opens an empty row at the top; type the title and press Enter, and the next row opens
235
246
  right under it. Esc or Backspace on an empty row drops it.
236
247
  - Titles and details are edited where they are: click into the text. Enter at the end of a title
237
248
  starts the next todo under it; Backspace on an emptied title deletes the todo. The details field
@@ -244,10 +255,10 @@ rail next to the comments, under the *Todo* tab, as a task list that works the w
244
255
  - The picker at the top is the list selector: the current branch, any other branch that has todos,
245
256
  or all of them with the branch on each row. A new todo goes to the branch being shown.
246
257
 
247
- Issues (the *Issues* tab in the top bar) are the same list: closing an issue is ticking it, a
258
+ Issues (the rail's third tab) are the same list: closing an issue is ticking it, a
248
259
  *已关闭* section holds the closed ones, and the comments linked to an issue sit under its open row
249
260
  like subtasks, each with *跳转* and *解除关联*. Tick comments in the rail and the *创建 Issue* button
250
- opens the drawer with the add row ready and the comments attached on creation; for an existing
261
+ switches to that tab with the add row ready and the comments attached on creation; for an existing
251
262
  issue, open its row and use *关联选中的评论*.
252
263
 
253
264
  Each row has its own *复制* button, and there is deliberately no "copy all": a todo is one task to
@@ -351,3 +362,7 @@ to `main` and every pull request.
351
362
  ## License
352
363
 
353
364
  MIT
365
+
366
+ ## Storybook
367
+
368
+ Run `pnpm storybook` for isolated review and layout components at http://127.0.0.1:6006. Stories use in-memory fixtures and include interaction checks. Run `pnpm build:storybook` to build the static preview.
package/dist/cli.js CHANGED
@@ -2918,7 +2918,7 @@ function parseTargetKey(key) {
2918
2918
  if (key.startsWith("worktree:")) {
2919
2919
  const rest = key.slice("worktree:".length);
2920
2920
  const m = /^(.+):(working|staged|all|commit:.+|range:.+|base:.+)$/.exec(rest);
2921
- if (!m || !m[1] || !m[2]) throw new TargetKeyError(`invalid worktree target key: ${key}`);
2921
+ if (!m?.[1] || !m[2]) throw new TargetKeyError(`invalid worktree target key: ${key}`);
2922
2922
  const worktree = m[1];
2923
2923
  if (!worktree.startsWith("/")) throw new TargetKeyError(`worktree path must be absolute: ${worktree}`);
2924
2924
  return { ...parseSuffix(m[2]), worktree };
@@ -2968,11 +2968,7 @@ function stageModeFor(t) {
2968
2968
  function localViewKeys(key) {
2969
2969
  const t = tryParseTargetKey(key);
2970
2970
  const wt = t?.worktree ? { worktree: t.worktree } : {};
2971
- return [
2972
- formatTargetKey({ kind: "working", ...wt }),
2973
- formatTargetKey({ kind: "staged", ...wt }),
2974
- formatTargetKey({ kind: "all", ...wt })
2975
- ];
2971
+ return [formatTargetKey({ kind: "working", ...wt }), formatTargetKey({ kind: "staged", ...wt }), formatTargetKey({ kind: "all", ...wt })];
2976
2972
  }
2977
2973
 
2978
2974
  // packages/shared/src/order.ts
@@ -3049,7 +3045,19 @@ function parseCommitLog(stdout) {
3049
3045
  // packages/server/src/git.ts
3050
3046
  import { execFile } from "child_process";
3051
3047
  import { existsSync } from "fs";
3052
- var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set(["rev-parse", "diff", "show", "log", "worktree", "ls-files", "status", "merge-base", "rev-list", "for-each-ref", "check-ref-format"]);
3048
+ var ALLOWED_SUBCOMMANDS = /* @__PURE__ */ new Set([
3049
+ "rev-parse",
3050
+ "diff",
3051
+ "show",
3052
+ "log",
3053
+ "worktree",
3054
+ "ls-files",
3055
+ "status",
3056
+ "merge-base",
3057
+ "rev-list",
3058
+ "for-each-ref",
3059
+ "check-ref-format"
3060
+ ]);
3053
3061
  var FORBIDDEN_OPTION_PREFIXES = ["--output", "--ext-diff", "--textconv", "-c", "--config-env", "--exec-path", "--git-dir", "--work-tree"];
3054
3062
  var DEFAULT_GIT_TIMEOUT_MS = 3e4;
3055
3063
  var EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
@@ -3269,7 +3277,7 @@ async function resolveRepo(dir) {
3269
3277
  try {
3270
3278
  const r = await runGit(["rev-parse", "--show-toplevel"], { cwd: dir });
3271
3279
  top = r.stdout.trim();
3272
- } catch (e) {
3280
+ } catch {
3273
3281
  throw new HttpError(400, `${dir} is not inside a git repository`, "not_a_repo");
3274
3282
  }
3275
3283
  if (!top) throw new HttpError(400, `${dir} is not inside a git worktree (bare repository?)`, "not_a_repo");
@@ -3284,7 +3292,7 @@ async function listWorktreesAll(ctx) {
3284
3292
  const out = [];
3285
3293
  let cur = null;
3286
3294
  const flush = () => {
3287
- if (cur && cur.path) {
3295
+ if (cur?.path) {
3288
3296
  out.push({
3289
3297
  path: cur.path,
3290
3298
  head: cur.head ?? "",
@@ -3306,7 +3314,6 @@ async function listWorktreesAll(ctx) {
3306
3314
  flush();
3307
3315
  cur = { path: line.slice("worktree ".length) };
3308
3316
  } else if (!cur) {
3309
- continue;
3310
3317
  } else if (line.startsWith("HEAD ")) cur.head = line.slice(5);
3311
3318
  else if (line.startsWith("branch ")) cur.branch = line.slice(7).replace(/^refs\/heads\//, "");
3312
3319
  else if (line === "detached") cur.detached = true;
@@ -3366,7 +3373,9 @@ function lineHash(content) {
3366
3373
  return sha1(content.trimEnd());
3367
3374
  }
3368
3375
  function hunkHash(lineContents) {
3369
- return sha1(lineContents.map((l) => l.trimEnd()).join("\n").trim());
3376
+ return sha1(
3377
+ lineContents.map((l) => l.trimEnd()).join("\n").trim()
3378
+ );
3370
3379
  }
3371
3380
 
3372
3381
  // packages/server/src/diffparse.ts
@@ -3407,7 +3416,7 @@ function stripPrefix(p, prefix) {
3407
3416
  function splitGitHeader(rest) {
3408
3417
  if (rest.startsWith('"')) {
3409
3418
  const m = /^("(?:[^"\\]|\\.)*")\s+("(?:[^"\\]|\\.)*")$/.exec(rest);
3410
- if (m && m[1] && m[2]) return { a: stripPrefix(m[1], "a/"), b: stripPrefix(m[2], "b/") };
3419
+ if (m?.[1] && m[2]) return { a: stripPrefix(m[1], "a/"), b: stripPrefix(m[2], "b/") };
3411
3420
  }
3412
3421
  for (let i = 0; i < rest.length; i++) {
3413
3422
  if (rest[i] !== " ") continue;
@@ -3564,7 +3573,6 @@ function parseUnifiedDiff(text) {
3564
3573
  if (line.startsWith("Binary files ") || line === "GIT binary patch") {
3565
3574
  c.binary = true;
3566
3575
  c.raw.push(line);
3567
- continue;
3568
3576
  }
3569
3577
  }
3570
3578
  flush();
@@ -3888,12 +3896,13 @@ function pickedLines(diff, selection) {
3888
3896
  const picked = /* @__PURE__ */ new Map();
3889
3897
  const changed = (h) => h.lines.map((l, i) => l.type === "context" ? -1 : i).filter((i) => i >= 0);
3890
3898
  if (selection === void 0) {
3891
- diff.hunks.forEach((h, i) => picked.set(i, new Set(changed(h))));
3899
+ for (const [i, h] of diff.hunks.entries()) picked.set(i, new Set(changed(h)));
3892
3900
  return picked;
3893
3901
  }
3894
3902
  if (!Array.isArray(selection)) throw badRequest("hunks must be an array", "bad_selection");
3895
3903
  for (const sel of selection) {
3896
- if (!sel || !Number.isInteger(sel.index) || sel.index < 0 || sel.index >= diff.hunks.length) throw badRequest(`no such hunk: ${sel?.index}`, "bad_selection");
3904
+ if (!sel || !Number.isInteger(sel.index) || sel.index < 0 || sel.index >= diff.hunks.length)
3905
+ throw badRequest(`no such hunk: ${sel?.index}`, "bad_selection");
3897
3906
  const hunk = diff.hunks[sel.index];
3898
3907
  const set = picked.get(sel.index) ?? /* @__PURE__ */ new Set();
3899
3908
  picked.set(sel.index, set);
@@ -4014,13 +4023,11 @@ async function assertNewWorktreePath(ctx, worktrees, p) {
4014
4023
  for (const w of worktrees) {
4015
4024
  if (target === w.path || target.startsWith(w.path + path3.sep)) throw badRequest(`${target} is inside the worktree at ${w.path}`, "invalid_path");
4016
4025
  }
4017
- let st;
4018
- try {
4019
- st = await stat(target);
4020
- } catch (e) {
4021
- if (e.code === "ENOENT") return target;
4026
+ const st = await stat(target).catch((e) => {
4027
+ if (e.code === "ENOENT") return void 0;
4022
4028
  throw e;
4023
- }
4029
+ });
4030
+ if (!st) return target;
4024
4031
  if (!st.isDirectory() || (await readdir(target)).length > 0) throw badRequest(`${target} already exists`, "path_exists");
4025
4032
  return target;
4026
4033
  }
@@ -4093,7 +4100,7 @@ import path4 from "path";
4093
4100
  import { mkdir, open, readFile as readFile2, rename, stat as stat2, unlink, writeFile } from "fs/promises";
4094
4101
  function dataDir() {
4095
4102
  const xdg = process.env.XDG_DATA_HOME;
4096
- const base = xdg && xdg.trim() ? xdg : path4.join(os.homedir(), ".local", "share");
4103
+ const base = xdg?.trim() ? xdg : path4.join(os.homedir(), ".local", "share");
4097
4104
  return path4.join(base, "warden");
4098
4105
  }
4099
4106
  function repoHash(repoRoot) {
@@ -4103,7 +4110,7 @@ function stateFilePath(repoRoot, baseDir = dataDir()) {
4103
4110
  return path4.join(baseDir, repoHash(repoRoot), "state.json");
4104
4111
  }
4105
4112
  function defaultPrefs() {
4106
- return { viewMode: "unified", nvimSocketByRoot: {}, autoRefresh: true };
4113
+ return { viewMode: "unified", nvimSocketByRoot: {}, autoRefresh: true, railOpen: false };
4107
4114
  }
4108
4115
  function defaultState(repoRoot) {
4109
4116
  return { schemaVersion: 1, repoRoot, targets: {}, issues: [], todos: [], prefs: defaultPrefs() };
@@ -4143,7 +4150,8 @@ function normalise(raw2, repoRoot) {
4143
4150
  ...defaultPrefs(),
4144
4151
  ...r.prefs ?? {},
4145
4152
  nvimSocketByRoot: r.prefs?.nvimSocketByRoot ?? {},
4146
- autoRefresh: typeof r.prefs?.autoRefresh === "boolean" ? r.prefs.autoRefresh : true
4153
+ autoRefresh: typeof r.prefs?.autoRefresh === "boolean" ? r.prefs.autoRefresh : true,
4154
+ railOpen: typeof r.prefs?.railOpen === "boolean" ? r.prefs.railOpen : false
4147
4155
  }
4148
4156
  };
4149
4157
  }
@@ -4294,7 +4302,7 @@ var LANG_BY_EXT = {
4294
4302
  };
4295
4303
  function langForPath(filePath) {
4296
4304
  const m = /\.([A-Za-z0-9]+)$/.exec(filePath);
4297
- if (!m || !m[1]) return "";
4305
+ if (!m?.[1]) return "";
4298
4306
  return LANG_BY_EXT[m[1].toLowerCase()] ?? m[1].toLowerCase();
4299
4307
  }
4300
4308
  function formatCommentSection(c) {
@@ -4315,13 +4323,7 @@ function formatCommentsExport({ repoRoot, comments }) {
4315
4323
  }
4316
4324
  function formatIssueExport({ repoRoot, issue, comments }) {
4317
4325
  const targets = [...new Set(comments.map((c) => c.targetKey))];
4318
- const head = [
4319
- `# Issue: ${issue.title}`,
4320
- `Status: ${issue.status}`,
4321
- `Target: ${targets.join(", ") || "-"}`,
4322
- `Repo: ${repoRoot}`,
4323
- `Count: ${comments.length}`
4324
- ];
4326
+ const head = [`# Issue: ${issue.title}`, `Status: ${issue.status}`, `Target: ${targets.join(", ") || "-"}`, `Repo: ${repoRoot}`, `Count: ${comments.length}`];
4325
4327
  const parts = [head.join("\n")];
4326
4328
  if (issue.body.trim()) parts.push(issue.body.trim());
4327
4329
  parts.push(...comments.map(formatCommentSection));
@@ -4682,7 +4684,11 @@ function createApp(opts) {
4682
4684
  const target = last ? tryParseTargetKey(last) : void 0;
4683
4685
  const usable2 = !!target && (!target.worktree || info.worktrees.some((w) => w.path === target.worktree));
4684
4686
  if (last && usable2) info.defaultTarget = last;
4685
- else if (last) await store.update((s) => void (s.prefs.lastTarget = "working"));
4687
+ else if (last) {
4688
+ await store.update((s) => {
4689
+ s.prefs.lastTarget = "working";
4690
+ });
4691
+ }
4686
4692
  return c.json(info);
4687
4693
  });
4688
4694
  api.get("/state", async (c) => c.json(await store.load()));
@@ -4692,6 +4698,7 @@ function createApp(opts) {
4692
4698
  if (body.viewMode === "unified" || body.viewMode === "split") s.prefs.viewMode = body.viewMode;
4693
4699
  if (typeof body.lastTarget === "string") s.prefs.lastTarget = body.lastTarget;
4694
4700
  if (typeof body.autoRefresh === "boolean") s.prefs.autoRefresh = body.autoRefresh;
4701
+ if (typeof body.railOpen === "boolean") s.prefs.railOpen = body.railOpen;
4695
4702
  if (body.nvimSocketByRoot && typeof body.nvimSocketByRoot === "object") {
4696
4703
  s.prefs.nvimSocketByRoot = { ...s.prefs.nvimSocketByRoot, ...body.nvimSocketByRoot };
4697
4704
  }
@@ -4781,7 +4788,8 @@ function createApp(opts) {
4781
4788
  if (body.hunks !== void 0 && !Array.isArray(body.hunks)) throw badRequest("hunks must be an array", "bad_selection");
4782
4789
  const diff = await fileDiffWithHints(ctx, body.path);
4783
4790
  if (!diff) throw badRequest(`file ${body.path} is not part of ${key}`, "no_diff");
4784
- if (diff.contentHash !== body.contentHash) throw new HttpError(409, "the diff changed since it was loaded; refresh and pick the lines again", "diff_changed");
4791
+ if (diff.contentHash !== body.contentHash)
4792
+ throw new HttpError(409, "the diff changed since it was loaded; refresh and pick the lines again", "diff_changed");
4785
4793
  const { patch, lines } = buildStagePatch(diff, mode, body.hunks);
4786
4794
  await applyToIndex(ctx.cwd, patch, { reverse: mode === "unstage" });
4787
4795
  void watchers.get(ctx.cwd)?.poll();
@@ -5004,7 +5012,7 @@ function createApp(opts) {
5004
5012
  });
5005
5013
  api.post("/issues", async (c) => {
5006
5014
  const body = await c.req.json();
5007
- if (!body.title || !body.title.trim()) throw badRequest("title is required");
5015
+ if (!body.title?.trim()) throw badRequest("title is required");
5008
5016
  const now = (/* @__PURE__ */ new Date()).toISOString();
5009
5017
  const issue = {
5010
5018
  id: randomUUID(),
@@ -5097,7 +5105,7 @@ function createApp(opts) {
5097
5105
  });
5098
5106
  api.post("/todos", async (c) => {
5099
5107
  const body = await c.req.json();
5100
- if (!body.title || !body.title.trim()) throw badRequest("title is required");
5108
+ if (!body.title?.trim()) throw badRequest("title is required");
5101
5109
  const branch = body.branch?.trim() || await currentBranch(await knownRoot(body.root));
5102
5110
  const now = (/* @__PURE__ */ new Date()).toISOString();
5103
5111
  const todo = {
@@ -5406,7 +5414,7 @@ async function startServer(opts) {
5406
5414
  }
5407
5415
 
5408
5416
  // bin/cli.ts
5409
- var VERSION = true ? "0.8.0" : "dev";
5417
+ var VERSION = true ? "0.9.0" : "dev";
5410
5418
  function parseArgs(argv) {
5411
5419
  const args = { repoPath: process.cwd(), open: true, help: false, version: false };
5412
5420
  for (let i = 0; i < argv.length; i++) {