termdeck-cli 1.0.3 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -177
- package/package.json +9 -7
- package/sample-config.json +176 -0
- package/src/agentManager.js +217 -0
- package/src/config.js +636 -427
- package/src/dashboard.js +496 -213
- package/src/devServer.js +51 -2
- package/src/index.js +103 -10
- package/src/processMonitor.js +168 -0
- package/src/projectManager.js +159 -0
- package/src/updater.js +172 -0
- package/src/util.js +23 -0
- package/bin/termdeck.js +0 -22
package/src/dashboard.js
CHANGED
|
@@ -3,57 +3,89 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The termdeck TUI.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* +--------------------------------------------------------------+
|
|
8
|
-
* | header: termdeck - N projects from <root> |
|
|
9
|
-
* +----------------+---------------------------------------------+
|
|
10
|
-
* | project list | selected project card |
|
|
11
|
-
* | (click to pick)| [d] Dev Server [e] Editor [a] Agent |
|
|
12
|
-
* | +---------------------------------------------+
|
|
13
|
-
* | | dev server logs (scrollable, mouse wheel) |
|
|
14
|
-
* +----------------+---------------------------------------------+
|
|
15
|
-
* | footer: hints / status messages |
|
|
16
|
-
* +--------------------------------------------------------------+
|
|
6
|
+
* 12x12 blessed-contrib grid layout:
|
|
17
7
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
8
|
+
* +-------------------------------------------------------------------+
|
|
9
|
+
* | header: termdeck · [ALL 14][LIVE 6]… /search · time · ● DAEMON OFF |
|
|
10
|
+
* +---------------------------------+---------------------------------+
|
|
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
|
+
* | +---------------------------------+
|
|
16
|
+
* | | OUTPUT (dev server / agents) |
|
|
17
|
+
* | | 21:04:12 ✓ vite ready 3000 |
|
|
18
|
+
* +---------------------------------+---------------------------------+
|
|
19
|
+
* | footer: [1/14] selected · keys · tab switch pane · q quit |
|
|
20
|
+
* +-------------------------------------------------------------------+
|
|
21
|
+
*
|
|
22
|
+
* The controller object returned by launchDashboard() keeps the same shape
|
|
23
|
+
* the codebase relied on before the redesign (widgets.projectList / card /
|
|
24
|
+
* logBox / buttons, servers, logView, actions, updateStatus), so the headless
|
|
25
|
+
* smoke test and the auto-updater keep talking to it unchanged. Buttons live
|
|
26
|
+
* inside the ACTIONS cell but stay real blessed buttons (mouse + tab focus).
|
|
21
27
|
*/
|
|
22
28
|
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
23
31
|
const blessed = require('blessed');
|
|
24
32
|
const contrib = require('blessed-contrib');
|
|
25
33
|
|
|
26
34
|
const { DevServerManager } = require('./devServer');
|
|
27
35
|
const { openInNewTerminal } = require('./terminal');
|
|
28
36
|
const { LogView } = require('./logView');
|
|
29
|
-
const { STATUS_COLORS, loadConfig } = require('./config');
|
|
30
|
-
const { escapeBraces, truncate, timestamp } = require('./util');
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
cardHeight: 8,
|
|
35
|
-
buttonHeight: 3,
|
|
36
|
-
footerHeight: 1,
|
|
37
|
-
};
|
|
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');
|
|
41
|
+
const { startMonitoring, stopMonitoring, stopAllMonitoring } = require('./processMonitor');
|
|
38
42
|
|
|
39
|
-
const
|
|
40
|
-
const BUTTON_TOP = CARD_TOP + LAYOUT.cardHeight;
|
|
41
|
-
const PANEL_TOP = BUTTON_TOP + LAYOUT.buttonHeight;
|
|
42
|
-
const SIDEBAR_WIDTH_PCT = 0.3;
|
|
43
|
-
const SIDEBAR_WIDTH = '30%';
|
|
44
|
-
const MAIN_LEFT = '30%';
|
|
45
|
-
const MAIN_WIDTH = '70%';
|
|
43
|
+
const LAYOUT = { rows: 12, cols: 12, headerHeight: 1, footerHeight: 1 };
|
|
46
44
|
|
|
47
45
|
const PROJECT_COLORS = ['cyan', 'green', 'yellow', 'magenta', 'red', 'white'];
|
|
48
46
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
/** Legacy status -> short modern label, used for dots, chips and cycling. */
|
|
48
|
+
const MODERN_OF = {
|
|
49
|
+
Live: 'live',
|
|
50
|
+
Experimental: 'exp',
|
|
51
|
+
Working: 'pend',
|
|
52
|
+
Pending: 'pend',
|
|
53
|
+
live: 'live',
|
|
54
|
+
exp: 'exp',
|
|
55
|
+
pend: 'pend',
|
|
56
|
+
scrap: 'scrap',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Dot / chip colour per modern status (design spec). */
|
|
60
|
+
const DOT_COLORS = { live: 'green', exp: 'yellow', pend: 'blue', scrap: 'gray' };
|
|
61
|
+
|
|
62
|
+
const DEMO_PID = 49201;
|
|
63
|
+
const SAMPLE_CONFIG_PATH = path.join(__dirname, '..', 'sample-config.json');
|
|
64
|
+
|
|
65
|
+
const DEFAULT_AGENT_COMMANDS = { claude: 'claude', codex: 'codex', opencode: 'opencode', freebuff: 'freebuff', kilocode: 'kilocode' };
|
|
66
|
+
|
|
67
|
+
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';
|
|
69
|
+
const HINTS = ` ${FOOTER_KEYS} `;
|
|
70
|
+
|
|
71
|
+
/** Colour-coded demo log lines so the OUTPUT pane styling can be checked. */
|
|
72
|
+
const SAMPLE_LOG_LINES = [
|
|
73
|
+
{ stream: 'system', line: '{green-fg}✓{/green-fg} dev server ready on http://localhost:3000 — logs follow' },
|
|
74
|
+
{ stream: 'stdout', line: '{cyan-fg}[vite]{/cyan-fg} VITE v5.0.0 ready in 412 ms' },
|
|
75
|
+
{ stream: 'stdout', line: '{green-fg}✓{/green-fg} ➜ Local: http://localhost:3000/' },
|
|
76
|
+
{ stream: 'stdout', line: '{green-fg}✓{/green-fg} ➜ Network: http://192.168.1.24:3000/' },
|
|
77
|
+
{ stream: 'stdout', line: '{cyan-fg}[vite]{/cyan-fg} hmr update /src/app/page.tsx 2.14s' },
|
|
78
|
+
{ stream: 'stderr', line: '{red-fg}✗{/red-fg} cache flush failed — retrying (1/3)' },
|
|
79
|
+
{ stream: 'stdout', line: '{yellow-fg}⚠{/yellow-fg} 412 rate limited, backoff 800ms' },
|
|
80
|
+
{ stream: 'system', line: '{cyan-fg}[claude]{/cyan-fg} analysing query-plan regression' },
|
|
81
|
+
{ stream: 'system', line: '{green-fg}✓{/green-fg} cache flush recovered after retry' },
|
|
82
|
+
];
|
|
52
83
|
|
|
53
84
|
/**
|
|
54
|
-
* @param {object} config parsed
|
|
85
|
+
* @param {object} config parsed config (real or the shipped demo dataset)
|
|
55
86
|
* @param {object} [options]
|
|
56
87
|
* @param {boolean} [options.autoOpen] open the browser when a dev server reports a URL
|
|
88
|
+
* @param {boolean} [options.autoRestart] enable/disable dev-server crash recovery
|
|
57
89
|
* @param {object} [options.screenOptions] extra blessed screen options (headless tests)
|
|
58
90
|
* @returns {object} controller (useful for tests)
|
|
59
91
|
*/
|
|
@@ -62,7 +94,7 @@ function launchDashboard(config, options = {}) {
|
|
|
62
94
|
smartCSR: true,
|
|
63
95
|
fullUnicode: true,
|
|
64
96
|
title: 'termdeck',
|
|
65
|
-
mouse: true,
|
|
97
|
+
mouse: true,
|
|
66
98
|
dockBorders: true,
|
|
67
99
|
autoPadding: true,
|
|
68
100
|
...(options.screenOptions || {}),
|
|
@@ -71,7 +103,12 @@ function launchDashboard(config, options = {}) {
|
|
|
71
103
|
const projects = config.projects;
|
|
72
104
|
const runStates = new Map();
|
|
73
105
|
const palette = new Map();
|
|
74
|
-
const status = { message: null, timer: null, quitArmed: false, quitTimer: null };
|
|
106
|
+
const status = { message: null, timer: null, quitArmed: false, quitTimer: null, clock: null, chip: null, search: null };
|
|
107
|
+
const gitInfo = new Map(); // project.path -> git snapshot (branch / hash / dirty)
|
|
108
|
+
const processStats = new Map(); // project.path -> {running, pid, memory, cpu}
|
|
109
|
+
let monitoredPath = null; // project.path currently polled by processMonitor
|
|
110
|
+
let searchActive = false;
|
|
111
|
+
let searchBuffer = '';
|
|
75
112
|
|
|
76
113
|
const colorFor = (project) => palette.get(project.path) || 'white';
|
|
77
114
|
|
|
@@ -81,129 +118,63 @@ function launchDashboard(config, options = {}) {
|
|
|
81
118
|
}
|
|
82
119
|
rebuildPalette();
|
|
83
120
|
|
|
121
|
+
const dotColor = (project) => DOT_COLORS[MODERN_OF[project.status] || 'pend'] || 'gray';
|
|
122
|
+
const badgeColor = (project) => STATUS_COLORS[project.status] || dotColor(project);
|
|
123
|
+
|
|
124
|
+
const configLoader = config.demoMode ? () => loadConfigFromPath(SAMPLE_CONFIG_PATH, {}) : () => loadConfig({});
|
|
125
|
+
|
|
84
126
|
/* ---------------------------------------------------------------- *
|
|
85
|
-
* Widgets
|
|
127
|
+
* Widgets (blessed-contrib 12x12 grid)
|
|
86
128
|
* ---------------------------------------------------------------- */
|
|
87
129
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const header = grid.set(0, 0, 1, 12, blessed.box, { tags: true });
|
|
140
|
+
styleCell(header, ' termdeck ');
|
|
99
141
|
|
|
100
|
-
const projectList = blessed.list
|
|
101
|
-
parent: screen,
|
|
102
|
-
top: LAYOUT.headerHeight,
|
|
103
|
-
left: 0,
|
|
104
|
-
width: SIDEBAR_WIDTH,
|
|
105
|
-
bottom: LAYOUT.footerHeight,
|
|
106
|
-
label: ' projects ',
|
|
142
|
+
const projectList = grid.set(1, 0, 10, 5, blessed.list, {
|
|
107
143
|
tags: true,
|
|
108
144
|
keys: true,
|
|
109
|
-
vi: false,
|
|
110
145
|
mouse: true,
|
|
111
146
|
interactive: true,
|
|
112
147
|
scrollable: true,
|
|
113
148
|
alwaysScroll: true,
|
|
114
|
-
border: { type: 'line' },
|
|
115
|
-
style: {
|
|
116
|
-
border: { fg: 'gray' },
|
|
117
|
-
label: { fg: 'white' },
|
|
118
|
-
selected: { bg: 'blue', fg: 'white', bold: true },
|
|
119
|
-
item: { fg: 'white', hover: { bg: 'gray' } },
|
|
120
|
-
},
|
|
121
149
|
items: [],
|
|
150
|
+
style: { selected: { bg: 'blue', fg: 'white', bold: true }, item: { fg: 'white', hover: { bg: '#333333' } } },
|
|
122
151
|
});
|
|
152
|
+
styleCell(projectList, ' PROJECTS (14 repos) ');
|
|
123
153
|
|
|
124
|
-
const card = blessed.box
|
|
125
|
-
|
|
126
|
-
top: CARD_TOP,
|
|
127
|
-
left: MAIN_LEFT,
|
|
128
|
-
width: MAIN_WIDTH,
|
|
129
|
-
height: LAYOUT.cardHeight,
|
|
130
|
-
tags: true,
|
|
131
|
-
label: ' selected project ',
|
|
132
|
-
border: { type: 'line' },
|
|
133
|
-
style: { border: { fg: 'gray' }, label: { fg: 'white' }, fg: 'white' },
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const logBox = contrib.log({
|
|
137
|
-
parent: screen,
|
|
138
|
-
top: PANEL_TOP,
|
|
139
|
-
left: MAIN_LEFT,
|
|
140
|
-
width: MAIN_WIDTH,
|
|
141
|
-
bottom: LAYOUT.footerHeight,
|
|
142
|
-
label: ' dev server logs ',
|
|
143
|
-
tags: true,
|
|
144
|
-
border: { type: 'line' },
|
|
145
|
-
bufferLength: 600,
|
|
146
|
-
// The widget renders items top-down; LogView handles scroll-back itself.
|
|
147
|
-
keys: false,
|
|
148
|
-
mouse: false,
|
|
149
|
-
interactive: false,
|
|
150
|
-
style: {
|
|
151
|
-
border: { fg: 'gray' },
|
|
152
|
-
label: { fg: 'white' },
|
|
153
|
-
fg: 'white',
|
|
154
|
-
item: { fg: 'white' },
|
|
155
|
-
selected: { fg: 'white', bg: 'black' },
|
|
156
|
-
},
|
|
157
|
-
});
|
|
154
|
+
const card = grid.set(1, 5, 3, 7, blessed.box, { tags: true, scrollable: true, mouse: true });
|
|
155
|
+
styleCell(card, ' DETAILS ');
|
|
158
156
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
bottom: 0,
|
|
162
|
-
left: 0,
|
|
163
|
-
width: '100%',
|
|
164
|
-
height: LAYOUT.footerHeight,
|
|
165
|
-
tags: true,
|
|
166
|
-
style: { fg: 'white', bg: 'blue' },
|
|
167
|
-
content: HINTS,
|
|
168
|
-
});
|
|
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] ');
|
|
169
159
|
|
|
170
|
-
const
|
|
171
|
-
maxLines: 800,
|
|
172
|
-
flushInterval: 120,
|
|
173
|
-
viewportHeight: () => Math.max(1, screen.rows - PANEL_TOP - LAYOUT.footerHeight - 2),
|
|
174
|
-
onChange: () => screen.render(),
|
|
175
|
-
label: ' dev server logs ',
|
|
176
|
-
});
|
|
160
|
+
const footer = grid.set(11, 0, 1, 12, blessed.box, { tags: true, style: { fg: 'white', bg: 'blue' } });
|
|
177
161
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
* wires click -> press), answer to space/enter when focused, light up on
|
|
181
|
-
* hover/focus, and each shows its own keyboard shortcut. `autoFocus: false`
|
|
182
|
-
* keeps keyboard focus on the project list until the user Tabs to a button.
|
|
183
|
-
*/
|
|
184
|
-
function makeButton({ left, width, label, hint, color, onPress }) {
|
|
162
|
+
/** One-line action button, nested inside the ACTIONS cell. */
|
|
163
|
+
function makeButton({ content, color, onPress }) {
|
|
185
164
|
const button = blessed.button({
|
|
186
|
-
parent:
|
|
187
|
-
top:
|
|
188
|
-
left,
|
|
189
|
-
width,
|
|
190
|
-
height:
|
|
191
|
-
content
|
|
165
|
+
parent: actionsShell,
|
|
166
|
+
top: '0%',
|
|
167
|
+
left: '0%',
|
|
168
|
+
width: '47%',
|
|
169
|
+
height: '24%',
|
|
170
|
+
content,
|
|
192
171
|
align: 'center',
|
|
193
172
|
valign: 'middle',
|
|
194
173
|
tags: true,
|
|
195
174
|
mouse: true,
|
|
196
175
|
clickable: true,
|
|
197
|
-
autoFocus: false,
|
|
198
|
-
|
|
199
|
-
style: {
|
|
200
|
-
fg: 'white',
|
|
201
|
-
bg: color,
|
|
202
|
-
bold: true,
|
|
203
|
-
border: { fg: color },
|
|
204
|
-
hover: { bg: 'lightwhite', fg: 'black', bold: true },
|
|
205
|
-
focus: { bg: 'lightwhite', fg: 'black', bold: true },
|
|
206
|
-
},
|
|
176
|
+
autoFocus: false,
|
|
177
|
+
style: { fg: color, focus: { bg: 'lightwhite', fg: 'black', bold: true }, hover: { bg: 'lightwhite', fg: 'black', bold: true } },
|
|
207
178
|
});
|
|
208
179
|
|
|
209
180
|
button.on('press', () => {
|
|
@@ -212,21 +183,50 @@ function launchDashboard(config, options = {}) {
|
|
|
212
183
|
} catch (err) {
|
|
213
184
|
setStatus(`Error: ${err.message}`);
|
|
214
185
|
} finally {
|
|
215
|
-
// blessed's Button.press() focuses the button before emitting `press`;
|
|
216
|
-
// hand focus back so the arrow keys keep working after activation.
|
|
217
186
|
projectList.focus();
|
|
218
187
|
screen.render();
|
|
219
188
|
}
|
|
220
189
|
});
|
|
190
|
+
return button;
|
|
191
|
+
}
|
|
221
192
|
|
|
193
|
+
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%';
|
|
199
|
+
buttons[name] = button;
|
|
222
200
|
return button;
|
|
223
201
|
}
|
|
202
|
+
const BUTTON_SLOTS = new Map();
|
|
203
|
+
|
|
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());
|
|
212
|
+
|
|
213
|
+
// Created after the buttons so tab-focus order is list -> actions -> output.
|
|
214
|
+
const logBox = grid.set(7, 5, 4, 7, contrib.log, {
|
|
215
|
+
tags: true,
|
|
216
|
+
keys: true,
|
|
217
|
+
mouse: true,
|
|
218
|
+
bufferLength: 600,
|
|
219
|
+
style: { item: { fg: 'white' }, selected: { fg: 'white', bg: 'black' } },
|
|
220
|
+
});
|
|
221
|
+
styleCell(logBox, ' OUTPUT (dev server / agents) ');
|
|
224
222
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
223
|
+
const logView = new LogView(logBox, {
|
|
224
|
+
maxLines: 800,
|
|
225
|
+
flushInterval: 120,
|
|
226
|
+
viewportHeight: () => Math.max(1, (typeof logBox.height === 'number' ? logBox.height : screen.rows) - 2),
|
|
227
|
+
onChange: () => screen.render(),
|
|
228
|
+
label: ' OUTPUT (dev server / agents) autoscroll [ON] ',
|
|
229
|
+
});
|
|
230
230
|
|
|
231
231
|
/* ---------------------------------------------------------------- *
|
|
232
232
|
* Dev servers
|
|
@@ -235,6 +235,7 @@ function launchDashboard(config, options = {}) {
|
|
|
235
235
|
const servers = new DevServerManager({
|
|
236
236
|
devCommand: config.devCommand,
|
|
237
237
|
autoOpenBrowser: options.autoOpen !== undefined ? options.autoOpen : config.openBrowser !== false,
|
|
238
|
+
autoRestart: options.autoRestart !== undefined ? options.autoRestart : config.autoRestart !== false,
|
|
238
239
|
fallbackPort: config.fallbackPort || 3000,
|
|
239
240
|
onLog: (project, line, stream) => appendLog(project, line, stream),
|
|
240
241
|
onState: (project, state) => {
|
|
@@ -243,8 +244,12 @@ function launchDashboard(config, options = {}) {
|
|
|
243
244
|
updateCard();
|
|
244
245
|
},
|
|
245
246
|
onExit: (project, info) => {
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
if (info.restart) {
|
|
248
|
+
appendLog(project, `{yellow-fg}dev server crashed — auto-restarting ({bold}${info.attempt}/${info.max}{/bold})\u2026{/yellow-fg}`, 'system');
|
|
249
|
+
} else {
|
|
250
|
+
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');
|
|
252
|
+
}
|
|
248
253
|
refreshList();
|
|
249
254
|
updateCard();
|
|
250
255
|
},
|
|
@@ -254,23 +259,77 @@ function launchDashboard(config, options = {}) {
|
|
|
254
259
|
* Rendering helpers
|
|
255
260
|
* ---------------------------------------------------------------- */
|
|
256
261
|
|
|
257
|
-
function
|
|
258
|
-
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
+
function modernCounts() {
|
|
263
|
+
const counts = { live: 0, exp: 0, pend: 0, scrap: 0 };
|
|
264
|
+
for (const project of projects) {
|
|
265
|
+
const modern = MODERN_OF[project.status] || 'pend';
|
|
266
|
+
counts[modern] = (counts[modern] || 0) + 1;
|
|
267
|
+
}
|
|
268
|
+
return counts;
|
|
269
|
+
}
|
|
262
270
|
|
|
263
|
-
|
|
264
|
-
|
|
271
|
+
function filterChips() {
|
|
272
|
+
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(' ');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function searchLabel() {
|
|
290
|
+
if (searchActive) return `/search: ${searchBuffer}`;
|
|
291
|
+
return status.search ? `/search: ${status.search}` : '/search (regex)';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
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}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Projects after the chip (status) + search (regex on name) filters. */
|
|
301
|
+
function filteredProjects() {
|
|
302
|
+
let list = projects;
|
|
303
|
+
if (status.chip) {
|
|
304
|
+
list = list.filter((project) => (MODERN_OF[project.status] || 'pend') === status.chip);
|
|
305
|
+
}
|
|
306
|
+
if (status.search) {
|
|
307
|
+
let re = null;
|
|
308
|
+
try {
|
|
309
|
+
re = new RegExp(status.search, 'i');
|
|
310
|
+
} catch (_) {
|
|
311
|
+
re = null;
|
|
312
|
+
}
|
|
313
|
+
if (re) list = list.filter((project) => re.test(project.name));
|
|
314
|
+
}
|
|
315
|
+
return list;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function listItems() {
|
|
319
|
+
const inner = Math.max(12, Math.floor((screen.cols * 5) / 12) - 4);
|
|
320
|
+
const nameWidth = Math.max(6, inner - 12);
|
|
321
|
+
return filteredProjects().map((project) => {
|
|
265
322
|
const state = runStates.get(project.path);
|
|
266
323
|
const running = state && (state.status === 'running' || state.status === 'starting');
|
|
267
324
|
const dot = running
|
|
268
325
|
? state.status === 'running'
|
|
269
326
|
? '{green-fg}●{/green-fg}'
|
|
270
327
|
: '{yellow-fg}●{/yellow-fg}'
|
|
271
|
-
:
|
|
328
|
+
: `{${dotColor(project)}-fg}●{/${dotColor(project)}-fg}`;
|
|
272
329
|
const name = escapeBraces(truncate(project.name, nameWidth));
|
|
273
|
-
|
|
330
|
+
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}`;
|
|
274
333
|
});
|
|
275
334
|
}
|
|
276
335
|
|
|
@@ -280,7 +339,7 @@ function launchDashboard(config, options = {}) {
|
|
|
280
339
|
return ` {green-fg}● dev server ${state.status} (pid ${state.pid}){/green-fg}${where}`;
|
|
281
340
|
}
|
|
282
341
|
if (state && state.status === 'error') {
|
|
283
|
-
return ` {red-fg}● ${escapeBraces(truncate(state.error || 'failed to start',
|
|
342
|
+
return ` {red-fg}● ${escapeBraces(truncate(state.error || 'failed to start', 50))}{/red-fg}`;
|
|
284
343
|
}
|
|
285
344
|
const last = servers.lastExit.get(project.path);
|
|
286
345
|
if (last) {
|
|
@@ -299,42 +358,88 @@ function launchDashboard(config, options = {}) {
|
|
|
299
358
|
}
|
|
300
359
|
|
|
301
360
|
const state = runStates.get(project.path) || { status: 'idle' };
|
|
302
|
-
const color =
|
|
303
|
-
const inner = Math.max(
|
|
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;
|
|
364
|
+
const info = gitInfo.get(project.path);
|
|
365
|
+
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
|
+
const memCpu = stats && stats.memory
|
|
374
|
+
? `${escapeBraces(stats.memory)} \u00b7 ${escapeBraces(stats.cpu || '\u2014')}`
|
|
375
|
+
: config.demoMode
|
|
376
|
+
? '213.4 MB \u00b7 0.8% {gray-fg}(demo){/gray-fg}'
|
|
377
|
+
: '\u2014';
|
|
378
|
+
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}`
|
|
381
|
+
: '';
|
|
382
|
+
const branch = (info && info.branch) || project.branch || '\u2014';
|
|
383
|
+
const hash = (info && info.commitHash) || (project.lastCommit && project.lastCommit.hash) || '';
|
|
384
|
+
const msg = (info && info.commitMsg) || (project.lastCommit && project.lastCommit.message) || '';
|
|
385
|
+
const lastAt = (info && timeAgo(info.lastCommitAt)) || project.lastActivity || null;
|
|
386
|
+
const commit = `${hash} ${msg} ${lastAt ? `(${lastAt})` : ''}`.trim() || branch;
|
|
304
387
|
|
|
305
388
|
const lines = [
|
|
306
|
-
` {
|
|
307
|
-
` {gray-fg}${escapeBraces(truncate(project.
|
|
308
|
-
` ${escapeBraces(truncate(project.
|
|
309
|
-
` {gray-fg}
|
|
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))}`,
|
|
310
398
|
devStateLine(project, state),
|
|
311
399
|
];
|
|
312
400
|
|
|
313
401
|
card.setContent(lines.join('\n'));
|
|
314
|
-
card.setLabel(` ${project.name} `);
|
|
402
|
+
card.setLabel(` DETAILS: ${project.name} `);
|
|
315
403
|
screen.render();
|
|
316
404
|
}
|
|
317
405
|
|
|
318
|
-
function
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
);
|
|
406
|
+
function buildFooter() {
|
|
407
|
+
const sel = selectedProject();
|
|
408
|
+
const index = sel ? filteredProjects().indexOf(sel) + 1 : 0;
|
|
409
|
+
const pane = currentPane();
|
|
410
|
+
const size = `${screen.cols}x${screen.rows}`;
|
|
411
|
+
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} `;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function updateFooter() {
|
|
417
|
+
footer.setContent(buildFooter());
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function currentPane() {
|
|
421
|
+
const focused = screen.focused;
|
|
422
|
+
if (focused === projectList) return 'PROJECTS';
|
|
423
|
+
if (focused === logBox) return 'OUTPUT';
|
|
424
|
+
if (focused && Object.values(buttons).includes(focused)) return 'ACTIONS';
|
|
425
|
+
return 'LIST';
|
|
324
426
|
}
|
|
325
427
|
|
|
326
428
|
function refreshList() {
|
|
327
429
|
const selected = projectList.selected;
|
|
328
430
|
projectList.setItems(listItems());
|
|
431
|
+
projectList.setLabel(` PROJECTS (${projects.length} repos) `);
|
|
329
432
|
if (typeof selected === 'number' && selected < projects.length) projectList.select(selected);
|
|
330
433
|
updateHeader();
|
|
434
|
+
updateFooter();
|
|
331
435
|
screen.render();
|
|
332
436
|
}
|
|
333
437
|
|
|
334
438
|
function selectedProject() {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
439
|
+
const list = filteredProjects();
|
|
440
|
+
if (!list.length) return null;
|
|
441
|
+
const index = Math.min(Math.max(projectList.selected || 0, 0), list.length - 1);
|
|
442
|
+
return list[index];
|
|
338
443
|
}
|
|
339
444
|
|
|
340
445
|
function setStatus(message) {
|
|
@@ -345,7 +450,7 @@ function launchDashboard(config, options = {}) {
|
|
|
345
450
|
|
|
346
451
|
status.timer = setTimeout(() => {
|
|
347
452
|
status.message = null;
|
|
348
|
-
|
|
453
|
+
updateFooter();
|
|
349
454
|
screen.render();
|
|
350
455
|
}, 6000);
|
|
351
456
|
if (status.timer.unref) status.timer.unref();
|
|
@@ -355,7 +460,6 @@ function launchDashboard(config, options = {}) {
|
|
|
355
460
|
function appendLog(project, line, stream = 'stdout') {
|
|
356
461
|
const prefix = `{gray-fg}${timestamp()}{/gray-fg} {${colorFor(project)}-fg}${escapeBraces(truncate(project.name, 10))}{/${colorFor(project)}-fg}`;
|
|
357
462
|
if (stream === 'system') {
|
|
358
|
-
// Already contains termdeck's own blessed tags.
|
|
359
463
|
logView.push(`${prefix} {cyan-fg}[termdeck]{/cyan-fg} ${line}`);
|
|
360
464
|
return;
|
|
361
465
|
}
|
|
@@ -364,7 +468,7 @@ function launchDashboard(config, options = {}) {
|
|
|
364
468
|
}
|
|
365
469
|
|
|
366
470
|
/* ---------------------------------------------------------------- *
|
|
367
|
-
* Actions
|
|
471
|
+
* Actions
|
|
368
472
|
* ---------------------------------------------------------------- */
|
|
369
473
|
|
|
370
474
|
function startDevServer() {
|
|
@@ -387,7 +491,7 @@ function launchDashboard(config, options = {}) {
|
|
|
387
491
|
appendLog(project, `{red-fg}could not start: ${escapeBraces(result.error)}{/red-fg}`, 'system');
|
|
388
492
|
setStatus(`Could not start ${project.name}: ${result.error}`);
|
|
389
493
|
} else {
|
|
390
|
-
appendLog(project, `logs
|
|
494
|
+
appendLog(project, `logs streaming into the OUTPUT pane — press shift+x to stop`, 'system');
|
|
391
495
|
}
|
|
392
496
|
refreshList();
|
|
393
497
|
updateCard();
|
|
@@ -412,27 +516,56 @@ function launchDashboard(config, options = {}) {
|
|
|
412
516
|
const project = selectedProject();
|
|
413
517
|
if (!project) return;
|
|
414
518
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
519
|
+
if (kind === 'editor') {
|
|
520
|
+
const command = project.editorCommand || config.editorCommand;
|
|
521
|
+
setStatus(`Opening editor for ${project.name} in a new terminal window\u2026`);
|
|
522
|
+
appendLog(project, `opening editor in a new terminal: ${escapeBraces(command)}`, 'system');
|
|
523
|
+
const result = await openInNewTerminal({ cwd: project.path, command });
|
|
524
|
+
if (result.ok) {
|
|
525
|
+
appendLog(project, `{green-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(displayPath(project.path, config.root))}{/green-fg}`, 'system');
|
|
526
|
+
setStatus(`Opened editor in a new terminal window.`);
|
|
527
|
+
} else {
|
|
528
|
+
appendLog(project, `{red-fg}could not open a terminal: ${escapeBraces(result.error)}{/red-fg}`, 'system');
|
|
529
|
+
setStatus(`Could not open a terminal for ${project.name}.`);
|
|
530
|
+
}
|
|
531
|
+
return result;
|
|
532
|
+
}
|
|
423
533
|
|
|
534
|
+
// 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`);
|
|
537
|
+
const result = await launchAgent(project, kind);
|
|
424
538
|
if (result.ok) {
|
|
425
|
-
appendLog(project, `{green-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(
|
|
426
|
-
setStatus(`
|
|
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}.`);
|
|
541
|
+
if (result.logFile) {
|
|
542
|
+
tailAgentLog(project, kind, (line) => appendLog(project, line, 'stdout'));
|
|
543
|
+
}
|
|
427
544
|
} else {
|
|
428
|
-
appendLog(project, `{red-fg}could not
|
|
429
|
-
setStatus(`Could not
|
|
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}.`);
|
|
430
547
|
}
|
|
431
548
|
return result;
|
|
432
549
|
}
|
|
433
550
|
|
|
551
|
+
function cycleStatus() {
|
|
552
|
+
const project = selectedProject();
|
|
553
|
+
if (!project) return;
|
|
554
|
+
const current = MODERN_OF[project.status] || 'pend';
|
|
555
|
+
const index = MODERN_STATUSES.indexOf(current);
|
|
556
|
+
const next = MODERN_STATUSES[(index + 1) % MODERN_STATUSES.length];
|
|
557
|
+
project.status = next;
|
|
558
|
+
appendLog(project, `{cyan-fg}[termdeck]{/cyan-fg} status changed to {bold}${next}{/bold}`, 'system');
|
|
559
|
+
setStatus(`${project.name}: status \u2192 ${next}`);
|
|
560
|
+
if (!config.demoMode) {
|
|
561
|
+
try { saveConfig(config); } catch (_) { /* best effort */ }
|
|
562
|
+
}
|
|
563
|
+
refreshList();
|
|
564
|
+
updateCard();
|
|
565
|
+
}
|
|
566
|
+
|
|
434
567
|
function reloadConfig() {
|
|
435
|
-
const fresh =
|
|
568
|
+
const fresh = configLoader();
|
|
436
569
|
if (!fresh) {
|
|
437
570
|
setStatus('Could not reload the config file.');
|
|
438
571
|
return;
|
|
@@ -445,7 +578,6 @@ function launchDashboard(config, options = {}) {
|
|
|
445
578
|
config.openBrowser = fresh.openBrowser;
|
|
446
579
|
projects.splice(0, projects.length, ...fresh.projects);
|
|
447
580
|
|
|
448
|
-
// Drop run states for projects that are gone.
|
|
449
581
|
for (const key of [...runStates.keys()]) {
|
|
450
582
|
if (!projects.some((p) => p.path === key)) runStates.delete(key);
|
|
451
583
|
}
|
|
@@ -454,14 +586,17 @@ function launchDashboard(config, options = {}) {
|
|
|
454
586
|
projectList.select(0);
|
|
455
587
|
refreshList();
|
|
456
588
|
updateCard();
|
|
457
|
-
setStatus(`Reloaded ${projects.length} projects from ${config.root}`);
|
|
589
|
+
setStatus(`Reloaded ${projects.length} projects from ${displayPath(config.root, config.root)}`);
|
|
458
590
|
}
|
|
459
591
|
|
|
460
592
|
function destroy() {
|
|
461
593
|
if (status.timer) clearTimeout(status.timer);
|
|
462
594
|
if (status.quitTimer) clearTimeout(status.quitTimer);
|
|
595
|
+
if (status.clock) clearInterval(status.clock);
|
|
463
596
|
logView.destroy();
|
|
464
597
|
servers.stopAll();
|
|
598
|
+
stopAllMonitoring();
|
|
599
|
+
try { stopAllAgents(); } catch (_) { /* cleanup only */ }
|
|
465
600
|
try {
|
|
466
601
|
screen.destroy();
|
|
467
602
|
} catch (_) {
|
|
@@ -477,7 +612,7 @@ function launchDashboard(config, options = {}) {
|
|
|
477
612
|
status.quitTimer = setTimeout(() => {
|
|
478
613
|
status.quitArmed = false;
|
|
479
614
|
status.message = null;
|
|
480
|
-
|
|
615
|
+
updateFooter();
|
|
481
616
|
screen.render();
|
|
482
617
|
}, 4000);
|
|
483
618
|
return;
|
|
@@ -487,44 +622,150 @@ function launchDashboard(config, options = {}) {
|
|
|
487
622
|
process.exit(0);
|
|
488
623
|
}
|
|
489
624
|
|
|
625
|
+
/* ---------------------------------------------------------------- *
|
|
626
|
+
* Git / process-monitor refresh on selection
|
|
627
|
+
* ---------------------------------------------------------------- */
|
|
628
|
+
|
|
629
|
+
function refreshProjectGit(project) {
|
|
630
|
+
if (!project || config.demoMode) return;
|
|
631
|
+
const info = getGitInfo(project.path);
|
|
632
|
+
gitInfo.set(project.path, info);
|
|
633
|
+
if (selectedProject() === project) {
|
|
634
|
+
refreshList();
|
|
635
|
+
updateCard();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function stopMonitor() {
|
|
640
|
+
if (!monitoredPath) return;
|
|
641
|
+
try { stopMonitoring(monitoredPath); } catch (_) { /* not critical */ }
|
|
642
|
+
monitoredPath = null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function refreshProcessMonitor(project) {
|
|
646
|
+
stopMonitor();
|
|
647
|
+
if (!project || config.demoMode) return;
|
|
648
|
+
if (!project.port) { updateCard(); return; }
|
|
649
|
+
startMonitoring(project, (stats) => {
|
|
650
|
+
processStats.set(project.path, stats);
|
|
651
|
+
if (selectedProject() === project) updateCard();
|
|
652
|
+
}, { intervalMs: 2000 });
|
|
653
|
+
monitoredPath = project.path;
|
|
654
|
+
}
|
|
655
|
+
|
|
490
656
|
/* ---------------------------------------------------------------- *
|
|
491
657
|
* Wiring
|
|
492
658
|
* ---------------------------------------------------------------- */
|
|
493
659
|
|
|
660
|
+
let selecting = false;
|
|
494
661
|
const selectProject = (item, index) => {
|
|
495
|
-
if (typeof index
|
|
662
|
+
if (selecting || typeof index !== 'number') { updateCard(); return; }
|
|
663
|
+
const list = filteredProjects();
|
|
664
|
+
if (index >= list.length) return;
|
|
665
|
+
selecting = true;
|
|
666
|
+
try {
|
|
667
|
+
const project = list[index];
|
|
668
|
+
updateCard();
|
|
669
|
+
refreshProjectGit(project);
|
|
670
|
+
refreshProcessMonitor(project);
|
|
671
|
+
} finally {
|
|
672
|
+
selecting = false;
|
|
673
|
+
}
|
|
496
674
|
};
|
|
497
675
|
|
|
498
|
-
// `select item` fires on arrow navigation and mouse clicks,
|
|
499
|
-
// `select`/`action` fire when pressing enter.
|
|
500
676
|
projectList.on('select item', selectProject);
|
|
501
677
|
projectList.on('select', selectProject);
|
|
502
678
|
projectList.on('action', selectProject);
|
|
503
679
|
projectList.on('cancel', () => updateCard());
|
|
504
680
|
|
|
505
681
|
screen.key(['q', 'C-c'], quit);
|
|
506
|
-
screen.key(['d'], () => startDevServer());
|
|
507
|
-
screen.key(['e'], () => openTool('editor'));
|
|
508
|
-
screen.key(['
|
|
509
|
-
screen.key(['
|
|
510
|
-
screen.key(['
|
|
511
|
-
screen.key(['
|
|
512
|
-
screen.key(['
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
screen.key(['
|
|
516
|
-
screen.key(['
|
|
517
|
-
|
|
682
|
+
screen.key(['r', 'd'], () => { if (!searchActive) startDevServer(); });
|
|
683
|
+
screen.key(['e'], () => { if (!searchActive) openTool('editor'); });
|
|
684
|
+
screen.key(['s'], () => { if (!searchActive) cycleStatus(); });
|
|
685
|
+
screen.key(['c'], () => { if (!searchActive) openTool('claude'); });
|
|
686
|
+
screen.key(['x'], () => { if (!searchActive) openTool('codex'); });
|
|
687
|
+
screen.key(['o'], () => { if (!searchActive) openTool('opencode'); });
|
|
688
|
+
screen.key(['f'], () => { if (!searchActive) openTool('freebuff'); });
|
|
689
|
+
screen.key(['k'], () => { if (!searchActive) openTool('kilocode'); });
|
|
690
|
+
screen.key(['a'], () => { if (!searchActive) openTool('opencode'); });
|
|
691
|
+
screen.key(['S-x'], () => { if (!searchActive) stopDevServer(); });
|
|
692
|
+
screen.key(['j'], () => { if (!searchActive) projectList.down(1); });
|
|
693
|
+
screen.key(['k'], () => { if (!searchActive) projectList.up(1); });
|
|
694
|
+
screen.key(['tab'], () => { if (!searchActive) screen.focusNext(); });
|
|
695
|
+
screen.key(['S-tab'], () => { if (!searchActive) screen.focusPrevious(); });
|
|
518
696
|
screen.key(['S-g', 'end'], () => logView.followTail());
|
|
519
697
|
screen.key(['pageup'], () => logView.page(-1));
|
|
520
698
|
screen.key(['pagedown'], () => logView.page(1));
|
|
521
699
|
screen.key(['S-pageup', 'home'], () => logView.scrollTop());
|
|
522
700
|
|
|
523
|
-
|
|
701
|
+
/* ---------------------------------------------------------------- *
|
|
702
|
+
* Filter chips: 1 = ALL, 2 = LIVE, 3 = EXP, 4 = PEND, 5 = SCRAP
|
|
703
|
+
* ---------------------------------------------------------------- */
|
|
704
|
+
|
|
705
|
+
const FILTER_KEYS = {
|
|
706
|
+
'1': null, // ALL (clears the chip)
|
|
707
|
+
'2': 'live',
|
|
708
|
+
'3': 'exp',
|
|
709
|
+
'4': 'pend',
|
|
710
|
+
'5': 'scrap',
|
|
711
|
+
};
|
|
712
|
+
screen.on('keypress', (ch, key) => {
|
|
713
|
+
// While search mode is active, capture every keystroke for the search buffer.
|
|
714
|
+
if (searchActive) {
|
|
715
|
+
if (key.name === 'escape' || key.name === 'S-q') {
|
|
716
|
+
searchActive = false;
|
|
717
|
+
searchBuffer = '';
|
|
718
|
+
projectList.focus();
|
|
719
|
+
refreshList();
|
|
720
|
+
screen.render();
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (key.name === 'return') {
|
|
724
|
+
status.search = searchBuffer || null;
|
|
725
|
+
searchActive = false;
|
|
726
|
+
searchBuffer = '';
|
|
727
|
+
projectList.focus();
|
|
728
|
+
refreshList();
|
|
729
|
+
updateCard();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (key.name === 'backspace') {
|
|
733
|
+
searchBuffer = searchBuffer.slice(0, -1);
|
|
734
|
+
updateHeader();
|
|
735
|
+
screen.render();
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (ch && ch.length === 1 && ch >= ' ') {
|
|
739
|
+
searchBuffer += ch;
|
|
740
|
+
updateHeader();
|
|
741
|
+
screen.render();
|
|
742
|
+
}
|
|
743
|
+
return; // ignore everything else while search-active
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Filter chips: 1–5.
|
|
747
|
+
if (FILTER_KEYS.hasOwnProperty(ch)) {
|
|
748
|
+
status.chip = FILTER_KEYS[ch];
|
|
749
|
+
refreshList();
|
|
750
|
+
updateCard();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// "/" toggles the search input overlay.
|
|
754
|
+
if (ch === '/') {
|
|
755
|
+
searchActive = true;
|
|
756
|
+
searchBuffer = '';
|
|
757
|
+
updateHeader();
|
|
758
|
+
screen.render();
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
524
762
|
screen.on('wheelup', () => logView.scrollUp(3));
|
|
525
763
|
screen.on('wheeldown', () => logView.scrollDown(3));
|
|
764
|
+
screen.on('focus', () => {
|
|
765
|
+
updateFooter();
|
|
766
|
+
screen.render();
|
|
767
|
+
});
|
|
526
768
|
|
|
527
|
-
// Never let a stray exception leave orphan dev servers behind.
|
|
528
769
|
const onFatal = (err) => {
|
|
529
770
|
destroy();
|
|
530
771
|
// eslint-disable-next-line no-console
|
|
@@ -546,18 +787,60 @@ function launchDashboard(config, options = {}) {
|
|
|
546
787
|
projectList.focus();
|
|
547
788
|
refreshList();
|
|
548
789
|
updateCard();
|
|
549
|
-
appendLog({ name: 'termdeck', path: '__termdeck__' }, `{bold}termdeck{/bold} ready — ${projects.length} projects from ${escapeBraces(config.root)}`, 'system');
|
|
550
|
-
appendLog({ name: 'termdeck', path: '__termdeck__' }, `pick a project and press {bold}
|
|
790
|
+
appendLog({ name: 'termdeck', path: '__termdeck__' }, `{bold}termdeck{/bold} ready — ${projects.length} projects from ${escapeBraces(displayPath(config.root, config.root))}`, 'system');
|
|
791
|
+
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
|
+
if (config.demoMode) {
|
|
793
|
+
const demoProject = projects[0] || { name: 'hyperion-core', path: 'demo' };
|
|
794
|
+
for (const sample of SAMPLE_LOG_LINES) {
|
|
795
|
+
appendLog(demoProject, sample.line, sample.stream);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Staggered git-info refresh so the first 14 git spawns do not block the
|
|
800
|
+
// initial render. Each spawn takes ~30-60 ms on a warm filesystem.
|
|
801
|
+
let gitBootIdx = 0;
|
|
802
|
+
const gitBoot = setInterval(() => {
|
|
803
|
+
const project = projects[gitBootIdx++];
|
|
804
|
+
if (!project) { clearInterval(gitBoot); return; }
|
|
805
|
+
try { refreshProjectGit(project); } catch (_) { /* non-fatal */ }
|
|
806
|
+
}, 80);
|
|
807
|
+
if (gitBoot.unref) gitBoot.unref();
|
|
808
|
+
|
|
809
|
+
status.clock = setInterval(() => {
|
|
810
|
+
updateHeader();
|
|
811
|
+
screen.render();
|
|
812
|
+
}, 1000);
|
|
813
|
+
if (status.clock.unref) status.clock.unref();
|
|
814
|
+
|
|
815
|
+
// Trigger the first process-monitor tick for the initially-selected project.
|
|
816
|
+
const bootMonitor = setTimeout(() => {
|
|
817
|
+
const project = selectedProject();
|
|
818
|
+
if (project) refreshProcessMonitor(project);
|
|
819
|
+
}, 200);
|
|
820
|
+
if (bootMonitor.unref) bootMonitor.unref();
|
|
821
|
+
|
|
551
822
|
screen.render();
|
|
552
823
|
|
|
824
|
+
// One-shot boot toast (e.g. "✨ Discovered and added 2 new projects") from
|
|
825
|
+
// the launch flow's silent auto-discovery. Routed through setStatus so it
|
|
826
|
+
// lands in the footer and clears itself like every other status message.
|
|
827
|
+
if (options.bootStatus) {
|
|
828
|
+
setStatus(options.bootStatus);
|
|
829
|
+
appendLog({ name: 'termdeck', path: '__termdeck__' }, options.bootStatus, 'system');
|
|
830
|
+
}
|
|
831
|
+
|
|
553
832
|
return {
|
|
554
833
|
screen,
|
|
555
834
|
widgets: { header, projectList, card, logBox, footer, buttons },
|
|
556
835
|
servers,
|
|
557
836
|
logView,
|
|
558
837
|
runStates,
|
|
559
|
-
|
|
838
|
+
gitInfo,
|
|
839
|
+
processStats,
|
|
840
|
+
filteredProjects,
|
|
841
|
+
updateStatus: setStatus,
|
|
842
|
+
actions: { startDevServer, stopDevServer, openTool, reloadConfig, cycleStatus, quit, destroy, selectedProject },
|
|
560
843
|
};
|
|
561
844
|
}
|
|
562
845
|
|
|
563
|
-
module.exports = { launchDashboard, LAYOUT, HINTS };
|
|
846
|
+
module.exports = { launchDashboard, LAYOUT, HINTS, SAMPLE_LOG_LINES };
|