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 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` | Scroll output 1 line (non-interactive panes) |
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.0",
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 src/index.ts",
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 === '-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) {
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 */
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
- // Alt+Shift combos
171
- if (key.meta && key.shift && !key.ctrl) {
172
- // Alt+Shift+R: restart all processes
173
- if (key.name === 'r') {
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
- if (key.meta && !key.ctrl && !key.shift) {
180
- // Alt+F: enter search mode
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
- // Alt+R: restart active process
187
- if (key.name === 'r' && this.activePane) {
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
- // Alt+S: stop/start active process
193
- if (key.name === 's' && this.activePane) {
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
- // Alt+L: clear active pane
204
- if (key.name === 'l' && this.activePane) {
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
- // Alt+1-9: jump to tab (uses display order from tab bar)
210
- const num = Number.parseInt(key.name, 10)
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
- // Alt+Left/Right: cycle tabs
218
- if (key.name === 'left' || key.name === 'right') {
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 = key.name === 'right' ? (current + 1) % count : (current - 1 + count) % count
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
- // Alt+PageUp/PageDown: scroll output
228
- if (this.activePane && (key.name === 'pageup' || key.name === 'pagedown')) {
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(key.name === 'pageup' ? -delta : delta)
238
+ pane?.scrollBy(name === 'pageup' ? -delta : delta)
232
239
  return
233
240
  }
234
241
 
235
- // Alt+Home/End: scroll to top/bottom
236
- if (this.activePane && key.name === 'home') {
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 (this.activePane && key.name === 'end') {
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
@@ -34,7 +34,9 @@ export class StatusBar {
34
34
  if (this._searchMode) {
35
35
  return this.buildSearchContent()
36
36
  }
37
- return new StyledText([plain('')])
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)
@@ -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 {
@@ -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 log(message: string, ...args: unknown[]): void {
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 = args.length > 0 ? `${message} ${args.map(a => JSON.stringify(a)).join(' ')}` : message
21
- appendFileSync(logFile, `[${timestamp}] ${formatted}\n`)
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
  }