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/src/dashboard.js CHANGED
@@ -3,57 +3,89 @@
3
3
  /**
4
4
  * The termdeck TUI.
5
5
  *
6
- * Layout
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
- * Mouse support is enabled on the screen, and the three CTAs are clickable
19
- * boxes: blessed emits `click` on mouseup for any element registered as
20
- * clickable, and every CTA also has a keyboard shortcut.
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 LAYOUT = {
33
- headerHeight: 3,
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 CARD_TOP = LAYOUT.headerHeight;
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
- const HINTS =
50
- ' {bold}↑/↓{/bold} select {bold}d{/bold} dev server {bold}e{/bold} editor {bold}a{/bold} agent ' +
51
- '{bold}x{/bold} stop server {bold}r{/bold} reload {bold}PgUp/PgDn{/bold} logs {bold}q{/bold} quit ';
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 ~/.termdeck-config.json
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, // enables clicking the CTAs
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 header = blessed.box({
89
- parent: screen,
90
- top: 0,
91
- left: 0,
92
- width: '100%',
93
- height: LAYOUT.headerHeight,
94
- tags: true,
95
- label: ' termdeck ',
96
- border: { type: 'line' },
97
- style: { border: { fg: 'cyan' }, label: { fg: 'cyan' }, fg: 'white' },
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
- parent: screen,
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 footer = blessed.box({
160
- parent: screen,
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 logView = new LogView(logBox, {
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
- /** The three CTAs. `clickable: true` is what makes blessed deliver `click`. */
179
- function makeButton({ left, width, label, color, onPress }) {
180
- const button = blessed.box({
181
- parent: screen,
182
- top: BUTTON_TOP,
183
- left,
184
- width,
185
- height: LAYOUT.buttonHeight,
186
- content: `{bold}${label}{/bold}`,
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, // keep keyboard focus on the project list
192
- border: { type: 'line' },
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('click', () => {
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 buttons = {
209
- dev: makeButton({ left: MAIN_LEFT, width: '23%', label: '▶ Dev Server', color: 'green', onPress: () => startDevServer() }),
210
- editor: makeButton({ left: '53%', width: '23%', label: '</> Editor', color: 'blue', onPress: () => openTool('editor') }),
211
- agent: makeButton({ left: '76%', width: '24%', label: '☕ Agent', color: 'magenta', onPress: () => openTool('agent') }),
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
- const detail = info.code === null || info.code === undefined ? `signal ${info.signal}` : `exit code ${info.code}`;
230
- appendLog(project, `{gray-fg}dev server stopped (${detail}){/gray-fg}`, 'system');
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 listItems() {
241
- // Wide enough for the longest status tag, "[Experimental]".
242
- const statusWidth = 14;
243
- const inner = Math.max(12, Math.floor(screen.cols * SIDEBAR_WIDTH_PCT) - 3);
244
- const nameWidth = Math.max(6, inner - statusWidth - 2);
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
- return projects.map((project) => {
247
- const color = STATUS_COLORS[project.status] || 'white';
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
- : '{gray-fg}○{/gray-fg}';
328
+ : `{${dotColor(project)}-fg}●{/${dotColor(project)}-fg}`;
255
329
  const name = escapeBraces(truncate(project.name, nameWidth));
256
- return `${dot} ${name} {${color}-fg}[${project.status}]{/${color}-fg}`;
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', 60))}{/red-fg}`;
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 = STATUS_COLORS[project.status] || 'white';
286
- const inner = Math.max(20, Math.floor(screen.cols * (1 - SIDEBAR_WIDTH_PCT)) - 4);
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
- ` {bold}${escapeBraces(truncate(project.name, 40))}{/bold} {${color}-fg}{bold}● ${project.status}{/bold}{/${color}-fg}`,
290
- ` {gray-fg}${escapeBraces(truncate(project.path, inner))}{/gray-fg}`,
291
- ` ${escapeBraces(truncate(project.info || '(no description)', inner - 2))}`,
292
- ` {gray-fg}editor:{/gray-fg} ${escapeBraces(project.editorCommand || config.editorCommand)} {gray-fg}agent:{/gray-fg} ${escapeBraces(project.agentCommand || config.agentCommand)}`,
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 updateHeader() {
302
- const running = servers.runningCount;
303
- header.setContent(
304
- ` {bold}termdeck{/bold} {gray-fg}${projects.length} project${projects.length === 1 ? '' : 's'} · ${escapeBraces(config.root)}{/gray-fg}` +
305
- `${running ? ` {green-fg}● ${running} dev server${running === 1 ? '' : 's'} running{/green-fg}` : ''}`
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
- if (!projects.length) return null;
319
- const index = Math.min(Math.max(projectList.selected || 0, 0), projects.length - 1);
320
- return projects[index];
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
- footer.setContent(HINTS);
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 (the CTAs)
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 are streaming into this pane — press x to stop`, 'system');
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
- const command = kind === 'editor'
399
- ? project.editorCommand || config.editorCommand
400
- : project.agentCommand || config.agentCommand;
401
-
402
- setStatus(`Opening ${kind} for ${project.name} in a new terminal window\u2026`);
403
- appendLog(project, `opening ${kind} in a new terminal: ${escapeBraces(command)}`, 'system');
404
-
405
- const result = await openInNewTerminal({ cwd: project.path, command });
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(project.path)}{/green-fg}`, 'system');
409
- setStatus(`Opened ${kind} in a new terminal window.`);
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 open a terminal: ${escapeBraces(result.error)}{/red-fg}`, 'system');
412
- setStatus(`Could not open a terminal for ${project.name}.`);
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 = loadConfig();
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
- footer.setContent(HINTS);
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 === 'number') updateCard();
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(['a'], () => openTool('agent'));
492
- screen.key(['x'], () => stopDevServer());
493
- screen.key(['r'], () => reloadConfig());
494
- screen.key(['j'], () => projectList.down(1));
495
- screen.key(['k'], () => projectList.up(1));
496
- // NOTE: blessed reports uppercase letters as `S-g`, so 'G' alone never fires.
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
- // Mouse wheel scrolls the log pane, no matter what the cursor is over.
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}d{/bold} for the dev server, {bold}e{/bold} for your editor, {bold}a{/bold} for an agent.`, 'system');
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
- actions: { startDevServer, stopDevServer, openTool, reloadConfig, quit, destroy, selectedProject },
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 };