@bakapiano/ccsm 0.21.0 → 0.21.2

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
@@ -52,16 +52,23 @@ function execWithTimeout(exe, args, { timeoutMs = 8000 } = {}) {
52
52
  });
53
53
  }
54
54
 
55
- // Parse `CODEX_HOME <path> (dir)` out of `codex doctor` output. Codex
56
- // formats it with variable whitespace; the `(dir)` / `(file)` suffix is
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.
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.
61
67
  function parseCodexHomeFromDoctor(text) {
62
68
  if (!text) return null;
63
69
  const clean = String(text).replace(/\x1b\[[0-9;]*m/g, '');
64
- const m = clean.match(/\bCODEX_HOME\s+(.+?)\s*\((?:dir|file)\)/);
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
65
72
  if (!m) return null;
66
73
  const p = m[1].trim();
67
74
  return p || null;
@@ -151,5 +158,26 @@ async function seedCodexSession({ id, cwd, cli }) {
151
158
  return file;
152
159
  }
153
160
 
154
- module.exports = { seedCodexSession, probeCodexHome, parseCodexHomeFromDoctor };
161
+ // Copy ccsm's bundled light codex syntax theme into the codex home's themes/
162
+ // dir so `-c tui.theme=ccsm-light` resolves. This theme carries light
163
+ // markup.inserted/deleted backgrounds, which at true-color level override
164
+ // codex's diff palette — the only way to get a LIGHT diff on Windows, where
165
+ // codex's own background detection (default_bg()) is compiled out and always
166
+ // falls back to a dark diff. Idempotent (writes only when missing/changed).
167
+ async function ensureCodexLightTheme(home) {
168
+ if (!home) return false;
169
+ const src = path.join(__dirname, 'codexThemes', 'ccsm-light.tmTheme');
170
+ const dstDir = path.join(home, 'themes');
171
+ const dst = path.join(dstDir, 'ccsm-light.tmTheme');
172
+ try {
173
+ const content = await fs.readFile(src, 'utf8');
174
+ await fs.mkdir(dstDir, { recursive: true });
175
+ let existing = null;
176
+ try { existing = await fs.readFile(dst, 'utf8'); } catch {}
177
+ if (existing !== content) await fs.writeFile(dst, content, 'utf8');
178
+ return true;
179
+ } catch { return false; }
180
+ }
181
+
182
+ module.exports = { seedCodexSession, probeCodexHome, parseCodexHomeFromDoctor, ensureCodexLightTheme };
155
183
 
@@ -0,0 +1,77 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <!-- ccsm-light: a GitHub-Light-flavoured codex syntax theme whose
4
+ markup.inserted / markup.deleted scopes carry LIGHT diff backgrounds.
5
+ ccsm injects this (via `-c tui.theme=ccsm-light`) when the ccsm terminal
6
+ is in light mode, because codex's own diff theme detection (default_bg())
7
+ is compiled out on Windows and always falls back to a DARK diff palette —
8
+ which buries readability on a white terminal. At true-color level codex
9
+ lets a syntax theme's markup scope backgrounds override that default, so
10
+ these two scopes are what flip the diff to light. -->
11
+ <plist version="1.0">
12
+ <dict>
13
+ <key>name</key>
14
+ <string>ccsm-light</string>
15
+ <key>settings</key>
16
+ <array>
17
+ <dict>
18
+ <key>settings</key>
19
+ <dict>
20
+ <key>background</key><string>#ffffff</string>
21
+ <key>foreground</key><string>#1f2328</string>
22
+ <key>caret</key><string>#1f2328</string>
23
+ <key>selection</key><string>#b6d6fd</string>
24
+ <key>lineHighlight</key><string>#eaeef2</string>
25
+ </dict>
26
+ </dict>
27
+ <dict>
28
+ <key>scope</key><string>comment, punctuation.definition.comment</string>
29
+ <key>settings</key><dict><key>foreground</key><string>#6e7781</string></dict>
30
+ </dict>
31
+ <dict>
32
+ <key>scope</key><string>string, string.quoted, punctuation.definition.string</string>
33
+ <key>settings</key><dict><key>foreground</key><string>#0a3069</string></dict>
34
+ </dict>
35
+ <dict>
36
+ <key>scope</key><string>constant.numeric, constant.language, constant.character, constant.other</string>
37
+ <key>settings</key><dict><key>foreground</key><string>#0550ae</string></dict>
38
+ </dict>
39
+ <dict>
40
+ <key>scope</key><string>keyword, keyword.control, keyword.operator, storage, storage.type, storage.modifier</string>
41
+ <key>settings</key><dict><key>foreground</key><string>#cf222e</string></dict>
42
+ </dict>
43
+ <dict>
44
+ <key>scope</key><string>entity.name.function, support.function, meta.function-call</string>
45
+ <key>settings</key><dict><key>foreground</key><string>#8250df</string></dict>
46
+ </dict>
47
+ <dict>
48
+ <key>scope</key><string>entity.name.type, entity.name.class, entity.name.namespace, support.type, support.class</string>
49
+ <key>settings</key><dict><key>foreground</key><string>#953800</string></dict>
50
+ </dict>
51
+ <dict>
52
+ <key>scope</key><string>variable, variable.other, variable.parameter</string>
53
+ <key>settings</key><dict><key>foreground</key><string>#1f2328</string></dict>
54
+ </dict>
55
+ <dict>
56
+ <key>scope</key><string>entity.name.tag, support.type.property-name</string>
57
+ <key>settings</key><dict><key>foreground</key><string>#116329</string></dict>
58
+ </dict>
59
+ <dict>
60
+ <key>scope</key><string>markup.inserted, markup.inserted.diff, meta.diff.header.to-file</string>
61
+ <key>settings</key>
62
+ <dict>
63
+ <key>background</key><string>#e6ffec</string>
64
+ <key>foreground</key><string>#1a7f37</string>
65
+ </dict>
66
+ </dict>
67
+ <dict>
68
+ <key>scope</key><string>markup.deleted, markup.deleted.diff, meta.diff.header.from-file</string>
69
+ <key>settings</key>
70
+ <dict>
71
+ <key>background</key><string>#ffebe9</string>
72
+ <key>foreground</key><string>#cf222e</string>
73
+ </dict>
74
+ </dict>
75
+ </array>
76
+ </dict>
77
+ </plist>
package/lib/config.js CHANGED
@@ -56,8 +56,13 @@ const DEFAULT_CLIS = [
56
56
  name: 'GitHub Copilot',
57
57
  command: 'copilot',
58
58
  args: [],
59
+ // copilot has no `--resume <id>` (space) flag — that parses as `--resume`
60
+ // (valueless → session picker) plus a stray positional arg, which hangs on
61
+ // a blank screen. `--session-id <id>` both creates AND resumes a session by
62
+ // id (`copilot --help`: "Resume an existing session or task by ID"), so we
63
+ // use it for both new and resume.
59
64
  newSessionIdArgs: ['--session-id', '<id>'],
60
- resumeIdArgs: ['--resume', '<id>'],
65
+ resumeIdArgs: ['--session-id', '<id>'],
61
66
  shell: 'direct',
62
67
  type: 'copilot',
63
68
  builtin: true,
@@ -149,6 +154,16 @@ function mergeWithDefaults(partial) {
149
154
  const needsBackfill = (v) => v == null || (Array.isArray(v) && v.length === 0);
150
155
  if (needsBackfill(existing.resumeIdArgs)) existing.resumeIdArgs = def.resumeIdArgs;
151
156
  if (needsBackfill(existing.newSessionIdArgs)) existing.newSessionIdArgs = def.newSessionIdArgs;
157
+ // Heal the old broken copilot resume flag: `--resume <id>` (space form)
158
+ // isn't valid copilot syntax — it parses as a valueless `--resume`
159
+ // (session picker) plus a stray positional, hanging on a blank screen.
160
+ // The builtin now uses `--session-id <id>`. Migrate configs still
161
+ // carrying the old default; genuine user customisations are left alone.
162
+ if (existing.id === 'copilot'
163
+ && Array.isArray(existing.resumeIdArgs)
164
+ && existing.resumeIdArgs.join(' ') === '--resume <id>') {
165
+ existing.resumeIdArgs = def.resumeIdArgs;
166
+ }
152
167
  // Drop the v0.x `resumeArgs` fallback — every builtin now has a
153
168
  // pre-assigned UUID (claude/copilot via flag, codex via seed) so
154
169
  // resumeIdArgs always applies on resume; the field is dead weight.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
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",
package/public/js/api.js CHANGED
@@ -282,7 +282,11 @@ export function resumeSession(sessionId) {
282
282
  const cached = resumeInFlight.get(sessionId);
283
283
  if (cached) return cached;
284
284
  const p = (async () => {
285
- const r = await api('POST', `/api/sessions/${sessionId}/resume`);
285
+ const r = await api('POST', `/api/sessions/${sessionId}/resume`, {
286
+ // Resolved terminal theme → backend sets a matching COLORFGBG so the
287
+ // CLI's light/dark auto-detection follows the ccsm terminal.
288
+ theme: document.documentElement.dataset.theme,
289
+ });
286
290
  await loadSessions();
287
291
  return r.launched;
288
292
  })();
@@ -183,6 +183,30 @@ export function TerminalView({ terminalId, cliType }) {
183
183
 
184
184
  const host = hostRef.current;
185
185
  term.open(host);
186
+
187
+ // Answer OSC 10/11 (default foreground / background colour) queries with
188
+ // the LIVE theme colours. CLIs like claude probe the terminal background
189
+ // (`OSC 11 ; ? ST`) to pick a light- or dark-tuned syntax theme. xterm.js
190
+ // doesn't answer these by default, so claude assumes a dark terminal and
191
+ // paints near-white tokens (comments, f-string interpolations, call
192
+ // names) that vanish on our light background — the "字体颜色和背景重复"
193
+ // bug. VSCode answers them; we match. Reply format is the xterm/X11
194
+ // `rgb:RRRR/GGGG/BBBB` (16-bit-per-channel) the query expects.
195
+ const answerColorOsc = (code, getHex) => (data) => {
196
+ if (data !== '?') return false; // only the query form
197
+ const hex = getHex(); // '#rrggbb'
198
+ const ch = (i) => parseInt(hex.slice(i, i + 2), 16);
199
+ const w = (v) => (v * 257).toString(16).padStart(4, '0'); // 8-bit → 16-bit
200
+ const reply = `\x1b]${code};rgb:${w(ch(1))}/${w(ch(3))}/${w(ch(5))}\x07`;
201
+ const ws = wsRef.current;
202
+ if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'input', data: reply }));
203
+ return true;
204
+ };
205
+ try {
206
+ term.parser.registerOscHandler(11, answerColorOsc(11, () => themeRef.current.background));
207
+ term.parser.registerOscHandler(10, answerColorOsc(10, () => themeRef.current.foreground));
208
+ } catch {}
209
+
186
210
  // Robust fit scheduler. A single requestAnimationFrame works most
187
211
  // of the time but races on tab/session switches: the .tab-panel
188
212
  // just flipped from display:none to display:flex and although the
@@ -47,7 +47,7 @@ function tokenizeCliArgs(v) {
47
47
  const CLI_TYPE_DEFAULTS = {
48
48
  claude: { command: 'claude', resumeIdArgs: '--resume <id>', newSessionIdArgs: '--session-id <id>' },
49
49
  codex: { command: 'codex', resumeIdArgs: 'resume <id>', newSessionIdArgs: 'resume <id>' },
50
- copilot: { command: 'copilot', resumeIdArgs: '--resume <id>', newSessionIdArgs: '--session-id <id>' },
50
+ copilot: { command: 'copilot', resumeIdArgs: '--session-id <id>', newSessionIdArgs: '--session-id <id>' },
51
51
  other: { resumeIdArgs: '', newSessionIdArgs: '' },
52
52
  };
53
53
 
@@ -171,7 +171,7 @@ function LaunchHero() {
171
171
  onChange: (v, next) => {
172
172
  const presets = { claude: { command: 'claude', resumeArgs: '--continue', resumeIdArgs: '--resume <id>', name: 'Claude Code' },
173
173
  codex: { command: 'codex', resumeArgs: 'resume --last', resumeIdArgs: 'resume <id>', name: 'OpenAI Codex' },
174
- copilot: { command: 'copilot', resumeArgs: '--continue', resumeIdArgs: '--resume <id>', name: 'GitHub Copilot' },
174
+ copilot: { command: 'copilot', resumeArgs: '--continue', resumeIdArgs: '--session-id <id>', name: 'GitHub Copilot' },
175
175
  other: {} }[v] || {};
176
176
  const patch = {};
177
177
  if (presets.resumeArgs != null) patch.resumeArgs = presets.resumeArgs;
@@ -60,10 +60,13 @@ function applyEvent(ev, rootId) {
60
60
  // onMeta(event) is called for workspace/launched/done events so the caller can
61
61
  // surface them in their own result text area.
62
62
  export async function streamNewSession(body, { progressRootId = 'newSessionProgress', onMeta } = {}) {
63
+ // Pass the resolved terminal theme so the backend can hand CLIs a matching
64
+ // COLORFGBG (light/dark detection). dataset.theme is set by applyTheme().
65
+ const theme = document.documentElement.dataset.theme;
63
66
  const res = await fetch(httpBase() + '/api/sessions/new', {
64
67
  method: 'POST',
65
68
  headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify(body),
69
+ body: JSON.stringify({ ...body, theme }),
67
70
  });
68
71
  if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
69
72
  const j = await res.json();
package/server.js CHANGED
@@ -281,7 +281,7 @@ function quoteForCmd(s) {
281
281
  return s;
282
282
  }
283
283
 
284
- function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
284
+ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [], theme }) {
285
285
  if (!webTerminal.available) {
286
286
  const e = new Error('node-pty unavailable · cannot spawn web terminal');
287
287
  e.code = 'PTY_UNAVAILABLE';
@@ -291,19 +291,46 @@ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
291
291
  // extraArgs into the single quoted command string — otherwise extraArgs
292
292
  // would become args to the shell itself, not the wrapped command.
293
293
  // Re-resolve here when extraArgs is present so the quoting is correct.
294
+ // Force a session-scoped theme=auto for claude so its syntax/diff colours
295
+ // follow the ccsm terminal background (which we report via OSC 10/11 in
296
+ // TerminalView). claude's DEFAULT theme is *dark*, whose near-white tokens
297
+ // (comments, f-string interpolations, call names) vanish on our light
298
+ // terminal — the "字体颜色和背景重复" bug. --settings is session-scoped, so
299
+ // the user's global ~/.claude/settings.json is left untouched, and ccsm
300
+ // sessions Just Work on a fresh machine without anyone running /theme auto.
301
+ // (Injected here as an integration arg, like --session-id — not via the
302
+ // user-editable cli.args, so it reaches existing configs too.)
303
+ // Skip the injection entirely if the user already put their own --settings
304
+ // in cli.args — claude deep-merges multiple --settings (verified: later ones
305
+ // win per-key), so ours would silently override a theme they set on purpose.
306
+ // Respect the user's explicit choice instead.
307
+ const userHasSettings = (cli.args || []).some(
308
+ (a) => a === '--settings' || String(a).startsWith('--settings='));
309
+ const baseArgs = [...(cli.args || [])];
310
+ if (cli.type === 'claude' && !userHasSettings) baseArgs.push('--settings', '{"theme":"auto"}');
294
311
  const resolved = resolveCommand(
295
312
  cli.command,
296
- [...(cli.args || []), ...extraArgs],
313
+ [...baseArgs, ...extraArgs],
297
314
  cli.shell || 'direct',
298
315
  );
299
316
  const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
300
317
  const args = consumesUserArgs
301
318
  ? prefixArgs
302
- : [...prefixArgs, ...(cli.args || []), ...extraArgs];
319
+ : [...prefixArgs, ...baseArgs, ...extraArgs];
303
320
  // Merge user-scope PATH from registry into the env we hand the PTY.
304
321
  // spawnEnv() also strips duplicate path-case keys so our override
305
322
  // doesn't get shadowed by the inherited `Path` from process.env.
306
323
  const env = spawnEnv(cli.env);
324
+ // Tell background-aware CLIs which way the ccsm terminal is painted, so
325
+ // their light/dark auto-detection matches it. COLORFGBG (fg;bg ANSI indices)
326
+ // is the de-facto signal that codex (its DiffTheme probes it), copilot, and
327
+ // claude all read — bg 15 = light, 0 = dark. claude additionally gets OSC
328
+ // 10/11 answers + --settings auto; this covers codex/copilot, which detect
329
+ // via COLORFGBG, not OSC. The frontend passes its resolved theme on spawn;
330
+ // a theme switch is picked up on the next resume.
331
+ if (theme === 'light' || theme === 'dark') {
332
+ env.COLORFGBG = theme === 'light' ? '0;15' : '15;0';
333
+ }
307
334
  const trySpawn = (executable) => webTerminal.spawn({
308
335
  id: sessionId,
309
336
  command: executable,
@@ -832,12 +859,14 @@ app.post('/api/sessions/new', async (req, res) => {
832
859
  cliSessionId: preAssignedId || undefined,
833
860
  });
834
861
  try {
862
+ const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
835
863
  const entry = spawnCliSession({
836
864
  cli,
837
865
  cwd: workspace.path,
838
866
  sessionId: record.id,
839
867
  meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
840
- extraArgs: newSessionArgs,
868
+ extraArgs: [...themeArgs, ...newSessionArgs],
869
+ theme: req.body && req.body.theme,
841
870
  });
842
871
  await persistedSessions.markRunning(record.id, entry.meta.pid);
843
872
  launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
@@ -966,13 +995,15 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
966
995
  // pre-assignment refactor every ccsm-launched session has one (via
967
996
  // newSessionIdArgs flag or the codex seed trick), and adopted
968
997
  // sessions inherit theirs from the disk scan.
998
+ const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
969
999
  const extraArgs = buildResumeArgs(cli, record);
970
1000
  const entry = spawnCliSession({
971
1001
  cli,
972
1002
  cwd: record.cwd,
973
1003
  sessionId: record.id,
974
1004
  meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
975
- extraArgs,
1005
+ extraArgs: [...themeArgs, ...extraArgs],
1006
+ theme: req.body && req.body.theme,
976
1007
  });
977
1008
  await persistedSessions.markRunning(record.id, entry.meta.pid);
978
1009
  res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
@@ -981,6 +1012,31 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
981
1012
  }
982
1013
  }));
983
1014
 
1015
+ // codex-only: when the ccsm terminal is in LIGHT mode, inject a session-scoped
1016
+ // `-c tui.theme=ccsm-light`. codex's diff theme detection (default_bg()) is
1017
+ // compiled out on Windows and always falls back to a DARK diff palette, which
1018
+ // reads poorly on a white terminal — and it ignores COLORFGBG/OSC. The only
1019
+ // lever is a syntax theme whose markup.inserted/deleted scopes carry light
1020
+ // backgrounds (they override the diff palette at true-color level). We ship
1021
+ // that theme (ccsm-light.tmTheme), copy it into the codex home, and point
1022
+ // tui.theme at it. Returns the args to prepend (before `resume <id>` so the
1023
+ // global -c lands before the subcommand), or [] when not applicable. Skipped
1024
+ // in dark mode (codex's dark default is already correct on a dark terminal)
1025
+ // and when the user configured their own tui.theme in cli.args.
1026
+ async function codexThemeArgs(cli, theme) {
1027
+ if (!cli || cli.type !== 'codex' || theme !== 'light') return [];
1028
+ const args = cli.args || [];
1029
+ const userSet = args.some((a, i) =>
1030
+ String(a).includes('tui.theme') || (a === '-c' && String(args[i + 1] || '').includes('tui.theme')));
1031
+ if (userSet) return [];
1032
+ try {
1033
+ const { probeCodexHome, ensureCodexLightTheme } = require('./lib/codexSeed');
1034
+ const home = await probeCodexHome({ command: cli.command, shell: cli.shell });
1035
+ if (!(await ensureCodexLightTheme(home))) return [];
1036
+ return ['-c', 'tui.theme="ccsm-light"'];
1037
+ } catch { return []; }
1038
+ }
1039
+
984
1040
  // Build the args appended on resume: substitute the captured upstream
985
1041
  // session UUID into cli.resumeIdArgs (e.g. ['--resume', '<id>'] →
986
1042
  // ['--resume', '7c28...']). Throws if either piece is missing — by