@bakapiano/ccsm 0.20.1 → 0.21.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/lib/codexSeed.js +14 -6
- package/lib/config.js +3 -3
- package/lib/localCliSessions.js +102 -72
- package/lib/winPath.js +67 -0
- package/package.json +1 -1
- package/public/assets/copilot-color.svg +1 -1
- package/public/css/dark.css +52 -11
- package/public/css/forms.css +12 -2
- package/public/css/responsive.css +32 -4
- package/public/css/terminals.css +26 -38
- package/public/css/widgets.css +123 -106
- package/public/js/components/AdoptModal.js +168 -250
- package/public/js/components/EntityFormModal.js +14 -14
- package/public/js/components/Modal.js +9 -3
- package/public/js/components/TerminalView.js +83 -55
- package/public/js/main.js +20 -0
- package/public/js/pages/ConfigurePage.js +1 -1
- package/public/js/pages/LaunchPage.js +12 -22
- package/server.js +1 -1
|
@@ -215,50 +215,74 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
215
215
|
if (dev) params.set('device', dev);
|
|
216
216
|
const qs = params.toString();
|
|
217
217
|
const wsUrl = `${wsBase()}/ws/terminal/${encodeURIComponent(terminalId)}${qs ? `?${qs}` : ''}`;
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
// Auto-reconnect. Mobile networks drop the WS constantly (radio sleep,
|
|
219
|
+
// cell↔wifi handoff, tab backgrounding) — leaving a dead "[disconnected]"
|
|
220
|
+
// terminal is the #1 mobile annoyance. We retry with capped backoff and
|
|
221
|
+
// re-attach to the same PTY. The server replays its FULL history on every
|
|
222
|
+
// attach (lib/webTerminal.js), so on a reconnect we reset the screen
|
|
223
|
+
// first, otherwise the replay stacks on top of what's already shown.
|
|
224
|
+
let closedByUs = false;
|
|
225
|
+
let reconnectTimer = null;
|
|
226
|
+
let attempts = 0;
|
|
227
|
+
let everOpened = false;
|
|
221
228
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
229
|
+
const connect = () => {
|
|
230
|
+
const ws = new WebSocket(wsUrl);
|
|
231
|
+
ws.binaryType = 'arraybuffer';
|
|
232
|
+
wsRef.current = ws;
|
|
233
|
+
|
|
234
|
+
ws.onopen = () => {
|
|
235
|
+
if (everOpened) {
|
|
236
|
+
// Reconnect: clear so the replayed history repopulates cleanly.
|
|
237
|
+
try { term.reset(); } catch {}
|
|
238
|
+
}
|
|
239
|
+
everOpened = true;
|
|
240
|
+
attempts = 0;
|
|
241
|
+
// Fit synchronously before sending cols/rows — the handshake often
|
|
242
|
+
// completes before the rAF-scheduled fit, so without this we'd ship
|
|
243
|
+
// the default 80x24 and claude would wrap its prompt at 80 cols.
|
|
244
|
+
scheduleFit();
|
|
245
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
246
|
+
};
|
|
247
|
+
ws.onmessage = (ev) => {
|
|
248
|
+
let frame;
|
|
249
|
+
try { frame = JSON.parse(ev.data); } catch { return; }
|
|
250
|
+
if (frame.type === 'output') {
|
|
251
|
+
term.write(frame.data);
|
|
252
|
+
} else if (frame.type === 'exit') {
|
|
253
|
+
term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
ws.onclose = (ev) => {
|
|
257
|
+
if (closedByUs) return;
|
|
258
|
+
// Displaced by another client (latest-wins, code 4001) — reconnecting
|
|
259
|
+
// would just ping-pong, so show the "Take it back" pane instead.
|
|
260
|
+
if (ev && ev.code === 4001) { setDisplaced(true); return; }
|
|
261
|
+
// PTY is gone (server restarted / session ended, code 4404) — a
|
|
262
|
+
// reconnect can't revive it; the session needs a full resume.
|
|
263
|
+
if (ev && ev.code === 4404) {
|
|
264
|
+
term.write('\r\n\x1b[2m[session ended]\x1b[0m\r\n');
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Network blip — retry with backoff (0.5/1/2/4/8s cap), indefinitely
|
|
268
|
+
// until the effect tears down (cleanup flips closedByUs).
|
|
269
|
+
attempts++;
|
|
270
|
+
const delay = Math.min(8000, 500 * 2 ** Math.min(attempts - 1, 4));
|
|
271
|
+
term.write('\r\n\x1b[2m[disconnected · reconnecting…]\x1b[0m\r\n');
|
|
272
|
+
reconnectTimer = setTimeout(() => { if (!closedByUs) connect(); }, delay);
|
|
273
|
+
};
|
|
255
274
|
};
|
|
275
|
+
connect();
|
|
256
276
|
|
|
277
|
+
// onData/onResize read wsRef.current (not a captured socket) so they keep
|
|
278
|
+
// working across reconnects.
|
|
257
279
|
const onData = (data) => {
|
|
258
|
-
|
|
280
|
+
const ws = wsRef.current;
|
|
281
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
|
|
259
282
|
};
|
|
260
283
|
const onResize = ({ cols, rows }) => {
|
|
261
|
-
|
|
284
|
+
const ws = wsRef.current;
|
|
285
|
+
if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
262
286
|
};
|
|
263
287
|
term.onData(onData);
|
|
264
288
|
term.onResize(onResize);
|
|
@@ -280,6 +304,16 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
280
304
|
vv?.addEventListener?.('resize', onVisualResize);
|
|
281
305
|
vv?.addEventListener?.('scroll', onVisualResize);
|
|
282
306
|
|
|
307
|
+
// Mobile touch scrolling is handled NATIVELY: responsive.css makes the
|
|
308
|
+
// .xterm-screen layer pointer-transparent so finger drags fall through to
|
|
309
|
+
// xterm's own scrollable .xterm-viewport (real momentum, never drops
|
|
310
|
+
// mid-flick the way a JS-intercepted scroll does). The only casualty is
|
|
311
|
+
// tap-to-focus — with the screen ignoring pointer events a tap no longer
|
|
312
|
+
// reaches xterm's focus path, so the soft keyboard wouldn't open. Re-focus
|
|
313
|
+
// on tap explicitly. A drag emits no click, so this fires only on real taps.
|
|
314
|
+
const onHostClick = () => { try { term.focus(); } catch {} };
|
|
315
|
+
if (isMobile) host.addEventListener('click', onHostClick);
|
|
316
|
+
|
|
283
317
|
// Tab-switch refresh. The terminal lives inside a .tab-panel which gets
|
|
284
318
|
// display:none when another tab is active. WebGL renderers keep a glyph
|
|
285
319
|
// texture atlas in GPU memory; when the canvas hides + redisplays at a
|
|
@@ -416,27 +450,18 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
416
450
|
};
|
|
417
451
|
document.addEventListener('keydown', onShiftEnter, true);
|
|
418
452
|
|
|
419
|
-
// IME
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
// it grows leftward instead. Toggling a class on the host is enough;
|
|
427
|
-
// the CSS in terminals.css does the rest.
|
|
453
|
+
// While composing (IME), hide the terminal's own cursor so the blinking
|
|
454
|
+
// bar doesn't sit on top of the composition box (terminals.css paints the
|
|
455
|
+
// box at the cursor, showing the in-progress pinyin). The cursor is drawn
|
|
456
|
+
// on the canvas from theme.cursor, so CSS can't touch it: swap the theme
|
|
457
|
+
// to a transparent cursor AND issue the DECTCEM hide sequence (the theme
|
|
458
|
+
// swap alone doesn't reliably stop the blink frame loop). Restore on end.
|
|
459
|
+
// Use the live theme (themeRef) so the restore matches current light/dark.
|
|
428
460
|
const onCompStart = () => {
|
|
429
|
-
if (host) host.classList.add('is-composing');
|
|
430
|
-
// The terminal cursor is rendered on canvas (theme.cursor), so CSS
|
|
431
|
-
// can't hide it. Theme swap alone doesn't reliably stop the blink
|
|
432
|
-
// frame loop, so also issue the DECTCEM hide sequence which the
|
|
433
|
-
// renderer honours immediately. Use the live theme (themeRef) so the
|
|
434
|
-
// restore on compEnd matches whatever light/dark is current.
|
|
435
461
|
try { term.options.theme = { ...themeRef.current, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
|
|
436
462
|
try { term.write('\x1b[?25l'); } catch {}
|
|
437
463
|
};
|
|
438
464
|
const onCompEnd = () => {
|
|
439
|
-
if (host) host.classList.remove('is-composing');
|
|
440
465
|
try { term.options.theme = themeRef.current; } catch {}
|
|
441
466
|
try { term.write('\x1b[?25h'); } catch {}
|
|
442
467
|
};
|
|
@@ -458,7 +483,10 @@ export function TerminalView({ terminalId, cliType }) {
|
|
|
458
483
|
if (panelMo) panelMo.disconnect();
|
|
459
484
|
vv?.removeEventListener?.('resize', onVisualResize);
|
|
460
485
|
vv?.removeEventListener?.('scroll', onVisualResize);
|
|
461
|
-
|
|
486
|
+
if (isMobile) host.removeEventListener('click', onHostClick);
|
|
487
|
+
closedByUs = true;
|
|
488
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
489
|
+
try { wsRef.current?.close(); } catch {}
|
|
462
490
|
try { term.dispose(); } catch {}
|
|
463
491
|
termRef.current = null;
|
|
464
492
|
wsRef.current = null;
|
package/public/js/main.js
CHANGED
|
@@ -156,6 +156,26 @@ function syncTitlebarHeight() {
|
|
|
156
156
|
syncTitlebarHeight();
|
|
157
157
|
navigator.windowControlsOverlay?.addEventListener?.('geometrychange', syncTitlebarHeight);
|
|
158
158
|
|
|
159
|
+
// Mobile soft-keyboard height. The layout viewport (100vh) does NOT shrink
|
|
160
|
+
// when the on-screen keyboard slides up — only `visualViewport` does — so a
|
|
161
|
+
// full-height terminal keeps its bottom rows hidden behind the keyboard. We
|
|
162
|
+
// publish the visible height as --app-vh (used by .app.is-mobile in
|
|
163
|
+
// responsive.css to shrink the whole app to the area above the keyboard)
|
|
164
|
+
// and flag body.kb-open when the keyboard is up (so the terminal can reserve
|
|
165
|
+
// room for the floating key bar). cap at a 120px delta so a browser
|
|
166
|
+
// URL-bar collapse doesn't read as a keyboard.
|
|
167
|
+
function syncViewportHeight() {
|
|
168
|
+
const vv = window.visualViewport;
|
|
169
|
+
if (!vv) return;
|
|
170
|
+
document.documentElement.style.setProperty('--app-vh', `${Math.round(vv.height)}px`);
|
|
171
|
+
const kbUp = (window.innerHeight - vv.height - vv.offsetTop) > 120;
|
|
172
|
+
document.body.classList.toggle('kb-open', kbUp);
|
|
173
|
+
}
|
|
174
|
+
syncViewportHeight();
|
|
175
|
+
window.visualViewport?.addEventListener?.('resize', syncViewportHeight);
|
|
176
|
+
window.visualViewport?.addEventListener?.('scroll', syncViewportHeight);
|
|
177
|
+
window.addEventListener('resize', syncViewportHeight);
|
|
178
|
+
|
|
159
179
|
(async () => {
|
|
160
180
|
// Version-mismatch guard runs FIRST. If the user's backend has been
|
|
161
181
|
// upgraded since this per-version frontend was loaded, bounce back to
|
|
@@ -87,7 +87,7 @@ function cliFieldsFor({ creating } = {}) {
|
|
|
87
87
|
},
|
|
88
88
|
},
|
|
89
89
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
90
|
-
{ key: 'command', label: 'Command', mono: true, placeholder: '
|
|
90
|
+
{ key: 'command', label: 'Command', mono: true, placeholder: 'claude / codex / ...', required: true },
|
|
91
91
|
{ key: 'args', label: 'Args', mono: true, placeholder: '',
|
|
92
92
|
hint: 'Used on every launch. Shell-style quoting: -Model "claude-opus-4-8" or -Path \'C:\\some dir\\bin\'.' },
|
|
93
93
|
{ key: 'newSessionIdArgs', label: 'New session id args', mono: true, placeholder: '--session-id <id>',
|
|
@@ -6,7 +6,7 @@ import { html } from '../html.js';
|
|
|
6
6
|
import { useState, useEffect } from 'preact/hooks';
|
|
7
7
|
import { signal } from '@preact/signals';
|
|
8
8
|
import { config, folders, selectSession, selectTab } from '../state.js';
|
|
9
|
-
import { createCli, createFolder, createRepo,
|
|
9
|
+
import { createCli, createFolder, createRepo, refreshAll } from '../api.js';
|
|
10
10
|
import { setToast } from '../toast.js';
|
|
11
11
|
import { streamNewSession, resetProgress } from '../streaming.js';
|
|
12
12
|
import { PageTitleBar } from '../components/PageTitleBar.js';
|
|
@@ -15,7 +15,6 @@ import { Modal } from '../components/Modal.js';
|
|
|
15
15
|
import { PickerPanel } from '../components/Picker.js';
|
|
16
16
|
import { DirectoryPicker } from '../components/DirectoryPicker.js';
|
|
17
17
|
import { AdoptModal } from '../components/AdoptModal.js';
|
|
18
|
-
import { useDragSort } from '../components/useDragSort.js';
|
|
19
18
|
import { BrandMark, IconTerminal, IconFolder, IconFolderOpen, IconBranch, IconChevronDown, IconForCliType, IconClaudeColor, IconCodexColor, IconCopilotColor, IconSparkle, IconWorkspace, IconArrowRight } from '../icons.js';
|
|
20
19
|
|
|
21
20
|
const ROOT_ID = 'newSessionProgress';
|
|
@@ -95,13 +94,6 @@ function LaunchHero() {
|
|
|
95
94
|
});
|
|
96
95
|
}, [cliId, folderId, mode, cwd, selectedRepos.value]);
|
|
97
96
|
|
|
98
|
-
const folderDnd = useDragSort(
|
|
99
|
-
folders.value.map((f) => f.id),
|
|
100
|
-
async (nextIds) => {
|
|
101
|
-
try { await reorderFolders(nextIds); }
|
|
102
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
103
|
-
},
|
|
104
|
-
);
|
|
105
97
|
|
|
106
98
|
const sig = repos.map((r) => r.name + ':' + r.defaultSelected).join('|');
|
|
107
99
|
useStateOnce(sig, () => initRepoSelection(repos, saved?.repos));
|
|
@@ -190,7 +182,7 @@ function LaunchHero() {
|
|
|
190
182
|
},
|
|
191
183
|
},
|
|
192
184
|
{ key: 'name', label: 'Name', placeholder: 'My CLI', required: true },
|
|
193
|
-
{ key: 'command', label: 'Command', mono: true, placeholder: '
|
|
185
|
+
{ key: 'command', label: 'Command', mono: true, placeholder: 'claude / codex / ...', required: true },
|
|
194
186
|
{ key: 'args', label: 'Args (space-separated)', mono: true, placeholder: '' },
|
|
195
187
|
{ key: 'resumeArgs', label: 'Resume args (fallback)', mono: true, placeholder: '--continue',
|
|
196
188
|
hint: 'Used when ccsm has no captured upstream session id.' },
|
|
@@ -205,8 +197,8 @@ function LaunchHero() {
|
|
|
205
197
|
|
|
206
198
|
// --- Folder picker config --------------------------------------------
|
|
207
199
|
const folderItems = [
|
|
208
|
-
{ id: '', label: 'Unsorted', meta: 'no folder', undraggable: true },
|
|
209
|
-
...folders.value.map((f) => ({ id: f.id, label: f.name })),
|
|
200
|
+
{ id: '', label: 'Unsorted', meta: 'no folder', undraggable: true, icon: html`<${IconFolderOpen} />` },
|
|
201
|
+
...folders.value.map((f) => ({ id: f.id, label: f.name, icon: html`<${IconFolder} />` })),
|
|
210
202
|
];
|
|
211
203
|
const folderCreateFields = [
|
|
212
204
|
{ key: 'name', label: 'Folder name', placeholder: 'Work / Personal / ...', autoFocus: true, required: true },
|
|
@@ -281,7 +273,14 @@ function LaunchHero() {
|
|
|
281
273
|
<span class="pill-chev"><${IconChevronDown} /></span>
|
|
282
274
|
</button>
|
|
283
275
|
${openPicker === 'workdir' ? html`
|
|
284
|
-
<${Modal} title="Working directory" onClose=${close} width=${640}
|
|
276
|
+
<${Modal} title="Working directory" onClose=${close} width=${640}
|
|
277
|
+
footer=${html`
|
|
278
|
+
<button type="button" class="action subtle" onClick=${close}>Cancel</button>
|
|
279
|
+
<button type="button" class="action primary"
|
|
280
|
+
disabled=${mode === 'cwd' && !cwd}
|
|
281
|
+
onClick=${close}>
|
|
282
|
+
${mode === 'cwd' ? 'Use folder' : 'Done'}
|
|
283
|
+
</button>`}>
|
|
285
284
|
<div class="workdir-modal">
|
|
286
285
|
<div class="workdir-mode-grid">
|
|
287
286
|
<button type="button"
|
|
@@ -321,14 +320,6 @@ function LaunchHero() {
|
|
|
321
320
|
onPick=${(p) => { setCwd(p); }} />
|
|
322
321
|
`}
|
|
323
322
|
</div>
|
|
324
|
-
<div class="workdir-foot">
|
|
325
|
-
<button type="button" class="action subtle" onClick=${close}>Cancel</button>
|
|
326
|
-
<button type="button" class="action primary"
|
|
327
|
-
disabled=${mode === 'cwd' && !cwd}
|
|
328
|
-
onClick=${close}>
|
|
329
|
-
${mode === 'cwd' ? 'Use folder' : 'Done'}
|
|
330
|
-
</button>
|
|
331
|
-
</div>
|
|
332
323
|
</div>
|
|
333
324
|
</${Modal}>` : null}
|
|
334
325
|
|
|
@@ -344,7 +335,6 @@ function LaunchHero() {
|
|
|
344
335
|
<${Modal} title="Choose folder" onClose=${close} width=${400}>
|
|
345
336
|
<${PickerPanel} items=${folderItems} selectedId=${folderId}
|
|
346
337
|
showSearch=${false}
|
|
347
|
-
dnd=${folderDnd}
|
|
348
338
|
onSelect=${(id) => setFolderId(id)}
|
|
349
339
|
onCreate=${async (v) => {
|
|
350
340
|
try {
|
package/server.js
CHANGED
|
@@ -222,7 +222,7 @@ function pickCli(cfg, requestedId) {
|
|
|
222
222
|
// 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
|
|
223
223
|
// Won't find pwsh aliases / functions.
|
|
224
224
|
// 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
|
|
225
|
-
// Loads $PROFILE → pwsh aliases / functions
|
|
225
|
+
// Loads $PROFILE → pwsh aliases / functions work.
|
|
226
226
|
// Falls back to powershell.exe (5.x) if pwsh.exe absent.
|
|
227
227
|
// 'cmd' — wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
|
|
228
228
|
// and PATH-only names without pwsh dependency.
|