termdeck-cli 1.0.2 → 2.0.2
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 +195 -174
- 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 +502 -198
- package/src/devServer.js +51 -2
- package/src/index.js +103 -9
- 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,135 +118,115 @@ 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
|
-
function makeButton({
|
|
180
|
-
const button = blessed.
|
|
181
|
-
parent:
|
|
182
|
-
top:
|
|
183
|
-
left,
|
|
184
|
-
width,
|
|
185
|
-
height:
|
|
186
|
-
content
|
|
162
|
+
/** One-line action button, nested inside the ACTIONS cell. */
|
|
163
|
+
function makeButton({ content, color, onPress }) {
|
|
164
|
+
const button = blessed.button({
|
|
165
|
+
parent: actionsShell,
|
|
166
|
+
top: '0%',
|
|
167
|
+
left: '0%',
|
|
168
|
+
width: '47%',
|
|
169
|
+
height: '24%',
|
|
170
|
+
content,
|
|
187
171
|
align: 'center',
|
|
188
172
|
valign: 'middle',
|
|
189
173
|
tags: true,
|
|
174
|
+
mouse: true,
|
|
190
175
|
clickable: true,
|
|
191
|
-
autoFocus: false,
|
|
192
|
-
|
|
193
|
-
style: { fg: 'black', bg: color, border: { fg: color } },
|
|
194
|
-
effects: { hover: { bg: 'white', fg: 'black' } },
|
|
176
|
+
autoFocus: false,
|
|
177
|
+
style: { fg: color, focus: { bg: 'lightwhite', fg: 'black', bold: true }, hover: { bg: 'lightwhite', fg: 'black', bold: true } },
|
|
195
178
|
});
|
|
196
179
|
|
|
197
|
-
button.on('
|
|
180
|
+
button.on('press', () => {
|
|
198
181
|
try {
|
|
199
182
|
onPress();
|
|
200
183
|
} catch (err) {
|
|
201
184
|
setStatus(`Error: ${err.message}`);
|
|
185
|
+
} finally {
|
|
186
|
+
projectList.focus();
|
|
187
|
+
screen.render();
|
|
202
188
|
}
|
|
203
189
|
});
|
|
190
|
+
return button;
|
|
191
|
+
}
|
|
204
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;
|
|
205
200
|
return button;
|
|
206
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) ');
|
|
207
222
|
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
+
});
|
|
213
230
|
|
|
214
231
|
/* ---------------------------------------------------------------- *
|
|
215
232
|
* Dev servers
|
|
@@ -218,6 +235,7 @@ function launchDashboard(config, options = {}) {
|
|
|
218
235
|
const servers = new DevServerManager({
|
|
219
236
|
devCommand: config.devCommand,
|
|
220
237
|
autoOpenBrowser: options.autoOpen !== undefined ? options.autoOpen : config.openBrowser !== false,
|
|
238
|
+
autoRestart: options.autoRestart !== undefined ? options.autoRestart : config.autoRestart !== false,
|
|
221
239
|
fallbackPort: config.fallbackPort || 3000,
|
|
222
240
|
onLog: (project, line, stream) => appendLog(project, line, stream),
|
|
223
241
|
onState: (project, state) => {
|
|
@@ -226,8 +244,12 @@ function launchDashboard(config, options = {}) {
|
|
|
226
244
|
updateCard();
|
|
227
245
|
},
|
|
228
246
|
onExit: (project, info) => {
|
|
229
|
-
|
|
230
|
-
|
|
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
|
+
}
|
|
231
253
|
refreshList();
|
|
232
254
|
updateCard();
|
|
233
255
|
},
|
|
@@ -237,23 +259,77 @@ function launchDashboard(config, options = {}) {
|
|
|
237
259
|
* Rendering helpers
|
|
238
260
|
* ---------------------------------------------------------------- */
|
|
239
261
|
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
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
|
+
}
|
|
270
|
+
|
|
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
|
+
}
|
|
245
299
|
|
|
246
|
-
|
|
247
|
-
|
|
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) => {
|
|
248
322
|
const state = runStates.get(project.path);
|
|
249
323
|
const running = state && (state.status === 'running' || state.status === 'starting');
|
|
250
324
|
const dot = running
|
|
251
325
|
? state.status === 'running'
|
|
252
326
|
? '{green-fg}●{/green-fg}'
|
|
253
327
|
: '{yellow-fg}●{/yellow-fg}'
|
|
254
|
-
:
|
|
328
|
+
: `{${dotColor(project)}-fg}●{/${dotColor(project)}-fg}`;
|
|
255
329
|
const name = escapeBraces(truncate(project.name, nameWidth));
|
|
256
|
-
|
|
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}`;
|
|
257
333
|
});
|
|
258
334
|
}
|
|
259
335
|
|
|
@@ -263,7 +339,7 @@ function launchDashboard(config, options = {}) {
|
|
|
263
339
|
return ` {green-fg}● dev server ${state.status} (pid ${state.pid}){/green-fg}${where}`;
|
|
264
340
|
}
|
|
265
341
|
if (state && state.status === 'error') {
|
|
266
|
-
return ` {red-fg}● ${escapeBraces(truncate(state.error || 'failed to start',
|
|
342
|
+
return ` {red-fg}● ${escapeBraces(truncate(state.error || 'failed to start', 50))}{/red-fg}`;
|
|
267
343
|
}
|
|
268
344
|
const last = servers.lastExit.get(project.path);
|
|
269
345
|
if (last) {
|
|
@@ -282,42 +358,88 @@ function launchDashboard(config, options = {}) {
|
|
|
282
358
|
}
|
|
283
359
|
|
|
284
360
|
const state = runStates.get(project.path) || { status: 'idle' };
|
|
285
|
-
const color =
|
|
286
|
-
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;
|
|
287
387
|
|
|
288
388
|
const lines = [
|
|
289
|
-
` {
|
|
290
|
-
` {gray-fg}${escapeBraces(truncate(project.
|
|
291
|
-
` ${escapeBraces(truncate(project.
|
|
292
|
-
` {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))}`,
|
|
293
398
|
devStateLine(project, state),
|
|
294
399
|
];
|
|
295
400
|
|
|
296
401
|
card.setContent(lines.join('\n'));
|
|
297
|
-
card.setLabel(` ${project.name} `);
|
|
402
|
+
card.setLabel(` DETAILS: ${project.name} `);
|
|
298
403
|
screen.render();
|
|
299
404
|
}
|
|
300
405
|
|
|
301
|
-
function
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
);
|
|
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';
|
|
307
426
|
}
|
|
308
427
|
|
|
309
428
|
function refreshList() {
|
|
310
429
|
const selected = projectList.selected;
|
|
311
430
|
projectList.setItems(listItems());
|
|
431
|
+
projectList.setLabel(` PROJECTS (${projects.length} repos) `);
|
|
312
432
|
if (typeof selected === 'number' && selected < projects.length) projectList.select(selected);
|
|
313
433
|
updateHeader();
|
|
434
|
+
updateFooter();
|
|
314
435
|
screen.render();
|
|
315
436
|
}
|
|
316
437
|
|
|
317
438
|
function selectedProject() {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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];
|
|
321
443
|
}
|
|
322
444
|
|
|
323
445
|
function setStatus(message) {
|
|
@@ -328,7 +450,7 @@ function launchDashboard(config, options = {}) {
|
|
|
328
450
|
|
|
329
451
|
status.timer = setTimeout(() => {
|
|
330
452
|
status.message = null;
|
|
331
|
-
|
|
453
|
+
updateFooter();
|
|
332
454
|
screen.render();
|
|
333
455
|
}, 6000);
|
|
334
456
|
if (status.timer.unref) status.timer.unref();
|
|
@@ -338,7 +460,6 @@ function launchDashboard(config, options = {}) {
|
|
|
338
460
|
function appendLog(project, line, stream = 'stdout') {
|
|
339
461
|
const prefix = `{gray-fg}${timestamp()}{/gray-fg} {${colorFor(project)}-fg}${escapeBraces(truncate(project.name, 10))}{/${colorFor(project)}-fg}`;
|
|
340
462
|
if (stream === 'system') {
|
|
341
|
-
// Already contains termdeck's own blessed tags.
|
|
342
463
|
logView.push(`${prefix} {cyan-fg}[termdeck]{/cyan-fg} ${line}`);
|
|
343
464
|
return;
|
|
344
465
|
}
|
|
@@ -347,7 +468,7 @@ function launchDashboard(config, options = {}) {
|
|
|
347
468
|
}
|
|
348
469
|
|
|
349
470
|
/* ---------------------------------------------------------------- *
|
|
350
|
-
* Actions
|
|
471
|
+
* Actions
|
|
351
472
|
* ---------------------------------------------------------------- */
|
|
352
473
|
|
|
353
474
|
function startDevServer() {
|
|
@@ -370,7 +491,7 @@ function launchDashboard(config, options = {}) {
|
|
|
370
491
|
appendLog(project, `{red-fg}could not start: ${escapeBraces(result.error)}{/red-fg}`, 'system');
|
|
371
492
|
setStatus(`Could not start ${project.name}: ${result.error}`);
|
|
372
493
|
} else {
|
|
373
|
-
appendLog(project, `logs
|
|
494
|
+
appendLog(project, `logs streaming into the OUTPUT pane — press shift+x to stop`, 'system');
|
|
374
495
|
}
|
|
375
496
|
refreshList();
|
|
376
497
|
updateCard();
|
|
@@ -395,27 +516,56 @@ function launchDashboard(config, options = {}) {
|
|
|
395
516
|
const project = selectedProject();
|
|
396
517
|
if (!project) return;
|
|
397
518
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
+
}
|
|
406
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);
|
|
407
538
|
if (result.ok) {
|
|
408
|
-
appendLog(project, `{green-fg}new ${escapeBraces(result.terminal)} window \u2192 ${escapeBraces(
|
|
409
|
-
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
|
+
}
|
|
410
544
|
} else {
|
|
411
|
-
appendLog(project, `{red-fg}could not
|
|
412
|
-
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}.`);
|
|
413
547
|
}
|
|
414
548
|
return result;
|
|
415
549
|
}
|
|
416
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
|
+
|
|
417
567
|
function reloadConfig() {
|
|
418
|
-
const fresh =
|
|
568
|
+
const fresh = configLoader();
|
|
419
569
|
if (!fresh) {
|
|
420
570
|
setStatus('Could not reload the config file.');
|
|
421
571
|
return;
|
|
@@ -428,7 +578,6 @@ function launchDashboard(config, options = {}) {
|
|
|
428
578
|
config.openBrowser = fresh.openBrowser;
|
|
429
579
|
projects.splice(0, projects.length, ...fresh.projects);
|
|
430
580
|
|
|
431
|
-
// Drop run states for projects that are gone.
|
|
432
581
|
for (const key of [...runStates.keys()]) {
|
|
433
582
|
if (!projects.some((p) => p.path === key)) runStates.delete(key);
|
|
434
583
|
}
|
|
@@ -437,14 +586,17 @@ function launchDashboard(config, options = {}) {
|
|
|
437
586
|
projectList.select(0);
|
|
438
587
|
refreshList();
|
|
439
588
|
updateCard();
|
|
440
|
-
setStatus(`Reloaded ${projects.length} projects from ${config.root}`);
|
|
589
|
+
setStatus(`Reloaded ${projects.length} projects from ${displayPath(config.root, config.root)}`);
|
|
441
590
|
}
|
|
442
591
|
|
|
443
592
|
function destroy() {
|
|
444
593
|
if (status.timer) clearTimeout(status.timer);
|
|
445
594
|
if (status.quitTimer) clearTimeout(status.quitTimer);
|
|
595
|
+
if (status.clock) clearInterval(status.clock);
|
|
446
596
|
logView.destroy();
|
|
447
597
|
servers.stopAll();
|
|
598
|
+
stopAllMonitoring();
|
|
599
|
+
try { stopAllAgents(); } catch (_) { /* cleanup only */ }
|
|
448
600
|
try {
|
|
449
601
|
screen.destroy();
|
|
450
602
|
} catch (_) {
|
|
@@ -460,7 +612,7 @@ function launchDashboard(config, options = {}) {
|
|
|
460
612
|
status.quitTimer = setTimeout(() => {
|
|
461
613
|
status.quitArmed = false;
|
|
462
614
|
status.message = null;
|
|
463
|
-
|
|
615
|
+
updateFooter();
|
|
464
616
|
screen.render();
|
|
465
617
|
}, 4000);
|
|
466
618
|
return;
|
|
@@ -470,40 +622,150 @@ function launchDashboard(config, options = {}) {
|
|
|
470
622
|
process.exit(0);
|
|
471
623
|
}
|
|
472
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
|
+
|
|
473
656
|
/* ---------------------------------------------------------------- *
|
|
474
657
|
* Wiring
|
|
475
658
|
* ---------------------------------------------------------------- */
|
|
476
659
|
|
|
660
|
+
let selecting = false;
|
|
477
661
|
const selectProject = (item, index) => {
|
|
478
|
-
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
|
+
}
|
|
479
674
|
};
|
|
480
675
|
|
|
481
|
-
// `select item` fires on arrow navigation and mouse clicks,
|
|
482
|
-
// `select`/`action` fire when pressing enter.
|
|
483
676
|
projectList.on('select item', selectProject);
|
|
484
677
|
projectList.on('select', selectProject);
|
|
485
678
|
projectList.on('action', selectProject);
|
|
486
679
|
projectList.on('cancel', () => updateCard());
|
|
487
680
|
|
|
488
681
|
screen.key(['q', 'C-c'], quit);
|
|
489
|
-
screen.key(['d'], () => startDevServer());
|
|
490
|
-
screen.key(['e'], () => openTool('editor'));
|
|
491
|
-
screen.key(['
|
|
492
|
-
screen.key(['
|
|
493
|
-
screen.key(['
|
|
494
|
-
screen.key(['
|
|
495
|
-
screen.key(['
|
|
496
|
-
|
|
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(); });
|
|
497
696
|
screen.key(['S-g', 'end'], () => logView.followTail());
|
|
498
697
|
screen.key(['pageup'], () => logView.page(-1));
|
|
499
698
|
screen.key(['pagedown'], () => logView.page(1));
|
|
500
699
|
screen.key(['S-pageup', 'home'], () => logView.scrollTop());
|
|
501
700
|
|
|
502
|
-
|
|
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
|
+
|
|
503
762
|
screen.on('wheelup', () => logView.scrollUp(3));
|
|
504
763
|
screen.on('wheeldown', () => logView.scrollDown(3));
|
|
764
|
+
screen.on('focus', () => {
|
|
765
|
+
updateFooter();
|
|
766
|
+
screen.render();
|
|
767
|
+
});
|
|
505
768
|
|
|
506
|
-
// Never let a stray exception leave orphan dev servers behind.
|
|
507
769
|
const onFatal = (err) => {
|
|
508
770
|
destroy();
|
|
509
771
|
// eslint-disable-next-line no-console
|
|
@@ -525,18 +787,60 @@ function launchDashboard(config, options = {}) {
|
|
|
525
787
|
projectList.focus();
|
|
526
788
|
refreshList();
|
|
527
789
|
updateCard();
|
|
528
|
-
appendLog({ name: 'termdeck', path: '__termdeck__' }, `{bold}termdeck{/bold} ready — ${projects.length} projects from ${escapeBraces(config.root)}`, 'system');
|
|
529
|
-
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
|
+
|
|
530
822
|
screen.render();
|
|
531
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
|
+
|
|
532
832
|
return {
|
|
533
833
|
screen,
|
|
534
834
|
widgets: { header, projectList, card, logBox, footer, buttons },
|
|
535
835
|
servers,
|
|
536
836
|
logView,
|
|
537
837
|
runStates,
|
|
538
|
-
|
|
838
|
+
gitInfo,
|
|
839
|
+
processStats,
|
|
840
|
+
filteredProjects,
|
|
841
|
+
updateStatus: setStatus,
|
|
842
|
+
actions: { startDevServer, stopDevServer, openTool, reloadConfig, cycleStatus, quit, destroy, selectedProject },
|
|
539
843
|
};
|
|
540
844
|
}
|
|
541
845
|
|
|
542
|
-
module.exports = { launchDashboard, LAYOUT, HINTS };
|
|
846
|
+
module.exports = { launchDashboard, LAYOUT, HINTS, SAMPLE_LOG_LINES };
|