numux 1.2.0 → 1.4.0
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/package.json +1 -1
- package/src/cli.ts +14 -4
- package/src/completions.ts +6 -4
- package/src/config/expand-scripts.ts +96 -0
- package/src/config/validator.ts +13 -3
- package/src/index.ts +35 -6
- package/src/process/manager.ts +4 -2
- package/src/process/runner.ts +1 -1
- package/src/types.ts +16 -4
- package/src/ui/app.ts +63 -9
- package/src/ui/prefix.ts +10 -2
- package/src/ui/tabs.ts +76 -6
- package/src/utils/color.ts +10 -2
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface ParsedArgs {
|
|
|
19
19
|
logDir?: string
|
|
20
20
|
only?: string[]
|
|
21
21
|
exclude?: string[]
|
|
22
|
+
colors?: string[]
|
|
22
23
|
commands: string[]
|
|
23
24
|
named: Array<{ name: string; command: string }>
|
|
24
25
|
}
|
|
@@ -72,8 +73,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
72
73
|
result.noRestart = true
|
|
73
74
|
} else if (arg === '--no-watch') {
|
|
74
75
|
result.noWatch = true
|
|
75
|
-
} else if (arg === '
|
|
76
|
+
} else if (arg === '--config') {
|
|
76
77
|
result.configPath = consumeValue(arg)
|
|
78
|
+
} else if (arg === '-c' || arg === '--color') {
|
|
79
|
+
result.colors = consumeValue(arg)
|
|
80
|
+
.split(',')
|
|
81
|
+
.map(s => s.trim())
|
|
82
|
+
.filter(Boolean)
|
|
77
83
|
} else if (arg === '--log-dir') {
|
|
78
84
|
result.logDir = consumeValue(arg)
|
|
79
85
|
} else if (arg === '--only') {
|
|
@@ -128,13 +134,16 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
128
134
|
export function buildConfigFromArgs(
|
|
129
135
|
commands: string[],
|
|
130
136
|
named: Array<{ name: string; command: string }>,
|
|
131
|
-
options?: { noRestart?: boolean }
|
|
137
|
+
options?: { noRestart?: boolean; colors?: string[] }
|
|
132
138
|
): ResolvedNumuxConfig {
|
|
133
139
|
const processes: ResolvedNumuxConfig['processes'] = {}
|
|
134
140
|
const maxRestarts = options?.noRestart ? 0 : undefined
|
|
141
|
+
const colors = options?.colors
|
|
142
|
+
let colorIndex = 0
|
|
135
143
|
|
|
136
144
|
for (const { name, command } of named) {
|
|
137
|
-
|
|
145
|
+
const color = colors?.[colorIndex++ % colors.length]
|
|
146
|
+
processes[name] = { command, persistent: true, maxRestarts, ...(color ? { color } : {}) }
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
for (let i = 0; i < commands.length; i++) {
|
|
@@ -144,7 +153,8 @@ export function buildConfigFromArgs(
|
|
|
144
153
|
if (processes[name]) {
|
|
145
154
|
name = `${name}-${i}`
|
|
146
155
|
}
|
|
147
|
-
|
|
156
|
+
const color = colors?.[colorIndex++ % colors.length]
|
|
157
|
+
processes[name] = { command: cmd, persistent: true, maxRestarts, ...(color ? { color } : {}) }
|
|
148
158
|
}
|
|
149
159
|
|
|
150
160
|
return { processes }
|
package/src/completions.ts
CHANGED
|
@@ -22,7 +22,7 @@ _numux() {
|
|
|
22
22
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
23
23
|
|
|
24
24
|
case "$prev" in
|
|
25
|
-
|
|
25
|
+
--config)
|
|
26
26
|
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
27
27
|
return ;;
|
|
28
28
|
--log-dir)
|
|
@@ -38,7 +38,7 @@ _numux() {
|
|
|
38
38
|
esac
|
|
39
39
|
|
|
40
40
|
if [[ "$cur" == -* ]]; then
|
|
41
|
-
COMPREPLY=( $(compgen -W "-h --help -v --version -c --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
|
|
41
|
+
COMPREPLY=( $(compgen -W "-h --help -v --version -c --color --config -n --name -p --prefix --only --exclude --kill-others --no-restart --no-watch -t --timestamps --log-dir --debug" -- "$cur") )
|
|
42
42
|
else
|
|
43
43
|
local subcmds="init validate exec completions"
|
|
44
44
|
COMPREPLY=( $(compgen -W "$subcmds" -- "$cur") )
|
|
@@ -63,7 +63,8 @@ _numux() {
|
|
|
63
63
|
_arguments -s \\
|
|
64
64
|
'(-h --help)'{-h,--help}'[Show help]' \\
|
|
65
65
|
'(-v --version)'{-v,--version}'[Show version]' \\
|
|
66
|
-
'(-c --
|
|
66
|
+
'(-c --color)'{-c,--color}'[Comma-separated colors for processes]' \\
|
|
67
|
+
'--config[Config file path]:file:_files' \\
|
|
67
68
|
'(-n --name)'{-n,--name}'[Named process (name=command)]:named process' \\
|
|
68
69
|
'(-p --prefix)'{-p,--prefix}'[Prefixed output mode]' \\
|
|
69
70
|
'--only[Only run these processes]:processes' \\
|
|
@@ -105,7 +106,8 @@ complete -c numux -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish
|
|
|
105
106
|
# Options
|
|
106
107
|
complete -c numux -s h -l help -d 'Show help'
|
|
107
108
|
complete -c numux -s v -l version -d 'Show version'
|
|
108
|
-
complete -c numux -s c -l
|
|
109
|
+
complete -c numux -s c -l color -r -d 'Comma-separated colors for processes'
|
|
110
|
+
complete -c numux -l config -rF -d 'Config file path'
|
|
109
111
|
complete -c numux -s n -l name -r -d 'Named process (name=command)'
|
|
110
112
|
complete -c numux -s p -l prefix -d 'Prefixed output mode'
|
|
111
113
|
complete -c numux -l only -r -d 'Only run these processes'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { NumuxConfig, NumuxProcessConfig } from '../types'
|
|
4
|
+
|
|
5
|
+
type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'
|
|
6
|
+
|
|
7
|
+
const LOCKFILE_PM: [string, PackageManager][] = [
|
|
8
|
+
['bun.lockb', 'bun'],
|
|
9
|
+
['bun.lock', 'bun'],
|
|
10
|
+
['yarn.lock', 'yarn'],
|
|
11
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
12
|
+
['package-lock.json', 'npm']
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
export function detectPackageManager(pkgJson: Record<string, unknown>, cwd: string): PackageManager {
|
|
16
|
+
const field = pkgJson.packageManager
|
|
17
|
+
if (typeof field === 'string') {
|
|
18
|
+
const name = field.split('@')[0] as PackageManager
|
|
19
|
+
if (['npm', 'yarn', 'pnpm', 'bun'].includes(name)) return name
|
|
20
|
+
}
|
|
21
|
+
for (const [file, pm] of LOCKFILE_PM) {
|
|
22
|
+
if (existsSync(resolve(cwd, file))) return pm
|
|
23
|
+
}
|
|
24
|
+
return 'npm'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function expandScriptPatterns(config: NumuxConfig, cwd?: string): NumuxConfig {
|
|
28
|
+
const entries = Object.entries(config.processes)
|
|
29
|
+
const hasWildcard = entries.some(([name]) => name.startsWith('npm:'))
|
|
30
|
+
if (!hasWildcard) return config
|
|
31
|
+
|
|
32
|
+
const dir = config.cwd ?? cwd ?? process.cwd()
|
|
33
|
+
const pkgPath = resolve(dir, 'package.json')
|
|
34
|
+
|
|
35
|
+
if (!existsSync(pkgPath)) {
|
|
36
|
+
throw new Error(`npm: patterns require a package.json (looked in ${dir})`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const pkgJson = JSON.parse(readFileSync(pkgPath, 'utf-8')) as Record<string, unknown>
|
|
40
|
+
const scripts = pkgJson.scripts as Record<string, string> | undefined
|
|
41
|
+
if (!scripts || typeof scripts !== 'object') {
|
|
42
|
+
throw new Error('package.json has no "scripts" field')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const scriptNames = Object.keys(scripts)
|
|
46
|
+
const pm = detectPackageManager(pkgJson, dir)
|
|
47
|
+
|
|
48
|
+
const expanded: Record<string, NumuxProcessConfig | string> = {}
|
|
49
|
+
|
|
50
|
+
for (const [name, value] of entries) {
|
|
51
|
+
if (!name.startsWith('npm:')) {
|
|
52
|
+
expanded[name] = value as NumuxProcessConfig | string
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const pattern = name.slice(4) // strip "npm:"
|
|
57
|
+
const template = (value ?? {}) as Partial<NumuxProcessConfig>
|
|
58
|
+
|
|
59
|
+
if (template.command) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`"${name}": wildcard processes cannot have a "command" field (commands come from package.json scripts)`
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const glob = new Bun.Glob(pattern)
|
|
66
|
+
const matches = scriptNames.filter(s => glob.match(s))
|
|
67
|
+
|
|
68
|
+
if (matches.length === 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`"${name}": no scripts matched pattern "${pattern}". Available scripts: ${scriptNames.join(', ')}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const colors = Array.isArray(template.color) ? template.color : undefined
|
|
75
|
+
const singleColor = typeof template.color === 'string' ? template.color : undefined
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < matches.length; i++) {
|
|
78
|
+
const scriptName = matches[i]
|
|
79
|
+
|
|
80
|
+
if (expanded[scriptName]) {
|
|
81
|
+
throw new Error(`"${name}": expanded script "${scriptName}" collides with an existing process name`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const color = colors ? colors[i % colors.length] : singleColor
|
|
85
|
+
|
|
86
|
+
const { color: _color, ...rest } = template
|
|
87
|
+
expanded[scriptName] = {
|
|
88
|
+
...rest,
|
|
89
|
+
command: `${pm} run ${scriptName}`,
|
|
90
|
+
...(color ? { color } : {})
|
|
91
|
+
} as NumuxProcessConfig
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ...config, processes: expanded }
|
|
96
|
+
}
|
package/src/config/validator.ts
CHANGED
|
@@ -72,8 +72,18 @@ export function validateConfig(raw: unknown, warnings?: ValidationWarning[]): Re
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
// Validate color hex format
|
|
75
|
-
if (typeof p.color === 'string'
|
|
76
|
-
|
|
75
|
+
if (typeof p.color === 'string') {
|
|
76
|
+
if (!HEX_COLOR_RE.test(p.color)) {
|
|
77
|
+
throw new Error(`Process "${name}".color must be a valid hex color (e.g. "#ff8800"), got "${p.color}"`)
|
|
78
|
+
}
|
|
79
|
+
} else if (Array.isArray(p.color)) {
|
|
80
|
+
for (const c of p.color) {
|
|
81
|
+
if (typeof c !== 'string' || !HEX_COLOR_RE.test(c)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Process "${name}".color entries must be valid hex colors (e.g. "#ff8800"), got "${c}"`
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
const persistent = typeof p.persistent === 'boolean' ? p.persistent : true
|
|
@@ -113,7 +123,7 @@ export function validateConfig(raw: unknown, warnings?: ValidationWarning[]): Re
|
|
|
113
123
|
delay: typeof p.delay === 'number' && p.delay > 0 ? p.delay : undefined,
|
|
114
124
|
condition: typeof p.condition === 'string' && p.condition.trim() ? p.condition.trim() : undefined,
|
|
115
125
|
stopSignal: validateStopSignal(p.stopSignal),
|
|
116
|
-
color: typeof p.color === 'string' ? p.color : undefined,
|
|
126
|
+
color: typeof p.color === 'string' ? p.color : Array.isArray(p.color) ? (p.color as string[]) : undefined,
|
|
117
127
|
watch: validateStringOrStringArray(p.watch),
|
|
118
128
|
interactive: typeof p.interactive === 'boolean' ? p.interactive : false
|
|
119
129
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,11 +3,12 @@ import { existsSync, writeFileSync } from 'node:fs'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { buildConfigFromArgs, filterConfig, parseArgs } from './cli'
|
|
5
5
|
import { generateCompletions } from './completions'
|
|
6
|
+
import { expandScriptPatterns } from './config/expand-scripts'
|
|
6
7
|
import { loadConfig } from './config/loader'
|
|
7
8
|
import { resolveDependencyTiers } from './config/resolver'
|
|
8
9
|
import { type ValidationWarning, validateConfig } from './config/validator'
|
|
9
10
|
import { ProcessManager } from './process/manager'
|
|
10
|
-
import type { ResolvedNumuxConfig } from './types'
|
|
11
|
+
import type { NumuxProcessConfig, ResolvedNumuxConfig } from './types'
|
|
11
12
|
import { App } from './ui/app'
|
|
12
13
|
import { PrefixDisplay } from './ui/prefix'
|
|
13
14
|
import { loadEnvFiles } from './utils/env-file'
|
|
@@ -28,7 +29,8 @@ Usage:
|
|
|
28
29
|
|
|
29
30
|
Options:
|
|
30
31
|
-n, --name <name=command> Add a named process
|
|
31
|
-
-c, --
|
|
32
|
+
-c, --color <colors> Comma-separated colors for processes (hex, e.g. #ff0,#0f0)
|
|
33
|
+
--config <path> Config file path (default: auto-detect)
|
|
32
34
|
-p, --prefix Prefixed output mode (no TUI, for CI/scripts)
|
|
33
35
|
--only <a,b,...> Only run these processes (+ their dependencies)
|
|
34
36
|
--exclude <a,b,...> Exclude these processes
|
|
@@ -100,7 +102,7 @@ async function main() {
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
if (parsed.validate) {
|
|
103
|
-
const raw = await loadConfig(parsed.configPath)
|
|
105
|
+
const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
|
|
104
106
|
const warnings: ValidationWarning[] = []
|
|
105
107
|
let config = validateConfig(raw, warnings)
|
|
106
108
|
|
|
@@ -135,7 +137,7 @@ async function main() {
|
|
|
135
137
|
}
|
|
136
138
|
|
|
137
139
|
if (parsed.exec) {
|
|
138
|
-
const raw = await loadConfig(parsed.configPath)
|
|
140
|
+
const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
|
|
139
141
|
const config = validateConfig(raw)
|
|
140
142
|
const proc = config.processes[parsed.execName!]
|
|
141
143
|
if (!proc) {
|
|
@@ -169,9 +171,36 @@ async function main() {
|
|
|
169
171
|
const warnings: ValidationWarning[] = []
|
|
170
172
|
|
|
171
173
|
if (parsed.commands.length > 0 || parsed.named.length > 0) {
|
|
172
|
-
|
|
174
|
+
const hasNpmPatterns = parsed.commands.some(c => c.startsWith('npm:'))
|
|
175
|
+
if (hasNpmPatterns) {
|
|
176
|
+
// Expand npm: patterns into named processes, pass remaining commands as-is
|
|
177
|
+
const npmPatterns = parsed.commands.filter(c => c.startsWith('npm:'))
|
|
178
|
+
const otherCommands = parsed.commands.filter(c => !c.startsWith('npm:'))
|
|
179
|
+
const processes: Record<string, NumuxProcessConfig | string> = {}
|
|
180
|
+
for (const pattern of npmPatterns) {
|
|
181
|
+
const entry: Partial<NumuxProcessConfig> = {}
|
|
182
|
+
if (parsed.colors?.length) entry.color = parsed.colors
|
|
183
|
+
processes[pattern] = entry as NumuxProcessConfig
|
|
184
|
+
}
|
|
185
|
+
for (let i = 0; i < otherCommands.length; i++) {
|
|
186
|
+
const cmd = otherCommands[i]
|
|
187
|
+
let name = cmd.split(/\s+/)[0].split('/').pop()!
|
|
188
|
+
if (processes[name]) name = `${name}-${i}`
|
|
189
|
+
processes[name] = cmd
|
|
190
|
+
}
|
|
191
|
+
for (const { name, command } of parsed.named) {
|
|
192
|
+
processes[name] = command
|
|
193
|
+
}
|
|
194
|
+
const expanded = expandScriptPatterns({ processes })
|
|
195
|
+
config = validateConfig(expanded, warnings)
|
|
196
|
+
} else {
|
|
197
|
+
config = buildConfigFromArgs(parsed.commands, parsed.named, {
|
|
198
|
+
noRestart: parsed.noRestart,
|
|
199
|
+
colors: parsed.colors
|
|
200
|
+
})
|
|
201
|
+
}
|
|
173
202
|
} else {
|
|
174
|
-
const raw = await loadConfig(parsed.configPath)
|
|
203
|
+
const raw = expandScriptPatterns(await loadConfig(parsed.configPath))
|
|
175
204
|
config = validateConfig(raw, warnings)
|
|
176
205
|
|
|
177
206
|
if (parsed.noRestart) {
|
package/src/process/manager.ts
CHANGED
|
@@ -217,10 +217,11 @@ export class ProcessManager {
|
|
|
217
217
|
this.fileWatcher.watch(name, patterns, cwd, changedFile => {
|
|
218
218
|
const state = this.states.get(name)
|
|
219
219
|
if (!state) return
|
|
220
|
-
// Don't restart processes that are stopped
|
|
220
|
+
// Don't restart processes that are stopped/finished, pending, stopping, or skipped
|
|
221
221
|
if (
|
|
222
222
|
state.status === 'pending' ||
|
|
223
223
|
state.status === 'stopped' ||
|
|
224
|
+
state.status === 'finished' ||
|
|
224
225
|
state.status === 'stopping' ||
|
|
225
226
|
state.status === 'skipped'
|
|
226
227
|
)
|
|
@@ -269,6 +270,7 @@ export class ProcessManager {
|
|
|
269
270
|
if (
|
|
270
271
|
state.status === 'pending' ||
|
|
271
272
|
state.status === 'stopped' ||
|
|
273
|
+
state.status === 'finished' ||
|
|
272
274
|
state.status === 'stopping' ||
|
|
273
275
|
state.status === 'skipped'
|
|
274
276
|
)
|
|
@@ -300,7 +302,7 @@ export class ProcessManager {
|
|
|
300
302
|
start(name: string, cols: number, rows: number): void {
|
|
301
303
|
const state = this.states.get(name)
|
|
302
304
|
if (!state) return
|
|
303
|
-
if (state.status !== 'stopped' && state.status !== 'failed') return
|
|
305
|
+
if (state.status !== 'stopped' && state.status !== 'finished' && state.status !== 'failed') return
|
|
304
306
|
|
|
305
307
|
// Cancel pending auto-restart and reset backoff
|
|
306
308
|
const timer = this.restartTimers.get(name)
|
package/src/process/runner.ts
CHANGED
|
@@ -110,7 +110,7 @@ export class ProcessRunner {
|
|
|
110
110
|
// duplicate status/exit events to avoid double onStatus('failed')
|
|
111
111
|
// and unintended auto-restart scheduling.
|
|
112
112
|
if (!this.readyTimedOut) {
|
|
113
|
-
const status: ProcessStatus = this.stopping
|
|
113
|
+
const status: ProcessStatus = this.stopping ? 'stopped' : code === 0 ? 'finished' : 'failed'
|
|
114
114
|
this.handler.onStatus(status)
|
|
115
115
|
this.handler.onExit(code)
|
|
116
116
|
}
|
package/src/types.ts
CHANGED
|
@@ -11,17 +11,20 @@ export interface NumuxProcessConfig {
|
|
|
11
11
|
delay?: number // ms to wait before starting the process (default: none)
|
|
12
12
|
condition?: string // env var name (prefix with ! to negate); process skipped if condition is falsy
|
|
13
13
|
stopSignal?: 'SIGTERM' | 'SIGINT' | 'SIGHUP' // signal for graceful stop (default: SIGTERM)
|
|
14
|
-
color?: string
|
|
14
|
+
color?: string | string[]
|
|
15
15
|
watch?: string | string[] // Glob patterns — restart process when matching files change
|
|
16
16
|
interactive?: boolean // default false — when true, keyboard input is forwarded to the process
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/** Config for npm: wildcard entries — command is derived from package.json scripts */
|
|
20
|
+
export type NumuxScriptPattern = Omit<NumuxProcessConfig, 'command'> & { command?: never }
|
|
21
|
+
|
|
22
|
+
/** Raw config as authored — processes can be string shorthand, full objects, or wildcard patterns */
|
|
20
23
|
export interface NumuxConfig {
|
|
21
24
|
cwd?: string // Global working directory, inherited by all processes
|
|
22
25
|
env?: Record<string, string> // Global env vars, merged into each process (process-level overrides)
|
|
23
26
|
envFile?: string | string[] // Global .env file(s), inherited by processes without their own envFile
|
|
24
|
-
processes: Record<string, NumuxProcessConfig | string>
|
|
27
|
+
processes: Record<string, NumuxProcessConfig | NumuxScriptPattern | string>
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/** Validated config with all shorthand expanded to full objects */
|
|
@@ -29,7 +32,16 @@ export interface ResolvedNumuxConfig {
|
|
|
29
32
|
processes: Record<string, NumuxProcessConfig>
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
export type ProcessStatus =
|
|
35
|
+
export type ProcessStatus =
|
|
36
|
+
| 'pending'
|
|
37
|
+
| 'starting'
|
|
38
|
+
| 'ready'
|
|
39
|
+
| 'running'
|
|
40
|
+
| 'stopping'
|
|
41
|
+
| 'stopped'
|
|
42
|
+
| 'finished'
|
|
43
|
+
| 'failed'
|
|
44
|
+
| 'skipped'
|
|
33
45
|
|
|
34
46
|
export interface ProcessState {
|
|
35
47
|
name: string
|
package/src/ui/app.ts
CHANGED
|
@@ -29,6 +29,10 @@ export class App {
|
|
|
29
29
|
private searchMatches: SearchMatch[] = []
|
|
30
30
|
private searchIndex = -1
|
|
31
31
|
|
|
32
|
+
// Input-waiting detection for interactive processes
|
|
33
|
+
private inputWaitTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
34
|
+
private awaitingInput = new Set<string>()
|
|
35
|
+
|
|
32
36
|
constructor(manager: ProcessManager, config: ResolvedNumuxConfig) {
|
|
33
37
|
this.manager = manager
|
|
34
38
|
this.config = config
|
|
@@ -113,9 +117,17 @@ export class App {
|
|
|
113
117
|
if (this.destroyed) return
|
|
114
118
|
if (event.type === 'output') {
|
|
115
119
|
this.panes.get(event.name)?.feed(event.data)
|
|
120
|
+
// Detect input-waiting for interactive processes
|
|
121
|
+
if (this.config.processes[event.name]?.interactive) {
|
|
122
|
+
this.checkInputWaiting(event.name, event.data)
|
|
123
|
+
}
|
|
116
124
|
} else if (event.type === 'status') {
|
|
117
125
|
const state = this.manager.getState(event.name)
|
|
118
126
|
this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount)
|
|
127
|
+
// Clear input-waiting on non-active statuses
|
|
128
|
+
if (event.status !== 'running' && event.status !== 'ready') {
|
|
129
|
+
this.clearInputWaiting(event.name)
|
|
130
|
+
}
|
|
119
131
|
}
|
|
120
132
|
})
|
|
121
133
|
|
|
@@ -180,7 +192,7 @@ export class App {
|
|
|
180
192
|
// Alt+S: stop/start active process
|
|
181
193
|
if (key.name === 's' && this.activePane) {
|
|
182
194
|
const state = this.manager.getState(this.activePane)
|
|
183
|
-
if (state?.status === 'stopped' || state?.status === 'failed') {
|
|
195
|
+
if (state?.status === 'stopped' || state?.status === 'finished' || state?.status === 'failed') {
|
|
184
196
|
this.manager.start(this.activePane, this.termCols, this.termRows)
|
|
185
197
|
} else {
|
|
186
198
|
this.manager.stop(this.activePane)
|
|
@@ -194,23 +206,21 @@ export class App {
|
|
|
194
206
|
return
|
|
195
207
|
}
|
|
196
208
|
|
|
197
|
-
// Alt+1-9: jump to tab
|
|
209
|
+
// Alt+1-9: jump to tab (uses display order from tab bar)
|
|
198
210
|
const num = Number.parseInt(key.name, 10)
|
|
199
|
-
if (num >= 1 && num <= 9 && num <= this.
|
|
211
|
+
if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
|
|
200
212
|
this.tabBar.setSelectedIndex(num - 1)
|
|
201
|
-
this.switchPane(this.
|
|
213
|
+
this.switchPane(this.tabBar.getNameAtIndex(num - 1))
|
|
202
214
|
return
|
|
203
215
|
}
|
|
204
216
|
|
|
205
217
|
// Alt+Left/Right: cycle tabs
|
|
206
218
|
if (key.name === 'left' || key.name === 'right') {
|
|
207
219
|
const current = this.tabBar.getSelectedIndex()
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
? (current + 1) % this.names.length
|
|
211
|
-
: (current - 1 + this.names.length) % this.names.length
|
|
220
|
+
const count = this.tabBar.count
|
|
221
|
+
const next = key.name === 'right' ? (current + 1) % count : (current - 1 + count) % count
|
|
212
222
|
this.tabBar.setSelectedIndex(next)
|
|
213
|
-
this.switchPane(this.
|
|
223
|
+
this.switchPane(this.tabBar.getNameAtIndex(next))
|
|
214
224
|
return
|
|
215
225
|
}
|
|
216
226
|
|
|
@@ -283,6 +293,45 @@ export class App {
|
|
|
283
293
|
this.panes.get(name)?.show()
|
|
284
294
|
}
|
|
285
295
|
|
|
296
|
+
/** Detect when an interactive process is likely waiting for user input */
|
|
297
|
+
private checkInputWaiting(name: string, data: Uint8Array): void {
|
|
298
|
+
// Clear existing timer
|
|
299
|
+
const existing = this.inputWaitTimers.get(name)
|
|
300
|
+
if (existing) clearTimeout(existing)
|
|
301
|
+
|
|
302
|
+
// If we were showing awaiting input, clear it since new output arrived
|
|
303
|
+
if (this.awaitingInput.has(name)) {
|
|
304
|
+
this.awaitingInput.delete(name)
|
|
305
|
+
this.tabBar.setInputWaiting(name, false)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// If the last byte is not a newline, the process may be showing a prompt
|
|
309
|
+
const lastByte = data[data.length - 1]
|
|
310
|
+
if (lastByte !== 0x0a && lastByte !== 0x0d) {
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
this.inputWaitTimers.delete(name)
|
|
313
|
+
const state = this.manager.getState(name)
|
|
314
|
+
if (state && (state.status === 'running' || state.status === 'ready')) {
|
|
315
|
+
this.awaitingInput.add(name)
|
|
316
|
+
this.tabBar.setInputWaiting(name, true)
|
|
317
|
+
}
|
|
318
|
+
}, 200)
|
|
319
|
+
this.inputWaitTimers.set(name, timer)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private clearInputWaiting(name: string): void {
|
|
324
|
+
const timer = this.inputWaitTimers.get(name)
|
|
325
|
+
if (timer) {
|
|
326
|
+
clearTimeout(timer)
|
|
327
|
+
this.inputWaitTimers.delete(name)
|
|
328
|
+
}
|
|
329
|
+
if (this.awaitingInput.has(name)) {
|
|
330
|
+
this.awaitingInput.delete(name)
|
|
331
|
+
this.tabBar.setInputWaiting(name, false)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
286
335
|
private enterSearch(): void {
|
|
287
336
|
this.searchMode = true
|
|
288
337
|
this.searchQuery = ''
|
|
@@ -384,6 +433,11 @@ export class App {
|
|
|
384
433
|
clearTimeout(this.resizeTimer)
|
|
385
434
|
this.resizeTimer = null
|
|
386
435
|
}
|
|
436
|
+
// Clear all input-waiting timers
|
|
437
|
+
for (const timer of this.inputWaitTimers.values()) {
|
|
438
|
+
clearTimeout(timer)
|
|
439
|
+
}
|
|
440
|
+
this.inputWaitTimers.clear()
|
|
387
441
|
await this.manager.stopAll()
|
|
388
442
|
for (const pane of this.panes.values()) {
|
|
389
443
|
pane.destroy()
|
package/src/ui/prefix.ts
CHANGED
|
@@ -102,7 +102,13 @@ export class PrefixDisplay {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
private handleStatus(name: string, status: ProcessStatus): void {
|
|
105
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
status === 'ready' ||
|
|
107
|
+
status === 'failed' ||
|
|
108
|
+
status === 'finished' ||
|
|
109
|
+
status === 'stopped' ||
|
|
110
|
+
status === 'skipped'
|
|
111
|
+
) {
|
|
106
112
|
if (this.noColor) {
|
|
107
113
|
this.printLine(name, `→ ${status}`)
|
|
108
114
|
} else {
|
|
@@ -144,7 +150,9 @@ export class PrefixDisplay {
|
|
|
144
150
|
private checkAllDone(): void {
|
|
145
151
|
if (this.stopping) return
|
|
146
152
|
const states = this.manager.getAllStates()
|
|
147
|
-
const allDone = states.every(
|
|
153
|
+
const allDone = states.every(
|
|
154
|
+
s => s.status === 'stopped' || s.status === 'finished' || s.status === 'failed' || s.status === 'skipped'
|
|
155
|
+
)
|
|
148
156
|
if (allDone) {
|
|
149
157
|
this.printSummary()
|
|
150
158
|
this.logWriter?.close()
|
package/src/ui/tabs.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type CliRenderer,
|
|
3
|
+
type MouseEvent,
|
|
3
4
|
type OptimizedBuffer,
|
|
4
5
|
parseColor,
|
|
5
6
|
type RGBA,
|
|
@@ -15,6 +16,7 @@ const STATUS_ICONS: Record<ProcessStatus, string> = {
|
|
|
15
16
|
ready: '●',
|
|
16
17
|
stopping: '◑',
|
|
17
18
|
stopped: '■',
|
|
19
|
+
finished: '✓',
|
|
18
20
|
failed: '✖',
|
|
19
21
|
skipped: '⊘'
|
|
20
22
|
}
|
|
@@ -22,11 +24,15 @@ const STATUS_ICONS: Record<ProcessStatus, string> = {
|
|
|
22
24
|
/** Status-specific icon colors (override process colors) */
|
|
23
25
|
const STATUS_ICON_HEX: Partial<Record<ProcessStatus, string>> = {
|
|
24
26
|
ready: '#00cc00',
|
|
27
|
+
finished: '#66aa66',
|
|
25
28
|
failed: '#ff5555',
|
|
26
29
|
stopped: '#888888',
|
|
27
30
|
skipped: '#888888'
|
|
28
31
|
}
|
|
29
32
|
|
|
33
|
+
/** Statuses that represent a terminal (done) state — tabs move to bottom */
|
|
34
|
+
const TERMINAL_STATUSES = new Set<ProcessStatus>(['finished', 'stopped', 'failed', 'skipped'])
|
|
35
|
+
|
|
30
36
|
interface OptionColors {
|
|
31
37
|
icon: RGBA | null
|
|
32
38
|
name: RGBA | null
|
|
@@ -54,6 +60,18 @@ class ColoredSelectRenderable extends SelectRenderable {
|
|
|
54
60
|
}
|
|
55
61
|
}
|
|
56
62
|
|
|
63
|
+
protected onMouseEvent(event: MouseEvent): void {
|
|
64
|
+
if (event.type === 'down') {
|
|
65
|
+
const linesPerItem = (this as any).linesPerItem as number
|
|
66
|
+
const scrollOffset = (this as any).scrollOffset as number
|
|
67
|
+
const clickedIndex = scrollOffset + Math.floor(event.y / linesPerItem)
|
|
68
|
+
if (clickedIndex >= 0 && clickedIndex < this.options.length) {
|
|
69
|
+
this.setSelectedIndex(clickedIndex)
|
|
70
|
+
this.selectCurrent()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
private colorizeOptions(): void {
|
|
58
76
|
const fb = this.frameBuffer!
|
|
59
77
|
// Access internal layout state (private in TS, accessible at runtime)
|
|
@@ -83,15 +101,18 @@ class ColoredSelectRenderable extends SelectRenderable {
|
|
|
83
101
|
|
|
84
102
|
export class TabBar {
|
|
85
103
|
readonly renderable: ColoredSelectRenderable
|
|
104
|
+
private originalNames: string[]
|
|
86
105
|
private names: string[]
|
|
87
106
|
private statuses: Map<string, ProcessStatus>
|
|
88
|
-
private
|
|
107
|
+
private baseDescriptions: Map<string, string>
|
|
89
108
|
private processColors: Map<string, string>
|
|
109
|
+
private inputWaiting = new Set<string>()
|
|
90
110
|
|
|
91
111
|
constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>) {
|
|
92
|
-
this.
|
|
112
|
+
this.originalNames = names
|
|
113
|
+
this.names = [...names]
|
|
93
114
|
this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
|
|
94
|
-
this.
|
|
115
|
+
this.baseDescriptions = new Map(names.map(n => [n, 'pending']))
|
|
95
116
|
this.processColors = colors ?? new Map()
|
|
96
117
|
|
|
97
118
|
this.renderable = new ColoredSelectRenderable(renderer, {
|
|
@@ -125,18 +146,67 @@ export class TabBar {
|
|
|
125
146
|
|
|
126
147
|
updateStatus(name: string, status: ProcessStatus, exitCode?: number | null, restartCount?: number): void {
|
|
127
148
|
this.statuses.set(name, status)
|
|
128
|
-
this.
|
|
149
|
+
this.baseDescriptions.set(name, this.formatDescription(status, exitCode, restartCount))
|
|
150
|
+
// Clear input waiting on terminal status changes
|
|
151
|
+
if (TERMINAL_STATUSES.has(status) || status === 'stopping') {
|
|
152
|
+
this.inputWaiting.delete(name)
|
|
153
|
+
}
|
|
154
|
+
this.refreshOptions()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
setInputWaiting(name: string, waiting: boolean): void {
|
|
158
|
+
if (waiting) this.inputWaiting.add(name)
|
|
159
|
+
else this.inputWaiting.delete(name)
|
|
160
|
+
this.refreshOptions()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Get the process name at the given display index */
|
|
164
|
+
getNameAtIndex(index: number): string {
|
|
165
|
+
return this.names[index]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
get count(): number {
|
|
169
|
+
return this.names.length
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private refreshOptions(): void {
|
|
173
|
+
// Preserve currently selected name
|
|
174
|
+
const currentIdx = this.renderable.getSelectedIndex()
|
|
175
|
+
const currentName = this.names[currentIdx]
|
|
176
|
+
|
|
177
|
+
// Reorder: active first, terminal states at bottom
|
|
178
|
+
this.names = this.getDisplayOrder()
|
|
179
|
+
|
|
129
180
|
this.renderable.options = this.names.map(n => ({
|
|
130
181
|
name: this.formatTab(n, this.statuses.get(n)!),
|
|
131
|
-
description: this.
|
|
182
|
+
description: this.getDescription(n)
|
|
132
183
|
}))
|
|
184
|
+
|
|
185
|
+
// Restore selection by name
|
|
186
|
+
const newIdx = this.names.indexOf(currentName)
|
|
187
|
+
if (newIdx >= 0 && newIdx !== currentIdx) {
|
|
188
|
+
this.renderable.setSelectedIndex(newIdx)
|
|
189
|
+
}
|
|
190
|
+
|
|
133
191
|
this.updateOptionColors()
|
|
134
192
|
}
|
|
135
193
|
|
|
194
|
+
private getDisplayOrder(): string[] {
|
|
195
|
+
const active = this.originalNames.filter(n => !TERMINAL_STATUSES.has(this.statuses.get(n)!))
|
|
196
|
+
const terminal = this.originalNames.filter(n => TERMINAL_STATUSES.has(this.statuses.get(n)!))
|
|
197
|
+
return [...active, ...terminal]
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private getDescription(name: string): string {
|
|
201
|
+
if (this.inputWaiting.has(name)) return 'awaiting input'
|
|
202
|
+
return this.baseDescriptions.get(name) ?? 'pending'
|
|
203
|
+
}
|
|
204
|
+
|
|
136
205
|
private updateOptionColors(): void {
|
|
137
206
|
const colors = this.names.map(name => {
|
|
138
207
|
const status = this.statuses.get(name)!
|
|
139
|
-
const
|
|
208
|
+
const waiting = this.inputWaiting.has(name)
|
|
209
|
+
const statusHex = waiting ? '#ffaa00' : STATUS_ICON_HEX[status]
|
|
140
210
|
const processHex = this.processColors.get(name)
|
|
141
211
|
return {
|
|
142
212
|
icon: parseColor(statusHex ?? processHex ?? '#888888'),
|
package/src/utils/color.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { ProcessStatus, ResolvedNumuxConfig } from '../types'
|
|
|
20
20
|
export const STATUS_ANSI: Partial<Record<ProcessStatus, string>> = {
|
|
21
21
|
ready: '\x1b[32m',
|
|
22
22
|
running: '\x1b[36m',
|
|
23
|
+
finished: '\x1b[32m',
|
|
23
24
|
failed: '\x1b[31m',
|
|
24
25
|
stopped: '\x1b[90m',
|
|
25
26
|
skipped: '\x1b[90m'
|
|
@@ -50,13 +51,20 @@ const DEFAULT_ANSI_COLORS = [
|
|
|
50
51
|
/** Default palette as hex colors (for styled text rendering) */
|
|
51
52
|
const DEFAULT_HEX_COLORS = ['#00cccc', '#cccc00', '#cc00cc', '#0000cc', '#00cc00', '#ff5555', '#ffff55', '#ff55ff']
|
|
52
53
|
|
|
54
|
+
/** Resolve a color value (string or array) to a single hex string, or undefined. */
|
|
55
|
+
function resolveColor(color: string | string[] | undefined): string | undefined {
|
|
56
|
+
if (typeof color === 'string') return color
|
|
57
|
+
if (Array.isArray(color) && color.length > 0) return color[0]
|
|
58
|
+
return undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
/** Build a map of process names to ANSI color codes, using explicit config colors or a default palette. */
|
|
54
62
|
export function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
|
|
55
63
|
const map = new Map<string, string>()
|
|
56
64
|
if ('NO_COLOR' in process.env) return map
|
|
57
65
|
let paletteIndex = 0
|
|
58
66
|
for (const name of names) {
|
|
59
|
-
const explicit = config.processes[name]?.color
|
|
67
|
+
const explicit = resolveColor(config.processes[name]?.color)
|
|
60
68
|
if (explicit) {
|
|
61
69
|
map.set(name, hexToAnsi(explicit))
|
|
62
70
|
} else {
|
|
@@ -73,7 +81,7 @@ export function buildProcessHexColorMap(names: string[], config: ResolvedNumuxCo
|
|
|
73
81
|
if ('NO_COLOR' in process.env) return map
|
|
74
82
|
let paletteIndex = 0
|
|
75
83
|
for (const name of names) {
|
|
76
|
-
const explicit = config.processes[name]?.color
|
|
84
|
+
const explicit = resolveColor(config.processes[name]?.color)
|
|
77
85
|
if (explicit) {
|
|
78
86
|
map.set(name, explicit.startsWith('#') ? explicit : `#${explicit}`)
|
|
79
87
|
} else {
|