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 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 } = sessionTitle(m);
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: sessionTitle(m).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: sessionTitle(m).title, projectName: friendlyName(g.path) };
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,
@@ -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
- function sessionTitle(meta) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Local dashboard for Claude Code: live sessions, transcripts, costs, git status — all your projects in one place.",
5
5
  "main": "server.js",
6
6
  "bin": {
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()