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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",
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 === '-c' || arg === '--config') {
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
- processes[name] = { command, persistent: true, maxRestarts }
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
- processes[name] = { command: cmd, persistent: true, maxRestarts }
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 }
@@ -22,7 +22,7 @@ _numux() {
22
22
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
23
23
 
24
24
  case "$prev" in
25
- -c|--config)
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 --config)'{-c,--config}'[Config file path]:file:_files' \\
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 config -rF -d 'Config file path'
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
+ }
@@ -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' && !HEX_COLOR_RE.test(p.color)) {
76
- throw new Error(`Process "${name}".color must be a valid hex color (e.g. "#ff8800"), got "${p.color}"`)
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, --config <path> Config file path (default: auto-detect)
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
- config = buildConfigFromArgs(parsed.commands, parsed.named, { noRestart: parsed.noRestart })
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) {
@@ -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 (user intentionally stopped), pending, stopping, or skipped
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)
@@ -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 || code === 0 ? 'stopped' : 'failed'
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
- /** Raw config as authoredprocesses can be string shorthand or full objects */
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 = 'pending' | 'starting' | 'ready' | 'running' | 'stopping' | 'stopped' | 'failed' | 'skipped'
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.names.length) {
211
+ if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
200
212
  this.tabBar.setSelectedIndex(num - 1)
201
- this.switchPane(this.names[num - 1])
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 next =
209
- key.name === 'right'
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.names[next])
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 (status === 'ready' || status === 'failed' || status === 'stopped' || status === 'skipped') {
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(s => s.status === 'stopped' || s.status === 'failed' || s.status === 'skipped')
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 descriptions: Map<string, string>
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.names = names
112
+ this.originalNames = names
113
+ this.names = [...names]
93
114
  this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
94
- this.descriptions = new Map(names.map(n => [n, 'pending']))
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.descriptions.set(name, this.formatDescription(status, exitCode, restartCount))
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.descriptions.get(n)!
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 statusHex = STATUS_ICON_HEX[status]
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'),
@@ -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 {