termdeck-cli 1.0.3 → 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 real blessed
19
- * buttons: clickable with the mouse, `press`-able with Enter/Space when focused
20
- * (Tab cycles focus), and every CTA also has its own 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}tab{/bold} focus buttons {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,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 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
- /**
179
- * The three CTAs: real blessed buttons. They are clickable (`mouse: true`
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: screen,
187
- top: BUTTON_TOP,
188
- left,
189
- width,
190
- height: LAYOUT.buttonHeight,
191
- content: `{bold}${label}{/bold} {gray-fg}(${hint}){/gray-fg}`,
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, // keep keyboard focus on the project list
198
- border: { type: 'line' },
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 buttons = {
226
- dev: makeButton({ left: MAIN_LEFT, width: '23%', label: '▶ Dev Server', hint: 'd', color: 'green', onPress: () => startDevServer() }),
227
- editor: makeButton({ left: '53%', width: '23%', label: '</> Editor', hint: 'e', color: 'blue', onPress: () => openTool('editor') }),
228
- agent: makeButton({ left: '76%', width: '24%', label: '☕ Agent', hint: 'a', color: 'magenta', onPress: () => openTool('agent') }),
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
- const detail = info.code === null || info.code === undefined ? `signal ${info.signal}` : `exit code ${info.code}`;
247
- 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
+ }
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 listItems() {
258
- // Wide enough for the longest status tag, "[Experimental]".
259
- const statusWidth = 14;
260
- const inner = Math.max(12, Math.floor(screen.cols * SIDEBAR_WIDTH_PCT) - 3);
261
- 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
+ }
262
270
 
263
- return projects.map((project) => {
264
- const color = STATUS_COLORS[project.status] || 'white';
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
- : '{gray-fg}○{/gray-fg}';
328
+ : `{${dotColor(project)}-fg}●{/${dotColor(project)}-fg}`;
272
329
  const name = escapeBraces(truncate(project.name, nameWidth));
273
- 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}`;
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', 60))}{/red-fg}`;
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 = STATUS_COLORS[project.status] || 'white';
303
- 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;
304
387
 
305
388
  const lines = [
306
- ` {bold}${escapeBraces(truncate(project.name, 40))}{/bold} {${color}-fg}{bold}● ${project.status}{/bold}{/${color}-fg}`,
307
- ` {gray-fg}${escapeBraces(truncate(project.path, inner))}{/gray-fg}`,
308
- ` ${escapeBraces(truncate(project.info || '(no description)', inner - 2))}`,
309
- ` {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))}`,
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 updateHeader() {
319
- const running = servers.runningCount;
320
- header.setContent(
321
- ` {bold}termdeck{/bold} {gray-fg}${projects.length} project${projects.length === 1 ? '' : 's'} · ${escapeBraces(config.root)}{/gray-fg}` +
322
- `${running ? ` {green-fg}● ${running} dev server${running === 1 ? '' : 's'} running{/green-fg}` : ''}`
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
- if (!projects.length) return null;
336
- const index = Math.min(Math.max(projectList.selected || 0, 0), projects.length - 1);
337
- 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];
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
- footer.setContent(HINTS);
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 (the CTAs)
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 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');
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
- const command = kind === 'editor'
416
- ? project.editorCommand || config.editorCommand
417
- : project.agentCommand || config.agentCommand;
418
-
419
- setStatus(`Opening ${kind} for ${project.name} in a new terminal window\u2026`);
420
- appendLog(project, `opening ${kind} in a new terminal: ${escapeBraces(command)}`, 'system');
421
-
422
- 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
+ }
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(project.path)}{/green-fg}`, 'system');
426
- 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
+ }
427
544
  } else {
428
- appendLog(project, `{red-fg}could not open a terminal: ${escapeBraces(result.error)}{/red-fg}`, 'system');
429
- 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}.`);
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 = loadConfig();
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
- footer.setContent(HINTS);
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 === '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
+ }
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(['a'], () => openTool('agent'));
509
- screen.key(['x'], () => stopDevServer());
510
- screen.key(['r'], () => reloadConfig());
511
- screen.key(['j'], () => projectList.down(1));
512
- screen.key(['k'], () => projectList.up(1));
513
- // Tab cycles keyboard focus across the list and the three CTAs; the focused
514
- // CTA lights up and answers to space/enter.
515
- screen.key(['tab'], () => screen.focusNext());
516
- screen.key(['S-tab'], () => screen.focusPrevious());
517
- // 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(); });
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
- // 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
+
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}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
+
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
- 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 },
560
843
  };
561
844
  }
562
845
 
563
- module.exports = { launchDashboard, LAYOUT, HINTS };
846
+ module.exports = { launchDashboard, LAYOUT, HINTS, SAMPLE_LOG_LINES };