codebay 0.8.0 → 0.9.1

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 (80) hide show
  1. package/.mochi/import-css/xterm-1yy1hf1g.css +252 -0
  2. package/.mochi/manifest.json +325 -206
  3. package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +1 -0
  4. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +2 -0
  5. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js +14 -0
  6. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +1 -0
  7. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +29 -0
  8. package/.mochi/svelte-client/chunk-2j95sv83.js +1 -0
  9. package/.mochi/svelte-client/chunk-6rbs0mhx.js +1 -0
  10. package/.mochi/svelte-client/chunk-7wkvn2vx.js +2 -0
  11. package/.mochi/svelte-client/chunk-a2psjmrm.js +2 -0
  12. package/.mochi/svelte-client/chunk-m497q88s.js +2 -0
  13. package/.mochi/svelte-client/{chunk-fqg2qzj8.js → chunk-m4fzsd5b.js} +1 -1
  14. package/.mochi/svelte-client/chunk-mpv3qx28.js +36 -0
  15. package/.mochi/svelte-client/chunk-rs41by6w.js +2 -0
  16. package/.mochi/svelte-client/{chunk-qj7zy2d9.js → chunk-v65n8th6.js} +4 -4
  17. package/.mochi/svelte-compile/{App-6rcf0d74.server.js → App-7vjcp38v.server.js} +368 -37
  18. package/.mochi/svelte-compile/{Avatars-pzyz0tv3.server.js → Avatars-e2cfc95t.server.js} +130 -25
  19. package/.mochi/svelte-compile/{ClientStats-z3tn9ad6.server.js → ClientStats-4cphmrnp.server.js} +1 -1
  20. package/.mochi/svelte-compile/{DefaultError-wh77vebf.server.js → DefaultError-je52k17d.server.js} +1 -1
  21. package/.mochi/svelte-compile/{Instance-49ksrp11.server.js → Instance-h78n2dsv.server.js} +16 -13
  22. package/.mochi/svelte-compile/{Settings-sz5d6qj7.server.js → Settings-tav2asn7.server.js} +153 -127
  23. package/.mochi/svelte-compile/{chunk-fpe16ywq.js → chunk-60mpagr9.js} +17 -7
  24. package/.mochi/svelte-compile/chunk-9nqc2r0y.js +9706 -0
  25. package/.mochi/svelte-compile/{chunk-6ghzacpg.js → chunk-bpqwf1nw.js} +36 -4
  26. package/.mochi/svelte-compile/chunk-jx6h1xzp.js +20 -0
  27. package/.mochi/svelte-compile/{chunk-q8rbesaw.js → chunk-m8ygpyhr.js} +5 -4
  28. package/.mochi/svelte-compile/chunk-rtdne1m8.js +31 -0
  29. package/.mochi/svelte-compile/{chunk-we0ggtws.js → chunk-xpt1a7jm.js} +2 -1
  30. package/.mochi/svelte-css/AvatarEditor-2mdq8ss346drw.css +1 -0
  31. package/.mochi/svelte-css/{AvatarsView-2ughbzc26luyb.css → AvatarsView-2llo48w0wt0xn.css} +1 -1
  32. package/.mochi/svelte-css/FolderBrowser-2pxp9z1o7zixk.css +1 -0
  33. package/.mochi/svelte-css/{IdeBar-2qoqi2ya9zdn3.css → IdeBar-10xodudcx57qr.css} +1 -1
  34. package/.mochi/svelte-css/{SettingsView-3uycxa8z548m4.css → SettingsView-2qfauz68axro1.css} +1 -1
  35. package/.mochi/svelte-css/TerminalPane-3bwgk6ulzq6b3.css +1 -0
  36. package/.mochi/svelte-css/TerminalSplit-2416xfuln89a3.css +1 -0
  37. package/migrations/0005_instance_mode.sql +1 -0
  38. package/migrations/0006_terminal_split.sql +1 -0
  39. package/package.json +3 -1
  40. package/src/avatars/serialize.ts +35 -6
  41. package/src/components/AppShell.svelte +46 -8
  42. package/src/components/Avatar.svelte +3 -2
  43. package/src/components/AvatarEditor.svelte +96 -19
  44. package/src/components/AvatarsView.svelte +0 -0
  45. package/src/components/DashboardView.svelte +22 -7
  46. package/src/components/FolderBrowser.svelte +88 -8
  47. package/src/components/HealthBox.svelte +12 -5
  48. package/src/components/IdeBar.svelte +10 -0
  49. package/src/components/InstanceCard.svelte +3 -1
  50. package/src/components/InstanceView.svelte +1 -0
  51. package/src/components/SettingsView.svelte +108 -0
  52. package/src/components/TerminalPane.svelte +274 -0
  53. package/src/components/TerminalSplit.svelte +154 -0
  54. package/src/container-injections/claude-code-ide-extension.ts +2 -0
  55. package/src/container-injections/claude-code-install.ts +67 -0
  56. package/src/container-injections/ttyd.ts +49 -0
  57. package/src/lib/config.server.ts +3 -0
  58. package/src/lib/db.server.ts +14 -4
  59. package/src/lib/devcontainer.server.ts +185 -23
  60. package/src/lib/health.server.ts +1 -1
  61. package/src/lib/injections.server.ts +18 -5
  62. package/src/lib/instances.server.ts +68 -10
  63. package/src/lib/proxy.server.ts +26 -5
  64. package/src/pages/App.svelte +5 -3
  65. package/src/pages/Settings.svelte +4 -0
  66. package/src/routes.ts +55 -6
  67. package/src/types.ts +18 -0
  68. package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +0 -1
  69. package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +0 -2
  70. package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +0 -13
  71. package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +0 -1
  72. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js +0 -28
  73. package/.mochi/svelte-client/chunk-330mrspw.js +0 -2
  74. package/.mochi/svelte-client/chunk-4jgkak13.js +0 -1
  75. package/.mochi/svelte-client/chunk-k5m4kt20.js +0 -2
  76. package/.mochi/svelte-client/chunk-rpdphch7.js +0 -2
  77. package/.mochi/svelte-compile/chunk-bacb4b0r.js +0 -14
  78. package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +0 -1
  79. package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +0 -1
  80. /package/.mochi/svelte-compile/{chunk-btmcxhqy.js → chunk-brjv9pyd.js} +0 -0
@@ -2,9 +2,11 @@
2
2
  import Copy from '@lucide/svelte/icons/copy';
3
3
  import Eraser from '@lucide/svelte/icons/eraser';
4
4
  import Contrast from '@lucide/svelte/icons/contrast';
5
+ import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
5
6
  import ExternalLink from '@lucide/svelte/icons/external-link';
7
+ import X from '@lucide/svelte/icons/x';
6
8
  import toast from 'svelte-french-toast';
7
- import { ROWS, COLS, ON, OFF, GRAY, type AvatarArt } from '../avatars/types.ts';
9
+ import { ROWS, COLS, ON, OFF, GRAY, decode, type AvatarArt } from '../avatars/types.ts';
8
10
  import { MODES, nextValue, type Mode } from '../avatars/editor.ts';
9
11
  import {
10
12
  cellsToPixels,
@@ -16,13 +18,26 @@
16
18
  import Button from './Button.svelte';
17
19
 
18
20
  // Rolled by the route, not here, so hydration doesn't pick a different word.
19
- let { namePlaceholder }: { namePlaceholder: string } = $props();
21
+ // `editing` seeds the canvas with an existing sprite (a redraw); null is a fresh draw.
22
+ let {
23
+ namePlaceholder,
24
+ editing = null,
25
+ oncancelEdit
26
+ }: {
27
+ namePlaceholder: string;
28
+ editing?: AvatarArt | null;
29
+ oncancelEdit?: () => void;
30
+ } = $props();
20
31
 
21
- // Same shape `decode()` produces, so the two round-trip.
22
- let cells = $state<number[]>(Array(ROWS * COLS).fill(OFF));
23
- let avatarName = $state('');
32
+ // A redraw keeps the sprite's name (it's the catalog key), so the name is fixed then.
33
+ const isEdit = $derived(editing != null);
24
34
 
25
- const slug = $derived(normalizeName(avatarName));
35
+ // Same shape `decode()` produces, so the two round-trip. Seeded once — the parent
36
+ // remounts this component (keyed) when it switches which sprite is being edited.
37
+ let cells = $state<number[]>(editing ? decode(editing) : Array(ROWS * COLS).fill(OFF));
38
+ let avatarName = $state(editing?.name ?? '');
39
+
40
+ const slug = $derived(editing ? editing.name : normalizeName(avatarName));
26
41
  const art = $derived<AvatarArt>({ name: slug || 'my-avatar', pixels: cellsToPixels(cells) });
27
42
  // Contributing needs a name and at least one lit pixel — a blank sprite is no sprite.
28
43
  const canContribute = $derived(slug.length > 0 && cells.some((c) => c !== OFF));
@@ -89,6 +104,11 @@
89
104
  cells = Array(ROWS * COLS).fill(OFF);
90
105
  }
91
106
 
107
+ // Only meaningful for a redraw: restore the sprite the edit started from.
108
+ function reset() {
109
+ if (editing) cells = decode(editing);
110
+ }
111
+
92
112
  // Only swaps off↔on; gray sits in the middle and is left untouched.
93
113
  function invert() {
94
114
  cells = cells.map((c) => (c === ON ? OFF : c === OFF ? ON : c));
@@ -106,7 +126,13 @@
106
126
 
107
127
  <section class="editor">
108
128
  <div class="head">
109
- <h2>Draw your own avatar</h2>
129
+ <h2>{isEdit ? `Redraw “${editing!.name}”` : 'Draw your own avatar'}</h2>
130
+ {#if isEdit}
131
+ <button type="button" class="head-cancel" onclick={() => oncancelEdit?.()}>
132
+ <X size={14} />
133
+ <span>New drawing</span>
134
+ </button>
135
+ {/if}
110
136
  </div>
111
137
 
112
138
  <div class="body">
@@ -141,18 +167,33 @@
141
167
  </div>
142
168
  </div>
143
169
 
170
+ {#if isEdit}
171
+ <!-- The AI-generated original, so the redraw can be compared against it at a glance. -->
172
+ <div class="preview before">
173
+ <div class="preview-label">Before</div>
174
+ <div class="preview-row">
175
+ <Avatar art={editing!} name={editing!.name} scale={3} />
176
+ </div>
177
+ </div>
178
+ {/if}
179
+
144
180
  <label class="name-field">
145
181
  <span class="preview-label">Name</span>
146
- <input
147
- type="text"
148
- bind:value={avatarName}
149
- placeholder={namePlaceholder}
150
- spellcheck="false"
151
- autocapitalize="off"
152
- autocorrect="off"
153
- autocomplete="off"
154
- maxlength="24"
155
- />
182
+ {#if isEdit}
183
+ <!-- The name is the catalog key; a redraw keeps it, so it's shown read-only. -->
184
+ <input type="text" value={editing!.name} readonly aria-readonly="true" />
185
+ {:else}
186
+ <input
187
+ type="text"
188
+ bind:value={avatarName}
189
+ placeholder={namePlaceholder}
190
+ spellcheck="false"
191
+ autocapitalize="off"
192
+ autocorrect="off"
193
+ autocomplete="off"
194
+ maxlength="24"
195
+ />
196
+ {/if}
156
197
  </label>
157
198
 
158
199
  <div class="mode-field">
@@ -177,6 +218,9 @@
177
218
  </div>
178
219
 
179
220
  <div class="tools">
221
+ {#if isEdit}
222
+ <Button size="sm" icon={RotateCcw} onclick={reset}>Reset</Button>
223
+ {/if}
180
224
  <Button size="sm" icon={Eraser} onclick={clear}>Clear</Button>
181
225
  <Button size="sm" icon={Contrast} onclick={invert}>Invert</Button>
182
226
  </div>
@@ -184,7 +228,11 @@
184
228
  </div>
185
229
 
186
230
  <div class="foot">
187
- <span class="hint">Draw something, then open an issue to get it into the official set.</span>
231
+ <span class="hint">
232
+ {isEdit
233
+ ? 'Redraw it, then open an issue to replace the AI-generated original.'
234
+ : 'Draw something, then open an issue to get it into the official set.'}
235
+ </span>
188
236
  <div class="actions">
189
237
  <Button size="sm" icon={Copy} disabled={!canContribute} onclick={copyModule}>Copy</Button>
190
238
  <Button
@@ -192,7 +240,7 @@
192
240
  variant="primary"
193
241
  icon={ExternalLink}
194
242
  disabled={!canContribute}
195
- href={toIssueUrl(art)}
243
+ href={toIssueUrl(art, isEdit ? 'edit' : 'create')}
196
244
  target="_blank"
197
245
  rel="noopener"
198
246
  >
@@ -214,6 +262,8 @@
214
262
  .head {
215
263
  display: flex;
216
264
  align-items: center;
265
+ justify-content: space-between;
266
+ gap: 12px;
217
267
  padding: 14px 18px;
218
268
  border-bottom: 1px solid var(--ink);
219
269
  background: var(--ink);
@@ -227,6 +277,29 @@
227
277
  letter-spacing: 0.06em;
228
278
  color: var(--bg);
229
279
  }
280
+ .head-cancel {
281
+ display: inline-flex;
282
+ align-items: center;
283
+ gap: 6px;
284
+ flex: none;
285
+ padding: 5px 10px;
286
+ border: 1px solid var(--bg);
287
+ background: transparent;
288
+ color: var(--bg);
289
+ font-family: var(--font-mono);
290
+ font-size: 11px;
291
+ text-transform: uppercase;
292
+ letter-spacing: 0.06em;
293
+ cursor: pointer;
294
+ }
295
+ .head-cancel:hover {
296
+ background: var(--bg);
297
+ color: var(--ink);
298
+ }
299
+ .head-cancel:focus-visible {
300
+ outline: none;
301
+ box-shadow: 0 0 0 2px var(--rule-soft);
302
+ }
230
303
  .body {
231
304
  display: flex;
232
305
  flex-wrap: wrap;
@@ -308,6 +381,10 @@
308
381
  .name-field input::placeholder {
309
382
  color: var(--ink-faint);
310
383
  }
384
+ .name-field input[readonly] {
385
+ color: var(--ink-soft);
386
+ cursor: default;
387
+ }
311
388
  .name-field input:focus {
312
389
  outline: none;
313
390
  box-shadow: inset 3px 3px 0 var(--rule-soft);
Binary file
@@ -13,13 +13,21 @@
13
13
  preflight,
14
14
  instances,
15
15
  loaded,
16
- pet
17
- }: { preflight: Preflight; instances: Instance[]; loaded: boolean; pet?: AvatarArt } = $props();
16
+ pet,
17
+ filter,
18
+ onFilter
19
+ }: {
20
+ preflight: Preflight;
21
+ instances: Instance[];
22
+ loaded: boolean;
23
+ pet?: AvatarArt;
24
+ filter: InstanceFilter;
25
+ onFilter: (v: InstanceFilter) => void;
26
+ } = $props();
18
27
 
19
28
  type Action = 'start' | 'stop' | 'delete' | 'rebuild';
20
29
 
21
30
  let browserOpen = $state(false);
22
- let filter = $state<InstanceFilter>('all');
23
31
  let creating = $state(false);
24
32
  let actionError = $state<string | null>(null);
25
33
  let editingId = $state<string | null>(null);
@@ -51,14 +59,17 @@
51
59
  pending = rest;
52
60
  }
53
61
 
54
- async function createFrom(sourcePath: string, opts?: { branch?: string }) {
62
+ async function createFrom(
63
+ sourcePath: string,
64
+ opts?: { branch?: string; mode?: Instance['mode'] }
65
+ ) {
55
66
  browserOpen = false;
56
67
  creating = true;
57
68
  actionError = null;
58
69
  try {
59
70
  await apiPost(
60
71
  '/api/instances',
61
- { sourcePath, branch: opts?.branch },
72
+ { sourcePath, branch: opts?.branch, mode: opts?.mode },
62
73
  'Failed to create instance'
63
74
  );
64
75
  // The live stream delivers the new instance.
@@ -136,7 +147,7 @@
136
147
  {ready}
137
148
  {creating}
138
149
  {filter}
139
- onFilter={(v) => (filter = v)}
150
+ {onFilter}
140
151
  onNew={() => (browserOpen = true)}
141
152
  onDeleteAll={deleteAll}
142
153
  />
@@ -185,7 +196,11 @@
185
196
  </main>
186
197
 
187
198
  {#if browserOpen}
188
- <FolderBrowser onpick={createFrom} onclose={() => (browserOpen = false)} />
199
+ <FolderBrowser
200
+ onpick={createFrom}
201
+ defaultMode={preflight.defaultMode}
202
+ onclose={() => (browserOpen = false)}
203
+ />
189
204
  {/if}
190
205
 
191
206
  <style>
@@ -1,7 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { tick } from 'svelte';
3
- import type { BrowseResult, FolderHistoryEntry } from '../types.ts';
3
+ import type { BrowseResult, FolderHistoryEntry, InstanceMode } from '../types.ts';
4
4
  import { isRepoUrl } from '../lib/repo-url.ts';
5
+ import Terminal from '@lucide/svelte/icons/terminal';
6
+ import LayoutTemplate from '@lucide/svelte/icons/layout-template';
5
7
  import X from '@lucide/svelte/icons/x';
6
8
  import FolderClock from '@lucide/svelte/icons/folder-clock';
7
9
  import GitBranch from '@lucide/svelte/icons/git-branch';
@@ -12,9 +14,18 @@
12
14
 
13
15
  let {
14
16
  onpick,
15
- onclose
16
- }: { onpick: (source: string, opts?: { branch?: string }) => void; onclose: () => void } =
17
- $props();
17
+ onclose,
18
+ defaultMode = 'ide'
19
+ }: {
20
+ onpick: (source: string, opts?: { branch?: string; mode?: InstanceMode }) => void;
21
+ onclose: () => void;
22
+ defaultMode?: InstanceMode;
23
+ } = $props();
24
+
25
+ // Follows the global default live — settings opens in its own popup, so the toggle there can
26
+ // move while this picker is open. An explicit choice here wins and is never yanked back.
27
+ let modeOverride = $state<InstanceMode | null>(null);
28
+ const mode = $derived(modeOverride ?? defaultMode);
18
29
 
19
30
  let result = $state<BrowseResult | null>(null);
20
31
  let loading = $state(true);
@@ -30,7 +41,7 @@
30
41
 
31
42
  function cloneRepoUrl() {
32
43
  if (!repoValid) return;
33
- onpick(repoUrl.trim(), { branch: repoBranch.trim() || undefined });
44
+ onpick(repoUrl.trim(), { branch: repoBranch.trim() || undefined, mode });
34
45
  }
35
46
 
36
47
  const filtered = $derived(
@@ -103,6 +114,32 @@
103
114
  <button class="x" onclick={onclose} aria-label="Close"><X size={16} /></button>
104
115
  </div>
105
116
 
117
+ <div class="mode">
118
+ <div class="mode-label">Editor</div>
119
+ <div class="mode-toggle" role="group" aria-label="Editor mode">
120
+ <button
121
+ type="button"
122
+ class="mode-btn"
123
+ class:active={mode === 'ide'}
124
+ aria-pressed={mode === 'ide'}
125
+ onclick={() => (modeOverride = 'ide')}
126
+ >
127
+ <LayoutTemplate size={15} />
128
+ Full IDE
129
+ </button>
130
+ <button
131
+ type="button"
132
+ class="mode-btn"
133
+ class:active={mode === 'terminal'}
134
+ aria-pressed={mode === 'terminal'}
135
+ onclick={() => (modeOverride = 'terminal')}
136
+ >
137
+ <Terminal size={15} />
138
+ Terminal
139
+ </button>
140
+ </div>
141
+ </div>
142
+
106
143
  <div class="clone">
107
144
  <div class="clone-label">Clone a Git repository</div>
108
145
  <div class="clone-row">
@@ -147,7 +184,7 @@
147
184
  <div class="recent-label">Recent</div>
148
185
  {#each shownHistory as entry (entry.source_path)}
149
186
  <div class="recent-row">
150
- <button class="recent-pick" onclick={() => onpick(entry.source_path)}>
187
+ <button class="recent-pick" onclick={() => onpick(entry.source_path, { mode })}>
151
188
  <span class="icon">
152
189
  {#if isRepoUrl(entry.source_path)}<GitBranch size={16} />{:else}<FolderClock
153
190
  size={16}
@@ -221,7 +258,7 @@
221
258
  <span class="ename">{entry.name}</span>
222
259
  {#if entry.hasDevcontainer}<span class="badge">devcontainer</span>{/if}
223
260
  </button>
224
- <button class="pick-inline" onclick={() => onpick(entry.path)}>Select</button>
261
+ <button class="pick-inline" onclick={() => onpick(entry.path, { mode })}>Select</button>
225
262
  </div>
226
263
  {/each}
227
264
  {/if}
@@ -239,7 +276,11 @@
239
276
 
240
277
  <div class="foot">
241
278
  <span class="hint">Browse to a folder, then select this folder or any subfolder.</span>
242
- <button class="primary" disabled={!result} onclick={() => result && onpick(result.path)}>
279
+ <button
280
+ class="primary"
281
+ disabled={!result}
282
+ onclick={() => result && onpick(result.path, { mode })}
283
+ >
243
284
  Select this folder
244
285
  </button>
245
286
  </div>
@@ -293,6 +334,45 @@
293
334
  .x:hover {
294
335
  opacity: 0.7;
295
336
  }
337
+ .mode {
338
+ display: flex;
339
+ align-items: center;
340
+ gap: 12px;
341
+ padding: 12px 18px;
342
+ border-bottom: 1px solid var(--rule-soft);
343
+ }
344
+ .mode-label {
345
+ font-size: 11px;
346
+ text-transform: uppercase;
347
+ letter-spacing: 0.06em;
348
+ color: var(--ink-faint);
349
+ }
350
+ .mode-toggle {
351
+ display: flex;
352
+ border: 1px solid var(--ink);
353
+ }
354
+ .mode-btn {
355
+ display: inline-flex;
356
+ align-items: center;
357
+ gap: 6px;
358
+ font-family: var(--font-mono);
359
+ font-weight: 700;
360
+ font-size: 12px;
361
+ text-transform: uppercase;
362
+ letter-spacing: 0.05em;
363
+ padding: 7px 14px;
364
+ border: none;
365
+ background: var(--bg);
366
+ color: var(--ink);
367
+ cursor: pointer;
368
+ }
369
+ .mode-btn + .mode-btn {
370
+ border-left: 1px solid var(--ink);
371
+ }
372
+ .mode-btn.active {
373
+ background: var(--ink);
374
+ color: var(--bg);
375
+ }
296
376
  .clone {
297
377
  padding: 12px 18px;
298
378
  border-bottom: 1px solid var(--rule-soft);
@@ -1,17 +1,15 @@
1
1
  <script lang="ts">
2
2
  import Check from '@lucide/svelte/icons/check';
3
3
  import X from '@lucide/svelte/icons/x';
4
- import type { InstanceHealth } from '../types.ts';
4
+ import type { InstanceHealth, InstanceMode } from '../types.ts';
5
5
  import Skeleton from './Skeleton.svelte';
6
6
 
7
- // Intrinsic to every instance, independent of any injection.
8
- const FIXED_CHECKS = ['Container running', 'Code-server reachable'] as const;
9
-
10
7
  let {
11
8
  health = null,
12
9
  lastFetchedAt = null,
13
10
  active = true,
14
- injectionChecks = 0
11
+ injectionChecks = 0,
12
+ mode = 'ide'
15
13
  }: {
16
14
  /** Latest health snapshot, or null while the first check is pending. */
17
15
  health?: InstanceHealth | null;
@@ -21,8 +19,17 @@
21
19
  active?: boolean;
22
20
  /** Registry-derived, so the skeleton renders one row per real check. */
23
21
  injectionChecks?: number;
22
+ /** Terminal instances serve ttyd, not code-server, so the reachability label differs. */
23
+ mode?: InstanceMode;
24
24
  } = $props();
25
25
 
26
+ // Intrinsic to every instance, independent of any injection. The second label tracks the
27
+ // served surface — the probe underneath is the same host-port reachability check either way.
28
+ const FIXED_CHECKS = $derived([
29
+ 'Container running',
30
+ mode === 'terminal' ? 'Terminal reachable' : 'Code-server reachable'
31
+ ] as const);
32
+
26
33
  // The value word stays uniform because the tick/cross icon already conveys pass vs fail.
27
34
  const checks = $derived.by((): { label: string; ok: boolean; value: string }[] => {
28
35
  if (!health) return [];
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { type Instance } from '../types.ts';
3
3
  import Settings from '@lucide/svelte/icons/settings';
4
+ import Terminal from '@lucide/svelte/icons/terminal';
4
5
  import Avatar from './Avatar.svelte';
5
6
  import AppBar from './AppBar.svelte';
6
7
  import { withPopupMarker } from '../lib/popup-nav.ts';
@@ -64,6 +65,9 @@
64
65
  >
65
66
  <Avatar id={inst.id} name={inst.name} scale={4} />
66
67
  <span class="tab-name">{inst.name}</span>
68
+ {#if inst.mode === 'terminal'}
69
+ <span class="tab-mode" title="Terminal-only instance"><Terminal size={13} /></span>
70
+ {/if}
67
71
  </button>
68
72
  {/if}
69
73
  </div>
@@ -169,6 +173,12 @@
169
173
  overflow: hidden;
170
174
  text-overflow: ellipsis;
171
175
  }
176
+ .tab-mode {
177
+ display: inline-flex;
178
+ align-items: center;
179
+ flex: none;
180
+ opacity: 0.75;
181
+ }
172
182
  .tab:not(.active) .tab-label:hover {
173
183
  color: var(--ink);
174
184
  }
@@ -94,7 +94,9 @@
94
94
  {/if}
95
95
  <div class="actions">
96
96
  {#if instance.status === 'running'}
97
- <Button variant="primary" size="sm" href={`/ide/${instance.id}`}>Open IDE</Button>
97
+ <Button variant="primary" size="sm" href={`/ide/${instance.id}`}>
98
+ {instance.mode === 'terminal' ? 'Open terminal' : 'Open IDE'}
99
+ </Button>
98
100
  <Button size="sm" onclick={() => onact('stop')}>Stop</Button>
99
101
  {:else if instance.status === 'stopped' || (instance.status === 'error' && instance.container_id)}
100
102
  <Button size="sm" onclick={() => onact('start')}>Start</Button>
@@ -193,6 +193,7 @@
193
193
  {lastFetchedAt}
194
194
  {injectionChecks}
195
195
  active={!instance || instance.status === 'running'}
196
+ mode={instance?.mode ?? 'ide'}
196
197
  />
197
198
  </div>
198
199
 
@@ -27,6 +27,9 @@
27
27
  import CoinButton from './CoinButton.svelte';
28
28
  import Avatar from './Avatar.svelte';
29
29
  import { avatars, findAvatar, type AvatarArt } from '../avatars/index.ts';
30
+ import type { InstanceMode } from '../types.ts';
31
+ import Terminal from '@lucide/svelte/icons/terminal';
32
+ import LayoutTemplate from '@lucide/svelte/icons/layout-template';
30
33
  import { installPopupBackTrap } from '../lib/popup-nav.ts';
31
34
 
32
35
  /** Every settings form action fails with the same `{ error }` shape. */
@@ -34,6 +37,7 @@
34
37
 
35
38
  let {
36
39
  pet,
40
+ defaultMode,
37
41
  defaultImage,
38
42
  builtinImage,
39
43
  disableBuildCache,
@@ -66,6 +70,7 @@
66
70
  version
67
71
  }: {
68
72
  pet?: AvatarArt;
73
+ defaultMode: InstanceMode;
69
74
  defaultImage: string;
70
75
  builtinImage: string;
71
76
  disableBuildCache: boolean;
@@ -172,6 +177,29 @@
172
177
  };
173
178
  }
174
179
 
180
+ // DB-backed, so it initializes from the prop; a per-instance choice in the picker overrides it.
181
+ // svelte-ignore state_referenced_locally
182
+ let modeChoice = $state<InstanceMode>(defaultMode);
183
+ let savingMode = $state(false);
184
+ let modeError = $state<string | null>(null);
185
+ let modeFormEl: HTMLFormElement | undefined;
186
+
187
+ const defaultModeOpts = saveOpts<{ mode: InstanceMode }>({
188
+ setSaving: (v) => (savingMode = v),
189
+ setError: (v) => (modeError = v),
190
+ setMsg: () => {},
191
+ onSuccess: (data) => {
192
+ if (data?.mode) modeChoice = data.mode;
193
+ }
194
+ });
195
+
196
+ // Set the bound hidden value then resubmit, mirroring the image reset (flushSync before requestSubmit).
197
+ function chooseMode(next: InstanceMode) {
198
+ if (next === modeChoice) return;
199
+ flushSync(() => (modeChoice = next));
200
+ modeFormEl?.requestSubmit();
201
+ }
202
+
175
203
  // svelte-ignore state_referenced_locally
176
204
  let image = $state(defaultImage);
177
205
  let savingImage = $state(false);
@@ -605,6 +633,56 @@
605
633
  </AppBar>
606
634
 
607
635
  <main class="content">
636
+ <section class="card">
637
+ <form
638
+ class="row"
639
+ method="POST"
640
+ action="?/defaultMode"
641
+ bind:this={modeFormEl}
642
+ {@attach enhance(defaultModeOpts)}
643
+ >
644
+ <div class="label">
645
+ <Terminal size={20} />
646
+ <div class="text">
647
+ <div class="name">Default editor</div>
648
+ <div class="desc">
649
+ What new instances start in. <strong>Full IDE</strong> serves browser VS Code;
650
+ <strong>Terminal</strong> is lighter — just Claude Code in a terminal, no code-server. You
651
+ can override this per instance when creating one.
652
+ </div>
653
+ </div>
654
+ </div>
655
+ <input type="hidden" name="mode" value={modeChoice} />
656
+ <div class="mode-toggle" role="group" aria-label="Default editor mode">
657
+ <button
658
+ type="button"
659
+ class="mode-btn"
660
+ class:active={modeChoice === 'ide'}
661
+ aria-pressed={modeChoice === 'ide'}
662
+ disabled={savingMode}
663
+ onclick={() => chooseMode('ide')}
664
+ >
665
+ <LayoutTemplate size={15} />
666
+ Full IDE
667
+ </button>
668
+ <button
669
+ type="button"
670
+ class="mode-btn"
671
+ class:active={modeChoice === 'terminal'}
672
+ aria-pressed={modeChoice === 'terminal'}
673
+ disabled={savingMode}
674
+ onclick={() => chooseMode('terminal')}
675
+ >
676
+ <Terminal size={15} />
677
+ Terminal
678
+ </button>
679
+ </div>
680
+ </form>
681
+ {#if modeError}
682
+ <div class="sub"><div class="msg error">{modeError}</div></div>
683
+ {/if}
684
+ </section>
685
+
608
686
  <section class="card">
609
687
  <form
610
688
  class="row image-row"
@@ -1809,6 +1887,36 @@
1809
1887
  outline: 2px solid var(--ink);
1810
1888
  outline-offset: 1px;
1811
1889
  }
1890
+ .mode-toggle {
1891
+ display: flex;
1892
+ flex: none;
1893
+ border: 1px solid var(--ink);
1894
+ }
1895
+ .mode-btn {
1896
+ display: inline-flex;
1897
+ align-items: center;
1898
+ gap: 6px;
1899
+ font-family: var(--font-mono);
1900
+ font-weight: 700;
1901
+ font-size: 12px;
1902
+ text-transform: uppercase;
1903
+ letter-spacing: 0.05em;
1904
+ padding: 7px 14px;
1905
+ border: none;
1906
+ background: var(--bg);
1907
+ color: var(--ink);
1908
+ cursor: pointer;
1909
+ }
1910
+ .mode-btn + .mode-btn {
1911
+ border-left: 1px solid var(--ink);
1912
+ }
1913
+ .mode-btn.active {
1914
+ background: var(--ink);
1915
+ color: var(--bg);
1916
+ }
1917
+ .mode-btn:disabled {
1918
+ cursor: not-allowed;
1919
+ }
1812
1920
  .switch {
1813
1921
  position: relative;
1814
1922
  flex: none;