@bakapiano/ccsm 0.9.0 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +222 -195
- package/README.md +77 -79
- package/lib/cliSessionWatcher.js +249 -0
- package/lib/config.js +101 -24
- package/lib/folders.js +96 -0
- package/lib/localCliSessions.js +177 -0
- package/lib/persistedSessions.js +134 -0
- package/lib/webTerminal.js +31 -18
- package/lib/workspace.js +26 -4
- package/package.json +1 -1
- package/public/assets/claude-color.svg +1 -0
- package/public/assets/codex-color.svg +1 -0
- package/public/assets/copilot-color.svg +1 -0
- package/public/css/base.css +22 -5
- package/public/css/cards.css +37 -3
- package/public/css/feedback.css +127 -43
- package/public/css/forms.css +97 -25
- package/public/css/layout.css +74 -26
- package/public/css/modal.css +40 -26
- package/public/css/responsive.css +2 -2
- package/public/css/sidebar.css +424 -25
- package/public/css/terminals.css +138 -0
- package/public/css/tokens.css +28 -12
- package/public/css/wco.css +38 -39
- package/public/css/widgets.css +1177 -6
- package/public/index.html +35 -2
- package/public/js/api.js +194 -37
- package/public/js/components/AdoptModal.js +171 -0
- package/public/js/components/App.js +1 -11
- package/public/js/components/DirectoryPicker.js +203 -0
- package/public/js/components/EntityFormModal.js +105 -0
- package/public/js/components/Modal.js +51 -0
- package/public/js/components/OfflineBanner.js +29 -23
- package/public/js/components/PageTitleBar.js +13 -0
- package/public/js/components/Picker.js +179 -0
- package/public/js/components/Popover.js +55 -0
- package/public/js/components/Sidebar.js +219 -32
- package/public/js/components/TerminalView.js +27 -3
- package/public/js/components/useDragSort.js +67 -0
- package/public/js/dialog.js +10 -2
- package/public/js/icons.js +66 -3
- package/public/js/main.js +54 -3
- package/public/js/pages/AboutPage.js +80 -0
- package/public/js/pages/ConfigurePage.js +429 -207
- package/public/js/pages/LaunchPage.js +326 -86
- package/public/js/pages/SessionsPage.js +91 -41
- package/public/js/state.js +102 -73
- package/public/manifest.webmanifest +2 -2
- package/scripts/install.js +7 -2
- package/server.js +755 -441
- package/lib/favorites.js +0 -51
- package/lib/focus.js +0 -369
- package/lib/labels.js +0 -29
- package/lib/launcher.js +0 -219
- package/lib/sessions.js +0 -272
- package/lib/snapshot.js +0 -141
- package/public/js/actions.js +0 -107
- package/public/js/components/Fab.js +0 -11
- package/public/js/components/FavoritesTable.js +0 -81
- package/public/js/components/Footer.js +0 -12
- package/public/js/components/NewSessionModal.js +0 -153
- package/public/js/components/PageHead.js +0 -33
- package/public/js/components/Pagination.js +0 -27
- package/public/js/components/RecentTable.js +0 -68
- package/public/js/components/SessionsTable.js +0 -71
- package/public/js/components/SnapshotPanel.js +0 -77
- package/public/js/components/TitleCell.js +0 -40
- package/public/js/components/WorkspacesGrid.js +0 -41
- package/public/js/pages/TerminalsPage.js +0 -74
package/public/js/state.js
CHANGED
|
@@ -5,67 +5,86 @@ import { signal, computed } from '@preact/signals';
|
|
|
5
5
|
|
|
6
6
|
// ── server-driven data ──────────────────────────────────────────
|
|
7
7
|
export const config = signal(null);
|
|
8
|
-
export const terminals = signal([]);
|
|
9
8
|
export const capabilities = signal({ webTerminal: false });
|
|
9
|
+
// `sessions` is the ccsm-persisted list (lib/persistedSessions). Every
|
|
10
|
+
// entry has { id, cliId, cwd, workspace, title, folderId, repos,
|
|
11
|
+
// createdAt, lastActiveAt, status, exitedAt, exitCode, pid }.
|
|
10
12
|
export const sessions = signal([]);
|
|
11
|
-
export const
|
|
12
|
-
export const activeTerminalId = signal(null); // which one's open in the right pane
|
|
13
|
-
export const recent = signal([]);
|
|
14
|
-
export const recentTotal = signal(0);
|
|
15
|
-
export const favorites = signal({}); // { sessionId: {sessionId, cwd, title, gitBranch, addedAt} }
|
|
16
|
-
export const labels = signal({}); // { sessionId: customLabel }
|
|
13
|
+
export const folders = signal([]); // [{id,name,order,createdAt}]
|
|
17
14
|
export const workspaces = signal([]);
|
|
18
|
-
export const snapshot = signal(null);
|
|
19
|
-
export const history = signal([]);
|
|
20
15
|
export const serverHealth = signal({ state: 'connecting' });
|
|
21
16
|
|
|
22
17
|
// ── ui state (persisted in localStorage where noted) ───────────
|
|
23
18
|
export const activeTab = signal('sessions');
|
|
19
|
+
export const activeSessionId = signal(null); // the session currently rendered in the right pane
|
|
24
20
|
export const sidebarCollapsed = signal(false);
|
|
21
|
+
// True when viewport is narrow enough that the sidebar is force-collapsed
|
|
22
|
+
// by the responsive layout — the toggle button hides in that case so the
|
|
23
|
+
// user can't try (and fail) to expand it.
|
|
24
|
+
export const sidebarForcedCollapsed = signal(false);
|
|
25
25
|
export const sidebarWidth = signal(232); // px when expanded, persisted in localStorage
|
|
26
|
-
export const accentColor = signal('#
|
|
27
|
-
//
|
|
28
|
-
|
|
26
|
+
export const accentColor = signal('#2f6fa3'); // user-chosen brand accent, persisted
|
|
27
|
+
// Per-folder collapse state in the sidebar tree. Stored as a plain object
|
|
28
|
+
// {folderId: true} (true = collapsed). Key 'unsorted' covers the implicit
|
|
29
|
+
// Unsorted bucket.
|
|
30
|
+
export const foldersCollapsed = signal({});
|
|
29
31
|
export const configDirty = signal(false);
|
|
30
|
-
|
|
32
|
+
// Per-card fold state on pages that use the <Card> component. The card
|
|
33
|
+
// just toggles a key here; persistence is best-effort via localStorage
|
|
34
|
+
// under `ccsm.fold.<key>` (set by toggleCardFold).
|
|
35
|
+
export const cardFolded = signal({});
|
|
31
36
|
export const clockTick = signal(Date.now()); // re-ticked each second so fmtAgo refreshes
|
|
32
37
|
export const lastRefreshAt = signal(0); // ms timestamp of last successful refreshAll()
|
|
33
38
|
export const installPrompt = signal(null); // captured beforeinstallprompt event (PWA install)
|
|
34
|
-
export const isInstalledPwa = signal(false); // running inside an installed PWA window
|
|
35
|
-
|
|
36
|
-
// ── pagination ──────────────────────────────────────────────────
|
|
37
|
-
export const sessionsOffset = signal(0);
|
|
38
|
-
export const sessionsLimit = signal(10);
|
|
39
|
-
export const favoritesOffset = signal(0);
|
|
40
|
-
export const favoritesLimit = signal(10);
|
|
41
|
-
export const recentOffset = signal(0);
|
|
42
|
-
export const recentLimit = signal(10);
|
|
39
|
+
export const isInstalledPwa = signal(false); // running inside an installed PWA window
|
|
43
40
|
|
|
44
41
|
// ── derived ─────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
// Group sessions by folder, with a synthetic "unsorted" bucket for those
|
|
43
|
+
// without a folderId. Folders define the rendering order; sessions
|
|
44
|
+
// inside each are sorted by createdAt desc (stable — using lastActiveAt
|
|
45
|
+
// would make rows jump on resume).
|
|
46
|
+
//
|
|
47
|
+
// We pre-create a bucket per declared session.folderId even if the
|
|
48
|
+
// matching folder hasn't loaded yet — that way on first paint sessions
|
|
49
|
+
// don't all collapse into Unsorted and then snap back into their real
|
|
50
|
+
// folder a few ms later when /api/folders resolves.
|
|
51
|
+
export const sessionsByFolder = computed(() => {
|
|
52
|
+
const groups = new Map();
|
|
53
|
+
groups.set(null, []);
|
|
54
|
+
for (const f of folders.value) groups.set(f.id, []);
|
|
55
|
+
for (const s of sessions.value) {
|
|
56
|
+
const key = s.folderId || null;
|
|
57
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
58
|
+
groups.get(key).push(s);
|
|
59
|
+
}
|
|
60
|
+
for (const list of groups.values()) {
|
|
61
|
+
list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
62
|
+
}
|
|
63
|
+
return groups;
|
|
64
|
+
});
|
|
48
65
|
|
|
49
66
|
export const TAB_HEADINGS = {
|
|
50
|
-
sessions: { title: 'Sessions', subtitle: '
|
|
51
|
-
launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace
|
|
52
|
-
terminals: { title: 'Terminals', subtitle: 'Claude sessions running in this page.' },
|
|
67
|
+
sessions: { title: 'Sessions', subtitle: 'Sessions you started in ccsm.' },
|
|
68
|
+
launch: { title: 'Launch', subtitle: 'Spin up a new session in a fresh workspace.' },
|
|
53
69
|
configure: { title: 'Configure', subtitle: 'Persisted to ~/.ccsm/config.json.' },
|
|
54
|
-
about: { title: 'About', subtitle: 'ccsm — Claude
|
|
70
|
+
about: { title: 'About', subtitle: 'ccsm — Claude CLI Sessions Manager.' },
|
|
55
71
|
};
|
|
56
72
|
|
|
57
73
|
// ── persistence helpers (localStorage) ──────────────────────────
|
|
58
74
|
const LS_SIDEBAR = 'ccsm.sidebar-collapsed';
|
|
59
75
|
const LS_SIDEBAR_W = 'ccsm.sidebar-width';
|
|
60
76
|
const LS_ACCENT = 'ccsm.accent';
|
|
61
|
-
const
|
|
77
|
+
const LS_FOLDERS_COLLAPSED = 'ccsm.folders-collapsed';
|
|
78
|
+
// Last-known sidebar tree, rehydrated on boot to keep the first paint
|
|
79
|
+
// stable. The next refreshAll() overwrites these from the server, so
|
|
80
|
+
// stale entries self-heal within ~5s without any explicit invalidation.
|
|
81
|
+
const LS_FOLDERS_CACHE = 'ccsm.folders-cache';
|
|
82
|
+
const LS_SESSIONS_CACHE = 'ccsm.sessions-cache';
|
|
62
83
|
|
|
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
84
|
export const SIDEBAR_MIN = 180;
|
|
66
85
|
export const SIDEBAR_MAX = 400;
|
|
67
86
|
export const SIDEBAR_DEFAULT = 232;
|
|
68
|
-
export const ACCENT_DEFAULT = '#
|
|
87
|
+
export const ACCENT_DEFAULT = '#2f6fa3';
|
|
69
88
|
|
|
70
89
|
export function loadPersisted() {
|
|
71
90
|
sidebarCollapsed.value = localStorage.getItem(LS_SIDEBAR) === 'true';
|
|
@@ -75,23 +94,36 @@ export function loadPersisted() {
|
|
|
75
94
|
}
|
|
76
95
|
applySidebarWidthCssVar();
|
|
77
96
|
const a = localStorage.getItem(LS_ACCENT);
|
|
78
|
-
if (isHexColor(a))
|
|
79
|
-
accentColor.value = a;
|
|
80
|
-
}
|
|
97
|
+
if (isHexColor(a)) accentColor.value = a;
|
|
81
98
|
applyAccentCssVars();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
99
|
+
try {
|
|
100
|
+
const raw = localStorage.getItem(LS_FOLDERS_COLLAPSED);
|
|
101
|
+
if (raw) {
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
if (parsed && typeof parsed === 'object') foldersCollapsed.value = parsed;
|
|
104
|
+
}
|
|
105
|
+
} catch {}
|
|
106
|
+
// Rehydrate the sidebar tree from the last seen server state so
|
|
107
|
+
// the first paint matches the user's last view. refreshAll() arrives
|
|
108
|
+
// ~50–500ms later and overwrites with fresh data.
|
|
109
|
+
try {
|
|
110
|
+
const raw = localStorage.getItem(LS_FOLDERS_CACHE);
|
|
111
|
+
if (raw) {
|
|
112
|
+
const parsed = JSON.parse(raw);
|
|
113
|
+
if (Array.isArray(parsed)) folders.value = parsed;
|
|
114
|
+
}
|
|
115
|
+
} catch {}
|
|
116
|
+
try {
|
|
117
|
+
const raw = localStorage.getItem(LS_SESSIONS_CACHE);
|
|
118
|
+
if (raw) {
|
|
119
|
+
const parsed = JSON.parse(raw);
|
|
120
|
+
if (Array.isArray(parsed)) sessions.value = parsed;
|
|
121
|
+
}
|
|
122
|
+
} catch {}
|
|
88
123
|
const hash = location.hash.slice(1);
|
|
89
124
|
if (TAB_HEADINGS[hash]) activeTab.value = hash;
|
|
90
125
|
}
|
|
91
126
|
|
|
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
127
|
function applySidebarWidthCssVar() {
|
|
96
128
|
document.documentElement.style.setProperty('--sidebar-w', `${sidebarWidth.value}px`);
|
|
97
129
|
}
|
|
@@ -104,9 +136,6 @@ export function setSidebarWidth(px) {
|
|
|
104
136
|
}
|
|
105
137
|
|
|
106
138
|
// ── 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
139
|
function isHexColor(s) {
|
|
111
140
|
return typeof s === 'string' && /^#[0-9a-fA-F]{6}$/.test(s);
|
|
112
141
|
}
|
|
@@ -119,17 +148,8 @@ function rgbToHex({ r, g, b }) {
|
|
|
119
148
|
return `#${h(r)}${h(g)}${h(b)}`;
|
|
120
149
|
}
|
|
121
150
|
function darken({ r, g, b }, amount) {
|
|
122
|
-
// amount 0..1; pull each channel toward 0
|
|
123
151
|
return { r: r * (1 - amount), g: g * (1 - amount), b: b * (1 - amount) };
|
|
124
152
|
}
|
|
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
153
|
function mixWithWhite({ r, g, b }, t) {
|
|
134
154
|
return { r: r * t + 255 * (1 - t), g: g * t + 255 * (1 - t), b: b * t + 255 * (1 - t) };
|
|
135
155
|
}
|
|
@@ -139,23 +159,19 @@ function applyAccentCssVars() {
|
|
|
139
159
|
const deep = rgbToHex(darken(rgb, 0.2));
|
|
140
160
|
const soft = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.10)`;
|
|
141
161
|
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
162
|
const bg = rgbToHex(mixWithWhite(rgb, 0.04));
|
|
154
163
|
const sidebarHover = rgbToHex(mixWithWhite(rgb, 0.10));
|
|
155
164
|
const sidebarActive= rgbToHex(mixWithWhite(rgb, 0.15));
|
|
156
165
|
const border = rgbToHex(mixWithWhite(rgb, 0.15));
|
|
157
166
|
const borderSoft = rgbToHex(mixWithWhite(rgb, 0.12));
|
|
158
167
|
const borderStrong = rgbToHex(mixWithWhite(rgb, 0.25));
|
|
168
|
+
// UI chrome (sidebar bg, dividers, footer strip) — themed too but
|
|
169
|
+
// visibly darker than the main bg so sidebar/main read as distinct.
|
|
170
|
+
// Border colors stay deliberately desaturated so dividers don't shout
|
|
171
|
+
// the brand color back at the user.
|
|
172
|
+
const uiBg = rgbToHex(mixWithWhite(rgb, 0.10));
|
|
173
|
+
const uiBorder = '#d8d4c6'; // theme-independent neutral
|
|
174
|
+
const uiBorderSoft = '#e6e2d4'; // theme-independent neutral
|
|
159
175
|
const root = document.documentElement.style;
|
|
160
176
|
root.setProperty('--accent', base);
|
|
161
177
|
root.setProperty('--accent-deep', deep);
|
|
@@ -168,10 +184,9 @@ function applyAccentCssVars() {
|
|
|
168
184
|
root.setProperty('--border', border);
|
|
169
185
|
root.setProperty('--border-soft', borderSoft);
|
|
170
186
|
root.setProperty('--border-strong', borderStrong);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// saturated).
|
|
187
|
+
root.setProperty('--ui-bg', uiBg);
|
|
188
|
+
root.setProperty('--ui-border', uiBorder);
|
|
189
|
+
root.setProperty('--ui-border-soft', uiBorderSoft);
|
|
175
190
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
176
191
|
if (meta) meta.setAttribute('content', bg);
|
|
177
192
|
}
|
|
@@ -190,13 +205,27 @@ export function selectTab(name) {
|
|
|
190
205
|
if (location.hash !== `#${name}`) window.history.replaceState(null, '', `#${name}`);
|
|
191
206
|
}
|
|
192
207
|
|
|
208
|
+
export function selectSession(id) {
|
|
209
|
+
activeSessionId.value = id;
|
|
210
|
+
activeTab.value = 'sessions';
|
|
211
|
+
if (location.hash !== '#sessions') window.history.replaceState(null, '', '#sessions');
|
|
212
|
+
}
|
|
213
|
+
|
|
193
214
|
export function toggleSidebar() {
|
|
215
|
+
if (sidebarForcedCollapsed.value) return;
|
|
194
216
|
sidebarCollapsed.value = !sidebarCollapsed.value;
|
|
195
217
|
localStorage.setItem(LS_SIDEBAR, String(sidebarCollapsed.value));
|
|
196
218
|
}
|
|
197
219
|
|
|
220
|
+
export function toggleFolder(folderId) {
|
|
221
|
+
const key = folderId || 'unsorted';
|
|
222
|
+
const next = { ...foldersCollapsed.value, [key]: !foldersCollapsed.value[key] };
|
|
223
|
+
foldersCollapsed.value = next;
|
|
224
|
+
localStorage.setItem(LS_FOLDERS_COLLAPSED, JSON.stringify(next));
|
|
225
|
+
}
|
|
226
|
+
|
|
198
227
|
export function toggleCardFold(key) {
|
|
199
228
|
const next = { ...cardFolded.value, [key]: !cardFolded.value[key] };
|
|
200
229
|
cardFolded.value = next;
|
|
201
|
-
localStorage.setItem(
|
|
230
|
+
try { localStorage.setItem(`ccsm.fold.${key}`, next[key] ? '1' : '0'); } catch {}
|
|
202
231
|
}
|
package/scripts/install.js
CHANGED
|
@@ -116,11 +116,16 @@ try {
|
|
|
116
116
|
if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
|
|
117
117
|
try {
|
|
118
118
|
const { spawn } = require('node:child_process');
|
|
119
|
-
|
|
119
|
+
// Spawn `node bin/ccsm.js` directly — NOT ccsm.cmd. On Windows,
|
|
120
|
+
// child_process.spawn() with shell:false refuses .cmd files (throws
|
|
121
|
+
// EINVAL); using shell:true would flash a console window. Going
|
|
122
|
+
// through node + the JS entrypoint sidesteps both problems and
|
|
123
|
+
// matches exactly what the .cmd shim would have invoked.
|
|
124
|
+
const launcherJs = path.join(__dirname, '..', 'bin', 'ccsm.js');
|
|
125
|
+
const child = spawn(process.execPath, [launcherJs], {
|
|
120
126
|
detached: true,
|
|
121
127
|
stdio: 'ignore',
|
|
122
128
|
windowsHide: true,
|
|
123
|
-
shell: false,
|
|
124
129
|
});
|
|
125
130
|
child.unref();
|
|
126
131
|
log('launching ccsm now · check for the chromeless window');
|