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 +1 -1
- package/src/process/manager.ts +4 -2
- package/src/process/runner.ts +1 -1
- package/src/types.ts +10 -1
- package/src/ui/app.ts +63 -9
- package/src/ui/prefix.ts +10 -2
- package/src/ui/tabs.ts +63 -6
- package/src/utils/color.ts +1 -0
package/package.json
CHANGED
package/src/process/manager.ts
CHANGED
|
@@ -217,10 +217,11 @@ export class ProcessManager {
|
|
|
217
217
|
this.fileWatcher.watch(name, patterns, cwd, changedFile => {
|
|
218
218
|
const state = this.states.get(name)
|
|
219
219
|
if (!state) return
|
|
220
|
-
// Don't restart processes that are stopped
|
|
220
|
+
// Don't restart processes that are stopped/finished, pending, stopping, or skipped
|
|
221
221
|
if (
|
|
222
222
|
state.status === 'pending' ||
|
|
223
223
|
state.status === 'stopped' ||
|
|
224
|
+
state.status === 'finished' ||
|
|
224
225
|
state.status === 'stopping' ||
|
|
225
226
|
state.status === 'skipped'
|
|
226
227
|
)
|
|
@@ -269,6 +270,7 @@ export class ProcessManager {
|
|
|
269
270
|
if (
|
|
270
271
|
state.status === 'pending' ||
|
|
271
272
|
state.status === 'stopped' ||
|
|
273
|
+
state.status === 'finished' ||
|
|
272
274
|
state.status === 'stopping' ||
|
|
273
275
|
state.status === 'skipped'
|
|
274
276
|
)
|
|
@@ -300,7 +302,7 @@ export class ProcessManager {
|
|
|
300
302
|
start(name: string, cols: number, rows: number): void {
|
|
301
303
|
const state = this.states.get(name)
|
|
302
304
|
if (!state) return
|
|
303
|
-
if (state.status !== 'stopped' && state.status !== 'failed') return
|
|
305
|
+
if (state.status !== 'stopped' && state.status !== 'finished' && state.status !== 'failed') return
|
|
304
306
|
|
|
305
307
|
// Cancel pending auto-restart and reset backoff
|
|
306
308
|
const timer = this.restartTimers.get(name)
|
package/src/process/runner.ts
CHANGED
|
@@ -110,7 +110,7 @@ export class ProcessRunner {
|
|
|
110
110
|
// duplicate status/exit events to avoid double onStatus('failed')
|
|
111
111
|
// and unintended auto-restart scheduling.
|
|
112
112
|
if (!this.readyTimedOut) {
|
|
113
|
-
const status: ProcessStatus = this.stopping
|
|
113
|
+
const status: ProcessStatus = this.stopping ? 'stopped' : code === 0 ? 'finished' : 'failed'
|
|
114
114
|
this.handler.onStatus(status)
|
|
115
115
|
this.handler.onExit(code)
|
|
116
116
|
}
|
package/src/types.ts
CHANGED
|
@@ -29,7 +29,16 @@ export interface ResolvedNumuxConfig {
|
|
|
29
29
|
processes: Record<string, NumuxProcessConfig>
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export type ProcessStatus =
|
|
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.
|
|
211
|
+
if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
|
|
200
212
|
this.tabBar.setSelectedIndex(num - 1)
|
|
201
|
-
this.switchPane(this.
|
|
213
|
+
this.switchPane(this.tabBar.getNameAtIndex(num - 1))
|
|
202
214
|
return
|
|
203
215
|
}
|
|
204
216
|
|
|
205
217
|
// Alt+Left/Right: cycle tabs
|
|
206
218
|
if (key.name === 'left' || key.name === 'right') {
|
|
207
219
|
const current = this.tabBar.getSelectedIndex()
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
? (current + 1) % this.names.length
|
|
211
|
-
: (current - 1 + this.names.length) % this.names.length
|
|
220
|
+
const count = this.tabBar.count
|
|
221
|
+
const next = key.name === 'right' ? (current + 1) % count : (current - 1 + count) % count
|
|
212
222
|
this.tabBar.setSelectedIndex(next)
|
|
213
|
-
this.switchPane(this.
|
|
223
|
+
this.switchPane(this.tabBar.getNameAtIndex(next))
|
|
214
224
|
return
|
|
215
225
|
}
|
|
216
226
|
|
|
@@ -283,6 +293,45 @@ export class App {
|
|
|
283
293
|
this.panes.get(name)?.show()
|
|
284
294
|
}
|
|
285
295
|
|
|
296
|
+
/** Detect when an interactive process is likely waiting for user input */
|
|
297
|
+
private checkInputWaiting(name: string, data: Uint8Array): void {
|
|
298
|
+
// Clear existing timer
|
|
299
|
+
const existing = this.inputWaitTimers.get(name)
|
|
300
|
+
if (existing) clearTimeout(existing)
|
|
301
|
+
|
|
302
|
+
// If we were showing awaiting input, clear it since new output arrived
|
|
303
|
+
if (this.awaitingInput.has(name)) {
|
|
304
|
+
this.awaitingInput.delete(name)
|
|
305
|
+
this.tabBar.setInputWaiting(name, false)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// If the last byte is not a newline, the process may be showing a prompt
|
|
309
|
+
const lastByte = data[data.length - 1]
|
|
310
|
+
if (lastByte !== 0x0a && lastByte !== 0x0d) {
|
|
311
|
+
const timer = setTimeout(() => {
|
|
312
|
+
this.inputWaitTimers.delete(name)
|
|
313
|
+
const state = this.manager.getState(name)
|
|
314
|
+
if (state && (state.status === 'running' || state.status === 'ready')) {
|
|
315
|
+
this.awaitingInput.add(name)
|
|
316
|
+
this.tabBar.setInputWaiting(name, true)
|
|
317
|
+
}
|
|
318
|
+
}, 200)
|
|
319
|
+
this.inputWaitTimers.set(name, timer)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private clearInputWaiting(name: string): void {
|
|
324
|
+
const timer = this.inputWaitTimers.get(name)
|
|
325
|
+
if (timer) {
|
|
326
|
+
clearTimeout(timer)
|
|
327
|
+
this.inputWaitTimers.delete(name)
|
|
328
|
+
}
|
|
329
|
+
if (this.awaitingInput.has(name)) {
|
|
330
|
+
this.awaitingInput.delete(name)
|
|
331
|
+
this.tabBar.setInputWaiting(name, false)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
286
335
|
private enterSearch(): void {
|
|
287
336
|
this.searchMode = true
|
|
288
337
|
this.searchQuery = ''
|
|
@@ -384,6 +433,11 @@ export class App {
|
|
|
384
433
|
clearTimeout(this.resizeTimer)
|
|
385
434
|
this.resizeTimer = null
|
|
386
435
|
}
|
|
436
|
+
// Clear all input-waiting timers
|
|
437
|
+
for (const timer of this.inputWaitTimers.values()) {
|
|
438
|
+
clearTimeout(timer)
|
|
439
|
+
}
|
|
440
|
+
this.inputWaitTimers.clear()
|
|
387
441
|
await this.manager.stopAll()
|
|
388
442
|
for (const pane of this.panes.values()) {
|
|
389
443
|
pane.destroy()
|
package/src/ui/prefix.ts
CHANGED
|
@@ -102,7 +102,13 @@ export class PrefixDisplay {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
private handleStatus(name: string, status: ProcessStatus): void {
|
|
105
|
-
if (
|
|
105
|
+
if (
|
|
106
|
+
status === 'ready' ||
|
|
107
|
+
status === 'failed' ||
|
|
108
|
+
status === 'finished' ||
|
|
109
|
+
status === 'stopped' ||
|
|
110
|
+
status === 'skipped'
|
|
111
|
+
) {
|
|
106
112
|
if (this.noColor) {
|
|
107
113
|
this.printLine(name, `→ ${status}`)
|
|
108
114
|
} else {
|
|
@@ -144,7 +150,9 @@ export class PrefixDisplay {
|
|
|
144
150
|
private checkAllDone(): void {
|
|
145
151
|
if (this.stopping) return
|
|
146
152
|
const states = this.manager.getAllStates()
|
|
147
|
-
const allDone = states.every(
|
|
153
|
+
const allDone = states.every(
|
|
154
|
+
s => s.status === 'stopped' || s.status === 'finished' || s.status === 'failed' || s.status === 'skipped'
|
|
155
|
+
)
|
|
148
156
|
if (allDone) {
|
|
149
157
|
this.printSummary()
|
|
150
158
|
this.logWriter?.close()
|
package/src/ui/tabs.ts
CHANGED
|
@@ -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
|
|
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.
|
|
99
|
+
this.originalNames = names
|
|
100
|
+
this.names = [...names]
|
|
93
101
|
this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
|
|
94
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
|
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'),
|
package/src/utils/color.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { ProcessStatus, ResolvedNumuxConfig } from '../types'
|
|
|
20
20
|
export const STATUS_ANSI: Partial<Record<ProcessStatus, string>> = {
|
|
21
21
|
ready: '\x1b[32m',
|
|
22
22
|
running: '\x1b[36m',
|
|
23
|
+
finished: '\x1b[32m',
|
|
23
24
|
failed: '\x1b[31m',
|
|
24
25
|
stopped: '\x1b[90m',
|
|
25
26
|
skipped: '\x1b[90m'
|