@bakapiano/ccsm 0.20.2 → 0.21.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.
package/lib/codexSeed.js CHANGED
@@ -14,8 +14,8 @@
14
14
  // Filename timestamp uses dashes-only (codex's convention), but it's
15
15
  // purely cosmetic — codex looks up sessions by UUID, not filename.
16
16
  //
17
- // CODEX_HOME resolution. Wrappers like `cxp` relocate CODEX_HOME to a
18
- // non-default dir (e.g. %LOCALAPPDATA%\gc2cc\codex-home) so the seed has
17
+ // CODEX_HOME resolution. Some wrappers relocate CODEX_HOME to a
18
+ // non-default dir (e.g. %LOCALAPPDATA%\<wrapper>\codex-home) so the seed has
19
19
  // to land there or `resume <id>` won't find it. We probe by running
20
20
  // `<cli.command> doctor` once per (command, shell) pair and parsing the
21
21
  // "CODEX_HOME ... (dir)" line out of its output. Cached for the life of
@@ -25,6 +25,7 @@ const fs = require('node:fs/promises');
25
25
  const path = require('node:path');
26
26
  const os = require('node:os');
27
27
  const { execFile } = require('node:child_process');
28
+ const { spawnEnv } = require('./winPath');
28
29
 
29
30
  function isoForFilename(d = new Date()) {
30
31
  // 2026-05-25T15:39:11 → 2026-05-25T15-39-11 (codex strips ms + colons)
@@ -42,18 +43,32 @@ function execWithTimeout(exe, args, { timeoutMs = 8000 } = {}) {
42
43
  windowsHide: true,
43
44
  timeout: timeoutMs,
44
45
  maxBuffer: 1024 * 1024,
46
+ // Use the registry-merged user PATH so wrapper commands resolve
47
+ // even when the long-running server inherited a stale PATH at boot.
48
+ env: spawnEnv(),
45
49
  }, (err, stdout, stderr) => {
46
50
  resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') });
47
51
  });
48
52
  });
49
53
  }
50
54
 
51
- // Parse `CODEX_HOME <path> (dir)` out of `codex doctor` output. Codex
52
- // formats it with variable whitespace; the `(dir)` / `(file)` suffix is
53
- // the easiest anchor to identify the path end.
55
+ // Pull CODEX_HOME out of a wrapper's `doctor` (or `--version`) output. Two
56
+ // shapes appear, and we must handle BOTH:
57
+ // 1. Diagnostic table: `CODEX_HOME <path> (dir)` — only when `doctor`
58
+ // fully succeeds. Variable whitespace; the `(dir)`/`(file)` suffix marks
59
+ // the path end.
60
+ // 2. Wrapper banner: `CODEX_HOME=<path>` — printed on EVERY invocation.
61
+ // Critical because in ccsm's non-interactive spawn `doctor` often exits
62
+ // non-zero (skipping the table) yet still prints this banner line, so it's
63
+ // the reliable source. Without it the probe returns null and the seed
64
+ // lands in ~/.codex instead of the wrapper's relocated home, breaking
65
+ // `resume <id>` ("No saved session found with ID …").
66
+ // Some wrappers colour the label (`\x1b[7mCODEX_HOME\x1b[0m`); strip ANSI first.
54
67
  function parseCodexHomeFromDoctor(text) {
55
68
  if (!text) return null;
56
- const m = text.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/);
69
+ const clean = String(text).replace(/\x1b\[[0-9;]*m/g, '');
70
+ let m = clean.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/); // table form
71
+ if (!m) m = clean.match(/\bCODEX_HOME=(.+?)\s*$/m); // banner form
57
72
  if (!m) return null;
58
73
  const p = m[1].trim();
59
74
  return p || null;
@@ -99,7 +114,7 @@ async function probeCodexHome({ command, shell }) {
99
114
  const inv = buildDoctorInvocation(command, shell);
100
115
  if (!inv) { codexHomeCache.set(key, null); return null; }
101
116
  const { stdout, stderr } = await execWithTimeout(inv.exe, inv.args);
102
- // Wrappers like cxp print their banner to stderr; doctor itself prints
117
+ // Some wrappers print their banner to stderr; doctor itself prints
103
118
  // the CODEX_HOME line to stdout. Search both to be safe.
104
119
  const home = parseCodexHomeFromDoctor(stdout) || parseCodexHomeFromDoctor(stderr);
105
120
  codexHomeCache.set(key, home);
@@ -109,7 +124,7 @@ async function probeCodexHome({ command, shell }) {
109
124
  async function seedCodexSession({ id, cwd, cli }) {
110
125
  if (!id || !cwd) throw new Error('seedCodexSession: id and cwd required');
111
126
  // Resolution order:
112
- // 1. `<cli.command> doctor` probe (handles wrappers like cxp that
127
+ // 1. `<cli.command> doctor` probe (handles wrappers that
113
128
  // relocate CODEX_HOME)
114
129
  // 2. process.env.CODEX_HOME (global override)
115
130
  // 3. ~/.codex (codex's own default)
package/lib/config.js CHANGED
@@ -70,7 +70,7 @@ const DEFAULTS = {
70
70
  // Repos available for cloning into a fresh workspace at launch time.
71
71
  // { name: 'foo', url: 'https://github.com/me/foo.git', defaultSelected: true }
72
72
  repos: [],
73
- // Pluggable CLIs. Add wrappers like `ccp` (gc2cc) or self-hosted
73
+ // Pluggable CLIs. Add custom wrappers or self-hosted
74
74
  // proxies by appending an entry. defaultCliId picks one for the
75
75
  // Launch button when the user doesn't override.
76
76
  clis: DEFAULT_CLIS,
@@ -170,8 +170,8 @@ function mergeWithDefaults(partial) {
170
170
  type: ['claude', 'codex', 'copilot', 'other'].includes(rest.type) ? rest.type : 'other',
171
171
  builtin: !!rest.builtin,
172
172
  };
173
- // Type-based fallback for non-builtin CLIs (wrappers like `ccp`
174
- // that just call claude under the hood). If user picked
173
+ // Type-based fallback for non-builtin CLIs (wrappers that
174
+ // just call claude under the hood). If user picked
175
175
  // type='claude' but left newSessionIdArgs / resumeIdArgs blank,
176
176
  // assume they want the same args claude / copilot / codex use
177
177
  // canonically — without this the wrapped CLI gets spawned with
@@ -103,16 +103,47 @@ async function discoverClaude() {
103
103
  return all;
104
104
  }
105
105
 
106
+ // Codex sessions can live under a RELOCATED CODEX_HOME. Some wrappers
107
+ // point it at e.g. %LOCALAPPDATA%\<wrapper>\codex-home, so `~/.codex` is empty
108
+ // of the sessions the user actually created. Seeding already honours this
109
+ // (codexSeed.probeCodexHome runs `<cli> doctor`); the import scan must too,
110
+ // or those relocated sessions are invisible in the adopt modal. Gather every
111
+ // candidate `<home>/sessions` dir: each configured codex CLI's detected
112
+ // home, the CODEX_HOME env override, and codex's own ~/.codex default.
113
+ async function codexSessionRoots() {
114
+ const roots = new Set();
115
+ try {
116
+ const { loadConfig } = require('./config');
117
+ const { probeCodexHome } = require('./codexSeed');
118
+ const cfg = await loadConfig();
119
+ const codexClis = (cfg?.clis || []).filter((c) => c.type === 'codex' || c.id === 'codex');
120
+ for (const c of codexClis) {
121
+ if (!c.command) continue;
122
+ let home = null;
123
+ try { home = await probeCodexHome({ command: c.command, shell: c.shell }); }
124
+ catch { /* probe is best-effort */ }
125
+ if (home) roots.add(path.join(home, 'sessions'));
126
+ }
127
+ } catch { /* config/probe unavailable — fall through to defaults */ }
128
+ if (process.env.CODEX_HOME) roots.add(path.join(process.env.CODEX_HOME, 'sessions'));
129
+ roots.add(path.join(os.homedir(), '.codex', 'sessions'));
130
+ return [...roots];
131
+ }
132
+
106
133
  async function discoverCodex() {
107
- const root = path.join(os.homedir(), '.codex', 'sessions');
134
+ const roots = await codexSessionRoots();
108
135
  const candidates = [];
109
- await walkFiles(root, async (filepath, st) => {
110
- if (!filepath.endsWith('.jsonl')) return;
111
- const base = path.basename(filepath);
112
- const m = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
113
- if (!m) return;
114
- candidates.push({ id: m[1], filepath, mtimeMs: st.mtimeMs });
115
- });
136
+ const seen = new Set(); // dedup by session id across homes
137
+ for (const root of roots) {
138
+ await walkFiles(root, async (filepath, st) => {
139
+ if (!filepath.endsWith('.jsonl')) return;
140
+ const base = path.basename(filepath);
141
+ const m = base.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
142
+ if (!m || seen.has(m[1])) return;
143
+ seen.add(m[1]);
144
+ candidates.push({ id: m[1], filepath, mtimeMs: st.mtimeMs });
145
+ });
146
+ }
116
147
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
117
148
  return candidates;
118
149
  }
@@ -121,8 +152,14 @@ async function discoverCodex() {
121
152
  // records by parsing each jsonl head. cliType is the discriminator in the
122
153
  // returned record.
123
154
  async function hydrateJsonl(candidates, cliType) {
155
+ // codex prepends a big system preamble (<permissions instructions> +
156
+ // <environment_context>, ~32KB) before the first user message, so 16KB
157
+ // catches the cwd (line 1) but not the summary. Read a larger head for
158
+ // codex; its dataset is tiny so the extra bytes are free. claude/copilot
159
+ // keep the cheap 16KB.
160
+ const headBytes = cliType === 'codex' ? 48 * 1024 : HEAD_BYTES;
124
161
  const parseTasks = candidates.map((c) => async () => {
125
- const { cwd, summary } = await parseJsonlHead(c.filepath, c.mtimeMs);
162
+ const { cwd, summary } = await parseJsonlHead(c.filepath, c.mtimeMs, headBytes);
126
163
  if (!cwd) return null;
127
164
  return {
128
165
  cliType,
@@ -328,74 +365,53 @@ async function listForTypeWithActive(cliType) {
328
365
  }));
329
366
  }
330
367
 
331
- // Paginated list the fast path used by the import modal.
332
- //
333
- // Strategy:
334
- // 1. Discover phase = stat all candidates. Cheap, even at 1000+ files.
335
- // 2. Compute active set in parallel.
336
- // 3. ALWAYS hydrate every active candidate (they go first, never paged
337
- // out — user explicitly wants live sessions visible up top).
338
- // 4. For non-active, hydrate only `[offset, offset+limit)` sorted mtime
339
- // desc. "Load more" = call again with the next offset.
368
+ // Page-based list for the import modal. offset/limit is a window into the
369
+ // full candidate set, ordered active-first then newest-first. Each call
370
+ // returns exactly that page — the modal renders ‹ Prev · X–Y of Z · Next ›.
340
371
  //
341
- // Returns: { sessions, totalActive, totalNonActive, offset, limit, hasMore }
342
- async function listPaginated(cliType, { offset = 0, limit = 30 } = {}) {
343
- // copilot's "discover" is also the parse (no separate jsonl head to read
344
- // cheaply), so for now we just list all of it. Codex/Claude get the
345
- // proper two-phase treatment.
372
+ // Returns: { sessions, total, totalActive, totalNonActive, offset, limit, hasMore }
373
+ async function listPaginated(cliType, { offset = 0, limit = 20 } = {}) {
374
+ const now = Date.now();
375
+
376
+ // copilot's "discover" is also the parse (no cheap jsonl head), so we
377
+ // already have the full hydrated list — just slice the requested page.
346
378
  if (cliType === 'copilot') {
347
379
  const all = await listForTypeWithActive('copilot');
348
- all.sort((a, b) => {
349
- if (a.active !== b.active) return a.active ? -1 : 1;
350
- return b.mtime - a.mtime;
351
- });
380
+ all.sort((a, b) => (a.active !== b.active ? (a.active ? -1 : 1) : b.mtime - a.mtime));
381
+ const totalActive = all.filter((x) => x.active).length;
352
382
  return {
353
- sessions: all,
354
- totalActive: all.filter((x) => x.active).length,
355
- totalNonActive: all.filter((x) => !x.active).length,
356
- offset: 0,
357
- limit: all.length,
358
- hasMore: false,
383
+ sessions: all.slice(offset, offset + limit),
384
+ total: all.length,
385
+ totalActive,
386
+ totalNonActive: all.length - totalActive,
387
+ offset, limit,
388
+ hasMore: offset + limit < all.length,
359
389
  };
360
390
  }
361
391
 
392
+ // claude / codex: two-phase — cheap discover (stat only), hydrate just the
393
+ // page's slice.
362
394
  const discover = cliType === 'codex' ? discoverCodex : discoverClaude;
363
- const [candidates, activeIds] = await Promise.all([
364
- discover(),
365
- getActiveIds(cliType),
366
- ]);
367
- // Already sorted mtime desc inside discover.
368
- const now = Date.now();
369
- const isActiveCand = (c) =>
370
- activeIds.has(c.id) || (now - c.mtimeMs) < RECENT_MS;
371
- const active = candidates.filter(isActiveCand);
372
- const rest = candidates.filter((c) => !isActiveCand(c));
373
-
374
- // Slice non-active to the requested page.
375
- const slice = rest.slice(offset, offset + limit);
376
-
377
- // Hydrate active (always all) + slice of non-active.
378
- // First page: hydrate both. Later pages: only the slice — frontend
379
- // already has the active set from page 0.
380
- const toHydrate = offset === 0 ? [...active, ...slice] : slice;
381
- const hydrated = await hydrateJsonl(toHydrate, cliType);
382
-
383
- // Stamp active flag back on. Doing it post-hydrate so we don't have
384
- // to thread it through hydrateJsonl.
385
- const activeIdSet = new Set(active.map((c) => c.id));
395
+ const [candidates, activeIds] = await Promise.all([discover(), getActiveIds(cliType)]);
396
+ const isActive = (c) => activeIds.has(c.id) || (now - c.mtimeMs) < RECENT_MS;
397
+ // Active-first; discover already sorted mtime-desc so a stable partition
398
+ // keeps newest-first within each group.
399
+ const ordered = [...candidates.filter(isActive), ...candidates.filter((c) => !isActive(c))];
400
+ const totalActive = ordered.length - candidates.filter((c) => !isActive(c)).length;
401
+
402
+ const slice = ordered.slice(offset, offset + limit);
403
+ const hydrated = await hydrateJsonl(slice, cliType);
386
404
  for (const s of hydrated) {
387
- s.active = activeIdSet.has(s.cliSessionId)
388
- || activeIds.has(s.cliSessionId)
389
- || (now - s.mtime) < RECENT_MS;
405
+ s.active = activeIds.has(s.cliSessionId) || (now - s.mtime) < RECENT_MS;
390
406
  }
391
407
 
392
408
  return {
393
409
  sessions: hydrated,
394
- totalActive: active.length,
395
- totalNonActive: rest.length,
396
- offset,
397
- limit,
398
- hasMore: offset + limit < rest.length,
410
+ total: candidates.length,
411
+ totalActive,
412
+ totalNonActive: candidates.length - totalActive,
413
+ offset, limit,
414
+ hasMore: offset + limit < candidates.length,
399
415
  };
400
416
  }
401
417
 
@@ -442,17 +458,17 @@ function truncate(s, n) {
442
458
  // cwd lives in the head of every jsonl (it's part of the per-message
443
459
  // envelope), so 16KB is more than enough. First user text usually too;
444
460
  // if it's beyond the head we just don't preview, that's fine.
445
- async function parseJsonlHead(filepath, mtimeMs) {
461
+ async function parseJsonlHead(filepath, mtimeMs, headBytes = HEAD_BYTES) {
446
462
  const cached = cacheGet(filepath, mtimeMs);
447
463
  if (cached) return cached;
448
464
 
449
465
  let fh;
450
466
  try { fh = await fsp.open(filepath, 'r'); }
451
467
  catch { return { cwd: null, summary: '' }; }
452
- const buf = Buffer.allocUnsafe(HEAD_BYTES);
468
+ const buf = Buffer.allocUnsafe(headBytes);
453
469
  let bytesRead = 0;
454
470
  try {
455
- const r = await fh.read(buf, 0, HEAD_BYTES, 0);
471
+ const r = await fh.read(buf, 0, headBytes, 0);
456
472
  bytesRead = r.bytesRead || 0;
457
473
  } catch {
458
474
  /* leave bytesRead = 0 */
@@ -468,7 +484,7 @@ async function parseJsonlHead(filepath, mtimeMs) {
468
484
  const text = buf.slice(0, bytesRead).toString('utf8');
469
485
  // Drop the trailing partial line — JSON.parse on it will fail anyway.
470
486
  const lines = text.split('\n');
471
- if (bytesRead === HEAD_BYTES) lines.pop();
487
+ if (bytesRead === headBytes) lines.pop();
472
488
 
473
489
  let cwd = null;
474
490
  let summary = '';
@@ -477,10 +493,24 @@ async function parseJsonlHead(filepath, mtimeMs) {
477
493
  if (!line) continue;
478
494
  let obj;
479
495
  try { obj = JSON.parse(line); } catch { continue; }
480
- if (!cwd && obj && obj.cwd) cwd = obj.cwd;
481
- if (!summary && obj && obj.type === 'user' && obj.message?.content) {
482
- const c = obj.message.content;
483
- if (typeof c === 'string') summary = truncate(c, SUMMARY_MAX);
496
+ if (!obj) continue;
497
+ // cwd
498
+ // claude · top-level { cwd: "..." } on every message envelope
499
+ // codex · session_meta line { payload: { cwd: "..." } }
500
+ if (!cwd) {
501
+ if (typeof obj.cwd === 'string') cwd = obj.cwd;
502
+ else if (obj.payload && typeof obj.payload.cwd === 'string') cwd = obj.payload.cwd;
503
+ }
504
+ // first user message (preview) —
505
+ // claude · { type:'user', message:{ content:'...' } }
506
+ // codex · { type:'event_msg', payload:{ type:'user_message', message:'...' } }
507
+ if (!summary) {
508
+ if (obj.type === 'user' && typeof obj.message?.content === 'string') {
509
+ summary = truncate(obj.message.content, SUMMARY_MAX);
510
+ } else if (obj.type === 'event_msg' && obj.payload?.type === 'user_message'
511
+ && typeof obj.payload.message === 'string') {
512
+ summary = truncate(obj.payload.message, SUMMARY_MAX);
513
+ }
484
514
  }
485
515
  }
486
516
  const v = { cwd, summary };
@@ -54,7 +54,13 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
54
54
  name: 'xterm-256color',
55
55
  cols, rows,
56
56
  cwd: cwd ? path.resolve(cwd) : process.cwd(),
57
- env: { ...process.env, ...(env || {}) },
57
+ // Advertise 24-bit colour. xterm.js renders truecolor, and CLIs like
58
+ // claude paint their diffs with 24-bit DARK red/green backgrounds +
59
+ // white text when COLORTERM says so. Without it they downgrade to the
60
+ // bright ANSI-16 red/green backgrounds, which wash the white foreground
61
+ // out (the "white text gets buried" report). VSCode sets the same on its
62
+ // terminal PTYs; our ANSI-16 palette already matches VSCode's verbatim.
63
+ env: { ...process.env, ...(env || {}), COLORTERM: 'truecolor' },
58
64
  };
59
65
  if (process.platform === 'win32') {
60
66
  ptyOpts.useConpty = true;
package/lib/winPath.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // Windows user-PATH merge. A long-running ccsm server inherits whatever PATH
4
+ // it was launched with, which can be stale — e.g. a CLI (or a wrapper for one)
5
+ // installed or put on PATH AFTER the server started. We read the
6
+ // persisted user PATH from the registry and merge it in, then hand the
7
+ // deduped env to every child we spawn. server.js uses this for session
8
+ // launches; codexSeed uses it for the `<cli> doctor` home probe so the wrapper
9
+ // resolves regardless of the parent process's PATH.
10
+ //
11
+ // (server.js keeps its own equivalent inline for the hot spawn path; this
12
+ // module is the shared copy used by lib/ code that can't depend on server.js.)
13
+
14
+ const { spawnSync } = require('node:child_process');
15
+
16
+ let _merged = null; // cached for the process lifetime
17
+
18
+ function buildMergedUserPath() {
19
+ if (process.platform !== 'win32') return process.env.PATH;
20
+ try {
21
+ const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'],
22
+ { encoding: 'utf8', windowsHide: true });
23
+ if (r.status !== 0 || !r.stdout) return process.env.PATH;
24
+ const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
25
+ if (!line) return process.env.PATH;
26
+ const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
27
+ if (!m) return process.env.PATH;
28
+ // REG_EXPAND_SZ keeps %VAR% literal — expand against the current env.
29
+ const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
30
+ const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
31
+ const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
32
+ const merged = [];
33
+ const seen = new Set();
34
+ for (const p of [...adds, ...existing]) {
35
+ const k = p.toLowerCase();
36
+ if (seen.has(k)) continue;
37
+ seen.add(k);
38
+ merged.push(p);
39
+ }
40
+ return merged.join(';');
41
+ } catch {
42
+ return process.env.PATH;
43
+ }
44
+ }
45
+
46
+ function mergedUserPath() {
47
+ if (_merged === null) _merged = buildMergedUserPath();
48
+ return _merged;
49
+ }
50
+
51
+ // Fresh env for a child spawn: process.env + extras, every case variant of
52
+ // PATH stripped and replaced by the merged user PATH. Windows resolves the
53
+ // FIRST path-case key in the env block, so a stale inherited `Path` would
54
+ // otherwise shadow our `PATH`.
55
+ function spawnEnv(extraEnv = {}) {
56
+ const env = { ...process.env, ...extraEnv };
57
+ if (process.platform === 'win32') {
58
+ for (const k of Object.keys(env)) {
59
+ if (k.toLowerCase() === 'path') delete env[k];
60
+ }
61
+ }
62
+ const mp = mergedUserPath();
63
+ if (mp) env.PATH = mp;
64
+ return env;
65
+ }
66
+
67
+ module.exports = { mergedUserPath, spawnEnv, buildMergedUserPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.20.2",
3
+ "version": "0.21.1",
4
4
  "description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -66,6 +66,22 @@
66
66
  the FAB sits over the terminal corner — leave it; the user can
67
67
  scroll the terminal independently. */
68
68
 
69
+ /* Touch scrolling: drive xterm's OWN scrollable .xterm-viewport natively
70
+ so finger drags get real momentum and never "drop" mid-flick. A
71
+ JS-intercepted scroll can't: the moment Chrome's compositor decides the
72
+ drag is a vertical pan it cancels the touch sequence, so the handler
73
+ only ever sees the opening frames — the "滑一点就断触" symptom. The
74
+ catch is that the .xterm-screen layer (DOM rows on mobile) sits ON TOP
75
+ of the viewport and swallows every touch; only the thin scrollbar strip
76
+ at the right edge reached the viewport, which is exactly the
77
+ "中间滑不动、侧边能滑" report. Make the screen transparent to pointers so
78
+ the drag lands on the viewport underneath; pan-y marks it a vertical
79
+ scroller so the compositor scrolls it directly. (Tap-to-focus is then
80
+ re-added in JS — see TerminalView — since taps no longer reach xterm's
81
+ own focus path.) */
82
+ .app.is-mobile .terminal-host .xterm-screen { pointer-events: none; }
83
+ .app.is-mobile .terminal-host .xterm-viewport { touch-action: pan-y; }
84
+
69
85
  /* Long inline code (URLs, paths) in the About / Remote bodies break
70
86
  out of card edges on a narrow viewport. Let them wrap on any
71
87
  character instead of stretching the line. */
@@ -125,49 +125,37 @@
125
125
  min-height: 0;
126
126
  width: 100%;
127
127
  /* IME composition (Chinese/Japanese pinyin) lives in absolutely-positioned
128
- .xterm-helper-textarea + .composition-view that grow with the composed
129
- string. Near the right edge they overflow the viewport and trigger a
130
- horizontal scrollbar that visually "pushes" the layout. Clip here so
131
- the overflow is silently absorbed instead of expanding the page.
132
- Do NOT touch the textarea/composition-view's own text properties —
133
- xterm relies on their single-line behaviour to keep IME events firing
134
- correctly (forcing pre-wrap / break-all eats compositionupdate events
128
+ .xterm-helper-textarea + .composition-view at the cursor. xterm 5.5 caps
129
+ the view's width to the remaining columns, but as a belt-and-braces guard
130
+ we still clip here so any residual right-edge overflow is absorbed rather
131
+ than expanding the page (it used to trigger a horizontal scrollbar that
132
+ "pushed" the layout). Do NOT touch the textarea/composition-view's own
133
+ text properties — xterm relies on their single-line behaviour to keep IME
134
+ events firing (forcing pre-wrap / break-all eats compositionupdate events
135
135
  in Chromium and Chinese input stops working entirely). */
136
136
  overflow: hidden;
137
137
  contain: layout;
138
138
  }
139
- /* While the user is composing (IME), pin the helper textarea to the right
140
- edge of the terminal so it grows leftward instead of pushing the layout
141
- rightward. Only touches positioning NOT width / wrap / max-width, which
142
- would break Chromium's compositionupdate event flow and stop Chinese
143
- input from working. The class is toggled by TerminalView.js. */
144
- .terminal-host.is-composing .xterm-helper-textarea {
145
- left: auto !important;
146
- right: 0 !important;
147
- text-align: right;
148
- /* xterm un-hides the textarea during composition so the user can see the
149
- composed string inline. We've moved it to the right edge to stop it
150
- pushing layout — but that means the composed pinyin would now visibly
151
- appear on the right. Hide its glyphs (caret + text) so the user only
152
- sees the OS-native IME candidate popup, which floats independently
153
- and is unaffected. */
154
- color: transparent !important;
155
- caret-color: transparent !important;
156
- background: transparent !important;
157
- text-shadow: none !important;
158
- }
159
- /* xterm also overlays a .composition-view (a small box at the cursor with
160
- the in-progress string + a gold caret using THEME.cursor). We can't
161
- display:none it — Chromium needs it in the layout tree to keep the IME
162
- compositionupdate events flowing — but we can make it visually invisible
163
- while leaving it laid out. */
139
+ /* IME composition box. While you're typing pinyin before committing the
140
+ Chinese characters xterm writes the in-progress string into
141
+ .composition-view at the cursor. VSCode's terminal shows this box in the
142
+ terminal's own colours; we do the same. An earlier version hid it outright
143
+ (opacity:0) to dodge a layout push, which is why the typed letters were
144
+ invisible — but the host's overflow:hidden + contain:layout above already
145
+ absorb any overflow, and xterm caps the box width, so hiding it was both
146
+ unnecessary and the bug. The helper textarea is left exactly where xterm
147
+ puts it (at the cursor, opacity:0) so the OS IME candidate popup anchors
148
+ correctly we no longer move or restyle it. */
164
149
  .terminal-host .composition-view {
165
- opacity: 0 !important;
166
- color: transparent !important;
167
- background: transparent !important;
168
- border-color: transparent !important;
169
- box-shadow: none !important;
170
- pointer-events: none;
150
+ /* !important to beat xterm.css's own `.xterm .composition-view`
151
+ (#000/#FFF, same specificity, loaded after us). var() still re-resolves
152
+ per theme, so this tracks light/dark automatically. */
153
+ background: var(--term-surface) !important;
154
+ color: var(--term-on) !important;
155
+ border: 1px solid var(--accent);
156
+ border-radius: 3px;
157
+ padding: 0 3px;
158
+ z-index: 5;
171
159
  }
172
160
  /* Don't override xterm's background — its renderer (canvas/WebGL) assumes
173
161
  an opaque surface and ghosts on scroll if we force transparent. The