@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 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,6 +43,9 @@ 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
  });
@@ -50,10 +54,14 @@ function execWithTimeout(exe, args, { timeoutMs = 8000 } = {}) {
50
54
 
51
55
  // Parse `CODEX_HOME <path> (dir)` out of `codex doctor` output. Codex
52
56
  // formats it with variable whitespace; the `(dir)` / `(file)` suffix is
53
- // the easiest anchor to identify the path end.
57
+ // the easiest anchor to identify the path end. Some wrappers colour
58
+ // the label (`\x1b[7mCODEX_HOME\x1b[0m`), so strip ANSI SGR codes first —
59
+ // otherwise `CODEX_HOME` is followed by an escape, not whitespace, and the
60
+ // match fails.
54
61
  function parseCodexHomeFromDoctor(text) {
55
62
  if (!text) return null;
56
- const m = text.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/);
63
+ const clean = String(text).replace(/\x1b\[[0-9;]*m/g, '');
64
+ const m = clean.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/);
57
65
  if (!m) return null;
58
66
  const p = m[1].trim();
59
67
  return p || null;
@@ -99,7 +107,7 @@ async function probeCodexHome({ command, shell }) {
99
107
  const inv = buildDoctorInvocation(command, shell);
100
108
  if (!inv) { codexHomeCache.set(key, null); return null; }
101
109
  const { stdout, stderr } = await execWithTimeout(inv.exe, inv.args);
102
- // Wrappers like cxp print their banner to stderr; doctor itself prints
110
+ // Some wrappers print their banner to stderr; doctor itself prints
103
111
  // the CODEX_HOME line to stdout. Search both to be safe.
104
112
  const home = parseCodexHomeFromDoctor(stdout) || parseCodexHomeFromDoctor(stderr);
105
113
  codexHomeCache.set(key, home);
@@ -109,7 +117,7 @@ async function probeCodexHome({ command, shell }) {
109
117
  async function seedCodexSession({ id, cwd, cli }) {
110
118
  if (!id || !cwd) throw new Error('seedCodexSession: id and cwd required');
111
119
  // Resolution order:
112
- // 1. `<cli.command> doctor` probe (handles wrappers like cxp that
120
+ // 1. `<cli.command> doctor` probe (handles wrappers that
113
121
  // relocate CODEX_HOME)
114
122
  // 2. process.env.CODEX_HOME (global override)
115
123
  // 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 };
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.1",
3
+ "version": "0.21.0",
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",
@@ -1 +1 @@
1
- <svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
1
+ <svg fill="#8957e5" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>GithubCopilot</title><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"></path></svg>
@@ -14,18 +14,41 @@
14
14
  * session tabs, and the mobile key bar — those are dark in both themes. */
15
15
 
16
16
  /* ── buttons ─────────────────────────────────────────────────────── */
17
- /* .action.primary is bg:var(--ink)/text:var(--bg-elev) already inverts
18
- correctly (light slab, dark text) when the vars flip. Only its hover
19
- hardcoded #000, which would darken the wrong way; send it brighter. */
17
+ /* Primary CTA. In light mode it's the ink slab (near-black bg, light
18
+ text). The faithful inversion in dark mode is a near-WHITE slab, which
19
+ reads as a harsh pure-white button floating in the dark popups (the
20
+ Create / Done / Save buttons). Use the accent instead — that's the
21
+ conventional dark-UI primary and matches the accent-colored Launch CTA,
22
+ so every "do this" button in dark mode is one coherent color. */
23
+ [data-theme="dark"] .action.primary {
24
+ background: var(--accent);
25
+ border-color: var(--accent);
26
+ color: #fff;
27
+ }
20
28
  [data-theme="dark"] .action.primary:hover {
21
- background: #ffffff;
22
- border-color: #ffffff;
29
+ background: var(--accent-deep);
30
+ border-color: var(--accent-deep);
23
31
  box-shadow: 0 4px 14px -4px rgba(0, 0, 0, 0.6);
24
32
  }
25
- /* .fab base is var(--ink) (a light slab in dark mode) with var(--bg-elev)
26
- text; its hover hardcoded #000, which would render dark text on black.
27
- Send the hover lighter instead, matching .action.primary. */
28
- [data-theme="dark"] .fab:hover { background: #ffffff; }
33
+ /* .fab is the same ink-slab pattern (var(--ink) bg) give it the same
34
+ accent treatment so it isn't a white circle either. */
35
+ [data-theme="dark"] .fab {
36
+ background: var(--accent);
37
+ color: #fff;
38
+ }
39
+ [data-theme="dark"] .fab:hover { background: var(--accent-deep); }
40
+ /* Active "Working directory" mode card (Launch page). Its selected
41
+ highlight uses var(--ink) for the border + icon chip, which flips to
42
+ light cream in dark mode → a glaring white frame + white icon square.
43
+ Use the accent highlight instead, matching the dark primary button. */
44
+ [data-theme="dark"] .workdir-mode-opt.is-active {
45
+ border-color: var(--accent);
46
+ box-shadow: 0 0 0 1px var(--accent) inset;
47
+ }
48
+ [data-theme="dark"] .workdir-mode-opt.is-active .workdir-mode-icon {
49
+ background: var(--accent);
50
+ color: #fff;
51
+ }
29
52
  /* Focus rings / hover shadows used a dark ink wash that vanishes on a dark
30
53
  ground — switch to a light wash so the affordance stays visible. */
31
54
  [data-theme="dark"] .action:hover { box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.5); }
@@ -36,9 +59,11 @@
36
59
  [data-theme="dark"] textarea:focus { box-shadow: 0 0 0 3px rgba(236, 231, 218, 0.12); }
37
60
  [data-theme="dark"] .action.danger:hover { background: #c75050; border-color: #c75050; }
38
61
 
39
- /* The select chevron SVG is baked with a mid-gray stroke; lighten it. */
62
+ /* Filled triangle (see forms.css note) tinted to the faint dark-mode ink
63
+ so the select arrow is a calm solid mark instead of a pair of bright
64
+ jagged strokes. */
40
65
  [data-theme="dark"] select {
41
- background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%23b4ab98' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,1 6,7 11,1'/></svg>");
66
+ background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'><path d='M1 1 L5 5 L9 1 Z' fill='%236d6a62'/></svg>");
42
67
  }
43
68
 
44
69
  /* ── brand mark ──────────────────────────────────────────────────── */
@@ -49,6 +74,22 @@
49
74
  fill untouched — it's already legible there.) */
50
75
  [data-theme="dark"] .brand-rect { fill: #38342f; }
51
76
 
77
+ /* ── notifications (toast + restart pill) ────────────────────────── */
78
+ /* Both use var(--ink) as their surface and var(--bg) as their text — a
79
+ deliberate high-contrast inverted pill in light mode. But var(--ink)
80
+ flips to light cream in dark mode, so the bottom-right notification
81
+ showed up as a pale pill that read as "still light / not following the
82
+ theme". In dark mode, give them a dark elevated surface with light text
83
+ + a defined border so they sit IN the dark theme like a snackbar. The
84
+ ::before chip and the spinner already use currentColor, so they invert
85
+ along with the text for free. */
86
+ [data-theme="dark"] .toast,
87
+ [data-theme="dark"] .restart-banner {
88
+ background: var(--bg-elev);
89
+ color: var(--ink);
90
+ border: 1px solid var(--border-strong);
91
+ }
92
+
52
93
  /* ── paper grain ─────────────────────────────────────────────────── */
53
94
  /* The noise texture is a dark-tinted SVG multiplied over the surface —
54
95
  invisible (and wrong blend) on a dark ground. Screen-blend it at low
@@ -76,7 +76,14 @@
76
76
 
77
77
  .input, input[type="text"], input[type="number"], select, textarea {
78
78
  appearance: none;
79
- background: var(--bg-elev);
79
+ /* background-COLOR, not the `background` shorthand — the shorthand resets
80
+ background-repeat/position/size to their initial values (repeat / 0% 0%
81
+ / auto), and since this rule matches <select> via the higher-specificity
82
+ `.input` selector, it was overriding the `select` rule's no-repeat +
83
+ positioning. Result: the dropdown arrow SVG tiled across the whole
84
+ select as a grid of little triangles. Setting only the color leaves the
85
+ select rule's background-* longhands intact. */
86
+ background-color: var(--bg-elev);
80
87
  border: 1px solid var(--border-strong);
81
88
  color: var(--ink);
82
89
  padding: 8px 12px;
@@ -94,7 +101,10 @@
94
101
  box-shadow: 0 0 0 3px rgba(26, 24, 21, 0.08);
95
102
  }
96
103
  select {
97
- background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg' fill='none' stroke='%238a8475' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='1,1 6,7 11,1'/></svg>");
104
+ /* A single FILLED triangle, not a 2-stroke chevron. Thin strokes alias
105
+ into jagged "teeth" at this ~9px size; a solid shape anti-aliases
106
+ cleanly and reads as one arrow rather than a pair of serrations. */
107
+ background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 10 6' xmlns='http://www.w3.org/2000/svg'><path d='M1 1 L5 5 L9 1 Z' fill='%238a8475'/></svg>");
98
108
  background-repeat: no-repeat;
99
109
  background-position: right 10px center;
100
110
  background-size: 10px;
@@ -14,7 +14,15 @@
14
14
  layout; a circular floating button bottom-left toggles a full-screen
15
15
  drawer that re-mounts the sidebar over everything else. */
16
16
  @media (max-width: 640px) {
17
- .app.is-mobile { grid-template-columns: 1fr !important; }
17
+ /* Shrink the whole app to the visible area ABOVE the soft keyboard.
18
+ --app-vh is the visualViewport height (main.js); the layout-viewport
19
+ 100vh never shrinks for the keyboard, which left the terminal's bottom
20
+ rows hidden behind it. 100dvh is the fallback before the JS runs. */
21
+ .app.is-mobile { grid-template-columns: 1fr !important; height: var(--app-vh, 100dvh); }
22
+ /* Keyboard up: keep the terminal's content above the floating key bar
23
+ (TerminalKeyBar, ~50px). Only the terminal pane needs this — other
24
+ pages have their own scroll padding. */
25
+ body.kb-open .app.is-mobile .session-pane-body { padding-bottom: 50px; }
18
26
  .app.is-mobile .sidebar {
19
27
  /* Collapsed (drawer closed): out of the flow + invisible. */
20
28
  position: fixed;
@@ -58,6 +66,22 @@
58
66
  the FAB sits over the terminal corner — leave it; the user can
59
67
  scroll the terminal independently. */
60
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
+
61
85
  /* Long inline code (URLs, paths) in the About / Remote bodies break
62
86
  out of card edges on a narrow viewport. Let them wrap on any
63
87
  character instead of stretching the line. */
@@ -96,7 +120,7 @@
96
120
  scrolling the page while the user is dragging the FAB. */
97
121
  .mobile-nav-fab {
98
122
  position: fixed;
99
- z-index: 210;
123
+ z-index: 220; /* above the terminal key bar (215) */
100
124
  width: 52px;
101
125
  height: 52px;
102
126
  border-radius: 50%;
@@ -112,8 +136,12 @@
112
136
  cursor: grab;
113
137
  touch-action: none;
114
138
  user-select: none;
115
- transition: box-shadow .15s, background .15s;
139
+ transition: box-shadow .15s, background .15s, transform .18s ease;
116
140
  }
141
+ /* When the soft keyboard (and the terminal key bar that floats above it)
142
+ is up, lift the FAB clear of the key bar so they don't overlap. The
143
+ key bar is ~50px tall; nudge up a bit more for breathing room. */
144
+ body.kb-open .mobile-nav-fab { transform: translateY(-60px); }
117
145
  /* No translateY on hover — would fight the inline left/bottom we set
118
146
  on every pointermove during drag, making the FAB jitter under the
119
147
  finger as :hover toggles on/off. Background-only hover for desktop
@@ -141,7 +169,7 @@
141
169
  .mobile-nav-backdrop {
142
170
  position: fixed;
143
171
  inset: 0;
144
- z-index: 199; /* below sidebar (200) + fab (210), above content */
172
+ z-index: 199; /* below sidebar (200) + fab (220), above content */
145
173
  background: rgba(26, 24, 21, 0.45);
146
174
  backdrop-filter: blur(2px);
147
175
  animation: panel-in .15s ease-out;