@webappwiz/arbor 0.0.28 → 0.0.30

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.
Files changed (119) hide show
  1. package/AGENTS.md +7 -0
  2. package/README.md +34 -12
  3. package/add.test.ts +125 -0
  4. package/add.ts +192 -0
  5. package/age.ts +11 -0
  6. package/arbor.test.ts +145 -0
  7. package/arbor.ts +429 -0
  8. package/assets.d.ts +9 -0
  9. package/attachments.ts +87 -0
  10. package/build.ts +98 -0
  11. package/claim.test.ts +89 -0
  12. package/claim.ts +73 -0
  13. package/components.json +21 -0
  14. package/config.ts +58 -0
  15. package/dev/api.ts +120 -0
  16. package/dev/app.test.tsx +645 -0
  17. package/dev/app.tsx +80 -0
  18. package/dev/assets.ts +16 -0
  19. package/dev/build/main.txt +68 -0
  20. package/dev/build/shell.txt +21 -0
  21. package/dev/build/styles.txt +2263 -0
  22. package/dev/components/ui/badge.tsx +51 -0
  23. package/dev/components/ui/button.tsx +57 -0
  24. package/dev/components/ui/dialog.tsx +154 -0
  25. package/dev/components/ui/empty.tsx +100 -0
  26. package/dev/components/ui/input-group.tsx +155 -0
  27. package/dev/components/ui/input.tsx +19 -0
  28. package/dev/components/ui/progress.tsx +80 -0
  29. package/dev/components/ui/textarea.tsx +17 -0
  30. package/dev/components/ui/toast.tsx +227 -0
  31. package/dev/feed.ts +69 -0
  32. package/dev/files.tsx +191 -0
  33. package/dev/index.html +21 -0
  34. package/dev/lib/utils.ts +7 -0
  35. package/dev/main.tsx +22 -0
  36. package/dev/markdown.test.tsx +133 -0
  37. package/dev/markdown.tsx +124 -0
  38. package/dev/mentions.tsx +319 -0
  39. package/dev/shadcn.css +649 -0
  40. package/dev/styles.css +107 -0
  41. package/dev/tags.tsx +44 -0
  42. package/dev/tasks.tsx +151 -0
  43. package/dev/todos.tsx +759 -0
  44. package/dev.test.ts +439 -0
  45. package/dev.ts +386 -0
  46. package/e2e.test.ts +256 -0
  47. package/escalate.test.ts +76 -0
  48. package/escalate.ts +112 -0
  49. package/exit.test.ts +71 -0
  50. package/exit.ts +74 -0
  51. package/git.ts +294 -0
  52. package/index.ts +9 -0
  53. package/journal.ts +89 -0
  54. package/list.test.ts +82 -0
  55. package/list.ts +137 -0
  56. package/load-config.test.ts +121 -0
  57. package/load-config.ts +62 -0
  58. package/log.test.ts +65 -0
  59. package/log.ts +40 -0
  60. package/merge.test.ts +332 -0
  61. package/merge.ts +283 -0
  62. package/package.json +32 -15
  63. package/path.test.ts +35 -0
  64. package/path.ts +38 -0
  65. package/plan.test.ts +318 -0
  66. package/plan.ts +302 -0
  67. package/progress.test.ts +54 -0
  68. package/progress.ts +31 -0
  69. package/remove.test.ts +68 -0
  70. package/remove.ts +88 -0
  71. package/repository.ts +83 -0
  72. package/retry.test.ts +43 -0
  73. package/retry.ts +48 -0
  74. package/shell.ts +44 -0
  75. package/show.test.ts +91 -0
  76. package/show.ts +151 -0
  77. package/snapshot.test.ts +33 -0
  78. package/snapshot.ts +84 -0
  79. package/table.ts +20 -0
  80. package/testing.ts +229 -0
  81. package/todo.test.ts +490 -0
  82. package/todo.ts +834 -0
  83. package/wait.test.ts +61 -0
  84. package/wait.ts +77 -0
  85. package/worktree-service.test.ts +158 -0
  86. package/worktree-service.ts +193 -0
  87. package/worktree.ts +239 -0
  88. package/add.d.ts +0 -23
  89. package/age.d.ts +0 -2
  90. package/arbor.d.ts +0 -11
  91. package/attachments.d.ts +0 -36
  92. package/claim.d.ts +0 -11
  93. package/config.d.ts +0 -55
  94. package/config.js +0 -7
  95. package/dev/assets.d.ts +0 -11
  96. package/dev.d.ts +0 -47
  97. package/escalate.d.ts +0 -28
  98. package/exit.d.ts +0 -42
  99. package/git.d.ts +0 -77
  100. package/index.d.ts +0 -2
  101. package/index.js +0 -4669
  102. package/journal.d.ts +0 -29
  103. package/list.d.ts +0 -18
  104. package/load-config.d.ts +0 -15
  105. package/log.d.ts +0 -14
  106. package/merge.d.ts +0 -24
  107. package/path.d.ts +0 -13
  108. package/plan.d.ts +0 -83
  109. package/remove.d.ts +0 -19
  110. package/repository.d.ts +0 -25
  111. package/retry.d.ts +0 -17
  112. package/shell.d.ts +0 -17
  113. package/show.d.ts +0 -53
  114. package/snapshot.d.ts +0 -31
  115. package/table.d.ts +0 -5
  116. package/todo.d.ts +0 -230
  117. package/wait.d.ts +0 -22
  118. package/worktree-service.d.ts +0 -51
  119. package/worktree.d.ts +0 -95
@@ -0,0 +1,124 @@
1
+ import { type MarkdownToJSX, Markdown as Render } from "markdown-to-jsx/react";
2
+ import type { ComponentProps, JSX } from "react";
3
+ import { cn } from "#dev/lib/utils.ts";
4
+
5
+ export interface MarkdownProps {
6
+ text: string;
7
+ /** Added to the wrapping `<div>`, for margins and the like. */
8
+ className?: string;
9
+ /**
10
+ * A card's two-line glimpse: every block at the size of the text around
11
+ * it and with no margins, so the lines that show are the content's.
12
+ */
13
+ preview?: boolean;
14
+ }
15
+
16
+ // Every level renders as the same quiet uppercase label: the documents this
17
+ // renders sit inside a card or a dialog that already carries the real title,
18
+ // so a heading here is a section marker, not a hierarchy, and a run of
19
+ // title-sized lines would shout over the content. The tag still says the
20
+ // level for a reader.
21
+ const HEADING =
22
+ "mt-[0.9rem] mb-[0.3rem] first:mt-0 font-bold text-[0.8rem] uppercase tracking-[0.08em] opacity-55";
23
+
24
+ const heading = (tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6") => ({
25
+ component: tag,
26
+ props: { className: HEADING },
27
+ });
28
+
29
+ /** A link opens in a new tab, so the page keeps its place. */
30
+ function Link(props: ComponentProps<"a">): JSX.Element {
31
+ return (
32
+ <a
33
+ {...props}
34
+ target="_blank"
35
+ rel="noreferrer noopener"
36
+ className="break-all underline"
37
+ />
38
+ );
39
+ }
40
+
41
+ /**
42
+ * An image reads as its alt text: the paths agents write are files on their
43
+ * machine, which the page has no way to load.
44
+ */
45
+ function Image({ alt }: ComponentProps<"img">): JSX.Element {
46
+ return <span className="text-muted-foreground">[{alt || "image"}]</span>;
47
+ }
48
+
49
+ /** A checklist box only reports: the document is the agent's to tick. */
50
+ function Checkbox(props: ComponentProps<"input">): JSX.Element {
51
+ return (
52
+ <input {...props} disabled className="mr-1.5 align-[-0.1em]" readOnly />
53
+ );
54
+ }
55
+
56
+ const OPTIONS: MarkdownToJSX.Options = {
57
+ // Markup in a todo or a plan is shown as typed, never as elements.
58
+ disableParsingRawHTML: true,
59
+ forceBlock: true,
60
+ overrides: {
61
+ h1: heading("h1"),
62
+ h2: heading("h2"),
63
+ h3: heading("h3"),
64
+ h4: heading("h4"),
65
+ h5: heading("h5"),
66
+ h6: heading("h6"),
67
+ p: { props: { className: "my-1" } },
68
+ ul: { props: { className: "my-1 list-disc pl-5" } },
69
+ // A checklist item gives up its bullet to the box, and fades once ticked.
70
+ li: {
71
+ props: {
72
+ className:
73
+ "has-[>input]:-ml-5 has-[>input]:list-none has-[>input:checked]:opacity-50",
74
+ },
75
+ },
76
+ ol: { props: { className: "my-1 list-decimal pl-5" } },
77
+ blockquote: {
78
+ props: { className: "my-1 border-l-2 pl-3 text-muted-foreground" },
79
+ },
80
+ pre: {
81
+ props: {
82
+ className: "my-2 overflow-x-auto rounded bg-current/8 p-3 text-xs",
83
+ },
84
+ },
85
+ code: {
86
+ props: {
87
+ className:
88
+ "rounded bg-current/10 px-1 [pre_&]:p-0 [pre_&]:bg-transparent",
89
+ },
90
+ },
91
+ table: {
92
+ props: { className: "my-2 block overflow-x-auto text-xs" },
93
+ },
94
+ th: { props: { className: "border px-2 py-1 text-left font-medium" } },
95
+ td: { props: { className: "border px-2 py-1" } },
96
+ hr: { props: { className: "my-3" } },
97
+ a: Link,
98
+ img: Image,
99
+ input: Checkbox,
100
+ },
101
+ };
102
+
103
+ /**
104
+ * Renders markdown with markdown-to-jsx: GitHub's flavor, nesting, tables and
105
+ * task lists included, styled to sit quietly in the page.
106
+ */
107
+ export function Markdown({
108
+ text,
109
+ className,
110
+ preview = false,
111
+ }: MarkdownProps): JSX.Element {
112
+ return (
113
+ <div
114
+ className={cn(
115
+ "min-w-0 break-words",
116
+ preview &&
117
+ "max-h-[2lh] overflow-hidden [&_*]:my-0 [&_*]:text-[length:inherit] [&_*]:normal-case [&_*]:tracking-normal [&_pre]:p-0 [&_pre]:bg-transparent",
118
+ className,
119
+ )}
120
+ >
121
+ <Render options={OPTIONS}>{text}</Render>
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,319 @@
1
+ import { FileIcon, FolderIcon } from "lucide-react";
2
+ import {
3
+ type ChangeEvent,
4
+ type JSX,
5
+ type KeyboardEvent,
6
+ useEffect,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { cn } from "#dev/lib/utils.ts";
11
+ import { paths as fetchPaths } from "./api";
12
+
13
+ /** As many as fit without the list needing a scroll of its own. */
14
+ const SHOWN = 8;
15
+ /** One row's height in pixels, fixed so the list never jumps as it filters. */
16
+ const ROW = 32;
17
+
18
+ /** Which side of the box the list opens on, and how tall it may get there. */
19
+ interface Place {
20
+ below: boolean;
21
+ room: number;
22
+ }
23
+
24
+ /** An `@` being typed: where it starts, and what follows it so far. */
25
+ interface Mention {
26
+ start: number;
27
+ query: string;
28
+ }
29
+
30
+ /**
31
+ * Points an agent at a file or directory by typing `@` in a text box: a list
32
+ * of the tree's paths, filtered as you type, and the path written in place of
33
+ * what was typed. The list floats over the page, above the box or below it
34
+ * when there is no room above, so nothing around it moves.
35
+ *
36
+ * The paths load once, when the box first shows, so there is nothing to wait
37
+ * for at the `@`.
38
+ */
39
+ export function useMentions<
40
+ Box extends HTMLInputElement | HTMLTextAreaElement,
41
+ >({
42
+ task,
43
+ text,
44
+ setText,
45
+ }: {
46
+ /** Whose tree to list; empty for the main tree. */
47
+ task: string;
48
+ text: string;
49
+ setText: (text: string) => void;
50
+ }) {
51
+ const ref = useRef<Box>(null);
52
+ const [all, setAll] = useState<string[]>([]);
53
+ const [caret, setCaret] = useState(0);
54
+ const [active, setActive] = useState(0);
55
+ // Where Escape closed the list, so it stays closed until another `@`.
56
+ const [dismissed, setDismissed] = useState<number | null>(null);
57
+ const [place, setPlace] = useState<Place>({
58
+ below: false,
59
+ room: SHOWN * ROW,
60
+ });
61
+
62
+ useEffect(() => {
63
+ let live = true;
64
+ fetchPaths(task).then(
65
+ (found) => live && setAll(found),
66
+ // Without the list, `@` is only a character: nothing else breaks.
67
+ () => undefined,
68
+ );
69
+ return () => {
70
+ live = false;
71
+ };
72
+ }, [task]);
73
+
74
+ const mention = typedAt(text, caret);
75
+ const matches =
76
+ mention === null || mention.start === dismissed
77
+ ? []
78
+ : rank(all, mention.query).slice(0, SHOWN);
79
+ const open = matches.length > 0;
80
+ const picked = Math.min(active, matches.length - 1);
81
+
82
+ // Decided as the list opens, not while it filters, so it stays put.
83
+ const opening = open && mention !== null;
84
+ const _at = mention?.start;
85
+ useEffect(() => {
86
+ if (!opening || ref.current === null) {
87
+ return;
88
+ }
89
+ const box = ref.current.getBoundingClientRect();
90
+ // A dialog the box sits in clips anything past its edges, and the window
91
+ // clips the rest.
92
+ const frame = ref.current
93
+ .closest("[data-slot=dialog-content]")
94
+ ?.getBoundingClientRect() ?? { top: 0, bottom: innerHeight };
95
+ const above = box.top - frame.top;
96
+ const under = Math.min(frame.bottom, innerHeight) - box.bottom;
97
+ const full = SHOWN * ROW + 10;
98
+ // Above by choice, where a phone's keyboard cannot cover it; below
99
+ // only when that has more room.
100
+ const below = above < full && under > above;
101
+ setPlace({ below, room: Math.min(full, (below ? under : above) - 8) });
102
+ setActive(0);
103
+ }, [opening]);
104
+
105
+ const track = () => setCaret(ref.current?.selectionStart ?? 0);
106
+
107
+ const insert = (path: string) => {
108
+ if (mention === null) {
109
+ return;
110
+ }
111
+ const end = mention.start + 1 + mention.query.length;
112
+ // A directory keeps the list open on what is inside it; a file is done.
113
+ const written = `@${path}${path.endsWith("/") ? "" : " "}`;
114
+ const next = text.slice(0, mention.start) + written + text.slice(end);
115
+ const after = mention.start + written.length;
116
+ setText(next);
117
+ setCaret(after);
118
+ requestAnimationFrame(() => {
119
+ ref.current?.focus();
120
+ ref.current?.setSelectionRange(after, after);
121
+ });
122
+ };
123
+
124
+ return {
125
+ ref,
126
+ onChange: (event: ChangeEvent<Box>) => {
127
+ setText(event.target.value);
128
+ setCaret(event.target.selectionStart ?? event.target.value.length);
129
+ },
130
+ onSelect: track,
131
+ onClick: track,
132
+ /** Handles the keys the list takes while open; true when it took one. */
133
+ onKeyDown: (event: KeyboardEvent<Box>): boolean => {
134
+ if (!open) {
135
+ return false;
136
+ }
137
+ const move = { ArrowDown: 1, ArrowUp: -1 }[event.key];
138
+ if (move !== undefined) {
139
+ setActive((picked + move + matches.length) % matches.length);
140
+ } else if (event.key === "Enter" || event.key === "Tab") {
141
+ insert(matches[picked] ?? "");
142
+ } else if (event.key === "Escape") {
143
+ setDismissed(mention?.start ?? null);
144
+ } else {
145
+ return false;
146
+ }
147
+ event.preventDefault();
148
+ // Escape would otherwise close the dialog as well as the list.
149
+ event.stopPropagation();
150
+ return true;
151
+ },
152
+ menu: open ? (
153
+ <Menu
154
+ matches={matches}
155
+ active={picked}
156
+ place={place}
157
+ onPick={insert}
158
+ onHover={setActive}
159
+ />
160
+ ) : null,
161
+ };
162
+ }
163
+
164
+ export type Mentions = ReturnType<typeof useMentions<HTMLTextAreaElement>>;
165
+
166
+ function Menu({
167
+ matches,
168
+ active,
169
+ place,
170
+ onPick,
171
+ onHover,
172
+ }: {
173
+ matches: string[];
174
+ active: number;
175
+ place: Place;
176
+ onPick: (path: string) => void;
177
+ onHover: (index: number) => void;
178
+ }): JSX.Element {
179
+ return (
180
+ <div
181
+ role="listbox"
182
+ aria-label="files"
183
+ style={{ maxHeight: place.room }}
184
+ className={cn(
185
+ "absolute inset-x-0 z-50 overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
186
+ place.below ? "top-full mt-1" : "bottom-full mb-1",
187
+ )}
188
+ >
189
+ {matches.map((path, index) => {
190
+ const directory = path.endsWith("/");
191
+ const Icon = directory ? FolderIcon : FileIcon;
192
+ const [folder, name] = split(path);
193
+ return (
194
+ <div
195
+ key={path}
196
+ role="option"
197
+ tabIndex={-1}
198
+ aria-selected={index === active}
199
+ // Down, not click, so the box keeps its focus and caret.
200
+ onMouseDown={(event) => {
201
+ event.preventDefault();
202
+ onPick(path);
203
+ }}
204
+ onMouseEnter={() => onHover(index)}
205
+ // Kept in view as the arrow keys move past the room there is.
206
+ ref={(row) => {
207
+ if (index === active) {
208
+ row?.scrollIntoView({ block: "nearest" });
209
+ }
210
+ }}
211
+ style={{ height: ROW }}
212
+ className={cn(
213
+ "flex cursor-default items-center gap-2 rounded-md px-2 text-sm",
214
+ index === active && "bg-muted",
215
+ )}
216
+ >
217
+ <Icon className="size-4 shrink-0 text-muted-foreground" />
218
+ <span className="truncate">{name}</span>
219
+ {/* Gives way first, so the name keeps as much as it can. */}
220
+ <span className="shrink-1000 truncate text-muted-foreground text-xs">
221
+ {folder}
222
+ </span>
223
+ </div>
224
+ );
225
+ })}
226
+ </div>
227
+ );
228
+ }
229
+
230
+ /** The `@` the caret is in, if any: one at the start or after a space. */
231
+ export function typedAt(text: string, caret: number): Mention | null {
232
+ const found = /(^|\s)@([^\s@]*)$/.exec(text.slice(0, caret));
233
+ if (found === null) {
234
+ return null;
235
+ }
236
+ const query = found[2] ?? "";
237
+ return { start: caret - query.length - 1, query };
238
+ }
239
+
240
+ /**
241
+ * The paths that match what was typed, best first: a name that starts with it,
242
+ * then any that contain it, then the ones that hold its letters in order.
243
+ * Shallower before deeper, so a directory's own entries lead once it is picked,
244
+ * and directories before files.
245
+ */
246
+ export function rank(paths: string[], query: string): string[] {
247
+ const typed = query.toLowerCase();
248
+ const scored: { path: string; score: number; depth: number }[] = [];
249
+ for (const path of paths) {
250
+ const lower = path.toLowerCase();
251
+ // Already written out in full: nothing left to pick.
252
+ if (lower === typed) {
253
+ continue;
254
+ }
255
+ const [, name] = split(lower);
256
+ const score =
257
+ name.startsWith(typed.split("/").at(-1) ?? "") && lower.includes(typed)
258
+ ? 0
259
+ : lower.includes(typed)
260
+ ? 1
261
+ : inOrder(lower, typed)
262
+ ? 2
263
+ : null;
264
+ if (score !== null) {
265
+ scored.push({
266
+ path,
267
+ score,
268
+ depth: path.replace(/\/$/, "").split("/").length,
269
+ });
270
+ }
271
+ }
272
+ return scored
273
+ .sort(
274
+ (left, right) =>
275
+ left.score - right.score ||
276
+ left.depth - right.depth ||
277
+ // Directories first, the way a file browser lists them.
278
+ Number(!left.path.endsWith("/")) - Number(!right.path.endsWith("/")) ||
279
+ left.path.localeCompare(right.path),
280
+ )
281
+ .map(({ path }) => path);
282
+ }
283
+
284
+ /** A path as its folder and its own name, a directory's with its `/`. */
285
+ function split(path: string): [string, string] {
286
+ const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
287
+ const cut = trimmed.lastIndexOf("/");
288
+ return [
289
+ trimmed.slice(0, cut + 1),
290
+ trimmed.slice(cut + 1) + (path.endsWith("/") ? "/" : ""),
291
+ ];
292
+ }
293
+
294
+ function inOrder(text: string, letters: string): boolean {
295
+ let at = 0;
296
+ for (const letter of letters) {
297
+ at = text.indexOf(letter, at) + 1;
298
+ if (at === 0) {
299
+ return false;
300
+ }
301
+ }
302
+ return true;
303
+ }
304
+
305
+ /** Holds a text box and the list that floats over it. */
306
+ export function MentionAnchor({
307
+ mentions,
308
+ children,
309
+ }: {
310
+ mentions: { menu: JSX.Element | null };
311
+ children: JSX.Element;
312
+ }): JSX.Element {
313
+ return (
314
+ <div className="relative">
315
+ {children}
316
+ {mentions.menu}
317
+ </div>
318
+ );
319
+ }