numux 1.3.0 → 1.5.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/README.md +1 -1
- package/package.json +2 -2
- 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/types.ts +6 -3
- package/src/ui/app.ts +35 -47
- package/src/ui/status-bar.ts +3 -1
- package/src/ui/tabs.ts +13 -0
- package/src/utils/color.ts +9 -2
- package/src/utils/logger.ts +11 -3
package/README.md
CHANGED
|
@@ -264,7 +264,7 @@ Persistent processes that crash are auto-restarted with exponential backoff (1s
|
|
|
264
264
|
| `Alt+L` | Clear active pane output |
|
|
265
265
|
| `Alt+1`–`Alt+9` | Jump to tab |
|
|
266
266
|
| `Alt+Left/Right` | Cycle tabs |
|
|
267
|
-
| `Up/Down` |
|
|
267
|
+
| `Up/Down` | Navigate between tabs |
|
|
268
268
|
| `PageUp/PageDown` | Scroll output by page (non-interactive panes) |
|
|
269
269
|
| `Home/End` | Scroll to top/bottom (non-interactive panes) |
|
|
270
270
|
| `Alt+PageUp/PageDown` | Scroll output up/down |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "numux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Terminal multiplexer with dependency orchestration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
".": "./src/config.ts"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
-
"dev": "bun run
|
|
30
|
+
"dev": "cd example && bun run dev --debug",
|
|
31
31
|
"test": "bun test",
|
|
32
32
|
"typecheck": "bunx tsc --noEmit",
|
|
33
33
|
"lint": "biome check .",
|
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/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 */
|
package/src/ui/app.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { BoxRenderable, type CliRenderer, createCliRenderer } from '@opentui/cor
|
|
|
2
2
|
import type { ProcessManager } from '../process/manager'
|
|
3
3
|
import type { ResolvedNumuxConfig } from '../types'
|
|
4
4
|
import { buildProcessHexColorMap } from '../utils/color'
|
|
5
|
+
import { log } from '../utils/logger'
|
|
5
6
|
import { Pane, type SearchMatch } from './pane'
|
|
6
7
|
import { StatusBar } from './status-bar'
|
|
7
8
|
import { TabBar } from './tabs'
|
|
@@ -149,6 +150,8 @@ export class App {
|
|
|
149
150
|
this.renderer.keyInput.on(
|
|
150
151
|
'keypress',
|
|
151
152
|
(key: { ctrl: boolean; shift: boolean; meta: boolean; name: string; sequence: string }) => {
|
|
153
|
+
log(key)
|
|
154
|
+
|
|
152
155
|
// Ctrl+C: quit (always works)
|
|
153
156
|
if (key.ctrl && key.name === 'c') {
|
|
154
157
|
if (this.searchMode) {
|
|
@@ -167,30 +170,34 @@ export class App {
|
|
|
167
170
|
return
|
|
168
171
|
}
|
|
169
172
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
173
|
+
if (!this.activePane) return
|
|
174
|
+
|
|
175
|
+
const isInteractive = this.config.processes[this.activePane]?.interactive === true
|
|
176
|
+
|
|
177
|
+
// Non-interactive panes: plain keys act as shortcuts
|
|
178
|
+
if (!isInteractive) {
|
|
179
|
+
const name = key.name.toLowerCase()
|
|
180
|
+
|
|
181
|
+
// Shift+R: restart all processes
|
|
182
|
+
if (key.shift && name === 'r') {
|
|
174
183
|
this.manager.restartAll(this.termCols, this.termRows)
|
|
175
184
|
return
|
|
176
185
|
}
|
|
177
|
-
}
|
|
178
186
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (key.name === 'f' && this.activePane) {
|
|
187
|
+
// F: enter search mode
|
|
188
|
+
if (name === 'f') {
|
|
182
189
|
this.enterSearch()
|
|
183
190
|
return
|
|
184
191
|
}
|
|
185
192
|
|
|
186
|
-
//
|
|
187
|
-
if (
|
|
193
|
+
// R: restart active process
|
|
194
|
+
if (name === 'r') {
|
|
188
195
|
this.manager.restart(this.activePane, this.termCols, this.termRows)
|
|
189
196
|
return
|
|
190
197
|
}
|
|
191
198
|
|
|
192
|
-
//
|
|
193
|
-
if (
|
|
199
|
+
// S: stop/start active process
|
|
200
|
+
if (name === 's') {
|
|
194
201
|
const state = this.manager.getState(this.activePane)
|
|
195
202
|
if (state?.status === 'stopped' || state?.status === 'finished' || state?.status === 'failed') {
|
|
196
203
|
this.manager.start(this.activePane, this.termCols, this.termRows)
|
|
@@ -200,80 +207,61 @@ export class App {
|
|
|
200
207
|
return
|
|
201
208
|
}
|
|
202
209
|
|
|
203
|
-
//
|
|
204
|
-
if (
|
|
210
|
+
// L: clear active pane
|
|
211
|
+
if (name === 'l') {
|
|
205
212
|
this.panes.get(this.activePane)?.clear()
|
|
206
213
|
return
|
|
207
214
|
}
|
|
208
215
|
|
|
209
|
-
//
|
|
210
|
-
const num = Number.parseInt(
|
|
216
|
+
// 1-9: jump to tab (uses display order from tab bar)
|
|
217
|
+
const num = Number.parseInt(name, 10)
|
|
211
218
|
if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
|
|
212
219
|
this.tabBar.setSelectedIndex(num - 1)
|
|
213
220
|
this.switchPane(this.tabBar.getNameAtIndex(num - 1))
|
|
214
221
|
return
|
|
215
222
|
}
|
|
216
223
|
|
|
217
|
-
//
|
|
218
|
-
if (
|
|
224
|
+
// Left/Right: cycle tabs
|
|
225
|
+
if (name === 'left' || name === 'right') {
|
|
219
226
|
const current = this.tabBar.getSelectedIndex()
|
|
220
227
|
const count = this.tabBar.count
|
|
221
|
-
const next =
|
|
228
|
+
const next = name === 'right' ? (current + 1) % count : (current - 1 + count) % count
|
|
222
229
|
this.tabBar.setSelectedIndex(next)
|
|
223
230
|
this.switchPane(this.tabBar.getNameAtIndex(next))
|
|
224
231
|
return
|
|
225
232
|
}
|
|
226
233
|
|
|
227
|
-
//
|
|
228
|
-
if (
|
|
234
|
+
// PageUp/PageDown: scroll by page
|
|
235
|
+
if (name === 'pageup' || name === 'pagedown') {
|
|
229
236
|
const pane = this.panes.get(this.activePane)
|
|
230
237
|
const delta = this.termRows - 2
|
|
231
|
-
pane?.scrollBy(
|
|
238
|
+
pane?.scrollBy(name === 'pageup' ? -delta : delta)
|
|
232
239
|
return
|
|
233
240
|
}
|
|
234
241
|
|
|
235
|
-
//
|
|
236
|
-
if (
|
|
242
|
+
// Home/End: scroll to top/bottom
|
|
243
|
+
if (name === 'home') {
|
|
237
244
|
this.panes.get(this.activePane)?.scrollToTop()
|
|
238
245
|
return
|
|
239
246
|
}
|
|
240
|
-
if (
|
|
247
|
+
if (name === 'end') {
|
|
241
248
|
this.panes.get(this.activePane)?.scrollToBottom()
|
|
242
249
|
return
|
|
243
250
|
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (!this.activePane) return
|
|
247
|
-
|
|
248
|
-
const isInteractive = this.config.processes[this.activePane]?.interactive === true
|
|
249
|
-
|
|
250
|
-
// Non-interactive panes: arrow keys scroll, all other input is dropped
|
|
251
|
-
if (!isInteractive) {
|
|
252
|
-
if (key.name === 'up' || key.name === 'down') {
|
|
253
|
-
const pane = this.panes.get(this.activePane)
|
|
254
|
-
pane?.scrollBy(key.name === 'up' ? -1 : 1)
|
|
255
|
-
} else if (key.name === 'pageup' || key.name === 'pagedown') {
|
|
256
|
-
const pane = this.panes.get(this.activePane)
|
|
257
|
-
const delta = this.termRows - 2
|
|
258
|
-
pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
|
|
259
|
-
} else if (key.name === 'home') {
|
|
260
|
-
this.panes.get(this.activePane)?.scrollToTop()
|
|
261
|
-
} else if (key.name === 'end') {
|
|
262
|
-
this.panes.get(this.activePane)?.scrollToBottom()
|
|
263
|
-
}
|
|
264
251
|
return
|
|
265
252
|
}
|
|
266
253
|
|
|
267
|
-
// Forward all other input to the active process
|
|
254
|
+
// Forward all other input to the active process (interactive mode)
|
|
268
255
|
if (key.sequence) {
|
|
269
256
|
this.manager.write(this.activePane, key.sequence)
|
|
270
257
|
}
|
|
271
258
|
}
|
|
272
259
|
)
|
|
273
260
|
|
|
274
|
-
// Show first pane
|
|
261
|
+
// Show first pane and focus sidebar for keyboard navigation
|
|
275
262
|
if (this.names.length > 0) {
|
|
276
263
|
this.switchPane(this.names[0])
|
|
264
|
+
this.tabBar.focus()
|
|
277
265
|
}
|
|
278
266
|
|
|
279
267
|
// Start all processes
|
package/src/ui/status-bar.ts
CHANGED
|
@@ -34,7 +34,9 @@ export class StatusBar {
|
|
|
34
34
|
if (this._searchMode) {
|
|
35
35
|
return this.buildSearchContent()
|
|
36
36
|
}
|
|
37
|
-
return new StyledText([
|
|
37
|
+
return new StyledText([
|
|
38
|
+
plain('\u2190\u2192/1-9: tabs R: restart S: stop/start F: search L: clear Ctrl+C: quit')
|
|
39
|
+
])
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
private buildSearchContent(): StyledText {
|
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,
|
|
@@ -59,6 +60,18 @@ class ColoredSelectRenderable extends SelectRenderable {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
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
|
+
|
|
62
75
|
private colorizeOptions(): void {
|
|
63
76
|
const fb = this.frameBuffer!
|
|
64
77
|
// Access internal layout state (private in TS, accessible at runtime)
|
package/src/utils/color.ts
CHANGED
|
@@ -51,13 +51,20 @@ const DEFAULT_ANSI_COLORS = [
|
|
|
51
51
|
/** Default palette as hex colors (for styled text rendering) */
|
|
52
52
|
const DEFAULT_HEX_COLORS = ['#00cccc', '#cccc00', '#cc00cc', '#0000cc', '#00cc00', '#ff5555', '#ffff55', '#ff55ff']
|
|
53
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
|
+
|
|
54
61
|
/** Build a map of process names to ANSI color codes, using explicit config colors or a default palette. */
|
|
55
62
|
export function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string> {
|
|
56
63
|
const map = new Map<string, string>()
|
|
57
64
|
if ('NO_COLOR' in process.env) return map
|
|
58
65
|
let paletteIndex = 0
|
|
59
66
|
for (const name of names) {
|
|
60
|
-
const explicit = config.processes[name]?.color
|
|
67
|
+
const explicit = resolveColor(config.processes[name]?.color)
|
|
61
68
|
if (explicit) {
|
|
62
69
|
map.set(name, hexToAnsi(explicit))
|
|
63
70
|
} else {
|
|
@@ -74,7 +81,7 @@ export function buildProcessHexColorMap(names: string[], config: ResolvedNumuxCo
|
|
|
74
81
|
if ('NO_COLOR' in process.env) return map
|
|
75
82
|
let paletteIndex = 0
|
|
76
83
|
for (const name of names) {
|
|
77
|
-
const explicit = config.processes[name]?.color
|
|
84
|
+
const explicit = resolveColor(config.processes[name]?.color)
|
|
78
85
|
if (explicit) {
|
|
79
86
|
map.set(name, explicit.startsWith('#') ? explicit : `#${explicit}`)
|
|
80
87
|
} else {
|
package/src/utils/logger.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
let enabled = false
|
|
5
5
|
let logFile = ''
|
|
6
|
+
let debugCallback: ((line: string) => void) | null = null
|
|
6
7
|
|
|
7
8
|
export function enableDebugLog(dir?: string): void {
|
|
8
9
|
const logDir = dir ?? resolve(process.cwd(), '.numux')
|
|
@@ -13,12 +14,18 @@ export function enableDebugLog(dir?: string): void {
|
|
|
13
14
|
enabled = true
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export function
|
|
17
|
+
export function setDebugCallback(cb: ((line: string) => void) | null): void {
|
|
18
|
+
debugCallback = cb
|
|
19
|
+
}
|
|
20
|
+
export function log(...args: unknown[]): void {
|
|
17
21
|
if (!enabled) return
|
|
18
22
|
try {
|
|
19
23
|
const timestamp = new Date().toISOString()
|
|
20
|
-
const formatted =
|
|
21
|
-
|
|
24
|
+
const formatted =
|
|
25
|
+
args.length > 0 ? `${args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}` : ''
|
|
26
|
+
const line = `[${timestamp}] ${formatted}`
|
|
27
|
+
appendFileSync(logFile, `${line}\n`)
|
|
28
|
+
debugCallback?.(line)
|
|
22
29
|
} catch {
|
|
23
30
|
// Disk errors in debug logging should not crash the app
|
|
24
31
|
enabled = false
|
|
@@ -29,4 +36,5 @@ export function log(message: string, ...args: unknown[]): void {
|
|
|
29
36
|
export function _resetLogger(): void {
|
|
30
37
|
enabled = false
|
|
31
38
|
logFile = ''
|
|
39
|
+
debugCallback = null
|
|
32
40
|
}
|