termdeck-cli 2.0.3 → 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
@@ -3,20 +3,24 @@
3
3
  /**
4
4
  * The termdeck TUI.
5
5
  *
6
- * 12x12 blessed-contrib grid layout:
6
+ * Compact "email client" layout — hand-positioned widgets so every pane is
7
+ * exactly the rows it needs, with zero gaps between panes:
7
8
  *
8
9
  * +-------------------------------------------------------------------+
9
- * | header: termdeck · [ALL 14][LIVE 6]… /search · time · ● DAEMON OFF |
10
+ * | 15:14:19 +------------+ ● DAEMON ON |
11
+ * | | TERMDECK | |
12
+ * | +------------+ |
13
+ * | [ALL 14] [LIVE 6] [EXP 4] … /search (regex) [box] |
10
14
  * +---------------------------------+---------------------------------+
11
- * | PROJECTS (14 repos) | DETAILS: hyperion-core … |
12
- * | ● hyperion-core 12m ago | path / status / branch / port |
13
- * | ● atlas-engine 2h ago +---------------------------------+
14
- * | … | ACTIONS (r/e/c/x/o/f/k/s) |
15
+ * | PROJECTS (14) | DETAILS: hyperion-core |
16
+ * | ● hyperion-core 12m ago | status / path / branch / port |
17
+ * | ● atlas-engine 2h ago +---------------------------------+
18
+ * | … | ACTIONS [c] Claude Code … |
15
19
  * | +---------------------------------+
16
- * | | OUTPUT (dev server / agents) |
17
- * | | 21:04:12 ✓ vite ready 3000 |
20
+ * | | OUTPUT (dev server / agents) |
21
+ * | | 21:04:12 ✓ vite ready 3000 |
18
22
  * +---------------------------------+---------------------------------+
19
- * | footer: [1/14] selected · keys · tab switch pane · q quit |
23
+ * | [1/14] SELECTED · keys · PANE: [PROJECTS] |
20
24
  * +-------------------------------------------------------------------+
21
25
  *
22
26
  * The controller object returned by launchDashboard() keeps the same shape
@@ -26,23 +30,58 @@
26
30
  * inside the ACTIONS cell but stay real blessed buttons (mouse + tab focus).
27
31
  */
28
32
 
29
- const path = require('path');
30
-
31
- const blessed = require('blessed');
33
+ const path = require('path');const blessed = require('blessed');
32
34
  const contrib = require('blessed-contrib');
33
35
 
34
36
  const { DevServerManager } = require('./devServer');
35
37
  const { openInNewTerminal } = require('./terminal');
36
38
  const { LogView } = require('./logView');
37
- const { STATUS_COLORS, MODERN_STATUSES, loadConfig, loadConfigFromPath, displayPath, saveConfig } = require('./config');
38
- const { escapeBraces, truncate, timestamp, timeAgo } = require('./util');
39
- const { getGitInfo } = require('./projectManager');
40
- const { AGENT_COMMANDS, launchAgent, tailAgentLog, stopAllAgents } = require('./agentManager');
39
+ const { MODERN_STATUSES, loadConfig, loadConfigFromPath, displayPath, saveConfig } = require('./config');
40
+ const { escapeBraces, truncate, formatTimestamp, timestamp, timeAgo } = require('./util');
41
+ const { getGitInfo, detectStack, generateCommitMessage, commitAndPush } = require('./projectManager');
42
+ const { launchAgent, tailAgentLog, stopAllAgents, activeTails } = require('./agentManager');
41
43
  const { startMonitoring, stopMonitoring, stopAllMonitoring } = require('./processMonitor');
42
44
 
43
- const LAYOUT = { rows: 12, cols: 12, headerHeight: 1, footerHeight: 1 };
45
+ const LAYOUT = { rows: 12, cols: 12, headerHeight: 6, footerHeight: 2 };
46
+
47
+ /* ------------------------------------------------------------------ *
48
+ * Theme — dark, modern palette, pastel status tags, thin borders.
49
+ * ------------------------------------------------------------------ */
50
+
51
+ const THEME = {
52
+ bg: '#1e1e2e', // base background (main screen + boxes)
53
+ surface: '#181825', // slightly darker panels (output, footer)
54
+ text: '#cdd6f4', // general text (light gray-white)
55
+ textDim: '#9399b2', // secondary text (timestamps, labels)
56
+ tagCyan: '#94e2d5', // log [termdeck] source tag
57
+ border: '#45475a', // thin, unobtrusive box borders
58
+ accentBg: '#3b82f6', // selected-project / focused-button highlight
59
+ accentFg: '#ffffff',
60
+ chipBg: '#2d2d3f', // action-button / stat-chip background
61
+ };
44
62
 
45
- const PROJECT_COLORS = ['cyan', 'green', 'yellow', 'magenta', 'red', 'white'];
63
+ /** Pastel status colours per modern status; `unknown` is neutral gray. */
64
+ const STATUS_FG = {
65
+ live: '#a6e3a1', // soft green
66
+ exp: '#f9e2af', // soft yellow/orange
67
+ pend: '#89b4fa', // soft blue
68
+ scrap: '#6c7086', // soft gray
69
+ unknown: '#6c7086', // neutral gray for missing statuses
70
+ };
71
+
72
+ /** Full uppercase words shown in the DETAILS pane. */
73
+ const STATUS_FULL = { live: 'LIVE', exp: 'EXPERIMENTAL', pend: 'PENDING', scrap: 'SCRAP' };
74
+
75
+ /** Agent buttons name the agent explicitly instead of a bare key hint. */
76
+ const AGENT_LABELS = {
77
+ claude: 'Claude Code',
78
+ codex: 'Codex',
79
+ opencode: 'OpenCode',
80
+ freebuff: 'Freebuff',
81
+ kilocode: 'Kilocode',
82
+ };
83
+
84
+ const PROJECT_COLORS = ['#89b4fa', '#a6e3a1', '#f9e2af', '#f5c2e7', '#f38ba8', '#94e2d5'];
46
85
 
47
86
  /** Legacy status -> short modern label, used for dots, chips and cycling. */
48
87
  const MODERN_OF = {
@@ -56,16 +95,11 @@ const MODERN_OF = {
56
95
  scrap: 'scrap',
57
96
  };
58
97
 
59
- /** Dot / chip colour per modern status (design spec). */
60
- const DOT_COLORS = { live: 'green', exp: 'yellow', pend: 'blue', scrap: 'gray' };
61
-
62
98
  const DEMO_PID = 49201;
63
99
  const SAMPLE_CONFIG_PATH = path.join(__dirname, '..', 'sample-config.json');
64
100
 
65
- const DEFAULT_AGENT_COMMANDS = { claude: 'claude', codex: 'codex', opencode: 'opencode', freebuff: 'freebuff', kilocode: 'kilocode' };
66
-
67
101
  const FOOTER_KEYS =
68
- '{bold}↑↓{/bold} navigate {bold}tab{/bold} switch pane {bold}s{/bold} status {bold}/{/bold} search {bold}shift+x{/bold} stop dev {bold}r{/bold} run dev {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';
69
103
  const HINTS = ` ${FOOTER_KEYS} `;
70
104
 
71
105
  /** Colour-coded demo log lines so the OUTPUT pane styling can be checked. */
@@ -81,6 +115,22 @@ const SAMPLE_LOG_LINES = [
81
115
  { stream: 'system', line: '{green-fg}✓{/green-fg} cache flush recovered after retry' },
82
116
  ];
83
117
 
118
+ /**
119
+ * Modern short status for a project, or `null` when the status is missing or
120
+ * unrecognised (rendered as "[?] Unknown" instead of guessing).
121
+ */
122
+ function modernStatusOf(project) {
123
+ if (!project) return null;
124
+ const raw = project.status;
125
+ if (raw === null || raw === undefined || raw === '') return null;
126
+ if (String(raw).toLowerCase() === 'unknown') return null;
127
+ return MODERN_OF[raw] || null;
128
+ }
129
+
130
+ function statusFg(modern) {
131
+ return STATUS_FG[modern] || STATUS_FG.unknown;
132
+ }
133
+
84
134
  /**
85
135
  * @param {object} config parsed config (real or the shipped demo dataset)
86
136
  * @param {object} [options]
@@ -105,12 +155,14 @@ function launchDashboard(config, options = {}) {
105
155
  const palette = new Map();
106
156
  const status = { message: null, timer: null, quitArmed: false, quitTimer: null, clock: null, chip: null, search: null };
107
157
  const gitInfo = new Map(); // project.path -> git snapshot (branch / hash / dirty)
158
+ const stackInfo = new Map();
108
159
  const processStats = new Map(); // project.path -> {running, pid, memory, cpu}
109
160
  let monitoredPath = null; // project.path currently polled by processMonitor
110
161
  let searchActive = false;
111
162
  let searchBuffer = '';
163
+ let gitModal = null;
112
164
 
113
- const colorFor = (project) => palette.get(project.path) || 'white';
165
+ const colorFor = (project) => palette.get(project.path) || THEME.text;
114
166
 
115
167
  function rebuildPalette() {
116
168
  palette.clear();
@@ -118,28 +170,185 @@ function launchDashboard(config, options = {}) {
118
170
  }
119
171
  rebuildPalette();
120
172
 
121
- const dotColor = (project) => DOT_COLORS[MODERN_OF[project.status] || 'pend'] || 'gray';
122
- const badgeColor = (project) => STATUS_COLORS[project.status] || dotColor(project);
123
-
124
173
  const configLoader = config.demoMode ? () => loadConfigFromPath(SAMPLE_CONFIG_PATH, {}) : () => loadConfig({});
125
174
 
126
175
  /* ---------------------------------------------------------------- *
127
- * Widgets (blessed-contrib 12x12 grid)
176
+ * Widgets (hand-positioned: exact rows, zero gaps between panes)
128
177
  * ---------------------------------------------------------------- */
129
178
 
130
- const grid = new contrib.grid({ rows: LAYOUT.rows, cols: LAYOUT.cols, screen, color: '#444444' });
179
+ /**
180
+ * Shared look for a bordered panel: dark bg, thin dark-gray border,
181
+ * subtle label. Style is assigned before setLabel on purpose — blessed
182
+ * bakes style.label into the label widget when it is created.
183
+ */
184
+ function panel(el, label) {
185
+ el.style.border = { type: 'line', fg: THEME.border };
186
+ el.style.label = { fg: THEME.textDim };
187
+ el.style.fg = THEME.text;
188
+ el.style.bg = THEME.bg;
189
+ if (label) {
190
+ el.headerLabel = label;
191
+ el.setLabel(label);
192
+ }
193
+ return el;
194
+ }
195
+
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
+ }
131
229
 
132
- function styleCell(el, label) {
133
- el.setLabel(label);
134
- el.style.border = { fg: '#444444' };
135
- el.style.label = { fg: '#aaaaaa' };
136
- el.style.fg = 'white';
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())}`;
137
246
  }
138
247
 
139
- const header = grid.set(0, 0, 1, 12, blessed.box, { tags: true });
140
- styleCell(header, ' termdeck ');
248
+ const masthead = blessed.box({
249
+ parent: screen,
250
+ top: 0,
251
+ left: 0,
252
+ width: '100%',
253
+ height: 3,
254
+ tags: true,
255
+ style: { bg: THEME.bg, fg: THEME.text },
256
+ });
257
+
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
+ });
141
281
 
142
- const projectList = grid.set(1, 0, 10, 5, blessed.list, {
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({
295
+ parent: screen,
296
+ top: CHIP_STRIP_TOP,
297
+ left: 0,
298
+ width: '100%',
299
+ height: 3,
300
+ tags: true,
301
+ style: { bg: THEME.bg, fg: THEME.text },
302
+ });
303
+
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;
345
+
346
+ const projectList = blessed.list({
347
+ parent: screen,
348
+ top: bodyTop,
349
+ left: 0,
350
+ width: '40%',
351
+ height: bodyHeight,
143
352
  tags: true,
144
353
  keys: true,
145
354
  mouse: true,
@@ -147,26 +356,72 @@ function launchDashboard(config, options = {}) {
147
356
  scrollable: true,
148
357
  alwaysScroll: true,
149
358
  items: [],
150
- style: { selected: { bg: 'blue', fg: 'white', bold: true }, item: { fg: 'white', hover: { bg: '#333333' } } },
359
+ border: { type: 'line', fg: THEME.border },
360
+ style: {
361
+ bg: THEME.bg,
362
+ item: { fg: THEME.text, hover: { bg: '#313244' } },
363
+ selected: { bg: THEME.accentBg, fg: THEME.accentFg, bold: true },
364
+ },
365
+ });
366
+ panel(projectList, ' PROJECTS ');
367
+ paneHeaderRight(projectList, ' SORT: RECENT ');
368
+
369
+ const rightLeft = '40%';
370
+ const rightWidth = '60%';
371
+
372
+ const card = blessed.box({
373
+ parent: screen,
374
+ top: bodyTop,
375
+ left: rightLeft,
376
+ width: rightWidth,
377
+ height: cardHeight,
378
+ tags: true,
379
+ scrollable: true,
380
+ mouse: true,
381
+ border: { type: 'line', fg: THEME.border },
382
+ });
383
+ panel(card, ' DETAILS ');
384
+ const gitHeader = paneHeaderRight(card, '{#a6e3a1-fg}GIT: CLEAN{/#a6e3a1-fg}');
385
+
386
+ const actionsShell = blessed.box({
387
+ parent: screen,
388
+ top: bodyTop + cardHeight,
389
+ left: rightLeft,
390
+ width: rightWidth,
391
+ height: actionsHeight,
392
+ tags: true,
393
+ border: { type: 'line', fg: THEME.border },
394
+ });
395
+ panel(actionsShell, ' ACTIONS - r/e/c/x/o/f/k/s or [Enter] ');
396
+ paneHeaderRight(actionsShell, ' KEYMAP: VIM/CLI ');
397
+
398
+ const footerMeta = blessed.box({
399
+ parent: screen,
400
+ top: bodyTop + bodyHeight,
401
+ left: 0,
402
+ width: '100%',
403
+ height: 1,
404
+ tags: true,
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 },
151
415
  });
152
- styleCell(projectList, ' PROJECTS (14 repos) ');
153
-
154
- const card = grid.set(1, 5, 3, 7, blessed.box, { tags: true, scrollable: true, mouse: true });
155
- styleCell(card, ' DETAILS ');
156
-
157
- const actionsShell = grid.set(4, 5, 3, 7, blessed.box, { tags: true });
158
- styleCell(actionsShell, ' ACTIONS — r/e/c/x/o/f/k/s or [Enter] ');
159
-
160
- const footer = grid.set(11, 0, 1, 12, blessed.box, { tags: true, style: { fg: 'white', bg: 'blue' } });
161
416
 
162
- /** One-line action button, nested inside the ACTIONS cell. */
163
- function makeButton({ content, color, onPress }) {
417
+ function makeButton({ content, onPress }) {
164
418
  const button = blessed.button({
165
419
  parent: actionsShell,
166
420
  top: '0%',
167
421
  left: '0%',
168
422
  width: '47%',
169
- height: '24%',
423
+ height: large ? 3 : 1,
424
+ shrink: true,
170
425
  content,
171
426
  align: 'center',
172
427
  valign: 'middle',
@@ -174,58 +429,80 @@ function launchDashboard(config, options = {}) {
174
429
  mouse: true,
175
430
  clickable: true,
176
431
  autoFocus: false,
177
- style: { fg: color, focus: { bg: 'lightwhite', fg: 'black', bold: true }, hover: { bg: 'lightwhite', fg: 'black', bold: true } },
432
+ padding: large ? { left: 1, right: 1 } : {},
433
+ border: large ? { type: 'line', fg: THEME.border } : undefined,
434
+ style: {
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,
440
+ },
178
441
  });
179
442
 
180
443
  button.on('press', () => {
444
+ let handled = false;
181
445
  try {
182
- onPress();
446
+ handled = onPress() === true;
183
447
  } catch (err) {
184
448
  setStatus(`Error: ${err.message}`);
185
449
  } finally {
186
- projectList.focus();
187
- screen.render();
450
+ if (!handled && !gitModal) {
451
+ projectList.focus();
452
+ screen.render();
453
+ }
188
454
  }
189
455
  });
190
456
  return button;
191
457
  }
192
458
 
193
459
  const buttons = {};
194
- function addButton(name, slot, label, color, onPress) {
195
- const button = makeButton({ content: `{bold}[${label}]{/bold} ${label === 'r' ? 'Run dev server' : label === 'e' ? 'Open in editor' : label === 's' ? 'Change status' : `${label.toUpperCase()} agent`}`, color, onPress });
196
- BUTTON_SLOTS.set(button, slot);
197
- button.top = `${slot.row * 25}%`;
198
- button.left = slot.col === 0 ? '1%' : '51%';
460
+
461
+ function addButton(name, slot, content, opts) {
462
+ const button = makeButton({ content, ...opts });
463
+ const rowH = large ? 3 : 1;
464
+ button.top = 1 + slot.row * rowH;
465
+ button.height = rowH;
466
+ button.left = slot.col === 0 ? '2%' : '51%';
467
+ button.width = '47%';
199
468
  buttons[name] = button;
200
469
  return button;
201
470
  }
202
- const BUTTON_SLOTS = new Map();
203
471
 
204
- addButton('dev', { row: 0, col: 0 }, 'r', 'green', () => startDevServer());
205
- addButton('editor', { row: 0, col: 1 }, 'e', 'blue', () => openTool('editor'));
206
- addButton('claude', { row: 1, col: 0 }, 'c', 'cyan', () => openTool('claude'));
207
- addButton('codex', { row: 1, col: 1 }, 'x', 'cyan', () => openTool('codex'));
208
- addButton('opencode', { row: 2, col: 0 }, 'o', 'cyan', () => openTool('opencode'));
209
- addButton('freebuff', { row: 2, col: 1 }, 'f', 'cyan', () => openTool('freebuff'));
210
- addButton('kilocode', { row: 3, col: 0 }, 'k', 'cyan', () => openTool('kilocode'));
211
- addButton('status', { row: 3, col: 1 }, 's', 'yellow', () => 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 });
212
481
 
213
482
  // Created after the buttons so tab-focus order is list -> actions -> output.
214
- const logBox = grid.set(7, 5, 4, 7, contrib.log, {
483
+ const logBox = contrib.log({
484
+ parent: screen,
485
+ top: bodyTop + cardHeight + actionsHeight,
486
+ left: rightLeft,
487
+ width: rightWidth,
488
+ height: logHeight,
215
489
  tags: true,
216
490
  keys: true,
217
491
  mouse: true,
218
492
  bufferLength: 600,
219
- style: { item: { fg: 'white' }, selected: { fg: 'white', bg: 'black' } },
493
+ border: { type: 'line', fg: THEME.border },
494
+ style: { bg: THEME.surface, item: { fg: THEME.text }, selected: { fg: THEME.text, bg: '#313244' } },
220
495
  });
221
- styleCell(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} `);
222
498
 
223
499
  const logView = new LogView(logBox, {
224
500
  maxLines: 800,
225
501
  flushInterval: 120,
226
502
  viewportHeight: () => Math.max(1, (typeof logBox.height === 'number' ? logBox.height : screen.rows) - 2),
227
503
  onChange: () => screen.render(),
228
- label: ' OUTPUT (dev server / agents) autoscroll [ON] ',
504
+ onLabelChange: (label) => bufferHeader.refresh(label),
505
+ label: ' OUTPUT (dev server / agents) [ON] ',
229
506
  });
230
507
 
231
508
  /* ---------------------------------------------------------------- *
@@ -245,10 +522,10 @@ function launchDashboard(config, options = {}) {
245
522
  },
246
523
  onExit: (project, info) => {
247
524
  if (info.restart) {
248
- appendLog(project, `{yellow-fg}dev server crashed — auto-restarting ({bold}${info.attempt}/${info.max}{/bold})\u2026{/yellow-fg}`, 'system');
525
+ appendLog(project, `{${STATUS_FG.exp}-fg}dev server crashed — auto-restarting ({bold}${info.attempt}/${info.max}{/bold})\u2026{/${STATUS_FG.exp}-fg}`, 'system');
249
526
  } else {
250
527
  const detail = info.code === null || info.code === undefined ? `signal ${info.signal}` : `exit code ${info.code}`;
251
- appendLog(project, `{gray-fg}dev server stopped (${detail}){/gray-fg}`, 'system');
528
+ appendLog(project, `{${THEME.textDim}-fg}dev server stopped (${detail}){/${THEME.textDim}-fg}`, 'system');
252
529
  }
253
530
  refreshList();
254
531
  updateCard();
@@ -260,48 +537,56 @@ function launchDashboard(config, options = {}) {
260
537
  * ---------------------------------------------------------------- */
261
538
 
262
539
  function modernCounts() {
263
- const counts = { live: 0, exp: 0, pend: 0, scrap: 0 };
540
+ const counts = { live: 0, exp: 0, pend: 0, scrap: 0, unknown: 0 };
264
541
  for (const project of projects) {
265
- const modern = MODERN_OF[project.status] || 'pend';
542
+ const modern = modernStatusOf(project) || 'unknown';
266
543
  counts[modern] = (counts[modern] || 0) + 1;
267
544
  }
268
545
  return counts;
269
546
  }
270
547
 
271
- function filterChips() {
548
+ function chipData() {
272
549
  const counts = modernCounts();
273
- const chip = (label, count, color) => {
274
- const modern = label.toLowerCase();
275
- const active = status.chip === modern;
276
- const text = `[${label} ${count}]`;
277
- return count > 0 ? `{${color}-fg}${active ? '{bold}' : ''}${text}${active ? '{/bold}' : ''}{/${color}-fg}` : '';
278
- };
279
- const allActive = status.chip === null;
280
- return [
281
- `{white-fg}${allActive ? '{bold}' : ''}[ALL ${projects.length}]${allActive ? '{/bold}' : ''}{/white-fg}`,
282
- chip('LIVE', counts.live, 'green'),
283
- chip('EXP', counts.exp, 'yellow'),
284
- chip('PEND', counts.pend, 'blue'),
285
- chip('SCRAP', counts.scrap, 'gray'),
286
- ].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
+ });
287
555
  }
288
556
 
289
557
  function searchLabel() {
290
- if (searchActive) return `/search: ${searchBuffer}`;
291
- 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)';
292
560
  }
293
561
 
294
562
  function updateHeader() {
295
- const left = ` {bold}termdeck{/bold} ${filterChips()} {white-fg}${escapeBraces(searchLabel())}{/white-fg}`;
296
- const right = ` {gray-fg}${timestamp()}{/gray-fg} {green-fg}● DAEMON OFF{/green-fg} `;
297
- header.setContent(`${left}${right}`);
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()));
298
581
  }
299
582
 
300
583
  /** Projects after the chip (status) + search (regex on name) filters. */
301
584
  function filteredProjects() {
302
585
  let list = projects;
303
- if (status.chip) {
304
- list = list.filter((project) => (MODERN_OF[project.status] || 'pend') === status.chip);
586
+ if (status.chip === 'unknown') {
587
+ list = list.filter((project) => modernStatusOf(project) === null);
588
+ } else if (status.chip) {
589
+ list = list.filter((project) => modernStatusOf(project) === status.chip);
305
590
  }
306
591
  if (status.search) {
307
592
  let re = null;
@@ -316,68 +601,61 @@ function launchDashboard(config, options = {}) {
316
601
  }
317
602
 
318
603
  function listItems() {
319
- const inner = Math.max(12, Math.floor((screen.cols * 5) / 12) - 4);
320
- const nameWidth = Math.max(6, inner - 12);
604
+ const inner = Math.max(12, Math.floor(screen.cols * 0.4) - 4);
321
605
  return filteredProjects().map((project) => {
322
606
  const state = runStates.get(project.path);
323
607
  const running = state && (state.status === 'running' || state.status === 'starting');
324
- const dot = running
325
- ? state.status === 'running'
326
- ? '{green-fg}●{/green-fg}'
327
- : '{yellow-fg}●{/yellow-fg}'
328
- : `{${dotColor(project)}-fg}●{/${dotColor(project)}-fg}`;
329
- const name = escapeBraces(truncate(project.name, nameWidth));
608
+ const modern = modernStatusOf(project);
609
+ const dotFg = running ? (state.status === 'running' ? STATUS_FG.live : STATUS_FG.exp) : statusFg(modern);
610
+ const name = escapeBraces(truncate(project.name, Math.max(6, inner - 14)));
330
611
  const info = gitInfo.get(project.path);
331
- const activity = escapeBraces(truncate(project.lastActivity || timeAgo(info && info.lastCommitAt) || '\u2014', 10));
332
- return `${dot} ${name} {gray-fg}${activity}{/gray-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}`;
333
615
  });
334
616
  }
335
617
 
336
618
  function devStateLine(project, state) {
337
619
  if (state && (state.status === 'running' || state.status === 'starting')) {
338
620
  const where = state.url ? ` \u2192 ${escapeBraces(state.url)}` : ' \u2014 waiting for a localhost URL\u2026';
339
- return ` {green-fg}● dev server ${state.status} (pid ${state.pid}){/green-fg}${where}`;
621
+ return ` {${STATUS_FG.live}-fg}● dev server ${state.status} (pid ${state.pid}){/${STATUS_FG.live}-fg}${where}`;
340
622
  }
341
623
  if (state && state.status === 'error') {
342
- return ` {red-fg}● ${escapeBraces(truncate(state.error || 'failed to start', 50))}{/red-fg}`;
624
+ return ` {#f38ba8-fg}● ${escapeBraces(truncate(state.error || 'failed to start', 50))}{/#f38ba8-fg}`;
343
625
  }
344
626
  const last = servers.lastExit.get(project.path);
345
627
  if (last) {
346
628
  const detail = last.code === null || last.code === undefined ? `signal ${last.signal}` : `exit code ${last.code}`;
347
- return ` {gray-fg}○ dev server stopped (${detail}){/gray-fg}`;
629
+ return ` {${THEME.textDim}-fg}○ dev server stopped (${detail}){/${THEME.textDim}-fg}`;
348
630
  }
349
- return ' {gray-fg}○ dev server not running{/gray-fg}';
631
+ return ` {${THEME.textDim}-fg}○ dev server not running{/${THEME.textDim}-fg}`;
350
632
  }
351
633
 
352
634
  function updateCard() {
353
635
  const project = selectedProject();
354
636
  if (!project) {
355
- card.setContent(' {gray-fg}No projects configured. Run `termdeck --setup`.{/gray-fg}');
637
+ card.setContent(` {${THEME.textDim}-fg}No projects configured. Run \`termdeck --setup\`.{/${THEME.textDim}-fg}`);
356
638
  screen.render();
357
639
  return;
358
640
  }
359
641
 
360
642
  const state = runStates.get(project.path) || { status: 'idle' };
361
- const color = badgeColor(project);
362
- const inner = Math.max(24, Math.floor((screen.cols * 7) / 12) - 4);
363
- const runPid = state && state.pid ? state.pid : null;
643
+ const modern = modernStatusOf(project);
644
+ const sFg = statusFg(modern);
645
+ const inner = Math.max(24, Math.floor(screen.cols * 0.6) - 4);
364
646
  const info = gitInfo.get(project.path);
365
647
  const stats = processStats.get(project.path);
366
- const pidLabel = runPid
367
- ? runPid
368
- : stats && stats.pid
369
- ? stats.pid
370
- : config.demoMode && project.port
371
- ? `${DEMO_PID} {gray-fg}(demo){/gray-fg}`
372
- : '\u2014';
373
648
  const memCpu = stats && stats.memory
374
649
  ? `${escapeBraces(stats.memory)} \u00b7 ${escapeBraces(stats.cpu || '\u2014')}`
375
650
  : config.demoMode
376
- ? '213.4 MB \u00b7 0.8% {gray-fg}(demo){/gray-fg}'
651
+ ? `213.4 MB \u00b7 0.8% {${THEME.textDim}-fg}(demo){/${THEME.textDim}-fg}`
377
652
  : '\u2014';
378
653
  const dirty = info && info.dirty ? info.dirty : { added: 0, removed: 0 };
379
- const dirtyLabel = dirty.added || dirty.removed
380
- ? ` {red-fg}+${dirty.added}/{blue-fg}-${dirty.removed}{/blue-fg}{/red-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}`
381
659
  : '';
382
660
  const branch = (info && info.branch) || project.branch || '\u2014';
383
661
  const hash = (info && info.commitHash) || (project.lastCommit && project.lastCommit.hash) || '';
@@ -385,36 +663,89 @@ function launchDashboard(config, options = {}) {
385
663
  const lastAt = (info && timeAgo(info.lastCommitAt)) || project.lastActivity || null;
386
664
  const commit = `${hash} ${msg} ${lastAt ? `(${lastAt})` : ''}`.trim() || branch;
387
665
 
666
+ // Unknown / missing status renders as "[?] Unknown" in neutral gray; the
667
+ // status button is recoloured below to prompt the user to set it.
668
+ const unknown = !modern;
669
+ const fullLabel = modern ? (STATUS_FULL[modern] || modern.toUpperCase()) : 'UNKNOWN';
670
+ const statusChip = unknown
671
+ ? `{${STATUS_FG.unknown}-fg}{bold}[?] Unknown{/bold}{/${STATUS_FG.unknown}-fg}`
672
+ : `{${sFg}-fg}{bold}[${modern.toUpperCase()}]{/bold} ${fullLabel}{/${sFg}-fg}`;
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
+
388
682
  const lines = [
389
- ` {${color}-fg}●{/${color}-fg} {bold}${escapeBraces(truncate(project.name, 40))}{/bold}`,
390
- ` {gray-fg}${escapeBraces(truncate(project.info || '(no description)', inner - 2))}{/gray-fg}`,
391
- ` {gray-fg}Path:{/gray-fg} ${escapeBraces(truncate(displayPath(project.path, config.root), inner - 8))}`,
392
- ` {gray-fg}Status:{/gray-fg} {${color}-fg}{bold}${project.status.toUpperCase()}{/bold}{/${color}-fg} {gray-fg}Branch:{/gray-fg} {green-fg}${escapeBraces(truncate(branch, 30))}{/green-fg}${dirtyLabel}`,
393
- ` {gray-fg}Dev port:{/gray-fg} ${project.port || '\u2014'} {gray-fg}PID:{/gray-fg} ${escapeBraces(pidLabel)}`,
394
- ` {gray-fg}Package mgr:{/gray-fg} ${escapeBraces(project.packageManager || '\u2014')}`,
395
- ` {gray-fg}Stack:{/gray-fg} ${escapeBraces(truncate(project.stack || '\u2014', inner - 12))}`,
396
- ` {gray-fg}Mem/CPU:{/gray-fg} ${escapeBraces(memCpu)}`,
397
- ` {gray-fg}Last commit:{/gray-fg} ${escapeBraces(truncate(commit, inner - 16))}`,
683
+ ` {${sFg}-fg}\u25cf{/${sFg}-fg} {bold}${escapeBraces(truncate(project.name, 40))}{/bold}`,
684
+ ` {${THEME.textDim}-fg}${escapeBraces(truncate(project.info || '(no description)', inner - 2))}{/${THEME.textDim}-fg}`,
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),
398
692
  devStateLine(project, state),
399
693
  ];
400
694
 
401
695
  card.setContent(lines.join('\n'));
402
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} `;
702
+
703
+ // Visual cue while the status is unknown: red button asking to be set.
704
+ if (unknown) {
705
+ buttons.status.setContent('{bold}[s]{/bold} Change status!');
706
+ buttons.status.style.fg = '#f38ba8';
707
+ buttons.status.style.bold = true;
708
+ } else {
709
+ buttons.status.setContent(`{bold}[s]{/bold} Change status \u2192 ${fullLabel}`);
710
+ buttons.status.style.fg = sFg;
711
+ buttons.status.style.bold = false;
712
+ }
713
+
403
714
  screen.render();
404
715
  }
405
716
 
406
- 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() {
407
733
  const sel = selectedProject();
408
734
  const index = sel ? filteredProjects().indexOf(sel) + 1 : 0;
409
- const pane = currentPane();
735
+ const paneLabel = currentPane();
410
736
  const size = `${screen.cols}x${screen.rows}`;
411
737
  const chipLabel = status.chip ? status.chip.toUpperCase() : 'ALL';
412
- const searchLabel = status.search ? ` /${status.search}` : '';
413
- return ` {white-fg}[${index}/${filteredProjects().length}] SELECTED FILTER: ${chipLabel}${searchLabel}{/white-fg} ${FOOTER_KEYS} {cyan-fg}PANE: [${pane}]{/cyan-fg} \u2502 utf-8 \u2502 ${size} `;
738
+ const search = status.search ? ` /${status.search}` : '';
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;
414
744
  }
415
745
 
416
746
  function updateFooter() {
417
- footer.setContent(buildFooter());
747
+ footerMeta.setContent(buildFooterRow1());
748
+ footer.setContent(buildFooterRow2());
418
749
  }
419
750
 
420
751
  function currentPane() {
@@ -428,7 +759,7 @@ function launchDashboard(config, options = {}) {
428
759
  function refreshList() {
429
760
  const selected = projectList.selected;
430
761
  projectList.setItems(listItems());
431
- projectList.setLabel(` PROJECTS (${projects.length} repos) `);
762
+ projectList.setLabel(` PROJECTS (${projects.length}) `);
432
763
  if (typeof selected === 'number' && selected < projects.length) projectList.select(selected);
433
764
  updateHeader();
434
765
  updateFooter();
@@ -445,6 +776,7 @@ function launchDashboard(config, options = {}) {
445
776
  function setStatus(message) {
446
777
  if (status.timer) clearTimeout(status.timer);
447
778
  status.message = message;
779
+ footerMeta.setContent(buildFooterRow1());
448
780
  footer.setContent(` {bold}${escapeBraces(message)}{/bold}`);
449
781
  screen.render();
450
782
 
@@ -458,12 +790,18 @@ function launchDashboard(config, options = {}) {
458
790
 
459
791
  /** Log lines from child processes are raw text -> escape blessed markup. */
460
792
  function appendLog(project, line, stream = 'stdout') {
461
- const prefix = `{gray-fg}${timestamp()}{/gray-fg} {${colorFor(project)}-fg}${escapeBraces(truncate(project.name, 10))}{/${colorFor(project)}-fg}`;
793
+ const time = `{${THEME.textDim}-fg}${timestamp()}{/${THEME.textDim}-fg}`;
462
794
  if (stream === 'system') {
463
- logView.push(`${prefix} {cyan-fg}[termdeck]{/cyan-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}`);
464
800
  return;
465
801
  }
466
- const marker = stream === 'stderr' ? '{red-fg}✗{/red-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} ' : '';
467
805
  logView.push(`${prefix} ${marker}${escapeBraces(line)}`);
468
806
  }
469
807
 
@@ -471,6 +809,187 @@ function launchDashboard(config, options = {}) {
471
809
  * Actions
472
810
  * ---------------------------------------------------------------- */
473
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
+
474
993
  function startDevServer() {
475
994
  const project = selectedProject();
476
995
  if (!project) return;
@@ -488,7 +1007,7 @@ function launchDashboard(config, options = {}) {
488
1007
  const result = servers.start(project);
489
1008
 
490
1009
  if (!result.ok) {
491
- appendLog(project, `{red-fg}could not start: ${escapeBraces(result.error)}{/red-fg}`, 'system');
1010
+ appendLog(project, `{#f38ba8-fg}could not start: ${escapeBraces(result.error)}{/#f38ba8-fg}`, 'system');
492
1011
  setStatus(`Could not start ${project.name}: ${result.error}`);
493
1012
  } else {
494
1013
  appendLog(project, `logs streaming into the OUTPUT pane — press shift+x to stop`, 'system');
@@ -522,28 +1041,29 @@ function launchDashboard(config, options = {}) {
522
1041
  appendLog(project, `opening editor in a new terminal: ${escapeBraces(command)}`, 'system');
523
1042
  const result = await openInNewTerminal({ cwd: project.path, command });
524
1043
  if (result.ok) {
525
- appendLog(project, `{green-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(displayPath(project.path, config.root))}{/green-fg}`, 'system');
1044
+ appendLog(project, `{${STATUS_FG.live}-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(displayPath(project.path, config.root))}{/${STATUS_FG.live}-fg}`, 'system');
526
1045
  setStatus(`Opened editor in a new terminal window.`);
527
1046
  } else {
528
- appendLog(project, `{red-fg}could not open a terminal: ${escapeBraces(result.error)}{/red-fg}`, 'system');
1047
+ appendLog(project, `{#f38ba8-fg}could not open a terminal: ${escapeBraces(result.error)}{/#f38ba8-fg}`, 'system');
529
1048
  setStatus(`Could not open a terminal for ${project.name}.`);
530
1049
  }
531
1050
  return result;
532
1051
  }
533
1052
 
534
1053
  // Agent: launch in a new terminal with log capture via tee where possible.
535
- appendLog(project, `{cyan-fg}[${escapeBraces(kind)}]{/cyan-fg} launching ${escapeBraces(kind)} in a new terminal`, 'system');
536
- setStatus(`Launching ${kind} for ${project.name}\u2026`);
1054
+ const agentName = AGENT_LABELS[kind] || kind;
1055
+ appendLog(project, `{${STATUS_FG.exp}-fg}[${escapeBraces(kind)}]{/${STATUS_FG.exp}-fg} launching ${escapeBraces(agentName)} in a new terminal`, 'system');
1056
+ setStatus(`Launching ${agentName} for ${project.name}\u2026`);
537
1057
  const result = await launchAgent(project, kind);
538
1058
  if (result.ok) {
539
- appendLog(project, `{green-fg}${escapeBraces(kind)} launched in new ${escapeBraces(result.terminal || 'terminal')} window \u2192 logs \u2192 ${escapeBraces(result.logFile || 'terminal only')}{/green-fg}`, 'system');
540
- setStatus(`Launched ${kind} for ${project.name}.`);
1059
+ appendLog(project, `{${STATUS_FG.live}-fg}${escapeBraces(agentName)} launched in new ${escapeBraces(result.terminal || 'terminal')} window \u2192 logs \u2192 ${escapeBraces(result.logFile || 'terminal only')}{/${STATUS_FG.live}-fg}`, 'system');
1060
+ setStatus(`Launched ${agentName} for ${project.name}.`);
541
1061
  if (result.logFile) {
542
1062
  tailAgentLog(project, kind, (line) => appendLog(project, line, 'stdout'));
543
1063
  }
544
1064
  } else {
545
- appendLog(project, `{red-fg}could not launch ${escapeBraces(kind)}: ${escapeBraces(result.error)}{/red-fg}`, 'system');
546
- setStatus(`Could not launch ${kind} for ${project.name}.`);
1065
+ appendLog(project, `{#f38ba8-fg}could not launch ${escapeBraces(agentName)}: ${escapeBraces(result.error)}{/#f38ba8-fg}`, 'system');
1066
+ setStatus(`Could not launch ${agentName} for ${project.name}.`);
547
1067
  }
548
1068
  return result;
549
1069
  }
@@ -551,11 +1071,13 @@ function launchDashboard(config, options = {}) {
551
1071
  function cycleStatus() {
552
1072
  const project = selectedProject();
553
1073
  if (!project) return;
554
- const current = MODERN_OF[project.status] || 'pend';
1074
+ // Unknown / missing statuses enter the cycle at `exp` so a single press
1075
+ // gives the project a real status.
1076
+ const current = modernStatusOf(project) || 'exp';
555
1077
  const index = MODERN_STATUSES.indexOf(current);
556
1078
  const next = MODERN_STATUSES[(index + 1) % MODERN_STATUSES.length];
557
1079
  project.status = next;
558
- appendLog(project, `{cyan-fg}[termdeck]{/cyan-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');
559
1081
  setStatus(`${project.name}: status \u2192 ${next}`);
560
1082
  if (!config.demoMode) {
561
1083
  try { saveConfig(config); } catch (_) { /* best effort */ }
@@ -590,6 +1112,7 @@ function launchDashboard(config, options = {}) {
590
1112
  }
591
1113
 
592
1114
  function destroy() {
1115
+ if (gitModal) gitModal.close();
593
1116
  if (status.timer) clearTimeout(status.timer);
594
1117
  if (status.quitTimer) clearTimeout(status.quitTimer);
595
1118
  if (status.clock) clearInterval(status.clock);
@@ -626,9 +1149,10 @@ function launchDashboard(config, options = {}) {
626
1149
  * Git / process-monitor refresh on selection
627
1150
  * ---------------------------------------------------------------- */
628
1151
 
629
- function refreshProjectGit(project) {
1152
+ function refreshProjectGit(project, { force = false } = {}) {
630
1153
  if (!project || config.demoMode) return;
631
- const info = getGitInfo(project.path);
1154
+ refreshProjectStack(project);
1155
+ const info = getGitInfo(project.path, { force });
632
1156
  gitInfo.set(project.path, info);
633
1157
  if (selectedProject() === project) {
634
1158
  refreshList();
@@ -687,10 +1211,9 @@ function launchDashboard(config, options = {}) {
687
1211
  screen.key(['o'], () => { if (!searchActive) openTool('opencode'); });
688
1212
  screen.key(['f'], () => { if (!searchActive) openTool('freebuff'); });
689
1213
  screen.key(['k'], () => { if (!searchActive) openTool('kilocode'); });
690
- screen.key(['a'], () => { if (!searchActive) openTool('opencode'); });
1214
+ screen.key(['g'], () => { if (!searchActive) openGitCommitModal(); });
691
1215
  screen.key(['S-x'], () => { if (!searchActive) stopDevServer(); });
692
1216
  screen.key(['j'], () => { if (!searchActive) projectList.down(1); });
693
- screen.key(['k'], () => { if (!searchActive) projectList.up(1); });
694
1217
  screen.key(['tab'], () => { if (!searchActive) screen.focusNext(); });
695
1218
  screen.key(['S-tab'], () => { if (!searchActive) screen.focusPrevious(); });
696
1219
  screen.key(['S-g', 'end'], () => logView.followTail());
@@ -699,7 +1222,7 @@ function launchDashboard(config, options = {}) {
699
1222
  screen.key(['S-pageup', 'home'], () => logView.scrollTop());
700
1223
 
701
1224
  /* ---------------------------------------------------------------- *
702
- * Filter chips: 1 = ALL, 2 = LIVE, 3 = EXP, 4 = PEND, 5 = SCRAP
1225
+ * Filter chips: 1 = ALL, 2 = LIVE, 3 = EXP, 4 = PEND, 5 = UNKNOWN, 6 = SCRAP
703
1226
  * ---------------------------------------------------------------- */
704
1227
 
705
1228
  const FILTER_KEYS = {
@@ -707,7 +1230,8 @@ function launchDashboard(config, options = {}) {
707
1230
  '2': 'live',
708
1231
  '3': 'exp',
709
1232
  '4': 'pend',
710
- '5': 'scrap',
1233
+ '5': 'unknown',
1234
+ '6': 'scrap',
711
1235
  };
712
1236
  screen.on('keypress', (ch, key) => {
713
1237
  // While search mode is active, capture every keystroke for the search buffer.
@@ -743,7 +1267,7 @@ function launchDashboard(config, options = {}) {
743
1267
  return; // ignore everything else while search-active
744
1268
  }
745
1269
 
746
- // Filter chips: 1–5.
1270
+ // Filter chips: 1–6.
747
1271
  if (FILTER_KEYS.hasOwnProperty(ch)) {
748
1272
  status.chip = FILTER_KEYS[ch];
749
1273
  refreshList();
@@ -787,7 +1311,7 @@ function launchDashboard(config, options = {}) {
787
1311
  projectList.focus();
788
1312
  refreshList();
789
1313
  updateCard();
790
- 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');
791
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');
792
1316
  if (config.demoMode) {
793
1317
  const demoProject = projects[0] || { name: 'hyperion-core', path: 'demo' };
@@ -796,7 +1320,7 @@ function launchDashboard(config, options = {}) {
796
1320
  }
797
1321
  }
798
1322
 
799
- // Staggered git-info refresh so the first 14 git spawns do not block the
1323
+ // Staggered git-info refresh so the first git spawns do not block the
800
1324
  // initial render. Each spawn takes ~30-60 ms on a warm filesystem.
801
1325
  let gitBootIdx = 0;
802
1326
  const gitBoot = setInterval(() => {
@@ -819,6 +1343,8 @@ function launchDashboard(config, options = {}) {
819
1343
  }, 200);
820
1344
  if (bootMonitor.unref) bootMonitor.unref();
821
1345
 
1346
+ screen.render();
1347
+ updateHeader(); // re-center the title now that geometry exists
822
1348
  screen.render();
823
1349
 
824
1350
  // One-shot boot toast (e.g. "✨ Discovered and added 2 new projects") from
@@ -831,7 +1357,7 @@ function launchDashboard(config, options = {}) {
831
1357
 
832
1358
  return {
833
1359
  screen,
834
- widgets: { header, projectList, card, logBox, footer, buttons },
1360
+ widgets: { header: titleBox, title: titleBox, statsBar: chipStrip, projectList, card, logBox, footer, buttons },
835
1361
  servers,
836
1362
  logView,
837
1363
  runStates,
@@ -843,4 +1369,4 @@ function launchDashboard(config, options = {}) {
843
1369
  };
844
1370
  }
845
1371
 
846
- module.exports = { launchDashboard, LAYOUT, HINTS, SAMPLE_LOG_LINES };
1372
+ module.exports = { launchDashboard, LAYOUT, HINTS, SAMPLE_LOG_LINES };