codebay 0.0.1 → 0.2.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.
Files changed (202) hide show
  1. package/.mochi/import-css/index-0nwv3654.css +18 -0
  2. package/.mochi/import-css/index-xndx4980.css +54 -0
  3. package/.mochi/manifest.json +1608 -0
  4. package/.mochi/server-island.js +1 -0
  5. package/.mochi/svelte-client/HydratableIsland-ps09gjy2.js +1 -0
  6. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-62w5zwww.js +2 -0
  7. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-jmm501zf.js +13 -0
  8. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-ewk4n2c0.js +1 -0
  9. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-e7e89md0.js +23 -0
  10. package/.mochi/svelte-client/chunk-jx5cekhh.js +2 -0
  11. package/.mochi/svelte-client/chunk-k3my1snp.js +2 -0
  12. package/.mochi/svelte-client/chunk-kgn1b2t1.js +4 -0
  13. package/.mochi/svelte-client/chunk-ry7q5zk5.js +1 -0
  14. package/.mochi/svelte-client/chunk-x843txjc.js +2 -0
  15. package/.mochi/svelte-client/chunk-zjcg8kw5.js +2 -0
  16. package/.mochi/svelte-compile/App-8k1gyxdc.server.js +1167 -0
  17. package/.mochi/svelte-compile/Avatars-f7c6rp96.server.js +351 -0
  18. package/.mochi/svelte-compile/ClientStats-z3tn9ad6.server.js +99 -0
  19. package/.mochi/svelte-compile/DefaultError-wh77vebf.server.js +52 -0
  20. package/.mochi/svelte-compile/Instance-yvps12zg.server.js +355 -0
  21. package/.mochi/svelte-compile/Settings-jwg3httb.server.js +1169 -0
  22. package/.mochi/svelte-compile/chunk-4kwy539a.js +14 -0
  23. package/.mochi/svelte-compile/chunk-9bjk7yx6.js +81 -0
  24. package/.mochi/svelte-compile/chunk-dqgstte4.js +52 -0
  25. package/.mochi/svelte-compile/chunk-sxqys3nw.js +1668 -0
  26. package/.mochi/svelte-compile/chunk-v6796ga4.js +3295 -0
  27. package/.mochi/svelte-compile/chunk-we0ggtws.js +34 -0
  28. package/.mochi/svelte-css/AppBar-3bxinv4pabv8l.css +1 -0
  29. package/.mochi/svelte-css/AppShell-36mpg5smnbmuw.css +1 -0
  30. package/.mochi/svelte-css/Avatar-24th63awm9mna.css +1 -0
  31. package/.mochi/svelte-css/AvatarEditor-1ryrs1md5g36y.css +1 -0
  32. package/.mochi/svelte-css/AvatarsView-3hq37u4dgtljn.css +1 -0
  33. package/.mochi/svelte-css/BackLink-2wlu0eovz9j86.css +1 -0
  34. package/.mochi/svelte-css/BranchBox-1yqu1q2mgnjwz.css +1 -0
  35. package/.mochi/svelte-css/Brand-1la7zw5uhcbl4.css +1 -0
  36. package/.mochi/svelte-css/Button-2vtbrzb5kgu25.css +1 -0
  37. package/.mochi/svelte-css/CheckmarkIcon-3qlazj2oc50jc.css +1 -0
  38. package/.mochi/svelte-css/ClientStats-19qzckq40p2q9.css +1 -0
  39. package/.mochi/svelte-css/CoinButton-1rej3sh3tkjud.css +1 -0
  40. package/.mochi/svelte-css/CredMenu-9hwgeptp7crb.css +1 -0
  41. package/.mochi/svelte-css/DashboardView-1cy4xpqhja9sx.css +1 -0
  42. package/.mochi/svelte-css/DefaultError-2b0daamydxgb2.css +1 -0
  43. package/.mochi/svelte-css/ErrorIcon-2x0tzsqxskw28.css +1 -0
  44. package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +1 -0
  45. package/.mochi/svelte-css/HealthBox-er2her0jukwl.css +1 -0
  46. package/.mochi/svelte-css/IdeBar-2qoqi2ya9zdn3.css +1 -0
  47. package/.mochi/svelte-css/IdeLoader-26rlko8e00j3g.css +1 -0
  48. package/.mochi/svelte-css/InstanceCard-16f77aysm7lgp.css +1 -0
  49. package/.mochi/svelte-css/InstanceView-wq85bxtusont.css +1 -0
  50. package/.mochi/svelte-css/LoaderIcon-soq5wt3p05ye.css +1 -0
  51. package/.mochi/svelte-css/PortsBox-1t25b5yg7jva3.css +1 -0
  52. package/.mochi/svelte-css/SettingsCog-jftyadvo64qo.css +1 -0
  53. package/.mochi/svelte-css/SettingsView-3bbkzsvnrth3v.css +1 -0
  54. package/.mochi/svelte-css/Skeleton-8k69usiju43h.css +1 -0
  55. package/.mochi/svelte-css/StatusBadge-3mq0j7z6aq4j.css +1 -0
  56. package/.mochi/svelte-css/ThemePicker-1ir8jyocm8mvi.css +1 -0
  57. package/.mochi/svelte-css/ToastBar-3havgkmtofntq.css +1 -0
  58. package/.mochi/svelte-css/ToastIcon-2z5wrkc95khg0.css +1 -0
  59. package/.mochi/svelte-css/ToastMessage-2jfwb2og2lfa8.css +1 -0
  60. package/.mochi/svelte-css/ToastWrapper-1fbnym10rm2nv.css +1 -0
  61. package/.mochi/svelte-css/Toaster-kk60j193854n.css +1 -0
  62. package/.mochi/svelte-css/TopBar-2q87li6yt8ids.css +1 -0
  63. package/LICENSE +21 -0
  64. package/README.md +33 -3
  65. package/bin/codebay.ts +22 -0
  66. package/migrations/0001_init.sql +20 -0
  67. package/migrations/0002_port_forwards.sql +9 -0
  68. package/migrations/0003_options.sql +4 -0
  69. package/migrations/0004_instance_image.sql +1 -0
  70. package/package.json +87 -9
  71. package/public/sounds/done.wav +0 -0
  72. package/public/sounds/waiting.wav +0 -0
  73. package/src/__fixtures__/HelloWorld.svelte +4 -0
  74. package/src/additional-svelte-typings.d.ts +1 -0
  75. package/src/api.ts +61 -0
  76. package/src/avatars/anchor.ts +17 -0
  77. package/src/avatars/bear.ts +17 -0
  78. package/src/avatars/bee.ts +17 -0
  79. package/src/avatars/bob-marley.ts +17 -0
  80. package/src/avatars/car.ts +17 -0
  81. package/src/avatars/carpathians.ts +17 -0
  82. package/src/avatars/cat-body.ts +17 -0
  83. package/src/avatars/cat-sits.ts +17 -0
  84. package/src/avatars/cat.ts +17 -0
  85. package/src/avatars/celtic-cross.ts +17 -0
  86. package/src/avatars/cherry.ts +17 -0
  87. package/src/avatars/cotton.ts +17 -0
  88. package/src/avatars/crab.ts +17 -0
  89. package/src/avatars/crown.ts +17 -0
  90. package/src/avatars/diamond.ts +17 -0
  91. package/src/avatars/dog.ts +17 -0
  92. package/src/avatars/elephant.ts +17 -0
  93. package/src/avatars/fish.ts +17 -0
  94. package/src/avatars/fleur-de-lis.ts +17 -0
  95. package/src/avatars/flower.ts +17 -0
  96. package/src/avatars/fox.ts +17 -0
  97. package/src/avatars/frog.ts +17 -0
  98. package/src/avatars/ghost.ts +17 -0
  99. package/src/avatars/giraffe.ts +17 -0
  100. package/src/avatars/glasses.ts +17 -0
  101. package/src/avatars/heart.ts +17 -0
  102. package/src/avatars/index.ts +128 -0
  103. package/src/avatars/invader.ts +17 -0
  104. package/src/avatars/kangaroo.ts +17 -0
  105. package/src/avatars/key.ts +17 -0
  106. package/src/avatars/lightning.ts +17 -0
  107. package/src/avatars/mushroom.ts +17 -0
  108. package/src/avatars/octopus.ts +17 -0
  109. package/src/avatars/owl.ts +17 -0
  110. package/src/avatars/penguin.ts +17 -0
  111. package/src/avatars/planet.ts +17 -0
  112. package/src/avatars/rabbit.ts +17 -0
  113. package/src/avatars/rat.ts +17 -0
  114. package/src/avatars/robot.ts +17 -0
  115. package/src/avatars/rocket.ts +17 -0
  116. package/src/avatars/sea.ts +17 -0
  117. package/src/avatars/serialize.ts +65 -0
  118. package/src/avatars/skull.ts +17 -0
  119. package/src/avatars/snail.ts +17 -0
  120. package/src/avatars/snake.ts +17 -0
  121. package/src/avatars/snakegame.ts +17 -0
  122. package/src/avatars/star.ts +17 -0
  123. package/src/avatars/svelte.ts +17 -0
  124. package/src/avatars/target.ts +17 -0
  125. package/src/avatars/tree.ts +17 -0
  126. package/src/avatars/types.ts +24 -0
  127. package/src/avatars/ukraine.ts +17 -0
  128. package/src/avatars/whale.ts +17 -0
  129. package/src/avatars/wheat.ts +17 -0
  130. package/src/avatars/yinyan.ts +17 -0
  131. package/src/components/AppBar.svelte +35 -0
  132. package/src/components/AppShell.svelte +302 -0
  133. package/src/components/Avatar.svelte +235 -0
  134. package/src/components/AvatarEditor.svelte +321 -0
  135. package/src/components/AvatarsView.svelte +113 -0
  136. package/src/components/BranchBox.svelte +32 -0
  137. package/src/components/Brand.svelte +41 -0
  138. package/src/components/Button.svelte +161 -0
  139. package/src/components/CoinButton.svelte +121 -0
  140. package/src/components/CredMenu.svelte +171 -0
  141. package/src/components/DashboardView.svelte +198 -0
  142. package/src/components/FolderBrowser.svelte +635 -0
  143. package/src/components/HealthBox.svelte +161 -0
  144. package/src/components/IdeBar.svelte +186 -0
  145. package/src/components/IdeLoader.svelte +135 -0
  146. package/src/components/InstanceCard.svelte +220 -0
  147. package/src/components/InstanceView.svelte +565 -0
  148. package/src/components/PortsBox.svelte +73 -0
  149. package/src/components/SettingsCog.svelte +22 -0
  150. package/src/components/SettingsView.svelte +1484 -0
  151. package/src/components/Skeleton.svelte +43 -0
  152. package/src/components/StatusBadge.svelte +54 -0
  153. package/src/components/ThemePicker.svelte +77 -0
  154. package/src/components/TopBar.svelte +64 -0
  155. package/src/components/UiShowcase.svelte +578 -0
  156. package/src/components/ui-showcase/ComponentDemo.svelte +85 -0
  157. package/src/container-injections/attention-hooks.ts +117 -0
  158. package/src/container-injections/claude-aliases.ts +43 -0
  159. package/src/container-injections/claude-code-credentials.ts +145 -0
  160. package/src/container-injections/claude-code-custom.ts +125 -0
  161. package/src/container-injections/claude-no-coauthor.ts +35 -0
  162. package/src/container-injections/claude-skip-permissions.ts +38 -0
  163. package/src/container-injections/claude-statusline.ts +109 -0
  164. package/src/container-injections/git-identity.ts +63 -0
  165. package/src/container-injections/git-safe-directory.ts +18 -0
  166. package/src/container-injections/github-credentials.ts +162 -0
  167. package/src/container-injections/host-env-vars.ts +108 -0
  168. package/src/container-injections/tmux.ts +67 -0
  169. package/src/fontsource.d.ts +3 -0
  170. package/src/index.ts +81 -0
  171. package/src/lib/auth.server.ts +95 -0
  172. package/src/lib/bridge.server.ts +34 -0
  173. package/src/lib/config.server.ts +122 -0
  174. package/src/lib/crypto.server.ts +12 -0
  175. package/src/lib/db.server.ts +196 -0
  176. package/src/lib/devcontainer.server.ts +503 -0
  177. package/src/lib/docker-client.server.ts +61 -0
  178. package/src/lib/docker.server.ts +139 -0
  179. package/src/lib/exec.server.ts +92 -0
  180. package/src/lib/git.server.ts +68 -0
  181. package/src/lib/health.server.ts +105 -0
  182. package/src/lib/injections.server.ts +72 -0
  183. package/src/lib/instances.server.ts +552 -0
  184. package/src/lib/picker.server.ts +46 -0
  185. package/src/lib/ports.server.ts +9 -0
  186. package/src/lib/proxy.server.ts +170 -0
  187. package/src/lib/repo-url.ts +60 -0
  188. package/src/lib/spawn.server.ts +14 -0
  189. package/src/lib/theme.server.ts +22 -0
  190. package/src/live.ts +50 -0
  191. package/src/pages/App.svelte +14 -0
  192. package/src/pages/Avatars.svelte +7 -0
  193. package/src/pages/Instance.svelte +9 -0
  194. package/src/pages/Settings.svelte +73 -0
  195. package/src/pages/UI.svelte +10 -0
  196. package/src/routes.ts +440 -0
  197. package/src/settings.ts +14 -0
  198. package/src/shell.html +100 -0
  199. package/src/sound.ts +69 -0
  200. package/src/theme.ts +24 -0
  201. package/src/types.ts +79 -0
  202. package/svelte.config.js +5 -0
@@ -0,0 +1,1484 @@
1
+ <script lang="ts">
2
+ import Container from '@lucide/svelte/icons/container';
3
+ import AppBar from './AppBar.svelte';
4
+ import Power from '@lucide/svelte/icons/power';
5
+ import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
6
+ import Volume2 from '@lucide/svelte/icons/volume-2';
7
+ import SunMoon from '@lucide/svelte/icons/sun-moon';
8
+ import ThemePicker from './ThemePicker.svelte';
9
+ import Layers from '@lucide/svelte/icons/layers';
10
+ import FolderMinus from '@lucide/svelte/icons/folder-minus';
11
+ import Trash2 from '@lucide/svelte/icons/trash-2';
12
+ import Hammer from '@lucide/svelte/icons/hammer';
13
+ import KeyRound from '@lucide/svelte/icons/key-round';
14
+ import Variable from '@lucide/svelte/icons/variable';
15
+ import Plus from '@lucide/svelte/icons/plus';
16
+ import X from '@lucide/svelte/icons/x';
17
+ import { Toaster } from 'svelte-french-toast';
18
+ import { flushSync } from 'svelte';
19
+ import { enhance } from 'mochi-framework';
20
+ import type { MochiEnhanceOptions } from 'mochi-framework';
21
+ import { soundEnabled, setSoundEnabled } from '../settings.ts';
22
+ import { playChime, unlockAudio } from '../sound.ts';
23
+ import Button from './Button.svelte';
24
+ import CoinButton from './CoinButton.svelte';
25
+
26
+ /** Every settings form action fails with the same `{ error }` shape. */
27
+ type ActionFailure = { error: string };
28
+
29
+ let {
30
+ defaultImage,
31
+ builtinImage,
32
+ disableBuildCache,
33
+ copyIgnorePatterns,
34
+ builtinCopyIgnore,
35
+ dockerArch,
36
+ manualTokensEnabled,
37
+ githubTokenSet,
38
+ claudeTokenSet,
39
+ customEndpointEnabled,
40
+ customEndpointBaseUrl,
41
+ customEndpointTokenSet,
42
+ customEndpointOpusModel,
43
+ customEndpointSonnetModel,
44
+ customEndpointHaikuModel,
45
+ customEndpointSmallFastModel,
46
+ customEndpointModel,
47
+ hostEnvVarsEnabled,
48
+ hostEnvVarNames,
49
+ hostEnvVarPresence
50
+ }: {
51
+ defaultImage: string;
52
+ builtinImage: string;
53
+ disableBuildCache: boolean;
54
+ copyIgnorePatterns: string;
55
+ builtinCopyIgnore: string;
56
+ dockerArch: string | null;
57
+ manualTokensEnabled: boolean;
58
+ githubTokenSet: boolean;
59
+ claudeTokenSet: boolean;
60
+ customEndpointEnabled: boolean;
61
+ customEndpointBaseUrl: string;
62
+ customEndpointTokenSet: boolean;
63
+ customEndpointOpusModel: string;
64
+ customEndpointSonnetModel: string;
65
+ customEndpointHaikuModel: string;
66
+ customEndpointSmallFastModel: string;
67
+ customEndpointModel: string;
68
+ hostEnvVarsEnabled: boolean;
69
+ hostEnvVarNames: string[];
70
+ hostEnvVarPresence: Record<string, boolean>;
71
+ } = $props();
72
+
73
+ // Defaults to on during SSR, where localStorage doesn't exist.
74
+ let sound = $state(soundEnabled());
75
+
76
+ let shuttingDown = $state(false);
77
+
78
+ /** Reused by every plain save form below; only the per-control state setters differ. */
79
+ function saveOpts<Success extends Record<string, unknown> = Record<string, unknown>>(handlers: {
80
+ setSaving: (v: boolean) => void;
81
+ setError: (v: string | null) => void;
82
+ setMsg?: (v: string | null) => void;
83
+ onSuccess: (data: Success | undefined) => void;
84
+ confirmMessage?: string;
85
+ }): MochiEnhanceOptions<Success, ActionFailure> {
86
+ return {
87
+ onPending: handlers.setSaving,
88
+ submit: ({ cancel }) => {
89
+ if (handlers.confirmMessage && !confirm(handlers.confirmMessage)) {
90
+ cancel();
91
+ return;
92
+ }
93
+ handlers.setError(null);
94
+ handlers.setMsg?.(null);
95
+ return ({ result }) => {
96
+ if (result.type === 'success') {
97
+ handlers.onSuccess(result.data);
98
+ } else if (result.type === 'failure') {
99
+ handlers.setError(result.data?.error ?? 'Request failed');
100
+ } else if (result.type === 'error') {
101
+ handlers.setError('Network error. Try again.');
102
+ }
103
+ };
104
+ }
105
+ };
106
+ }
107
+
108
+ /**
109
+ * The optimistic flip is free: `onchange` fires before submit, so `formData`
110
+ * already carries the new value. Failure reverts to the opposite of what was sent.
111
+ */
112
+ function toggleOpts<Success extends Record<string, unknown> = Record<string, unknown>>(handlers: {
113
+ set: (v: boolean) => void;
114
+ setSaving: (v: boolean) => void;
115
+ setError: (v: string | null) => void;
116
+ onSuccess?: (data: Success | undefined) => void;
117
+ }): MochiEnhanceOptions<Success, ActionFailure> {
118
+ return {
119
+ onPending: handlers.setSaving,
120
+ submit: ({ formData }) => {
121
+ const intended = formData.get('enabled') === 'on';
122
+ handlers.setError(null);
123
+ return ({ result }) => {
124
+ if (result.type === 'success') {
125
+ handlers.onSuccess?.(result.data);
126
+ return;
127
+ }
128
+ handlers.set(!intended);
129
+ handlers.setError(
130
+ result.type === 'failure'
131
+ ? (result.data?.error ?? 'Request failed')
132
+ : 'Network error. Try again.'
133
+ );
134
+ };
135
+ }
136
+ };
137
+ }
138
+
139
+ // svelte-ignore state_referenced_locally
140
+ let image = $state(defaultImage);
141
+ let savingImage = $state(false);
142
+ let imageError = $state<string | null>(null);
143
+ let imageSaved = $state(false);
144
+ let imageFormEl: HTMLFormElement | undefined;
145
+
146
+ const imageOpts = saveOpts<{ image: string }>({
147
+ setSaving: (v) => (savingImage = v),
148
+ setError: (v) => (imageError = v),
149
+ setMsg: (v) => (imageSaved = !!v),
150
+ onSuccess: (data) => {
151
+ image = data?.image ?? image;
152
+ imageSaved = true;
153
+ }
154
+ });
155
+
156
+ // Setting the bound value then resubmitting keeps this a one-form control.
157
+ function resetImage() {
158
+ // flushSync forces the bind:value DOM update to happen before requestSubmit
159
+ // reads the input's value via FormData — otherwise it would submit the stale value.
160
+ flushSync(() => (image = builtinImage));
161
+ imageFormEl?.requestSubmit();
162
+ }
163
+
164
+ // Unlike the image field, an empty value is valid here — it means "copy everything".
165
+ // svelte-ignore state_referenced_locally
166
+ let copyIgnore = $state(copyIgnorePatterns);
167
+ let savingCopyIgnore = $state(false);
168
+ let copyIgnoreError = $state<string | null>(null);
169
+ let copyIgnoreSaved = $state(false);
170
+ let copyIgnoreFormEl: HTMLFormElement | undefined;
171
+
172
+ const copyIgnoreOpts = saveOpts<{ patterns: string }>({
173
+ setSaving: (v) => (savingCopyIgnore = v),
174
+ setError: (v) => (copyIgnoreError = v),
175
+ setMsg: (v) => (copyIgnoreSaved = !!v),
176
+ onSuccess: (data) => {
177
+ copyIgnore = data?.patterns ?? copyIgnore;
178
+ copyIgnoreSaved = true;
179
+ }
180
+ });
181
+
182
+ function resetCopyIgnore() {
183
+ flushSync(() => (copyIgnore = builtinCopyIgnore));
184
+ copyIgnoreFormEl?.requestSubmit();
185
+ }
186
+
187
+ // DB-backed rather than localStorage, so it initializes from the prop.
188
+ // svelte-ignore state_referenced_locally
189
+ let noCache = $state(disableBuildCache);
190
+ let savingCache = $state(false);
191
+ let cacheError = $state<string | null>(null);
192
+
193
+ const buildCacheToggleOpts = toggleOpts({
194
+ set: (v) => (noCache = v),
195
+ setSaving: (v) => (savingCache = v),
196
+ setError: (v) => (cacheError = v)
197
+ });
198
+
199
+ let clearing = $state(false);
200
+ let clearMsg = $state<string | null>(null);
201
+ let clearError = $state<string | null>(null);
202
+
203
+ function formatBytes(n: number): string {
204
+ if (n <= 0) return '0 B';
205
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
206
+ const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
207
+ return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
208
+ }
209
+
210
+ const clearCacheOpts = saveOpts<{ spaceReclaimed: number }>({
211
+ setSaving: (v) => (clearing = v),
212
+ setError: (v) => (clearError = v),
213
+ setMsg: (v) => (clearMsg = v),
214
+ onSuccess: (data) => {
215
+ clearMsg = `Cleared — freed ${formatBytes(data?.spaceReclaimed ?? 0)}.`;
216
+ }
217
+ });
218
+
219
+ let rebuilding = $state(false);
220
+ let rebuildMsg = $state<string | null>(null);
221
+ let rebuildError = $state<string | null>(null);
222
+
223
+ const rebuildAllOpts = saveOpts<{ count: number }>({
224
+ setSaving: (v) => (rebuilding = v),
225
+ setError: (v) => (rebuildError = v),
226
+ setMsg: (v) => (rebuildMsg = v),
227
+ confirmMessage:
228
+ 'Rebuild every running container from scratch (no build cache)? Each will restart and may take a while.',
229
+ onSuccess: (data) => {
230
+ const n = data?.count ?? 0;
231
+ rebuildMsg = n === 0 ? 'No running containers to rebuild.' : `Rebuilding ${n} container(s)…`;
232
+ }
233
+ });
234
+
235
+ // Token values never reach the client; only whether each is set, for the placeholder.
236
+ // svelte-ignore state_referenced_locally
237
+ let manualTokens = $state(manualTokensEnabled);
238
+ let savingManualToggle = $state(false);
239
+ let manualToggleError = $state<string | null>(null);
240
+
241
+ const manualTokensToggleOpts = toggleOpts({
242
+ set: (v) => (manualTokens = v),
243
+ setSaving: (v) => (savingManualToggle = v),
244
+ setError: (v) => (manualToggleError = v)
245
+ });
246
+
247
+ // svelte-ignore state_referenced_locally
248
+ let ghSaved = $state(githubTokenSet);
249
+ let githubToken = $state('');
250
+ let savingGithub = $state(false);
251
+ let githubMsg = $state<string | null>(null);
252
+ let githubError = $state<string | null>(null);
253
+
254
+ const githubTokenOpts = saveOpts<{ set: boolean }>({
255
+ setSaving: (v) => (savingGithub = v),
256
+ setError: (v) => (githubError = v),
257
+ setMsg: (v) => (githubMsg = v),
258
+ onSuccess: (data) => {
259
+ ghSaved = data?.set ?? false;
260
+ githubToken = '';
261
+ githubMsg = ghSaved ? 'Saved.' : 'Cleared.';
262
+ }
263
+ });
264
+
265
+ // svelte-ignore state_referenced_locally
266
+ let claudeSaved = $state(claudeTokenSet);
267
+ let claudeToken = $state('');
268
+ let savingClaude = $state(false);
269
+ let claudeMsg = $state<string | null>(null);
270
+ let claudeError = $state<string | null>(null);
271
+
272
+ const claudeTokenOpts = saveOpts<{ set: boolean }>({
273
+ setSaving: (v) => (savingClaude = v),
274
+ setError: (v) => (claudeError = v),
275
+ setMsg: (v) => (claudeMsg = v),
276
+ onSuccess: (data) => {
277
+ claudeSaved = data?.set ?? false;
278
+ claudeToken = '';
279
+ claudeMsg = claudeSaved ? 'Saved.' : 'Cleared.';
280
+ }
281
+ });
282
+
283
+ // As with manual tokens, the token value never comes back from the server.
284
+ // svelte-ignore state_referenced_locally
285
+ let customEndpoint = $state(customEndpointEnabled);
286
+ let savingCustomToggle = $state(false);
287
+ let customToggleError = $state<string | null>(null);
288
+
289
+ const customEndpointToggleOpts = toggleOpts({
290
+ set: (v) => (customEndpoint = v),
291
+ setSaving: (v) => (savingCustomToggle = v),
292
+ setError: (v) => (customToggleError = v)
293
+ });
294
+
295
+ // svelte-ignore state_referenced_locally
296
+ let customBaseUrl = $state(customEndpointBaseUrl);
297
+ let savingCustomBaseUrl = $state(false);
298
+ let customBaseUrlMsg = $state<string | null>(null);
299
+ let customBaseUrlError = $state<string | null>(null);
300
+
301
+ const customBaseUrlOpts = saveOpts<{ set: boolean }>({
302
+ setSaving: (v) => (savingCustomBaseUrl = v),
303
+ setError: (v) => (customBaseUrlError = v),
304
+ setMsg: (v) => (customBaseUrlMsg = v),
305
+ onSuccess: (data) => {
306
+ customBaseUrlMsg = data?.set ? 'Saved.' : 'Cleared.';
307
+ }
308
+ });
309
+
310
+ let customToken = $state('');
311
+ // svelte-ignore state_referenced_locally
312
+ let customTokenSaved = $state(customEndpointTokenSet);
313
+ let savingCustomToken = $state(false);
314
+ let customTokenMsg = $state<string | null>(null);
315
+ let customTokenError = $state<string | null>(null);
316
+
317
+ const customTokenOpts = saveOpts<{ set: boolean }>({
318
+ setSaving: (v) => (savingCustomToken = v),
319
+ setError: (v) => (customTokenError = v),
320
+ setMsg: (v) => (customTokenMsg = v),
321
+ onSuccess: (data) => {
322
+ customTokenSaved = data?.set ?? false;
323
+ customToken = '';
324
+ customTokenMsg = customTokenSaved ? 'Saved.' : 'Cleared.';
325
+ }
326
+ });
327
+
328
+ // svelte-ignore state_referenced_locally
329
+ let customOpusModel = $state(customEndpointOpusModel);
330
+ // svelte-ignore state_referenced_locally
331
+ let customSonnetModel = $state(customEndpointSonnetModel);
332
+ // svelte-ignore state_referenced_locally
333
+ let customHaikuModel = $state(customEndpointHaikuModel);
334
+ // svelte-ignore state_referenced_locally
335
+ let customSmallFastModel = $state(customEndpointSmallFastModel);
336
+ // svelte-ignore state_referenced_locally
337
+ let customDefaultModel = $state(customEndpointModel);
338
+ let savingCustomModels = $state(false);
339
+ let customModelsMsg = $state<string | null>(null);
340
+ let customModelsError = $state<string | null>(null);
341
+
342
+ const customModelsOpts = saveOpts({
343
+ setSaving: (v) => (savingCustomModels = v),
344
+ setError: (v) => (customModelsError = v),
345
+ setMsg: (v) => (customModelsMsg = v),
346
+ onSuccess: () => {
347
+ customModelsMsg = 'Saved.';
348
+ }
349
+ });
350
+
351
+ // Only names round-trip; `hostEnvPresence` is refreshed from each save response so a
352
+ // newly-added name doesn't read as "missing" until the next full page load.
353
+ // svelte-ignore state_referenced_locally
354
+ let hostEnvVars = $state(hostEnvVarsEnabled);
355
+ let savingHostEnvToggle = $state(false);
356
+ let hostEnvToggleError = $state<string | null>(null);
357
+
358
+ const hostEnvVarsToggleOpts = toggleOpts<{ presence: Record<string, boolean> }>({
359
+ set: (v) => (hostEnvVars = v),
360
+ setSaving: (v) => (savingHostEnvToggle = v),
361
+ setError: (v) => (hostEnvToggleError = v),
362
+ onSuccess: (data) => {
363
+ hostEnvPresence = data?.presence ?? {};
364
+ }
365
+ });
366
+
367
+ // svelte-ignore state_referenced_locally
368
+ let hostEnvNames = $state([...hostEnvVarNames]);
369
+ // svelte-ignore state_referenced_locally
370
+ let hostEnvPresence = $state({ ...hostEnvVarPresence });
371
+ let newHostEnvName = $state('');
372
+ let savingHostEnvNames = $state(false);
373
+ let hostEnvNamesMsg = $state<string | null>(null);
374
+ let hostEnvNamesError = $state<string | null>(null);
375
+
376
+ /**
377
+ * Add form: submits every existing name (as hidden inputs) plus the free-text
378
+ * input, all under the repeated `names` field — the last entry is always the
379
+ * typed value. Validated and normalized (uppercased, deduped) here, in the
380
+ * submit callback, by mutating `formData` directly before the fetch fires —
381
+ * mirrors the old client-side pre-check without a second code path.
382
+ */
383
+ const addHostEnvOpts: MochiEnhanceOptions<{ presence: Record<string, boolean> }, ActionFailure> =
384
+ {
385
+ onPending: (v) => (savingHostEnvNames = v),
386
+ submit: ({ formData, cancel }) => {
387
+ hostEnvNamesError = null;
388
+ hostEnvNamesMsg = null;
389
+ const existing = formData.getAll('names').map(String);
390
+ const typed = existing.pop() ?? '';
391
+ const name = typed.trim().toUpperCase();
392
+ if (!name) {
393
+ cancel();
394
+ return;
395
+ }
396
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
397
+ hostEnvNamesError = `Invalid variable name: ${name}`;
398
+ cancel();
399
+ return;
400
+ }
401
+ newHostEnvName = '';
402
+ if (existing.includes(name)) {
403
+ cancel();
404
+ return;
405
+ }
406
+ const names = [...existing, name];
407
+ formData.delete('names');
408
+ for (const n of names) formData.append('names', n);
409
+ return ({ result }) => {
410
+ if (result.type === 'success') {
411
+ hostEnvNames = names;
412
+ hostEnvPresence = result.data?.presence ?? {};
413
+ hostEnvNamesMsg = 'Saved.';
414
+ } else if (result.type === 'failure') {
415
+ hostEnvNamesError = result.data?.error ?? 'Request failed';
416
+ } else if (result.type === 'error') {
417
+ hostEnvNamesError = 'Network error. Try again.';
418
+ }
419
+ };
420
+ }
421
+ };
422
+
423
+ /** Remove form (one per row): submits every name except this one. */
424
+ function removeHostEnvOpts(name: string) {
425
+ return saveOpts<{ presence: Record<string, boolean> }>({
426
+ setSaving: (v) => (savingHostEnvNames = v),
427
+ setError: (v) => (hostEnvNamesError = v),
428
+ setMsg: (v) => (hostEnvNamesMsg = v),
429
+ onSuccess: (data) => {
430
+ hostEnvNames = hostEnvNames.filter((n) => n !== name);
431
+ hostEnvPresence = data?.presence ?? {};
432
+ }
433
+ });
434
+ }
435
+
436
+ function toggleSound(on: boolean) {
437
+ sound = on;
438
+ setSoundEnabled(on);
439
+ // A toggle is a user gesture — unlock audio and preview when enabling.
440
+ unlockAudio();
441
+ if (on) playChime('done');
442
+ }
443
+
444
+ // The server exits mid-flight, so a dropped connection means success here too.
445
+ const shutdownOpts: MochiEnhanceOptions<Record<string, never>, ActionFailure> = {
446
+ submit: ({ cancel }) => {
447
+ if (
448
+ !confirm(
449
+ 'Delete the database, remove all instances and their containers, and shut down the server? This cannot be undone.'
450
+ )
451
+ ) {
452
+ cancel();
453
+ return;
454
+ }
455
+ shuttingDown = true;
456
+ }
457
+ };
458
+ </script>
459
+
460
+ <div class="page">
461
+ <AppBar>
462
+ <span class="title">Settings</span>
463
+ </AppBar>
464
+
465
+ <main class="content">
466
+ <section class="card">
467
+ <form
468
+ class="row image-row"
469
+ method="POST"
470
+ action="?/defaultImage"
471
+ bind:this={imageFormEl}
472
+ {@attach enhance(imageOpts)}
473
+ >
474
+ <div class="label">
475
+ <Container size={18} />
476
+ <div class="text">
477
+ <div class="name">
478
+ Default container image
479
+ {#if dockerArch}
480
+ <span class="arch" title="Docker daemon architecture">{dockerArch}</span>
481
+ {/if}
482
+ </div>
483
+ <div class="desc">
484
+ Used only when a project folder ships no devcontainer.json. Takes effect for instances
485
+ created from now on.
486
+ </div>
487
+ </div>
488
+ </div>
489
+ <div class="image-controls">
490
+ <input
491
+ type="text"
492
+ name="image"
493
+ class="image-input"
494
+ bind:value={image}
495
+ spellcheck="false"
496
+ autocapitalize="off"
497
+ autocorrect="off"
498
+ placeholder="mcr.microsoft.com/devcontainers/base:ubuntu"
499
+ />
500
+ <Button type="submit" disabled={savingImage}>Save</Button>
501
+ <Button
502
+ type="button"
503
+ icon={RotateCcw}
504
+ disabled={savingImage}
505
+ onclick={resetImage}
506
+ title="Reset to default ({builtinImage})"
507
+ aria-label="Reset to default image"
508
+ />
509
+ </div>
510
+ <div class="desc tip">
511
+ {#if dockerArch}
512
+ Your Docker daemon runs on <strong>{dockerArch}</strong> — pick an image that publishes
513
+ an <strong>{dockerArch}</strong> manifest, or the pull will fail.
514
+ {:else}
515
+ Pick an image whose manifest covers your Docker daemon's architecture, or the pull will
516
+ fail.
517
+ {/if}
518
+ </div>
519
+ {#if imageError}
520
+ <div class="msg error">{imageError}</div>
521
+ {:else if imageSaved}
522
+ <div class="msg ok">Saved.</div>
523
+ {/if}
524
+ </form>
525
+ </section>
526
+
527
+ <section class="card">
528
+ <form
529
+ class="row image-row"
530
+ method="POST"
531
+ action="?/copyIgnorePatterns"
532
+ bind:this={copyIgnoreFormEl}
533
+ {@attach enhance(copyIgnoreOpts)}
534
+ >
535
+ <div class="label">
536
+ <FolderMinus size={18} />
537
+ <div class="text">
538
+ <div class="name">Skip when copying a local folder</div>
539
+ <div class="desc">
540
+ Comma-separated folder/file names excluded when copying a local source folder into a
541
+ new instance's workspace. Leave blank to copy everything. Only applies to local
542
+ folders — cloning a Git repo URL is unaffected.
543
+ </div>
544
+ </div>
545
+ </div>
546
+ <div class="image-controls">
547
+ <input
548
+ type="text"
549
+ name="patterns"
550
+ class="image-input"
551
+ bind:value={copyIgnore}
552
+ spellcheck="false"
553
+ autocapitalize="off"
554
+ autocorrect="off"
555
+ placeholder="node_modules, .venv, dist"
556
+ />
557
+ <Button type="submit" disabled={savingCopyIgnore}>Save</Button>
558
+ <Button
559
+ type="button"
560
+ icon={RotateCcw}
561
+ disabled={savingCopyIgnore}
562
+ onclick={resetCopyIgnore}
563
+ title="Reset to default ({builtinCopyIgnore})"
564
+ aria-label="Reset to default copy-ignore patterns"
565
+ />
566
+ </div>
567
+ {#if copyIgnoreError}
568
+ <div class="msg error">{copyIgnoreError}</div>
569
+ {:else if copyIgnoreSaved}
570
+ <div class="msg ok">Saved.</div>
571
+ {/if}
572
+ </form>
573
+ </section>
574
+
575
+ <section class="card">
576
+ <form
577
+ class="row"
578
+ method="POST"
579
+ action="?/disableBuildCache"
580
+ {@attach enhance(buildCacheToggleOpts)}
581
+ >
582
+ <div class="label">
583
+ <Layers size={18} />
584
+ <div class="text">
585
+ <div class="name">Disable build cache</div>
586
+ <div class="desc">
587
+ Build every new container with <code>--build-no-cache</code>. Applies to first boot
588
+ and rebuilds — slower, but always picks up upstream image/layer changes.
589
+ </div>
590
+ </div>
591
+ </div>
592
+ <label class="switch">
593
+ <input
594
+ type="checkbox"
595
+ name="enabled"
596
+ checked={noCache}
597
+ disabled={savingCache}
598
+ onchange={(e) => {
599
+ noCache = e.currentTarget.checked;
600
+ e.currentTarget.form?.requestSubmit();
601
+ }}
602
+ />
603
+ <span class="track"><span class="thumb"></span></span>
604
+ </label>
605
+ </form>
606
+ {#if cacheError}
607
+ <div class="sub"><div class="msg error">{cacheError}</div></div>
608
+ {/if}
609
+
610
+ <form
611
+ class="row divided"
612
+ method="POST"
613
+ action="?/clearBuildCache"
614
+ {@attach enhance(clearCacheOpts)}
615
+ >
616
+ <div class="label">
617
+ <Trash2 size={18} />
618
+ <div class="text">
619
+ <div class="name">Clear build cache</div>
620
+ <div class="desc">
621
+ Purge Docker's BuildKit layer cache now, so the next build runs uncached. Doesn't
622
+ remove pulled images.
623
+ </div>
624
+ {#if clearError}
625
+ <div class="msg error">{clearError}</div>
626
+ {:else if clearMsg}
627
+ <div class="msg ok">{clearMsg}</div>
628
+ {/if}
629
+ </div>
630
+ </div>
631
+ <Button type="submit" icon={Trash2} disabled={clearing}>
632
+ {clearing ? 'Clearing…' : 'Clear cache'}
633
+ </Button>
634
+ </form>
635
+
636
+ <form
637
+ class="row divided"
638
+ method="POST"
639
+ action="?/rebuildAllNoCache"
640
+ {@attach enhance(rebuildAllOpts)}
641
+ >
642
+ <div class="label">
643
+ <Hammer size={18} />
644
+ <div class="text">
645
+ <div class="name">Rebuild running containers (no cache)</div>
646
+ <div class="desc">
647
+ Re-run <code>devcontainer up --build-no-cache</code> for every currently-running instance.
648
+ In-container edits are kept; stopped instances are left alone.
649
+ </div>
650
+ {#if rebuildError}
651
+ <div class="msg error">{rebuildError}</div>
652
+ {:else if rebuildMsg}
653
+ <div class="msg ok">{rebuildMsg}</div>
654
+ {/if}
655
+ </div>
656
+ </div>
657
+ <Button type="submit" icon={Hammer} disabled={rebuilding}>
658
+ {rebuilding ? 'Starting…' : 'Rebuild all'}
659
+ </Button>
660
+ </form>
661
+ </section>
662
+
663
+ <section class="card" class:disabled-card={customEndpoint}>
664
+ <form
665
+ class="row"
666
+ method="POST"
667
+ action="?/manualTokensToggle"
668
+ {@attach enhance(manualTokensToggleOpts)}
669
+ >
670
+ <div class="label">
671
+ <KeyRound size={18} />
672
+ <div class="text">
673
+ <div class="name">
674
+ Set tokens manually
675
+ {#if customEndpoint}
676
+ <span class="arch" title="Disabled while LiteLLM + Bedrock mode is on"
677
+ >disabled</span
678
+ >
679
+ {/if}
680
+ </div>
681
+ <div class="desc">
682
+ {#if customEndpoint}
683
+ Not available while LiteLLM + Bedrock mode is enabled — Claude credentials are
684
+ provided by the LiteLLM endpoint instead.
685
+ {:else}
686
+ Provide GitHub and Claude Code tokens yourself instead of discovering them from this
687
+ machine. Useful on a headless server or when signed in as a different identity. A
688
+ token set here is injected into every new container and overrides host credential
689
+ discovery.
690
+ {/if}
691
+ </div>
692
+ </div>
693
+ </div>
694
+ <label class="switch">
695
+ <input
696
+ type="checkbox"
697
+ name="enabled"
698
+ checked={manualTokens}
699
+ disabled={savingManualToggle || customEndpoint}
700
+ onchange={(e) => {
701
+ manualTokens = e.currentTarget.checked;
702
+ e.currentTarget.form?.requestSubmit();
703
+ }}
704
+ />
705
+ <span class="track"><span class="thumb"></span></span>
706
+ </label>
707
+ </form>
708
+ {#if manualToggleError}
709
+ <div class="sub"><div class="msg error">{manualToggleError}</div></div>
710
+ {/if}
711
+
712
+ {#if manualTokens && !customEndpoint}
713
+ <form
714
+ class="row divided token-row"
715
+ method="POST"
716
+ action="?/githubToken"
717
+ {@attach enhance(githubTokenOpts)}
718
+ >
719
+ <div class="label">
720
+ <div class="text">
721
+ <div class="name">GitHub token</div>
722
+ <div class="desc">
723
+ macOS / Linux: run <code>gh auth token</code> to print your GitHub CLI token, or
724
+ create a Personal Access Token at
725
+ <code>github.com/settings/tokens</code> (scopes: <code>repo</code>,
726
+ <code>read:org</code>). Leave blank and Save to clear.
727
+ </div>
728
+ </div>
729
+ </div>
730
+ <div class="image-controls">
731
+ <input
732
+ type="password"
733
+ name="githubToken"
734
+ class="image-input"
735
+ bind:value={githubToken}
736
+ spellcheck="false"
737
+ autocapitalize="off"
738
+ autocorrect="off"
739
+ autocomplete="off"
740
+ placeholder={ghSaved ? '•••••••• (saved)' : 'ghp_… / gho_…'}
741
+ />
742
+ <Button type="submit" disabled={savingGithub}>Save</Button>
743
+ </div>
744
+ {#if githubError}
745
+ <div class="msg error">{githubError}</div>
746
+ {:else if githubMsg}
747
+ <div class="msg ok">{githubMsg}</div>
748
+ {/if}
749
+ </form>
750
+
751
+ <form
752
+ class="row divided token-row"
753
+ method="POST"
754
+ action="?/claudeToken"
755
+ {@attach enhance(claudeTokenOpts)}
756
+ >
757
+ <div class="label">
758
+ <div class="text">
759
+ <div class="name">Claude Code token</div>
760
+ <div class="desc">
761
+ macOS / Linux: run <code>claude setup-token</code> to mint a long-lived token and
762
+ paste it here. Copying a live login out of the keychain or
763
+ <code>~/.claude/.credentials.json</code> works too, but that credential rotates —
764
+ the first <code>claude</code> to refresh it signs the other one out. Leave blank and Save
765
+ to clear.
766
+ </div>
767
+ </div>
768
+ </div>
769
+ <div class="image-controls">
770
+ <input
771
+ type="password"
772
+ name="claudeToken"
773
+ class="image-input"
774
+ bind:value={claudeToken}
775
+ spellcheck="false"
776
+ autocapitalize="off"
777
+ autocorrect="off"
778
+ autocomplete="off"
779
+ placeholder={claudeSaved ? '•••••••• (saved)' : 'sk-ant-oat…'}
780
+ />
781
+ <Button type="submit" disabled={savingClaude}>Save</Button>
782
+ </div>
783
+ {#if claudeError}
784
+ <div class="msg error">{claudeError}</div>
785
+ {:else if claudeMsg}
786
+ <div class="msg ok">{claudeMsg}</div>
787
+ {/if}
788
+ </form>
789
+ {/if}
790
+ </section>
791
+
792
+ <section class="card" class:disabled-card={manualTokens}>
793
+ <form
794
+ class="row"
795
+ method="POST"
796
+ action="?/customEndpointToggle"
797
+ {@attach enhance(customEndpointToggleOpts)}
798
+ >
799
+ <div class="label">
800
+ <KeyRound size={18} />
801
+ <div class="text">
802
+ <div class="name">
803
+ LiteLLM + Bedrock
804
+ {#if manualTokens}
805
+ <span class="arch" title="Disabled while Set tokens manually is on">disabled</span>
806
+ {/if}
807
+ </div>
808
+ <div class="desc">
809
+ {#if manualTokens}
810
+ Not available while "Set tokens manually" is enabled — these modes are mutually
811
+ incompatible.
812
+ {:else}
813
+ Route <code>claude</code> through a LiteLLM proxy fronting AWS Bedrock instead of Anthropic's
814
+ default API. When enabled, the Bedrock endpoint variables are injected into every new
815
+ container, host OAuth credentials are not used, and "Set tokens manually" is disabled.
816
+ {/if}
817
+ </div>
818
+ </div>
819
+ </div>
820
+ <label class="switch">
821
+ <input
822
+ type="checkbox"
823
+ name="enabled"
824
+ checked={customEndpoint}
825
+ disabled={savingCustomToggle || manualTokens}
826
+ onchange={(e) => {
827
+ customEndpoint = e.currentTarget.checked;
828
+ e.currentTarget.form?.requestSubmit();
829
+ }}
830
+ />
831
+ <span class="track"><span class="thumb"></span></span>
832
+ </label>
833
+ </form>
834
+ {#if customToggleError}
835
+ <div class="sub"><div class="msg error">{customToggleError}</div></div>
836
+ {/if}
837
+
838
+ {#if customEndpoint}
839
+ <form
840
+ class="row divided token-row"
841
+ method="POST"
842
+ action="?/customBaseUrl"
843
+ {@attach enhance(customBaseUrlOpts)}
844
+ >
845
+ <div class="label">
846
+ <div class="text">
847
+ <div class="name">Base URL</div>
848
+ <div class="desc">
849
+ The LiteLLM proxy endpoint, e.g.
850
+ <code>https://litellm.example.com/bedrock</code>. Passed as
851
+ <code>ANTHROPIC_BEDROCK_BASE_URL</code>.
852
+ </div>
853
+ </div>
854
+ </div>
855
+ <div class="image-controls">
856
+ <input
857
+ type="text"
858
+ name="baseUrl"
859
+ class="image-input"
860
+ bind:value={customBaseUrl}
861
+ spellcheck="false"
862
+ autocapitalize="off"
863
+ autocorrect="off"
864
+ autocomplete="off"
865
+ placeholder="https://litellm.example.com/bedrock"
866
+ />
867
+ <Button type="submit" disabled={savingCustomBaseUrl}>Save</Button>
868
+ </div>
869
+ {#if customBaseUrlError}
870
+ <div class="msg error">{customBaseUrlError}</div>
871
+ {:else if customBaseUrlMsg}
872
+ <div class="msg ok">{customBaseUrlMsg}</div>
873
+ {/if}
874
+ </form>
875
+
876
+ <form
877
+ class="row divided token-row"
878
+ method="POST"
879
+ action="?/customToken"
880
+ {@attach enhance(customTokenOpts)}
881
+ >
882
+ <div class="label">
883
+ <div class="text">
884
+ <div class="name">Auth token</div>
885
+ <div class="desc">
886
+ Your LiteLLM API key. Passed as <code>ANTHROPIC_AUTH_TOKEN</code>. Leave blank and
887
+ Save to clear.
888
+ </div>
889
+ </div>
890
+ </div>
891
+ <div class="image-controls">
892
+ <input
893
+ type="password"
894
+ name="token"
895
+ class="image-input"
896
+ bind:value={customToken}
897
+ spellcheck="false"
898
+ autocapitalize="off"
899
+ autocorrect="off"
900
+ autocomplete="off"
901
+ placeholder={customTokenSaved ? '•••••••• (saved)' : 'sk-…'}
902
+ />
903
+ <Button type="submit" disabled={savingCustomToken}>Save</Button>
904
+ </div>
905
+ {#if customTokenError}
906
+ <div class="msg error">{customTokenError}</div>
907
+ {:else if customTokenMsg}
908
+ <div class="msg ok">{customTokenMsg}</div>
909
+ {/if}
910
+ </form>
911
+
912
+ <form
913
+ class="row divided token-row"
914
+ method="POST"
915
+ action="?/customModels"
916
+ {@attach enhance(customModelsOpts)}
917
+ >
918
+ <div class="label">
919
+ <div class="text">
920
+ <div class="name">Model IDs</div>
921
+ <div class="desc">
922
+ Model aliases to use for each tier. Prefilled with defaults from the reference
923
+ launcher script. Leave a field as-is to keep its current value.
924
+ </div>
925
+ </div>
926
+ </div>
927
+ <div class="model-fields">
928
+ <label class="model-row">
929
+ <span class="model-label">Opus</span>
930
+ <input
931
+ type="text"
932
+ name="opusModel"
933
+ class="image-input"
934
+ bind:value={customOpusModel}
935
+ spellcheck="false"
936
+ autocapitalize="off"
937
+ autocorrect="off"
938
+ autocomplete="off"
939
+ />
940
+ </label>
941
+ <label class="model-row">
942
+ <span class="model-label">Sonnet</span>
943
+ <input
944
+ type="text"
945
+ name="sonnetModel"
946
+ class="image-input"
947
+ bind:value={customSonnetModel}
948
+ spellcheck="false"
949
+ autocapitalize="off"
950
+ autocorrect="off"
951
+ autocomplete="off"
952
+ />
953
+ </label>
954
+ <label class="model-row">
955
+ <span class="model-label">Haiku</span>
956
+ <input
957
+ type="text"
958
+ name="haikuModel"
959
+ class="image-input"
960
+ bind:value={customHaikuModel}
961
+ spellcheck="false"
962
+ autocapitalize="off"
963
+ autocorrect="off"
964
+ autocomplete="off"
965
+ />
966
+ </label>
967
+ <label class="model-row">
968
+ <span class="model-label">Small/fast</span>
969
+ <input
970
+ type="text"
971
+ name="smallFastModel"
972
+ class="image-input"
973
+ bind:value={customSmallFastModel}
974
+ spellcheck="false"
975
+ autocapitalize="off"
976
+ autocorrect="off"
977
+ autocomplete="off"
978
+ />
979
+ </label>
980
+ <label class="model-row">
981
+ <span class="model-label">Default</span>
982
+ <input
983
+ type="text"
984
+ name="defaultModel"
985
+ class="image-input"
986
+ bind:value={customDefaultModel}
987
+ spellcheck="false"
988
+ autocapitalize="off"
989
+ autocorrect="off"
990
+ autocomplete="off"
991
+ />
992
+ </label>
993
+ <div class="model-save-row">
994
+ <Button type="submit" disabled={savingCustomModels}>Save models</Button>
995
+ </div>
996
+ </div>
997
+ {#if customModelsError}
998
+ <div class="msg error">{customModelsError}</div>
999
+ {:else if customModelsMsg}
1000
+ <div class="msg ok">{customModelsMsg}</div>
1001
+ {/if}
1002
+ </form>
1003
+ {/if}
1004
+ </section>
1005
+
1006
+ <section class="card">
1007
+ <form
1008
+ class="row"
1009
+ method="POST"
1010
+ action="?/hostEnvVarsToggle"
1011
+ {@attach enhance(hostEnvVarsToggleOpts)}
1012
+ >
1013
+ <div class="label">
1014
+ <Variable size={18} />
1015
+ <div class="text">
1016
+ <div class="name">Host environment variables</div>
1017
+ <div class="desc">
1018
+ Forward selected environment variables from this machine into every new container's
1019
+ interactive shells. Values are read from this process's own environment at container
1020
+ start and are never shown here or stored — only the variable names are saved.
1021
+ </div>
1022
+ </div>
1023
+ </div>
1024
+ <label class="switch">
1025
+ <input
1026
+ type="checkbox"
1027
+ name="enabled"
1028
+ checked={hostEnvVars}
1029
+ disabled={savingHostEnvToggle}
1030
+ onchange={(e) => {
1031
+ hostEnvVars = e.currentTarget.checked;
1032
+ e.currentTarget.form?.requestSubmit();
1033
+ }}
1034
+ />
1035
+ <span class="track"><span class="thumb"></span></span>
1036
+ </label>
1037
+ </form>
1038
+ {#if hostEnvToggleError}
1039
+ <div class="sub"><div class="msg error">{hostEnvToggleError}</div></div>
1040
+ {/if}
1041
+
1042
+ {#if hostEnvVars}
1043
+ <div class="row divided var-row">
1044
+ <div class="var-list">
1045
+ {#each hostEnvNames as name (name)}
1046
+ <form
1047
+ class="var-item"
1048
+ method="POST"
1049
+ action="?/hostEnvVarNames"
1050
+ {@attach enhance(removeHostEnvOpts(name))}
1051
+ >
1052
+ {#each hostEnvNames.filter((n) => n !== name) as other (other)}
1053
+ <input type="hidden" name="names" value={other} />
1054
+ {/each}
1055
+ <span class="var-name">{name}</span>
1056
+ <span
1057
+ class="var-status"
1058
+ class:present={hostEnvPresence[name]}
1059
+ title={hostEnvPresence[name]
1060
+ ? 'Set on this host — will be injected'
1061
+ : 'Not set on this host — will be skipped'}
1062
+ >
1063
+ {hostEnvPresence[name] ? 'set' : 'missing'}
1064
+ </span>
1065
+ <button
1066
+ type="submit"
1067
+ class="var-remove"
1068
+ disabled={savingHostEnvNames}
1069
+ aria-label={`Remove ${name}`}
1070
+ >
1071
+ <X size={13} />
1072
+ </button>
1073
+ </form>
1074
+ {:else}
1075
+ <div class="var-empty">No variables added yet.</div>
1076
+ {/each}
1077
+ </div>
1078
+ <form
1079
+ class="var-add"
1080
+ method="POST"
1081
+ action="?/hostEnvVarNames"
1082
+ {@attach enhance(addHostEnvOpts)}
1083
+ >
1084
+ {#each hostEnvNames as name (name)}
1085
+ <input type="hidden" name="names" value={name} />
1086
+ {/each}
1087
+ <input
1088
+ type="text"
1089
+ name="names"
1090
+ class="image-input"
1091
+ bind:value={newHostEnvName}
1092
+ spellcheck="false"
1093
+ autocapitalize="off"
1094
+ autocorrect="off"
1095
+ autocomplete="off"
1096
+ placeholder="AWS_ACCESS_KEY_ID"
1097
+ />
1098
+ <Button type="submit" icon={Plus} disabled={savingHostEnvNames}>Add</Button>
1099
+ </form>
1100
+ {#if hostEnvNamesError}
1101
+ <div class="msg error">{hostEnvNamesError}</div>
1102
+ {:else if hostEnvNamesMsg}
1103
+ <div class="msg ok">{hostEnvNamesMsg}</div>
1104
+ {/if}
1105
+ </div>
1106
+ {/if}
1107
+ </section>
1108
+
1109
+ <section class="card">
1110
+ <div class="row">
1111
+ <div class="label">
1112
+ <SunMoon size={18} />
1113
+ <div class="text">
1114
+ <div class="name">Theme</div>
1115
+ <div class="desc">
1116
+ Light, dark, or follow the browser preference automatically (auto).
1117
+ </div>
1118
+ </div>
1119
+ </div>
1120
+ <ThemePicker />
1121
+ </div>
1122
+ </section>
1123
+
1124
+ <section class="card">
1125
+ <div class="row">
1126
+ <div class="label">
1127
+ <Volume2 size={18} />
1128
+ <div class="text">
1129
+ <div class="name">Attention sound</div>
1130
+ <div class="desc">
1131
+ Play a chime when an instance finishes a task or needs your input.
1132
+ </div>
1133
+ </div>
1134
+ </div>
1135
+ <label class="switch">
1136
+ <input
1137
+ type="checkbox"
1138
+ checked={sound}
1139
+ onchange={(e) => toggleSound(e.currentTarget.checked)}
1140
+ />
1141
+ <span class="track"><span class="thumb"></span></span>
1142
+ </label>
1143
+ </div>
1144
+ </section>
1145
+
1146
+ <section class="card danger-card">
1147
+ <form class="row" method="POST" action="?/shutdown" {@attach enhance(shutdownOpts)}>
1148
+ <div class="label">
1149
+ <Power size={18} />
1150
+ <div class="text">
1151
+ <div class="name">Delete database, containers, and shut down</div>
1152
+ <div class="desc">
1153
+ Stop and remove every instance and its container, delete all copied workspaces and the
1154
+ database, then shut down the server. This cannot be undone.
1155
+ </div>
1156
+ </div>
1157
+ </div>
1158
+ {#if shuttingDown}
1159
+ <span class="shutting">Server is shutting down — you can close this tab.</span>
1160
+ {:else}
1161
+ <Button type="submit" variant="danger">Delete &amp; shut down</Button>
1162
+ {/if}
1163
+ </form>
1164
+ </section>
1165
+
1166
+ <CoinButton />
1167
+ </main>
1168
+ </div>
1169
+
1170
+ <!-- Settings renders outside AppShell, which hosts the app-wide Toaster. -->
1171
+ <Toaster
1172
+ toastOptions={{
1173
+ style:
1174
+ 'border:1px solid var(--ink); background:var(--bg-card); color:var(--ink); box-shadow:4px 4px 0 var(--ink); font-family:var(--font-mono); font-size:13px;'
1175
+ }}
1176
+ />
1177
+
1178
+ <style>
1179
+ .page {
1180
+ display: flex;
1181
+ flex-direction: column;
1182
+ min-height: 100vh;
1183
+ }
1184
+ .title {
1185
+ display: inline-flex;
1186
+ align-items: center;
1187
+ padding: 0 14px;
1188
+ font-family: var(--font-mono);
1189
+ font-size: 12px;
1190
+ text-transform: uppercase;
1191
+ letter-spacing: 0.08em;
1192
+ color: var(--ink-soft);
1193
+ }
1194
+ .content {
1195
+ flex: 1;
1196
+ display: flex;
1197
+ flex-direction: column;
1198
+ align-items: center;
1199
+ gap: 16px;
1200
+ padding: 32px 20px;
1201
+ }
1202
+ .card {
1203
+ width: 100%;
1204
+ max-width: 560px;
1205
+ background: var(--bg-card);
1206
+ border: 1px solid var(--rule);
1207
+ height: max-content;
1208
+ }
1209
+ .danger-card {
1210
+ border-color: var(--danger);
1211
+ }
1212
+ .disabled-card {
1213
+ opacity: 0.55;
1214
+ pointer-events: none;
1215
+ }
1216
+ /* Keep the action button on one line; in the flex row it would otherwise shrink and wrap. */
1217
+ .danger-card :global(.btn) {
1218
+ flex: none;
1219
+ white-space: nowrap;
1220
+ }
1221
+ .shutting {
1222
+ flex: none;
1223
+ max-width: 200px;
1224
+ font-family: var(--font-mono);
1225
+ font-size: 12px;
1226
+ color: var(--danger);
1227
+ line-height: 1.4;
1228
+ text-align: right;
1229
+ }
1230
+ .row {
1231
+ display: flex;
1232
+ align-items: center;
1233
+ justify-content: space-between;
1234
+ gap: 16px;
1235
+ padding: 18px 18px;
1236
+ }
1237
+ .row.divided {
1238
+ border-top: 1px solid var(--rule);
1239
+ align-items: flex-start;
1240
+ }
1241
+ .row.divided :global(.btn) {
1242
+ flex: none;
1243
+ white-space: nowrap;
1244
+ }
1245
+ .sub {
1246
+ padding: 0 18px 14px;
1247
+ }
1248
+ code {
1249
+ font-family: var(--font-mono);
1250
+ font-size: 0.92em;
1251
+ padding: 1px 4px;
1252
+ background: var(--bg);
1253
+ border: 1px solid var(--rule);
1254
+ border-radius: 3px;
1255
+ }
1256
+ .label {
1257
+ display: flex;
1258
+ align-items: flex-start;
1259
+ gap: 12px;
1260
+ color: var(--ink);
1261
+ min-width: 0;
1262
+ }
1263
+ .text {
1264
+ min-width: 0;
1265
+ }
1266
+ .name {
1267
+ font-family: var(--font-mono);
1268
+ font-size: 13px;
1269
+ text-transform: uppercase;
1270
+ letter-spacing: 0.06em;
1271
+ }
1272
+ .arch {
1273
+ margin-left: 8px;
1274
+ padding: 2px 6px;
1275
+ font-size: 11px;
1276
+ letter-spacing: 0.04em;
1277
+ color: var(--ink-soft);
1278
+ border: 1px solid var(--rule);
1279
+ border-radius: 3px;
1280
+ vertical-align: middle;
1281
+ }
1282
+ .tip {
1283
+ width: 100%;
1284
+ margin-top: 4px;
1285
+ padding: 8px 10px;
1286
+ color: var(--ink-soft);
1287
+ background: color-mix(in srgb, var(--info) 10%, transparent);
1288
+ border: 1px solid color-mix(in srgb, var(--info) 35%, transparent);
1289
+ border-radius: 4px;
1290
+ }
1291
+ .desc {
1292
+ margin-top: 4px;
1293
+ font-family: var(--font-mono);
1294
+ font-size: 12px;
1295
+ color: var(--ink-faint);
1296
+ line-height: 1.4;
1297
+ }
1298
+ .image-row {
1299
+ flex-wrap: wrap;
1300
+ }
1301
+ /* Manual-token rows: same input+Save layout, allowed to wrap so the help text and
1302
+ the "Saved." message drop below the field on narrow widths. */
1303
+ .token-row {
1304
+ flex-wrap: wrap;
1305
+ }
1306
+ .image-controls {
1307
+ display: flex;
1308
+ align-items: center;
1309
+ gap: 8px;
1310
+ flex: 1;
1311
+ min-width: 220px;
1312
+ justify-content: flex-end;
1313
+ }
1314
+ .image-input {
1315
+ flex: 1;
1316
+ min-width: 0;
1317
+ padding: 8px 10px;
1318
+ font-family: var(--font-mono);
1319
+ font-size: 12px;
1320
+ color: var(--ink);
1321
+ background: var(--bg);
1322
+ border: 1px solid var(--rule);
1323
+ }
1324
+ .image-input:focus-visible {
1325
+ outline: 2px solid var(--ink);
1326
+ outline-offset: 1px;
1327
+ }
1328
+ .image-controls :global(.btn) {
1329
+ flex: none;
1330
+ }
1331
+ .msg {
1332
+ width: 100%;
1333
+ margin-top: 2px;
1334
+ font-family: var(--font-mono);
1335
+ font-size: 12px;
1336
+ line-height: 1.4;
1337
+ }
1338
+ .msg.error {
1339
+ color: var(--danger);
1340
+ }
1341
+ .msg.ok {
1342
+ color: var(--ink-soft);
1343
+ }
1344
+ .switch {
1345
+ position: relative;
1346
+ flex: none;
1347
+ cursor: pointer;
1348
+ }
1349
+ .switch input {
1350
+ position: absolute;
1351
+ opacity: 0;
1352
+ inset: 0;
1353
+ margin: 0;
1354
+ cursor: pointer;
1355
+ }
1356
+ .track {
1357
+ display: block;
1358
+ width: 44px;
1359
+ height: 24px;
1360
+ border: 1px solid var(--rule);
1361
+ background: var(--bg);
1362
+ border-radius: 999px;
1363
+ transition: background 0.15s ease;
1364
+ }
1365
+ .thumb {
1366
+ display: block;
1367
+ width: 18px;
1368
+ height: 18px;
1369
+ margin: 2px;
1370
+ background: var(--ink-faint);
1371
+ border-radius: 999px;
1372
+ transition:
1373
+ transform 0.15s ease,
1374
+ background 0.15s ease;
1375
+ }
1376
+ .switch input:checked + .track {
1377
+ background: var(--switch-on-bg);
1378
+ }
1379
+ .switch input:checked + .track .thumb {
1380
+ transform: translateX(20px);
1381
+ background: var(--ink);
1382
+ }
1383
+ .switch input:focus-visible + .track {
1384
+ outline: 2px solid var(--ink);
1385
+ outline-offset: 2px;
1386
+ }
1387
+ .model-fields {
1388
+ display: flex;
1389
+ flex-direction: column;
1390
+ gap: 6px;
1391
+ flex: 1;
1392
+ min-width: 220px;
1393
+ }
1394
+ .model-row {
1395
+ display: flex;
1396
+ align-items: center;
1397
+ gap: 8px;
1398
+ }
1399
+ .model-label {
1400
+ flex: none;
1401
+ width: 72px;
1402
+ font-family: var(--font-mono);
1403
+ font-size: 11px;
1404
+ color: var(--ink-soft);
1405
+ text-align: right;
1406
+ }
1407
+ .model-row .image-input {
1408
+ flex: 1;
1409
+ }
1410
+ .model-save-row {
1411
+ display: flex;
1412
+ justify-content: flex-end;
1413
+ margin-top: 4px;
1414
+ }
1415
+ .var-row {
1416
+ flex-direction: column;
1417
+ align-items: stretch;
1418
+ gap: 10px;
1419
+ }
1420
+ .var-list {
1421
+ display: flex;
1422
+ flex-direction: column;
1423
+ gap: 6px;
1424
+ }
1425
+ .var-item {
1426
+ display: flex;
1427
+ align-items: center;
1428
+ gap: 8px;
1429
+ padding: 6px 8px;
1430
+ background: var(--bg);
1431
+ border: 1px solid var(--rule);
1432
+ }
1433
+ .var-name {
1434
+ flex: 1;
1435
+ min-width: 0;
1436
+ font-family: var(--font-mono);
1437
+ font-size: 12px;
1438
+ color: var(--ink);
1439
+ word-break: break-all;
1440
+ }
1441
+ .var-status {
1442
+ flex: none;
1443
+ font-family: var(--font-mono);
1444
+ font-size: 11px;
1445
+ letter-spacing: 0.04em;
1446
+ color: var(--danger);
1447
+ }
1448
+ .var-status.present {
1449
+ color: var(--ink-soft);
1450
+ }
1451
+ .var-remove {
1452
+ flex: none;
1453
+ display: inline-flex;
1454
+ align-items: center;
1455
+ justify-content: center;
1456
+ width: 22px;
1457
+ height: 22px;
1458
+ padding: 0;
1459
+ color: var(--ink-soft);
1460
+ background: transparent;
1461
+ border: 1px solid var(--rule);
1462
+ cursor: pointer;
1463
+ }
1464
+ .var-remove:hover:not(:disabled) {
1465
+ color: var(--danger);
1466
+ border-color: var(--danger);
1467
+ }
1468
+ .var-remove:disabled {
1469
+ opacity: 0.4;
1470
+ cursor: not-allowed;
1471
+ }
1472
+ .var-empty {
1473
+ font-family: var(--font-mono);
1474
+ font-size: 12px;
1475
+ color: var(--ink-faint);
1476
+ }
1477
+ .var-add {
1478
+ display: flex;
1479
+ gap: 8px;
1480
+ }
1481
+ .var-add :global(.btn) {
1482
+ flex: none;
1483
+ }
1484
+ </style>