@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.
@@ -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
- const ws = new WebSocket(wsUrl);
219
- ws.binaryType = 'arraybuffer';
220
- wsRef.current = ws;
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
- ws.onopen = () => {
223
- // Fit synchronously here before reading cols/rows. On localhost the
224
- // WS handshake usually completes within a few ms — well before the
225
- // rAF-scheduled initial fit runs — so without this we'd ship the
226
- // xterm.js default 80x24 to the PTY, claude would print its prompt
227
- // wrapped at 80 cols, and the follow-up resize from the rAF fit
228
- // wouldn't reflow the already-emitted bytes. Visible as squeezed
229
- // text on every session switch.
230
- scheduleFit();
231
- ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
232
- };
233
- ws.onmessage = (ev) => {
234
- let frame;
235
- try { frame = JSON.parse(ev.data); } catch { return; }
236
- if (frame.type === 'output') {
237
- term.write(frame.data);
238
- } else if (frame.type === 'exit') {
239
- term.write(`\r\n\x1b[2m[process exited · code ${frame.code}]\x1b[0m\r\n`);
240
- }
241
- };
242
- ws.onclose = (ev) => {
243
- // Server uses code 4001 + reason "displaced by another client"
244
- // when a fresh attach takes over the session (latest-wins policy
245
- // in lib/webTerminal.js's attach). We replace the terminal with
246
- // a full-pane prompt + Take it back button via setDisplaced(true).
247
- // Generic disconnects (network blip, server restart, PTY exit)
248
- // get the dim inline notice as before — those usually self-heal
249
- // and aren't worth a modal.
250
- if (ev && ev.code === 4001) {
251
- setDisplaced(true);
252
- } else {
253
- term.write('\r\n\x1b[2m[disconnected]\x1b[0m\r\n');
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
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data }));
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
- if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
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 fix: xterm positions .xterm-helper-textarea via `left: <col-px>`
420
- // following the cursor. When the cursor is near the right edge and the
421
- // user starts composing (e.g. Chinese pinyin), the textarea + native
422
- // composition popup grow with the composed string and overflow the
423
- // terminal host which visually pushes the layout right. We can't cap
424
- // width / change wrapping (that breaks Chromium's IME event flow), but
425
- // we CAN re-anchor the textarea to the right edge while composing so
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
- try { ws.close(); } catch {}
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: 'ccp / claude / ...', required: true },
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, reorderFolders, refreshAll } from '../api.js';
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: 'ccp / claude / ...', required: true },
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 (`ccp`, `cxp`) work.
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.