termdeck-cli 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dashboard.js CHANGED
@@ -7,10 +7,10 @@
7
7
  * exactly the rows it needs, with zero gaps between panes:
8
8
  *
9
9
  * +-------------------------------------------------------------------+
10
- * | TERMDECK |
11
- * | Sep 21, 2026 3:30:59 PM |
12
- * +-------------------------------------------------------------------+
13
- * | [ALL 14] [LIVE 6] [EXP 4] … /search (regex) |
10
+ * | 15:14:19 +------------+ ● DAEMON ON |
11
+ * | | TERMDECK | |
12
+ * | +------------+ |
13
+ * | [ALL 14] [LIVE 6] [EXP 4] … /search (regex) [box] |
14
14
  * +---------------------------------+---------------------------------+
15
15
  * | PROJECTS (14) | DETAILS: hyperion-core |
16
16
  * | ● hyperion-core 12m ago | status / path / branch / port |
@@ -38,11 +38,11 @@ const { openInNewTerminal } = require('./terminal');
38
38
  const { LogView } = require('./logView');
39
39
  const { MODERN_STATUSES, loadConfig, loadConfigFromPath, displayPath, saveConfig } = require('./config');
40
40
  const { escapeBraces, truncate, formatTimestamp, timestamp, timeAgo } = require('./util');
41
- const { getGitInfo } = require('./projectManager');
42
- const { launchAgent, tailAgentLog, stopAllAgents } = require('./agentManager');
41
+ const { getGitInfo, detectStack, generateCommitMessage, commitAndPush } = require('./projectManager');
42
+ const { launchAgent, tailAgentLog, stopAllAgents, activeTails } = require('./agentManager');
43
43
  const { startMonitoring, stopMonitoring, stopAllMonitoring } = require('./processMonitor');
44
44
 
45
- const LAYOUT = { rows: 12, cols: 12, headerHeight: 6, footerHeight: 1 };
45
+ const LAYOUT = { rows: 12, cols: 12, headerHeight: 6, footerHeight: 2 };
46
46
 
47
47
  /* ------------------------------------------------------------------ *
48
48
  * Theme — dark, modern palette, pastel status tags, thin borders.
@@ -53,9 +53,11 @@ const THEME = {
53
53
  surface: '#181825', // slightly darker panels (output, footer)
54
54
  text: '#cdd6f4', // general text (light gray-white)
55
55
  textDim: '#9399b2', // secondary text (timestamps, labels)
56
+ tagCyan: '#94e2d5', // log [termdeck] source tag
56
57
  border: '#45475a', // thin, unobtrusive box borders
57
- accentBg: '#3b82f6', // selected-project highlight (bright blue)
58
+ accentBg: '#3b82f6', // selected-project / focused-button highlight
58
59
  accentFg: '#ffffff',
60
+ chipBg: '#2d2d3f', // action-button / stat-chip background
59
61
  };
60
62
 
61
63
  /** Pastel status colours per modern status; `unknown` is neutral gray. */
@@ -97,7 +99,7 @@ const DEMO_PID = 49201;
97
99
  const SAMPLE_CONFIG_PATH = path.join(__dirname, '..', 'sample-config.json');
98
100
 
99
101
  const FOOTER_KEYS =
100
- '{bold}↑↓{/bold} navigate {bold}tab{/bold} pane {bold}s{/bold} status {bold}/{/bold} search {bold}r{/bold} dev {bold}shift+x{/bold} stop {bold}q{/bold} quit';
102
+ '{bold}\u2191\u2193{/bold} navigate {bold}tab{/bold} pane {bold}s{/bold} status {bold}/{/bold} search {bold}r{/bold} dev {bold}shift+x{/bold} stop {bold}q{/bold} quit';
101
103
  const HINTS = ` ${FOOTER_KEYS} `;
102
104
 
103
105
  /** Colour-coded demo log lines so the OUTPUT pane styling can be checked. */
@@ -153,10 +155,12 @@ function launchDashboard(config, options = {}) {
153
155
  const palette = new Map();
154
156
  const status = { message: null, timer: null, quitArmed: false, quitTimer: null, clock: null, chip: null, search: null };
155
157
  const gitInfo = new Map(); // project.path -> git snapshot (branch / hash / dirty)
158
+ const stackInfo = new Map();
156
159
  const processStats = new Map(); // project.path -> {running, pid, memory, cpu}
157
160
  let monitoredPath = null; // project.path currently polled by processMonitor
158
161
  let searchActive = false;
159
162
  let searchBuffer = '';
163
+ let gitModal = null;
160
164
 
161
165
  const colorFor = (project) => palette.get(project.path) || THEME.text;
162
166
 
@@ -182,45 +186,162 @@ function launchDashboard(config, options = {}) {
182
186
  el.style.label = { fg: THEME.textDim };
183
187
  el.style.fg = THEME.text;
184
188
  el.style.bg = THEME.bg;
185
- if (label) el.setLabel(label);
189
+ if (label) {
190
+ el.headerLabel = label;
191
+ el.setLabel(label);
192
+ }
186
193
  return el;
187
194
  }
188
195
 
189
- // Full-width masthead. A green-outlined title strip on the dark terminal
190
- // background: the TERMDECK wordmark sits centred with the live clock below
191
- // it, and vertical padding makes the strip read as a real header. No solid
192
- // fill — just a clean line border and green text. updateHeader() refreshes
193
- // the clock line every second. The box is created once and only its content
194
- // is mutated — recreating it per tick leaked the renderer.
195
- const TITLE_TEXT = ' TERMDECK ';
196
- const TITLE_HEIGHT = 6; // line border + vertical padding + title + clock
197
- const HEADER_ACCENT = '#00ff00';
198
- const titleBox = blessed.box({
196
+ /**
197
+ * Right-aligned header text drawn on a pane's top border. Has to be a direct
198
+ * screen child: blessed refuses to lay out top:-1 children of scrollable
199
+ * panes (projectList/card/logBox are all scrollable), so a pane-relative
200
+ * label would never paint. Position is derived from the pane's own geometry.
201
+ * Returns { widget, refresh(labelText) } so callers can re-evaluate whether the
202
+ * header still fits (the OUTPUT pane's label grows when logs are paused).
203
+ */
204
+ function paneHeaderRight(parent, content, fg = THEME.textDim) {
205
+ const pct = (v, fb) => {
206
+ const m = /^(\d+(?:\.\d+)?)%$/.exec(String(v));
207
+ return m ? parseFloat(m[1]) : fb;
208
+ };
209
+ const visible = String(content).replace(/\{[^{}]*\}/g, '').length;
210
+ const paneWidth = pct(parent.options.width, 0);
211
+ const position = `${(pct(parent.options.left, 0) + paneWidth).toFixed(2)}%-${visible + 1}`;
212
+ const widget = blessed.text({
213
+ parent: screen,
214
+ top: parent.options.top,
215
+ left: position,
216
+ height: 1,
217
+ tags: true,
218
+ content,
219
+ style: { fg, bg: 'transparent' },
220
+ });
221
+ widget.refresh = (labelText) => {
222
+ const labelLen = String(labelText != null ? labelText : parent.headerLabel || '').replace(/\{[^{}]*\}/g, '').length;
223
+ const px = Math.round((paneWidth / 100) * screen.width);
224
+ widget.hidden = visible + 1 > px - labelLen - 4;
225
+ };
226
+ widget.refresh();
227
+ return widget;
228
+ }
229
+
230
+ // Two-strip header: a 3-row masthead (clock | TERMDECK box | daemon) above a
231
+ // 3-row chips strip (status chips | search box). updateHeader() is the only
232
+ // renderer that mutates these.
233
+ const TITLE_TEXT = 'T E R M D E C K';
234
+ const LOGO_GREEN = '#00ff00';
235
+ const HEADER_ROWS = 6;
236
+ const CHIP_STRIP_TOP = 3;
237
+ const CHIP_ORDER = ['all', 'live', 'exp', 'pend', 'unknown', 'scrap'];
238
+ const CHIP_LABELS = { all: 'ALL', live: 'LIVE', exp: 'EXP', pend: 'PEND', unknown: 'UNKNOWN', scrap: 'SCRAP' };
239
+ const CHIP_WIDTHS = { all: 8, live: 8, exp: 8, pend: 8, unknown: 12, scrap: 11 };
240
+ const CHIP_COLORS = { live: STATUS_FG.live, exp: STATUS_FG.exp, pend: STATUS_FG.pend, unknown: STATUS_FG.unknown, scrap: STATUS_FG.scrap };
241
+
242
+ /** `15:14:19` — 24-hour clock for the masthead (logs stay 12-hour). */
243
+ function h24Time(date = new Date()) {
244
+ const p = (n) => String(n).padStart(2, '0');
245
+ return `${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
246
+ }
247
+
248
+ const masthead = blessed.box({
199
249
  parent: screen,
200
250
  top: 0,
201
251
  left: 0,
202
252
  width: '100%',
203
- height: TITLE_HEIGHT,
253
+ height: 3,
204
254
  tags: true,
205
- padding: { top: 1, bottom: 1 },
206
- border: { type: 'line', fg: HEADER_ACCENT },
207
- style: { fg: HEADER_ACCENT, bold: true },
255
+ style: { bg: THEME.bg, fg: THEME.text },
208
256
  });
209
257
 
210
- // Stats + search strip sits directly under the masthead (no gap).
211
- const statsBar = blessed.box({
258
+ const clockLabel = blessed.text({
259
+ parent: masthead,
260
+ top: 1,
261
+ left: 1,
262
+ height: 1,
263
+ tags: true,
264
+ content: '',
265
+ style: { fg: THEME.textDim, bg: THEME.bg },
266
+ });
267
+
268
+ const titleBox = blessed.box({
269
+ parent: masthead,
270
+ top: 0,
271
+ left: 'center',
272
+ width: TITLE_TEXT.length + 2,
273
+ height: 3,
274
+ tags: true,
275
+ align: 'center',
276
+ valign: 'middle',
277
+ border: { type: 'line', fg: LOGO_GREEN },
278
+ style: { fg: LOGO_GREEN, bold: true, bg: THEME.bg },
279
+ content: TITLE_TEXT,
280
+ });
281
+
282
+ const daemonLabel = blessed.text({
283
+ parent: masthead,
284
+ top: 1,
285
+ right: 1,
286
+ height: 1,
287
+ tags: true,
288
+ content: '',
289
+ style: { bg: THEME.bg },
290
+ });
291
+
292
+ // Row 2 of the header: bordered status chips at the left, bordered search box
293
+ // at the right. Chips are chunky 3-row boxes so the line border closes cleanly.
294
+ const chipStrip = blessed.box({
212
295
  parent: screen,
213
- top: TITLE_HEIGHT,
296
+ top: CHIP_STRIP_TOP,
214
297
  left: 0,
215
298
  width: '100%',
216
- height: 1,
299
+ height: 3,
217
300
  tags: true,
218
301
  style: { bg: THEME.bg, fg: THEME.text },
219
302
  });
220
303
 
221
- const bodyTop = TITLE_HEIGHT + 1; // masthead + stats bar, no gaps
222
- const footerHeight = 1;
223
- const bodyHeight = Math.max(3, screen.rows - bodyTop - footerHeight);
304
+ const searchBox = blessed.box({
305
+ parent: chipStrip,
306
+ top: 0,
307
+ right: 1,
308
+ width: Math.max(18, Math.min(26, Math.floor(screen.cols * 0.26))),
309
+ height: 3,
310
+ tags: true,
311
+ align: 'center',
312
+ valign: 'middle',
313
+ border: { type: 'line', fg: THEME.border },
314
+ style: { fg: THEME.textDim, bg: THEME.bg },
315
+ });
316
+
317
+ let chipCursor = 0;
318
+ const chipBoxes = CHIP_ORDER.map((key) => {
319
+ const box = blessed.box({
320
+ parent: chipStrip,
321
+ top: 0,
322
+ left: chipCursor,
323
+ width: CHIP_WIDTHS[key],
324
+ height: 3,
325
+ tags: true,
326
+ align: 'center',
327
+ valign: 'middle',
328
+ border: { type: 'line', fg: THEME.border },
329
+ style: { fg: CHIP_COLORS[key] ? CHIP_COLORS[key] : THEME.text, bg: THEME.bg },
330
+ });
331
+ chipCursor += CHIP_WIDTHS[key] + 1;
332
+ return box;
333
+ });
334
+
335
+ const bodyTop = HEADER_ROWS; // masthead + chips strip
336
+ const footerHeight = 2; // bordered footer box
337
+ const bodyHeight = Math.max(6, screen.rows - bodyTop - footerHeight);
338
+ // Bordered buttons need 5 rows x 3 cells (15) + border(2) + label-row(0). When the
339
+ // window is too short (80x24 → body 16), the grid collapses to 1-line chips.
340
+ const large = bodyHeight >= 29;
341
+ const actionsHeight = large ? 18 : 8; // border(2) + grid(5x3 or 5x1)
342
+ const outputHeight = Math.max(3, Math.min(large ? 6 : 4, bodyHeight - actionsHeight - (large ? 8 : 5)));
343
+ const cardHeight = Math.max(3, bodyHeight - actionsHeight - outputHeight);
344
+ const logHeight = outputHeight;
224
345
 
225
346
  const projectList = blessed.list({
226
347
  parent: screen,
@@ -243,13 +364,10 @@ function launchDashboard(config, options = {}) {
243
364
  },
244
365
  });
245
366
  panel(projectList, ' PROJECTS ');
367
+ paneHeaderRight(projectList, ' SORT: RECENT ');
246
368
 
247
369
  const rightLeft = '40%';
248
370
  const rightWidth = '60%';
249
- const cardHeight = Math.max(3, Math.round(bodyHeight * 0.42));
250
- // The ACTIONS pane must fit 4 button rows plus borders; OUTPUT gets the rest.
251
- const actionsHeight = Math.max(6, Math.round(bodyHeight * 0.34));
252
- const logHeight = Math.max(3, bodyHeight - cardHeight - actionsHeight);
253
371
 
254
372
  const card = blessed.box({
255
373
  parent: screen,
@@ -263,6 +381,7 @@ function launchDashboard(config, options = {}) {
263
381
  border: { type: 'line', fg: THEME.border },
264
382
  });
265
383
  panel(card, ' DETAILS ');
384
+ const gitHeader = paneHeaderRight(card, '{#a6e3a1-fg}GIT: CLEAN{/#a6e3a1-fg}');
266
385
 
267
386
  const actionsShell = blessed.box({
268
387
  parent: screen,
@@ -273,30 +392,35 @@ function launchDashboard(config, options = {}) {
273
392
  tags: true,
274
393
  border: { type: 'line', fg: THEME.border },
275
394
  });
276
- panel(actionsShell, ' ACTIONS — r/e/c/x/o/f/k/s or [Enter] ');
395
+ panel(actionsShell, ' ACTIONS - r/e/c/x/o/f/k/s or [Enter] ');
396
+ paneHeaderRight(actionsShell, ' KEYMAP: VIM/CLI ');
277
397
 
278
- const footer = blessed.box({
398
+ const footerMeta = blessed.box({
279
399
  parent: screen,
280
400
  top: bodyTop + bodyHeight,
281
401
  left: 0,
282
402
  width: '100%',
283
- height: footerHeight,
403
+ height: 1,
284
404
  tags: true,
285
- style: { fg: THEME.text, bg: THEME.surface },
405
+ style: { fg: THEME.textDim, bg: THEME.surface },
406
+ });
407
+ const footer = blessed.box({
408
+ parent: screen,
409
+ top: bodyTop + bodyHeight + 1,
410
+ left: 0,
411
+ width: '100%',
412
+ height: 1,
413
+ tags: true,
414
+ style: { fg: THEME.accentFg, bg: THEME.accentBg },
286
415
  });
287
416
 
288
- /**
289
- * One-line action button, nested inside the ACTIONS cell. `focusBg/focusFg`
290
- * drive the focused/hover look; the status button is re-styled at render
291
- * time to nudge the user while a project has no status.
292
- */
293
- function makeButton({ content, fg, focusBg, focusFg, onPress }) {
417
+ function makeButton({ content, onPress }) {
294
418
  const button = blessed.button({
295
419
  parent: actionsShell,
296
420
  top: '0%',
297
421
  left: '0%',
298
422
  width: '47%',
299
- height: '23%',
423
+ height: large ? 3 : 1,
300
424
  shrink: true,
301
425
  content,
302
426
  align: 'center',
@@ -305,57 +429,55 @@ function launchDashboard(config, options = {}) {
305
429
  mouse: true,
306
430
  clickable: true,
307
431
  autoFocus: false,
432
+ padding: large ? { left: 1, right: 1 } : {},
433
+ border: large ? { type: 'line', fg: THEME.border } : undefined,
308
434
  style: {
309
- fg: fg || THEME.text,
310
- bg: THEME.bg,
311
- focus: { bg: focusBg || THEME.accentBg, fg: focusFg || THEME.accentFg, bold: true },
312
- hover: { bg: focusBg || THEME.accentBg, fg: focusFg || THEME.accentFg, bold: true },
435
+ fg: THEME.text,
436
+ bg: THEME.chipBg,
437
+ focus: { bg: THEME.accentBg, fg: THEME.accentFg, bold: true },
438
+ hover: { bg: THEME.accentBg, fg: THEME.accentFg, bold: true },
439
+ border: large ? { fg: THEME.border } : undefined,
313
440
  },
314
441
  });
315
442
 
316
443
  button.on('press', () => {
444
+ let handled = false;
317
445
  try {
318
- onPress();
446
+ handled = onPress() === true;
319
447
  } catch (err) {
320
448
  setStatus(`Error: ${err.message}`);
321
449
  } finally {
322
- projectList.focus();
323
- screen.render();
450
+ if (!handled && !gitModal) {
451
+ projectList.focus();
452
+ screen.render();
453
+ }
324
454
  }
325
455
  });
326
456
  return button;
327
457
  }
328
458
 
329
459
  const buttons = {};
330
- const BUTTON_SLOTS = new Map();
331
460
 
332
- /**
333
- * 2-col x 4-row button grid nested inside the ACTIONS pane. Geometry is
334
- * computed from the pane's exact row count so buttons never clip borders,
335
- * even on an 80x24 terminal.
336
- */
337
461
  function addButton(name, slot, content, opts) {
338
462
  const button = makeButton({ content, ...opts });
339
- BUTTON_SLOTS.set(button, slot);
340
- const inner = Math.max(1, actionsHeight - 2);
341
- const rowH = Math.max(1, Math.floor(inner / 4));
342
- const pad = Math.max(0, Math.floor((inner - 4 * rowH) / 2));
343
- button.top = 1 + pad + slot.row * rowH;
463
+ const rowH = large ? 3 : 1;
464
+ button.top = 1 + slot.row * rowH;
344
465
  button.height = rowH;
345
- button.left = slot.col === 0 ? '2%' : '52%';
346
- button.width = '46%';
466
+ button.left = slot.col === 0 ? '2%' : '51%';
467
+ button.width = '47%';
347
468
  buttons[name] = button;
348
469
  return button;
349
470
  }
350
471
 
351
- addButton('dev', { row: 0, col: 0 }, '{bold}[r]{/bold} Run dev server', { fg: STATUS_FG.live, onPress: () => startDevServer() });
352
- addButton('editor', { row: 0, col: 1 }, '{bold}[e]{/bold} Open in editor', { fg: THEME.text, onPress: () => openTool('editor') });
353
- addButton('claude', { row: 1, col: 0 }, `{bold}[c]{/bold} ${AGENT_LABELS.claude}`, { fg: '#f5c2e7', onPress: () => openTool('claude') });
354
- addButton('codex', { row: 1, col: 1 }, `{bold}[x]{/bold} ${AGENT_LABELS.codex}`, { fg: '#94e2d5', onPress: () => openTool('codex') });
355
- addButton('opencode', { row: 2, col: 0 }, `{bold}[o]{/bold} ${AGENT_LABELS.opencode}`, { fg: STATUS_FG.pend, onPress: () => openTool('opencode') });
356
- addButton('freebuff', { row: 2, col: 1 }, `{bold}[f]{/bold} ${AGENT_LABELS.freebuff}`, { fg: STATUS_FG.exp, onPress: () => openTool('freebuff') });
357
- addButton('kilocode', { row: 3, col: 0 }, `{bold}[k]{/bold} ${AGENT_LABELS.kilocode}`, { fg: '#cba6f7', onPress: () => openTool('kilocode') });
358
- addButton('status', { row: 3, col: 1 }, '{bold}[s]{/bold} Change status', { fg: STATUS_FG.pend, onPress: () => cycleStatus() });
472
+ addButton('dev', { row: 0, col: 0 }, `{bold}[r]{/bold} Run dev server`, { onPress: () => startDevServer() });
473
+ addButton('editor', { row: 0, col: 1 }, `{bold}[e]{/bold} Open in editor`, { onPress: () => openTool('editor') });
474
+ addButton('claude', { row: 1, col: 0 }, `{bold}[c]{/bold} ${AGENT_LABELS.claude}`, { onPress: () => openTool('claude') });
475
+ addButton('codex', { row: 1, col: 1 }, `{bold}[x]{/bold} ${AGENT_LABELS.codex}`, { onPress: () => openTool('codex') });
476
+ addButton('opencode', { row: 2, col: 0 }, `{bold}[o]{/bold} ${AGENT_LABELS.opencode}`, { onPress: () => openTool('opencode') });
477
+ addButton('freebuff', { row: 2, col: 1 }, `{bold}[f]{/bold} ${AGENT_LABELS.freebuff}`, { onPress: () => openTool('freebuff') });
478
+ addButton('kilocode', { row: 3, col: 0 }, `{bold}[k]{/bold} ${AGENT_LABELS.kilocode}`, { onPress: () => openTool('kilocode') });
479
+ addButton('status', { row: 3, col: 1 }, `{bold}[s]{/bold} Change status`, { onPress: () => cycleStatus() });
480
+ addButton('git', { row: 4, col: 0 }, `{bold}[g]{/bold} Git commit & push \u25b8 git`, { onPress: openGitCommitModal });
359
481
 
360
482
  // Created after the buttons so tab-focus order is list -> actions -> output.
361
483
  const logBox = contrib.log({
@@ -371,14 +493,16 @@ function launchDashboard(config, options = {}) {
371
493
  border: { type: 'line', fg: THEME.border },
372
494
  style: { bg: THEME.surface, item: { fg: THEME.text }, selected: { fg: THEME.text, bg: '#313244' } },
373
495
  });
374
- panel(logBox, ' OUTPUT (dev server / agents) ');
496
+ panel(logBox, ' OUTPUT (dev server / agents) [ON] ');
497
+ const bufferHeader = paneHeaderRight(logBox, ` BUFFER: 1024L {${STATUS_FG.live}-fg}STREAM ACTIVE{/${STATUS_FG.live}-fg} `);
375
498
 
376
499
  const logView = new LogView(logBox, {
377
500
  maxLines: 800,
378
501
  flushInterval: 120,
379
502
  viewportHeight: () => Math.max(1, (typeof logBox.height === 'number' ? logBox.height : screen.rows) - 2),
380
503
  onChange: () => screen.render(),
381
- label: ' OUTPUT (dev server / agents) autoscroll [ON] ',
504
+ onLabelChange: (label) => bufferHeader.refresh(label),
505
+ label: ' OUTPUT (dev server / agents) [ON] ',
382
506
  });
383
507
 
384
508
  /* ---------------------------------------------------------------- *
@@ -421,37 +545,39 @@ function launchDashboard(config, options = {}) {
421
545
  return counts;
422
546
  }
423
547
 
424
- function filterChips() {
548
+ function chipData() {
425
549
  const counts = modernCounts();
426
- const chip = (label, count, color, key) => {
427
- if (count <= 0) return '';
428
- const active = status.chip === key;
429
- return `{${color}-fg}${active ? '{bold}' : ''}[${label} ${count}]${active ? '{/bold}' : ''}{/${color}-fg}`;
430
- };
431
- const allActive = status.chip === null;
432
- return [
433
- `{${THEME.text}-fg}${allActive ? '{bold}' : ''}[ALL ${projects.length}]${allActive ? '{/bold}' : ''}{/${THEME.text}-fg}`,
434
- chip('LIVE', counts.live, STATUS_FG.live, 'live'),
435
- chip('EXP', counts.exp, STATUS_FG.exp, 'exp'),
436
- chip('PEND', counts.pend, STATUS_FG.pend, 'pend'),
437
- chip('UNKNOWN', counts.unknown, STATUS_FG.unknown, 'unknown'),
438
- chip('SCRAP', counts.scrap, STATUS_FG.scrap, 'scrap'),
439
- ].filter(Boolean).join(' ');
550
+ const activeKey = status.chip || 'all';
551
+ return CHIP_ORDER.map((key) => {
552
+ const count = key === 'all' ? projects.length : counts[key] || 0;
553
+ return { key, label: CHIP_LABELS[key], count, active: key === activeKey, hidden: key !== 'all' && count <= 0 };
554
+ });
440
555
  }
441
556
 
442
557
  function searchLabel() {
443
- if (searchActive) return `/search: ${searchBuffer}`;
444
- return status.search ? `/search: ${status.search}` : '/search (regex)';
558
+ if (searchActive) return `/search (regex): ${searchBuffer}`;
559
+ return status.search ? `/search (regex): ${status.search}` : '/search (regex)';
445
560
  }
446
561
 
447
562
  function updateHeader() {
448
- const stats = filterChips();
449
- const search = `{${THEME.textDim}-fg}${escapeBraces(searchLabel())}{/${THEME.textDim}-fg}`;
450
- statsBar.setContent(` ${stats} ${search} `);
451
- // formatTimestamp() renders "Sep 21, 2026 3:30:59 PM"; fall back to the
452
- // platform formatter only if the custom 12-hour/date renderer ever fails.
453
- const clock = formatTimestamp(new Date()) || new Date().toLocaleTimeString();
454
- titleBox.setContent(`{center}{bold}${TITLE_TEXT}{/bold}{/center}\n{center}${escapeBraces(clock)}{/center}`);
563
+ clockLabel.setContent(` ${h24Time()}`);
564
+ const busy = servers.runningCount > 0 || activeTails() > 0;
565
+ daemonLabel.setContent(busy
566
+ ? `{${STATUS_FG.live}-fg}\u25cf DAEMON ON{/${STATUS_FG.live}-fg}`
567
+ : `{${THEME.textDim}-fg}\u25cb DAEMON OFF{/${THEME.textDim}-fg}`);
568
+
569
+ chipData().forEach((chip, index) => {
570
+ const box = chipBoxes[index];
571
+ if (box.hidden === chip.hidden && box.content === `${chip.label} ${chip.count}` && box.active === chip.active) return;
572
+ box.hidden = chip.hidden;
573
+ box.setContent(`${chip.label} ${chip.count}`);
574
+ box.style.bg = chip.active ? THEME.accentBg : THEME.bg;
575
+ box.style.fg = chip.active ? THEME.accentFg : (CHIP_COLORS[chip.key] || THEME.text);
576
+ box.style.bold = chip.key === 'all';
577
+ box.active = chip.active;
578
+ });
579
+
580
+ searchBox.setContent(escapeBraces(searchLabel()));
455
581
  }
456
582
 
457
583
  /** Projects after the chip (status) + search (regex on name) filters. */
@@ -476,16 +602,16 @@ function launchDashboard(config, options = {}) {
476
602
 
477
603
  function listItems() {
478
604
  const inner = Math.max(12, Math.floor(screen.cols * 0.4) - 4);
479
- const nameWidth = Math.max(6, inner - 12);
480
605
  return filteredProjects().map((project) => {
481
606
  const state = runStates.get(project.path);
482
607
  const running = state && (state.status === 'running' || state.status === 'starting');
483
608
  const modern = modernStatusOf(project);
484
609
  const dotFg = running ? (state.status === 'running' ? STATUS_FG.live : STATUS_FG.exp) : statusFg(modern);
485
- const name = escapeBraces(truncate(project.name, nameWidth));
610
+ const name = escapeBraces(truncate(project.name, Math.max(6, inner - 14)));
486
611
  const info = gitInfo.get(project.path);
487
- const activity = escapeBraces(truncate(project.lastActivity || timeAgo(info && info.lastCommitAt) || '\u2014', 10));
488
- return `{${dotFg}-fg}●{/${dotFg}-fg} ${name} {${THEME.textDim}-fg}${activity}{/${THEME.textDim}-fg}`;
612
+ const activity = escapeBraces(String(truncate(project.lastActivity || timeAgo(info && info.lastCommitAt) || '\u2014', 10)));
613
+ const pads = ' '.repeat(Math.max(0, inner - name.length - activity.length - 3));
614
+ return `{${dotFg}-fg}\u25cf{/${dotFg}-fg} ${name}${pads} {${THEME.textDim}-fg}${activity}{/${THEME.textDim}-fg}`;
489
615
  });
490
616
  }
491
617
 
@@ -517,24 +643,19 @@ function launchDashboard(config, options = {}) {
517
643
  const modern = modernStatusOf(project);
518
644
  const sFg = statusFg(modern);
519
645
  const inner = Math.max(24, Math.floor(screen.cols * 0.6) - 4);
520
- const runPid = state && state.pid ? state.pid : null;
521
646
  const info = gitInfo.get(project.path);
522
647
  const stats = processStats.get(project.path);
523
- const pidLabel = runPid
524
- ? String(runPid)
525
- : stats && stats.pid
526
- ? String(stats.pid)
527
- : config.demoMode && project.port
528
- ? `${DEMO_PID} {${THEME.textDim}-fg}(demo){/${THEME.textDim}-fg}`
529
- : '\u2014';
530
648
  const memCpu = stats && stats.memory
531
649
  ? `${escapeBraces(stats.memory)} \u00b7 ${escapeBraces(stats.cpu || '\u2014')}`
532
650
  : config.demoMode
533
651
  ? `213.4 MB \u00b7 0.8% {${THEME.textDim}-fg}(demo){/${THEME.textDim}-fg}`
534
652
  : '\u2014';
535
653
  const dirty = info && info.dirty ? info.dirty : { added: 0, removed: 0 };
536
- const dirtyLabel = dirty.added || dirty.removed
537
- ? ` {#f38ba8-fg}+${dirty.added}{/#f38ba8-fg}{#89b4fa-fg} -${dirty.removed}{/#89b4fa-fg}`
654
+ const dirtyLabel = dirty.added
655
+ ? ` {${STATUS_FG.live}-fg}+${dirty.added}{/${STATUS_FG.live}-fg}`
656
+ : '';
657
+ const dirtyLabel2 = dirty.removed
658
+ ? ` {#f38ba8-fg}-${dirty.removed}{/#f38ba8-fg}`
538
659
  : '';
539
660
  const branch = (info && info.branch) || project.branch || '\u2014';
540
661
  const hash = (info && info.commitHash) || (project.lastCommit && project.lastCommit.hash) || '';
@@ -550,21 +671,34 @@ function launchDashboard(config, options = {}) {
550
671
  ? `{${STATUS_FG.unknown}-fg}{bold}[?] Unknown{/bold}{/${STATUS_FG.unknown}-fg}`
551
672
  : `{${sFg}-fg}{bold}[${modern.toUpperCase()}]{/bold} ${fullLabel}{/${sFg}-fg}`;
552
673
 
674
+ // Escapes plain values for blessed markup; set `raw` for values that already
675
+ // carry blessed tags (branch colors, mem/cpu demo suffix).
676
+ const row = (label, value, raw) => {
677
+ const v = truncate(value, Math.max(4, inner - label.length - 2));
678
+ return ` {${THEME.textDim}-fg}${label}{/${THEME.textDim}-fg} ${raw ? v : escapeBraces(v)}`;
679
+ };
680
+ const selector = `{${STATUS_FG.live}-fg}${escapeBraces(truncate(branch, 30))}{/${STATUS_FG.live}-fg}`;
681
+
553
682
  const lines = [
554
- ` {${sFg}-fg}●{/${sFg}-fg} {bold}${escapeBraces(truncate(project.name, 40))}{/bold}`,
683
+ ` {${sFg}-fg}\u25cf{/${sFg}-fg} {bold}${escapeBraces(truncate(project.name, 40))}{/bold}`,
555
684
  ` {${THEME.textDim}-fg}${escapeBraces(truncate(project.info || '(no description)', inner - 2))}{/${THEME.textDim}-fg}`,
556
- ` {${THEME.textDim}-fg}Path:{/${THEME.textDim}-fg} ${escapeBraces(truncate(displayPath(project.path, config.root), inner - 8))}`,
557
- ` {${THEME.textDim}-fg}Status:{/${THEME.textDim}-fg} ${statusChip} {${THEME.textDim}-fg}Branch:{/${THEME.textDim}-fg} {${STATUS_FG.live}-fg}${escapeBraces(truncate(branch, 30))}{/${STATUS_FG.live}-fg}${dirtyLabel}`,
558
- ` {${THEME.textDim}-fg}Dev port:{/${THEME.textDim}-fg} ${project.port || '\u2014'} {${THEME.textDim}-fg}PID:{/${THEME.textDim}-fg} ${pidLabel}`,
559
- ` {${THEME.textDim}-fg}Package mgr:{/${THEME.textDim}-fg} ${escapeBraces(String(project.packageManager || '\u2014'))}`,
560
- ` {${THEME.textDim}-fg}Stack:{/${THEME.textDim}-fg} ${escapeBraces(truncate(project.stack || '\u2014', inner - 12))}`,
561
- ` {${THEME.textDim}-fg}Mem/CPU:{/${THEME.textDim}-fg} ${memCpu}`,
562
- ` {${THEME.textDim}-fg}Last commit:{/${THEME.textDim}-fg} ${escapeBraces(truncate(commit, inner - 16))}`,
685
+ ` {${THEME.textDim}-fg}Status:{/${THEME.textDim}-fg} ${statusChip}`,
686
+ row('Path:', displayPath(project.path, config.root)),
687
+ row('Branch:', `${selector}${dirtyLabel}${dirtyLabel2}`, true),
688
+ row('Package mgr:', String(project.packageManager || '\u2014')),
689
+ row('Stack:', stackLabel(project)),
690
+ row('Mem/CPU:', memCpu, true),
691
+ row('Last commit:', commit),
563
692
  devStateLine(project, state),
564
693
  ];
565
694
 
566
695
  card.setContent(lines.join('\n'));
567
696
  card.setLabel(` DETAILS: ${project.name} `);
697
+ const dirtyNow = Boolean(dirty && (dirty.added || dirty.removed));
698
+ gitHeader.setContent(dirtyNow
699
+ ? `{#f38ba8-fg}GIT: DIRTY{/#f38ba8-fg}`
700
+ : `{#a6e3a1-fg}GIT: CLEAN{/#a6e3a1-fg}`);
701
+ card.headerLabel = ` DETAILS: ${project.name} `;
568
702
 
569
703
  // Visual cue while the status is unknown: red button asking to be set.
570
704
  if (unknown) {
@@ -580,18 +714,38 @@ function launchDashboard(config, options = {}) {
580
714
  screen.render();
581
715
  }
582
716
 
583
- function buildFooter() {
717
+ function footerHints() {
718
+ if (screen.cols >= 110) return FOOTER_KEYS;
719
+ if (screen.cols >= 90) return '{bold}\u2191\u2193{/bold} navigate {bold}s{/bold} status {bold}r{/bold} dev {bold}shift+x{/bold} stop {bold}q{/bold} quit';
720
+ return '{bold}\u2191\u2193{/bold} navigate {bold}s{/bold} status {bold}r{/bold} dev {bold}q{/bold} quit';
721
+ }
722
+
723
+ function buildFooterRow1() {
724
+ const showing = ` SHOWING ${filteredProjects().length} OF ${projects.length} `;
725
+ const press = 'PRESS [/] FILTER';
726
+ const centerAt = Math.floor(screen.cols / 2) - Math.floor(press.length / 2);
727
+ const pad = Math.max(0, centerAt - showing.length);
728
+ const row = `${showing}${' '.repeat(pad)}${press}`.slice(0, screen.cols);
729
+ return row;
730
+ }
731
+
732
+ function buildFooterRow2() {
584
733
  const sel = selectedProject();
585
734
  const index = sel ? filteredProjects().indexOf(sel) + 1 : 0;
586
735
  const paneLabel = currentPane();
587
736
  const size = `${screen.cols}x${screen.rows}`;
588
737
  const chipLabel = status.chip ? status.chip.toUpperCase() : 'ALL';
589
738
  const search = status.search ? ` /${status.search}` : '';
590
- return ` {${THEME.text}-fg}[${index}/${filteredProjects().length}] SELECTED FILTER: ${chipLabel}${search}{/${THEME.text}-fg} ${FOOTER_KEYS} {${THEME.textDim}-fg}PANE: [${paneLabel}] \u2502 ${size}{/${THEME.textDim}-fg} `;
739
+ const fixed = `[${index}/${filteredProjects().length}] SELECTED FILTER: ${chipLabel}${search}`;
740
+ const hints = ` ${footerHints()} `;
741
+ const tail = ` PANE: [${paneLabel}] \u2502 ${size} `;
742
+ const row = ` ${fixed}${hints}${tail}`.slice(0, screen.cols);
743
+ return row;
591
744
  }
592
745
 
593
746
  function updateFooter() {
594
- footer.setContent(buildFooter());
747
+ footerMeta.setContent(buildFooterRow1());
748
+ footer.setContent(buildFooterRow2());
595
749
  }
596
750
 
597
751
  function currentPane() {
@@ -622,6 +776,7 @@ function launchDashboard(config, options = {}) {
622
776
  function setStatus(message) {
623
777
  if (status.timer) clearTimeout(status.timer);
624
778
  status.message = message;
779
+ footerMeta.setContent(buildFooterRow1());
625
780
  footer.setContent(` {bold}${escapeBraces(message)}{/bold}`);
626
781
  screen.render();
627
782
 
@@ -635,13 +790,18 @@ function launchDashboard(config, options = {}) {
635
790
 
636
791
  /** Log lines from child processes are raw text -> escape blessed markup. */
637
792
  function appendLog(project, line, stream = 'stdout') {
638
- const fg = colorFor(project);
639
- const prefix = `{${THEME.textDim}-fg}${timestamp()}{/${THEME.textDim}-fg} {${fg}-fg}${escapeBraces(truncate(project.name, 10))}{/${fg}-fg}`;
793
+ const time = `{${THEME.textDim}-fg}${timestamp()}{/${THEME.textDim}-fg}`;
640
794
  if (stream === 'system') {
641
- logView.push(`${prefix} {#89b4fa-fg}[termdeck]{/#89b4fa-fg} ${line}`);
795
+ // Give generic notes a cyan [termdeck] tag; lines that already carry their
796
+ // own [tag] keep it.
797
+ const visible = String(line).replace(/^(?:\{[^{}]*\})*/, '').replace(/^\s+/, '');
798
+ const tag = visible.startsWith('[') ? '' : `{${THEME.tagCyan}-fg}[termdeck]{/${THEME.tagCyan}-fg} `;
799
+ logView.push(`${time} ${tag}${line}`);
642
800
  return;
643
801
  }
644
- const marker = stream === 'stderr' ? '{#f38ba8-fg}✗{/#f38ba8-fg} ' : '';
802
+ const fg = colorFor(project);
803
+ const prefix = `{${THEME.textDim}-fg}${timestamp()}{/${THEME.textDim}-fg} ${escapeBraces(truncate(project.name, 10))}`;
804
+ const marker = stream === 'stderr' ? '{#f38ba8-fg}\u2717{/#f38ba8-fg} ' : '';
645
805
  logView.push(`${prefix} ${marker}${escapeBraces(line)}`);
646
806
  }
647
807
 
@@ -649,6 +809,187 @@ function launchDashboard(config, options = {}) {
649
809
  * Actions
650
810
  * ---------------------------------------------------------------- */
651
811
 
812
+ function getProjectStack(project) {
813
+ if (!project) return '\u2014';
814
+ if (project.stack) return project.stack;
815
+ if (config.demoMode) return '\u2014';
816
+ if (!stackInfo.has(project.path)) stackInfo.set(project.path, detectStack(project.path));
817
+ return stackInfo.get(project.path) || '\u2014';
818
+ }
819
+
820
+ /** Stack shown as comma-separated names, even when config used dashes. Plain
821
+ * audit text — the row() helper escapes it for blessed markup. */
822
+ function stackLabel(project) {
823
+ return String(getProjectStack(project) || '\u2014').replace(/\s*-\s*/g, ', ');
824
+ }
825
+
826
+ function refreshProjectStack(project) {
827
+ if (!project || config.demoMode || project.stack) return;
828
+ const stack = detectStack(project.path);
829
+ stackInfo.set(project.path, stack);
830
+ if (selectedProject() === project) updateCard();
831
+ }
832
+
833
+ function openGitCommitModal() {
834
+ const project = selectedProject();
835
+ if (!project) return false;
836
+ if (gitModal) {
837
+ gitModal.textbox.focus();
838
+ return true;
839
+ }
840
+
841
+ let generated;
842
+ try {
843
+ generated = generateCommitMessage(project.path);
844
+ } catch (_) {
845
+ generated = 'Update project files';
846
+ }
847
+ if (generated === null) {
848
+ appendLog(project, `{${STATUS_FG.exp}-fg}⚠ No changes to commit.{/${STATUS_FG.exp}-fg}`, 'system');
849
+ setStatus(`${project.name}: No changes to commit.`);
850
+ return true;
851
+ }
852
+
853
+ const width = Math.max(32, Math.min(72, screen.cols - 4));
854
+ const height = Math.max(10, Math.min(14, screen.rows - 4));
855
+ const modal = blessed.box({
856
+ parent: screen,
857
+ top: 'center',
858
+ left: 'center',
859
+ width,
860
+ height,
861
+ tags: true,
862
+ keys: true,
863
+ border: { type: 'line', fg: THEME.border },
864
+ style: { bg: THEME.surface, fg: THEME.text },
865
+ });
866
+ modal.setLabel(' Git Commit Message ');
867
+ blessed.text({
868
+ parent: modal,
869
+ top: 1,
870
+ left: 2,
871
+ width: width - 4,
872
+ height: 1,
873
+ tags: true,
874
+ content: 'Press Enter to commit & push, Esc to cancel',
875
+ style: { fg: THEME.textDim },
876
+ });
877
+ const textbox = blessed.textbox({
878
+ parent: modal,
879
+ top: 3,
880
+ left: 2,
881
+ width: width - 4,
882
+ height: Math.max(3, height - 7),
883
+ keys: true,
884
+ mouse: true,
885
+ value: generated || 'Update project files',
886
+ style: {
887
+ fg: THEME.text,
888
+ bg: THEME.surface,
889
+ focus: { fg: THEME.text, bg: '#282838' },
890
+ },
891
+ });
892
+ const hint = blessed.text({
893
+ parent: modal,
894
+ bottom: 1,
895
+ left: 2,
896
+ width: width - 4,
897
+ height: 1,
898
+ tags: true,
899
+ content: "Press 'a' to auto-generate, or edit manually. Enter to commit, Esc to cancel.",
900
+ style: { fg: THEME.textDim },
901
+ });
902
+
903
+ const originalListener = textbox._listener;
904
+ textbox._listener = function(ch, key) {
905
+ if ((key.name === 'a' || key.name === 'A') && !key.ctrl && !key.meta) {
906
+ let next;
907
+ try {
908
+ next = generateCommitMessage(project.path);
909
+ } catch (_) {
910
+ next = 'Update project files';
911
+ }
912
+ if (next === null) {
913
+ appendLog(project, `{${STATUS_FG.exp}-fg}⚠ No changes to commit.{/${STATUS_FG.exp}-fg}`, 'system');
914
+ hint.setContent("Press 'a' to auto-generate, or edit manually. Enter to commit, Esc to cancel.");
915
+ } else {
916
+ textbox.setValue(next || 'Update project files');
917
+ hint.setContent("Auto-generated. Press 'a' to regenerate, or edit manually. Enter to commit, Esc to cancel.");
918
+ }
919
+ screen.render();
920
+ return;
921
+ }
922
+ return originalListener.call(this, ch, key);
923
+ };
924
+
925
+ function closeGitModal() {
926
+ if (!gitModal) return;
927
+ gitModal = null;
928
+ try { modal.destroy(); } catch (_) {}
929
+ try { projectList.focus(); } catch (_) {}
930
+ try { screen.render(); } catch (_) {}
931
+ }
932
+
933
+ function finish(value) {
934
+ closeGitModal();
935
+ if (value == null) {
936
+ setStatus(`${project.name}: Git commit cancelled.`);
937
+ return;
938
+ }
939
+ const message = String(value).trim();
940
+ if (!message) {
941
+ appendLog(project, `{${STATUS_FG.exp}-fg}⚠ Commit message cannot be empty.{/${STATUS_FG.exp}-fg}`, 'system');
942
+ setStatus(`${project.name}: Commit message cannot be empty.`);
943
+ return;
944
+ }
945
+ setImmediate(() => runGitCommit(project, message));
946
+ }
947
+
948
+ textbox.on('submit', finish);
949
+ textbox.on('cancel', () => finish(null));
950
+ gitModal = { modal, textbox, close: closeGitModal };
951
+ textbox.readInput();
952
+ screen.render();
953
+ return true;
954
+ }
955
+
956
+ function runGitCommit(project, message) {
957
+ logView.followTail();
958
+ appendLog(project, `{${STATUS_FG.live}-fg}[git]{/${STATUS_FG.live}-fg} committing and pushing…`, 'system');
959
+ let result;
960
+ try {
961
+ result = commitAndPush(project.path, message, {
962
+ onOutput: (line, stream) => appendLog(project, line, stream),
963
+ });
964
+ } catch (err) {
965
+ appendLog(project, `{#f38ba8-fg}✗ Git operation failed: ${escapeBraces(err.message)}{/#f38ba8-fg}`, 'system');
966
+ setStatus(`${project.name}: Git operation failed: ${err.message}`);
967
+ refreshProjectGit(project, { force: true });
968
+ return;
969
+ }
970
+
971
+ if (result.warning) {
972
+ appendLog(project, `{${STATUS_FG.exp}-fg}⚠ ${escapeBraces(result.warning)}{/${STATUS_FG.exp}-fg}`, 'system');
973
+ setStatus(`${project.name}: ${result.warning}`);
974
+ return;
975
+ }
976
+ if (!result.ok) {
977
+ appendLog(project, `{#f38ba8-fg}✗ ${escapeBraces(result.error)}{/#f38ba8-fg}`, 'system');
978
+ setStatus(`${project.name}: ${result.error}`);
979
+ refreshProjectGit(project, { force: true });
980
+ return;
981
+ }
982
+
983
+ const fileCount = result.fileCount || 1;
984
+ appendLog(
985
+ project,
986
+ `{${STATUS_FG.live}-fg}✓ [git] Committed and pushed ${fileCount} files: "${escapeBraces(result.message)}"{/${STATUS_FG.live}-fg}`,
987
+ 'system'
988
+ );
989
+ setStatus(`${project.name}: committed and pushed.`);
990
+ refreshProjectGit(project, { force: true });
991
+ }
992
+
652
993
  function startDevServer() {
653
994
  const project = selectedProject();
654
995
  if (!project) return;
@@ -711,7 +1052,7 @@ function launchDashboard(config, options = {}) {
711
1052
 
712
1053
  // Agent: launch in a new terminal with log capture via tee where possible.
713
1054
  const agentName = AGENT_LABELS[kind] || kind;
714
- appendLog(project, `{#89b4fa-fg}[${escapeBraces(kind)}]{/#89b4fa-fg} launching ${escapeBraces(agentName)} in a new terminal`, 'system');
1055
+ appendLog(project, `{${STATUS_FG.exp}-fg}[${escapeBraces(kind)}]{/${STATUS_FG.exp}-fg} launching ${escapeBraces(agentName)} in a new terminal`, 'system');
715
1056
  setStatus(`Launching ${agentName} for ${project.name}\u2026`);
716
1057
  const result = await launchAgent(project, kind);
717
1058
  if (result.ok) {
@@ -736,7 +1077,7 @@ function launchDashboard(config, options = {}) {
736
1077
  const index = MODERN_STATUSES.indexOf(current);
737
1078
  const next = MODERN_STATUSES[(index + 1) % MODERN_STATUSES.length];
738
1079
  project.status = next;
739
- appendLog(project, `{#89b4fa-fg}[termdeck]{/#89b4fa-fg} status changed to {bold}${next}{/bold}`, 'system');
1080
+ appendLog(project, `{${THEME.tagCyan}-fg}[termdeck]{/${THEME.tagCyan}-fg} status changed to {bold}${next}{/bold}`, 'system');
740
1081
  setStatus(`${project.name}: status \u2192 ${next}`);
741
1082
  if (!config.demoMode) {
742
1083
  try { saveConfig(config); } catch (_) { /* best effort */ }
@@ -771,6 +1112,7 @@ function launchDashboard(config, options = {}) {
771
1112
  }
772
1113
 
773
1114
  function destroy() {
1115
+ if (gitModal) gitModal.close();
774
1116
  if (status.timer) clearTimeout(status.timer);
775
1117
  if (status.quitTimer) clearTimeout(status.quitTimer);
776
1118
  if (status.clock) clearInterval(status.clock);
@@ -807,9 +1149,10 @@ function launchDashboard(config, options = {}) {
807
1149
  * Git / process-monitor refresh on selection
808
1150
  * ---------------------------------------------------------------- */
809
1151
 
810
- function refreshProjectGit(project) {
1152
+ function refreshProjectGit(project, { force = false } = {}) {
811
1153
  if (!project || config.demoMode) return;
812
- const info = getGitInfo(project.path);
1154
+ refreshProjectStack(project);
1155
+ const info = getGitInfo(project.path, { force });
813
1156
  gitInfo.set(project.path, info);
814
1157
  if (selectedProject() === project) {
815
1158
  refreshList();
@@ -868,6 +1211,7 @@ function launchDashboard(config, options = {}) {
868
1211
  screen.key(['o'], () => { if (!searchActive) openTool('opencode'); });
869
1212
  screen.key(['f'], () => { if (!searchActive) openTool('freebuff'); });
870
1213
  screen.key(['k'], () => { if (!searchActive) openTool('kilocode'); });
1214
+ screen.key(['g'], () => { if (!searchActive) openGitCommitModal(); });
871
1215
  screen.key(['S-x'], () => { if (!searchActive) stopDevServer(); });
872
1216
  screen.key(['j'], () => { if (!searchActive) projectList.down(1); });
873
1217
  screen.key(['tab'], () => { if (!searchActive) screen.focusNext(); });
@@ -967,7 +1311,7 @@ function launchDashboard(config, options = {}) {
967
1311
  projectList.focus();
968
1312
  refreshList();
969
1313
  updateCard();
970
- appendLog({ name: 'termdeck', path: '__termdeck__' }, `{bold}termdeck{/bold} ready — ${projects.length} projects from ${escapeBraces(displayPath(config.root, config.root))}`, 'system');
1314
+ appendLog({ name: 'termdeck', path: '__termdeck__' }, `{bold}termdeck{/bold} ready - ${projects.length} projects discovered from ${escapeBraces(displayPath(config.root, config.root))}`, 'system');
971
1315
  appendLog({ name: 'termdeck', path: '__termdeck__' }, `pick a project and press {bold}r{/bold} for the dev server, {bold}e{/bold} for your editor, {bold}c/x/o/f/k{/bold} for an agent.`, 'system');
972
1316
  if (config.demoMode) {
973
1317
  const demoProject = projects[0] || { name: 'hyperion-core', path: 'demo' };
@@ -1013,7 +1357,7 @@ function launchDashboard(config, options = {}) {
1013
1357
 
1014
1358
  return {
1015
1359
  screen,
1016
- widgets: { header: titleBox, title: titleBox, statsBar, projectList, card, logBox, footer, buttons },
1360
+ widgets: { header: titleBox, title: titleBox, statsBar: chipStrip, projectList, card, logBox, footer, buttons },
1017
1361
  servers,
1018
1362
  logView,
1019
1363
  runStates,