termdeck-cli 2.0.3 → 2.0.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termdeck-cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "Terminal project control: a multi-pane TUI to manage your local dev projects — dev servers, git state, process stats, and AI coding agents.",
5
5
  "keywords": [
6
6
  "cli",
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
+ * | TERMDECK |
11
+ * | Sep 21, 2026 3:30:59 PM |
12
+ * +-------------------------------------------------------------------+
13
+ * | [ALL 14] [LIVE 6] [EXP 4] … /search (regex) |
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,56 @@
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 { MODERN_STATUSES, loadConfig, loadConfigFromPath, displayPath, saveConfig } = require('./config');
40
+ const { escapeBraces, truncate, formatTimestamp, timestamp, timeAgo } = require('./util');
39
41
  const { getGitInfo } = require('./projectManager');
40
- const { AGENT_COMMANDS, launchAgent, tailAgentLog, stopAllAgents } = require('./agentManager');
42
+ const { launchAgent, tailAgentLog, stopAllAgents } = 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: 1 };
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
+ border: '#45475a', // thin, unobtrusive box borders
57
+ accentBg: '#3b82f6', // selected-project highlight (bright blue)
58
+ accentFg: '#ffffff',
59
+ };
60
+
61
+ /** Pastel status colours per modern status; `unknown` is neutral gray. */
62
+ const STATUS_FG = {
63
+ live: '#a6e3a1', // soft green
64
+ exp: '#f9e2af', // soft yellow/orange
65
+ pend: '#89b4fa', // soft blue
66
+ scrap: '#6c7086', // soft gray
67
+ unknown: '#6c7086', // neutral gray for missing statuses
68
+ };
69
+
70
+ /** Full uppercase words shown in the DETAILS pane. */
71
+ const STATUS_FULL = { live: 'LIVE', exp: 'EXPERIMENTAL', pend: 'PENDING', scrap: 'SCRAP' };
72
+
73
+ /** Agent buttons name the agent explicitly instead of a bare key hint. */
74
+ const AGENT_LABELS = {
75
+ claude: 'Claude Code',
76
+ codex: 'Codex',
77
+ opencode: 'OpenCode',
78
+ freebuff: 'Freebuff',
79
+ kilocode: 'Kilocode',
80
+ };
44
81
 
45
- const PROJECT_COLORS = ['cyan', 'green', 'yellow', 'magenta', 'red', 'white'];
82
+ const PROJECT_COLORS = ['#89b4fa', '#a6e3a1', '#f9e2af', '#f5c2e7', '#f38ba8', '#94e2d5'];
46
83
 
47
84
  /** Legacy status -> short modern label, used for dots, chips and cycling. */
48
85
  const MODERN_OF = {
@@ -56,16 +93,11 @@ const MODERN_OF = {
56
93
  scrap: 'scrap',
57
94
  };
58
95
 
59
- /** Dot / chip colour per modern status (design spec). */
60
- const DOT_COLORS = { live: 'green', exp: 'yellow', pend: 'blue', scrap: 'gray' };
61
-
62
96
  const DEMO_PID = 49201;
63
97
  const SAMPLE_CONFIG_PATH = path.join(__dirname, '..', 'sample-config.json');
64
98
 
65
- const DEFAULT_AGENT_COMMANDS = { claude: 'claude', codex: 'codex', opencode: 'opencode', freebuff: 'freebuff', kilocode: 'kilocode' };
66
-
67
99
  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';
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';
69
101
  const HINTS = ` ${FOOTER_KEYS} `;
70
102
 
71
103
  /** Colour-coded demo log lines so the OUTPUT pane styling can be checked. */
@@ -81,6 +113,22 @@ const SAMPLE_LOG_LINES = [
81
113
  { stream: 'system', line: '{green-fg}✓{/green-fg} cache flush recovered after retry' },
82
114
  ];
83
115
 
116
+ /**
117
+ * Modern short status for a project, or `null` when the status is missing or
118
+ * unrecognised (rendered as "[?] Unknown" instead of guessing).
119
+ */
120
+ function modernStatusOf(project) {
121
+ if (!project) return null;
122
+ const raw = project.status;
123
+ if (raw === null || raw === undefined || raw === '') return null;
124
+ if (String(raw).toLowerCase() === 'unknown') return null;
125
+ return MODERN_OF[raw] || null;
126
+ }
127
+
128
+ function statusFg(modern) {
129
+ return STATUS_FG[modern] || STATUS_FG.unknown;
130
+ }
131
+
84
132
  /**
85
133
  * @param {object} config parsed config (real or the shipped demo dataset)
86
134
  * @param {object} [options]
@@ -110,7 +158,7 @@ function launchDashboard(config, options = {}) {
110
158
  let searchActive = false;
111
159
  let searchBuffer = '';
112
160
 
113
- const colorFor = (project) => palette.get(project.path) || 'white';
161
+ const colorFor = (project) => palette.get(project.path) || THEME.text;
114
162
 
115
163
  function rebuildPalette() {
116
164
  palette.clear();
@@ -118,28 +166,68 @@ function launchDashboard(config, options = {}) {
118
166
  }
119
167
  rebuildPalette();
120
168
 
121
- const dotColor = (project) => DOT_COLORS[MODERN_OF[project.status] || 'pend'] || 'gray';
122
- const badgeColor = (project) => STATUS_COLORS[project.status] || dotColor(project);
123
-
124
169
  const configLoader = config.demoMode ? () => loadConfigFromPath(SAMPLE_CONFIG_PATH, {}) : () => loadConfig({});
125
170
 
126
171
  /* ---------------------------------------------------------------- *
127
- * Widgets (blessed-contrib 12x12 grid)
172
+ * Widgets (hand-positioned: exact rows, zero gaps between panes)
128
173
  * ---------------------------------------------------------------- */
129
174
 
130
- const grid = new contrib.grid({ rows: LAYOUT.rows, cols: LAYOUT.cols, screen, color: '#444444' });
131
-
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';
175
+ /**
176
+ * Shared look for a bordered panel: dark bg, thin dark-gray border,
177
+ * subtle label. Style is assigned before setLabel on purpose — blessed
178
+ * bakes style.label into the label widget when it is created.
179
+ */
180
+ function panel(el, label) {
181
+ el.style.border = { type: 'line', fg: THEME.border };
182
+ el.style.label = { fg: THEME.textDim };
183
+ el.style.fg = THEME.text;
184
+ el.style.bg = THEME.bg;
185
+ if (label) el.setLabel(label);
186
+ return el;
137
187
  }
138
188
 
139
- const header = grid.set(0, 0, 1, 12, blessed.box, { tags: true });
140
- styleCell(header, ' termdeck ');
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({
199
+ parent: screen,
200
+ top: 0,
201
+ left: 0,
202
+ width: '100%',
203
+ height: TITLE_HEIGHT,
204
+ tags: true,
205
+ padding: { top: 1, bottom: 1 },
206
+ border: { type: 'line', fg: HEADER_ACCENT },
207
+ style: { fg: HEADER_ACCENT, bold: true },
208
+ });
141
209
 
142
- const projectList = grid.set(1, 0, 10, 5, blessed.list, {
210
+ // Stats + search strip sits directly under the masthead (no gap).
211
+ const statsBar = blessed.box({
212
+ parent: screen,
213
+ top: TITLE_HEIGHT,
214
+ left: 0,
215
+ width: '100%',
216
+ height: 1,
217
+ tags: true,
218
+ style: { bg: THEME.bg, fg: THEME.text },
219
+ });
220
+
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);
224
+
225
+ const projectList = blessed.list({
226
+ parent: screen,
227
+ top: bodyTop,
228
+ left: 0,
229
+ width: '40%',
230
+ height: bodyHeight,
143
231
  tags: true,
144
232
  keys: true,
145
233
  mouse: true,
@@ -147,26 +235,69 @@ function launchDashboard(config, options = {}) {
147
235
  scrollable: true,
148
236
  alwaysScroll: true,
149
237
  items: [],
150
- style: { selected: { bg: 'blue', fg: 'white', bold: true }, item: { fg: 'white', hover: { bg: '#333333' } } },
238
+ border: { type: 'line', fg: THEME.border },
239
+ style: {
240
+ bg: THEME.bg,
241
+ item: { fg: THEME.text, hover: { bg: '#313244' } },
242
+ selected: { bg: THEME.accentBg, fg: THEME.accentFg, bold: true },
243
+ },
244
+ });
245
+ panel(projectList, ' PROJECTS ');
246
+
247
+ const rightLeft = '40%';
248
+ 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
+
254
+ const card = blessed.box({
255
+ parent: screen,
256
+ top: bodyTop,
257
+ left: rightLeft,
258
+ width: rightWidth,
259
+ height: cardHeight,
260
+ tags: true,
261
+ scrollable: true,
262
+ mouse: true,
263
+ border: { type: 'line', fg: THEME.border },
264
+ });
265
+ panel(card, ' DETAILS ');
266
+
267
+ const actionsShell = blessed.box({
268
+ parent: screen,
269
+ top: bodyTop + cardHeight,
270
+ left: rightLeft,
271
+ width: rightWidth,
272
+ height: actionsHeight,
273
+ tags: true,
274
+ border: { type: 'line', fg: THEME.border },
275
+ });
276
+ panel(actionsShell, ' ACTIONS — r/e/c/x/o/f/k/s or [Enter] ');
277
+
278
+ const footer = blessed.box({
279
+ parent: screen,
280
+ top: bodyTop + bodyHeight,
281
+ left: 0,
282
+ width: '100%',
283
+ height: footerHeight,
284
+ tags: true,
285
+ style: { fg: THEME.text, bg: THEME.surface },
151
286
  });
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
287
 
162
- /** One-line action button, nested inside the ACTIONS cell. */
163
- function makeButton({ content, color, onPress }) {
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 }) {
164
294
  const button = blessed.button({
165
295
  parent: actionsShell,
166
296
  top: '0%',
167
297
  left: '0%',
168
298
  width: '47%',
169
- height: '24%',
299
+ height: '23%',
300
+ shrink: true,
170
301
  content,
171
302
  align: 'center',
172
303
  valign: 'middle',
@@ -174,7 +305,12 @@ function launchDashboard(config, options = {}) {
174
305
  mouse: true,
175
306
  clickable: true,
176
307
  autoFocus: false,
177
- style: { fg: color, focus: { bg: 'lightwhite', fg: 'black', bold: true }, hover: { bg: 'lightwhite', fg: 'black', bold: true } },
308
+ 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 },
313
+ },
178
314
  });
179
315
 
180
316
  button.on('press', () => {
@@ -191,34 +327,51 @@ function launchDashboard(config, options = {}) {
191
327
  }
192
328
 
193
329
  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 });
330
+ const BUTTON_SLOTS = new Map();
331
+
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
+ function addButton(name, slot, content, opts) {
338
+ const button = makeButton({ content, ...opts });
196
339
  BUTTON_SLOTS.set(button, slot);
197
- button.top = `${slot.row * 25}%`;
198
- button.left = slot.col === 0 ? '1%' : '51%';
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;
344
+ button.height = rowH;
345
+ button.left = slot.col === 0 ? '2%' : '52%';
346
+ button.width = '46%';
199
347
  buttons[name] = button;
200
348
  return button;
201
349
  }
202
- const BUTTON_SLOTS = new Map();
203
350
 
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());
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() });
212
359
 
213
360
  // Created after the buttons so tab-focus order is list -> actions -> output.
214
- const logBox = grid.set(7, 5, 4, 7, contrib.log, {
361
+ const logBox = contrib.log({
362
+ parent: screen,
363
+ top: bodyTop + cardHeight + actionsHeight,
364
+ left: rightLeft,
365
+ width: rightWidth,
366
+ height: logHeight,
215
367
  tags: true,
216
368
  keys: true,
217
369
  mouse: true,
218
370
  bufferLength: 600,
219
- style: { item: { fg: 'white' }, selected: { fg: 'white', bg: 'black' } },
371
+ border: { type: 'line', fg: THEME.border },
372
+ style: { bg: THEME.surface, item: { fg: THEME.text }, selected: { fg: THEME.text, bg: '#313244' } },
220
373
  });
221
- styleCell(logBox, ' OUTPUT (dev server / agents) ');
374
+ panel(logBox, ' OUTPUT (dev server / agents) ');
222
375
 
223
376
  const logView = new LogView(logBox, {
224
377
  maxLines: 800,
@@ -245,10 +398,10 @@ function launchDashboard(config, options = {}) {
245
398
  },
246
399
  onExit: (project, info) => {
247
400
  if (info.restart) {
248
- appendLog(project, `{yellow-fg}dev server crashed — auto-restarting ({bold}${info.attempt}/${info.max}{/bold})\u2026{/yellow-fg}`, 'system');
401
+ appendLog(project, `{${STATUS_FG.exp}-fg}dev server crashed — auto-restarting ({bold}${info.attempt}/${info.max}{/bold})\u2026{/${STATUS_FG.exp}-fg}`, 'system');
249
402
  } else {
250
403
  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');
404
+ appendLog(project, `{${THEME.textDim}-fg}dev server stopped (${detail}){/${THEME.textDim}-fg}`, 'system');
252
405
  }
253
406
  refreshList();
254
407
  updateCard();
@@ -260,9 +413,9 @@ function launchDashboard(config, options = {}) {
260
413
  * ---------------------------------------------------------------- */
261
414
 
262
415
  function modernCounts() {
263
- const counts = { live: 0, exp: 0, pend: 0, scrap: 0 };
416
+ const counts = { live: 0, exp: 0, pend: 0, scrap: 0, unknown: 0 };
264
417
  for (const project of projects) {
265
- const modern = MODERN_OF[project.status] || 'pend';
418
+ const modern = modernStatusOf(project) || 'unknown';
266
419
  counts[modern] = (counts[modern] || 0) + 1;
267
420
  }
268
421
  return counts;
@@ -270,19 +423,19 @@ function launchDashboard(config, options = {}) {
270
423
 
271
424
  function filterChips() {
272
425
  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}` : '';
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}`;
278
430
  };
279
431
  const allActive = status.chip === null;
280
432
  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'),
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'),
286
439
  ].filter(Boolean).join(' ');
287
440
  }
288
441
 
@@ -292,16 +445,22 @@ function launchDashboard(config, options = {}) {
292
445
  }
293
446
 
294
447
  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}`);
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}`);
298
455
  }
299
456
 
300
457
  /** Projects after the chip (status) + search (regex on name) filters. */
301
458
  function filteredProjects() {
302
459
  let list = projects;
303
- if (status.chip) {
304
- list = list.filter((project) => (MODERN_OF[project.status] || 'pend') === status.chip);
460
+ if (status.chip === 'unknown') {
461
+ list = list.filter((project) => modernStatusOf(project) === null);
462
+ } else if (status.chip) {
463
+ list = list.filter((project) => modernStatusOf(project) === status.chip);
305
464
  }
306
465
  if (status.search) {
307
466
  let re = null;
@@ -316,68 +475,66 @@ function launchDashboard(config, options = {}) {
316
475
  }
317
476
 
318
477
  function listItems() {
319
- const inner = Math.max(12, Math.floor((screen.cols * 5) / 12) - 4);
478
+ const inner = Math.max(12, Math.floor(screen.cols * 0.4) - 4);
320
479
  const nameWidth = Math.max(6, inner - 12);
321
480
  return filteredProjects().map((project) => {
322
481
  const state = runStates.get(project.path);
323
482
  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}`;
483
+ const modern = modernStatusOf(project);
484
+ const dotFg = running ? (state.status === 'running' ? STATUS_FG.live : STATUS_FG.exp) : statusFg(modern);
329
485
  const name = escapeBraces(truncate(project.name, nameWidth));
330
486
  const info = gitInfo.get(project.path);
331
487
  const activity = escapeBraces(truncate(project.lastActivity || timeAgo(info && info.lastCommitAt) || '\u2014', 10));
332
- return `${dot} ${name} {gray-fg}${activity}{/gray-fg}`;
488
+ return `{${dotFg}-fg}●{/${dotFg}-fg} ${name} {${THEME.textDim}-fg}${activity}{/${THEME.textDim}-fg}`;
333
489
  });
334
490
  }
335
491
 
336
492
  function devStateLine(project, state) {
337
493
  if (state && (state.status === 'running' || state.status === 'starting')) {
338
494
  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}`;
495
+ return ` {${STATUS_FG.live}-fg}● dev server ${state.status} (pid ${state.pid}){/${STATUS_FG.live}-fg}${where}`;
340
496
  }
341
497
  if (state && state.status === 'error') {
342
- return ` {red-fg}● ${escapeBraces(truncate(state.error || 'failed to start', 50))}{/red-fg}`;
498
+ return ` {#f38ba8-fg}● ${escapeBraces(truncate(state.error || 'failed to start', 50))}{/#f38ba8-fg}`;
343
499
  }
344
500
  const last = servers.lastExit.get(project.path);
345
501
  if (last) {
346
502
  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}`;
503
+ return ` {${THEME.textDim}-fg}○ dev server stopped (${detail}){/${THEME.textDim}-fg}`;
348
504
  }
349
- return ' {gray-fg}○ dev server not running{/gray-fg}';
505
+ return ` {${THEME.textDim}-fg}○ dev server not running{/${THEME.textDim}-fg}`;
350
506
  }
351
507
 
352
508
  function updateCard() {
353
509
  const project = selectedProject();
354
510
  if (!project) {
355
- card.setContent(' {gray-fg}No projects configured. Run `termdeck --setup`.{/gray-fg}');
511
+ card.setContent(` {${THEME.textDim}-fg}No projects configured. Run \`termdeck --setup\`.{/${THEME.textDim}-fg}`);
356
512
  screen.render();
357
513
  return;
358
514
  }
359
515
 
360
516
  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);
517
+ const modern = modernStatusOf(project);
518
+ const sFg = statusFg(modern);
519
+ const inner = Math.max(24, Math.floor(screen.cols * 0.6) - 4);
363
520
  const runPid = state && state.pid ? state.pid : null;
364
521
  const info = gitInfo.get(project.path);
365
522
  const stats = processStats.get(project.path);
366
523
  const pidLabel = runPid
367
- ? runPid
524
+ ? String(runPid)
368
525
  : stats && stats.pid
369
- ? stats.pid
526
+ ? String(stats.pid)
370
527
  : config.demoMode && project.port
371
- ? `${DEMO_PID} {gray-fg}(demo){/gray-fg}`
528
+ ? `${DEMO_PID} {${THEME.textDim}-fg}(demo){/${THEME.textDim}-fg}`
372
529
  : '\u2014';
373
530
  const memCpu = stats && stats.memory
374
531
  ? `${escapeBraces(stats.memory)} \u00b7 ${escapeBraces(stats.cpu || '\u2014')}`
375
532
  : config.demoMode
376
- ? '213.4 MB \u00b7 0.8% {gray-fg}(demo){/gray-fg}'
533
+ ? `213.4 MB \u00b7 0.8% {${THEME.textDim}-fg}(demo){/${THEME.textDim}-fg}`
377
534
  : '\u2014';
378
535
  const dirty = info && info.dirty ? info.dirty : { added: 0, removed: 0 };
379
536
  const dirtyLabel = dirty.added || dirty.removed
380
- ? ` {red-fg}+${dirty.added}/{blue-fg}-${dirty.removed}{/blue-fg}{/red-fg}`
537
+ ? ` {#f38ba8-fg}+${dirty.added}{/#f38ba8-fg}{#89b4fa-fg} -${dirty.removed}{/#89b4fa-fg}`
381
538
  : '';
382
539
  const branch = (info && info.branch) || project.branch || '\u2014';
383
540
  const hash = (info && info.commitHash) || (project.lastCommit && project.lastCommit.hash) || '';
@@ -385,32 +542,52 @@ function launchDashboard(config, options = {}) {
385
542
  const lastAt = (info && timeAgo(info.lastCommitAt)) || project.lastActivity || null;
386
543
  const commit = `${hash} ${msg} ${lastAt ? `(${lastAt})` : ''}`.trim() || branch;
387
544
 
545
+ // Unknown / missing status renders as "[?] Unknown" in neutral gray; the
546
+ // status button is recoloured below to prompt the user to set it.
547
+ const unknown = !modern;
548
+ const fullLabel = modern ? (STATUS_FULL[modern] || modern.toUpperCase()) : 'UNKNOWN';
549
+ const statusChip = unknown
550
+ ? `{${STATUS_FG.unknown}-fg}{bold}[?] Unknown{/bold}{/${STATUS_FG.unknown}-fg}`
551
+ : `{${sFg}-fg}{bold}[${modern.toUpperCase()}]{/bold} ${fullLabel}{/${sFg}-fg}`;
552
+
388
553
  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))}`,
554
+ ` {${sFg}-fg}●{/${sFg}-fg} {bold}${escapeBraces(truncate(project.name, 40))}{/bold}`,
555
+ ` {${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))}`,
398
563
  devStateLine(project, state),
399
564
  ];
400
565
 
401
566
  card.setContent(lines.join('\n'));
402
567
  card.setLabel(` DETAILS: ${project.name} `);
568
+
569
+ // Visual cue while the status is unknown: red button asking to be set.
570
+ if (unknown) {
571
+ buttons.status.setContent('{bold}[s]{/bold} Change status!');
572
+ buttons.status.style.fg = '#f38ba8';
573
+ buttons.status.style.bold = true;
574
+ } else {
575
+ buttons.status.setContent(`{bold}[s]{/bold} Change status \u2192 ${fullLabel}`);
576
+ buttons.status.style.fg = sFg;
577
+ buttons.status.style.bold = false;
578
+ }
579
+
403
580
  screen.render();
404
581
  }
405
582
 
406
583
  function buildFooter() {
407
584
  const sel = selectedProject();
408
585
  const index = sel ? filteredProjects().indexOf(sel) + 1 : 0;
409
- const pane = currentPane();
586
+ const paneLabel = currentPane();
410
587
  const size = `${screen.cols}x${screen.rows}`;
411
588
  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} `;
589
+ 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} `;
414
591
  }
415
592
 
416
593
  function updateFooter() {
@@ -428,7 +605,7 @@ function launchDashboard(config, options = {}) {
428
605
  function refreshList() {
429
606
  const selected = projectList.selected;
430
607
  projectList.setItems(listItems());
431
- projectList.setLabel(` PROJECTS (${projects.length} repos) `);
608
+ projectList.setLabel(` PROJECTS (${projects.length}) `);
432
609
  if (typeof selected === 'number' && selected < projects.length) projectList.select(selected);
433
610
  updateHeader();
434
611
  updateFooter();
@@ -458,12 +635,13 @@ function launchDashboard(config, options = {}) {
458
635
 
459
636
  /** Log lines from child processes are raw text -> escape blessed markup. */
460
637
  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}`;
638
+ const fg = colorFor(project);
639
+ const prefix = `{${THEME.textDim}-fg}${timestamp()}{/${THEME.textDim}-fg} {${fg}-fg}${escapeBraces(truncate(project.name, 10))}{/${fg}-fg}`;
462
640
  if (stream === 'system') {
463
- logView.push(`${prefix} {cyan-fg}[termdeck]{/cyan-fg} ${line}`);
641
+ logView.push(`${prefix} {#89b4fa-fg}[termdeck]{/#89b4fa-fg} ${line}`);
464
642
  return;
465
643
  }
466
- const marker = stream === 'stderr' ? '{red-fg}✗{/red-fg} ' : '';
644
+ const marker = stream === 'stderr' ? '{#f38ba8-fg}✗{/#f38ba8-fg} ' : '';
467
645
  logView.push(`${prefix} ${marker}${escapeBraces(line)}`);
468
646
  }
469
647
 
@@ -488,7 +666,7 @@ function launchDashboard(config, options = {}) {
488
666
  const result = servers.start(project);
489
667
 
490
668
  if (!result.ok) {
491
- appendLog(project, `{red-fg}could not start: ${escapeBraces(result.error)}{/red-fg}`, 'system');
669
+ appendLog(project, `{#f38ba8-fg}could not start: ${escapeBraces(result.error)}{/#f38ba8-fg}`, 'system');
492
670
  setStatus(`Could not start ${project.name}: ${result.error}`);
493
671
  } else {
494
672
  appendLog(project, `logs streaming into the OUTPUT pane — press shift+x to stop`, 'system');
@@ -522,28 +700,29 @@ function launchDashboard(config, options = {}) {
522
700
  appendLog(project, `opening editor in a new terminal: ${escapeBraces(command)}`, 'system');
523
701
  const result = await openInNewTerminal({ cwd: project.path, command });
524
702
  if (result.ok) {
525
- appendLog(project, `{green-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(displayPath(project.path, config.root))}{/green-fg}`, 'system');
703
+ appendLog(project, `{${STATUS_FG.live}-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(displayPath(project.path, config.root))}{/${STATUS_FG.live}-fg}`, 'system');
526
704
  setStatus(`Opened editor in a new terminal window.`);
527
705
  } else {
528
- appendLog(project, `{red-fg}could not open a terminal: ${escapeBraces(result.error)}{/red-fg}`, 'system');
706
+ appendLog(project, `{#f38ba8-fg}could not open a terminal: ${escapeBraces(result.error)}{/#f38ba8-fg}`, 'system');
529
707
  setStatus(`Could not open a terminal for ${project.name}.`);
530
708
  }
531
709
  return result;
532
710
  }
533
711
 
534
712
  // 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`);
713
+ const agentName = AGENT_LABELS[kind] || kind;
714
+ appendLog(project, `{#89b4fa-fg}[${escapeBraces(kind)}]{/#89b4fa-fg} launching ${escapeBraces(agentName)} in a new terminal`, 'system');
715
+ setStatus(`Launching ${agentName} for ${project.name}\u2026`);
537
716
  const result = await launchAgent(project, kind);
538
717
  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}.`);
718
+ 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');
719
+ setStatus(`Launched ${agentName} for ${project.name}.`);
541
720
  if (result.logFile) {
542
721
  tailAgentLog(project, kind, (line) => appendLog(project, line, 'stdout'));
543
722
  }
544
723
  } 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}.`);
724
+ appendLog(project, `{#f38ba8-fg}could not launch ${escapeBraces(agentName)}: ${escapeBraces(result.error)}{/#f38ba8-fg}`, 'system');
725
+ setStatus(`Could not launch ${agentName} for ${project.name}.`);
547
726
  }
548
727
  return result;
549
728
  }
@@ -551,11 +730,13 @@ function launchDashboard(config, options = {}) {
551
730
  function cycleStatus() {
552
731
  const project = selectedProject();
553
732
  if (!project) return;
554
- const current = MODERN_OF[project.status] || 'pend';
733
+ // Unknown / missing statuses enter the cycle at `exp` so a single press
734
+ // gives the project a real status.
735
+ const current = modernStatusOf(project) || 'exp';
555
736
  const index = MODERN_STATUSES.indexOf(current);
556
737
  const next = MODERN_STATUSES[(index + 1) % MODERN_STATUSES.length];
557
738
  project.status = next;
558
- appendLog(project, `{cyan-fg}[termdeck]{/cyan-fg} status changed to {bold}${next}{/bold}`, 'system');
739
+ appendLog(project, `{#89b4fa-fg}[termdeck]{/#89b4fa-fg} status changed to {bold}${next}{/bold}`, 'system');
559
740
  setStatus(`${project.name}: status \u2192 ${next}`);
560
741
  if (!config.demoMode) {
561
742
  try { saveConfig(config); } catch (_) { /* best effort */ }
@@ -687,10 +868,8 @@ function launchDashboard(config, options = {}) {
687
868
  screen.key(['o'], () => { if (!searchActive) openTool('opencode'); });
688
869
  screen.key(['f'], () => { if (!searchActive) openTool('freebuff'); });
689
870
  screen.key(['k'], () => { if (!searchActive) openTool('kilocode'); });
690
- screen.key(['a'], () => { if (!searchActive) openTool('opencode'); });
691
871
  screen.key(['S-x'], () => { if (!searchActive) stopDevServer(); });
692
872
  screen.key(['j'], () => { if (!searchActive) projectList.down(1); });
693
- screen.key(['k'], () => { if (!searchActive) projectList.up(1); });
694
873
  screen.key(['tab'], () => { if (!searchActive) screen.focusNext(); });
695
874
  screen.key(['S-tab'], () => { if (!searchActive) screen.focusPrevious(); });
696
875
  screen.key(['S-g', 'end'], () => logView.followTail());
@@ -699,7 +878,7 @@ function launchDashboard(config, options = {}) {
699
878
  screen.key(['S-pageup', 'home'], () => logView.scrollTop());
700
879
 
701
880
  /* ---------------------------------------------------------------- *
702
- * Filter chips: 1 = ALL, 2 = LIVE, 3 = EXP, 4 = PEND, 5 = SCRAP
881
+ * Filter chips: 1 = ALL, 2 = LIVE, 3 = EXP, 4 = PEND, 5 = UNKNOWN, 6 = SCRAP
703
882
  * ---------------------------------------------------------------- */
704
883
 
705
884
  const FILTER_KEYS = {
@@ -707,7 +886,8 @@ function launchDashboard(config, options = {}) {
707
886
  '2': 'live',
708
887
  '3': 'exp',
709
888
  '4': 'pend',
710
- '5': 'scrap',
889
+ '5': 'unknown',
890
+ '6': 'scrap',
711
891
  };
712
892
  screen.on('keypress', (ch, key) => {
713
893
  // While search mode is active, capture every keystroke for the search buffer.
@@ -743,7 +923,7 @@ function launchDashboard(config, options = {}) {
743
923
  return; // ignore everything else while search-active
744
924
  }
745
925
 
746
- // Filter chips: 1–5.
926
+ // Filter chips: 1–6.
747
927
  if (FILTER_KEYS.hasOwnProperty(ch)) {
748
928
  status.chip = FILTER_KEYS[ch];
749
929
  refreshList();
@@ -796,7 +976,7 @@ function launchDashboard(config, options = {}) {
796
976
  }
797
977
  }
798
978
 
799
- // Staggered git-info refresh so the first 14 git spawns do not block the
979
+ // Staggered git-info refresh so the first git spawns do not block the
800
980
  // initial render. Each spawn takes ~30-60 ms on a warm filesystem.
801
981
  let gitBootIdx = 0;
802
982
  const gitBoot = setInterval(() => {
@@ -819,6 +999,8 @@ function launchDashboard(config, options = {}) {
819
999
  }, 200);
820
1000
  if (bootMonitor.unref) bootMonitor.unref();
821
1001
 
1002
+ screen.render();
1003
+ updateHeader(); // re-center the title now that geometry exists
822
1004
  screen.render();
823
1005
 
824
1006
  // One-shot boot toast (e.g. "✨ Discovered and added 2 new projects") from
@@ -831,7 +1013,7 @@ function launchDashboard(config, options = {}) {
831
1013
 
832
1014
  return {
833
1015
  screen,
834
- widgets: { header, projectList, card, logBox, footer, buttons },
1016
+ widgets: { header: titleBox, title: titleBox, statsBar, projectList, card, logBox, footer, buttons },
835
1017
  servers,
836
1018
  logView,
837
1019
  runStates,
@@ -843,4 +1025,4 @@ function launchDashboard(config, options = {}) {
843
1025
  };
844
1026
  }
845
1027
 
846
- module.exports = { launchDashboard, LAYOUT, HINTS, SAMPLE_LOG_LINES };
1028
+ module.exports = { launchDashboard, LAYOUT, HINTS, SAMPLE_LOG_LINES };
package/src/util.js CHANGED
@@ -49,10 +49,37 @@ function padEnd(text, width) {
49
49
  return str.length >= width ? str : str + ' '.repeat(width - str.length);
50
50
  }
51
51
 
52
- /** `HH:MM:SS` timestamp for log lines. */
53
- function timestamp(date = new Date()) {
52
+ /** Friendly month names used by the short date forms. */
53
+ const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
54
+
55
+ /**
56
+ * `4:42:09 PM` — 12-hour clock for a Date, ISO string or epoch ms.
57
+ * Defaults to the current time when no value is given.
58
+ * Returns null when the value cannot be parsed.
59
+ */
60
+ function formatTime(value = new Date()) {
61
+ if (value == null || value === '') return null;
62
+ const d = value instanceof Date ? value : new Date(value);
63
+ if (Number.isNaN(d.getTime())) return null;
54
64
  const p = (n) => String(n).padStart(2, '0');
55
- return `${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
65
+ let hours = d.getHours();
66
+ const ampm = hours >= 12 ? 'PM' : 'AM';
67
+ hours %= 12;
68
+ if (hours === 0) hours = 12;
69
+ return `${hours}:${p(d.getMinutes())}:${p(d.getSeconds())} ${ampm}`;
70
+ }
71
+
72
+ /** `May 18, 2025 4:42:09 PM` — 12-hour time with the date kept visible. */
73
+ function formatTimestamp(value = new Date()) {
74
+ if (value == null || value === '') return null;
75
+ const d = value instanceof Date ? value : new Date(value);
76
+ if (Number.isNaN(d.getTime())) return null;
77
+ return `${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()} ${formatTime(d)}`;
78
+ }
79
+
80
+ /** `4:42:09 PM` timestamp for log lines (12-hour clock). */
81
+ function timestamp(date = new Date()) {
82
+ return formatTime(date) || '';
56
83
  }
57
84
 
58
85
  /**
@@ -230,6 +257,8 @@ module.exports = {
230
257
  truncate,
231
258
  padEnd,
232
259
  timestamp,
260
+ formatTime,
261
+ formatTimestamp,
233
262
  normalizeLocalUrl,
234
263
  extractLocalUrl,
235
264
  splitLines,