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 +1 -1
- package/src/dashboard.js +323 -141
- package/src/util.js +32 -3
package/package.json
CHANGED
package/src/dashboard.js
CHANGED
|
@@ -3,20 +3,24 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The termdeck TUI.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
-
* |
|
|
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
|
|
12
|
-
* | ● hyperion-core
|
|
13
|
-
* | ● atlas-engine
|
|
14
|
-
* | …
|
|
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
|
-
* |
|
|
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 {
|
|
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 {
|
|
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:
|
|
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 = ['
|
|
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}
|
|
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) ||
|
|
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 (
|
|
172
|
+
* Widgets (hand-positioned: exact rows, zero gaps between panes)
|
|
128
173
|
* ---------------------------------------------------------------- */
|
|
129
174
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
el.style.
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
163
|
-
|
|
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: '
|
|
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: {
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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',
|
|
205
|
-
addButton('editor', { row: 0, col: 1 }, 'e',
|
|
206
|
-
addButton('claude', { row: 1, col: 0 },
|
|
207
|
-
addButton('codex', { row: 1, col: 1 },
|
|
208
|
-
addButton('opencode', { row: 2, col: 0 },
|
|
209
|
-
addButton('freebuff', { row: 2, col: 1 },
|
|
210
|
-
addButton('kilocode', { row: 3, col: 0 },
|
|
211
|
-
addButton('status', { row: 3, col: 1 }, 's',
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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, `{
|
|
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, `{
|
|
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 =
|
|
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
|
-
|
|
275
|
-
const active = status.chip ===
|
|
276
|
-
|
|
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
|
-
`{
|
|
282
|
-
chip('LIVE', counts.live, '
|
|
283
|
-
chip('EXP', counts.exp, '
|
|
284
|
-
chip('PEND', counts.pend, '
|
|
285
|
-
chip('
|
|
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
|
|
296
|
-
const
|
|
297
|
-
|
|
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) => (
|
|
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(
|
|
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
|
|
325
|
-
|
|
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
|
|
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 ` {
|
|
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 ` {
|
|
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 ` {
|
|
503
|
+
return ` {${THEME.textDim}-fg}○ dev server stopped (${detail}){/${THEME.textDim}-fg}`;
|
|
348
504
|
}
|
|
349
|
-
return
|
|
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(
|
|
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
|
|
362
|
-
const
|
|
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} {
|
|
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
|
-
?
|
|
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
|
-
? ` {
|
|
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
|
-
` {${
|
|
390
|
-
` {
|
|
391
|
-
` {
|
|
392
|
-
` {
|
|
393
|
-
` {
|
|
394
|
-
` {
|
|
395
|
-
` {
|
|
396
|
-
` {
|
|
397
|
-
` {
|
|
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
|
|
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
|
|
413
|
-
return ` {
|
|
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}
|
|
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
|
|
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} {
|
|
641
|
+
logView.push(`${prefix} {#89b4fa-fg}[termdeck]{/#89b4fa-fg} ${line}`);
|
|
464
642
|
return;
|
|
465
643
|
}
|
|
466
|
-
const marker = stream === 'stderr' ? '{
|
|
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, `{
|
|
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, `{
|
|
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, `{
|
|
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
|
-
|
|
536
|
-
|
|
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, `{
|
|
540
|
-
setStatus(`Launched ${
|
|
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, `{
|
|
546
|
-
setStatus(`Could not launch ${
|
|
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
|
-
|
|
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, `{
|
|
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': '
|
|
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–
|
|
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
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
-
|
|
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,
|