codebay 0.11.1 → 0.12.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 (56) hide show
  1. package/.mochi/manifest.json +202 -194
  2. package/.mochi/svelte-client/HydratableIsland-4j3bazeq.js +1 -0
  3. package/.mochi/svelte-client/{_hydrate-AppShell_2rtnxn6pgp2rh-v65ctd99.js → _hydrate-AppShell_2rtnxn6pgp2rh-5kb6kh2d.js} +2 -2
  4. package/.mochi/svelte-client/{_hydrate-AvatarsView_257s9yq1wwr4f-htjfv8q9.js → _hydrate-AvatarsView_257s9yq1wwr4f-c51gt15m.js} +1 -1
  5. package/.mochi/svelte-client/{_hydrate-InstanceView_2ma45l9bybfhl-v07seqge.js → _hydrate-InstanceView_2ma45l9bybfhl-d256bw54.js} +1 -1
  6. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-rw5z89pa.js +39 -0
  7. package/.mochi/svelte-client/{chunk-wjagstre.js → chunk-1ttqgrz7.js} +1 -1
  8. package/.mochi/svelte-client/{chunk-czkxd74n.js → chunk-2eaagsfc.js} +1 -1
  9. package/.mochi/svelte-client/chunk-6v5d7dex.js +2 -0
  10. package/.mochi/svelte-client/{chunk-qx1defbm.js → chunk-a5e8vytv.js} +2 -2
  11. package/.mochi/svelte-client/{chunk-hxydjtda.js → chunk-d8rdrqsh.js} +1 -1
  12. package/.mochi/svelte-client/{chunk-566rqq4c.js → chunk-d9bszcp8.js} +1 -1
  13. package/.mochi/svelte-client/chunk-pz6dq0v3.js +2 -0
  14. package/.mochi/svelte-client/{chunk-0zkdsrth.js → chunk-s70rry6z.js} +1 -1
  15. package/.mochi/svelte-client/{chunk-79aj59e0.js → chunk-wcfbjj3x.js} +2 -2
  16. package/.mochi/svelte-client/{chunk-y9yy1004.js → chunk-y1xjps59.js} +1 -1
  17. package/.mochi/svelte-compile/{App-mzn3dqjm.server.js → App-dn3kh0xn.server.js} +5 -4
  18. package/.mochi/svelte-compile/{Avatars-c0k5jx8q.server.js → Avatars-7k5055m8.server.js} +1 -1
  19. package/.mochi/svelte-compile/{Settings-tvc5yp08.server.js → Settings-m2bfa4k4.server.js} +179 -148
  20. package/.mochi/svelte-compile/{chunk-bpqwf1nw.js → chunk-pqb403fh.js} +12 -1
  21. package/.mochi/svelte-compile/{chunk-gz6hv1h5.js → chunk-qc0f73f3.js} +114 -135
  22. package/.mochi/svelte-css/AppShell-22m1mjm8nq2hn.css +1 -0
  23. package/package.json +1 -1
  24. package/src/avatars/anchor.ts +1 -2
  25. package/src/avatars/bear.ts +2 -3
  26. package/src/avatars/cat.ts +4 -5
  27. package/src/avatars/cherry.ts +7 -8
  28. package/src/avatars/crab.ts +6 -7
  29. package/src/avatars/crown.ts +2 -3
  30. package/src/avatars/dog.ts +8 -9
  31. package/src/avatars/fish.ts +8 -9
  32. package/src/avatars/flower.ts +6 -7
  33. package/src/avatars/fox.ts +8 -9
  34. package/src/avatars/frog.ts +6 -7
  35. package/src/avatars/key.ts +2 -3
  36. package/src/avatars/mushroom.ts +4 -5
  37. package/src/avatars/octopus.ts +7 -8
  38. package/src/avatars/owl.ts +7 -8
  39. package/src/avatars/penguin.ts +8 -9
  40. package/src/avatars/planet.ts +8 -9
  41. package/src/avatars/rabbit.ts +3 -4
  42. package/src/avatars/robot.ts +6 -7
  43. package/src/avatars/snail.ts +6 -7
  44. package/src/avatars/star.ts +5 -6
  45. package/src/components/AppShell.svelte +5 -1
  46. package/src/components/SettingsView.svelte +36 -1
  47. package/src/container-injections/code-server-dark.ts +216 -0
  48. package/src/lib/devcontainer.server.ts +22 -7
  49. package/src/lib/injections.server.ts +4 -2
  50. package/src/settings.ts +13 -0
  51. package/src/shell.html +17 -6
  52. package/.mochi/svelte-client/HydratableIsland-dammyce5.js +0 -1
  53. package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-hj9yvqc4.js +0 -38
  54. package/.mochi/svelte-client/chunk-r3wxmmc0.js +0 -2
  55. package/.mochi/svelte-client/chunk-vs6pd2b4.js +0 -2
  56. package/.mochi/svelte-css/AppShell-36mpg5smnbmuw.css +0 -1
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'fox',
5
- robot: true,
6
5
  pixels: [
7
- '##....##',
8
- '##....##',
9
- '########',
10
- '###..###',
11
- '########',
12
- '########',
13
- '.######.',
14
- '..####..'
6
+ '#...+...',
7
+ '##.++...',
8
+ '###++...',
9
+ '#####..#',
10
+ '##.#####',
11
+ '#######.',
12
+ '######..',
13
+ '#####...'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'frog',
5
- robot: true,
6
5
  pixels: [
7
- '.#....#.',
8
- '###..###',
6
+ '........',
7
+ '........',
8
+ '.##..##.',
9
+ '#..##..#',
10
+ '#..##..#',
9
11
  '########',
10
12
  '########',
11
- '########',
12
- '########',
13
- '.######.',
14
- '..####..'
13
+ '.######.'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'key',
5
- robot: true,
6
5
  pixels: [
7
6
  '.####...',
8
7
  '##..##..',
9
8
  '##..##..',
10
- '##..##..',
11
9
  '.####...',
12
10
  '..##....',
13
11
  '..####..',
14
- '..##.##.'
12
+ '..##....',
13
+ '..####..'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'mushroom',
5
- robot: true,
6
5
  pixels: [
7
6
  '..####..',
8
- '.######.',
9
- '########',
10
- '########',
7
+ '.##.###.',
8
+ '#.###.##',
11
9
  '########',
12
10
  '..####..',
13
11
  '..####..',
14
- '.######.'
12
+ '..####..',
13
+ '...##...'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'octopus',
5
- robot: true,
6
5
  pixels: [
7
- '..####..',
6
+ '.#..###.',
7
+ '#..####.',
8
+ '#..#.#..',
9
+ '#..###..',
8
10
  '.######.',
9
- '###..###',
10
- '########',
11
- '########',
12
- '########',
13
- '.######.',
14
- '#.#.#.#.'
11
+ '...#####',
12
+ '##.##.#.',
13
+ '.###..##'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'owl',
5
- robot: true,
6
5
  pixels: [
6
+ '.#....#.',
7
7
  '#......#',
8
- '##....##',
9
- '.######.',
10
- '.#.##.#.',
11
- '.#.##.#.',
12
- '.######.',
13
- '.######.',
14
- '..#..#..'
8
+ '########',
9
+ '#..##..#',
10
+ '#..##..#',
11
+ '########',
12
+ '###..###',
13
+ '.###.##.'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'penguin',
5
- robot: true,
6
5
  pixels: [
7
- '..####..',
8
- '.######.',
9
- '.##..##.',
10
- '.#.##.#.',
11
- '##...##.',
12
- '##...##.',
13
- '#######.',
14
- '.#....#.'
6
+ '..++++..',
7
+ '.++++++.',
8
+ '.+.++.+.',
9
+ '.++..++.',
10
+ '++##.#++',
11
+ '+######+',
12
+ '+######+',
13
+ '.######.'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'planet',
5
- robot: true,
6
5
  pixels: [
7
- '...###..',
8
- '..#####.',
9
- '#.#####.',
10
- '.#######',
11
- '..#####.',
12
- '..####..',
13
- '.###....',
14
- '........'
6
+ '.....+++',
7
+ '...###.+',
8
+ '..####+.',
9
+ '.####+#.',
10
+ '.###+##.',
11
+ '+##+###.',
12
+ '+.+###..',
13
+ '++......'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'rabbit',
5
- robot: true,
6
5
  pixels: [
7
6
  '.#....#.',
8
7
  '.##..##.',
9
8
  '.##..##.',
10
9
  '..####..',
11
- '.######.',
10
+ '.#.##.#.',
11
+ '########',
12
12
  '###..###',
13
- '#..##..#',
14
- '.######.'
13
+ '.#.##.#.'
15
14
  ]
16
15
  };
17
16
 
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'robot',
5
- robot: true,
6
5
  pixels: [
7
- '...##...',
8
- '...#....',
9
6
  '.######.',
7
+ '...##...',
8
+ '########',
9
+ '########',
10
10
  '#.####.#',
11
- '#..##..#',
12
- '.######.',
13
- '.#####..',
14
- '.######.'
11
+ '#.####.#',
12
+ '###..###',
13
+ '########'
15
14
  ]
16
15
  };
17
16
 
@@ -2,15 +2,14 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'snail',
5
- robot: true,
6
5
  pixels: [
7
- '.####..#',
8
- '#....#.#',
6
+ '........',
7
+ '.....#.#',
8
+ '.##..#.#',
9
9
  '#.##.#.#',
10
- '#.#..#.#',
11
- '#....##.',
12
- '#...#.#.',
13
- '######.#',
10
+ '##.#.#.#',
11
+ '#..##.#.',
12
+ '########',
14
13
  '.######.'
15
14
  ]
16
15
  };
@@ -2,16 +2,15 @@ import type { AvatarArt } from './types.ts';
2
2
 
3
3
  const art: AvatarArt = {
4
4
  name: 'star',
5
- robot: true,
6
5
  pixels: [
6
+ '...#....',
7
7
  '...##...',
8
8
  '...##...',
9
- '########',
9
+ '..####..',
10
10
  '.######.',
11
- '.######.',
12
- '.##..##.',
13
- '##....##',
14
- '#......#'
11
+ '..####..',
12
+ '...##...',
13
+ '....#...'
15
14
  ]
16
15
  };
17
16
 
@@ -9,6 +9,7 @@
9
9
  import IdeLoader from './IdeLoader.svelte';
10
10
  import TerminalSplit from './TerminalSplit.svelte';
11
11
  import { playChime, unlockAudio } from '../sound.ts';
12
+ import { tabShortcutsEnabled } from '../settings.ts';
12
13
  import { liveStream } from '../live.ts';
13
14
  import { apiPost } from '../api.ts';
14
15
  import { syncTheme } from '../theme.ts';
@@ -189,7 +190,7 @@
189
190
  // for its own tabs and can't be intercepted, and Ctrl+Alt is AltGr on many European
190
191
  // layouts, so it would collide with typing punctuation.
191
192
  function jumpToTab(e: KeyboardEvent, fromFrame: boolean) {
192
- if (!e.altKey || e.ctrlKey || e.metaKey || editingId) return;
193
+ if (!e.altKey || e.ctrlKey || e.metaKey || editingId || !tabShortcutsEnabled()) return;
193
194
  // `code`, not `key`: Alt+digit emits punctuation on macOS layouts.
194
195
  const digit = /^Digit([1-9])$/.exec(e.code)?.[1];
195
196
  if (!digit) return;
@@ -440,6 +441,9 @@
440
441
  height: 100%;
441
442
  border: 0;
442
443
  display: block;
444
+ /* An embedded document takes its prefers-color-scheme from the embedder, so without this
445
+ Codebay's own light theme makes code-server's startup theme resolve light. */
446
+ color-scheme: dark;
443
447
  }
444
448
  .empty {
445
449
  position: absolute;
@@ -4,6 +4,7 @@
4
4
  import Power from '@lucide/svelte/icons/power';
5
5
  import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
6
6
  import Volume2 from '@lucide/svelte/icons/volume-2';
7
+ import Keyboard from '@lucide/svelte/icons/keyboard';
7
8
  import PawPrint from '@lucide/svelte/icons/paw-print';
8
9
  import SunMoon from '@lucide/svelte/icons/sun-moon';
9
10
  import ThemePicker from './ThemePicker.svelte';
@@ -27,7 +28,12 @@
27
28
  import { flushSync } from 'svelte';
28
29
  import { enhance } from 'mochi-framework';
29
30
  import type { MochiEnhanceOptions } from 'mochi-framework';
30
- import { soundEnabled, setSoundEnabled } from '../settings.ts';
31
+ import {
32
+ soundEnabled,
33
+ setSoundEnabled,
34
+ tabShortcutsEnabled,
35
+ setTabShortcutsEnabled
36
+ } from '../settings.ts';
31
37
  import { playChime, unlockAudio } from '../sound.ts';
32
38
  import Button from './Button.svelte';
33
39
  import CoinButton from './CoinButton.svelte';
@@ -133,6 +139,7 @@
133
139
 
134
140
  // Defaults to on during SSR, where localStorage doesn't exist.
135
141
  let sound = $state(soundEnabled());
142
+ let tabShortcuts = $state(tabShortcutsEnabled());
136
143
 
137
144
  // DB-backed, so it initializes from the prop. Undefined is the off state — the header keeps its box logo.
138
145
  // svelte-ignore state_referenced_locally
@@ -780,6 +787,11 @@
780
787
  if (on) playChime('done');
781
788
  }
782
789
 
790
+ function toggleTabShortcuts(on: boolean) {
791
+ tabShortcuts = on;
792
+ setTabShortcutsEnabled(on);
793
+ }
794
+
783
795
  // State is object-shaped (the chosen sprite), so this can't reuse the boolean `toggleOpts`.
784
796
  const petToggleOpts: MochiEnhanceOptions<{ enabled: boolean; name?: string }, ActionFailure> = {
785
797
  onPending: (v) => (savingPet = v),
@@ -1980,6 +1992,29 @@
1980
1992
  </div>
1981
1993
  </section>
1982
1994
 
1995
+ <section class="card">
1996
+ <div class="row">
1997
+ <div class="label">
1998
+ <Keyboard size={20} />
1999
+ <div class="text">
2000
+ <div class="name">Tab keyboard shortcuts</div>
2001
+ <div class="desc">
2002
+ Jump between open instance tabs with <code>Alt</code>+<code>1</code>–<code>9</code> (9 is
2003
+ always the last tab). Turn off to leave those keystrokes to the editor.
2004
+ </div>
2005
+ </div>
2006
+ </div>
2007
+ <label class="switch">
2008
+ <input
2009
+ type="checkbox"
2010
+ checked={tabShortcuts}
2011
+ onchange={(e) => toggleTabShortcuts(e.currentTarget.checked)}
2012
+ />
2013
+ <span class="track"><span class="thumb"></span></span>
2014
+ </label>
2015
+ </div>
2016
+ </section>
2017
+
1983
2018
  <section class="card">
1984
2019
  <form class="row" method="POST" action="?/petToggle" {@attach enhance(petToggleOpts)}>
1985
2020
  <div class="label">
@@ -0,0 +1,216 @@
1
+ import { checkPresence, execInContainer, type ExecTarget } from '../lib/exec.server.ts';
2
+ import { editJsonFile, readJsonFile } from '../lib/container-files.server.ts';
3
+ import { CODE_SERVER_SETTINGS } from '../lib/devcontainer.server.ts';
4
+ import type { Injection } from '../lib/injections.server.ts';
5
+
6
+ /** Locates the bundled VS Code from the binary; shared prelude for the resolve and the check. */
7
+ const WALK_TO_BUNDLE =
8
+ `p=$(command -v code-server) || exit 1; p=$(readlink -f "$p"); ` +
9
+ `while [ "$p" != "/" ]; do for c in "$p" "$p/lib/vscode"; do `;
10
+
11
+ /**
12
+ * Walks up from the resolved binary rather than assuming /usr/lib/code-server: the feature and the
13
+ * standalone tarball put the wrapper at different depths, and only one of the two layouts keeps the
14
+ * bundled VS Code directly above `bin/`. Prints the root on the last line — `bash -lc` runs profile
15
+ * scripts whose stdout comes first, so callers must read the last line, never the whole capture.
16
+ */
17
+ export const RESOLVE_ROOT_SCRIPT =
18
+ WALK_TO_BUNDLE +
19
+ `[ -d "$c/extensions/theme-defaults" ] && { printf '\\n%s\\n' "$c"; exit 0; }; done; ` +
20
+ `p=$(dirname "$p"); done; exit 1`;
21
+
22
+ /**
23
+ * Whitespace-tolerant because the shipped manifest is minified (`"uiTheme":"vs"`) while our own
24
+ * rewrite pretty-prints it — a fixed-spacing pattern would miss the unpatched file and report green.
25
+ * The closing quote is what keeps `"vs"` from also matching `"vs-dark"`.
26
+ */
27
+ const LIGHT_UI_THEME_RE = '"uiTheme"[[:space:]]*:[[:space:]]*"(vs|hc-light)"';
28
+
29
+ /**
30
+ * Resolve and probe in one exec: `check()` runs on every health tick, in the same `Promise.all` as
31
+ * the `codeServerAccessible` probe that gates mounting the IDE iframe, so a second round trip here
32
+ * would slow the boot-time fast cadence for no gain — the root can't move for a container's life.
33
+ */
34
+ export const CHECK_SCRIPT =
35
+ WALK_TO_BUNDLE +
36
+ `m="$c/extensions/theme-defaults/package.json"; ` +
37
+ `[ -f "$m" ] && { grep -qE '${LIGHT_UI_THEME_RE}' "$m" && echo 0 || echo 1; exit 0; }; done; ` +
38
+ `p=$(dirname "$p"); done; echo 0`;
39
+
40
+ /**
41
+ * The builtin-extension scan is cached against the mtime of the `extensions/` **directory**, which
42
+ * rewriting a manifest inside it does not touch — so without invalidating both the cache file and
43
+ * that mtime, the running server and the next window keep serving the pre-rewrite themes.
44
+ */
45
+ const CLEAR_BUILTIN_CACHE =
46
+ `rm -f ~/.local/share/code-server/CachedProfilesData/*/extensions.builtin.cache 2>/dev/null; ` +
47
+ `exit 0`;
48
+
49
+ const SETTINGS_DIR = '$h/.local/share/code-server/User';
50
+
51
+ /**
52
+ * VS Code matches `workbench.colorTheme` against a theme's *settingsId*, which is its manifest `id`
53
+ * (the label is only a fallback for entries without one) — so this reads ids, never the nls labels.
54
+ * The order tracks the build default (`ThemeSettingDefaults.COLOR_THEME_DARK`) across versions,
55
+ * because pinning exactly that id is what lets the workbench paint its real dark colors before any
56
+ * extension loads. Anything unrecognised falls back to whatever dark theme the build does ship.
57
+ */
58
+ const PREFERRED_DARK_IDS = ['Dark 2026', 'Default Dark Modern', 'Dark Modern', 'Dark+'];
59
+
60
+ const THEME_KEYS = [
61
+ 'workbench.colorTheme',
62
+ 'workbench.preferredDarkColorTheme',
63
+ 'workbench.preferredLightColorTheme'
64
+ ];
65
+
66
+ /** A root path we are about to interpolate into a shell script, so nothing exotic may pass. */
67
+ const SAFE_PATH = /^\/[A-Za-z0-9._+\-/]+$/;
68
+
69
+ /** Mirrors `checkPresence`: only the last line is ours, everything before it is login-shell noise. */
70
+ function lastLine(stdout: string): string {
71
+ return stdout.trimEnd().split('\n').pop()?.trim() ?? '';
72
+ }
73
+
74
+ interface ThemeContribution {
75
+ id?: string;
76
+ uiTheme?: string;
77
+ path?: string;
78
+ }
79
+
80
+ function themesOf(manifest: Record<string, unknown>): ThemeContribution[] | null {
81
+ const contributes = manifest.contributes;
82
+ if (typeof contributes !== 'object' || contributes === null) return null;
83
+ const themes = (contributes as Record<string, unknown>).themes;
84
+ return Array.isArray(themes) ? (themes as ThemeContribution[]) : null;
85
+ }
86
+
87
+ /** Null when the build ships no identifiable dark theme — better to leave the staged settings alone than pin an id nothing resolves. */
88
+ export function pickDarkThemeId(manifest: Record<string, unknown>): string | null {
89
+ const ids = (themesOf(manifest) ?? [])
90
+ .filter((t) => t?.uiTheme === 'vs-dark' && typeof t.id === 'string' && t.id.trim())
91
+ .map((t) => t.id!.trim());
92
+ if (!ids.length) return null;
93
+ return PREFERRED_DARK_IDS.find((id) => ids.includes(id)) ?? ids[0]!;
94
+ }
95
+
96
+ /**
97
+ * Selecting by `uiTheme` rather than by filename is load-bearing: the light set is
98
+ * 2026-light/light_modern/light_plus/light_vs *and* hc_light, so a `light*.json` glob silently
99
+ * misses two of them. Repointing `path` too, since the light JSONs stay light on disk.
100
+ */
101
+ export function darkenThemeManifest(manifest: Record<string, unknown>): {
102
+ next: Record<string, unknown>;
103
+ changed: number;
104
+ } {
105
+ const themes = themesOf(manifest);
106
+ if (!themes) return { next: manifest, changed: 0 };
107
+
108
+ const pathFor = (ui: string) =>
109
+ themes.find((t) => t?.uiTheme === ui && typeof t.path === 'string')?.path;
110
+ const darkPath = pathFor('vs-dark');
111
+ const hcDarkPath = pathFor('hc-black');
112
+
113
+ let changed = 0;
114
+ const nextThemes = themes.map((theme) => {
115
+ if (theme?.uiTheme === 'vs' && darkPath) {
116
+ changed++;
117
+ return { ...theme, uiTheme: 'vs-dark', path: darkPath };
118
+ }
119
+ if (theme?.uiTheme === 'hc-light' && hcDarkPath) {
120
+ changed++;
121
+ return { ...theme, uiTheme: 'hc-black', path: hcDarkPath };
122
+ }
123
+ return theme;
124
+ });
125
+
126
+ const contributes = manifest.contributes as Record<string, unknown>;
127
+ return {
128
+ next: { ...manifest, contributes: { ...contributes, themes: nextThemes } },
129
+ changed
130
+ };
131
+ }
132
+
133
+ /** Only the writes need root; resolution runs as the remote user, whose PATH also carries a per-user standalone install. */
134
+ const rootTarget = (target: ExecTarget): ExecTarget => ({ containerId: target.containerId });
135
+
136
+ async function resolveRoot(target: ExecTarget): Promise<string | null> {
137
+ const res = await execInContainer(target, { script: RESOLVE_ROOT_SCRIPT, capture: true });
138
+ if (!res.ok) return null;
139
+ const root = lastLine(res.stdout);
140
+ return SAFE_PATH.test(root) ? root : null;
141
+ }
142
+
143
+ /**
144
+ * VS Code Web's pre-extension paint falls back to `isWeb ? LIGHT : DARK` whenever it has no usable
145
+ * cached theme, so pinning the theme in settings alone can never keep the first frame dark. This
146
+ * closes both halves: it pins the theme id *this* build uses (so the workbench stops discarding its
147
+ * own cache), and rewrites the bundled manifest so the light themes render dark if one is selected.
148
+ */
149
+ export const codeServerDark: Injection = {
150
+ id: 'code-server-dark',
151
+ label: 'code-server dark theme',
152
+ // code-server-only; terminal-mode instances never run a workbench.
153
+ modes: ['ide'],
154
+
155
+ async apply(target, log) {
156
+ log('Forcing code-server dark theme…\n');
157
+ const root = await resolveRoot(target);
158
+ if (!root) {
159
+ log('⚠ code-server install not found — dark theme left to the staged settings\n');
160
+ return;
161
+ }
162
+
163
+ const themeDir = `${root}/extensions/theme-defaults`;
164
+ const manifest = await readJsonFile(target, { dir: themeDir, name: 'package.json' });
165
+ if (!manifest) {
166
+ log('⚠ Could not read the bundled theme manifest — dark theme left to the staged settings\n');
167
+ return;
168
+ }
169
+
170
+ const darkTheme = pickDarkThemeId(manifest);
171
+ if (!darkTheme) {
172
+ log('⚠ No dark theme found in the bundled manifest — staged settings left as they are\n');
173
+ } else {
174
+ // Seeded from the staged defaults so this still writes a complete file on the path where
175
+ // the launcher's own copy silently failed, rather than a settings.json of three keys.
176
+ const settings = await editJsonFile(
177
+ target,
178
+ { dir: SETTINGS_DIR, name: 'settings.json' },
179
+ (current) => ({
180
+ ...CODE_SERVER_SETTINGS,
181
+ ...current,
182
+ ...Object.fromEntries(THEME_KEYS.map((k) => [k, darkTheme]))
183
+ })
184
+ );
185
+ log(
186
+ settings.ok
187
+ ? `✓ Pinned code-server theme to "${darkTheme}"\n`
188
+ : `⚠ Could not pin the code-server theme: ${settings.error}\n`
189
+ );
190
+ }
191
+
192
+ if (darkenThemeManifest(manifest).changed === 0) {
193
+ log('· No light themes to darken in this build\n');
194
+ return;
195
+ }
196
+ const written = await editJsonFile(
197
+ rootTarget(target),
198
+ { dir: themeDir, name: 'package.json' },
199
+ (current) => darkenThemeManifest(current).next
200
+ );
201
+ if (!written.ok) {
202
+ log(`⚠ Could not darken the built-in light themes: ${written.error}\n`);
203
+ return;
204
+ }
205
+ await execInContainer(target, { script: CLEAR_BUILTIN_CACHE });
206
+ // Bumping the scan's cache key; root because the bundled tree isn't the remote user's.
207
+ await execInContainer(rootTarget(target), {
208
+ script: `touch "${root}/extensions" 2>/dev/null; exit 0`
209
+ });
210
+ log('✓ Built-in light themes now render dark\n');
211
+ },
212
+
213
+ check(target) {
214
+ return checkPresence(target, CHECK_SCRIPT);
215
+ }
216
+ };
@@ -124,12 +124,17 @@ const MANAGER_GIT_EXCLUDES = [
124
124
  const EXCLUDE_MARKER_START = '# >>> codebay (auto-generated) >>>';
125
125
  const EXCLUDE_MARKER_END = '# <<< codebay <<<';
126
126
 
127
- const CODE_SERVER_SETTINGS = {
127
+ export const CODE_SERVER_SETTINGS = {
128
+ // Overwritten at boot by the code-server-dark injection with the id this build actually
129
+ // ships; the legacy label only has to carry a code-server old enough to still use it.
128
130
  'workbench.colorTheme': 'Default Dark Modern',
129
- // Stop VS Code re-resolving the theme from the OS/browser color scheme at runtime —
130
- // that re-resolution (fired on focus/tab-switch) is what randomly flips the editor to
131
- // light. Pinning both preferred themes to dark keeps either branch dark even if it does.
132
- 'window.autoDetectColorScheme': false,
131
+ // Counter-intuitive, and the opposite of what this file used to say: VS Code Web's
132
+ // pre-extension paint is `getPreferredColorScheme() ?? (isWeb ? LIGHT : DARK)`, and
133
+ // getPreferredColorScheme returns undefined unless detection is on — so turning detection
134
+ // OFF is what forced that first paint to light. On is only safe because both preferred
135
+ // themes below are the same dark theme, making either branch dark.
136
+ 'window.autoDetectColorScheme': true,
137
+ // Left off so the high-contrast branch (which could resolve hcLight) stays unreachable.
133
138
  'window.autoDetectHighContrast': false,
134
139
  'workbench.preferredDarkColorTheme': 'Default Dark Modern',
135
140
  'workbench.preferredLightColorTheme': 'Default Dark Modern',
@@ -241,10 +246,20 @@ const terminalTask = (permissionMode: ClaudePermissionMode) => ({
241
246
  problemMatcher: []
242
247
  });
243
248
 
249
+ const CODE_SERVER_USER_DIR = '~/.local/share/code-server/User';
250
+
251
+ /**
252
+ * Seeds the file only when it's missing. `relaunchSurface` re-runs this launcher *after* the
253
+ * injections have rewritten the theme id to the one the installed build uses, so an unconditional
254
+ * copy would put the stale staged id back and re-arm the very cache mismatch this all exists to
255
+ * avoid. A rebuild always gets a fresh container, so the staged defaults still land on every build.
256
+ * `[ -f ]` rather than `cp -n`, which busybox coreutils don't reliably carry.
257
+ */
244
258
  const CODE_SERVER_APPLY_SETTINGS =
245
- `mkdir -p ~/.local/share/code-server/User && ` +
259
+ `mkdir -p ${CODE_SERVER_USER_DIR}; ` +
260
+ `[ -f ${CODE_SERVER_USER_DIR}/settings.json ] || ` +
246
261
  `cp -f \\"$PWD/.devcontainer/${CODE_SERVER_SETTINGS_FILE}\\" ` +
247
- `~/.local/share/code-server/User/settings.json 2>/dev/null;`;
262
+ `${CODE_SERVER_USER_DIR}/settings.json 2>/dev/null;`;
248
263
 
249
264
  // Fully detached (redirects + </dev/null + &) because `devcontainer up` waits for postStart's
250
265
  // pipes to close — an attached download would keep the whole boot on the Open VSX critical path.
@@ -8,6 +8,7 @@ import { gitIdentity } from '../container-injections/git-identity.ts';
8
8
  import { claudeCodeCredentials } from '../container-injections/claude-code-credentials.ts';
9
9
  import { claudeCodeCustom } from '../container-injections/claude-code-custom.ts';
10
10
  import { claudeCodeIdeExtension } from '../container-injections/claude-code-ide-extension.ts';
11
+ import { codeServerDark } from '../container-injections/code-server-dark.ts';
11
12
  import { claudeCodeInstall } from '../container-injections/claude-code-install.ts';
12
13
  import { claudeCodeUpdate } from '../container-injections/claude-code-update.ts';
13
14
  import { claudeCodeModels } from '../container-injections/claude-code-models.ts';
@@ -69,8 +70,9 @@ function buildStages(claudeInjection: Injection): Injection[][] {
69
70
  claudeEffortLevel
70
71
  ],
71
72
  // git-identity needs stage 1's safe.directory; ttyd shares the apt/dpkg lock with tmux and
72
- // the /usr/local/bin symlink with claude-code-install, so it trails both.
73
- [gitIdentity, attentionHooks, claudeCodeModels, ttyd, claudeCodeUpdate],
73
+ // the /usr/local/bin symlink with claude-code-install, so it trails both. code-server-dark
74
+ // drops the extension cache, so it must not run while stage 1 is still installing into it.
75
+ [gitIdentity, attentionHooks, claudeCodeModels, ttyd, claudeCodeUpdate, codeServerDark],
74
76
  [githubCredentials, claudeStatusline, claudePermissionMode],
75
77
  [claudeModel, claudeAliases],
76
78
  // claude-trust edits ~/.claude.json, which the stage-1 Claude slot also writes.