numux 1.2.0 → 1.3.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.3.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
@@ -29,7 +29,16 @@ export interface ResolvedNumuxConfig {
29
29
  processes: Record<string, NumuxProcessConfig>
30
30
  }
31
31
 
32
- export type ProcessStatus = 'pending' | 'starting' | 'ready' | 'running' | 'stopping' | 'stopped' | 'failed' | 'skipped'
32
+ export type ProcessStatus =
33
+ | 'pending'
34
+ | 'starting'
35
+ | 'ready'
36
+ | 'running'
37
+ | 'stopping'
38
+ | 'stopped'
39
+ | 'finished'
40
+ | 'failed'
41
+ | 'skipped'
33
42
 
34
43
  export interface ProcessState {
35
44
  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
@@ -15,6 +15,7 @@ const STATUS_ICONS: Record<ProcessStatus, string> = {
15
15
  ready: '●',
16
16
  stopping: '◑',
17
17
  stopped: '■',
18
+ finished: '✓',
18
19
  failed: '✖',
19
20
  skipped: '⊘'
20
21
  }
@@ -22,11 +23,15 @@ const STATUS_ICONS: Record<ProcessStatus, string> = {
22
23
  /** Status-specific icon colors (override process colors) */
23
24
  const STATUS_ICON_HEX: Partial<Record<ProcessStatus, string>> = {
24
25
  ready: '#00cc00',
26
+ finished: '#66aa66',
25
27
  failed: '#ff5555',
26
28
  stopped: '#888888',
27
29
  skipped: '#888888'
28
30
  }
29
31
 
32
+ /** Statuses that represent a terminal (done) state — tabs move to bottom */
33
+ const TERMINAL_STATUSES = new Set<ProcessStatus>(['finished', 'stopped', 'failed', 'skipped'])
34
+
30
35
  interface OptionColors {
31
36
  icon: RGBA | null
32
37
  name: RGBA | null
@@ -83,15 +88,18 @@ class ColoredSelectRenderable extends SelectRenderable {
83
88
 
84
89
  export class TabBar {
85
90
  readonly renderable: ColoredSelectRenderable
91
+ private originalNames: string[]
86
92
  private names: string[]
87
93
  private statuses: Map<string, ProcessStatus>
88
- private descriptions: Map<string, string>
94
+ private baseDescriptions: Map<string, string>
89
95
  private processColors: Map<string, string>
96
+ private inputWaiting = new Set<string>()
90
97
 
91
98
  constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>) {
92
- this.names = names
99
+ this.originalNames = names
100
+ this.names = [...names]
93
101
  this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
94
- this.descriptions = new Map(names.map(n => [n, 'pending']))
102
+ this.baseDescriptions = new Map(names.map(n => [n, 'pending']))
95
103
  this.processColors = colors ?? new Map()
96
104
 
97
105
  this.renderable = new ColoredSelectRenderable(renderer, {
@@ -125,18 +133,67 @@ export class TabBar {
125
133
 
126
134
  updateStatus(name: string, status: ProcessStatus, exitCode?: number | null, restartCount?: number): void {
127
135
  this.statuses.set(name, status)
128
- this.descriptions.set(name, this.formatDescription(status, exitCode, restartCount))
136
+ this.baseDescriptions.set(name, this.formatDescription(status, exitCode, restartCount))
137
+ // Clear input waiting on terminal status changes
138
+ if (TERMINAL_STATUSES.has(status) || status === 'stopping') {
139
+ this.inputWaiting.delete(name)
140
+ }
141
+ this.refreshOptions()
142
+ }
143
+
144
+ setInputWaiting(name: string, waiting: boolean): void {
145
+ if (waiting) this.inputWaiting.add(name)
146
+ else this.inputWaiting.delete(name)
147
+ this.refreshOptions()
148
+ }
149
+
150
+ /** Get the process name at the given display index */
151
+ getNameAtIndex(index: number): string {
152
+ return this.names[index]
153
+ }
154
+
155
+ get count(): number {
156
+ return this.names.length
157
+ }
158
+
159
+ private refreshOptions(): void {
160
+ // Preserve currently selected name
161
+ const currentIdx = this.renderable.getSelectedIndex()
162
+ const currentName = this.names[currentIdx]
163
+
164
+ // Reorder: active first, terminal states at bottom
165
+ this.names = this.getDisplayOrder()
166
+
129
167
  this.renderable.options = this.names.map(n => ({
130
168
  name: this.formatTab(n, this.statuses.get(n)!),
131
- description: this.descriptions.get(n)!
169
+ description: this.getDescription(n)
132
170
  }))
171
+
172
+ // Restore selection by name
173
+ const newIdx = this.names.indexOf(currentName)
174
+ if (newIdx >= 0 && newIdx !== currentIdx) {
175
+ this.renderable.setSelectedIndex(newIdx)
176
+ }
177
+
133
178
  this.updateOptionColors()
134
179
  }
135
180
 
181
+ private getDisplayOrder(): string[] {
182
+ const active = this.originalNames.filter(n => !TERMINAL_STATUSES.has(this.statuses.get(n)!))
183
+ const terminal = this.originalNames.filter(n => TERMINAL_STATUSES.has(this.statuses.get(n)!))
184
+ return [...active, ...terminal]
185
+ }
186
+
187
+ private getDescription(name: string): string {
188
+ if (this.inputWaiting.has(name)) return 'awaiting input'
189
+ return this.baseDescriptions.get(name) ?? 'pending'
190
+ }
191
+
136
192
  private updateOptionColors(): void {
137
193
  const colors = this.names.map(name => {
138
194
  const status = this.statuses.get(name)!
139
- const statusHex = STATUS_ICON_HEX[status]
195
+ const waiting = this.inputWaiting.has(name)
196
+ const statusHex = waiting ? '#ffaa00' : STATUS_ICON_HEX[status]
140
197
  const processHex = this.processColors.get(name)
141
198
  return {
142
199
  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'