bom-pi 0.0.0-stage → 0.0.2

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 (131) hide show
  1. package/README.md +33 -2
  2. package/apps/README.md +7 -0
  3. package/apps/web/README.md +21 -0
  4. package/apps/web/pocket-adapter.mjs +323 -0
  5. package/apps/web/public/LICENSE.pocket +21 -0
  6. package/apps/web/public/activity-fold.js +28 -0
  7. package/apps/web/public/agent-now.js +37 -0
  8. package/apps/web/public/agent-tile.js +79 -0
  9. package/apps/web/public/app.js +975 -0
  10. package/apps/web/public/apple-touch-icon.png +0 -0
  11. package/apps/web/public/artifact-bridge.js +687 -0
  12. package/apps/web/public/avatar.js +87 -0
  13. package/apps/web/public/back.js +554 -0
  14. package/apps/web/public/box-keys.js +216 -0
  15. package/apps/web/public/browser-panel.js +1097 -0
  16. package/apps/web/public/browser.js +68 -0
  17. package/apps/web/public/calls.js +139 -0
  18. package/apps/web/public/chat.js +919 -0
  19. package/apps/web/public/clock.js +34 -0
  20. package/apps/web/public/commands.js +578 -0
  21. package/apps/web/public/composer.js +2068 -0
  22. package/apps/web/public/diff-parse.js +672 -0
  23. package/apps/web/public/diff.js +1646 -0
  24. package/apps/web/public/dock.js +299 -0
  25. package/apps/web/public/extension-ui.js +2734 -0
  26. package/apps/web/public/files-panel.js +775 -0
  27. package/apps/web/public/files.js +368 -0
  28. package/apps/web/public/fonts/FiraCode-VF.woff2 +0 -0
  29. package/apps/web/public/fonts/Geist.woff2 +0 -0
  30. package/apps/web/public/fonts/IBMPlexMono-Variable-Italic.woff2 +0 -0
  31. package/apps/web/public/fonts/IBMPlexMono-Variable.woff2 +0 -0
  32. package/apps/web/public/fonts/InterVariable-Italic.woff2 +0 -0
  33. package/apps/web/public/fonts/InterVariable.woff2 +0 -0
  34. package/apps/web/public/fonts/Iosevka-Bold.woff2 +0 -0
  35. package/apps/web/public/fonts/Iosevka-BoldItalic.woff2 +0 -0
  36. package/apps/web/public/fonts/Iosevka-Italic.woff2 +0 -0
  37. package/apps/web/public/fonts/Iosevka-Regular.woff2 +0 -0
  38. package/apps/web/public/fonts/Iosevka-SemiBold.woff2 +0 -0
  39. package/apps/web/public/fonts/Iosevka-SemiBoldItalic.woff2 +0 -0
  40. package/apps/web/public/fonts/JetBrainsMono-Italic.woff2 +0 -0
  41. package/apps/web/public/fonts/JetBrainsMono.woff2 +0 -0
  42. package/apps/web/public/fonts/NerdFontsSymbolsMono.woff2 +0 -0
  43. package/apps/web/public/fonts/SOURCES.md +69 -0
  44. package/apps/web/public/gestures.js +610 -0
  45. package/apps/web/public/highlight.js +480 -0
  46. package/apps/web/public/history.js +70 -0
  47. package/apps/web/public/home.js +169 -0
  48. package/apps/web/public/icon-192.png +0 -0
  49. package/apps/web/public/icon-512.png +0 -0
  50. package/apps/web/public/icon-maskable-512.png +0 -0
  51. package/apps/web/public/icon.svg +16 -0
  52. package/apps/web/public/index.html +71 -0
  53. package/apps/web/public/launcher.js +1293 -0
  54. package/apps/web/public/linked-sessions.js +55 -0
  55. package/apps/web/public/live.js +39 -0
  56. package/apps/web/public/manifest.webmanifest +32 -0
  57. package/apps/web/public/model-refresh.js +73 -0
  58. package/apps/web/public/notify.js +251 -0
  59. package/apps/web/public/peeks.js +920 -0
  60. package/apps/web/public/pip-attachments.js +182 -0
  61. package/apps/web/public/pip-window.js +141 -0
  62. package/apps/web/public/pip.js +1217 -0
  63. package/apps/web/public/quote-reply.js +109 -0
  64. package/apps/web/public/quote-text.js +31 -0
  65. package/apps/web/public/rich.js +403 -0
  66. package/apps/web/public/run-frame.js +106 -0
  67. package/apps/web/public/safe-mode.js +71 -0
  68. package/apps/web/public/seen.js +169 -0
  69. package/apps/web/public/session-tree.js +117 -0
  70. package/apps/web/public/sessions.js +2695 -0
  71. package/apps/web/public/share.js +126 -0
  72. package/apps/web/public/sheets/appearance.js +820 -0
  73. package/apps/web/public/sheets/branch.js +353 -0
  74. package/apps/web/public/sheets/extensions.js +818 -0
  75. package/apps/web/public/sheets/file.js +318 -0
  76. package/apps/web/public/sheets/find.js +250 -0
  77. package/apps/web/public/sheets/folder.js +680 -0
  78. package/apps/web/public/sheets/gallery.js +2340 -0
  79. package/apps/web/public/sheets/hooks.js +350 -0
  80. package/apps/web/public/sheets/memory.js +531 -0
  81. package/apps/web/public/sheets/menu.js +580 -0
  82. package/apps/web/public/sheets/message.js +220 -0
  83. package/apps/web/public/sheets/model.js +412 -0
  84. package/apps/web/public/sheets/people.js +414 -0
  85. package/apps/web/public/sheets/pi-sessions.js +264 -0
  86. package/apps/web/public/sheets/providers.js +525 -0
  87. package/apps/web/public/sheets/running.js +130 -0
  88. package/apps/web/public/sheets/schedules.js +71 -0
  89. package/apps/web/public/sheets/session.js +249 -0
  90. package/apps/web/public/sheets/shortcuts.js +101 -0
  91. package/apps/web/public/sheets/spend.js +150 -0
  92. package/apps/web/public/sheets/text.js +53 -0
  93. package/apps/web/public/sheets/viewers.js +363 -0
  94. package/apps/web/public/sheets/worktree.js +50 -0
  95. package/apps/web/public/sheets.js +410 -0
  96. package/apps/web/public/signin.js +57 -0
  97. package/apps/web/public/slots.js +208 -0
  98. package/apps/web/public/store.js +1760 -0
  99. package/apps/web/public/style.css +18625 -0
  100. package/apps/web/public/subagents-board.js +879 -0
  101. package/apps/web/public/subagents.js +273 -0
  102. package/apps/web/public/sw.js +169 -0
  103. package/apps/web/public/taps.js +26 -0
  104. package/apps/web/public/theme-surface.d.ts +4 -0
  105. package/apps/web/public/theme-surface.js +266 -0
  106. package/apps/web/public/theme.js +845 -0
  107. package/apps/web/public/themes/lantern/colors.toml +25 -0
  108. package/apps/web/public/themes/lantern/direction.css +60 -0
  109. package/apps/web/public/themes/lantern/frame.css +254 -0
  110. package/apps/web/public/themes/lantern/theme.css +10 -0
  111. package/apps/web/public/themes/lantern/theme.json +7 -0
  112. package/apps/web/public/themes/pocket/theme.css +189 -0
  113. package/apps/web/public/themes/pocket/theme.json +6 -0
  114. package/apps/web/public/themes.js +556 -0
  115. package/apps/web/public/transcript.js +3348 -0
  116. package/apps/web/public/trust.js +295 -0
  117. package/apps/web/public/turns.js +507 -0
  118. package/apps/web/public/ui.js +1910 -0
  119. package/apps/web/public/voice.js +345 -0
  120. package/apps/web/server.mjs +774 -0
  121. package/package.json +43 -4
  122. package/packages/cli/src/bom-pi.mjs +452 -0
  123. package/packages/cli/templates/blank.factory.json +7 -0
  124. package/packages/cli/templates/dev.factory.json +11 -0
  125. package/packages/cli/templates/docs.factory.json +12 -0
  126. package/packages/cli/templates/ops.factory.json +14 -0
  127. package/packages/cli/templates/triage.factory.json +10 -0
  128. package/packages/core/src/daemon.mjs +2047 -0
  129. package/packages/core/src/limit-gate.mjs +192 -0
  130. package/packages/core/src/rpc-client.mjs +66 -0
  131. package/systemd/README.md +11 -0
@@ -0,0 +1,34 @@
1
+ // The server's clock as a tab knows it: how far it is ahead of this browser's, from the clocks hellos carry. No DOM
2
+ // here: `store.js` keeps the offset, and `seen.js` turns the server's times into this device's.
3
+
4
+ /** How far this browser's clock and its steady one may part during a request before it counts as changed. */
5
+ const DRIFT_MS = 1000;
6
+
7
+ /** A moment, on this browser's clock (`wall`) and on its steady one (`steady`, which no clock change moves). */
8
+ export const moment = () => ({ wall: Date.now(), steady: performance.now() });
9
+
10
+ /**
11
+ * The offset (the server's clock minus this browser's) after a hello whose clock says `now`, from the offset kept so far
12
+ * (undefined before the first). The server read `now` before this tab handled the hello (`handled`), and after this tab
13
+ * asked for it (`asked`), when the hello answers a request of its own: so the offset is at least `now - handled.wall`,
14
+ * and at most `now - asked.wall`. The kept offset moves only as far as those bounds need, and a first one starts between
15
+ * them. A hello handled late (a tab woken from the background, events queued while it slept) only lowers the first
16
+ * bound: it moves nothing, where taking `now - handled.wall` as the offset would move every time shown.
17
+ *
18
+ * The second bound holds only on one clock: when this browser's clock changed during the request, or its steady clock
19
+ * stopped while the device slept, the two clocks part, and the hello counts as one the tab did not ask for.
20
+ */
21
+ export function fitClock(kept, now, handled, asked) {
22
+ const least = now - handled.wall;
23
+ const steady =
24
+ asked !== undefined &&
25
+ Math.abs(handled.wall - asked.wall - (handled.steady - asked.steady)) <= DRIFT_MS;
26
+ // The first bound is sure; the second gives way to it if they cross.
27
+ const most = steady ? Math.max(least, now - asked.wall) : Infinity;
28
+
29
+ if (kept === undefined) {
30
+ return most === Infinity ? least : (least + most) / 2;
31
+ }
32
+
33
+ return Math.min(Math.max(kept, least), most);
34
+ }
@@ -0,0 +1,578 @@
1
+ // Slash commands in the message box ("/compact", "/model sonnet", …). They run here in the app and are never sent to Pi.
2
+ import { browserAvailable, openInBrowser, toggleBrowser } from "./browser.js";
3
+ import { filesAvailable, toggleFiles } from "./files-panel.js";
4
+ import { branchAvailable } from "./sheets/branch.js";
5
+ import { memoryAvailable, remember } from "./sheets/memory.js";
6
+ import { togglePeeks } from "./peeks.js";
7
+ import { askPip, openPip, pipAvailable } from "./pip.js";
8
+ import { actions, collab, navigate, notify, openSheet, scoped, store } from "./store.js";
9
+ import {
10
+ BASE_LOOK,
11
+ chooseLook,
12
+ chooseTheme,
13
+ folderPaletteIds,
14
+ lookIds,
15
+ paletteOf,
16
+ THEMES,
17
+ } from "./theme.js";
18
+ import { trustAvailable } from "./trust.js";
19
+ import { copyText, formatTokens, formatWhen, modelLabel, replyText, shortPath } from "./ui.js";
20
+
21
+ const agent = () => store.state.view.agent;
22
+ const isSession = () => store.state.view.conversation?.kind === "session";
23
+
24
+ /** Plan mode needs its extension, which the owner can turn off. */
25
+ export const planAvailable = () => store.state.server?.extensions?.includes("pocket-plan") === true;
26
+ /** Scheduled messages need theirs, and a session; so do goals. */
27
+ export const schedulesAvailable = () =>
28
+ isSession() && store.state.server?.extensions?.includes("pocket-schedules") === true;
29
+ const goalsAvailable = () =>
30
+ isSession() && store.state.server?.extensions?.includes("pocket-goals") === true;
31
+
32
+ /** Webhooks need the triggers extension; so does watching a PR, which is for sessions other than PiP. */
33
+ export const triggersOn = () =>
34
+ store.state.server?.extensions?.includes("pocket-triggers") === true;
35
+ export const watchAvailable = () =>
36
+ isSession() && !store.state.view.conversation?.pip && triggersOn();
37
+
38
+ /** Watch this session's pull request (its branch's, or `pr`), and say what that means. */
39
+ export async function watchPr(pr) {
40
+ const { number } = await actions.watchPr(pr);
41
+
42
+ notify(
43
+ "info",
44
+ `Watching PR #${number}: Pi hears when its checks fail, it has conflicts, or reviewers comment, until it is merged.`,
45
+ );
46
+ }
47
+
48
+ /** How long a list of prompt templates is used before it is fetched again: someone may be writing one. */
49
+ const TEMPLATES_FOR_MS = 30_000;
50
+
51
+ /**
52
+ * Fetch Pi's prompt templates for this conversation, unless a fresh list is here. Call when someone types a command. A
53
+ * list is for a folder too: the session moved (`/cwd`, a worktree) has another project's templates and skills.
54
+ */
55
+ export function loadTemplates() {
56
+ const id = store.state.conversationId;
57
+ const cwd = store.state.view.agent?.cwd;
58
+ const cached = store.state.templates;
59
+ const same = cached?.conversationId === id && cached?.cwd === cwd;
60
+
61
+ if (id === null || (same && Date.now() - cached.at < TEMPLATES_FOR_MS)) {
62
+ return;
63
+ }
64
+
65
+ store.set({
66
+ templates: { conversationId: id, cwd, at: Date.now(), list: same ? cached.list : [] },
67
+ });
68
+ actions.prompts().then(
69
+ (list) =>
70
+ store.state.templates?.conversationId === id &&
71
+ store.state.templates?.cwd === cwd &&
72
+ store.set({ templates: { ...store.state.templates, list } }),
73
+ () => {},
74
+ );
75
+ }
76
+
77
+ /** The prompt templates and skills (`skill:name`) known for this conversation, as entries like the app's commands. */
78
+ function templates() {
79
+ const cached = store.state.templates;
80
+
81
+ if (
82
+ cached?.conversationId !== store.state.conversationId ||
83
+ cached.cwd !== store.state.view.agent?.cwd
84
+ ) {
85
+ return [];
86
+ }
87
+
88
+ return cached.list.map((template) => ({
89
+ name: template.name,
90
+ args: template.argumentHint ?? "",
91
+ description: template.description,
92
+ template: true,
93
+ skill: template.skill === true,
94
+ }));
95
+ }
96
+
97
+ /** A path as the conversation means it: absolute, `~/…`, or relative to its working directory. */
98
+ function pathFrom(arg) {
99
+ if (arg.startsWith("/") || arg.startsWith("~")) {
100
+ return arg;
101
+ }
102
+
103
+ const cwd = agent()?.cwd ?? store.state.server?.defaultCwd ?? "~";
104
+
105
+ return `${cwd.replace(/\/$/, "")}/${arg}`;
106
+ }
107
+
108
+ /** Models matching what someone typed: exact ids or names first, then every word anywhere in the name. */
109
+ function findModels(query) {
110
+ const needle = query.toLowerCase();
111
+ const { models } = store.state;
112
+ const exact = models.filter((model) =>
113
+ [`${model.provider}/${model.id}`, model.id, model.name].some(
114
+ (each) => each.toLowerCase() === needle,
115
+ ),
116
+ );
117
+
118
+ if (exact.length > 0) {
119
+ return exact;
120
+ }
121
+
122
+ const words = needle.split(/\s+/);
123
+
124
+ return models.filter((model) => {
125
+ const haystack = `${model.provider}/${model.id} ${model.name}`.toLowerCase();
126
+
127
+ return words.every((word) => haystack.includes(word));
128
+ });
129
+ }
130
+
131
+ async function switchModel(arg) {
132
+ if (arg === "") {
133
+ return openSheet({ type: "model" });
134
+ }
135
+
136
+ let found = findModels(arg);
137
+
138
+ // The same model from several providers: keep the current provider.
139
+ if (found.length > 1 && found.every((model) => model.id === found[0].id)) {
140
+ const current = found.find((model) => model.provider === agent()?.model?.provider);
141
+
142
+ if (current) {
143
+ found = [current];
144
+ }
145
+ }
146
+
147
+ if (found.length !== 1) {
148
+ if (found.length === 0) {
149
+ notify("info", `No model matches “${arg}”.`);
150
+ }
151
+
152
+ return openSheet({ type: "model", query: found.length === 0 ? "" : arg });
153
+ }
154
+
155
+ await actions.configure({ model: { provider: found[0].provider, modelId: found[0].id } });
156
+ }
157
+
158
+ async function setThinking(arg) {
159
+ if (arg === "") {
160
+ return openSheet({ type: "model" });
161
+ }
162
+
163
+ const levels = agent()?.levels ?? ["off"];
164
+ const level = arg.toLowerCase();
165
+
166
+ if (!levels.includes(level)) {
167
+ throw new Error(
168
+ levels.length > 1
169
+ ? `Thinking can be ${levels.join(", ")}.`
170
+ : `${modelLabel(agent())} does not think.`,
171
+ );
172
+ }
173
+
174
+ await actions.configure({ thinkingLevel: level });
175
+ }
176
+
177
+ async function newSession(arg) {
178
+ const cwd = arg === "" ? agent()?.cwd : pathFrom(arg);
179
+ const created = await actions.createSession(cwd);
180
+
181
+ navigate(created.id);
182
+ }
183
+
184
+ async function copyLast() {
185
+ const { view } = store.state;
186
+
187
+ for (let index = view.order.length - 1; index >= 0; index--) {
188
+ const entry = view.entries.get(view.order[index]);
189
+
190
+ if (entry?.kind !== "assistant") {
191
+ continue;
192
+ }
193
+
194
+ const text = replyText(entry).trim();
195
+
196
+ if (text === "") {
197
+ continue;
198
+ }
199
+
200
+ await copyText(text);
201
+
202
+ return notify("info", "Copied Pi’s last reply.");
203
+ }
204
+
205
+ notify("info", "Pi has not replied yet.");
206
+ }
207
+
208
+ function showSession() {
209
+ const { view, server } = store.state;
210
+ const stats = view.stats ?? {};
211
+ const window = view.agent?.contextWindow;
212
+ const level =
213
+ view.agent?.reasoning && view.agent.thinkingLevel !== "off"
214
+ ? ` (${view.agent.thinkingLevel})`
215
+ : "";
216
+ const parts = [`${modelLabel(view.agent)}${level}`];
217
+
218
+ if (window) {
219
+ parts.push(
220
+ `context ${Math.round(((stats.contextTokens ?? 0) / window) * 100)}% of ${formatTokens(window)}`,
221
+ );
222
+ }
223
+
224
+ parts.push(`$${(stats.cost ?? 0).toFixed(2)}`, shortPath(view.agent?.cwd, server?.home));
225
+ notify("info", parts.join(" · "));
226
+ }
227
+
228
+ /**
229
+ * `args` in brackets is optional. `available` hides a command where it does not apply. Keep the names the same as
230
+ * `BUILT_IN_COMMANDS` in `src/server/extension-web.ts`, which keeps them from extensions' commands.
231
+ */
232
+ const COMMANDS = [
233
+ {
234
+ name: "compact",
235
+ args: "[what to keep]",
236
+ description: "Summarize older messages to free up context",
237
+ run: (arg) => actions.compact(arg || undefined),
238
+ },
239
+ {
240
+ name: "reset",
241
+ args: "[handoff note]",
242
+ description: "Start a new context: Pi starts fresh, history stays",
243
+ run: (arg) => actions.reset(arg || undefined),
244
+ },
245
+ {
246
+ name: "instructions",
247
+ args: "[text]",
248
+ description: "Set what Pi is told with every message here",
249
+ available: isSession,
250
+ run: (arg) =>
251
+ arg === "" ? openSheet({ type: "instructions" }) : actions.setInstructions(arg),
252
+ },
253
+ { name: "model", args: "[name]", description: "Switch the model", run: switchModel },
254
+ { name: "thinking", args: "[level]", description: "Set the thinking level", run: setThinking },
255
+ {
256
+ name: "new",
257
+ args: "[folder]",
258
+ description: "Start a new session in this folder",
259
+ available: () => !scoped(),
260
+ run: newSession,
261
+ },
262
+ {
263
+ name: "name",
264
+ args: "[title]",
265
+ description: "Rename this session",
266
+ available: isSession,
267
+ run: (arg) =>
268
+ arg === ""
269
+ ? openSheet({ type: "rename" })
270
+ : actions.updateSession(store.state.view.conversation.id, { title: arg }),
271
+ },
272
+ {
273
+ name: "cwd",
274
+ args: "[folder]",
275
+ description: "Change the working directory",
276
+ run: (arg) =>
277
+ arg === ""
278
+ ? openSheet({ type: "cwd", mode: "change" })
279
+ : actions.configure({ cwd: pathFrom(arg) }),
280
+ },
281
+ {
282
+ name: "plan",
283
+ description: "Plan mode on or off: Pi reads and proposes, and changes nothing",
284
+ available: () => planAvailable() && isSession(),
285
+ run: async () => {
286
+ const on = !store.state.view.plan?.on;
287
+
288
+ await actions.setPlan(on);
289
+ notify(
290
+ "info",
291
+ on
292
+ ? "Plan mode is on: Pi proposes a plan and changes nothing until you approve it."
293
+ : "Plan mode is off.",
294
+ );
295
+ },
296
+ },
297
+ {
298
+ name: "schedule",
299
+ args: "<when> <message>",
300
+ description:
301
+ "Send Pi a message later or on repeat: in 2h …, tomorrow 9:00 …, every weekday 8:00 …",
302
+ available: schedulesAvailable,
303
+ run: async (arg) => {
304
+ if (arg === "") {
305
+ return openSheet({ type: "schedules" });
306
+ }
307
+
308
+ const added = await actions.schedule(arg);
309
+
310
+ notify("info", `Scheduled for ${formatWhen(added.next)}.`);
311
+ },
312
+ },
313
+ {
314
+ name: "remember",
315
+ args: "<lesson>",
316
+ description: "Keep a lesson for Pi in every session of this project",
317
+ available: () => memoryAvailable() && store.state.me?.role !== "viewer",
318
+ run: async (arg) => {
319
+ if (arg === "") {
320
+ return openSheet({ type: "memory" });
321
+ }
322
+
323
+ const lesson = await remember(arg);
324
+
325
+ notify(
326
+ "info",
327
+ lesson.pending
328
+ ? "Remembered. Pi uses it once the owner approves it."
329
+ : "Remembered. Pi gets it from its next message.",
330
+ );
331
+ },
332
+ },
333
+ {
334
+ name: "until",
335
+ args: "<command>",
336
+ description: "Pi keeps going until a check passes, such as npm test (5 checks at most)",
337
+ available: goalsAvailable,
338
+ run: async (arg) => {
339
+ if (arg === "") {
340
+ throw new Error("Say which command has to pass, such as /until npm test.");
341
+ }
342
+
343
+ await actions.setGoal(arg);
344
+ notify("info", `Pi keeps going until ${arg} passes, checked after each answer.`);
345
+ },
346
+ },
347
+ {
348
+ name: "watch",
349
+ args: "[PR number or URL]",
350
+ description:
351
+ "Watch this session's pull request: Pi fixes failed checks and answers reviews until it is merged",
352
+ available: watchAvailable,
353
+ run: (arg) => watchPr(arg),
354
+ },
355
+ { name: "stop", description: "Stop the current run", run: () => actions.abort() },
356
+ { name: "copy", description: "Copy Pi’s last reply", run: copyLast },
357
+ {
358
+ name: "find",
359
+ args: "[text]",
360
+ description: "Find in this session’s messages",
361
+ run: (arg) => openSheet({ type: "find", query: arg }),
362
+ },
363
+ { name: "session", description: "Show the model, context use, and cost", run: showSession },
364
+ {
365
+ name: "export",
366
+ description: "Download this conversation as Markdown",
367
+ run: () => location.assign(`/api/c/${store.state.view.conversation.id}/export`),
368
+ },
369
+ {
370
+ name: "resume",
371
+ description: "Switch to another session",
372
+ run: () => store.set({ drawer: true }),
373
+ },
374
+ {
375
+ name: "chat",
376
+ description: "Open the people chat",
377
+ available: collab,
378
+ run: () => openSheet({ type: "chat" }),
379
+ },
380
+ {
381
+ name: "browser",
382
+ args: "[address]",
383
+ description: "Open the browser Pi uses, or an address in it, such as localhost:5173",
384
+ available: browserAvailable,
385
+ run: (arg) => (arg === "" ? toggleBrowser() : openInBrowser(arg)),
386
+ },
387
+ {
388
+ name: "files",
389
+ description: "Show or hide the Files tile: the folder's files, and what changed",
390
+ available: filesAvailable,
391
+ run: () => toggleFiles("files"),
392
+ },
393
+ {
394
+ name: "changes",
395
+ description: "Review the folder's uncommitted changes, file by file",
396
+ available: filesAvailable,
397
+ run: () => toggleFiles("changes"),
398
+ },
399
+ {
400
+ name: "branch",
401
+ args: "[name]",
402
+ description: "Switch the folder to another git branch, or make one",
403
+ available: branchAvailable,
404
+ run: (arg) => openSheet({ type: "branch", query: arg }),
405
+ },
406
+ {
407
+ name: "pip",
408
+ args: "[message]",
409
+ description: "Open PiP, your assistant over every session, or ask it something",
410
+ available: pipAvailable,
411
+ run: (arg) => (arg === "" ? openPip() : askPip(arg)),
412
+ },
413
+ {
414
+ name: "peek",
415
+ description: "Show or hide peek tiles: other sessions' live work beside this one",
416
+ available: () => store.state.server?.peeks === true,
417
+ run: () => togglePeeks(),
418
+ },
419
+ {
420
+ name: "artifacts",
421
+ description: "Show artifacts",
422
+ run: () => openSheet({ type: "artifacts" }),
423
+ },
424
+ {
425
+ name: "login",
426
+ description: "Sign in to a model provider",
427
+ run: () => openSheet({ type: "providers" }),
428
+ },
429
+ { name: "settings", description: "Open the menu", run: () => openSheet({ type: "menu" }) },
430
+ {
431
+ name: "trust",
432
+ description: "Whether Pi trusts this project, for its own skills",
433
+ available: trustAvailable,
434
+ run: () => openSheet({ type: "trust" }),
435
+ },
436
+ {
437
+ name: "theme",
438
+ args: "[name]",
439
+ description:
440
+ "Change the theme or the colors: a theme, an Omarchy palette, or desktop to follow yours",
441
+ run: setTheme,
442
+ },
443
+ ];
444
+
445
+ /**
446
+ * `/theme lantern` (a theme folder), `/theme gruvbox`, `/theme rose`, `/theme desktop` (colors); without a name, the
447
+ * Appearance sheet. A theme's name wins over colors' of the same name.
448
+ */
449
+ function setTheme(arg) {
450
+ if (arg === "") {
451
+ return openSheet({ type: "appearance" });
452
+ }
453
+
454
+ const needle = arg.toLowerCase().replace(/\s+/g, "-");
455
+ const words = arg.toLowerCase();
456
+ // Themes that work: one broken, which Appearance shows greyed out, would leave the app on Pocket.
457
+ const looks = lookIds().flatMap((id) => {
458
+ const listed = store.state.themeList?.find((each) => each.id === id);
459
+
460
+ return id === BASE_LOOK || listed?.usable
461
+ ? [{ id, name: id === BASE_LOOK ? "Pocket" : (listed?.name ?? id) }]
462
+ : [];
463
+ });
464
+ // The desktop's colors only where the server has a desktop: elsewhere "desktop" would show Tokyo Night.
465
+ const desktop = paletteOf("desktop").desktop === true;
466
+ const palettes = [
467
+ ...(desktop ? ["desktop"] : []),
468
+ ...Object.keys(THEMES),
469
+ ...folderPaletteIds(),
470
+ ].map((id) => ({
471
+ id,
472
+ name: id === "desktop" ? "desktop" : paletteOf(id).name,
473
+ }));
474
+ const find = (list) =>
475
+ list.find((each) => each.id === needle) ??
476
+ list.find(
477
+ (each) => each.id.startsWith(needle) || each.name.toLowerCase().startsWith(words),
478
+ );
479
+ const look = find(looks);
480
+
481
+ if (look) {
482
+ chooseLook(look.id);
483
+ notify("info", `Theme: ${look.name}.`);
484
+
485
+ return;
486
+ }
487
+
488
+ const palette = find(palettes);
489
+
490
+ if (!palette) {
491
+ const some = [
492
+ "pocket",
493
+ ...Object.keys(THEMES).slice(0, 3),
494
+ ...(desktop ? ["desktop"] : []),
495
+ ];
496
+
497
+ throw new Error(
498
+ `No theme or colors called “${arg}”. Try ${some.slice(0, -1).join(", ")}, or ${some.at(-1)}.`,
499
+ );
500
+ }
501
+
502
+ chooseTheme(palette.id);
503
+ notify("info", `Colors: ${palette.id === "desktop" ? "follow your desktop" : palette.name}.`);
504
+ }
505
+
506
+ /** Extensions' commands this person may run (`hello` lists them). A prompt template of the same name keeps it. */
507
+ function extensionCommands() {
508
+ const prompts = new Set(templates().map((template) => template.name));
509
+
510
+ return (store.state.server?.extensionCommands ?? [])
511
+ .filter((command) => !prompts.has(command.name))
512
+ .map((command) => ({
513
+ ...command,
514
+ extension: true,
515
+ run: async (args, requestId) => {
516
+ const result = await actions.extensionCommand(command.id, args, requestId);
517
+
518
+ if (result?.type === "toast") {
519
+ notify(result.level, result.message);
520
+ }
521
+ },
522
+ }));
523
+ }
524
+
525
+ const available = () => [
526
+ ...COMMANDS.filter((command) => command.available?.() ?? true),
527
+ ...extensionCommands(),
528
+ ];
529
+
530
+ /** The command a message runs, or null for a message to Pi (such as one starting with a path like /etc/hosts). */
531
+ export function parseCommand(text) {
532
+ const match = /^\/([a-z][\w-]*)(?:\s+([\s\S]*))?$/i.exec(text.trim());
533
+
534
+ if (!match) {
535
+ return null;
536
+ }
537
+
538
+ const command = available().find((each) => each.name === match[1].toLowerCase());
539
+
540
+ return command ? { command, arg: (match[2] ?? "").trim() } : null;
541
+ }
542
+
543
+ /** The prompt template a message to Pi starts with, or null. The app's own commands come first. */
544
+ export function parseTemplate(text) {
545
+ const match = /^\/(\S+)(?:\s|$)/.exec(text.trim());
546
+
547
+ if (!match || parseCommand(text)) {
548
+ return null;
549
+ }
550
+
551
+ return templates().find((template) => template.name === match[1]) ?? null;
552
+ }
553
+
554
+ /** Commands to offer while someone types a command name: "/" lists them all, then Pi's prompt templates and skills. */
555
+ export function suggestCommands(text) {
556
+ const match = /^\/([\w:-]*)$/.exec(text);
557
+
558
+ if (!match) {
559
+ return [];
560
+ }
561
+
562
+ const prefix = match[1].toLowerCase();
563
+ const own = available();
564
+ const commands = [
565
+ ...own,
566
+ ...templates().filter((template) => !own.some((command) => command.name === template.name)),
567
+ ];
568
+ const starts = commands.filter((each) => each.name.startsWith(prefix));
569
+ // Two letters or more also find names that contain them: "/py" finds /copy.
570
+ const contains =
571
+ prefix.length < 2
572
+ ? []
573
+ : commands.filter(
574
+ (each) => !each.name.startsWith(prefix) && each.name.includes(prefix),
575
+ );
576
+
577
+ return [...starts, ...contains];
578
+ }