@bakapiano/ccsm 0.8.4 → 0.9.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/CLAUDE.md +3 -3
- package/README.md +7 -7
- package/bin/ccsm.js +1 -1
- package/lib/config.js +5 -0
- package/lib/webTerminal.js +25 -3
- package/package.json +4 -4
- package/public/css/forms.css +51 -0
- package/public/css/layout.css +5 -0
- package/public/css/sidebar.css +38 -1
- package/public/css/terminals.css +44 -0
- package/public/css/wco.css +29 -0
- package/public/index.html +4 -2
- package/public/js/actions.js +27 -7
- package/public/js/components/NewSessionModal.js +18 -7
- package/public/js/components/OfflineBanner.js +1 -1
- package/public/js/components/Sidebar.js +32 -1
- package/public/js/components/TerminalView.js +166 -0
- package/public/js/pages/AboutPage.js +1 -1
- package/public/js/pages/ConfigurePage.js +72 -1
- package/public/js/pages/LaunchPage.js +35 -23
- package/public/js/state.js +115 -0
- package/scripts/install.js +1 -1
- package/server.js +51 -9
package/CLAUDE.md
CHANGED
|
@@ -16,12 +16,12 @@ at-a-glance list and a snapshot/restore safety net.
|
|
|
16
16
|
|
|
17
17
|
The single most important fact about ccsm v0.8+ is that **the frontend
|
|
18
18
|
is no longer bundled into the npm package** in production. It lives at
|
|
19
|
-
`https://bakapiano.github.io/
|
|
19
|
+
`https://bakapiano.github.io/ccsm/v1/`, served by GitHub Pages, deployed
|
|
20
20
|
via the workflow at `.github/workflows/deploy-pages.yml`.
|
|
21
21
|
|
|
22
22
|
```
|
|
23
23
|
┌── browser ────────────────────────────┐
|
|
24
|
-
│ https://bakapiano.github.io/
|
|
24
|
+
│ https://bakapiano.github.io/ccsm/v1/ ← static frontend
|
|
25
25
|
└────────────┬──────────────────────────┘
|
|
26
26
|
│ fetch /api/* (CORS, allow-list)
|
|
27
27
|
│ ws://localhost:7777/ws/*
|
|
@@ -62,7 +62,7 @@ Terminal returns immediately (the server is spawned detached). Close
|
|
|
62
62
|
the window → server saves a final snapshot and exits within ~12s.
|
|
63
63
|
|
|
64
64
|
If you don't want the auto-opened window (e.g. you live in the PWA),
|
|
65
|
-
just visit `https://bakapiano.github.io/
|
|
65
|
+
just visit `https://bakapiano.github.io/ccsm/v1/` — when backend is
|
|
66
66
|
down you see an OfflineBanner with a **Start ccsm** button.
|
|
67
67
|
|
|
68
68
|
Default port `7777`, default workDir `~/ccsm-workspaces`. Config +
|
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@ A single pane over every live Claude Code session on your machine.
|
|
|
4
4
|
Hosted web UI + tiny local Node daemon. Windows-first; cross-platform
|
|
5
5
|
in progress.
|
|
6
6
|
|
|
7
|
-
[](https://bakapiano.github.io/ccsm/v1/)
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
┌── browser ─────────────────────────┐
|
|
11
|
-
│ https://bakapiano.github.io/
|
|
11
|
+
│ https://bakapiano.github.io/ccsm/v1/ ← static frontend
|
|
12
12
|
└────────────┬───────────────────────┘
|
|
13
13
|
│ fetch /api/* (CORS)
|
|
14
14
|
│ ws://localhost:7777/ws/*
|
|
@@ -51,7 +51,7 @@ gets registered.
|
|
|
51
51
|
ccsm # starts the backend, opens the frontend
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
Or just visit **https://bakapiano.github.io/
|
|
54
|
+
Or just visit **https://bakapiano.github.io/ccsm/v1/** in any browser.
|
|
55
55
|
If the backend isn't running, you'll see a "Backend not running" banner
|
|
56
56
|
with a **Start ccsm** button — click it, Windows asks once whether to
|
|
57
57
|
open the `ccsm://` handler (check "Always allow"), and the backend
|
|
@@ -119,7 +119,7 @@ ccsm/
|
|
|
119
119
|
|
|
120
120
|
## How "wake on click" works
|
|
121
121
|
|
|
122
|
-
The hosted frontend (https://bakapiano.github.io/
|
|
122
|
+
The hosted frontend (https://bakapiano.github.io/ccsm/v1/) lives entirely
|
|
123
123
|
in the browser sandbox — it cannot spawn processes. So when the backend
|
|
124
124
|
is down, the OfflineBanner's **Start ccsm** is a plain
|
|
125
125
|
`<a href="ccsm://start">`. The OS hands that off to a per-user URL
|
|
@@ -151,7 +151,7 @@ Every gracefulShutdown saves a final snapshot before exit.
|
|
|
151
151
|
## Dev
|
|
152
152
|
|
|
153
153
|
```bash
|
|
154
|
-
git clone https://github.com/bakapiano/
|
|
154
|
+
git clone https://github.com/bakapiano/ccsm
|
|
155
155
|
cd cssm
|
|
156
156
|
npm install
|
|
157
157
|
node server.js
|
|
@@ -175,8 +175,8 @@ build feature-detects via `/api/capabilities`, so a slightly older
|
|
|
175
175
|
backend still works as long as it advertises the needed feature.
|
|
176
176
|
|
|
177
177
|
```
|
|
178
|
-
https://bakapiano.github.io/
|
|
179
|
-
https://bakapiano.github.io/
|
|
178
|
+
https://bakapiano.github.io/ccsm/v1/ ← current
|
|
179
|
+
https://bakapiano.github.io/ccsm/v2/ ← future, when /api breaking-changes
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
Installed PWAs are pinned to whichever path they were installed from.
|
package/bin/ccsm.js
CHANGED
|
@@ -176,7 +176,7 @@ function isSameVersion(running) {
|
|
|
176
176
|
}
|
|
177
177
|
console.log(`ccsm started · v${ready.version}`);
|
|
178
178
|
console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
|
|
179
|
-
console.log(`frontend: https://bakapiano.github.io/
|
|
179
|
+
console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
|
|
180
180
|
console.log(`logs: ${LOG}`);
|
|
181
181
|
|
|
182
182
|
// First-run hint — printed once, then a marker file makes us quiet.
|
package/lib/config.js
CHANGED
|
@@ -21,6 +21,11 @@ const DEFAULTS = {
|
|
|
21
21
|
claudeCommand: 'claude',
|
|
22
22
|
terminal: 'wt',
|
|
23
23
|
commandShell: 'pwsh',
|
|
24
|
+
// 'wt' — open a new Windows Terminal window (or whatever `terminal` is set to)
|
|
25
|
+
// 'web' — spawn in-process PTY, attach via xterm.js in the Terminals tab
|
|
26
|
+
// Used as the default for new sessions, resume, continue, finder.
|
|
27
|
+
// Per-launch radio in the UI can still override.
|
|
28
|
+
defaultTerminalMode: 'wt',
|
|
24
29
|
autoFocusOnLaunch: true,
|
|
25
30
|
focusMovesToCenter: false,
|
|
26
31
|
// 'app' — Edge/Chrome --app=<url> chromeless window (looks like a desktop app)
|
package/lib/webTerminal.js
CHANGED
|
@@ -33,6 +33,8 @@ function genId() {
|
|
|
33
33
|
return 'web-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
|
|
37
|
+
|
|
36
38
|
// Spawn a new PTY. `command` and `args` are passed straight to node-pty.
|
|
37
39
|
// `meta` is whatever the caller wants surfaced to the UI (title, cwd, etc).
|
|
38
40
|
// Throws if node-pty isn't available.
|
|
@@ -43,12 +45,25 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {}
|
|
|
43
45
|
throw err;
|
|
44
46
|
}
|
|
45
47
|
const id = genId();
|
|
46
|
-
|
|
48
|
+
// useConpty: new ConPTY API (Win10 1809+). node-pty defaults this true on
|
|
49
|
+
// Windows, but spell it out so we know we're on the modern path.
|
|
50
|
+
// useConptyDll: opt-in to the newest, separately-versioned conpty.dll
|
|
51
|
+
// (node-pty 1.0+, Windows 10 1809+ if the dll is present). This is the
|
|
52
|
+
// path VSCode uses (see vscode/src/vs/platform/terminal/node/terminalProcess.ts)
|
|
53
|
+
// — it has a larger stdin buffer and doesn't split bracketed-paste
|
|
54
|
+
// payloads across multiple child-process reads, so claude code's
|
|
55
|
+
// [Pasted text] chip detection actually fires.
|
|
56
|
+
const ptyOpts = {
|
|
47
57
|
name: 'xterm-256color',
|
|
48
58
|
cols, rows,
|
|
49
59
|
cwd: cwd ? path.resolve(cwd) : process.cwd(),
|
|
50
60
|
env: { ...process.env, ...(env || {}) },
|
|
51
|
-
}
|
|
61
|
+
};
|
|
62
|
+
if (process.platform === 'win32') {
|
|
63
|
+
ptyOpts.useConpty = true;
|
|
64
|
+
ptyOpts.useConptyDll = true;
|
|
65
|
+
}
|
|
66
|
+
const proc = pty.spawn(command, args, ptyOpts);
|
|
52
67
|
const entry = {
|
|
53
68
|
id,
|
|
54
69
|
pty: proc,
|
|
@@ -110,7 +125,14 @@ function attach(id, ws) {
|
|
|
110
125
|
if (entry.exitedAt) return; // PTY is dead, ignore further input
|
|
111
126
|
switch (event.type) {
|
|
112
127
|
case 'input':
|
|
113
|
-
if (typeof event.data === 'string')
|
|
128
|
+
if (typeof event.data === 'string') {
|
|
129
|
+
if (process.env.CCSM_DEBUG_PASTE === '1') {
|
|
130
|
+
const d = event.data;
|
|
131
|
+
const hex = Buffer.from(d, 'utf8').toString('hex').match(/.{1,2}/g).join(' ');
|
|
132
|
+
console.log(`[pty.write id=${id}] len=${d.length} hex=${hex.slice(0, 400)}${hex.length > 400 ? '...' : ''}`);
|
|
133
|
+
}
|
|
134
|
+
entry.pty.write(event.data);
|
|
135
|
+
}
|
|
114
136
|
break;
|
|
115
137
|
case 'resize':
|
|
116
138
|
if (Number(event.cols) > 0 && Number(event.rows) > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -44,12 +44,12 @@
|
|
|
44
44
|
],
|
|
45
45
|
"repository": {
|
|
46
46
|
"type": "git",
|
|
47
|
-
"url": "git+https://github.com/bakapiano/
|
|
47
|
+
"url": "git+https://github.com/bakapiano/ccsm.git"
|
|
48
48
|
},
|
|
49
49
|
"bugs": {
|
|
50
|
-
"url": "https://github.com/bakapiano/
|
|
50
|
+
"url": "https://github.com/bakapiano/ccsm/issues"
|
|
51
51
|
},
|
|
52
|
-
"homepage": "https://github.com/bakapiano/
|
|
52
|
+
"homepage": "https://github.com/bakapiano/ccsm#readme",
|
|
53
53
|
"publishConfig": {
|
|
54
54
|
"access": "public",
|
|
55
55
|
"registry": "https://registry.npmjs.org/"
|
package/public/css/forms.css
CHANGED
|
@@ -280,3 +280,54 @@ input[type="checkbox"]:checked::after {
|
|
|
280
280
|
.repos-table thead th:last-child,
|
|
281
281
|
.repos-table tbody td:last-child { padding-right: var(--s-2); }
|
|
282
282
|
.repos-table tbody td input { font-size: 12px; max-width: none; }
|
|
283
|
+
|
|
284
|
+
/* Theme accent picker (Configure tab). 8 swatches + native color picker
|
|
285
|
+
+ hex input + reset. Swatches are filled circles; the active one wears
|
|
286
|
+
a subtle ring in --ink so it pops on any accent. */
|
|
287
|
+
.accent-picker {
|
|
288
|
+
display: flex;
|
|
289
|
+
flex-direction: column;
|
|
290
|
+
gap: var(--s-3);
|
|
291
|
+
}
|
|
292
|
+
.accent-swatches {
|
|
293
|
+
display: flex;
|
|
294
|
+
flex-wrap: wrap;
|
|
295
|
+
gap: var(--s-2);
|
|
296
|
+
}
|
|
297
|
+
.accent-swatch {
|
|
298
|
+
appearance: none;
|
|
299
|
+
width: 24px;
|
|
300
|
+
height: 24px;
|
|
301
|
+
border-radius: 999px;
|
|
302
|
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
303
|
+
cursor: pointer;
|
|
304
|
+
padding: 0;
|
|
305
|
+
transition: transform .12s ease, box-shadow .12s ease;
|
|
306
|
+
}
|
|
307
|
+
.accent-swatch:hover { transform: scale(1.08); }
|
|
308
|
+
.accent-swatch.is-active {
|
|
309
|
+
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--ink);
|
|
310
|
+
}
|
|
311
|
+
.accent-custom {
|
|
312
|
+
display: flex;
|
|
313
|
+
align-items: center;
|
|
314
|
+
gap: var(--s-2);
|
|
315
|
+
}
|
|
316
|
+
.accent-custom input[type="color"] {
|
|
317
|
+
width: 36px;
|
|
318
|
+
height: 28px;
|
|
319
|
+
padding: 0;
|
|
320
|
+
border: 1px solid var(--border);
|
|
321
|
+
border-radius: var(--r-sm);
|
|
322
|
+
background: var(--bg-elev);
|
|
323
|
+
cursor: pointer;
|
|
324
|
+
}
|
|
325
|
+
.accent-hex {
|
|
326
|
+
font-family: var(--mono);
|
|
327
|
+
font-size: 12px;
|
|
328
|
+
width: 96px;
|
|
329
|
+
padding: 4px 8px;
|
|
330
|
+
border: 1px solid var(--border);
|
|
331
|
+
border-radius: var(--r-sm);
|
|
332
|
+
background: var(--bg-elev);
|
|
333
|
+
}
|
package/public/css/layout.css
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
.app:has(.sidebar[data-collapsed="true"]) {
|
|
10
10
|
grid-template-columns: var(--sidebar-w-collapsed) 1fr;
|
|
11
11
|
}
|
|
12
|
+
/* While dragging the sidebar resize handle, kill the transition so the
|
|
13
|
+
grid tracks pointermove pixel-for-pixel instead of lagging behind. */
|
|
14
|
+
body.is-resizing-sidebar .app {
|
|
15
|
+
transition: none;
|
|
16
|
+
}
|
|
12
17
|
|
|
13
18
|
.main {
|
|
14
19
|
display: flex;
|
package/public/css/sidebar.css
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
display: flex;
|
|
10
10
|
flex-direction: column;
|
|
11
11
|
padding: var(--s-4) var(--s-3);
|
|
12
|
-
|
|
12
|
+
/* visible: let the resize handle (positioned at right: -3px) overflow the
|
|
13
|
+
sidebar's right edge. We instead clip the things that USED to need
|
|
14
|
+
this via the .sidebar-nav scroll region. */
|
|
15
|
+
overflow: visible;
|
|
13
16
|
transition: padding .25s cubic-bezier(.4, 0, .2, 1);
|
|
14
17
|
}
|
|
15
18
|
.sidebar[data-collapsed="true"] {
|
|
@@ -163,3 +166,37 @@
|
|
|
163
166
|
.sidebar[data-collapsed="true"] .collapse-toggle .nav-icon {
|
|
164
167
|
transform: rotate(180deg);
|
|
165
168
|
}
|
|
169
|
+
|
|
170
|
+
/* Drag-to-resize handle. Sits absolutely against the sidebar's right
|
|
171
|
+
border so the cursor target spans the full height. 6px wide hit area
|
|
172
|
+
centered on the visible 1px border — easy to grab without bumping
|
|
173
|
+
into adjacent layout. */
|
|
174
|
+
.sidebar-resize-handle {
|
|
175
|
+
position: absolute;
|
|
176
|
+
top: 0;
|
|
177
|
+
right: -3px;
|
|
178
|
+
width: 6px;
|
|
179
|
+
height: 100%;
|
|
180
|
+
cursor: col-resize;
|
|
181
|
+
z-index: 5;
|
|
182
|
+
touch-action: none;
|
|
183
|
+
/* Subtle hover indicator: deepens the border-right color on hover so the
|
|
184
|
+
user knows the edge is interactive. */
|
|
185
|
+
background: transparent;
|
|
186
|
+
transition: background .12s ease;
|
|
187
|
+
}
|
|
188
|
+
.sidebar-resize-handle:hover,
|
|
189
|
+
body.is-resizing-sidebar .sidebar-resize-handle {
|
|
190
|
+
background: linear-gradient(to right, transparent 0, transparent 2px,
|
|
191
|
+
var(--ink-mid) 2px, var(--ink-mid) 3px,
|
|
192
|
+
transparent 3px);
|
|
193
|
+
}
|
|
194
|
+
/* While dragging, freeze global cursor + suppress text selection so the
|
|
195
|
+
whole page tracks resize cleanly even if pointer leaves the handle. */
|
|
196
|
+
body.is-resizing-sidebar {
|
|
197
|
+
cursor: col-resize !important;
|
|
198
|
+
user-select: none;
|
|
199
|
+
}
|
|
200
|
+
body.is-resizing-sidebar * {
|
|
201
|
+
cursor: col-resize !important;
|
|
202
|
+
}
|
package/public/css/terminals.css
CHANGED
|
@@ -91,6 +91,50 @@
|
|
|
91
91
|
flex: 1;
|
|
92
92
|
min-height: 0;
|
|
93
93
|
width: 100%;
|
|
94
|
+
/* IME composition (Chinese/Japanese pinyin) lives in absolutely-positioned
|
|
95
|
+
.xterm-helper-textarea + .composition-view that grow with the composed
|
|
96
|
+
string. Near the right edge they overflow the viewport and trigger a
|
|
97
|
+
horizontal scrollbar that visually "pushes" the layout. Clip here so
|
|
98
|
+
the overflow is silently absorbed instead of expanding the page.
|
|
99
|
+
Do NOT touch the textarea/composition-view's own text properties —
|
|
100
|
+
xterm relies on their single-line behaviour to keep IME events firing
|
|
101
|
+
correctly (forcing pre-wrap / break-all eats compositionupdate events
|
|
102
|
+
in Chromium and Chinese input stops working entirely). */
|
|
103
|
+
overflow: hidden;
|
|
104
|
+
contain: layout;
|
|
105
|
+
}
|
|
106
|
+
/* While the user is composing (IME), pin the helper textarea to the right
|
|
107
|
+
edge of the terminal so it grows leftward instead of pushing the layout
|
|
108
|
+
rightward. Only touches positioning — NOT width / wrap / max-width, which
|
|
109
|
+
would break Chromium's compositionupdate event flow and stop Chinese
|
|
110
|
+
input from working. The class is toggled by TerminalView.js. */
|
|
111
|
+
.terminal-host.is-composing .xterm-helper-textarea {
|
|
112
|
+
left: auto !important;
|
|
113
|
+
right: 0 !important;
|
|
114
|
+
text-align: right;
|
|
115
|
+
/* xterm un-hides the textarea during composition so the user can see the
|
|
116
|
+
composed string inline. We've moved it to the right edge to stop it
|
|
117
|
+
pushing layout — but that means the composed pinyin would now visibly
|
|
118
|
+
appear on the right. Hide its glyphs (caret + text) so the user only
|
|
119
|
+
sees the OS-native IME candidate popup, which floats independently
|
|
120
|
+
and is unaffected. */
|
|
121
|
+
color: transparent !important;
|
|
122
|
+
caret-color: transparent !important;
|
|
123
|
+
background: transparent !important;
|
|
124
|
+
text-shadow: none !important;
|
|
125
|
+
}
|
|
126
|
+
/* xterm also overlays a .composition-view (a small box at the cursor with
|
|
127
|
+
the in-progress string + a gold caret using THEME.cursor). We can't
|
|
128
|
+
display:none it — Chromium needs it in the layout tree to keep the IME
|
|
129
|
+
compositionupdate events flowing — but we can make it visually invisible
|
|
130
|
+
while leaving it laid out. */
|
|
131
|
+
.terminal-host .composition-view {
|
|
132
|
+
opacity: 0 !important;
|
|
133
|
+
color: transparent !important;
|
|
134
|
+
background: transparent !important;
|
|
135
|
+
border-color: transparent !important;
|
|
136
|
+
box-shadow: none !important;
|
|
137
|
+
pointer-events: none;
|
|
94
138
|
}
|
|
95
139
|
/* Don't override xterm's background — its renderer (canvas/WebGL) assumes
|
|
96
140
|
an opaque surface and ghosts on scroll if we force transparent. The
|
package/public/css/wco.css
CHANGED
|
@@ -58,6 +58,34 @@ body.is-app .sidebar-brand {
|
|
|
58
58
|
}
|
|
59
59
|
body.is-app .page-head {
|
|
60
60
|
padding-top: var(--s-8); /* 32px = old main.pt */
|
|
61
|
+
/* Reserve space on the right for the OS window controls (close /
|
|
62
|
+
maximize / minimize) that float over the top-right of every
|
|
63
|
+
chromeless app window — both --app= (display-mode: standalone)
|
|
64
|
+
and PWA. Without this, the server-status pill + Refresh button
|
|
65
|
+
slide under those controls and stop being clickable. ~150px covers
|
|
66
|
+
three Windows controls + a little breathing room; macOS traffic
|
|
67
|
+
lights are on the left so this is harmless there. The WCO override
|
|
68
|
+
below uses the precise env() value when the API is available. */
|
|
69
|
+
padding-right: 150px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* In app/PWA mode, hide the in-page title/subtitle entirely. The sidebar
|
|
73
|
+
nav already tells the user which section they're on, and the OS title
|
|
74
|
+
bar (or the floating WCO controls) is the only chrome we want. This
|
|
75
|
+
reclaims a chunky 60+ px of vertical real estate at the top of every
|
|
76
|
+
tab — really noticeable in the Terminals tab where xterm benefits
|
|
77
|
+
from every pixel. The page-head wrapper survives because it still
|
|
78
|
+
hosts the status pill + Refresh button on the right and remains
|
|
79
|
+
draggable. */
|
|
80
|
+
body.is-app .page-title,
|
|
81
|
+
body.is-app .page-subtitle {
|
|
82
|
+
display: none;
|
|
83
|
+
}
|
|
84
|
+
body.is-app .page-head {
|
|
85
|
+
padding-bottom: 0;
|
|
86
|
+
border-bottom: 0;
|
|
87
|
+
align-items: center;
|
|
88
|
+
min-height: 28px;
|
|
61
89
|
}
|
|
62
90
|
|
|
63
91
|
/* WCO-only · the OS title bar is fully gone, so right-pad the page-head
|
|
@@ -66,5 +94,6 @@ body.is-app .page-head {
|
|
|
66
94
|
@media (display-mode: window-controls-overlay) {
|
|
67
95
|
.page-head {
|
|
68
96
|
padding-right: calc(var(--s-10) + 100vw - env(titlebar-area-width, 100vw));
|
|
97
|
+
min-height: env(titlebar-area-height, 32px);
|
|
69
98
|
}
|
|
70
99
|
}
|
package/public/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<title>CCSM — Claude CLI Sessions Manager</title>
|
|
12
12
|
<!-- All asset paths are RELATIVE so the same index.html works when
|
|
13
13
|
served from localhost:7777/ (backend bundle) AND from
|
|
14
|
-
https://bakapiano.github.io/
|
|
14
|
+
https://bakapiano.github.io/ccsm/v1/ (GH Pages hosted). -->
|
|
15
15
|
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
|
16
16
|
<link rel="manifest" href="./manifest.webmanifest" />
|
|
17
17
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"htm": "https://esm.sh/htm@3.1.1",
|
|
44
44
|
"@xterm/xterm": "https://esm.sh/@xterm/xterm@5.5.0",
|
|
45
45
|
"@xterm/addon-fit": "https://esm.sh/@xterm/addon-fit@0.10.0?deps=@xterm/xterm@5.5.0",
|
|
46
|
-
"@xterm/addon-web-links": "https://esm.sh/@xterm/addon-web-links@0.11.0?deps=@xterm/xterm@5.5.0"
|
|
46
|
+
"@xterm/addon-web-links": "https://esm.sh/@xterm/addon-web-links@0.11.0?deps=@xterm/xterm@5.5.0",
|
|
47
|
+
"@xterm/addon-clipboard": "https://esm.sh/@xterm/addon-clipboard@0.1.0?deps=@xterm/xterm@5.5.0",
|
|
48
|
+
"@xterm/addon-webgl": "https://esm.sh/@xterm/addon-webgl@0.18.0?deps=@xterm/xterm@5.5.0"
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
</script>
|
package/public/js/actions.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Mutation actions shared by SessionsPage, FavoritesTable etc. — each
|
|
2
2
|
// optimistically updates the relevant signal and rolls back on error.
|
|
3
3
|
|
|
4
|
-
import { favorites, labels, sessions, recent } from './state.js';
|
|
5
|
-
import { api, loadSessions, loadRecent } from './api.js';
|
|
4
|
+
import { favorites, labels, sessions, recent, config, capabilities, activeTerminalId, selectTab } from './state.js';
|
|
5
|
+
import { api, loadSessions, loadRecent, loadWebTerminals } from './api.js';
|
|
6
6
|
import { setToast } from './toast.js';
|
|
7
7
|
import { ccsmPrompt } from './dialog.js';
|
|
8
8
|
|
|
@@ -68,10 +68,20 @@ export async function focusSession(sessionId) {
|
|
|
68
68
|
|
|
69
69
|
export async function resumeSession(sessionId, cwd, { kind = 'resume' } = {}) {
|
|
70
70
|
if (!cwd) return setToast('no cwd for this session', 'error');
|
|
71
|
+
const wantWeb = capabilities.value?.webTerminal
|
|
72
|
+
&& (config.value?.defaultTerminalMode || 'wt') === 'web';
|
|
73
|
+
const terminal = wantWeb ? 'web' : 'wt';
|
|
71
74
|
try {
|
|
72
|
-
await api('POST', `/api/sessions/${sessionId}/resume`, { cwd });
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
const r = await api('POST', `/api/sessions/${sessionId}/resume`, { cwd, terminal });
|
|
76
|
+
if (r.launched?.mode === 'web') {
|
|
77
|
+
setToast(`${kind === 'continue' ? 'continuing' : 'resuming'} in web · ${sessionId.slice(0, 8)}…`);
|
|
78
|
+
await loadWebTerminals();
|
|
79
|
+
if (r.launched.id) activeTerminalId.value = r.launched.id;
|
|
80
|
+
selectTab('terminals');
|
|
81
|
+
} else {
|
|
82
|
+
const verb = kind === 'continue' ? 'continuing' : 'opening wt';
|
|
83
|
+
setToast(`${verb} · ${sessionId.slice(0, 8)}…`);
|
|
84
|
+
}
|
|
75
85
|
if (kind === 'continue') {
|
|
76
86
|
setTimeout(() => loadSessions().catch(() => {}), 3000);
|
|
77
87
|
setTimeout(() => loadRecent().catch(() => {}), 4000);
|
|
@@ -80,8 +90,18 @@ export async function resumeSession(sessionId, cwd, { kind = 'resume' } = {}) {
|
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
export async function runFinder() {
|
|
93
|
+
const wantWeb = capabilities.value?.webTerminal
|
|
94
|
+
&& (config.value?.defaultTerminalMode || 'wt') === 'web';
|
|
95
|
+
const terminal = wantWeb ? 'web' : 'wt';
|
|
83
96
|
try {
|
|
84
|
-
await api('POST', '/api/sessions/finder');
|
|
85
|
-
|
|
97
|
+
const r = await api('POST', '/api/sessions/finder', { terminal });
|
|
98
|
+
if (r.launched?.mode === 'web') {
|
|
99
|
+
await loadWebTerminals();
|
|
100
|
+
if (r.launched.id) activeTerminalId.value = r.launched.id;
|
|
101
|
+
selectTab('terminals');
|
|
102
|
+
setToast('finder launching in web terminal');
|
|
103
|
+
} else {
|
|
104
|
+
setToast('finder session launching in a new wt window');
|
|
105
|
+
}
|
|
86
106
|
} catch (e) { setToast(e.message, 'error'); }
|
|
87
107
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { html } from '../html.js';
|
|
6
6
|
import { useEffect, useState } from 'preact/hooks';
|
|
7
7
|
import { signal } from '@preact/signals';
|
|
8
|
-
import { modalOpen, config } from '../state.js';
|
|
9
|
-
import { api, loadWorkspaces } from '../api.js';
|
|
8
|
+
import { modalOpen, config, capabilities, activeTerminalId, selectTab } from '../state.js';
|
|
9
|
+
import { api, loadWorkspaces, loadWebTerminals } from '../api.js';
|
|
10
10
|
import { setToast } from '../toast.js';
|
|
11
11
|
import { streamNewSession, resetProgress } from '../streaming.js';
|
|
12
12
|
import { IconClose } from '../icons.js';
|
|
@@ -51,29 +51,40 @@ function ModalBody() {
|
|
|
51
51
|
|
|
52
52
|
const onLaunch = async () => {
|
|
53
53
|
const repos = [...modalSelected.value];
|
|
54
|
-
if (repos.length === 0) return setToast('select at least one repo', 'error');
|
|
55
54
|
setBusy(true);
|
|
56
55
|
setResult('');
|
|
57
56
|
resetProgress(repos, ROOT_ID);
|
|
57
|
+
const wantWeb = capabilities.value?.webTerminal
|
|
58
|
+
&& (config.value?.defaultTerminalMode || 'wt') === 'web';
|
|
59
|
+
const terminal = wantWeb ? 'web' : 'wt';
|
|
58
60
|
try {
|
|
59
61
|
const final = await streamNewSession(
|
|
60
|
-
{ repos, workspace: workspace || undefined },
|
|
62
|
+
{ repos, workspace: workspace || undefined, terminal },
|
|
61
63
|
{
|
|
62
64
|
progressRootId: ROOT_ID,
|
|
63
65
|
onMeta: (ev) => {
|
|
64
66
|
if (ev.type === 'workspace') {
|
|
65
67
|
setResult(`workspace: ${ev.workspace.path}${ev.created ? ' · newly created' : ''}`);
|
|
66
68
|
} else if (ev.type === 'launched') {
|
|
67
|
-
|
|
69
|
+
const l = ev.launched || {};
|
|
70
|
+
if (l.mode === 'web') setResult(`web terminal launched · pid ${l.pid} · id ${l.id}`);
|
|
71
|
+
else setResult(`terminal launching · pid ${l.pid} · ${l.terminal}`);
|
|
68
72
|
}
|
|
69
73
|
},
|
|
70
74
|
},
|
|
71
75
|
);
|
|
72
76
|
if (final.success) {
|
|
73
77
|
const summary = (final.cloneResults || []).map((c) => `${c.repo}: ${c.action || c.error}`).join(' · ');
|
|
74
|
-
setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''} —
|
|
78
|
+
setResult(`launched in ${final.workspace.path}${final.created ? ' · newly created' : ''}${summary ? ' — ' + summary : ''}`);
|
|
75
79
|
setToast(`launched · ${final.workspace.name}`);
|
|
76
|
-
|
|
80
|
+
if (terminal === 'web' && final.launched?.id) {
|
|
81
|
+
activeTerminalId.value = final.launched.id;
|
|
82
|
+
await loadWebTerminals();
|
|
83
|
+
selectTab('terminals');
|
|
84
|
+
modalOpen.value = false;
|
|
85
|
+
} else {
|
|
86
|
+
setTimeout(close, 1500);
|
|
87
|
+
}
|
|
77
88
|
} else {
|
|
78
89
|
setResult(`error: ${final.error}`);
|
|
79
90
|
setToast(final.error || 'new session failed', 'error');
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Shown when the backend probe fails. The hosted frontend (running at
|
|
2
|
-
// https://bakapiano.github.io/
|
|
2
|
+
// https://bakapiano.github.io/ccsm/v1/) can't spawn processes directly,
|
|
3
3
|
// so we surface a ccsm://start link instead. Windows / OS will hand that
|
|
4
4
|
// off to the registered protocol handler (ccsm.cmd), which spawns the
|
|
5
5
|
// backend silently. Our health probe picks it up on the next tick and
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { html } from '../html.js';
|
|
2
2
|
import {
|
|
3
3
|
activeTab, sidebarCollapsed, configDirty, sessions, webTerminals, capabilities,
|
|
4
|
-
selectTab, toggleSidebar,
|
|
4
|
+
selectTab, toggleSidebar, setSidebarWidth, SIDEBAR_MIN, SIDEBAR_MAX,
|
|
5
5
|
} from '../state.js';
|
|
6
6
|
import {
|
|
7
7
|
IconSessions, IconLaunch, IconTerminal, IconConfigure, IconInfo,
|
|
@@ -23,6 +23,29 @@ function NavItem({ tab, icon, label, badge, dirty }) {
|
|
|
23
23
|
export function Sidebar() {
|
|
24
24
|
const collapsed = sidebarCollapsed.value;
|
|
25
25
|
|
|
26
|
+
// Drag-to-resize handle. Pointer events let one handler cover mouse,
|
|
27
|
+
// touch, pen uniformly + setPointerCapture means dragging continues
|
|
28
|
+
// even if cursor leaves the 4px-wide handle. Collapsed sidebars don't
|
|
29
|
+
// expose a handle — Collapse-toggle is the only way out/in.
|
|
30
|
+
const onResizeStart = (ev) => {
|
|
31
|
+
if (collapsed) return;
|
|
32
|
+
ev.preventDefault();
|
|
33
|
+
const el = ev.currentTarget;
|
|
34
|
+
el.setPointerCapture(ev.pointerId);
|
|
35
|
+
document.body.classList.add('is-resizing-sidebar');
|
|
36
|
+
const move = (e) => setSidebarWidth(e.clientX);
|
|
37
|
+
const up = (e) => {
|
|
38
|
+
el.releasePointerCapture(ev.pointerId);
|
|
39
|
+
document.body.classList.remove('is-resizing-sidebar');
|
|
40
|
+
el.removeEventListener('pointermove', move);
|
|
41
|
+
el.removeEventListener('pointerup', up);
|
|
42
|
+
el.removeEventListener('pointercancel', up);
|
|
43
|
+
};
|
|
44
|
+
el.addEventListener('pointermove', move);
|
|
45
|
+
el.addEventListener('pointerup', up);
|
|
46
|
+
el.addEventListener('pointercancel', up);
|
|
47
|
+
};
|
|
48
|
+
|
|
26
49
|
return html`
|
|
27
50
|
<aside class="sidebar" data-collapsed=${collapsed ? 'true' : 'false'}>
|
|
28
51
|
<div class="sidebar-brand">
|
|
@@ -48,5 +71,13 @@ export function Sidebar() {
|
|
|
48
71
|
<span class="nav-label">Collapse</span>
|
|
49
72
|
</button>
|
|
50
73
|
</div>
|
|
74
|
+
|
|
75
|
+
${!collapsed ? html`
|
|
76
|
+
<div class="sidebar-resize-handle" role="separator" aria-orientation="vertical"
|
|
77
|
+
aria-label="resize sidebar"
|
|
78
|
+
title="drag to resize · double-click to reset"
|
|
79
|
+
onPointerDown=${onResizeStart}
|
|
80
|
+
onDblClick=${() => setSidebarWidth(232)}></div>
|
|
81
|
+
` : null}
|
|
51
82
|
</aside>`;
|
|
52
83
|
}
|
|
@@ -7,6 +7,8 @@ import { useEffect, useRef } from 'preact/hooks';
|
|
|
7
7
|
import { Terminal } from '@xterm/xterm';
|
|
8
8
|
import { FitAddon } from '@xterm/addon-fit';
|
|
9
9
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
|
10
|
+
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
|
11
|
+
import { WebglAddon } from '@xterm/addon-webgl';
|
|
10
12
|
import { wsBase } from '../backend.js';
|
|
11
13
|
|
|
12
14
|
// Dark xterm theme. We give the terminal a near-black ink background to
|
|
@@ -47,10 +49,43 @@ export function TerminalView({ terminalId }) {
|
|
|
47
49
|
scrollback: 5000,
|
|
48
50
|
allowProposedApi: true,
|
|
49
51
|
theme: THEME,
|
|
52
|
+
// Modern keyboard protocols. Without these, xterm.js encodes
|
|
53
|
+
// Shift+Enter, Ctrl+Enter, Ctrl+Shift+key etc. the same as their
|
|
54
|
+
// unmodified versions (e.g. both Enter and Shift+Enter send \r),
|
|
55
|
+
// so TUIs like claude code can't tell them apart.
|
|
56
|
+
//
|
|
57
|
+
// - kittyKeyboard: opt-in protocol that apps enable per-session;
|
|
58
|
+
// xterm emits CSI u sequences that uniquely encode every modifier
|
|
59
|
+
// combo. Claude / vim / fish recognise it.
|
|
60
|
+
// - win32InputMode: ConPTY-specific protocol that surfaces raw
|
|
61
|
+
// Win32 KEY_EVENT_RECORD to the child process, again preserving
|
|
62
|
+
// modifier info. Required for full key fidelity on Windows.
|
|
63
|
+
// (Same set VSCode enables — see vscode/src/.../xtermTerminal.ts)
|
|
64
|
+
vtExtensions: {
|
|
65
|
+
kittyKeyboard: true,
|
|
66
|
+
win32InputMode: true,
|
|
67
|
+
},
|
|
50
68
|
});
|
|
51
69
|
const fit = new FitAddon();
|
|
52
70
|
term.loadAddon(fit);
|
|
53
71
|
term.loadAddon(new WebLinksAddon());
|
|
72
|
+
// OSC 52 clipboard integration. Lets TUI apps initiate clipboard reads/
|
|
73
|
+
// writes via escape sequences (e.g. `tmux set-buffer` or claude code
|
|
74
|
+
// saying "copied to clipboard"). Does NOT handle the browser-side
|
|
75
|
+
// Ctrl+V — that's still our document-level paste handler below.
|
|
76
|
+
term.loadAddon(new ClipboardAddon());
|
|
77
|
+
// WebGL renderer for performance. The default DOM renderer struggles
|
|
78
|
+
// when claude code produces dense color output (its diff panels,
|
|
79
|
+
// syntax-highlighted code). WebGL paints onto a canvas, much smoother
|
|
80
|
+
// at thousands-of-cells per frame. Falls back to DOM if WebGL is
|
|
81
|
+
// unavailable (e.g. older GPU, hardware accel disabled).
|
|
82
|
+
try {
|
|
83
|
+
const webgl = new WebglAddon();
|
|
84
|
+
webgl.onContextLoss(() => { try { webgl.dispose(); } catch {} });
|
|
85
|
+
term.loadAddon(webgl);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
88
|
+
}
|
|
54
89
|
term.open(hostRef.current);
|
|
55
90
|
// Defer fit one tick so the container has measured layout
|
|
56
91
|
requestAnimationFrame(() => { try { fit.fit(); } catch {} });
|
|
@@ -92,7 +127,138 @@ export function TerminalView({ terminalId }) {
|
|
|
92
127
|
// give focus to terminal so user can type immediately
|
|
93
128
|
term.focus();
|
|
94
129
|
|
|
130
|
+
// Explicit paste handler. xterm.js relies on the browser routing paste
|
|
131
|
+
// events to its hidden .xterm-helper-textarea, which only works if that
|
|
132
|
+
// textarea has focus at the moment of Ctrl+V. When the user clicks
|
|
133
|
+
// elsewhere then hits Ctrl+V over the terminal, or pastes via the
|
|
134
|
+
// right-click menu on the host div, the event lands on the host and
|
|
135
|
+
// xterm never sees it. Catch it here and route through term.paste()
|
|
136
|
+
// so xterm wraps the text in bracketed-paste markers when the app
|
|
137
|
+
// (claude code) has DECSET 2004 enabled — that's what makes claude
|
|
138
|
+
// show the "[Pasted text]" affordance instead of treating it as
|
|
139
|
+
// typed input.
|
|
140
|
+
const isOurs = () => {
|
|
141
|
+
const ae = document.activeElement;
|
|
142
|
+
return ae && host.contains(ae);
|
|
143
|
+
};
|
|
144
|
+
const doPaste = (text) => {
|
|
145
|
+
if (!text) return;
|
|
146
|
+
if (ws.readyState !== 1) return;
|
|
147
|
+
// Normalize line endings to \r (CR / Enter). This mirrors VSCode's
|
|
148
|
+
// terminal sendText path (terminalInstance.ts ~L1385):
|
|
149
|
+
// text = text.replace(/\r?\n/g, '\r');
|
|
150
|
+
// Bracketed-paste markers protect each \r from being interpreted
|
|
151
|
+
// as a submit by the host app — claude / pwsh / vim all treat
|
|
152
|
+
// bracketed contents as opaque payload regardless of what's inside.
|
|
153
|
+
// Use \n instead and you trip apps that look for "real" line breaks.
|
|
154
|
+
const normalized = text.replace(/\r?\n/g, '\r');
|
|
155
|
+
// Wrap in bracketed-paste markers. Claude Code enables DECSET 2004
|
|
156
|
+
// on startup, so the markers let it detect a paste and render
|
|
157
|
+
// "[Pasted text]". If the host app doesn't have bracketed paste on,
|
|
158
|
+
// it just sees two ignored escape sequences plus the text.
|
|
159
|
+
const wrapped = `\x1b[200~${normalized}\x1b[201~`;
|
|
160
|
+
ws.send(JSON.stringify({ type: 'input', data: wrapped }));
|
|
161
|
+
};
|
|
162
|
+
const onPaste = async (ev) => {
|
|
163
|
+
if (!isOurs()) return;
|
|
164
|
+
let text = '';
|
|
165
|
+
if (ev.clipboardData) text = ev.clipboardData.getData('text');
|
|
166
|
+
if (!text && navigator.clipboard) {
|
|
167
|
+
try { text = await navigator.clipboard.readText(); } catch {}
|
|
168
|
+
}
|
|
169
|
+
if (!text) return;
|
|
170
|
+
ev.preventDefault();
|
|
171
|
+
ev.stopPropagation();
|
|
172
|
+
doPaste(text);
|
|
173
|
+
};
|
|
174
|
+
document.addEventListener('paste', onPaste, true);
|
|
175
|
+
|
|
176
|
+
// Ctrl/Cmd+V fallback for cases the paste event is suppressed (some
|
|
177
|
+
// extensions, or when our IME workaround moved the helper textarea
|
|
178
|
+
// off-screen and the browser refuses to fire paste on it).
|
|
179
|
+
// IMPORTANT: preventDefault must happen synchronously, BEFORE the
|
|
180
|
+
// await on navigator.clipboard.readText(). If we let the event tick
|
|
181
|
+
// run first, xterm's keystroke handler converts Ctrl+V into the raw
|
|
182
|
+
// ^V (0x16) control byte and ships it before our async paste even
|
|
183
|
+
// resolves.
|
|
184
|
+
const onKey = (ev) => {
|
|
185
|
+
const meta = ev.ctrlKey || ev.metaKey;
|
|
186
|
+
if (!meta || ev.key.toLowerCase() !== 'v') return;
|
|
187
|
+
if (ev.shiftKey || ev.altKey) return;
|
|
188
|
+
if (!isOurs()) return;
|
|
189
|
+
if (!navigator.clipboard?.readText) return;
|
|
190
|
+
ev.preventDefault();
|
|
191
|
+
ev.stopPropagation();
|
|
192
|
+
ev.stopImmediatePropagation();
|
|
193
|
+
navigator.clipboard.readText().then((text) => {
|
|
194
|
+
if (text) doPaste(text);
|
|
195
|
+
}).catch(() => {});
|
|
196
|
+
};
|
|
197
|
+
document.addEventListener('keydown', onKey, true);
|
|
198
|
+
|
|
199
|
+
// Shift+Enter / Ctrl+Enter → insert literal newline, don't submit.
|
|
200
|
+
// Background: xterm.js encodes BOTH plain Enter and Shift+Enter and
|
|
201
|
+
// Ctrl+Enter as \r (0x0D / CR). The kitty keyboard / win32 input
|
|
202
|
+
// protocols (enabled in vtExtensions above) WOULD distinguish them,
|
|
203
|
+
// but they're opt-in by the running app — claude code doesn't enable
|
|
204
|
+
// either, so we never get the distinction "for free".
|
|
205
|
+
//
|
|
206
|
+
// Send the LF (0x0A) explicitly. Claude code (and most modern TUIs)
|
|
207
|
+
// treat \n inside a prompt as a literal newline insert, \r as submit.
|
|
208
|
+
// Alt+Enter already works (xterm sends \x1b\r → meta-enter) so we
|
|
209
|
+
// leave that alone.
|
|
210
|
+
const onShiftEnter = (ev) => {
|
|
211
|
+
if (ev.key !== 'Enter') return;
|
|
212
|
+
if (!(ev.shiftKey || ev.ctrlKey)) return;
|
|
213
|
+
if (ev.metaKey || ev.altKey) return;
|
|
214
|
+
if (!isOurs()) return;
|
|
215
|
+
ev.preventDefault();
|
|
216
|
+
ev.stopPropagation();
|
|
217
|
+
ev.stopImmediatePropagation();
|
|
218
|
+
if (ws.readyState === 1) {
|
|
219
|
+
ws.send(JSON.stringify({ type: 'input', data: '\n' }));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
document.addEventListener('keydown', onShiftEnter, true);
|
|
223
|
+
|
|
224
|
+
// IME fix: xterm positions .xterm-helper-textarea via `left: <col-px>`
|
|
225
|
+
// following the cursor. When the cursor is near the right edge and the
|
|
226
|
+
// user starts composing (e.g. Chinese pinyin), the textarea + native
|
|
227
|
+
// composition popup grow with the composed string and overflow the
|
|
228
|
+
// terminal host — which visually pushes the layout right. We can't cap
|
|
229
|
+
// width / change wrapping (that breaks Chromium's IME event flow), but
|
|
230
|
+
// we CAN re-anchor the textarea to the right edge while composing so
|
|
231
|
+
// it grows leftward instead. Toggling a class on the host is enough;
|
|
232
|
+
// the CSS in terminals.css does the rest.
|
|
233
|
+
const host = hostRef.current;
|
|
234
|
+
const onCompStart = () => {
|
|
235
|
+
if (host) host.classList.add('is-composing');
|
|
236
|
+
// The terminal cursor is rendered on canvas (THEME.cursor), so CSS
|
|
237
|
+
// can't hide it. Theme swap alone doesn't reliably stop the blink
|
|
238
|
+
// frame loop, so also issue the DECTCEM hide sequence which the
|
|
239
|
+
// renderer honours immediately.
|
|
240
|
+
try { term.options.theme = { ...THEME, cursor: 'transparent', cursorAccent: 'transparent' }; } catch {}
|
|
241
|
+
try { term.write('\x1b[?25l'); } catch {}
|
|
242
|
+
};
|
|
243
|
+
const onCompEnd = () => {
|
|
244
|
+
if (host) host.classList.remove('is-composing');
|
|
245
|
+
try { term.options.theme = THEME; } catch {}
|
|
246
|
+
try { term.write('\x1b[?25h'); } catch {}
|
|
247
|
+
};
|
|
248
|
+
const helper = host?.querySelector('.xterm-helper-textarea');
|
|
249
|
+
if (helper) {
|
|
250
|
+
helper.addEventListener('compositionstart', onCompStart);
|
|
251
|
+
helper.addEventListener('compositionend', onCompEnd);
|
|
252
|
+
}
|
|
253
|
+
|
|
95
254
|
return () => {
|
|
255
|
+
document.removeEventListener('paste', onPaste, true);
|
|
256
|
+
document.removeEventListener('keydown', onKey, true);
|
|
257
|
+
document.removeEventListener('keydown', onShiftEnter, true);
|
|
258
|
+
if (helper) {
|
|
259
|
+
helper.removeEventListener('compositionstart', onCompStart);
|
|
260
|
+
helper.removeEventListener('compositionend', onCompEnd);
|
|
261
|
+
}
|
|
96
262
|
ro.disconnect();
|
|
97
263
|
try { ws.close(); } catch {}
|
|
98
264
|
try { term.dispose(); } catch {}
|
|
@@ -4,7 +4,7 @@ import { setToast } from '../toast.js';
|
|
|
4
4
|
import { Card } from '../components/Card.js';
|
|
5
5
|
import { BrandMark, IconGithub, IconExternal } from '../icons.js';
|
|
6
6
|
|
|
7
|
-
const REPO_URL = 'https://github.com/bakapiano/
|
|
7
|
+
const REPO_URL = 'https://github.com/bakapiano/ccsm';
|
|
8
8
|
const NPM_URL = 'https://www.npmjs.com/package/@bakapiano/ccsm';
|
|
9
9
|
|
|
10
10
|
async function onInstall() {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { html } from '../html.js';
|
|
7
7
|
import { useEffect, useState } from 'preact/hooks';
|
|
8
|
-
import { config, terminals, configDirty } from '../state.js';
|
|
8
|
+
import { config, terminals, configDirty, accentColor, setAccentColor, ACCENT_DEFAULT } from '../state.js';
|
|
9
9
|
import { api, loadWorkspaces } from '../api.js';
|
|
10
10
|
import { setToast } from '../toast.js';
|
|
11
11
|
import { ccsmConfirm } from '../dialog.js';
|
|
@@ -22,6 +22,7 @@ function defaultsFrom(cfg) {
|
|
|
22
22
|
claudeCommand: cfg.claudeCommand || 'claude',
|
|
23
23
|
terminal: cfg.terminal,
|
|
24
24
|
commandShell: cfg.commandShell || 'pwsh',
|
|
25
|
+
defaultTerminalMode: cfg.defaultTerminalMode || 'wt',
|
|
25
26
|
autoFocusOnLaunch: cfg.autoFocusOnLaunch !== false,
|
|
26
27
|
focusMovesToCenter: cfg.focusMovesToCenter === true,
|
|
27
28
|
browserMode: cfg.browserMode || (cfg.autoOpenBrowser === false ? 'none' : 'app'),
|
|
@@ -62,6 +63,7 @@ export function ConfigurePage() {
|
|
|
62
63
|
claudeCommand: (draft.claudeCommand || 'claude').trim(),
|
|
63
64
|
terminal: draft.terminal || 'wt',
|
|
64
65
|
commandShell: draft.commandShell || 'pwsh',
|
|
66
|
+
defaultTerminalMode: draft.defaultTerminalMode === 'web' ? 'web' : 'wt',
|
|
65
67
|
browserMode: draft.browserMode || 'app',
|
|
66
68
|
workDir: (draft.workDir || '').trim(),
|
|
67
69
|
repos: (cfg.repos || []).filter((r) => r.name && r.url),
|
|
@@ -127,6 +129,15 @@ export function ConfigurePage() {
|
|
|
127
129
|
onInput=${(e) => update({ claudeCommand: e.target.value })} />
|
|
128
130
|
<span class="hint">alias / function / exe name</span>
|
|
129
131
|
</label>
|
|
132
|
+
<label class="field">
|
|
133
|
+
<span class="label">Default mode <span class="hint inline">(new · resume · continue · finder)</span></span>
|
|
134
|
+
<select class="input" value=${draft.defaultTerminalMode}
|
|
135
|
+
onChange=${(e) => update({ defaultTerminalMode: e.target.value })}>
|
|
136
|
+
<option value="wt">system terminal · open a real ${draft.terminal || 'wt'} window</option>
|
|
137
|
+
<option value="web">web · in-page xterm under the Terminals tab</option>
|
|
138
|
+
</select>
|
|
139
|
+
<span class="hint">web requires node-pty; per-launch radios can override</span>
|
|
140
|
+
</label>
|
|
130
141
|
<label class="field">
|
|
131
142
|
<span class="label">Terminal</span>
|
|
132
143
|
<select class="input" value=${draft.terminal}
|
|
@@ -176,6 +187,12 @@ export function ConfigurePage() {
|
|
|
176
187
|
<span class="hint">passed as initial prompt to the finder session</span>
|
|
177
188
|
</label>
|
|
178
189
|
|
|
190
|
+
<div class="field">
|
|
191
|
+
<span class="label">Theme accent</span>
|
|
192
|
+
<${AccentPicker} />
|
|
193
|
+
<span class="hint">also tints the OS title bar (theme-color)</span>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
179
196
|
<div class="field full">
|
|
180
197
|
<div class="repos-head">
|
|
181
198
|
<span class="label">Repositories</span>
|
|
@@ -192,3 +209,57 @@ export function ConfigurePage() {
|
|
|
192
209
|
</div>
|
|
193
210
|
</${Card}>`;
|
|
194
211
|
}
|
|
212
|
+
|
|
213
|
+
// Curated preset palette + free hex input. Each preset is a hand-picked
|
|
214
|
+
// brand color that reads well on the cream surface. Selecting a swatch
|
|
215
|
+
// applies immediately via setAccentColor (which writes CSS vars +
|
|
216
|
+
// localStorage) — no save button needed since this is a per-browser
|
|
217
|
+
// UI preference, not part of the server-side config.
|
|
218
|
+
const PRESETS = [
|
|
219
|
+
{ name: 'Claude copper', hex: '#b3614a' }, // default
|
|
220
|
+
{ name: 'Anthropic ink', hex: '#1a1815' },
|
|
221
|
+
{ name: 'Ocean', hex: '#2f6fa3' },
|
|
222
|
+
{ name: 'Forest', hex: '#3f7a4a' },
|
|
223
|
+
{ name: 'Amber', hex: '#c4892b' },
|
|
224
|
+
{ name: 'Berry', hex: '#a44b78' },
|
|
225
|
+
{ name: 'Slate', hex: '#4a5563' },
|
|
226
|
+
{ name: 'Crimson', hex: '#b73f3f' },
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
function AccentPicker() {
|
|
230
|
+
const current = accentColor.value;
|
|
231
|
+
const [text, setText] = useState(current);
|
|
232
|
+
// Keep the text input in sync if the signal changes from elsewhere
|
|
233
|
+
// (preset click, reset). useState would otherwise drift on subsequent
|
|
234
|
+
// applies. eslint-disable-next-line — intentionally re-syncing on prop change.
|
|
235
|
+
useEffect(() => { setText(current); }, [current]);
|
|
236
|
+
|
|
237
|
+
const onText = (e) => {
|
|
238
|
+
const v = e.target.value.trim();
|
|
239
|
+
setText(v);
|
|
240
|
+
// Apply live only when it's a valid hex; otherwise let the user
|
|
241
|
+
// keep typing without flicker.
|
|
242
|
+
if (/^#[0-9a-fA-F]{6}$/.test(v)) setAccentColor(v);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
return html`
|
|
246
|
+
<div class="accent-picker">
|
|
247
|
+
<div class="accent-swatches">
|
|
248
|
+
${PRESETS.map((p) => html`
|
|
249
|
+
<button key=${p.hex} class=${`accent-swatch${current.toLowerCase() === p.hex.toLowerCase() ? ' is-active' : ''}`}
|
|
250
|
+
style=${`background:${p.hex}`}
|
|
251
|
+
title=${`${p.name} · ${p.hex}`}
|
|
252
|
+
aria-label=${p.name}
|
|
253
|
+
onClick=${() => setAccentColor(p.hex)}></button>`)}
|
|
254
|
+
</div>
|
|
255
|
+
<div class="accent-custom">
|
|
256
|
+
<input type="color" value=${current}
|
|
257
|
+
onInput=${(e) => setAccentColor(e.target.value)} />
|
|
258
|
+
<input type="text" class="accent-hex" value=${text}
|
|
259
|
+
spellcheck="false" maxlength="7"
|
|
260
|
+
onInput=${onText} placeholder="#rrggbb" />
|
|
261
|
+
<button class="action subtle small"
|
|
262
|
+
onClick=${() => setAccentColor(ACCENT_DEFAULT)}>Reset</button>
|
|
263
|
+
</div>
|
|
264
|
+
</div>`;
|
|
265
|
+
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { html } from '../html.js';
|
|
2
2
|
import { useState } from 'preact/hooks';
|
|
3
3
|
import { signal } from '@preact/signals';
|
|
4
|
-
import { capabilities, activeTerminalId, selectTab } from '../state.js';
|
|
5
|
-
import { loadWorkspaces, loadWebTerminals } from '../api.js';
|
|
4
|
+
import { capabilities, activeTerminalId, selectTab, config } from '../state.js';
|
|
5
|
+
import { api, loadWorkspaces, loadWebTerminals } from '../api.js';
|
|
6
6
|
import { setToast } from '../toast.js';
|
|
7
7
|
import { streamNewSession, resetProgress } from '../streaming.js';
|
|
8
8
|
import { Card } from '../components/Card.js';
|
|
9
9
|
import { RepoPicker } from '../components/RepoPicker.js';
|
|
10
|
+
import { ReposEditor, addEmptyRepo } from '../components/ReposEditor.js';
|
|
10
11
|
import { WorkspacePicker } from '../components/WorkspacePicker.js';
|
|
11
12
|
import { ProgressList } from '../components/ProgressList.js';
|
|
12
13
|
import { WorkspacesGrid, WorkspacesHeader } from '../components/WorkspacesGrid.js';
|
|
@@ -17,16 +18,30 @@ const inlineSelected = signal(new Set());
|
|
|
17
18
|
|
|
18
19
|
function NewSessionCard() {
|
|
19
20
|
const [workspace, setWorkspace] = useState('');
|
|
20
|
-
// 'web' = run inside this page (in-process PTY · bridges to xterm.js)
|
|
21
|
-
// 'wt' = open a new Windows Terminal window
|
|
22
|
-
const initialMode = capabilities.value.webTerminal ? 'web' : 'wt';
|
|
23
|
-
const [terminal, setTerminal] = useState(initialMode);
|
|
24
21
|
const [result, setResult] = useState('');
|
|
25
22
|
const [busy, setBusy] = useState(false);
|
|
23
|
+
const [reposSavedAt, setReposSavedAt] = useState('');
|
|
24
|
+
const repos = config.value?.repos || [];
|
|
25
|
+
const hasRepos = repos.length > 0;
|
|
26
|
+
// Always follow the global Configure → "Default mode" setting. The
|
|
27
|
+
// per-launch picker was removed; users who want a one-off override
|
|
28
|
+
// change the global setting first. This keeps "new" / "resume" /
|
|
29
|
+
// "continue" / "finder" all consistent.
|
|
30
|
+
const cfgDefault = config.value?.defaultTerminalMode || 'wt';
|
|
31
|
+
const terminal = capabilities.value.webTerminal ? cfgDefault : 'wt';
|
|
32
|
+
|
|
33
|
+
const onSaveRepos = async () => {
|
|
34
|
+
try {
|
|
35
|
+
const cfg = await api('PUT', '/api/config', config.value);
|
|
36
|
+
config.value = cfg;
|
|
37
|
+
setReposSavedAt(`saved · ${new Date().toLocaleTimeString(undefined, { hour12: false })}`);
|
|
38
|
+
setToast('repos saved');
|
|
39
|
+
} catch (e) { setToast(e.message, 'error'); }
|
|
40
|
+
};
|
|
26
41
|
|
|
27
42
|
const onLaunch = async () => {
|
|
28
43
|
const repos = [...inlineSelected.value];
|
|
29
|
-
|
|
44
|
+
// Allow zero-repo launches: workspace is created empty, claude opens there.
|
|
30
45
|
setBusy(true);
|
|
31
46
|
setResult('');
|
|
32
47
|
resetProgress(repos, ROOT_ID);
|
|
@@ -77,24 +92,21 @@ function NewSessionCard() {
|
|
|
77
92
|
meta=${html`Picks an unused workspace, clones missing repos, opens <code>claude</code> in a fresh terminal.`}>
|
|
78
93
|
<div class="form-row">
|
|
79
94
|
<span class="form-label">Repos</span>
|
|
80
|
-
|
|
95
|
+
${hasRepos
|
|
96
|
+
? html`<${RepoPicker} selectedSig=${inlineSelected} />`
|
|
97
|
+
: html`<span class="muted-text">no repos configured · add one below, or launch with no repos for an empty workspace</span>`}
|
|
81
98
|
</div>
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
</label>
|
|
91
|
-
<label class=${`radio${terminal === 'wt' ? ' is-checked' : ''}`}>
|
|
92
|
-
<input type="radio" name="terminal" value="wt"
|
|
93
|
-
checked=${terminal === 'wt'} onChange=${() => setTerminal('wt')} />
|
|
94
|
-
wt window
|
|
95
|
-
</label>
|
|
99
|
+
<details class="repos-inline-config" open=${!hasRepos}>
|
|
100
|
+
<summary>Manage repos</summary>
|
|
101
|
+
<div class="repos-inline-body">
|
|
102
|
+
<${ReposEditor} />
|
|
103
|
+
<div class="repos-inline-actions">
|
|
104
|
+
<button class="action small" onClick=${() => addEmptyRepo()}>+ Add repo</button>
|
|
105
|
+
<button class="action small primary" onClick=${onSaveRepos}>Save changes</button>
|
|
106
|
+
<span class="muted-text">${reposSavedAt}</span>
|
|
96
107
|
</div>
|
|
97
|
-
</div
|
|
108
|
+
</div>
|
|
109
|
+
</details>
|
|
98
110
|
<div class="form-row">
|
|
99
111
|
<label class="form-label">Workspace</label>
|
|
100
112
|
<${WorkspacePicker} value=${workspace} onChange=${setWorkspace} />
|
package/public/js/state.js
CHANGED
|
@@ -22,6 +22,8 @@ export const serverHealth = signal({ state: 'connecting' });
|
|
|
22
22
|
// ── ui state (persisted in localStorage where noted) ───────────
|
|
23
23
|
export const activeTab = signal('sessions');
|
|
24
24
|
export const sidebarCollapsed = signal(false);
|
|
25
|
+
export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
|
|
26
|
+
export const accentColor = signal('#b3614a'); // user-chosen brand accent, persisted
|
|
25
27
|
// fold state for the three cards on the Sessions tab
|
|
26
28
|
export const cardFolded = signal({ favorites: false, sessions: false, recent: false });
|
|
27
29
|
export const configDirty = signal(false);
|
|
@@ -54,10 +56,29 @@ export const TAB_HEADINGS = {
|
|
|
54
56
|
|
|
55
57
|
// ── persistence helpers (localStorage) ──────────────────────────
|
|
56
58
|
const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
|
|
59
|
+
const LS_SIDEBAR_W = 'ccsm.sidebar-width';
|
|
60
|
+
const LS_ACCENT = 'ccsm.accent';
|
|
57
61
|
const LS_FOLD = (k) => `ccsm.fold.${k}`;
|
|
58
62
|
|
|
63
|
+
// Resizable sidebar width (when not collapsed). Clamp range matches the
|
|
64
|
+
// CSS min/max — too narrow truncates labels, too wide eats main content.
|
|
65
|
+
export const SIDEBAR_MIN = 180;
|
|
66
|
+
export const SIDEBAR_MAX = 400;
|
|
67
|
+
export const SIDEBAR_DEFAULT = 232;
|
|
68
|
+
export const ACCENT_DEFAULT = '#b3614a';
|
|
69
|
+
|
|
59
70
|
export function loadPersisted() {
|
|
60
71
|
sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
|
|
72
|
+
const w = Number(localStorage.getItem(LS_SIDEBAR_W));
|
|
73
|
+
if (Number.isFinite(w) && w >= SIDEBAR_MIN && w <= SIDEBAR_MAX) {
|
|
74
|
+
sidebarWidth.value = w;
|
|
75
|
+
}
|
|
76
|
+
applySidebarWidthCssVar();
|
|
77
|
+
const a = localStorage.getItem(LS_ACCENT);
|
|
78
|
+
if (isHexColor(a)) {
|
|
79
|
+
accentColor.value = a;
|
|
80
|
+
}
|
|
81
|
+
applyAccentCssVars();
|
|
61
82
|
const folds = { ...cardFolded.value };
|
|
62
83
|
for (const k of Object.keys(folds)) {
|
|
63
84
|
folds[k] = localStorage.getItem(LS_FOLD(k)) === '1';
|
|
@@ -68,6 +89,100 @@ export function loadPersisted() {
|
|
|
68
89
|
if (TAB_HEADINGS[hash]) activeTab.value = hash;
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
// Push the current sidebar width into the CSS custom property so the grid
|
|
93
|
+
// in layout.css picks it up. Called on load and whenever the user drags
|
|
94
|
+
// the handle.
|
|
95
|
+
function applySidebarWidthCssVar() {
|
|
96
|
+
document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function setSidebarWidth(px) {
|
|
100
|
+
const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(px)));
|
|
101
|
+
sidebarWidth.value = clamped;
|
|
102
|
+
applySidebarWidthCssVar();
|
|
103
|
+
localStorage.setItem(LS_SIDEBAR_W, String(clamped));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── theme accent ────────────────────────────────────────────────
|
|
107
|
+
// We expose 4 derived CSS vars: --accent, --accent-deep, --accent-soft,
|
|
108
|
+
// --accent-softer. The user only picks the base; deep/soft are computed
|
|
109
|
+
// (darken / rgba alpha) so things stay self-consistent.
|
|
110
|
+
function isHexColor(s) {
|
|
111
|
+
return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
|
|
112
|
+
}
|
|
113
|
+
function hexToRgb(hex) {
|
|
114
|
+
const n = parseInt(hex.slice(1), 16);
|
|
115
|
+
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
116
|
+
}
|
|
117
|
+
function rgbToHex({ r, g, b }) {
|
|
118
|
+
const h = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0');
|
|
119
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
120
|
+
}
|
|
121
|
+
function darken({ r, g, b }, amount) {
|
|
122
|
+
// amount 0..1; pull each channel toward 0
|
|
123
|
+
return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
|
|
124
|
+
}
|
|
125
|
+
function lighten({ r, g, b }, amount) {
|
|
126
|
+
// amount 0..1; pull each channel toward 255
|
|
127
|
+
return { r: r + (255 - r) * amount, g: g + (255 - g) * amount, b: b + (255 - b) * amount };
|
|
128
|
+
}
|
|
129
|
+
// Mix the accent into white at a tiny ratio to get a faint warm/cool tint
|
|
130
|
+
// for surfaces. `t` controls strength (0 = pure white, 1 = pure accent).
|
|
131
|
+
// Surfaces use very low t (0.02–0.08) so the page reads as "white with
|
|
132
|
+
// a hint of the brand color" rather than colored.
|
|
133
|
+
function mixWithWhite({ r, g, b }, t) {
|
|
134
|
+
return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
|
|
135
|
+
}
|
|
136
|
+
function applyAccentCssVars() {
|
|
137
|
+
const base = accentColor.value;
|
|
138
|
+
const rgb = hexToRgb(base);
|
|
139
|
+
const deep = rgbToHex(darken(rgb, 0.2));
|
|
140
|
+
const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
|
|
141
|
+
const softer = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.04)`;
|
|
142
|
+
// Surface tints derived from the accent. Each surface keeps its
|
|
143
|
+
// relative brightness from the original palette (cream → white → cream
|
|
144
|
+
// hover → cream active) but its hue follows the chosen accent. Mixed
|
|
145
|
+
// weights chosen to roughly match the warm copper defaults:
|
|
146
|
+
// --bg was #faf9f5 → t≈0.04
|
|
147
|
+
// --bg-elev was #ffffff → t=0 (kept pure white)
|
|
148
|
+
// --sidebar-hover was #f0ece0 → t≈0.10
|
|
149
|
+
// --sidebar-active was #e8e3d5 → t≈0.15
|
|
150
|
+
// --border was #e8e3d5 → t≈0.15
|
|
151
|
+
// --border-soft was #ece8da → t≈0.12
|
|
152
|
+
// --border-strong was #d4cdb8 → t≈0.25
|
|
153
|
+
const bg = rgbToHex(mixWithWhite(rgb, 0.04));
|
|
154
|
+
const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
|
|
155
|
+
const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
|
|
156
|
+
const border = rgbToHex(mixWithWhite(rgb, 0.15));
|
|
157
|
+
const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
|
|
158
|
+
const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
|
|
159
|
+
const root = document.documentElement.style;
|
|
160
|
+
root.setProperty('--accent', base);
|
|
161
|
+
root.setProperty('--accent-deep', deep);
|
|
162
|
+
root.setProperty('--accent-soft', soft);
|
|
163
|
+
root.setProperty('--accent-softer', softer);
|
|
164
|
+
root.setProperty('--bg', bg);
|
|
165
|
+
root.setProperty('--sidebar-bg', bg);
|
|
166
|
+
root.setProperty('--sidebar-hover', sidebarHover);
|
|
167
|
+
root.setProperty('--sidebar-active', sidebarActive);
|
|
168
|
+
root.setProperty('--border', border);
|
|
169
|
+
root.setProperty('--border-soft', borderSoft);
|
|
170
|
+
root.setProperty('--border-strong', borderStrong);
|
|
171
|
+
// --bg-elev stays pure white so cards "lift" off the tinted surface.
|
|
172
|
+
// Sync the meta theme-color to the tinted surface so the OS title bar
|
|
173
|
+
// matches what the user sees (was previously the raw accent — too
|
|
174
|
+
// saturated).
|
|
175
|
+
const meta = document.querySelector('meta[name="theme-color"]');
|
|
176
|
+
if (meta) meta.setAttribute('content', bg);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function setAccentColor(hex) {
|
|
180
|
+
if (!isHexColor(hex)) return;
|
|
181
|
+
accentColor.value = hex;
|
|
182
|
+
applyAccentCssVars();
|
|
183
|
+
localStorage.setItem(LS_ACCENT, hex);
|
|
184
|
+
}
|
|
185
|
+
|
|
71
186
|
// ── actions ─────────────────────────────────────────────────────
|
|
72
187
|
export function selectTab(name) {
|
|
73
188
|
if (!TAB_HEADINGS[name]) name = 'sessions';
|
package/scripts/install.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
// ccsm postinstall · Windows-only · runs after `npm install -g @bakapiano/ccsm`.
|
|
5
5
|
// Registers the `ccsm://` URL protocol in HKCU so the hosted frontend
|
|
6
|
-
// (https://bakapiano.github.io/
|
|
6
|
+
// (https://bakapiano.github.io/ccsm/v1/) can fire `<a href="ccsm://start">`
|
|
7
7
|
// from its OfflineBanner and have Windows spawn the backend on demand.
|
|
8
8
|
//
|
|
9
9
|
// Best-effort: any failure MUST NOT break npm install. Each step is in
|
package/server.js
CHANGED
|
@@ -101,7 +101,7 @@ app.use((req, res, next) => {
|
|
|
101
101
|
// so a contributor can iterate without pushing to GH Pages; (b) hot-reload
|
|
102
102
|
// SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
|
|
103
103
|
// both explicitly. In production (npm-installed), backend is API-only —
|
|
104
|
-
// frontend lives at https://bakapiano.github.io/
|
|
104
|
+
// frontend lives at https://bakapiano.github.io/ccsm/v1/.
|
|
105
105
|
const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
|
|
106
106
|
|
|
107
107
|
if (IS_DEV) {
|
|
@@ -313,13 +313,17 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
313
313
|
|
|
314
314
|
try {
|
|
315
315
|
const cfg = await loadConfig();
|
|
316
|
-
const
|
|
316
|
+
const explicitRepos = Array.isArray(req.body && req.body.repos);
|
|
317
|
+
const wantedNames = explicitRepos
|
|
317
318
|
? req.body.repos
|
|
318
319
|
: cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
|
|
319
320
|
|
|
320
321
|
const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
|
|
321
|
-
|
|
322
|
-
|
|
322
|
+
// Allow launching with zero repos — caller explicitly passed [] (or no
|
|
323
|
+
// defaults exist). The workspace is still created; claude just opens
|
|
324
|
+
// in an empty directory.
|
|
325
|
+
if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
|
|
326
|
+
return fail('No matching repos found');
|
|
323
327
|
}
|
|
324
328
|
|
|
325
329
|
let workspace;
|
|
@@ -433,8 +437,26 @@ app.post('/api/sessions/new', async (req, res) => {
|
|
|
433
437
|
});
|
|
434
438
|
|
|
435
439
|
// ---- launch finder session (a claude session in the ccsm data dir pre-pointed at session data) ----
|
|
436
|
-
app.post('/api/sessions/finder', asyncH(async (
|
|
440
|
+
app.post('/api/sessions/finder', asyncH(async (req, res) => {
|
|
437
441
|
const cfg = await loadConfig();
|
|
442
|
+
const mode = (req.body && req.body.terminal)
|
|
443
|
+
|| cfg.defaultTerminalMode
|
|
444
|
+
|| 'wt';
|
|
445
|
+
if (mode === 'web') {
|
|
446
|
+
if (!webTerminal.available) {
|
|
447
|
+
return res.status(400).json({ error: 'node-pty unavailable · cannot launch finder in web terminal' });
|
|
448
|
+
}
|
|
449
|
+
const cmd = cfg.claudeCommand || 'claude';
|
|
450
|
+
const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
|
|
451
|
+
const promptArg = cfg.finderPrompt ? ` '${cfg.finderPrompt.replace(/'/g, "''")}'` : '';
|
|
452
|
+
const entry = webTerminal.spawn({
|
|
453
|
+
command: wrap,
|
|
454
|
+
args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${DATA_DIR.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}'${promptArg}`],
|
|
455
|
+
cwd: DATA_DIR,
|
|
456
|
+
meta: { title: 'ccsm finder', cwd: DATA_DIR },
|
|
457
|
+
});
|
|
458
|
+
return res.json({ launched: { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' }, cwd: DATA_DIR, prompt: cfg.finderPrompt });
|
|
459
|
+
}
|
|
438
460
|
const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
|
|
439
461
|
const launched = launchNewClaude({
|
|
440
462
|
cwd: DATA_DIR,
|
|
@@ -449,7 +471,7 @@ app.post('/api/sessions/finder', asyncH(async (_req, res) => {
|
|
|
449
471
|
beforeHwnds,
|
|
450
472
|
autoFocus: cfg.autoFocusOnLaunch !== false,
|
|
451
473
|
});
|
|
452
|
-
res.json({ launched, cwd: DATA_DIR, prompt: cfg.finderPrompt });
|
|
474
|
+
res.json({ launched: { mode: 'wt', ...launched }, cwd: DATA_DIR, prompt: cfg.finderPrompt });
|
|
453
475
|
}));
|
|
454
476
|
|
|
455
477
|
// ---- resume single session ----
|
|
@@ -458,6 +480,23 @@ app.post('/api/sessions/:sessionId/resume', asyncH(async (req, res) => {
|
|
|
458
480
|
const cwd = req.body && req.body.cwd;
|
|
459
481
|
if (!cwd) return res.status(400).json({ error: 'cwd required in body' });
|
|
460
482
|
const cfg = await loadConfig();
|
|
483
|
+
const mode = (req.body && req.body.terminal)
|
|
484
|
+
|| cfg.defaultTerminalMode
|
|
485
|
+
|| 'wt';
|
|
486
|
+
if (mode === 'web') {
|
|
487
|
+
if (!webTerminal.available) {
|
|
488
|
+
return res.status(400).json({ error: 'node-pty unavailable · cannot resume in web terminal' });
|
|
489
|
+
}
|
|
490
|
+
const cmd = cfg.claudeCommand || 'claude';
|
|
491
|
+
const wrap = (cfg.commandShell || 'pwsh') === 'powershell' ? 'powershell.exe' : 'pwsh.exe';
|
|
492
|
+
const entry = webTerminal.spawn({
|
|
493
|
+
command: wrap,
|
|
494
|
+
args: ['-NoExit', '-NoLogo', '-Command', `Set-Location -LiteralPath '${cwd.replace(/'/g, "''")}'; & '${cmd.replace(/'/g, "''")}' --resume '${sessionId.replace(/'/g, "''")}'`],
|
|
495
|
+
cwd,
|
|
496
|
+
meta: { title: `resume ${sessionId.slice(0, 8)}`, cwd, sessionId },
|
|
497
|
+
});
|
|
498
|
+
return res.json({ launched: { mode: 'web', id: entry.id, pid: entry.meta.pid, terminal: 'web' } });
|
|
499
|
+
}
|
|
461
500
|
const beforeHwnds = await snapshotWindowsOf(processNameFor(cfg.terminal) || 'WindowsTerminal.exe');
|
|
462
501
|
const launched = launchResume({
|
|
463
502
|
cwd,
|
|
@@ -471,7 +510,7 @@ app.post('/api/sessions/:sessionId/resume', asyncH(async (req, res) => {
|
|
|
471
510
|
beforeHwnds,
|
|
472
511
|
autoFocus: cfg.autoFocusOnLaunch !== false,
|
|
473
512
|
});
|
|
474
|
-
res.json({ launched });
|
|
513
|
+
res.json({ launched: { mode: 'wt', ...launched } });
|
|
475
514
|
}));
|
|
476
515
|
|
|
477
516
|
// ---- focus the wt window that's already hosting this session ----
|
|
@@ -645,7 +684,10 @@ function openInBrowser(url, mode) {
|
|
|
645
684
|
|
|
646
685
|
(async () => {
|
|
647
686
|
const cfg = await loadConfig();
|
|
648
|
-
|
|
687
|
+
// CCSM_PORT env var wins over config — handy for running a dev instance
|
|
688
|
+
// on a non-default port (e.g. 7778) while a prod ccsm keeps 7777.
|
|
689
|
+
const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
|
|
690
|
+
const { server, port } = await listenWithFallback(preferredPort);
|
|
649
691
|
currentPort = port;
|
|
650
692
|
|
|
651
693
|
// WebSocket upgrade for /ws/terminal/:id → bridges xterm.js to a PTY
|
|
@@ -688,7 +730,7 @@ function openInBrowser(url, mode) {
|
|
|
688
730
|
// prod → hosted frontend on GH Pages (backend is API-only)
|
|
689
731
|
const FRONTEND_URL = IS_DEV
|
|
690
732
|
? apiUrl
|
|
691
|
-
: 'https://bakapiano.github.io/
|
|
733
|
+
: 'https://bakapiano.github.io/ccsm/v1/';
|
|
692
734
|
frontendUrl = FRONTEND_URL;
|
|
693
735
|
console.log(`ccsm listening on ${apiUrl}${port !== cfg.port ? ` (requested ${cfg.port}, was taken)` : ''}`);
|
|
694
736
|
console.log(`frontend at ${FRONTEND_URL}`);
|