bhooai-nexus 2.0.11 → 2.0.13

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.
Files changed (124) hide show
  1. package/docs/ARCHITECTURE.md +3 -1
  2. package/docs/CONVENTIONS.md +60 -10
  3. package/package.json +4 -1
  4. package/packages/nexus-admin/package.json +1 -1
  5. package/packages/nexus-admin/src/App.tsx +128 -3
  6. package/packages/nexus-ads/package.json +1 -1
  7. package/packages/nexus-ai-client/package.json +1 -1
  8. package/packages/nexus-auth/package.json +1 -1
  9. package/packages/nexus-auth/src/csrf.ts +23 -0
  10. package/packages/nexus-cache/package.json +1 -1
  11. package/packages/nexus-cli/package.json +1 -1
  12. package/packages/nexus-cli/src/commands/add.ts +1 -0
  13. package/packages/nexus-cli/src/commands/init.ts +96 -51
  14. package/packages/nexus-cli/src/devPanel.ts +275 -121
  15. package/packages/nexus-cli/src/devServiceManager.ts +26 -12
  16. package/packages/nexus-cli/src/launcher.ts +11 -10
  17. package/packages/nexus-cli/src/layout.ts +45 -5
  18. package/packages/nexus-cli/src/make/generators.ts +125 -9
  19. package/packages/nexus-cli/src/tui.ts +23 -1
  20. package/packages/nexus-cli/src/wizard.ts +32 -35
  21. package/packages/nexus-cli/templates/base/apps/admin/nginx.conf.ejs +5 -0
  22. package/packages/nexus-cli/templates/base/apps/admin/vite.config.ts.ejs +4 -0
  23. package/packages/nexus-cli/templates/base/apps/frontend/nginx.conf.ejs +5 -0
  24. package/packages/nexus-cli/templates/base/apps/frontend/vite.config.ts.ejs +27 -1
  25. package/packages/nexus-cli/templates/base/nexus.config.ts.ejs +19 -16
  26. package/packages/nexus-core/package.json +1 -1
  27. package/packages/nexus-core/src/app/createNexusApp.ts +224 -21
  28. package/packages/nexus-core/src/app/discover.ts +39 -8
  29. package/packages/nexus-core/src/config/defaults.ts +4 -0
  30. package/packages/nexus-core/src/config/runtimeJson.ts +6 -0
  31. package/packages/nexus-core/src/config/schema.ts +4 -0
  32. package/packages/nexus-core/src/config/types.ts +8 -0
  33. package/packages/nexus-crypto/package.json +1 -1
  34. package/packages/nexus-data/package.json +1 -1
  35. package/packages/nexus-email/package.json +1 -1
  36. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/handlers/chat.handler.ts +27 -0
  37. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/middleware/validateChat.ts +3 -0
  38. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/routes/chat.ts +5 -38
  39. package/packages/nexus-examples/examples/ai-chat/apps/backend/src/types/ai.chat.types.ts +7 -0
  40. package/packages/nexus-examples/examples/blog-crud/README.md +8 -6
  41. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/graphql/app.graph.ts +89 -0
  42. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/handlers/blog.handler.ts +63 -0
  43. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/middleware/validatePost.ts +15 -0
  44. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/repositories/PostRepository.ts +20 -0
  45. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/services/PostService.ts +41 -0
  46. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.author.types.ts +16 -0
  47. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.comment.types.ts +15 -0
  48. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.post.types.ts +39 -0
  49. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/validators/post.schema.ts +9 -0
  50. package/packages/nexus-examples/examples/blog-crud/apps/frontend/src/main.tsx +45 -16
  51. package/packages/nexus-examples/examples/chat/apps/backend/src/graphql/app.graph.ts +94 -0
  52. package/packages/nexus-examples/examples/chat/apps/backend/src/handlers/chat.handler.ts +22 -0
  53. package/packages/nexus-examples/examples/chat/apps/backend/src/middleware/validateMessage.ts +11 -0
  54. package/packages/nexus-examples/examples/chat/apps/backend/src/routes/index.ts +11 -0
  55. package/packages/nexus-examples/examples/chat/apps/backend/src/services/ChatService.ts +14 -0
  56. package/packages/nexus-examples/examples/chat/apps/backend/src/types/chat.message.types.ts +23 -0
  57. package/packages/nexus-examples/examples/chat/apps/backend/src/ws/chat.room.ts +18 -55
  58. package/packages/nexus-examples/examples/chat/apps/frontend/src/main.tsx +192 -70
  59. package/packages/nexus-examples/examples/checkout/apps/backend/src/graphql/app.graph.ts +90 -0
  60. package/packages/nexus-examples/examples/checkout/apps/backend/src/handlers/checkout.handler.ts +36 -0
  61. package/packages/nexus-examples/examples/checkout/apps/backend/src/main.ts +28 -0
  62. package/packages/nexus-examples/examples/checkout/apps/backend/src/middleware/validateOrder.ts +3 -0
  63. package/packages/nexus-examples/examples/checkout/apps/backend/src/routes/checkout.ts +5 -70
  64. package/packages/nexus-examples/examples/checkout/apps/backend/src/routes/index.ts +30 -0
  65. package/packages/nexus-examples/examples/checkout/apps/backend/src/types/checkout.order.types.ts +7 -0
  66. package/packages/nexus-examples/examples/checkout/apps/frontend/index.html +13 -0
  67. package/packages/nexus-examples/examples/checkout/apps/frontend/src/main.tsx +190 -18
  68. package/packages/nexus-examples/examples/dashboard/README.md +3 -2
  69. package/packages/nexus-examples/examples/dashboard/apps/backend/src/graphql/app.graph.ts +38 -0
  70. package/packages/nexus-examples/examples/dashboard/apps/backend/src/handlers/metrics.handler.ts +42 -0
  71. package/packages/nexus-examples/examples/dashboard/apps/backend/src/middleware/validateMetric.ts +3 -0
  72. package/packages/nexus-examples/examples/dashboard/apps/backend/src/routes/metrics.ts +6 -70
  73. package/packages/nexus-examples/examples/dashboard/apps/backend/src/types/dashboard.metric.types.ts +7 -0
  74. package/packages/nexus-examples/examples/dashboard/apps/frontend/src/main.tsx +8 -2
  75. package/packages/nexus-examples/examples/file-storage/README.md +4 -2
  76. package/packages/nexus-examples/examples/file-storage/apps/backend/src/graphql/app.graph.ts +46 -0
  77. package/packages/nexus-examples/examples/file-storage/apps/backend/src/handlers/file.handler.ts +45 -0
  78. package/packages/nexus-examples/examples/file-storage/apps/backend/src/middleware/validateFile.ts +3 -0
  79. package/packages/nexus-examples/examples/file-storage/apps/backend/src/routes/files.ts +8 -82
  80. package/packages/nexus-examples/examples/file-storage/apps/backend/src/types/file.file.types.ts +7 -0
  81. package/packages/nexus-examples/examples/file-storage/apps/frontend/src/main.tsx +24 -7
  82. package/packages/nexus-examples/examples/livestream/apps/backend/src/middleware/validateStream.ts +3 -0
  83. package/packages/nexus-examples/examples/livestream/apps/backend/src/types/live.stream.types.ts +7 -0
  84. package/packages/nexus-examples/examples/multi-app/apps/frontend-shop/src/main.tsx +2 -1
  85. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/events/TeamCreated.ts +1 -7
  86. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/events/UserRegistered.ts +1 -6
  87. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/graphql/app.graph.ts +110 -0
  88. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/handlers/project.handler.ts +32 -0
  89. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/handlers/team.handler.ts +75 -0
  90. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/listeners/OnTeamCreated.ts +3 -6
  91. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/listeners/OnUserRegistered.ts +4 -5
  92. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/middleware/auth.ts +2 -5
  93. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/models/Membership.ts +6 -9
  94. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/models/User.ts +0 -2
  95. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/policies/TeamPolicy.ts +0 -9
  96. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/services/TeamService.ts +19 -0
  97. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/types/saas.team.types.ts +10 -0
  98. package/packages/nexus-examples/examples/saas-starter/apps/frontend/src/main.tsx +18 -13
  99. package/packages/nexus-examples/examples/video-call/apps/backend/src/middleware/validateCall.ts +3 -0
  100. package/packages/nexus-examples/examples/video-call/apps/backend/src/types/video.call.types.ts +7 -0
  101. package/packages/nexus-examples/package.json +1 -1
  102. package/packages/nexus-graphql/package.json +6 -1
  103. package/packages/nexus-graphql/src/builtin/helloSubgraph.ts +38 -0
  104. package/packages/nexus-graphql/src/builtin/index.ts +3 -0
  105. package/packages/nexus-graphql/src/explorer/explorerHtml.ts +1642 -0
  106. package/packages/nexus-graphql/src/federation/createFederatedGateway.ts +6 -1
  107. package/packages/nexus-graphql/src/federation/executor.ts +81 -6
  108. package/packages/nexus-graphql/src/federation/parseMetadata.ts +13 -11
  109. package/packages/nexus-graphql/src/gateway/graphqlHttpHandler.ts +31 -4
  110. package/packages/nexus-graphql/src/index.ts +4 -1
  111. package/packages/nexus-graphql/src/mutationCsrf.ts +25 -0
  112. package/packages/nexus-payments/package.json +1 -1
  113. package/packages/nexus-payments/src/providers/PayUProvider.ts +1 -0
  114. package/packages/nexus-plugins/package.json +1 -1
  115. package/packages/nexus-postcss/package.json +1 -1
  116. package/packages/nexus-realtime/package.json +1 -1
  117. package/packages/nexus-safe-goto/package.json +1 -1
  118. package/packages/nexus-safe-goto/src/index.tsx +1 -1
  119. package/packages/nexus-telemetry/package.json +1 -1
  120. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/graphql/post.graph.ts +0 -99
  121. package/packages/nexus-examples/examples/blog-crud/apps/backend/src/routes/posts.ts +0 -119
  122. package/packages/nexus-examples/examples/chat/apps/backend/src/routes/chat.ts +0 -27
  123. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/routes/projects.ts +0 -52
  124. package/packages/nexus-examples/examples/saas-starter/apps/backend/src/routes/teams.ts +0 -112
@@ -18,11 +18,12 @@
18
18
  *
19
19
  * Zero dependencies: raw ANSI + readline keypress via src/tui.ts.
20
20
  */
21
- import { existsSync } from 'node:fs';
21
+ import { existsSync, appendFileSync } from 'node:fs';
22
22
  import { join } from 'node:path';
23
23
  import { stdout as output } from 'node:process';
24
- import { ANSI, Tui, stripAnsi, resolveCliBin, spawnNode, type KeyInfo } from './tui.js';
25
- import { fitCell, clipVisible, divider, boxAround } from './layout.js';
24
+ import { tmpdir } from 'node:os';
25
+ import { ANSI, Tui, stripAnsi, resolveCliBin, spawnNode, getTermSize, type KeyInfo } from './tui.js';
26
+ import { fitCell, clipVisible, divider, boxAround, keyHint, sectionHeader } from './layout.js';
26
27
  import { getCommands, runCommand } from './dispatcher.js';
27
28
  import { ServiceManager } from './devServiceManager.js';
28
29
  import { FEATURES, detectFeatures, installFeature, readRegistry } from './features.js';
@@ -37,12 +38,12 @@ type Tab = 'services' | 'logs' | 'commands' | 'features' | 'settings';
37
38
 
38
39
  let isCapturing = false;
39
40
 
40
- const TABS: Array<{ id: Tab; label: string }> = [
41
- { id: 'services', label: 'Services' },
42
- { id: 'logs', label: 'Logs' },
43
- { id: 'commands', label: 'Commands' },
44
- { id: 'features', label: 'Features' },
45
- { id: 'settings', label: 'Settings' },
41
+ const TABS: Array<{ id: Tab; label: string; icon: string }> = [
42
+ { id: 'services', label: 'Services', icon: '' },
43
+ { id: 'logs', label: 'Logs', icon: '' },
44
+ { id: 'commands', label: 'Commands', icon: '' },
45
+ { id: 'features', label: 'Features', icon: '' },
46
+ { id: 'settings', label: 'Settings', icon: '' },
46
47
  ];
47
48
 
48
49
  /** Per-tab color theme: active tab fills with `bg` + `fg` text; content tints with `accent`. */
@@ -58,6 +59,26 @@ function tabAccent(tab: Tab): string {
58
59
  return TAB_THEME[tab]?.accent ?? ANSI.cyan;
59
60
  }
60
61
 
62
+ /** Normalize key name across platforms (Windows Terminal vs Unix).
63
+ * Node's readline may emit sequence-only on Windows for arrows/Tab/Esc. */
64
+ function effectiveName(key: KeyInfo, str: string): string {
65
+ if (key.name) return key.name;
66
+ const seq = key.sequence;
67
+ if (!seq) return str && str.length === 1 ? str.toLowerCase() : '';
68
+ if (seq === '\r' || seq === '\n' || seq === '\r\n') return 'return';
69
+ if (seq === '\x1b') return 'escape';
70
+ if (seq === '\x09' || seq === '\x1b[Z') return 'tab';
71
+ if (seq === '\x1b[A' || seq === '\x1bOA' || seq === '\x1b[a') return 'up';
72
+ if (seq === '\x1b[B' || seq === '\x1bOB') return 'down';
73
+ if (seq === '\x1b[C' || seq === '\x1bOC') return 'right';
74
+ if (seq === '\x1b[D' || seq === '\x1bOD') return 'left';
75
+ if (seq === '\x7f' || seq === '\x08') return 'backspace';
76
+ if (seq === '\x03') return 'c'; // Ctrl+C raw
77
+ if (seq === ' ' || str === ' ') return 'space';
78
+ if (str && str.length === 1 && !key.ctrl && !key.meta) return str.toLowerCase();
79
+ return '';
80
+ }
81
+
61
82
  /** Commands that need a real TTY (their own prompts) — suspend the panel. */
62
83
  const INTERACTIVE_COMMANDS = new Set(['init', 'add', 'plugin', 'queue:work']);
63
84
 
@@ -126,24 +147,34 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
126
147
  let throttleTimer: ReturnType<typeof setTimeout> | null = null;
127
148
 
128
149
  function throttledRender(): void {
129
- if (isCapturing || state.quit) return;
150
+ if (isCapturing || state.quit) {
151
+ if (throttleTimer) { clearTimeout(throttleTimer); throttleTimer = null; }
152
+ return;
153
+ }
130
154
  const now = Date.now();
131
155
  if (now - lastRender < 80) {
132
156
  if (!throttleTimer) {
133
157
  throttleTimer = setTimeout(() => {
134
158
  throttleTimer = null;
159
+ if (isCapturing || state.quit) return;
135
160
  lastRender = Date.now();
136
- render();
161
+ renderImmediate();
137
162
  }, 80);
138
163
  }
139
164
  return;
140
165
  }
141
166
  lastRender = now;
142
- render();
167
+ renderImmediate();
168
+ }
169
+
170
+ function renderImmediate(): void {
171
+ if (state.quit || isCapturing) return;
172
+ tui.draw(buildRows(state, services, palette, projectRoot), helpText(state));
143
173
  }
144
174
 
145
175
  function render(): void {
146
176
  if (state.quit || isCapturing) return;
177
+ lastRender = Date.now();
147
178
  tui.draw(buildRows(state, services, palette, projectRoot), helpText(state));
148
179
  }
149
180
 
@@ -156,9 +187,12 @@ export async function startDevPanel(opts: DevPanelOptions): Promise<void> {
156
187
 
157
188
  await refreshSettings(state, projectRoot, render);
158
189
  tui.enter();
190
+ const onResize = () => render();
191
+ output.on('resize', onResize);
159
192
  render();
160
193
 
161
194
  await tui.wait();
195
+ output.off('resize', onResize);
162
196
  if (throttleTimer) { clearTimeout(throttleTimer); throttleTimer = null; }
163
197
  tui.exit();
164
198
  services.onLog = null;
@@ -173,25 +207,43 @@ interface Ctx {
173
207
  redraw: () => void;
174
208
  }
175
209
 
210
+ function isCtrlC(str: string, key: KeyInfo, k: string): boolean {
211
+ return key.sequence === '\x03' || str === '\x03' || (k === 'c' && key.ctrl);
212
+ }
213
+
176
214
  function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
215
+ const k = effectiveName(key, str);
216
+ const ctrlC = isCtrlC(str, key, k);
217
+ if (process.env.NEXUS_TUI_DEBUG) {
218
+ try {
219
+ const msg = `[key str=${JSON.stringify(str)} name=${key.name} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} k=${k} ctrlC=${ctrlC} tab=${state.tab}]\n`;
220
+ appendFileSync(join(tmpdir(), 'nexus-tui.log'), msg);
221
+ } catch {}
222
+ }
177
223
  // ---- quit confirmation card ----
178
224
  if (state.confirmQuit) {
179
- if (key.name === 'escape' || key.name === 'q') {
225
+ if (k === 'escape' || k === 'n') {
180
226
  state.confirmQuit = false;
181
227
  ctx.redraw();
182
228
  return;
183
229
  }
184
- if (key.name === 'up' || key.name === 'left') {
230
+ if (ctrlC || k === 'q' || k === 'y') {
231
+ // Second Ctrl+C / q / y inside the card = force quit immediately (quit in one go)
232
+ state.quit = true;
233
+ ctx.tui.quit = true;
234
+ return;
235
+ }
236
+ if (k === 'up' || k === 'left') {
185
237
  state.quitIndex = 0;
186
238
  ctx.redraw();
187
239
  return;
188
240
  }
189
- if (key.name === 'down' || key.name === 'right') {
241
+ if (k === 'down' || k === 'right') {
190
242
  state.quitIndex = 1;
191
243
  ctx.redraw();
192
244
  return;
193
245
  }
194
- if (key.name === 'return' || key.name === 'enter') {
246
+ if (k === 'return' || k === 'enter') {
195
247
  if (state.quitIndex === 0) {
196
248
  state.quit = true; // Yes — quit
197
249
  ctx.tui.quit = true;
@@ -200,12 +252,6 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
200
252
  }
201
253
  return;
202
254
  }
203
- if (key.name === 'c' && key.ctrl) {
204
- // Second Ctrl+C inside the card = force quit immediately.
205
- state.quit = true;
206
- ctx.tui.quit = true;
207
- return;
208
- }
209
255
  return;
210
256
  }
211
257
 
@@ -214,13 +260,13 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
214
260
  ctx.redraw();
215
261
  return;
216
262
  }
217
- if (key.name === 'c' && key.ctrl) {
263
+ if (ctrlC) {
218
264
  state.confirmQuit = true;
219
265
  state.quitIndex = 0;
220
266
  ctx.redraw();
221
267
  return;
222
268
  }
223
- if (key.name === 'escape') {
269
+ if (k === 'escape') {
224
270
  if (state.tab === 'commands' && state.cmdFilter) {
225
271
  state.cmdFilter = '';
226
272
  state.cmdIndex = 0;
@@ -232,7 +278,7 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
232
278
  else { state.confirmQuit = true; state.quitIndex = 0; ctx.redraw(); }
233
279
  return;
234
280
  }
235
- if (key.name === '?') {
281
+ if (k === '?') {
236
282
  state.showHelp = !state.showHelp;
237
283
  ctx.redraw();
238
284
  return;
@@ -253,7 +299,7 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
253
299
  return;
254
300
  }
255
301
  // Backspace in commands tab = delete last filter char.
256
- if (state.tab === 'commands' && key.name === 'backspace' && state.cmdFilter) {
302
+ if (state.tab === 'commands' && k === 'backspace' && state.cmdFilter) {
257
303
  state.cmdFilter = state.cmdFilter.slice(0, -1);
258
304
  const fp = filteredPalette(ctx.palette, state.cmdFilter);
259
305
  state.cmdIndex = Math.min(state.cmdIndex, fp.length - 1);
@@ -264,14 +310,14 @@ function onKey(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
264
310
  ctx.redraw();
265
311
  return;
266
312
  }
267
- if (key.name === 'q') {
313
+ if (k === 'q') {
268
314
  state.confirmQuit = true;
269
315
  state.quitIndex = 0;
270
316
  ctx.redraw();
271
317
  return;
272
318
  }
273
319
 
274
- switch (key.name) {
320
+ switch (k) {
275
321
  case 'tab': {
276
322
  const idx = TABS.findIndex((t) => t.id === state.tab);
277
323
  state.tab = TABS[(idx + 1) % TABS.length]?.id ?? 'services';
@@ -344,17 +390,18 @@ function move(state: PanelState, dir: number, ctx: Ctx): void {
344
390
  function handleArgMode(state: PanelState, str: string, key: KeyInfo, ctx: Ctx): void {
345
391
  const argMode = state.argMode;
346
392
  if (!argMode) return;
347
- if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
393
+ const k = effectiveName(key, str);
394
+ if (k === 'escape' || (key.ctrl && k === 'c')) {
348
395
  state.argMode = null;
349
396
  return;
350
397
  }
351
- if (key.name === 'return' || key.name === 'enter') {
398
+ if (k === 'return' || k === 'enter') {
352
399
  const cmd = argMode.cmd;
353
400
  state.argMode = null;
354
401
  void runSelectedCommand(state, cmd, argMode.buffer, ctx);
355
402
  return;
356
403
  }
357
- if (key.name === 'backspace') {
404
+ if (k === 'backspace') {
358
405
  argMode.buffer = argMode.buffer.slice(0, -1);
359
406
  } else if (str && !key.ctrl && !key.meta) {
360
407
  argMode.buffer += str;
@@ -404,6 +451,34 @@ async function installSelected(state: PanelState, ctx: Ctx): Promise<void> {
404
451
  ctx.redraw();
405
452
  }
406
453
 
454
+ /** Parse a shell-like arg string, respecting single/double quotes and backslash escaping. */
455
+ function parseArgString(input: string): string[] {
456
+ const out: string[] = [];
457
+ let cur = '';
458
+ let inSingle = false;
459
+ let inDouble = false;
460
+ let escaped = false;
461
+ for (let i = 0; i < input.length; i++) {
462
+ const ch = input[i]!;
463
+ if (escaped) {
464
+ cur += ch;
465
+ escaped = false;
466
+ } else if (ch === '\\' && !inSingle) {
467
+ escaped = true;
468
+ } else if (ch === "'" && !inDouble) {
469
+ inSingle = !inSingle;
470
+ } else if (ch === '"' && !inSingle) {
471
+ inDouble = !inDouble;
472
+ } else if (/\s/.test(ch) && !inSingle && !inDouble) {
473
+ if (cur) { out.push(cur); cur = ''; }
474
+ } else {
475
+ cur += ch;
476
+ }
477
+ }
478
+ if (cur) out.push(cur);
479
+ return out;
480
+ }
481
+
407
482
  async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArgs: string, ctx: Ctx): Promise<void> {
408
483
  state.commandOutput = [];
409
484
  ctx.redraw();
@@ -416,7 +491,7 @@ async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArg
416
491
  ctx.tui.suspend();
417
492
  try {
418
493
  const bin = resolveCliBin();
419
- const args = inlineArgs ? [cmd.name, ...inlineArgs.trim().split(/\s+/)] : [cmd.name];
494
+ const args = inlineArgs ? [cmd.name, ...parseArgString(inlineArgs.trim())] : [cmd.name];
420
495
  await spawnNode(bin, args);
421
496
  } catch (err) {
422
497
  push(state, `✗ ${cmd.name} failed: ${(err as Error).message}`);
@@ -428,7 +503,7 @@ async function runSelectedCommand(state: PanelState, cmd: CommandItem, inlineArg
428
503
  return;
429
504
  }
430
505
 
431
- const argv = inlineArgs ? inlineArgs.trim().split(/\s+/) : [];
506
+ const argv = inlineArgs ? parseArgString(inlineArgs.trim()) : [];
432
507
  const origOut = process.stdout.write.bind(process.stdout);
433
508
  const origErr = process.stderr.write.bind(process.stderr);
434
509
  const prevLog2 = ctx.services.onLog;
@@ -491,9 +566,9 @@ async function refreshSettings(state: PanelState, projectRoot: string, redraw: (
491
566
  for (const [app, port] of Object.entries(reg)) {
492
567
  lines.push(` ${app.padEnd(18)} ${port}`);
493
568
  }
494
- if (Object.keys(reg).length === 0) lines.push(` ${ANSI.dim}No ports registered.${ANSI.reset}`);
569
+ if (Object.keys(reg).length === 0) lines.push(` ${ANSI.gray}No ports registered.${ANSI.reset}`);
495
570
  lines.push('', `${ANSI.bold}Environment${ANSI.reset}`);
496
- lines.push(` ${existsSync(join(projectRoot, '.env')) ? `${ANSI.dim}.env present${ANSI.reset}` : `${ANSI.dim}.env not found${ANSI.reset}`}`);
571
+ lines.push(` ${existsSync(join(projectRoot, '.env')) ? `${ANSI.gray}.env present${ANSI.reset}` : `${ANSI.gray}.env not found${ANSI.reset}`}`);
497
572
  state.settingsLines = lines;
498
573
  redraw();
499
574
  }
@@ -577,7 +652,7 @@ function filteredPalette(palette: PaletteRow[], filter: string): PaletteRow[] {
577
652
 
578
653
  /** Available rows for the command list given terminal height and output section. */
579
654
  function commandsViewportHeight(state: PanelState): number {
580
- const H = output.rows || 24;
655
+ const { rows: H } = getTermSize();
581
656
  // Header (2) + filter line (1 if active) + output section (2 + up to 12 lines)
582
657
  const outputH = state.commandOutput.length > 0 ? 2 + Math.min(state.commandOutput.length, 12) : 0;
583
658
  const filterH = state.cmdFilter ? 2 : 0; // filter line + blank
@@ -599,11 +674,20 @@ function clampScroll(state: PanelState, viewportH: number, listLen: number): voi
599
674
  const RAIL_W = 20;
600
675
 
601
676
  function buildRows(state: PanelState, services: ServiceManager, palette: PaletteRow[], projectRoot: string): string[] {
602
- const W = output.columns || 80;
603
- const contentW = Math.max(W - 2, 20);
677
+ const { cols: W } = getTermSize();
678
+ const contentW = Math.max(W - 4, 20);
604
679
  const rows: string[] = [];
605
- rows.push(`${ANSI.bold}${ANSI.cyan} Nexus Console${ANSI.reset}${ANSI.dim} — BhooAI Nexus · ${services.services.length} service(s)${ANSI.reset}`);
606
- rows.push('');
680
+ // ── Header: brand left, project + health right ──
681
+ const projectName = projectRoot.split('/').pop()?.split('\\').pop() || 'project';
682
+ const running = services.services.filter((s) => s.status === 'running').length;
683
+ const total = services.services.length;
684
+ const healthDot = total === 0 ? `${ANSI.dimGray}○${ANSI.reset}` : running === total ? `${ANSI.green}●${ANSI.reset}` : running > 0 ? `${ANSI.yellow}◐${ANSI.reset}` : `${ANSI.red}●${ANSI.reset}`;
685
+ const headerLeft = `${ANSI.bold}${ANSI.cyanBright}Nexus${ANSI.reset} ${ANSI.bold}Console${ANSI.reset}`;
686
+ const headerRight = `${ANSI.gray}·${ANSI.reset} ${ANSI.gray}${projectName}${ANSI.reset} ${healthDot} ${ANSI.bold}${running}${ANSI.reset}${ANSI.gray}/${total}${ANSI.reset} ${ANSI.gray}${total === 1 ? 'service' : 'services'}${ANSI.reset}`;
687
+ // On narrow terminals, show only left to keep top visible
688
+ const headerLine = W < 60 ? headerLeft : `${headerLeft} ${headerRight}`;
689
+ rows.push(clipVisible(headerLine, W));
690
+ rows.push(`${ANSI.gray}${'─'.repeat(Math.min(W, 80))}${ANSI.reset}`);
607
691
  rows.push(renderTabBar(state, W));
608
692
  rows.push(divider(W));
609
693
  rows.push('');
@@ -615,7 +699,7 @@ function buildRows(state: PanelState, services: ServiceManager, palette: Palette
615
699
  const crashed = services.services.filter((s) => s.status === 'crashed');
616
700
  if (crashed.length > 0) {
617
701
  rows.push('');
618
- const msg = ` ⚠ ${crashed.length} service(s) crashed — select and press s to restart `;
702
+ const msg = ` ⚠ ${crashed.length} service(s) crashed — select and press s to restart `;
619
703
  rows.push(`${ANSI.bgRed}${ANSI.white}${ANSI.bold}${fitCell(msg, contentW)}${ANSI.reset}`);
620
704
  }
621
705
 
@@ -625,56 +709,60 @@ function buildRows(state: PanelState, services: ServiceManager, palette: Palette
625
709
  }
626
710
  if (state.argMode) {
627
711
  rows.push('');
628
- rows.push(`${ANSI.cyan}${state.argMode.cmd.name}${ANSI.reset} ${state.argMode.buffer}█`);
712
+ const hint = `${ANSI.gray}Run:${ANSI.reset} ${ANSI.cyan}${state.argMode.cmd.name}${ANSI.reset} ${state.argMode.buffer}${ANSI.cyanBright}█${ANSI.reset} ${ANSI.gray}(Enter to run · Esc to cancel)${ANSI.reset}`;
713
+ rows.push(hint);
629
714
  }
630
715
  if (state.showHelp) {
631
716
  rows.push('');
632
- rows.push(helpBody());
717
+ const helpLines = helpBody().split('\n');
718
+ rows.push(...boxAround(helpLines, Math.min(contentW, 72)));
633
719
  }
634
720
  return rows;
635
721
  }
636
722
 
637
- /** A bordered confirmation card asking whether to quit (kills all services). */
723
+ /** Centered confirmation card with stronger visual weight */
638
724
  function renderQuitCard(state: PanelState, contentW: number): string[] {
639
- const innerW = Math.min(Math.max(contentW - 6, 30), 50);
725
+ const innerW = Math.min(Math.max(contentW - 10, 32), 52);
640
726
  const inner: string[] = [
641
- `${ANSI.bold}${ANSI.yellow}Quit the dev console?${ANSI.reset}`,
727
+ `${ANSI.bold}${ANSI.yellowBright} ⚠ Quit the dev console?${ANSI.reset}`,
642
728
  '',
643
- `${ANSI.dim}All running services will be stopped.${ANSI.reset}`,
729
+ `${ANSI.gray}All running services will be stopped.${ANSI.reset}`,
730
+ `${ANSI.gray}Use ${ANSI.cyan}q${ANSI.gray} again to confirm, or ${ANSI.cyan}Esc${ANSI.gray} to stay.${ANSI.reset}`,
644
731
  '',
645
732
  ];
646
- const options = ['Yes, quit', 'Cancel'];
733
+ const options = ['Yes, quit — stop all', 'Cancel — stay'];
647
734
  options.forEach((label, i) => {
648
735
  const sel = i === state.quitIndex;
649
- const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
650
- const text = sel ? `${ANSI.bold}${label}${ANSI.reset}` : label;
651
- inner.push(`${marker} ${text}`);
736
+ const marker = sel ? `${ANSI.cyanBright}▶${ANSI.reset}` : ' ';
737
+ const text = sel ? `${ANSI.cyanBright}${ANSI.bold}${label}${ANSI.reset}` : `${ANSI.gray}${label}${ANSI.reset}`;
738
+ const line = sel ? `${marker} ${text} ${ANSI.cyan}◀${ANSI.reset}` : `${marker} ${text}`;
739
+ inner.push(line);
652
740
  });
653
- return boxAround(inner, innerW);
741
+ return boxAround(inner, innerW, ANSI.white);
654
742
  }
655
743
 
656
- /** Horizontal tab bar across the top. Active tab = filled background in its theme; others = dim. */
744
+ /** Tab bar: only selected tab has background color, others are plain white text */
657
745
  function renderTabBar(state: PanelState, W: number): string {
658
- const cellW = Math.max(Math.floor(W / TABS.length), 8);
659
746
  const parts: string[] = [];
660
747
  for (const t of TABS) {
661
- const theme = TAB_THEME[t.id];
662
748
  const active = t.id === state.tab;
663
- if (active && theme) {
664
- // Filled cell: bg + readable fg + bold label, padded with bg spaces.
665
- const label = ` ${t.label} `;
666
- const cell = `${theme.bg}${theme.fg}${ANSI.bold}${fitCell(label, cellW)}${ANSI.reset}`;
749
+ const theme = TAB_THEME[t.id];
750
+ const base = t.icon ? `${t.icon} ${t.label}` : t.label;
751
+ const label = ` ${base} `;
752
+ if (active) {
753
+ // Selected: per-tab bg + fg + bold
754
+ const cell = `${theme.bg}${theme.fg}${ANSI.bold}${label}${ANSI.reset}`;
667
755
  parts.push(cell);
668
756
  } else {
669
- const accent = theme?.accent ?? ANSI.dimGray;
670
- const label = ` ${t.label} `;
671
- const cell = `${ANSI.dim}${accent}${fitCell(label, cellW)}${ANSI.reset}`;
672
- parts.push(cell);
757
+ // Unselected: no background, white text only — same padded width as active to keep alignment
758
+ parts.push(`${ANSI.white}${label}${ANSI.reset}`);
673
759
  }
674
760
  }
675
- // Join with single spaces; clip to width.
676
- const bar = parts.join(' ');
677
- return clipVisible(bar, W);
761
+ const sep = ` `;
762
+ const bar = parts.join(sep);
763
+ const tabHint = `${ANSI.gray} -- ${state.tab}${ANSI.reset}`;
764
+ const full = `${bar}${tabHint}`;
765
+ return clipVisible(full, W);
678
766
  }
679
767
 
680
768
  function renderContent(state: PanelState, services: ServiceManager, palette: PaletteRow[], projectRoot: string, contentW: number): string[] {
@@ -698,41 +786,69 @@ function statusDot(status: string): string {
698
786
  }
699
787
 
700
788
  function renderServices(state: PanelState, services: ServiceManager, accent: string, _contentW: number): string[] {
701
- const out = [`${ANSI.bold}${accent} SERVICES${ANSI.reset}${ANSI.dim} (s start · t stop · r restart)${ANSI.reset}`, ''];
789
+ const count = services.services.length;
790
+ const running = services.services.filter((s) => s.status === 'running').length;
791
+ const subtitle = count === 0 ? 'no services' : `${running}/${count} running · s start · t stop · r restart`;
792
+ const out = [sectionHeader('SERVICES', accent, subtitle), ''];
793
+ if (count === 0) {
794
+ out.push(...boxAround([`${ANSI.gray}No services discovered.${ANSI.reset}`, `${ANSI.gray}Check that ${ANSI.cyan}apps/*${ANSI.gray} exists and has a runnable entry.${ANSI.reset}`], Math.min(_contentW - 4, 56)));
795
+ return out;
796
+ }
797
+ // Table header — widths match row columns (STATUS includes dot+space => 12)
798
+ const header = ` ${fitCell(`${ANSI.gray}SERVICE${ANSI.reset}`, 18)} ${fitCell(`${ANSI.gray}PORT${ANSI.reset}`, 6)} ${fitCell(`${ANSI.gray}STATUS${ANSI.reset}`, 12)} ${fitCell(`${ANSI.gray}PID${ANSI.reset}`, 7)} ${fitCell(`${ANSI.gray}RESTARTS${ANSI.reset}`, 8)}`;
799
+ out.push(header);
800
+ out.push(`${ANSI.gray} ${'─'.repeat(18)} ${'─'.repeat(6)} ${'─'.repeat(12)} ${'─'.repeat(7)} ${'─'.repeat(8)}${ANSI.reset}`);
702
801
  services.services.forEach((svc, i) => {
703
802
  const sel = i === state.svcIndex;
704
- const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
705
- const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
706
- const pid = svc.pid ? String(svc.pid) : '—';
707
- const restarts = svc.restarts ? String(svc.restarts) : '0';
708
- const exitCode = svc.lastExit?.code != null ? ` exit:${svc.lastExit.code}` : '';
709
- const exitTag = svc.status === 'crashed' ? `${ANSI.red}${exitCode}${ANSI.reset}` : '';
710
- out.push(` ${marker} ${fitCell(name, 18)} :${fitCell(String(svc.port), 6)} ${statusDot(svc.status)} ${fitCell(svc.status, 8)} ${fitCell(pid, 7)} ${restarts}${exitTag}`);
803
+ const marker = sel ? `${ANSI.cyanBright}▶${ANSI.reset}` : ' ';
804
+ const name = sel ? `${ANSI.bold}${ANSI.cyanBright}${svc.name}${ANSI.reset}` : svc.name;
805
+ const pid = svc.pid ? String(svc.pid) : `${ANSI.gray}—${ANSI.reset}`;
806
+ const restarts = svc.restarts ? String(svc.restarts) : `${ANSI.gray}0${ANSI.reset}`;
807
+ const exitCode = svc.lastExit?.code != null ? ` ${ANSI.gray}exit:${svc.lastExit.code}${ANSI.reset}` : '';
808
+ const statusText = svc.status === 'running' ? `${ANSI.green}${svc.status}${ANSI.reset}` : svc.status === 'starting' ? `${ANSI.yellow}${svc.status}${ANSI.reset}` : svc.status === 'crashed' ? `${ANSI.red}${svc.status}${ANSI.reset}` : `${ANSI.gray}${svc.status}${ANSI.reset}`;
809
+ const rowBg = sel ? `${ANSI.inverse}` : '';
810
+ const rowEnd = sel ? `${ANSI.reset}` : '';
811
+ // Status column is dot + space + text (12 cols) to align with header
812
+ const statusCell = fitCell(`${statusDot(svc.status)} ${statusText}`, 12);
813
+ out.push(` ${marker} ${fitCell(name, 18)} ${fitCell(String(svc.port), 6)} ${statusCell} ${fitCell(pid, 7)} ${fitCell(`${restarts}${exitCode}`, 8)}${rowEnd}`);
711
814
  // Inline crash log under the crashed service row.
712
815
  if (svc.status === 'crashed' && svc.lastCrashLog.length > 0) {
713
816
  for (const line of svc.lastCrashLog.slice(-4)) {
714
- out.push(` ${ANSI.red}│${ANSI.reset} ${clipVisible(line, _contentW - 6)}`);
817
+ out.push(` ${ANSI.red}│${ANSI.reset} ${ANSI.gray}${clipVisible(line, _contentW - 6)}${ANSI.reset}`);
715
818
  }
716
819
  }
717
820
  });
718
- if (services.services.length === 0) out.push(` ${ANSI.dim}No services discovered.${ANSI.reset}`);
719
821
  return out;
720
822
  }
721
823
 
722
824
  function renderLogs(state: PanelState, services: ServiceManager, accent: string, contentW: number): string[] {
723
- const out = [`${ANSI.bold}${accent} LOGS${ANSI.reset}${ANSI.dim} (↑/↓ pick service)${ANSI.reset}`, ''];
825
+ const out = [sectionHeader('LOGS', accent, '↑/↓ pick service · logs tail below'), ''];
826
+ // Service picker as compact table — header widths match row cells
827
+ const header = ` ${fitCell(`${ANSI.gray}SERVICE${ANSI.reset}`, 18)} ${fitCell(`${ANSI.gray}PORT${ANSI.reset}`, 6)} ${fitCell(`${ANSI.gray}STATUS${ANSI.reset}`, 12)}`;
828
+ out.push(header);
829
+ out.push(`${ANSI.gray} ${'─'.repeat(18)} ${'─'.repeat(6)} ${'─'.repeat(12)}${ANSI.reset}`);
724
830
  services.services.forEach((svc, i) => {
725
831
  const sel = i === state.svcIndex;
726
- const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
727
- const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
728
- const exitTag = svc.status === 'crashed' && svc.lastExit?.code != null ? `${ANSI.red} exit:${svc.lastExit.code}${ANSI.reset}` : '';
729
- out.push(` ${marker} ${statusDot(svc.status)} ${fitCell(name, 18)} :${svc.port}${exitTag}`);
832
+ const marker = sel ? `${ANSI.cyanBright}▶${ANSI.reset}` : ' ';
833
+ const name = sel ? `${ANSI.bold}${ANSI.cyanBright}${svc.name}${ANSI.reset}` : svc.name;
834
+ const exitTag = svc.status === 'crashed' && svc.lastExit?.code != null ? ` ${ANSI.red}exit:${svc.lastExit.code}${ANSI.reset}` : '';
835
+ const statusCell = fitCell(`${svc.status}${exitTag}`, 12);
836
+ out.push(` ${marker} ${statusDot(svc.status)} ${fitCell(name, 18)} ${fitCell(String(svc.port), 6)} ${statusCell}`);
730
837
  });
731
838
  out.push('');
732
839
  const source = services.services[state.svcIndex]?.name ?? '';
733
- const tail = source ? services.tail(source, 20) : [];
734
- for (const line of tail) out.push(` ${ANSI.dim}|${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
735
- if (tail.length === 0) out.push(` ${ANSI.dim}No logs yet for ${source || 'selected service'}.${ANSI.reset}`);
840
+ const { rows: H } = getTermSize();
841
+ const maxTail = Math.max(Math.min(20, H - 12 - services.services.length), 4);
842
+ const tail = source ? services.tail(source, maxTail) : [];
843
+ // Log pane header
844
+ const logHeader = `${ANSI.gray}— ${ANSI.bold}${accent}${source || 'no service'}${ANSI.reset}${ANSI.gray} — ${tail.length} lines —${ANSI.reset}`;
845
+ out.push(logHeader);
846
+ out.push(`${ANSI.gray}${'─'.repeat(Math.min(contentW, 40))}${ANSI.reset}`);
847
+ if (tail.length === 0) {
848
+ out.push(` ${ANSI.gray}No logs yet for ${source || 'selected service'}. Start a service with ${ANSI.cyan}s${ANSI.gray}.${ANSI.reset}`);
849
+ } else {
850
+ for (const line of tail) out.push(` ${ANSI.gray}│${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
851
+ }
736
852
  return out;
737
853
  }
738
854
 
@@ -741,20 +857,18 @@ function renderCommands(state: PanelState, palette: PaletteRow[], accent: string
741
857
  const vh = commandsViewportHeight(state);
742
858
  clampScroll(state, vh, fp.length);
743
859
 
744
- const filterHint = state.cmdFilter
745
- ? `${ANSI.dim} (type to filter · Esc clears · Backspace deletes)${ANSI.reset}`
746
- : `${ANSI.dim} (Enter run · args inline · type to filter)${ANSI.reset}`;
747
- const out = [`${ANSI.bold}${accent} COMMANDS${ANSI.reset}${filterHint}`, ''];
860
+ const subtitle = state.cmdFilter ? `filter: "${state.cmdFilter}" · Esc clears` : `Enter run · type to filter · ↑/↓ navigate`;
861
+ const out = [sectionHeader('COMMANDS', accent, subtitle), ''];
748
862
 
749
- // Filter input line.
863
+ // Filter input line — more prominent with pill styling
750
864
  if (state.cmdFilter) {
751
- out.push(` ${ANSI.cyan}Filter:${ANSI.reset} ${state.cmdFilter}█`);
865
+ out.push(` ${ANSI.gray}Filter${ANSI.reset} ${ANSI.cyanBright}${ANSI.bold}${state.cmdFilter}${ANSI.reset}${ANSI.cyanBright}█${ANSI.reset} ${ANSI.gray}(${fp.filter((r) => r.kind === 'cmd').length} matches)${ANSI.reset}`);
752
866
  out.push('');
753
867
  }
754
868
 
755
- // Scroll-up indicator.
869
+ // Scroll-up indicator with subtle styling
756
870
  if (state.cmdScroll > 0) {
757
- out.push(` ${ANSI.dim}↑ ${state.cmdScroll} more above${ANSI.reset}`);
871
+ out.push(` ${ANSI.gray}↑ ${state.cmdScroll} more above — scroll up${ANSI.reset}`);
758
872
  }
759
873
 
760
874
  const start = state.cmdScroll;
@@ -762,73 +876,113 @@ function renderCommands(state: PanelState, palette: PaletteRow[], accent: string
762
876
  for (let i = start; i < end; i++) {
763
877
  const row = fp[i]!;
764
878
  if (row.kind === 'header') {
765
- out.push(` ${ANSI.dim}${row.label.toUpperCase()}${ANSI.reset}`);
879
+ const label = `─ ${row.label.toUpperCase()} ─`;
880
+ out.push(` ${ANSI.gray}${label}${ANSI.reset}`);
766
881
  } else {
767
882
  const isSelected = i === state.cmdIndex;
768
- const marker = isSelected ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
769
- const label = isSelected ? `${ANSI.bold}${row.cmd.name}${ANSI.reset}` : row.cmd.name;
770
- const arg = row.cmd.needsArgs ? `${ANSI.dim} <args>${ANSI.reset}` : '';
771
- const desc = row.cmd.desc ? `${ANSI.dim}${row.cmd.desc}${ANSI.reset}` : '';
883
+ const marker = isSelected ? `${ANSI.cyanBright}▶${ANSI.reset}` : ' ';
884
+ const label = isSelected ? `${ANSI.bold}${ANSI.cyanBright}${row.cmd.name}${ANSI.reset}` : row.cmd.name;
885
+ const arg = row.cmd.needsArgs ? `${ANSI.gray} ${ANSI.italic}<args>${ANSI.reset}` : '';
886
+ const desc = row.cmd.desc ? `${ANSI.gray}${row.cmd.desc}${ANSI.reset}` : '';
772
887
  const head = ` ${marker} ${fitCell(label, 20)}${arg}`;
773
- out.push(`${head} ${clipVisible(desc, Math.max(contentW - 24, 8))}`);
888
+ const line = `${head} ${clipVisible(desc, Math.max(contentW - 26, 8))}`;
889
+ out.push(line);
774
890
  }
775
891
  }
776
892
 
777
893
  // Scroll-down indicator.
778
894
  if (end < fp.length) {
779
- out.push(` ${ANSI.dim}↓ ${fp.length - end} more below${ANSI.reset}`);
895
+ out.push(` ${ANSI.gray}↓ ${fp.length - end} more below — keep scrolling${ANSI.reset}`);
780
896
  }
781
897
 
782
- // Empty filter result.
898
+ // Empty filter result with helpful box
783
899
  if (fp.length === 0) {
784
- out.push(` ${ANSI.dim}No commands match "${state.cmdFilter}"${ANSI.reset}`);
900
+ out.push('');
901
+ out.push(...boxAround([`${ANSI.gray}No commands match "${state.cmdFilter}"${ANSI.reset}`, `${ANSI.gray}Try a different filter or clear with ${ANSI.cyan}Esc${ANSI.gray}.${ANSI.reset}`], Math.min(contentW - 4, 48)));
785
902
  }
786
903
 
787
904
  if (state.commandOutput.length > 0) {
788
905
  out.push('');
789
- out.push(`${ANSI.bold}${ANSI.yellow} OUTPUT${ANSI.reset}`);
790
- for (const line of state.commandOutput.slice(-12)) out.push(` ${ANSI.dim}|${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
906
+ const outHeader = `${ANSI.bold}${ANSI.yellowBright}▸ OUTPUT${ANSI.reset} ${ANSI.gray}(${state.commandOutput.length} lines)${ANSI.reset}`;
907
+ out.push(outHeader);
908
+ out.push(`${ANSI.gray}${'─'.repeat(Math.min(contentW, 36))}${ANSI.reset}`);
909
+ for (const line of state.commandOutput.slice(-12)) out.push(` ${ANSI.gray}│${ANSI.reset} ${clipVisible(line, contentW - 3)}`);
791
910
  }
792
911
  return out;
793
912
  }
794
913
 
795
914
  function renderFeaturesTab(state: PanelState, accent: string, contentW: number): string[] {
796
- const out = [`${ANSI.bold}${accent} FEATURES${ANSI.reset}${ANSI.dim} (a or Enter installs)${ANSI.reset}`, ''];
797
- const statusW = 12;
915
+ const installedCount = state.installed.size;
916
+ const subtitle = `${installedCount}/${FEATURES.length} installed · a or Enter installs`;
917
+ const out = [sectionHeader('FEATURES', accent, subtitle), ''];
918
+ const statusW = 13;
798
919
  const descW = Math.max(contentW - 18 - statusW - 6, 8);
920
+ // Header row
921
+ out.push(` ${fitCell(`${ANSI.gray}FEATURE${ANSI.reset}`, 18)} ${fitCell(`${ANSI.gray}STATUS${ANSI.reset}`, statusW)} ${ANSI.gray}DESCRIPTION${ANSI.reset}`);
922
+ out.push(`${ANSI.gray} ${'─'.repeat(18)} ${'─'.repeat(statusW)} ${'─'.repeat(descW)}${ANSI.reset}`);
799
923
  FEATURES.forEach((f, i) => {
800
924
  const sel = i === state.featIndex;
801
925
  const on = state.installed.has(f.id);
802
- const marker = sel ? `${ANSI.cyan}▶${ANSI.reset}` : ' ';
803
- const status = on ? `${ANSI.green}installed${ANSI.reset}` : `${ANSI.dimGray}available${ANSI.reset}`;
804
- const name = sel ? `${ANSI.bold}${f.name}${ANSI.reset}` : f.name;
805
- out.push(` ${marker} ${fitCell(name, 18)} ${fitCell(status, statusW)} ${clipVisible(`${ANSI.dim}${f.desc}${ANSI.reset}`, descW)}`);
926
+ const marker = sel ? `${ANSI.cyanBright}▶${ANSI.reset}` : ' ';
927
+ const status = on ? `${ANSI.green}● installed${ANSI.reset}` : `${ANSI.gray}○ available${ANSI.reset}`;
928
+ const statusCell = on ? `${ANSI.greenBright}${fitCell(status, statusW)}${ANSI.reset}` : fitCell(status, statusW);
929
+ const name = sel ? `${ANSI.bold}${ANSI.cyanBright}${f.name}${ANSI.reset}` : f.name;
930
+ const desc = `${ANSI.gray}${f.desc}${ANSI.reset}`;
931
+ out.push(` ${marker} ${fitCell(name, 18)} ${statusCell} ${clipVisible(desc, descW)}`);
806
932
  });
807
933
  if (state.featureMsg.length > 0) {
808
934
  out.push('');
809
- for (const m of state.featureMsg) out.push(` ${ANSI.dim}·${ANSI.reset} ${clipVisible(m, contentW - 3)}`);
935
+ out.push(`${ANSI.gray}${'─'.repeat(Math.min(contentW, 40))}${ANSI.reset}`);
936
+ for (const m of state.featureMsg) {
937
+ const isSuccess = m.includes('installed') || m.includes('✓');
938
+ const bullet = isSuccess ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.gray}·${ANSI.reset}`;
939
+ out.push(` ${bullet} ${clipVisible(isSuccess ? `${ANSI.green}${m}${ANSI.reset}` : m, contentW - 3)}`);
940
+ }
810
941
  }
811
942
  return out;
812
943
  }
813
944
 
814
945
  function renderSettingsTab(state: PanelState, accent: string): string[] {
815
- return [
816
- `${ANSI.bold}${accent} SETTINGS${ANSI.reset}${ANSI.dim} (d runs doctor)${ANSI.reset}`,
817
- '',
818
- ...state.settingsLines,
819
- ];
946
+ const out = [sectionHeader('SETTINGS', accent, 'd runs doctor · environment & ports'), ''];
947
+ // Style the settingsLines with subtle indentation and accent
948
+ for (const line of state.settingsLines) {
949
+ if (line.startsWith(' ') || line.startsWith(`${ANSI.bold}`)) {
950
+ out.push(line);
951
+ } else if (line.trim() === '') {
952
+ out.push('');
953
+ } else {
954
+ out.push(`${ANSI.gray}${line}${ANSI.reset}`);
955
+ }
956
+ }
957
+ if (state.settingsLines.length === 0) {
958
+ out.push(`${ANSI.gray}Loading settings...${ANSI.reset}`);
959
+ }
960
+ out.push('');
961
+ out.push(`${ANSI.gray}Tip: edit ${ANSI.cyan}.env${ANSI.gray} or ${ANSI.cyan}.nexus-ports.json${ANSI.gray} then restart.${ANSI.reset}`);
962
+ return out;
820
963
  }
821
964
 
822
965
  function helpBody(): string {
823
- return `${ANSI.dim}Tab/←/→ switch tab · ↑/↓ select · Enter run/start/install · s/t/r service controls
824
- l logs · a install feature · d doctor · ? help · q/Esc quit (kills services)${ANSI.reset}`;
966
+ // Multi-line boxed help with grouped key hints
967
+ const cols = [
968
+ `${keyHint('Tab', 'switch tab')} ${ANSI.gray}·${ANSI.reset} ${keyHint('←→', 'tab')} ${ANSI.gray}·${ANSI.reset} ${keyHint('↑↓', 'move')}`,
969
+ `${keyHint('Enter', 'run/start')} ${ANSI.gray}·${ANSI.reset} ${keyHint('s/t/r', 'service')} ${ANSI.gray}·${ANSI.reset} ${keyHint('l', 'logs')}`,
970
+ `${keyHint('a', 'feature')} ${ANSI.gray}·${ANSI.reset} ${keyHint('d', 'doctor')} ${ANSI.gray}·${ANSI.reset} ${keyHint('?', 'help')} ${ANSI.gray}·${ANSI.reset} ${keyHint('q', 'quit')}`,
971
+ ];
972
+ return cols.join(`\n${ANSI.gray} ${'─'.repeat(48)}${ANSI.reset}\n`);
825
973
  }
826
974
 
827
975
  function helpText(state: PanelState): string {
828
- if (state.confirmQuit) return `${ANSI.dim}↑/↓ choose · Enter confirm · Esc cancel · Ctrl+C force quit${ANSI.reset}`;
829
- if (state.showHelp) return ` ? help · q quit${ANSI.dim} (kills all services on quit)${ANSI.reset}`;
830
- if (state.argMode) return `↑/↓ navigate · Enter run · Esc cancel`;
831
- return `${ANSI.dim}Tab pane · ↑/↓ select · Enter run · s/t/r start/stop/restart · l logs · a add feature · q quit · ? help${ANSI.reset}`;
976
+ if (state.confirmQuit) return `${keyHint('↑↓', 'choose')} ${ANSI.gray}·${ANSI.reset} ${keyHint('Enter', 'confirm')} ${ANSI.gray}·${ANSI.reset} ${keyHint('Esc', 'cancel')} ${ANSI.gray}·${ANSI.reset} ${keyHint('Ctrl+C', 'force quit')}`;
977
+ if (state.showHelp) return `${keyHint('?', 'help')} ${ANSI.gray}·${ANSI.reset} ${keyHint('q', 'quit')} ${ANSI.gray} — services stop on quit${ANSI.reset}`;
978
+ if (state.argMode) return `${keyHint('↑↓', 'navigate')} ${ANSI.gray}·${ANSI.reset} ${keyHint('Enter', 'run')} ${ANSI.gray}·${ANSI.reset} ${keyHint('Esc', 'cancel')}`;
979
+ // Context-aware footer: show relevant keys for current tab
980
+ const tab = state.tab;
981
+ if (tab === 'services') return `${keyHint('Tab', 'tab')} ${keyHint('↑↓', 'select')} ${keyHint('Enter/s', 'start')} ${keyHint('t/r', 'stop/restart')} ${keyHint('l', 'logs')} ${keyHint('?', 'help')}`;
982
+ if (tab === 'logs') return `${keyHint('Tab', 'tab')} ${keyHint('↑↓', 'service')} ${keyHint('l', 'back')} ${keyHint('q', 'quit')}`;
983
+ if (tab === 'commands') return `${keyHint('↑↓', 'pick')} ${keyHint('Enter', 'run')} ${keyHint('type', 'filter')} ${keyHint('Esc', 'clear')}`;
984
+ if (tab === 'features') return `${keyHint('↑↓', 'pick')} ${keyHint('a/Enter', 'install')} ${keyHint('Tab', 'switch')}`;
985
+ return `${keyHint('Tab', 'switch')} ${keyHint('↑↓', 'move')} ${keyHint('Enter', 'action')} ${keyHint('q', 'quit')} ${keyHint('?', 'help')}`;
832
986
  }
833
987
 
834
988
  function clamp(n: number, lo: number, hi: number): number {