claude-mission-control 1.8.0 → 1.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/README.md +2 -0
- package/lib/collector.js +12 -6
- package/lib/config.js +20 -1
- package/lib/transcripts.js +5 -1
- package/package.json +1 -1
- package/public/index.html +77 -0
- package/server.js +7 -0
package/README.md
CHANGED
|
@@ -132,6 +132,8 @@ Edit `names.json` to control how projects are titled:
|
|
|
132
132
|
|
|
133
133
|
Unlisted projects fall back to a cleaned-up folder name. Changes are picked up automatically — no restart needed.
|
|
134
134
|
|
|
135
|
+
Sessions can be renamed too: click the ✎ next to any session title (live board, project cards, pinned strip, or a project's slide-over), type a name, and press Enter. Escape cancels; an empty name goes back to the automatic title. Custom names live in `config.json` under `sessionNames`, keyed by session id, and win over the AI-generated or first-prompt title everywhere.
|
|
136
|
+
|
|
135
137
|
## Hiding projects
|
|
136
138
|
|
|
137
139
|
Edit `ignore.json` — an array of absolute path prefixes. A project is hidden if its path is, or sits under, any listed prefix, so one line hides a whole tree (e.g. all the plugins/themes inside one site). This only hides them from the dashboard, strip, and menu bar; nothing on disk or in `~/.claude` is touched. Picked up automatically.
|
package/lib/collector.js
CHANGED
|
@@ -165,8 +165,14 @@ class Collector {
|
|
|
165
165
|
return [...set.values()].filter((p) => !isIgnored(p));
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// sessionTitle with the user's custom names from config.json applied.
|
|
169
|
+
titleOf(m) {
|
|
170
|
+
return sessionTitle(m, readConfig().sessionNames);
|
|
171
|
+
}
|
|
172
|
+
|
|
168
173
|
assemble() {
|
|
169
174
|
const registry = readRegistry();
|
|
175
|
+
const sessionNames = readConfig().sessionNames || {}; // read once, not per session
|
|
170
176
|
const liveByProject = new Set(
|
|
171
177
|
this.raw.live.map((s) => worktreeRoot(s.cwd).root.toLowerCase())
|
|
172
178
|
);
|
|
@@ -203,7 +209,7 @@ class Collector {
|
|
|
203
209
|
0
|
|
204
210
|
),
|
|
205
211
|
sessions: sessionMetas.slice(0, SESSIONS_PER_PROJECT).map((m) => {
|
|
206
|
-
const { title, source } = sessionTitle(m);
|
|
212
|
+
const { title, source } = sessionTitle(m, sessionNames);
|
|
207
213
|
return {
|
|
208
214
|
sessionId: m.sessionId,
|
|
209
215
|
estCost: estimateCost(combinedUsage(m)),
|
|
@@ -235,7 +241,7 @@ class Collector {
|
|
|
235
241
|
const subsBySession = new Map();
|
|
236
242
|
for (const g of this.raw.transcriptGroups.values()) {
|
|
237
243
|
for (const m of g.sessions) {
|
|
238
|
-
titleBySession.set(m.sessionId, sessionTitle(m).title);
|
|
244
|
+
titleBySession.set(m.sessionId, sessionTitle(m, sessionNames).title);
|
|
239
245
|
if (m.model) modelBySession.set(m.sessionId, m.model);
|
|
240
246
|
const subs = subagentSummary(m);
|
|
241
247
|
if (subs) subsBySession.set(m.sessionId, subs);
|
|
@@ -306,7 +312,7 @@ class Collector {
|
|
|
306
312
|
if (pinnedIds.has(m.sessionId)) {
|
|
307
313
|
pinned.push({
|
|
308
314
|
sessionId: m.sessionId,
|
|
309
|
-
title: sessionTitle(m).title,
|
|
315
|
+
title: sessionTitle(m, sessionNames).title,
|
|
310
316
|
projectName: friendlyName(g.path),
|
|
311
317
|
model: m.model || null,
|
|
312
318
|
lastActivityAt: m.lastActivityAt,
|
|
@@ -365,7 +371,7 @@ class Collector {
|
|
|
365
371
|
const g = this.raw.transcriptGroups.get(projectPath.toLowerCase());
|
|
366
372
|
if (!g) return [];
|
|
367
373
|
return g.sessions.map((m) => {
|
|
368
|
-
const { title, source } =
|
|
374
|
+
const { title, source } = this.titleOf(m);
|
|
369
375
|
return {
|
|
370
376
|
sessionId: m.sessionId,
|
|
371
377
|
title,
|
|
@@ -441,7 +447,7 @@ class Collector {
|
|
|
441
447
|
timeline.push({
|
|
442
448
|
project: friendlyName(g.path),
|
|
443
449
|
sessionId: m.sessionId,
|
|
444
|
-
title:
|
|
450
|
+
title: this.titleOf(m).title,
|
|
445
451
|
start: Math.max(m.startedAt || m.lastActivityAt, dayCut),
|
|
446
452
|
end: m.lastActivityAt,
|
|
447
453
|
});
|
|
@@ -507,7 +513,7 @@ class Collector {
|
|
|
507
513
|
for (const g of this.raw.transcriptGroups.values()) {
|
|
508
514
|
for (const m of g.sessions) {
|
|
509
515
|
if (m.sessionId === sessionId) {
|
|
510
|
-
return { file: m.file, title:
|
|
516
|
+
return { file: m.file, title: this.titleOf(m).title, projectName: friendlyName(g.path) };
|
|
511
517
|
}
|
|
512
518
|
}
|
|
513
519
|
}
|
package/lib/config.js
CHANGED
|
@@ -33,7 +33,7 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
|
33
33
|
const NAMES_FILE = path.join(CONFIG_DIR, 'names.json');
|
|
34
34
|
const IGNORE_FILE = path.join(CONFIG_DIR, 'ignore.json');
|
|
35
35
|
|
|
36
|
-
const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], theme: 'board' };
|
|
36
|
+
const DEFAULTS = { terminal: 'ghostty', notifications: true, usageApi: true, mutedProjects: [], weeklyBudget: 0, pinnedSessions: [], sessionNames: {}, theme: 'board' };
|
|
37
37
|
|
|
38
38
|
// The one theme list: updateConfig validates against it and GET /api/config
|
|
39
39
|
// serves it, so the settings dropdown can never offer a value the server
|
|
@@ -96,6 +96,23 @@ function togglePin(sessionId) {
|
|
|
96
96
|
return !has;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Pure: returns a new sessionNames map with `name` set for `sessionId`
|
|
100
|
+
// (trimmed, capped at 80 chars); an empty name removes the entry.
|
|
101
|
+
function applySessionName(sessionNames, sessionId, name) {
|
|
102
|
+
const next = { ...(sessionNames || {}) };
|
|
103
|
+
const clean = String(name || '').trim().slice(0, 80);
|
|
104
|
+
if (clean) next[sessionId] = clean;
|
|
105
|
+
else delete next[sessionId];
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setSessionName(sessionId, name) {
|
|
110
|
+
const next = { ...readConfig() };
|
|
111
|
+
next.sessionNames = applySessionName(next.sessionNames, sessionId, name);
|
|
112
|
+
writeJson(CONFIG_FILE, next);
|
|
113
|
+
return next.sessionNames[sessionId] || '';
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
function readNames() {
|
|
100
117
|
return readJson(NAMES_FILE, {});
|
|
101
118
|
}
|
|
@@ -208,6 +225,8 @@ module.exports = {
|
|
|
208
225
|
updateConfig,
|
|
209
226
|
setProjectMuted,
|
|
210
227
|
togglePin,
|
|
228
|
+
applySessionName,
|
|
229
|
+
setSessionName,
|
|
211
230
|
readNames,
|
|
212
231
|
setName,
|
|
213
232
|
readIgnores,
|
package/lib/transcripts.js
CHANGED
|
@@ -357,7 +357,11 @@ function dailyCostSeries(daysList, numDays, today = Date.now()) {
|
|
|
357
357
|
}
|
|
358
358
|
|
|
359
359
|
// Best display title for a session, with its provenance.
|
|
360
|
-
|
|
360
|
+
// `custom` is a user-set name from config.json (sessionNames); it wins over
|
|
361
|
+
// anything derived from the transcript.
|
|
362
|
+
function sessionTitle(meta, customNames) {
|
|
363
|
+
const custom = customNames && customNames[meta.sessionId];
|
|
364
|
+
if (custom) return { title: custom, source: 'custom' };
|
|
361
365
|
if (meta.aiTitle) return { title: meta.aiTitle, source: 'ai-title' };
|
|
362
366
|
if (meta.firstUserPrompt) return { title: meta.firstUserPrompt, source: 'first-prompt' };
|
|
363
367
|
if (meta.lastPrompt) return { title: meta.lastPrompt, source: 'last-prompt' };
|
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -402,6 +402,15 @@
|
|
|
402
402
|
.s-title[data-session], .di-title[data-session] { cursor: pointer; }
|
|
403
403
|
.s-title[data-session]:hover, .di-title[data-session]:hover { color: var(--accent); }
|
|
404
404
|
|
|
405
|
+
/* inline session rename (pencil button swaps the title for this input) */
|
|
406
|
+
.rename-input {
|
|
407
|
+
font: inherit; font-size: 12.5px; color: var(--ink);
|
|
408
|
+
background: var(--surface2); border: 1px solid var(--accent);
|
|
409
|
+
border-radius: 2px; padding: 2px 6px; flex: 1; min-width: 0; width: 100%;
|
|
410
|
+
box-sizing: border-box;
|
|
411
|
+
}
|
|
412
|
+
.rename-input:focus { outline: none; }
|
|
413
|
+
|
|
405
414
|
/* transcript rendering */
|
|
406
415
|
.t-turn { margin: 10px 0; }
|
|
407
416
|
.t-user {
|
|
@@ -934,6 +943,10 @@ function copyBtn(cmd) {
|
|
|
934
943
|
|
|
935
944
|
// Live sessions are already open somewhere; only offer open on idle ones.
|
|
936
945
|
let liveIds = new Set();
|
|
946
|
+
function renameBtn(sessionId, title) {
|
|
947
|
+
return `<button class="copy rename-btn" data-rename-session="${esc(sessionId)}" data-title="${esc(title || '')}" title="Rename this session">✎</button>`;
|
|
948
|
+
}
|
|
949
|
+
|
|
937
950
|
function openBtn(sessionId) {
|
|
938
951
|
if (liveIds.has(sessionId)) return '';
|
|
939
952
|
return `<button class="copy open-btn" data-open="${esc(sessionId)}" title="Open this session">open ⬈</button>`;
|
|
@@ -972,6 +985,7 @@ function liveCard(s) {
|
|
|
972
985
|
${modelChip(s.model)}
|
|
973
986
|
<span class="dep-elapsed">${elapsed(s.startedAt)}</span>
|
|
974
987
|
${statusFlap(s)}
|
|
988
|
+
${renameBtn(s.sessionId, s.title)}
|
|
975
989
|
${copyBtn(s.resumeCommand)}
|
|
976
990
|
</div>
|
|
977
991
|
</div>`;
|
|
@@ -985,6 +999,7 @@ function projectCard(p) {
|
|
|
985
999
|
sess.worktree ? `<span class="wt mono">⎇ ${esc(sess.worktree)}</span>` : ''}${esc(sess.title)}</span>
|
|
986
1000
|
${modelChip(sess.model)}
|
|
987
1001
|
<span class="s-when">${relTime(sess.lastActivityAt)}</span>
|
|
1002
|
+
${renameBtn(sess.sessionId, sess.title)}
|
|
988
1003
|
${openBtn(sess.sessionId)}
|
|
989
1004
|
${copyBtn(sess.resumeCommand)}
|
|
990
1005
|
</li>`).join('')}</ul>`
|
|
@@ -1017,6 +1032,7 @@ function renderPinned(state) {
|
|
|
1017
1032
|
${modelChip(p.model)}
|
|
1018
1033
|
<span class="s-when">${esc(p.projectName)}</span>
|
|
1019
1034
|
<span class="s-when">${relTime(p.lastActivityAt)}</span>
|
|
1035
|
+
${renameBtn(p.sessionId, p.title)}
|
|
1020
1036
|
<button class="copy" data-pin="${esc(p.sessionId)}" title="Unpin">unpin</button>
|
|
1021
1037
|
</div>`).join(''));
|
|
1022
1038
|
}
|
|
@@ -1117,7 +1133,11 @@ function renderDigest(state) {
|
|
|
1117
1133
|
patch($('#digest-body'), html);
|
|
1118
1134
|
}
|
|
1119
1135
|
|
|
1136
|
+
let renderDeferred = false; // an SSE tick arrived while a rename input was open
|
|
1120
1137
|
function render(state) {
|
|
1138
|
+
// Rebuilding the DOM mid-edit would wipe the rename input; catch up afterwards.
|
|
1139
|
+
if (document.querySelector('.rename-input')) { renderDeferred = true; return; }
|
|
1140
|
+
renderDeferred = false;
|
|
1121
1141
|
if (state.theme && state.theme !== 'board') document.documentElement.dataset.theme = state.theme;
|
|
1122
1142
|
else delete document.documentElement.dataset.theme;
|
|
1123
1143
|
liveIds = new Set((state.liveSessions || []).map((s) => s.sessionId));
|
|
@@ -1356,6 +1376,7 @@ function renderDetail(d, name) {
|
|
|
1356
1376
|
s.worktree ? `<span class="wt mono">⎇ ${esc(s.worktree)}</span> ` : ''}${esc(s.title)}</span>
|
|
1357
1377
|
${fmtCost(s.estCost) ? `<span class="s-when">≈${fmtCost(s.estCost)}</span>` : ''}
|
|
1358
1378
|
<span class="s-when">${relTime(s.lastActivityAt)}</span>
|
|
1379
|
+
${renameBtn(s.sessionId, s.title)}
|
|
1359
1380
|
${openBtn(s.sessionId)}
|
|
1360
1381
|
${copyBtn(s.resumeCommand)}
|
|
1361
1382
|
</li>`).join('')}</ul>`
|
|
@@ -1773,6 +1794,60 @@ async function applyUpdate(out, currentVersion) {
|
|
|
1773
1794
|
})();
|
|
1774
1795
|
}
|
|
1775
1796
|
|
|
1797
|
+
// Swap a session title for a text input. Enter/blur saves, Escape cancels,
|
|
1798
|
+
// an empty name goes back to the automatic title.
|
|
1799
|
+
function startRename(btn) {
|
|
1800
|
+
const id = btn.dataset.renameSession;
|
|
1801
|
+
const row = btn.closest('li, .pin-row, .dep-row');
|
|
1802
|
+
const titleEl = row && row.querySelector(`[data-session="${CSS.escape(id)}"]`);
|
|
1803
|
+
if (!titleEl || row.querySelector('.rename-input')) return;
|
|
1804
|
+
const before = btn.dataset.title || '';
|
|
1805
|
+
const input = document.createElement('input');
|
|
1806
|
+
input.className = 'rename-input';
|
|
1807
|
+
input.type = 'text';
|
|
1808
|
+
input.maxLength = 80;
|
|
1809
|
+
input.value = before;
|
|
1810
|
+
input.placeholder = 'Session name — leave empty to reset';
|
|
1811
|
+
titleEl.hidden = true;
|
|
1812
|
+
titleEl.after(input);
|
|
1813
|
+
input.focus();
|
|
1814
|
+
input.select();
|
|
1815
|
+
let done = false;
|
|
1816
|
+
const finish = async (save) => {
|
|
1817
|
+
if (done) return;
|
|
1818
|
+
done = true;
|
|
1819
|
+
const val = input.value.trim();
|
|
1820
|
+
input.remove();
|
|
1821
|
+
titleEl.hidden = false;
|
|
1822
|
+
if (save && val !== before) {
|
|
1823
|
+
try {
|
|
1824
|
+
const r = await fetch('/api/config', {
|
|
1825
|
+
method: 'POST',
|
|
1826
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1827
|
+
body: JSON.stringify({ renameSession: id, name: val }),
|
|
1828
|
+
});
|
|
1829
|
+
const out = await r.json();
|
|
1830
|
+
if (out.ok) {
|
|
1831
|
+
// Drawers aren't re-rendered by SSE, so patch the title in place.
|
|
1832
|
+
const text = [...titleEl.childNodes].find((n) => n.nodeType === 3);
|
|
1833
|
+
if (text) text.textContent = out.title; else titleEl.append(out.title);
|
|
1834
|
+
btn.dataset.title = out.title;
|
|
1835
|
+
toast(val ? `Renamed to "${val}"` : 'Name reset to the automatic title');
|
|
1836
|
+
} else toast(`Couldn't rename: ${out.error}`);
|
|
1837
|
+
} catch {
|
|
1838
|
+
toast("Couldn't reach the server");
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
if (renderDeferred && lastState) render(lastState);
|
|
1842
|
+
};
|
|
1843
|
+
input.addEventListener('keydown', (e) => {
|
|
1844
|
+
e.stopPropagation(); // keep the page's shortcuts (Esc, /, etc.) out of the edit
|
|
1845
|
+
if (e.key === 'Enter') finish(true);
|
|
1846
|
+
else if (e.key === 'Escape') finish(false);
|
|
1847
|
+
});
|
|
1848
|
+
input.addEventListener('blur', () => finish(true));
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1776
1851
|
async function saveSetting(endpoint, payload, note) {
|
|
1777
1852
|
try {
|
|
1778
1853
|
const r = await fetch(endpoint, {
|
|
@@ -1973,6 +2048,8 @@ document.addEventListener('click', async (e) => {
|
|
|
1973
2048
|
}
|
|
1974
2049
|
return;
|
|
1975
2050
|
}
|
|
2051
|
+
const rb = e.target.closest('[data-rename-session]');
|
|
2052
|
+
if (rb) { startRename(rb); return; }
|
|
1976
2053
|
const t = e.target.closest('[data-session]');
|
|
1977
2054
|
if (t) { openTranscript(t.dataset.session, t.dataset.hl); return; }
|
|
1978
2055
|
const nb = e.target.closest('[data-new]');
|
package/server.js
CHANGED
|
@@ -279,6 +279,13 @@ const server = http.createServer((req, res) => {
|
|
|
279
279
|
collector.assemble();
|
|
280
280
|
return json(res, 200, { ok: true, pinned });
|
|
281
281
|
}
|
|
282
|
+
if (payload.renameSession !== undefined) {
|
|
283
|
+
const id = String(payload.renameSession || '');
|
|
284
|
+
if (!collector.findSessionFile(id)) return json(res, 404, { ok: false, error: 'unknown session' });
|
|
285
|
+
const name = cfg.setSessionName(id, String(payload.name || ''));
|
|
286
|
+
collector.assemble();
|
|
287
|
+
return json(res, 200, { ok: true, name, title: collector.findSessionFile(id).title });
|
|
288
|
+
}
|
|
282
289
|
if (payload.mutePath !== undefined) {
|
|
283
290
|
const known = collector
|
|
284
291
|
.projectPaths()
|