char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somdeslop: prose pass over ai-generated ui text10b0cd2

main
5.6 KiB170 linesraw
1import type { Signal } from "@char/aftercare";
2import { type ProjectIndex, workspacePath } from "@berry/api/projects.ts";
3import { api } from "./client.ts";
4import type { Scope } from "./ui.ts";
5
6export type FolderOptions = {
7  scope: Scope;
8  environment: Signal<string>;
9  base: () => string | null;
10  projects?: Signal<ProjectIndex>;
11};
12
13type Suggestion = { path: string; project?: string; platform?: string };
14
15let nextList = 0;
16
17export function folderInput({ scope, environment, base, projects }: FolderOptions) {
18  const id = `folder-options-${++nextList}`;
19  const input = (
20    <input
21      id={`${id}-input`}
22      type="text"
23      role="combobox"
24      aria-autocomplete="list"
25      aria-expanded="false"
26      aria-controls={id}
27      autocomplete="off"
28      spellcheck={false}
29      placeholder="/path/to/my-directory/"
30    />
31  ) as HTMLInputElement;
32  const list = <div id={id} role="listbox" class="folder-options" /> as HTMLElement;
33  const status = <p class="folder-status" role="status" /> as HTMLElement;
34  const menu = <div class="folder-menu" hidden>{list}{status}</div> as HTMLElement;
35  const element = <div class="folder-input">{input}{menu}</div> as HTMLElement;
36  let suggestions: Suggestion[] = [];
37  // the last answer for this machine, which stands in, narrowed, while the next is fetched
38  let found: string[] = [];
39  let selected = -1;
40  let revision = 0;
41  let timer: ReturnType<typeof setTimeout> | undefined;
42  let request: AbortController | undefined;
43  let focused = false;
44
45  const draw = () => {
46    list.replaceChildren(...suggestions.map((suggestion, index) => (
47      <button
48        type="button"
49        role="option"
50        tabIndex={-1}
51        id={`${id}-${index}`}
52        aria-selected={String(index === selected)}
53        title={suggestion.path}
54        _onmousedown={(event) => event.preventDefault()}
55        _onclick={() => choose(suggestion.path)}
56      >
57        <span class="folder-path">{suggestion.path}</span>
58        {suggestion.project ? <span class="folder-project">{suggestion.project}</span> : []}
59      </button>
60    )));
61    menu.hidden = suggestions.length === 0 && !status.textContent;
62    input.setAttribute("aria-expanded", String(suggestions.length > 0));
63    if (selected < 0) input.removeAttribute("aria-activedescendant");
64    else input.setAttribute("aria-activedescendant", `${id}-${selected}`);
65  };
66  const cancel = () => {
67    revision++;
68    clearTimeout(timer);
69    request?.abort();
70  };
71  const close = () => {
72    cancel();
73    suggestions = [];
74    selected = -1;
75    status.textContent = "";
76    draw();
77  };
78  const choose = (path: string) => {
79    input.value = path;
80    input.dispatchEvent(new Event("input", { bubbles: true }));
81    close();
82  };
83  const refresh = () => {
84    cancel();
85    if (!focused) return close();
86    const version = revision;
87    const machine = environment.get();
88    const prefix = input.value;
89    const saved = Object.values(projects?.get() ?? {}).flatMap((project) =>
90      project.roots.filter((root) =>
91        root.environment === machine &&
92        (root.platform === "win32"
93          ? root.cwd.toLowerCase().includes(prefix.toLowerCase())
94          : root.cwd.includes(prefix))
95      ).map((root) => ({ path: root.cwd, project: project.name, platform: root.platform }))
96    ).slice(0, 10);
97    const merge = (paths: string[]) => [
98      ...saved,
99      ...paths.filter((path) =>
100        !saved.some((item) =>
101          workspacePath(item.path, item.platform) === workspacePath(path, item.platform)
102        )
103      ).map((path) => ({ path })),
104    ];
105    suggestions = merge(found.filter((path) => path.startsWith(prefix)));
106    selected = -1;
107    status.textContent = suggestions.length ? "" : "finding folders…";
108    draw();
109    timer = setTimeout(async () => {
110      request = new AbortController();
111      const query = new URLSearchParams({ environment: machine, prefix });
112      const cwd = base();
113      if (cwd) query.set("cwd", cwd);
114      try {
115        const paths = await api<string[]>(
116          "GET",
117          `/api/folders?${query}`,
118          undefined,
119          request.signal,
120        );
121        if (version !== revision) return;
122        found = paths;
123        suggestions = merge(paths);
124        status.textContent = suggestions.length ? "" : "no matching folders";
125        draw();
126      } catch (error) {
127        if (version !== revision) return;
128        status.textContent = (error as Error).message;
129        draw();
130      }
131    }, 120);
132  };
133  input.addEventListener("focus", () => {
134    focused = true;
135    refresh();
136  });
137  input.addEventListener("input", refresh);
138  input.addEventListener("blur", () => {
139    focused = false;
140    close();
141  });
142  input.addEventListener("keydown", (event) => {
143    if (event.key === "Escape" && !menu.hidden) {
144      event.preventDefault();
145      event.stopImmediatePropagation();
146      close();
147    } else if ((event.key === "ArrowDown" || event.key === "ArrowUp") && suggestions.length) {
148      event.preventDefault();
149      selected = selected < 0
150        ? event.key === "ArrowDown" ? 0 : suggestions.length - 1
151        : (selected + (event.key === "ArrowDown" ? 1 : -1) + suggestions.length) %
152          suggestions.length;
153      draw();
154      list.children[selected]?.scrollIntoView?.({ block: "nearest" });
155    } else if (event.key === "Enter" && selected >= 0) {
156      event.preventDefault();
157      event.stopImmediatePropagation();
158      choose(suggestions[selected].path);
159    }
160  });
161  scope.add(
162    environment.subscribe(() => {
163      found = [];
164      refresh();
165    }).unsubscribe,
166  );
167  if (projects) scope.add(projects.subscribe(refresh).unsubscribe);
168  scope.add(close);
169  return { input, element };
170}