codebay 0.8.0 → 0.9.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.
- package/.mochi/import-css/xterm-1yy1hf1g.css +252 -0
- package/.mochi/manifest.json +330 -211
- package/.mochi/svelte-client/HydratableIsland-k6q8esj4.js +1 -0
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-sg96rp17.js +2 -0
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-92hepzby.js +14 -0
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-p3f649gk.js +1 -0
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-dzmgmv78.js +29 -0
- package/.mochi/svelte-client/chunk-2j95sv83.js +1 -0
- package/.mochi/svelte-client/chunk-6rbs0mhx.js +1 -0
- package/.mochi/svelte-client/chunk-7wkvn2vx.js +2 -0
- package/.mochi/svelte-client/chunk-a2psjmrm.js +2 -0
- package/.mochi/svelte-client/chunk-m497q88s.js +2 -0
- package/.mochi/svelte-client/{chunk-fqg2qzj8.js → chunk-m4fzsd5b.js} +1 -1
- package/.mochi/svelte-client/chunk-mpv3qx28.js +36 -0
- package/.mochi/svelte-client/chunk-rs41by6w.js +2 -0
- package/.mochi/svelte-client/{chunk-qj7zy2d9.js → chunk-v65n8th6.js} +4 -4
- package/.mochi/svelte-compile/{App-6rcf0d74.server.js → App-7vjcp38v.server.js} +368 -37
- package/.mochi/svelte-compile/{Avatars-pzyz0tv3.server.js → Avatars-e2cfc95t.server.js} +130 -25
- package/.mochi/svelte-compile/{ClientStats-z3tn9ad6.server.js → ClientStats-4cphmrnp.server.js} +1 -1
- package/.mochi/svelte-compile/{DefaultError-wh77vebf.server.js → DefaultError-je52k17d.server.js} +1 -1
- package/.mochi/svelte-compile/{Instance-49ksrp11.server.js → Instance-h78n2dsv.server.js} +16 -13
- package/.mochi/svelte-compile/{Settings-sz5d6qj7.server.js → Settings-tav2asn7.server.js} +153 -127
- package/.mochi/svelte-compile/{chunk-fpe16ywq.js → chunk-60mpagr9.js} +17 -7
- package/.mochi/svelte-compile/chunk-9nqc2r0y.js +9706 -0
- package/.mochi/svelte-compile/{chunk-6ghzacpg.js → chunk-bpqwf1nw.js} +36 -4
- package/.mochi/svelte-compile/chunk-jx6h1xzp.js +20 -0
- package/.mochi/svelte-compile/{chunk-q8rbesaw.js → chunk-m8ygpyhr.js} +5 -4
- package/.mochi/svelte-compile/chunk-rtdne1m8.js +31 -0
- package/.mochi/svelte-compile/{chunk-we0ggtws.js → chunk-xpt1a7jm.js} +2 -1
- package/.mochi/svelte-css/AvatarEditor-2mdq8ss346drw.css +1 -0
- package/.mochi/svelte-css/{AvatarsView-2ughbzc26luyb.css → AvatarsView-2llo48w0wt0xn.css} +1 -1
- package/.mochi/svelte-css/FolderBrowser-2pxp9z1o7zixk.css +1 -0
- package/.mochi/svelte-css/{IdeBar-2qoqi2ya9zdn3.css → IdeBar-10xodudcx57qr.css} +1 -1
- package/.mochi/svelte-css/{SettingsView-3uycxa8z548m4.css → SettingsView-2qfauz68axro1.css} +1 -1
- package/.mochi/svelte-css/TerminalPane-3bwgk6ulzq6b3.css +1 -0
- package/.mochi/svelte-css/TerminalSplit-2416xfuln89a3.css +1 -0
- package/migrations/0005_instance_mode.sql +1 -0
- package/migrations/0006_terminal_split.sql +1 -0
- package/package.json +3 -1
- package/src/avatars/serialize.ts +35 -6
- package/src/components/AppShell.svelte +46 -8
- package/src/components/Avatar.svelte +3 -2
- package/src/components/AvatarEditor.svelte +96 -19
- package/src/components/AvatarsView.svelte +0 -0
- package/src/components/DashboardView.svelte +22 -7
- package/src/components/FolderBrowser.svelte +88 -8
- package/src/components/HealthBox.svelte +12 -5
- package/src/components/IdeBar.svelte +10 -0
- package/src/components/InstanceCard.svelte +3 -1
- package/src/components/InstanceView.svelte +1 -0
- package/src/components/SettingsView.svelte +108 -0
- package/src/components/TerminalPane.svelte +274 -0
- package/src/components/TerminalSplit.svelte +154 -0
- package/src/container-injections/claude-code-ide-extension.ts +2 -0
- package/src/container-injections/ttyd.ts +49 -0
- package/src/lib/config.server.ts +3 -0
- package/src/lib/db.server.ts +14 -4
- package/src/lib/devcontainer.server.ts +136 -22
- package/src/lib/health.server.ts +1 -1
- package/src/lib/injections.server.ts +15 -5
- package/src/lib/instances.server.ts +64 -10
- package/src/lib/proxy.server.ts +87 -11
- package/src/pages/App.svelte +5 -3
- package/src/pages/Settings.svelte +4 -0
- package/src/routes.ts +55 -6
- package/src/types.ts +18 -0
- package/.mochi/svelte-client/HydratableIsland-qyjx1d98.js +0 -1
- package/.mochi/svelte-client/_hydrate-AppShell_2rtnxn6pgp2rh-fkna7bj2.js +0 -2
- package/.mochi/svelte-client/_hydrate-AvatarsView_257s9yq1wwr4f-89wtkb86.js +0 -13
- package/.mochi/svelte-client/_hydrate-InstanceView_2ma45l9bybfhl-eamnbyqk.js +0 -1
- package/.mochi/svelte-client/_hydrate-SettingsView_8hmarwmf8onq-kdra4mcb.js +0 -28
- package/.mochi/svelte-client/chunk-330mrspw.js +0 -2
- package/.mochi/svelte-client/chunk-4jgkak13.js +0 -1
- package/.mochi/svelte-client/chunk-k5m4kt20.js +0 -2
- package/.mochi/svelte-client/chunk-rpdphch7.js +0 -2
- package/.mochi/svelte-compile/chunk-bacb4b0r.js +0 -14
- package/.mochi/svelte-css/AvatarEditor-2ewwtejaml4e2.css +0 -1
- package/.mochi/svelte-css/FolderBrowser-1x428zb3f2kti.css +0 -1
- /package/.mochi/svelte-compile/{chunk-btmcxhqy.js → chunk-brjv9pyd.js} +0 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, type Snippet } from 'svelte';
|
|
3
|
+
import '@xterm/xterm/css/xterm.css';
|
|
4
|
+
import type { Terminal } from '@xterm/xterm';
|
|
5
|
+
import type { FitAddon } from '@xterm/addon-fit';
|
|
6
|
+
import RotateCw from '@lucide/svelte/icons/rotate-cw';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
id,
|
|
10
|
+
active,
|
|
11
|
+
/** ttyd `--url-arg` value selecting which session to attach to; omitted means Claude's. */
|
|
12
|
+
arg,
|
|
13
|
+
/** Split view: only one of the two panes may claim the keyboard when the tab activates. */
|
|
14
|
+
focus = true,
|
|
15
|
+
/** Extra overlay buttons, rendered left of this pane's own reload button. */
|
|
16
|
+
actions
|
|
17
|
+
}: {
|
|
18
|
+
id: string;
|
|
19
|
+
active: boolean;
|
|
20
|
+
arg?: string;
|
|
21
|
+
focus?: boolean;
|
|
22
|
+
actions?: Snippet;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
let el: HTMLDivElement;
|
|
26
|
+
let term: Terminal | undefined;
|
|
27
|
+
let fit: FitAddon | undefined;
|
|
28
|
+
let ws: WebSocket | undefined;
|
|
29
|
+
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
30
|
+
let disposed = false;
|
|
31
|
+
let connected = $state(false);
|
|
32
|
+
// Bumped whenever a socket is abandoned, so its late handlers can't resurrect a retry loop.
|
|
33
|
+
let gen = 0;
|
|
34
|
+
|
|
35
|
+
// ttyd's wire protocol: a single command byte prefixes every frame, sent as binary
|
|
36
|
+
// (matching ttyd's own client — libwebsockets delivers the raw bytes to the server).
|
|
37
|
+
const CMD_INPUT = '0';
|
|
38
|
+
const CMD_RESIZE = '1';
|
|
39
|
+
const OUTPUT = 0x30; // '0'
|
|
40
|
+
const encoder = new TextEncoder();
|
|
41
|
+
|
|
42
|
+
function send(data: string) {
|
|
43
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onFrame(data: ArrayBuffer) {
|
|
47
|
+
const bytes = new Uint8Array(data);
|
|
48
|
+
if (bytes.length === 0) return;
|
|
49
|
+
// Only OUTPUT carries terminal bytes; SET_WINDOW_TITLE/SET_PREFERENCES are ignored.
|
|
50
|
+
if (bytes[0] === OUTPUT) term?.write(bytes.subarray(1));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function connect() {
|
|
54
|
+
const myGen = ++gen;
|
|
55
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
56
|
+
// Same-origin through the proxy, which forwards the required `tty` subprotocol to ttyd
|
|
57
|
+
// and passes the query string through untouched.
|
|
58
|
+
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
|
|
59
|
+
const sock = new WebSocket(`${proto}//${location.host}/p/${id}/ws${query}`, ['tty']);
|
|
60
|
+
ws = sock;
|
|
61
|
+
sock.binaryType = 'arraybuffer';
|
|
62
|
+
sock.onopen = () => {
|
|
63
|
+
if (gen !== myGen) return;
|
|
64
|
+
connected = true;
|
|
65
|
+
// ttyd's init frame is raw JSON (its leading `{` is the JSON_DATA command byte).
|
|
66
|
+
send(JSON.stringify({ AuthToken: '', columns: term?.cols ?? 80, rows: term?.rows ?? 24 }));
|
|
67
|
+
};
|
|
68
|
+
sock.onmessage = (e) => {
|
|
69
|
+
if (gen === myGen) onFrame(e.data as ArrayBuffer);
|
|
70
|
+
};
|
|
71
|
+
sock.onclose = () => {
|
|
72
|
+
if (gen !== myGen || disposed) return;
|
|
73
|
+
connected = false;
|
|
74
|
+
retry = setTimeout(connect, 1000);
|
|
75
|
+
};
|
|
76
|
+
sock.onerror = () => {
|
|
77
|
+
try {
|
|
78
|
+
sock.close();
|
|
79
|
+
} catch {
|
|
80
|
+
/* already closing */
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Rebuild only the client half — the socket and xterm's buffer — and reattach to the same tmux
|
|
87
|
+
* session, so a wedged socket (dead wifi, resumed laptop) recovers without touching the container.
|
|
88
|
+
*/
|
|
89
|
+
function reload() {
|
|
90
|
+
if (retry) clearTimeout(retry);
|
|
91
|
+
gen++;
|
|
92
|
+
connected = false;
|
|
93
|
+
// tmux repaints the current screen on attach; without a reset that repaint lands under stale output.
|
|
94
|
+
term?.reset();
|
|
95
|
+
const old = ws;
|
|
96
|
+
ws = undefined;
|
|
97
|
+
|
|
98
|
+
let started = false;
|
|
99
|
+
const start = () => {
|
|
100
|
+
if (started || disposed) return;
|
|
101
|
+
started = true;
|
|
102
|
+
fitSafe();
|
|
103
|
+
connect();
|
|
104
|
+
if (active && focus) term?.focus();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (old && old.readyState !== WebSocket.CLOSED) {
|
|
108
|
+
// Let the old tmux client detach first, or tmux sizes the window to the smaller of the two.
|
|
109
|
+
old.addEventListener('close', start, { once: true });
|
|
110
|
+
setTimeout(start, 1500);
|
|
111
|
+
try {
|
|
112
|
+
old.close();
|
|
113
|
+
} catch {
|
|
114
|
+
/* already closing */
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
start();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function fitSafe() {
|
|
122
|
+
// A hidden pane (display:none) has no size, so fitting there would throw or size to 0.
|
|
123
|
+
if (el?.clientWidth > 0 && el?.clientHeight > 0) {
|
|
124
|
+
try {
|
|
125
|
+
fit?.fit();
|
|
126
|
+
} catch {
|
|
127
|
+
/* transient zero-size during layout */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function applyTheme() {
|
|
133
|
+
if (!term) return;
|
|
134
|
+
const cs = getComputedStyle(document.documentElement);
|
|
135
|
+
const bg = cs.getPropertyValue('--bg').trim() || '#1a1a1a';
|
|
136
|
+
const fg = cs.getPropertyValue('--ink').trim() || '#e6e6e6';
|
|
137
|
+
term.options.theme = { background: bg, foreground: fg, cursor: fg, cursorAccent: bg };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onMount(() => {
|
|
141
|
+
let themeObs: MutationObserver | undefined;
|
|
142
|
+
(async () => {
|
|
143
|
+
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
|
144
|
+
import('@xterm/xterm'),
|
|
145
|
+
import('@xterm/addon-fit')
|
|
146
|
+
]);
|
|
147
|
+
if (disposed) return;
|
|
148
|
+
term = new Terminal({
|
|
149
|
+
fontFamily: "'JetBrains Mono Variable', ui-monospace, monospace",
|
|
150
|
+
fontSize: 13,
|
|
151
|
+
cursorBlink: true,
|
|
152
|
+
scrollback: 10000,
|
|
153
|
+
allowProposedApi: true
|
|
154
|
+
});
|
|
155
|
+
fit = new FitAddon();
|
|
156
|
+
term.loadAddon(fit);
|
|
157
|
+
term.open(el);
|
|
158
|
+
applyTheme();
|
|
159
|
+
fitSafe();
|
|
160
|
+
term.onData((d) => send(CMD_INPUT + d));
|
|
161
|
+
term.onResize(({ cols, rows }) => send(CMD_RESIZE + JSON.stringify({ columns: cols, rows })));
|
|
162
|
+
// The theme cookie flips data-theme on <html>; re-derive xterm's colors when it does.
|
|
163
|
+
themeObs = new MutationObserver(applyTheme);
|
|
164
|
+
themeObs.observe(document.documentElement, {
|
|
165
|
+
attributes: true,
|
|
166
|
+
attributeFilter: ['data-theme']
|
|
167
|
+
});
|
|
168
|
+
connect();
|
|
169
|
+
if (active && focus) term.focus();
|
|
170
|
+
})();
|
|
171
|
+
|
|
172
|
+
// Observing the element rather than the window also covers split-divider drags and the
|
|
173
|
+
// split opening or closing, which don't resize the window at all.
|
|
174
|
+
const sizeObs = new ResizeObserver(() => fitSafe());
|
|
175
|
+
sizeObs.observe(el);
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
disposed = true;
|
|
179
|
+
sizeObs.disconnect();
|
|
180
|
+
themeObs?.disconnect();
|
|
181
|
+
if (retry) clearTimeout(retry);
|
|
182
|
+
try {
|
|
183
|
+
ws?.close();
|
|
184
|
+
} catch {
|
|
185
|
+
/* already closing */
|
|
186
|
+
}
|
|
187
|
+
term?.dispose();
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// A hidden xterm can't measure itself, so refit/focus the moment this pane becomes active.
|
|
192
|
+
$effect(() => {
|
|
193
|
+
if (active && term) {
|
|
194
|
+
fitSafe();
|
|
195
|
+
if (focus) term.focus();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
<div class="term" bind:this={el}></div>
|
|
201
|
+
<div class="overlay">
|
|
202
|
+
{#if !connected}
|
|
203
|
+
<span class="status">connecting…</span>
|
|
204
|
+
{/if}
|
|
205
|
+
{@render actions?.()}
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onclick={reload}
|
|
209
|
+
title="Reload terminal (reconnects, keeps the session)"
|
|
210
|
+
aria-label="Reload terminal"><RotateCw size={14} /></button
|
|
211
|
+
>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<style>
|
|
215
|
+
.term {
|
|
216
|
+
width: 100%;
|
|
217
|
+
height: 100%;
|
|
218
|
+
background: var(--bg);
|
|
219
|
+
padding: 6px 8px;
|
|
220
|
+
box-sizing: border-box;
|
|
221
|
+
}
|
|
222
|
+
/* xterm's viewport should own the scroll, not the pane. */
|
|
223
|
+
.term :global(.xterm),
|
|
224
|
+
.term :global(.xterm-viewport) {
|
|
225
|
+
height: 100%;
|
|
226
|
+
}
|
|
227
|
+
/* Floats over live terminal output, so only the button itself takes clicks. */
|
|
228
|
+
.overlay {
|
|
229
|
+
position: absolute;
|
|
230
|
+
top: 6px;
|
|
231
|
+
right: 8px;
|
|
232
|
+
display: flex;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 8px;
|
|
235
|
+
pointer-events: none;
|
|
236
|
+
}
|
|
237
|
+
.status {
|
|
238
|
+
font-family: var(--font-mono);
|
|
239
|
+
font-size: 11px;
|
|
240
|
+
letter-spacing: 0.05em;
|
|
241
|
+
text-transform: uppercase;
|
|
242
|
+
color: var(--ink-faint);
|
|
243
|
+
}
|
|
244
|
+
/* `:global` so buttons handed in through the `actions` snippet — which carry the parent
|
|
245
|
+
component's scope, not ours — pick up the same styling. */
|
|
246
|
+
.overlay :global(button) {
|
|
247
|
+
pointer-events: auto;
|
|
248
|
+
appearance: none;
|
|
249
|
+
display: inline-flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
justify-content: center;
|
|
252
|
+
width: 24px;
|
|
253
|
+
height: 24px;
|
|
254
|
+
padding: 0;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
background: var(--bg);
|
|
257
|
+
border: 1px solid var(--rule);
|
|
258
|
+
color: var(--ink);
|
|
259
|
+
opacity: 0.3;
|
|
260
|
+
transition: opacity 0.15s ease;
|
|
261
|
+
}
|
|
262
|
+
.overlay :global(button:hover),
|
|
263
|
+
.overlay :global(button:focus-visible),
|
|
264
|
+
.overlay :global(button[aria-pressed='true']) {
|
|
265
|
+
opacity: 1;
|
|
266
|
+
background: var(--ink);
|
|
267
|
+
color: var(--bg);
|
|
268
|
+
}
|
|
269
|
+
@media (prefers-reduced-motion: reduce) {
|
|
270
|
+
.overlay :global(button) {
|
|
271
|
+
transition: none;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
</style>
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import PanelRightOpen from '@lucide/svelte/icons/panel-right-open';
|
|
3
|
+
import PanelRightClose from '@lucide/svelte/icons/panel-right-close';
|
|
4
|
+
import TerminalPane from './TerminalPane.svelte';
|
|
5
|
+
import { apiPost } from '../api.ts';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
id,
|
|
9
|
+
active,
|
|
10
|
+
initialOpen = false
|
|
11
|
+
}: { id: string; active: boolean; initialOpen?: boolean } = $props();
|
|
12
|
+
|
|
13
|
+
// Seeding from the persisted flag is intentional — from here on the toggle owns the state.
|
|
14
|
+
// svelte-ignore state_referenced_locally
|
|
15
|
+
let open = $state(initialOpen);
|
|
16
|
+
// Once mounted the shell pane is only ever hidden, never torn down, so its xterm buffer and
|
|
17
|
+
// socket survive a close/reopen.
|
|
18
|
+
// svelte-ignore state_referenced_locally
|
|
19
|
+
let mounted = $state(initialOpen);
|
|
20
|
+
let ratio = $state(50);
|
|
21
|
+
let focusSide = $state<'left' | 'right'>('left');
|
|
22
|
+
let container: HTMLDivElement;
|
|
23
|
+
|
|
24
|
+
function toggle() {
|
|
25
|
+
open = !open;
|
|
26
|
+
if (open) mounted = true;
|
|
27
|
+
focusSide = open ? 'right' : 'left';
|
|
28
|
+
// Best-effort: a failed write only costs the restore-on-reload, never the split itself.
|
|
29
|
+
void apiPost(`/api/instances/${id}/split`, { open }).catch(() => {});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function clamp(pct: number): number {
|
|
33
|
+
return Math.min(80, Math.max(20, pct));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function dragTo(clientX: number) {
|
|
37
|
+
const rect = container.getBoundingClientRect();
|
|
38
|
+
if (rect.width > 0) ratio = clamp(((clientX - rect.left) / rect.width) * 100);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function onDividerDown(e: PointerEvent) {
|
|
42
|
+
e.preventDefault(); // or the drag selects terminal text instead
|
|
43
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onDividerMove(e: PointerEvent) {
|
|
47
|
+
if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) dragTo(e.clientX);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function onDividerKey(e: KeyboardEvent) {
|
|
51
|
+
if (e.key === 'ArrowLeft') ratio = clamp(ratio - 5);
|
|
52
|
+
else if (e.key === 'ArrowRight') ratio = clamp(ratio + 5);
|
|
53
|
+
else return;
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
{#snippet splitToggle()}
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onclick={toggle}
|
|
62
|
+
aria-pressed={open}
|
|
63
|
+
title={open ? 'Hide the shell pane' : 'Open a shell alongside Claude'}
|
|
64
|
+
aria-label={open ? 'Hide the shell pane' : 'Open a shell alongside Claude'}
|
|
65
|
+
>
|
|
66
|
+
{#if open}<PanelRightClose size={14} />{:else}<PanelRightOpen size={14} />{/if}
|
|
67
|
+
</button>
|
|
68
|
+
{/snippet}
|
|
69
|
+
|
|
70
|
+
<div class="split" bind:this={container}>
|
|
71
|
+
<!-- Clicking a terminal focuses it anyway; capture that so tab switches restore the same side. -->
|
|
72
|
+
<div
|
|
73
|
+
class="side"
|
|
74
|
+
style:flex-basis={open ? `${ratio}%` : '100%'}
|
|
75
|
+
onpointerdowncapture={() => (focusSide = 'left')}
|
|
76
|
+
>
|
|
77
|
+
<TerminalPane
|
|
78
|
+
{id}
|
|
79
|
+
{active}
|
|
80
|
+
focus={active && focusSide === 'left'}
|
|
81
|
+
actions={open ? undefined : splitToggle}
|
|
82
|
+
/>
|
|
83
|
+
</div>
|
|
84
|
+
{#if mounted}
|
|
85
|
+
<!-- A focusable separator is the ARIA window-splitter pattern; the rule doesn't model it. -->
|
|
86
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
87
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
88
|
+
<div
|
|
89
|
+
class="divider"
|
|
90
|
+
class:hidden={!open}
|
|
91
|
+
role="separator"
|
|
92
|
+
aria-orientation="vertical"
|
|
93
|
+
aria-label="Resize terminal panes"
|
|
94
|
+
aria-valuenow={Math.round(ratio)}
|
|
95
|
+
aria-valuemin={20}
|
|
96
|
+
aria-valuemax={80}
|
|
97
|
+
tabindex="0"
|
|
98
|
+
onpointerdown={onDividerDown}
|
|
99
|
+
onpointermove={onDividerMove}
|
|
100
|
+
ondblclick={() => (ratio = 50)}
|
|
101
|
+
onkeydown={onDividerKey}
|
|
102
|
+
></div>
|
|
103
|
+
<div
|
|
104
|
+
class="side"
|
|
105
|
+
class:hidden={!open}
|
|
106
|
+
style:flex-basis="{100 - ratio}%"
|
|
107
|
+
onpointerdowncapture={() => (focusSide = 'right')}
|
|
108
|
+
>
|
|
109
|
+
<TerminalPane
|
|
110
|
+
{id}
|
|
111
|
+
arg="shell"
|
|
112
|
+
active={active && open}
|
|
113
|
+
focus={active && open && focusSide === 'right'}
|
|
114
|
+
actions={open ? splitToggle : undefined}
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<style>
|
|
121
|
+
.split {
|
|
122
|
+
display: flex;
|
|
123
|
+
width: 100%;
|
|
124
|
+
height: 100%;
|
|
125
|
+
}
|
|
126
|
+
/* Positioned so each pane's absolute overlay anchors to its own half, not the whole split. */
|
|
127
|
+
.side {
|
|
128
|
+
position: relative;
|
|
129
|
+
/* No grow: the two flex-basis percentages are the split, the divider eats the rounding. */
|
|
130
|
+
flex: 0 1 auto;
|
|
131
|
+
min-width: 0;
|
|
132
|
+
}
|
|
133
|
+
.side.hidden {
|
|
134
|
+
display: none;
|
|
135
|
+
}
|
|
136
|
+
.divider {
|
|
137
|
+
flex: none;
|
|
138
|
+
width: 7px;
|
|
139
|
+
margin: 0 -3px; /* a wider grab target than the 1px rule it draws */
|
|
140
|
+
cursor: col-resize;
|
|
141
|
+
background: var(--rule);
|
|
142
|
+
background-clip: content-box;
|
|
143
|
+
border-left: 3px solid transparent;
|
|
144
|
+
border-right: 3px solid transparent;
|
|
145
|
+
z-index: 1;
|
|
146
|
+
}
|
|
147
|
+
.divider.hidden {
|
|
148
|
+
display: none;
|
|
149
|
+
}
|
|
150
|
+
.divider:focus-visible {
|
|
151
|
+
outline: 2px solid var(--ink);
|
|
152
|
+
outline-offset: -2px;
|
|
153
|
+
}
|
|
154
|
+
</style>
|
|
@@ -21,6 +21,8 @@ export const CHECK_SCRIPT = `ls -d ${EXT_GLOB} >/dev/null 2>&1 && echo 1 || echo
|
|
|
21
21
|
export const claudeCodeIdeExtension: Injection = {
|
|
22
22
|
id: 'claude-code-ide-extension',
|
|
23
23
|
label: 'Claude Code IDE extension',
|
|
24
|
+
// code-server-only; terminal-mode instances never run the extension host.
|
|
25
|
+
modes: ['ide'],
|
|
24
26
|
|
|
25
27
|
async apply(target, log) {
|
|
26
28
|
log('Installing Claude Code IDE extension…\n');
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { checkPresence, execInContainer } from '../lib/exec.server.ts';
|
|
2
|
+
import type { Injection } from '../lib/injections.server.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Package managers first (ttyd is in recent Debian/Ubuntu/Alpine repos), then the upstream
|
|
6
|
+
* static binary for arches/distros without a package. The default image is Debian, so apt-get
|
|
7
|
+
* is the real path.
|
|
8
|
+
*/
|
|
9
|
+
export const INSTALL_SCRIPT =
|
|
10
|
+
'if command -v ttyd >/dev/null 2>&1; then exit 0; fi; ' +
|
|
11
|
+
'export DEBIAN_FRONTEND=noninteractive; ' +
|
|
12
|
+
'if command -v apt-get >/dev/null 2>&1; then apt-get update -y >/dev/null 2>&1 && apt-get install -y --no-install-recommends ttyd >/dev/null 2>&1 && command -v ttyd >/dev/null 2>&1 && exit 0; fi; ' +
|
|
13
|
+
'if command -v apk >/dev/null 2>&1; then apk add --no-cache ttyd >/dev/null 2>&1 && command -v ttyd >/dev/null 2>&1 && exit 0; fi; ' +
|
|
14
|
+
'case "$(uname -m)" in x86_64|amd64) t=x86_64;; aarch64|arm64) t=aarch64;; armv7l|armv6l) t=arm;; i686|i386) t=i686;; *) echo "unsupported arch: $(uname -m)" >&2; exit 1;; esac; ' +
|
|
15
|
+
'url="https://github.com/tsl0922/ttyd/releases/latest/download/ttyd.$t"; ' +
|
|
16
|
+
'if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /usr/local/bin/ttyd; ' +
|
|
17
|
+
'elif command -v wget >/dev/null 2>&1; then wget -qO /usr/local/bin/ttyd "$url"; ' +
|
|
18
|
+
'else echo "no curl/wget to fetch ttyd" >&2; exit 1; fi; ' +
|
|
19
|
+
'chmod +x /usr/local/bin/ttyd; command -v ttyd >/dev/null 2>&1';
|
|
20
|
+
|
|
21
|
+
const CHECK_SCRIPT = 'command -v ttyd >/dev/null 2>&1 && echo 1 || echo 0';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The runtime fallback to the build-time `codebay-ttyd` feature (the primary install), mirroring
|
|
25
|
+
* tmux. Terminal-mode only — `resolveInjections(mode)` keeps it out of IDE instances entirely.
|
|
26
|
+
*/
|
|
27
|
+
export const ttyd: Injection = {
|
|
28
|
+
id: 'ttyd',
|
|
29
|
+
label: 'ttyd',
|
|
30
|
+
modes: ['terminal'],
|
|
31
|
+
|
|
32
|
+
async apply(target, log) {
|
|
33
|
+
log('Installing ttyd…\n');
|
|
34
|
+
// Omitting remoteUser runs as root, which the package install / /usr/local/bin write needs.
|
|
35
|
+
const install = await execInContainer(
|
|
36
|
+
{ containerId: target.containerId },
|
|
37
|
+
{ script: INSTALL_SCRIPT }
|
|
38
|
+
);
|
|
39
|
+
log(
|
|
40
|
+
install.ok
|
|
41
|
+
? '✓ ttyd installed\n'
|
|
42
|
+
: `⚠ ttyd install failed: ${install.error} — the terminal won't be reachable\n`
|
|
43
|
+
);
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async check(target) {
|
|
47
|
+
return checkPresence(target, CHECK_SCRIPT);
|
|
48
|
+
}
|
|
49
|
+
};
|
package/src/lib/config.server.ts
CHANGED
|
@@ -59,6 +59,9 @@ export const PORT_MAX = 8999;
|
|
|
59
59
|
/** Inside the container, not on the host. */
|
|
60
60
|
export const CODE_SERVER_PORT = 8080;
|
|
61
61
|
|
|
62
|
+
/** ttyd's default listen port, used in terminal mode instead of code-server's. */
|
|
63
|
+
export const TTYD_PORT = 7681;
|
|
64
|
+
|
|
62
65
|
/** `base:ubuntu` is multi-arch, unlike the `universal` images, which are amd64-only. */
|
|
63
66
|
export const DEFAULT_IMAGE = 'mcr.microsoft.com/devcontainers/base:ubuntu';
|
|
64
67
|
|
package/src/lib/db.server.ts
CHANGED
|
@@ -10,6 +10,9 @@ const MIGRATIONS_DIR = join(import.meta.dir, '../../migrations');
|
|
|
10
10
|
|
|
11
11
|
export type InstanceStatus = 'creating' | 'running' | 'stopped' | 'error';
|
|
12
12
|
|
|
13
|
+
/** `'ide'` serves full code-server; `'terminal'` serves only ttyd + Claude Code. */
|
|
14
|
+
export type InstanceMode = 'ide' | 'terminal';
|
|
15
|
+
|
|
13
16
|
export interface InstanceRow {
|
|
14
17
|
id: string;
|
|
15
18
|
name: string;
|
|
@@ -27,6 +30,10 @@ export interface InstanceRow {
|
|
|
27
30
|
remote_user: string | null;
|
|
28
31
|
/** `'local'` when the folder shipped its own config, else the injected image; null pre-dates this. */
|
|
29
32
|
image_source: string | null;
|
|
33
|
+
/** Fixed at creation: which editor surface this instance provisions and serves. */
|
|
34
|
+
mode: InstanceMode;
|
|
35
|
+
/** Terminal mode only: 1 when the scratch-shell pane was left open, so a reload restores it. */
|
|
36
|
+
terminal_split: number;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export interface PortForwardRow {
|
|
@@ -57,8 +64,8 @@ export function closeDb(): void {
|
|
|
57
64
|
export function insertInstance(row: InstanceRow): void {
|
|
58
65
|
db.query(
|
|
59
66
|
`INSERT INTO instances
|
|
60
|
-
(id, name, source_path, workspace_path, host_port, container_id, remote_workspace_folder, status, error, created_at, bridge_token, remote_user, image_source)
|
|
61
|
-
VALUES ($id, $name, $source_path, $workspace_path, $host_port, $container_id, $remote_workspace_folder, $status, $error, $created_at, $bridge_token, $remote_user, $image_source)`
|
|
67
|
+
(id, name, source_path, workspace_path, host_port, container_id, remote_workspace_folder, status, error, created_at, bridge_token, remote_user, image_source, mode, terminal_split)
|
|
68
|
+
VALUES ($id, $name, $source_path, $workspace_path, $host_port, $container_id, $remote_workspace_folder, $status, $error, $created_at, $bridge_token, $remote_user, $image_source, $mode, $terminal_split)`
|
|
62
69
|
).run({
|
|
63
70
|
$id: row.id,
|
|
64
71
|
$name: row.name,
|
|
@@ -72,7 +79,9 @@ export function insertInstance(row: InstanceRow): void {
|
|
|
72
79
|
$created_at: row.created_at,
|
|
73
80
|
$bridge_token: row.bridge_token,
|
|
74
81
|
$remote_user: row.remote_user,
|
|
75
|
-
$image_source: row.image_source
|
|
82
|
+
$image_source: row.image_source,
|
|
83
|
+
$mode: row.mode,
|
|
84
|
+
$terminal_split: row.terminal_split
|
|
76
85
|
});
|
|
77
86
|
}
|
|
78
87
|
|
|
@@ -133,7 +142,8 @@ const UPDATABLE_COLUMNS = [
|
|
|
133
142
|
'status',
|
|
134
143
|
'error',
|
|
135
144
|
'remote_user',
|
|
136
|
-
'image_source'
|
|
145
|
+
'image_source',
|
|
146
|
+
'terminal_split'
|
|
137
147
|
] as const;
|
|
138
148
|
|
|
139
149
|
type UpdatableColumn = (typeof UPDATABLE_COLUMNS)[number];
|