@bakapiano/ccsm 0.21.3 → 0.21.5

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/config.js CHANGED
@@ -80,6 +80,11 @@ const DEFAULTS = {
80
80
  // Launch button when the user doesn't override.
81
81
  clis: DEFAULT_CLIS,
82
82
  defaultCliId: 'claude',
83
+ // External editor command for the "Open in editor" session action.
84
+ // Spawned as `<editor> "<cwd>"`; default `code` = VS Code (whose Source
85
+ // Control panel doubles as the review-changes view once the folder's
86
+ // open). Point it at `cursor`, `code-insiders`, `subl`, … as desired.
87
+ editor: 'code',
83
88
  // Devtunnel state. tunnelId holds the persistent (named) tunnel
84
89
  // ccsm minted via `devtunnel create` on first Start. Reusing it
85
90
  // across host restarts keeps the public URL — and therefore the
@@ -135,6 +140,7 @@ function mergeWithDefaults(partial) {
135
140
 
136
141
  if (!Array.isArray(out.repos)) out.repos = DEFAULTS.repos;
137
142
  if (!Array.isArray(out.clis)) out.clis = [];
143
+ if (typeof out.editor !== 'string') out.editor = DEFAULTS.editor;
138
144
  // Always inject builtin CLIs (claude, codex) if they're missing or were
139
145
  // deleted from a saved config — they're managed by ccsm, the user can
140
146
  // tweak args/shell but can't remove them. Preserves any user
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakapiano/ccsm",
3
- "version": "0.21.3",
3
+ "version": "0.21.5",
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",
@@ -349,12 +349,17 @@
349
349
  display: inline-flex;
350
350
  align-items: center;
351
351
  justify-content: center;
352
- color: #fff;
352
+ /* Follow the terminal foreground so the dots read on both the light
353
+ (#f0f0f0) and dark (#252526) tab strip. The old hardcoded #fff was
354
+ invisible on the light strip. */
355
+ color: var(--term-on);
353
356
  cursor: pointer;
354
357
  flex-shrink: 0;
355
358
  transition: background-color .12s, color .12s;
356
359
  }
357
- .session-menu-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
360
+ /* Neutral-grey hover tint works on either strip colour (darkens the light
361
+ one, lightens the dark one) without needing a per-theme override. */
362
+ .session-menu-btn:hover { background: rgba(128, 128, 128, 0.2); color: var(--term-on); }
358
363
  .session-menu-btn svg { width: 16px; height: 16px; }
359
364
 
360
365
  .session-menu {
package/public/js/api.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { signal } from '@preact/signals';
5
5
  import * as S from './state.js';
6
- import { httpBase, getToken, getDeviceId, getDeviceCode, isRemoteAccess } from './backend.js';
6
+ import { httpBase, getToken, getDeviceId, getDeviceCode, isRemoteAccess, estimateTermSize } from './backend.js';
7
7
 
8
8
  // Global pending-approval signal. Flipped to true whenever any /api
9
9
  // call returns 403 {pending:true}; PendingApprovalOverlay watches this
@@ -259,6 +259,13 @@ export async function deleteSession(sessionId) {
259
259
  await loadSessions();
260
260
  }
261
261
 
262
+ // Open the session's working directory in the user's configured editor
263
+ // (Settings → Editor, default `code`). Returns { editor, cwd } so the
264
+ // caller can surface which editor it launched.
265
+ export function openSessionInEditor(sessionId) {
266
+ return api('POST', `/api/sessions/${sessionId}/open-editor`);
267
+ }
268
+
262
269
  // Per-session in-flight resume promise. Sidebar.onClick and the
263
270
  // SessionsPage auto-resume effect can both fire for the same exited
264
271
  // session in the same tick (clicking an exited row mounts SessionsPage
@@ -286,6 +293,9 @@ export function resumeSession(sessionId) {
286
293
  // Resolved terminal theme → backend sets a matching COLORFGBG so the
287
294
  // CLI's light/dark auto-detection follows the ccsm terminal.
288
295
  theme: document.documentElement.dataset.theme,
296
+ // Seed the PTY at the pane's real size so alt-screen CLIs (claude)
297
+ // don't lay out at node-pty's 30-row default and get stranded short.
298
+ ...(estimateTermSize() || {}),
289
299
  });
290
300
  await loadSessions();
291
301
  return r.launched;
@@ -83,6 +83,45 @@ export function getDeviceId() {
83
83
  }
84
84
  }
85
85
 
86
+ // ── Initial terminal geometry ─────────────────────────────────────
87
+ // Estimate how many cols/rows the live session pane can hold, so a
88
+ // resumed / newly-launched PTY can spawn at roughly the right size
89
+ // instead of node-pty's 120×30 default. Why it matters: an alt-screen
90
+ // TUI like claude lays its entire UI out the instant it starts, using
91
+ // whatever size the PTY had then. xterm only sends the real size once
92
+ // its WebSocket opens — a beat later — and claude, having already
93
+ // painted at 30 rows, doesn't re-expand to fill a tall window until
94
+ // something forces a redraw (e.g. the user resizing it). On a big
95
+ // display that strands the terminal at ~1/4 height. Seeding the spawn
96
+ // with the pane's real dimensions sidesteps the race; xterm's own fit
97
+ // still corrects any few-row estimate error when it attaches.
98
+ // Returns null when nothing measurable is mounted, so the caller omits
99
+ // the hint and the backend keeps its default.
100
+ export function estimateTermSize() {
101
+ let w, h;
102
+ const pane = document.querySelector('.terminal-host')
103
+ || document.querySelector('.session-pane-body');
104
+ if (pane) {
105
+ const r = pane.getBoundingClientRect();
106
+ w = r.width; h = r.height;
107
+ } else {
108
+ // Launching from the Launch page — no pane yet. Approximate from the
109
+ // window minus the sidebar column and the ~70px of top chrome.
110
+ const sb = document.querySelector('.sidebar');
111
+ w = window.innerWidth - (sb ? sb.getBoundingClientRect().width : 232) - 32;
112
+ h = window.innerHeight - 70;
113
+ }
114
+ if (!(w > 40) || !(h > 40)) return null;
115
+ // Mirror TerminalView's font sizing (13px desktop / 11px mobile,
116
+ // lineHeight 1.2); cell advance ≈ 0.6em for the mono stack.
117
+ const isMobile = window.matchMedia('(max-width: 640px)').matches;
118
+ const fontSize = isMobile ? 11 : 13;
119
+ return {
120
+ cols: Math.max(20, Math.min(400, Math.floor(w / (fontSize * 0.6)))),
121
+ rows: Math.max(8, Math.min(200, Math.floor(h / (fontSize * 1.2)))),
122
+ };
123
+ }
124
+
86
125
  // Per-device 4-digit human-verification code. Sent alongside the
87
126
  // device id so the operator approving on the host can match what
88
127
  // they see in the Remote page against what the requesting user
@@ -229,6 +229,20 @@ export function TerminalView({ terminalId, cliType }) {
229
229
  scheduleFit();
230
230
  termRef.current = term;
231
231
 
232
+ // Web fonts settle AFTER the first fit. The terminal's mono stack
233
+ // (`Geist Mono` / `JetBrains Mono`) loads async from Google Fonts with
234
+ // display:swap, so on a machine without a local `Cascadia Mono` the very
235
+ // first term.open()+fit() measures cell metrics against the fallback font.
236
+ // When the real font swaps in its cell height changes, making the row
237
+ // count fit computed wrong — and because the host's box size never changed,
238
+ // the ResizeObserver never fires to correct it. The terminal ends up a row
239
+ // or two short of (or past) the host, "sometimes" — depending purely on
240
+ // whether the font was already cached. Re-fit once fonts settle; it's a
241
+ // no-op when the metrics didn't actually change (e.g. local font hit).
242
+ try {
243
+ document.fonts?.ready?.then(() => { if (termRef.current === term) scheduleFit(); });
244
+ } catch {}
245
+
232
246
  // Browser WS API can't set Authorization headers — token + device
233
247
  // ride as query string when we have them (Remote-mode access).
234
248
  // Server's upgrade handler reads both when Host is non-loopback.
@@ -154,7 +154,7 @@ export function ConfigurePage() {
154
154
 
155
155
  useEffect(() => {
156
156
  if (cfg && !general) {
157
- setGeneral({ workDir: cfg.workDir });
157
+ setGeneral({ workDir: cfg.workDir, editor: cfg.editor });
158
158
  }
159
159
  }, [cfg]);
160
160
 
@@ -167,6 +167,7 @@ export function ConfigurePage() {
167
167
  const saved = await api('PUT', '/api/config', {
168
168
  ...cfg,
169
169
  workDir: (merged.workDir || '').trim(),
170
+ editor: (merged.editor || '').trim(),
170
171
  });
171
172
  config.value = saved;
172
173
  setToast('saved');
@@ -198,6 +199,13 @@ export function ConfigurePage() {
198
199
  <span class="label">Backend</span>
199
200
  <${RestartButton} />
200
201
  </div>
202
+ <label class="field">
203
+ <span class="label">Editor</span>
204
+ <input type="text" class="mono" value=${general.editor || ''}
205
+ placeholder="code"
206
+ onChange=${(e) => saveGeneral({ editor: e.target.value })} />
207
+ <span class="hint">Command for a session's “Open in editor” action. Default <code>code</code> (VS Code). Try <code>cursor</code>, <code>code-insiders</code>, …</span>
208
+ </label>
201
209
  </div>
202
210
  </${Section}>
203
211
 
@@ -7,13 +7,13 @@
7
7
  import { html } from '../html.js';
8
8
  import { useEffect, useRef, useState } from 'preact/hooks';
9
9
  import { activeSessionId, sessions, config, selectTab, selectSession, clockTick } from '../state.js';
10
- import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle } from '../api.js';
10
+ import { resumeSession, clearResumeFailure, deleteSession, setSessionTitle, openSessionInEditor } from '../api.js';
11
11
  import { setToast } from '../toast.js';
12
12
  import { ccsmConfirm, ccsmPrompt } from '../dialog.js';
13
13
  import { TerminalView } from '../components/TerminalView.js';
14
14
  import { PageTitleBar } from '../components/PageTitleBar.js';
15
15
  import { Popover } from '../components/Popover.js';
16
- import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal } from '../icons.js';
16
+ import { IconMoreVert, IconPencil, IconClose, IconPlus, IconForCliType, IconTerminal, IconExternal } from '../icons.js';
17
17
  import { fmtAgo } from '../util.js';
18
18
 
19
19
  function SessionTabs({ activeId, onActivate, onNew, kebab }) {
@@ -51,7 +51,7 @@ function SessionTabs({ activeId, onActivate, onNew, kebab }) {
51
51
  </div>`;
52
52
  }
53
53
 
54
- function SessionMenu({ session, onRename, onDelete }) {
54
+ function SessionMenu({ session, onRename, onDelete, onOpenEditor }) {
55
55
  const [open, setOpen] = useState(false);
56
56
  const anchor = useRef(null);
57
57
  return html`
@@ -61,9 +61,12 @@ function SessionMenu({ session, onRename, onDelete }) {
61
61
  <${IconMoreVert} />
62
62
  </button>
63
63
  ${open ? html`
64
- <${Popover} anchor=${anchor} align="right" width=${180}
64
+ <${Popover} anchor=${anchor} align="right" width=${200}
65
65
  onClose=${() => setOpen(false)}>
66
66
  <div class="session-menu">
67
+ <button class="session-menu-item" onClick=${() => { setOpen(false); onOpenEditor(); }}>
68
+ <${IconExternal} /> Open in editor
69
+ </button>
67
70
  <button class="session-menu-item" onClick=${() => { setOpen(false); onRename(); }}>
68
71
  <${IconPencil} /> Rename
69
72
  </button>
@@ -129,6 +132,12 @@ export function SessionsPage() {
129
132
  activeSessionId.value = null;
130
133
  } catch (e) { setToast(e.message, 'error'); }
131
134
  };
135
+ const onOpenEditor = async () => {
136
+ try {
137
+ const r = await openSessionInEditor(session.id);
138
+ setToast(`Opening in ${r?.editor || 'editor'}…`);
139
+ } catch (e) { setToast(e.message, 'error'); }
140
+ };
132
141
 
133
142
  return html`
134
143
  <${PageTitleBar} title=${html`
@@ -146,7 +155,7 @@ export function SessionsPage() {
146
155
  activeId=${session.id}
147
156
  onActivate=${(sid) => selectSession(sid)}
148
157
  onNew=${() => selectTab('launch')}
149
- kebab=${html`<${SessionMenu} session=${session} onRename=${onRename} onDelete=${onDelete} />`} />
158
+ kebab=${html`<${SessionMenu} session=${session} onRename=${onRename} onDelete=${onDelete} onOpenEditor=${onOpenEditor} />`} />
150
159
  <div class="session-pane">
151
160
  <div class="session-pane-body">
152
161
  ${running
@@ -2,7 +2,7 @@
2
2
  // Items live in a signal keyed by repo so progress rows are reactive.
3
3
 
4
4
  import { signal } from '@preact/signals';
5
- import { httpBase } from './backend.js';
5
+ import { httpBase, estimateTermSize } from './backend.js';
6
6
 
7
7
  // progressByContext[rootId] = { repoName: { phase, percent, detail, state, indeterminate, name } }
8
8
  export const progressByContext = signal({});
@@ -63,10 +63,13 @@ export async function streamNewSession(body, { progressRootId = 'newSessionProgr
63
63
  // Pass the resolved terminal theme so the backend can hand CLIs a matching
64
64
  // COLORFGBG (light/dark detection). dataset.theme is set by applyTheme().
65
65
  const theme = document.documentElement.dataset.theme;
66
+ // Seed the PTY at the pane's real size (estimated from the window here,
67
+ // since the terminal isn't mounted yet) so alt-screen CLIs don't start
68
+ // at node-pty's 30-row default and get stranded short of a tall window.
66
69
  const res = await fetch(httpBase() + '/api/sessions/new', {
67
70
  method: 'POST',
68
71
  headers: { 'Content-Type': 'application/json' },
69
- body: JSON.stringify({ ...body, theme }),
72
+ body: JSON.stringify({ ...body, theme, ...(estimateTermSize() || {}) }),
70
73
  });
71
74
  if (!res.ok && res.headers.get('content-type')?.startsWith('application/json')) {
72
75
  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 = [], theme }) {
284
+ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [], theme, cols, rows }) {
285
285
  if (!webTerminal.available) {
286
286
  const e = new Error('node-pty unavailable · cannot spawn web terminal');
287
287
  e.code = 'PTY_UNAVAILABLE';
@@ -331,12 +331,22 @@ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [], theme }) {
331
331
  if (theme === 'light' || theme === 'dark') {
332
332
  env.COLORFGBG = theme === 'light' ? '0;15' : '15;0';
333
333
  }
334
+ // Spawn the PTY at the size the frontend measured for its terminal pane
335
+ // (clamped against junk), so alt-screen CLIs lay out at the right height
336
+ // from the first frame instead of node-pty's 120×30 default. Omitted ⇒
337
+ // webTerminal.spawn keeps its default; xterm's first resize corrects any
338
+ // small estimate error on attach regardless.
339
+ const sized = (Number(cols) > 0 && Number(rows) > 0)
340
+ ? { cols: Math.min(400, Math.max(20, Math.floor(Number(cols)))),
341
+ rows: Math.min(200, Math.max(8, Math.floor(Number(rows)))) }
342
+ : {};
334
343
  const trySpawn = (executable) => webTerminal.spawn({
335
344
  id: sessionId,
336
345
  command: executable,
337
346
  args,
338
347
  cwd,
339
348
  env,
349
+ ...sized,
340
350
  meta: { ...meta, cliId: cli.id, cliName: cli.name },
341
351
  onData: () => {
342
352
  persistedSessions.touch(sessionId).catch(() => {});
@@ -623,6 +633,35 @@ app.delete('/api/sessions/:id', asyncH(async (req, res) => {
623
633
  res.json({ removed });
624
634
  }));
625
635
 
636
+ // Open a session's working directory in the user's configured editor
637
+ // (config.editor, default `code` = VS Code, whose Source Control panel is
638
+ // also the review-changes view once the folder's open). Spawned detached
639
+ // so it outlives ccsm; shell:true so Windows resolves `code.cmd` via
640
+ // PATHEXT and a command like `code --reuse-window` parses, with the cwd
641
+ // quoted so paths with spaces survive the shell. spawnEnv() merges the
642
+ // user-scope PATH so `code`/`cursor` are found even when the inherited
643
+ // env lacks them.
644
+ app.post('/api/sessions/:id/open-editor', asyncH(async (req, res) => {
645
+ const record = await persistedSessions.get(req.params.id);
646
+ if (!record) return res.status(404).json({ error: 'session not found' });
647
+ const cfg = await loadConfig();
648
+ const editor = (cfg.editor || '').trim() || 'code';
649
+ const { spawn } = require('node:child_process');
650
+ try {
651
+ const child = spawn(editor, [`"${record.cwd}"`], {
652
+ detached: true, stdio: 'ignore', shell: true,
653
+ env: spawnEnv(), windowsHide: true,
654
+ });
655
+ // A bad editor command fails the shell async (after we've responded);
656
+ // log it so it's diagnosable, but the happy path needs no await.
657
+ child.on('error', (e) => console.warn(`[ccsm] open-editor "${editor}" failed:`, e.message));
658
+ child.unref();
659
+ res.json({ ok: true, editor, cwd: record.cwd });
660
+ } catch (e) {
661
+ res.status(500).json({ error: `failed to launch ${editor}: ${e.message}` });
662
+ }
663
+ }));
664
+
626
665
  // Reorder sessions within a folder. Body: { folderId, ids } where ids
627
666
  // is the new sequence of session ids in their final display order
628
667
  // inside that folder. Each session gets `folderId` + `order: 0..N-1`
@@ -867,6 +906,8 @@ app.post('/api/sessions/new', async (req, res) => {
867
906
  meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
868
907
  extraArgs: [...themeArgs, ...newSessionArgs],
869
908
  theme: req.body && req.body.theme,
909
+ cols: req.body && req.body.cols,
910
+ rows: req.body && req.body.rows,
870
911
  });
871
912
  await persistedSessions.markRunning(record.id, entry.meta.pid);
872
913
  launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
@@ -1004,6 +1045,8 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
1004
1045
  meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
1005
1046
  extraArgs: [...themeArgs, ...extraArgs],
1006
1047
  theme: req.body && req.body.theme,
1048
+ cols: req.body && req.body.cols,
1049
+ rows: req.body && req.body.rows,
1007
1050
  });
1008
1051
  await persistedSessions.markRunning(record.id, entry.meta.pid);
1009
1052
  res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });