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.
- package/docs/ARCHITECTURE.md +3 -1
- package/docs/CONVENTIONS.md +60 -10
- package/package.json +4 -1
- package/packages/nexus-admin/package.json +1 -1
- package/packages/nexus-admin/src/App.tsx +128 -3
- package/packages/nexus-ads/package.json +1 -1
- package/packages/nexus-ai-client/package.json +1 -1
- package/packages/nexus-auth/package.json +1 -1
- package/packages/nexus-auth/src/csrf.ts +23 -0
- package/packages/nexus-cache/package.json +1 -1
- package/packages/nexus-cli/package.json +1 -1
- package/packages/nexus-cli/src/commands/add.ts +1 -0
- package/packages/nexus-cli/src/commands/init.ts +96 -51
- package/packages/nexus-cli/src/devPanel.ts +275 -121
- package/packages/nexus-cli/src/devServiceManager.ts +26 -12
- package/packages/nexus-cli/src/launcher.ts +11 -10
- package/packages/nexus-cli/src/layout.ts +45 -5
- package/packages/nexus-cli/src/make/generators.ts +125 -9
- package/packages/nexus-cli/src/tui.ts +23 -1
- package/packages/nexus-cli/src/wizard.ts +32 -35
- package/packages/nexus-cli/templates/base/apps/admin/nginx.conf.ejs +5 -0
- package/packages/nexus-cli/templates/base/apps/admin/vite.config.ts.ejs +4 -0
- package/packages/nexus-cli/templates/base/apps/frontend/nginx.conf.ejs +5 -0
- package/packages/nexus-cli/templates/base/apps/frontend/vite.config.ts.ejs +27 -1
- package/packages/nexus-cli/templates/base/nexus.config.ts.ejs +19 -16
- package/packages/nexus-core/package.json +1 -1
- package/packages/nexus-core/src/app/createNexusApp.ts +224 -21
- package/packages/nexus-core/src/app/discover.ts +39 -8
- package/packages/nexus-core/src/config/defaults.ts +4 -0
- package/packages/nexus-core/src/config/runtimeJson.ts +6 -0
- package/packages/nexus-core/src/config/schema.ts +4 -0
- package/packages/nexus-core/src/config/types.ts +8 -0
- package/packages/nexus-crypto/package.json +1 -1
- package/packages/nexus-data/package.json +1 -1
- package/packages/nexus-email/package.json +1 -1
- package/packages/nexus-examples/examples/ai-chat/apps/backend/src/handlers/chat.handler.ts +27 -0
- package/packages/nexus-examples/examples/ai-chat/apps/backend/src/middleware/validateChat.ts +3 -0
- package/packages/nexus-examples/examples/ai-chat/apps/backend/src/routes/chat.ts +5 -38
- package/packages/nexus-examples/examples/ai-chat/apps/backend/src/types/ai.chat.types.ts +7 -0
- package/packages/nexus-examples/examples/blog-crud/README.md +8 -6
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/graphql/app.graph.ts +89 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/handlers/blog.handler.ts +63 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/middleware/validatePost.ts +15 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/repositories/PostRepository.ts +20 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/services/PostService.ts +41 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.author.types.ts +16 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.comment.types.ts +15 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/types/blog.post.types.ts +39 -0
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/validators/post.schema.ts +9 -0
- package/packages/nexus-examples/examples/blog-crud/apps/frontend/src/main.tsx +45 -16
- package/packages/nexus-examples/examples/chat/apps/backend/src/graphql/app.graph.ts +94 -0
- package/packages/nexus-examples/examples/chat/apps/backend/src/handlers/chat.handler.ts +22 -0
- package/packages/nexus-examples/examples/chat/apps/backend/src/middleware/validateMessage.ts +11 -0
- package/packages/nexus-examples/examples/chat/apps/backend/src/routes/index.ts +11 -0
- package/packages/nexus-examples/examples/chat/apps/backend/src/services/ChatService.ts +14 -0
- package/packages/nexus-examples/examples/chat/apps/backend/src/types/chat.message.types.ts +23 -0
- package/packages/nexus-examples/examples/chat/apps/backend/src/ws/chat.room.ts +18 -55
- package/packages/nexus-examples/examples/chat/apps/frontend/src/main.tsx +192 -70
- package/packages/nexus-examples/examples/checkout/apps/backend/src/graphql/app.graph.ts +90 -0
- package/packages/nexus-examples/examples/checkout/apps/backend/src/handlers/checkout.handler.ts +36 -0
- package/packages/nexus-examples/examples/checkout/apps/backend/src/main.ts +28 -0
- package/packages/nexus-examples/examples/checkout/apps/backend/src/middleware/validateOrder.ts +3 -0
- package/packages/nexus-examples/examples/checkout/apps/backend/src/routes/checkout.ts +5 -70
- package/packages/nexus-examples/examples/checkout/apps/backend/src/routes/index.ts +30 -0
- package/packages/nexus-examples/examples/checkout/apps/backend/src/types/checkout.order.types.ts +7 -0
- package/packages/nexus-examples/examples/checkout/apps/frontend/index.html +13 -0
- package/packages/nexus-examples/examples/checkout/apps/frontend/src/main.tsx +190 -18
- package/packages/nexus-examples/examples/dashboard/README.md +3 -2
- package/packages/nexus-examples/examples/dashboard/apps/backend/src/graphql/app.graph.ts +38 -0
- package/packages/nexus-examples/examples/dashboard/apps/backend/src/handlers/metrics.handler.ts +42 -0
- package/packages/nexus-examples/examples/dashboard/apps/backend/src/middleware/validateMetric.ts +3 -0
- package/packages/nexus-examples/examples/dashboard/apps/backend/src/routes/metrics.ts +6 -70
- package/packages/nexus-examples/examples/dashboard/apps/backend/src/types/dashboard.metric.types.ts +7 -0
- package/packages/nexus-examples/examples/dashboard/apps/frontend/src/main.tsx +8 -2
- package/packages/nexus-examples/examples/file-storage/README.md +4 -2
- package/packages/nexus-examples/examples/file-storage/apps/backend/src/graphql/app.graph.ts +46 -0
- package/packages/nexus-examples/examples/file-storage/apps/backend/src/handlers/file.handler.ts +45 -0
- package/packages/nexus-examples/examples/file-storage/apps/backend/src/middleware/validateFile.ts +3 -0
- package/packages/nexus-examples/examples/file-storage/apps/backend/src/routes/files.ts +8 -82
- package/packages/nexus-examples/examples/file-storage/apps/backend/src/types/file.file.types.ts +7 -0
- package/packages/nexus-examples/examples/file-storage/apps/frontend/src/main.tsx +24 -7
- package/packages/nexus-examples/examples/livestream/apps/backend/src/middleware/validateStream.ts +3 -0
- package/packages/nexus-examples/examples/livestream/apps/backend/src/types/live.stream.types.ts +7 -0
- package/packages/nexus-examples/examples/multi-app/apps/frontend-shop/src/main.tsx +2 -1
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/events/TeamCreated.ts +1 -7
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/events/UserRegistered.ts +1 -6
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/graphql/app.graph.ts +110 -0
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/handlers/project.handler.ts +32 -0
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/handlers/team.handler.ts +75 -0
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/listeners/OnTeamCreated.ts +3 -6
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/listeners/OnUserRegistered.ts +4 -5
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/middleware/auth.ts +2 -5
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/models/Membership.ts +6 -9
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/models/User.ts +0 -2
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/policies/TeamPolicy.ts +0 -9
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/services/TeamService.ts +19 -0
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/types/saas.team.types.ts +10 -0
- package/packages/nexus-examples/examples/saas-starter/apps/frontend/src/main.tsx +18 -13
- package/packages/nexus-examples/examples/video-call/apps/backend/src/middleware/validateCall.ts +3 -0
- package/packages/nexus-examples/examples/video-call/apps/backend/src/types/video.call.types.ts +7 -0
- package/packages/nexus-examples/package.json +1 -1
- package/packages/nexus-graphql/package.json +6 -1
- package/packages/nexus-graphql/src/builtin/helloSubgraph.ts +38 -0
- package/packages/nexus-graphql/src/builtin/index.ts +3 -0
- package/packages/nexus-graphql/src/explorer/explorerHtml.ts +1642 -0
- package/packages/nexus-graphql/src/federation/createFederatedGateway.ts +6 -1
- package/packages/nexus-graphql/src/federation/executor.ts +81 -6
- package/packages/nexus-graphql/src/federation/parseMetadata.ts +13 -11
- package/packages/nexus-graphql/src/gateway/graphqlHttpHandler.ts +31 -4
- package/packages/nexus-graphql/src/index.ts +4 -1
- package/packages/nexus-graphql/src/mutationCsrf.ts +25 -0
- package/packages/nexus-payments/package.json +1 -1
- package/packages/nexus-payments/src/providers/PayUProvider.ts +1 -0
- package/packages/nexus-plugins/package.json +1 -1
- package/packages/nexus-postcss/package.json +1 -1
- package/packages/nexus-realtime/package.json +1 -1
- package/packages/nexus-safe-goto/package.json +1 -1
- package/packages/nexus-safe-goto/src/index.tsx +1 -1
- package/packages/nexus-telemetry/package.json +1 -1
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/graphql/post.graph.ts +0 -99
- package/packages/nexus-examples/examples/blog-crud/apps/backend/src/routes/posts.ts +0 -119
- package/packages/nexus-examples/examples/chat/apps/backend/src/routes/chat.ts +0 -27
- package/packages/nexus-examples/examples/saas-starter/apps/backend/src/routes/projects.ts +0 -52
- 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 {
|
|
25
|
-
import {
|
|
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)
|
|
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
|
-
|
|
161
|
+
renderImmediate();
|
|
137
162
|
}, 80);
|
|
138
163
|
}
|
|
139
164
|
return;
|
|
140
165
|
}
|
|
141
166
|
lastRender = now;
|
|
142
|
-
|
|
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 (
|
|
225
|
+
if (k === 'escape' || k === 'n') {
|
|
180
226
|
state.confirmQuit = false;
|
|
181
227
|
ctx.redraw();
|
|
182
228
|
return;
|
|
183
229
|
}
|
|
184
|
-
if (
|
|
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 (
|
|
241
|
+
if (k === 'down' || k === 'right') {
|
|
190
242
|
state.quitIndex = 1;
|
|
191
243
|
ctx.redraw();
|
|
192
244
|
return;
|
|
193
245
|
}
|
|
194
|
-
if (
|
|
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 (
|
|
263
|
+
if (ctrlC) {
|
|
218
264
|
state.confirmQuit = true;
|
|
219
265
|
state.quitIndex = 0;
|
|
220
266
|
ctx.redraw();
|
|
221
267
|
return;
|
|
222
268
|
}
|
|
223
|
-
if (
|
|
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 (
|
|
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' &&
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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()
|
|
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()
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
603
|
-
const contentW = Math.max(W -
|
|
677
|
+
const { cols: W } = getTermSize();
|
|
678
|
+
const contentW = Math.max(W - 4, 20);
|
|
604
679
|
const rows: string[] = [];
|
|
605
|
-
|
|
606
|
-
|
|
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 = ` ⚠
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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 -
|
|
725
|
+
const innerW = Math.min(Math.max(contentW - 10, 32), 52);
|
|
640
726
|
const inner: string[] = [
|
|
641
|
-
`${ANSI.bold}${ANSI.
|
|
727
|
+
`${ANSI.bold}${ANSI.yellowBright} ⚠ Quit the dev console?${ANSI.reset}`,
|
|
642
728
|
'',
|
|
643
|
-
`${ANSI.
|
|
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.
|
|
650
|
-
const text = sel ? `${ANSI.bold}${label}${ANSI.reset}` : label
|
|
651
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
-
|
|
670
|
-
|
|
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
|
-
|
|
676
|
-
const bar = parts.join(
|
|
677
|
-
|
|
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
|
|
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.
|
|
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) :
|
|
708
|
-
const exitCode = svc.lastExit?.code != null ? ` exit:${svc.lastExit.code}` : '';
|
|
709
|
-
const
|
|
710
|
-
|
|
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 = [
|
|
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.
|
|
727
|
-
const name = sel ? `${ANSI.bold}${svc.name}${ANSI.reset}` : svc.name;
|
|
728
|
-
const exitTag = svc.status === 'crashed' && svc.lastExit?.code != null ?
|
|
729
|
-
|
|
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
|
|
734
|
-
|
|
735
|
-
|
|
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
|
|
745
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
769
|
-
const label = isSelected ? `${ANSI.bold}${row.cmd.name}${ANSI.reset}` : row.cmd.name;
|
|
770
|
-
const arg = row.cmd.needsArgs ? `${ANSI.
|
|
771
|
-
const desc = row.cmd.desc ? `${ANSI.
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
790
|
-
|
|
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
|
|
797
|
-
const
|
|
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.
|
|
803
|
-
const status = on ? `${ANSI.green}installed${ANSI.reset}` : `${ANSI.
|
|
804
|
-
const
|
|
805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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.
|
|
829
|
-
if (state.showHelp) return
|
|
830
|
-
if (state.argMode) return
|
|
831
|
-
|
|
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 {
|