numux 1.1.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 +64 -34
- package/src/ui/prefix.ts +10 -2
- package/src/ui/status-bar.ts +3 -64
- 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
|
@@ -20,7 +20,6 @@ export class App {
|
|
|
20
20
|
private sidebarWidth = 20
|
|
21
21
|
|
|
22
22
|
private config: ResolvedNumuxConfig
|
|
23
|
-
private processHexColors: Map<string, string>
|
|
24
23
|
|
|
25
24
|
private resizeTimer: ReturnType<typeof setTimeout> | null = null
|
|
26
25
|
|
|
@@ -30,11 +29,14 @@ export class App {
|
|
|
30
29
|
private searchMatches: SearchMatch[] = []
|
|
31
30
|
private searchIndex = -1
|
|
32
31
|
|
|
32
|
+
// Input-waiting detection for interactive processes
|
|
33
|
+
private inputWaitTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
34
|
+
private awaitingInput = new Set<string>()
|
|
35
|
+
|
|
33
36
|
constructor(manager: ProcessManager, config: ResolvedNumuxConfig) {
|
|
34
37
|
this.manager = manager
|
|
35
38
|
this.config = config
|
|
36
39
|
this.names = manager.getProcessNames()
|
|
37
|
-
this.processHexColors = buildProcessHexColorMap(this.names, config)
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
async start(): Promise<void> {
|
|
@@ -60,7 +62,8 @@ export class App {
|
|
|
60
62
|
})
|
|
61
63
|
|
|
62
64
|
// Tab bar (vertical sidebar)
|
|
63
|
-
|
|
65
|
+
const processHexColors = buildProcessHexColorMap(this.names, this.config)
|
|
66
|
+
this.tabBar = new TabBar(this.renderer, this.names, processHexColors)
|
|
64
67
|
|
|
65
68
|
// Content row: sidebar | pane
|
|
66
69
|
const contentRow = new BoxRenderable(this.renderer, {
|
|
@@ -91,15 +94,12 @@ export class App {
|
|
|
91
94
|
for (const name of this.names) {
|
|
92
95
|
const interactive = this.config.processes[name].interactive === true
|
|
93
96
|
const pane = new Pane(this.renderer, name, termCols, termRows, interactive)
|
|
94
|
-
pane.onScroll(() => {
|
|
95
|
-
if (name === this.activePane) this.updateScrollIndicator()
|
|
96
|
-
})
|
|
97
97
|
this.panes.set(name, pane)
|
|
98
98
|
paneContainer.add(pane.scrollBox)
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
// Status bar
|
|
102
|
-
this.statusBar = new StatusBar(this.renderer
|
|
101
|
+
// Status bar (only visible during search)
|
|
102
|
+
this.statusBar = new StatusBar(this.renderer)
|
|
103
103
|
|
|
104
104
|
// Assemble layout
|
|
105
105
|
contentRow.add(sidebar)
|
|
@@ -117,13 +117,17 @@ export class App {
|
|
|
117
117
|
if (this.destroyed) return
|
|
118
118
|
if (event.type === 'output') {
|
|
119
119
|
this.panes.get(event.name)?.feed(event.data)
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
// Detect input-waiting for interactive processes
|
|
121
|
+
if (this.config.processes[event.name]?.interactive) {
|
|
122
|
+
this.checkInputWaiting(event.name, event.data)
|
|
122
123
|
}
|
|
123
124
|
} else if (event.type === 'status') {
|
|
124
125
|
const state = this.manager.getState(event.name)
|
|
125
126
|
this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount)
|
|
126
|
-
|
|
127
|
+
// Clear input-waiting on non-active statuses
|
|
128
|
+
if (event.status !== 'running' && event.status !== 'ready') {
|
|
129
|
+
this.clearInputWaiting(event.name)
|
|
130
|
+
}
|
|
127
131
|
}
|
|
128
132
|
})
|
|
129
133
|
|
|
@@ -188,7 +192,7 @@ export class App {
|
|
|
188
192
|
// Alt+S: stop/start active process
|
|
189
193
|
if (key.name === 's' && this.activePane) {
|
|
190
194
|
const state = this.manager.getState(this.activePane)
|
|
191
|
-
if (state?.status === 'stopped' || state?.status === 'failed') {
|
|
195
|
+
if (state?.status === 'stopped' || state?.status === 'finished' || state?.status === 'failed') {
|
|
192
196
|
this.manager.start(this.activePane, this.termCols, this.termRows)
|
|
193
197
|
} else {
|
|
194
198
|
this.manager.stop(this.activePane)
|
|
@@ -202,23 +206,21 @@ export class App {
|
|
|
202
206
|
return
|
|
203
207
|
}
|
|
204
208
|
|
|
205
|
-
// Alt+1-9: jump to tab
|
|
209
|
+
// Alt+1-9: jump to tab (uses display order from tab bar)
|
|
206
210
|
const num = Number.parseInt(key.name, 10)
|
|
207
|
-
if (num >= 1 && num <= 9 && num <= this.
|
|
211
|
+
if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
|
|
208
212
|
this.tabBar.setSelectedIndex(num - 1)
|
|
209
|
-
this.switchPane(this.
|
|
213
|
+
this.switchPane(this.tabBar.getNameAtIndex(num - 1))
|
|
210
214
|
return
|
|
211
215
|
}
|
|
212
216
|
|
|
213
217
|
// Alt+Left/Right: cycle tabs
|
|
214
218
|
if (key.name === 'left' || key.name === 'right') {
|
|
215
219
|
const current = this.tabBar.getSelectedIndex()
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
? (current + 1) % this.names.length
|
|
219
|
-
: (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
|
|
220
222
|
this.tabBar.setSelectedIndex(next)
|
|
221
|
-
this.switchPane(this.
|
|
223
|
+
this.switchPane(this.tabBar.getNameAtIndex(next))
|
|
222
224
|
return
|
|
223
225
|
}
|
|
224
226
|
|
|
@@ -227,19 +229,16 @@ export class App {
|
|
|
227
229
|
const pane = this.panes.get(this.activePane)
|
|
228
230
|
const delta = this.termRows - 2
|
|
229
231
|
pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
|
|
230
|
-
this.updateScrollIndicator()
|
|
231
232
|
return
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
// Alt+Home/End: scroll to top/bottom
|
|
235
236
|
if (this.activePane && key.name === 'home') {
|
|
236
237
|
this.panes.get(this.activePane)?.scrollToTop()
|
|
237
|
-
this.updateScrollIndicator()
|
|
238
238
|
return
|
|
239
239
|
}
|
|
240
240
|
if (this.activePane && key.name === 'end') {
|
|
241
241
|
this.panes.get(this.activePane)?.scrollToBottom()
|
|
242
|
-
this.updateScrollIndicator()
|
|
243
242
|
return
|
|
244
243
|
}
|
|
245
244
|
}
|
|
@@ -253,18 +252,14 @@ export class App {
|
|
|
253
252
|
if (key.name === 'up' || key.name === 'down') {
|
|
254
253
|
const pane = this.panes.get(this.activePane)
|
|
255
254
|
pane?.scrollBy(key.name === 'up' ? -1 : 1)
|
|
256
|
-
this.updateScrollIndicator()
|
|
257
255
|
} else if (key.name === 'pageup' || key.name === 'pagedown') {
|
|
258
256
|
const pane = this.panes.get(this.activePane)
|
|
259
257
|
const delta = this.termRows - 2
|
|
260
258
|
pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
|
|
261
|
-
this.updateScrollIndicator()
|
|
262
259
|
} else if (key.name === 'home') {
|
|
263
260
|
this.panes.get(this.activePane)?.scrollToTop()
|
|
264
|
-
this.updateScrollIndicator()
|
|
265
261
|
} else if (key.name === 'end') {
|
|
266
262
|
this.panes.get(this.activePane)?.scrollToBottom()
|
|
267
|
-
this.updateScrollIndicator()
|
|
268
263
|
}
|
|
269
264
|
return
|
|
270
265
|
}
|
|
@@ -296,14 +291,45 @@ export class App {
|
|
|
296
291
|
}
|
|
297
292
|
this.activePane = name
|
|
298
293
|
this.panes.get(name)?.show()
|
|
299
|
-
this.updateScrollIndicator()
|
|
300
294
|
}
|
|
301
295
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|
|
307
333
|
}
|
|
308
334
|
|
|
309
335
|
private enterSearch(): void {
|
|
@@ -398,7 +424,6 @@ export class App {
|
|
|
398
424
|
if (!pane) return
|
|
399
425
|
const match = this.searchMatches[this.searchIndex]
|
|
400
426
|
pane.scrollToLine(match.line)
|
|
401
|
-
this.updateScrollIndicator()
|
|
402
427
|
}
|
|
403
428
|
|
|
404
429
|
async shutdown(): Promise<void> {
|
|
@@ -408,6 +433,11 @@ export class App {
|
|
|
408
433
|
clearTimeout(this.resizeTimer)
|
|
409
434
|
this.resizeTimer = null
|
|
410
435
|
}
|
|
436
|
+
// Clear all input-waiting timers
|
|
437
|
+
for (const timer of this.inputWaitTimers.values()) {
|
|
438
|
+
clearTimeout(timer)
|
|
439
|
+
}
|
|
440
|
+
this.inputWaitTimers.clear()
|
|
411
441
|
await this.manager.stopAll()
|
|
412
442
|
for (const pane of this.panes.values()) {
|
|
413
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/status-bar.ts
CHANGED
|
@@ -1,24 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type CliRenderer,
|
|
3
|
-
cyan,
|
|
4
|
-
fg,
|
|
5
|
-
green,
|
|
6
|
-
red,
|
|
7
|
-
reverse,
|
|
8
|
-
StyledText,
|
|
9
|
-
type TextChunk,
|
|
10
|
-
TextRenderable,
|
|
11
|
-
yellow
|
|
12
|
-
} from '@opentui/core'
|
|
13
|
-
import type { ProcessStatus } from '../types'
|
|
14
|
-
|
|
15
|
-
const STATUS_STYLE: Partial<Record<ProcessStatus, (input: string) => TextChunk>> = {
|
|
16
|
-
ready: green,
|
|
17
|
-
running: cyan,
|
|
18
|
-
failed: red,
|
|
19
|
-
stopped: fg('#888'),
|
|
20
|
-
skipped: fg('#888')
|
|
21
|
-
}
|
|
1
|
+
import { type CliRenderer, cyan, red, reverse, StyledText, type TextChunk, TextRenderable, yellow } from '@opentui/core'
|
|
22
2
|
|
|
23
3
|
function plain(text: string): TextChunk {
|
|
24
4
|
return { __isChunk: true, text } as TextChunk
|
|
@@ -26,20 +6,12 @@ function plain(text: string): TextChunk {
|
|
|
26
6
|
|
|
27
7
|
export class StatusBar {
|
|
28
8
|
readonly renderable: TextRenderable
|
|
29
|
-
private statuses = new Map<string, ProcessStatus>()
|
|
30
|
-
private colors: Map<string, string>
|
|
31
|
-
private scrolledUp = false
|
|
32
9
|
private _searchMode = false
|
|
33
10
|
private _searchQuery = ''
|
|
34
11
|
private _searchMatchCount = 0
|
|
35
12
|
private _searchCurrentIndex = -1
|
|
36
13
|
|
|
37
|
-
constructor(renderer: CliRenderer
|
|
38
|
-
this.colors = colors ?? new Map()
|
|
39
|
-
for (const name of names) {
|
|
40
|
-
this.statuses.set(name, 'pending')
|
|
41
|
-
}
|
|
42
|
-
|
|
14
|
+
constructor(renderer: CliRenderer) {
|
|
43
15
|
this.renderable = new TextRenderable(renderer, {
|
|
44
16
|
id: 'status-bar',
|
|
45
17
|
width: '100%',
|
|
@@ -50,17 +22,6 @@ export class StatusBar {
|
|
|
50
22
|
})
|
|
51
23
|
}
|
|
52
24
|
|
|
53
|
-
updateStatus(name: string, status: ProcessStatus): void {
|
|
54
|
-
this.statuses.set(name, status)
|
|
55
|
-
this.renderable.content = this.buildContent()
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
setScrollIndicator(scrolledUp: boolean): void {
|
|
59
|
-
if (this.scrolledUp === scrolledUp) return
|
|
60
|
-
this.scrolledUp = scrolledUp
|
|
61
|
-
this.renderable.content = this.buildContent()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
25
|
setSearchMode(active: boolean, query = '', matchCount = 0, currentIndex = -1): void {
|
|
65
26
|
this._searchMode = active
|
|
66
27
|
this._searchQuery = query
|
|
@@ -73,29 +34,7 @@ export class StatusBar {
|
|
|
73
34
|
if (this._searchMode) {
|
|
74
35
|
return this.buildSearchContent()
|
|
75
36
|
}
|
|
76
|
-
|
|
77
|
-
let first = true
|
|
78
|
-
for (const [name, status] of this.statuses) {
|
|
79
|
-
if (!first) chunks.push(plain(' '))
|
|
80
|
-
first = false
|
|
81
|
-
const styleFn = STATUS_STYLE[status]
|
|
82
|
-
const hexColor = this.colors.get(name)
|
|
83
|
-
if (styleFn) {
|
|
84
|
-
chunks.push(styleFn(`${name}:${status}`))
|
|
85
|
-
} else if (hexColor) {
|
|
86
|
-
chunks.push(fg(hexColor)(`${name}:${status}`))
|
|
87
|
-
} else {
|
|
88
|
-
chunks.push(plain(`${name}:${status}`))
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
if (this.scrolledUp) {
|
|
92
|
-
chunks.push(plain(' '))
|
|
93
|
-
chunks.push(yellow('[scrolled]'))
|
|
94
|
-
}
|
|
95
|
-
chunks.push(
|
|
96
|
-
plain(' Alt+\u2190\u2192/1-9: tabs Alt+PgUp/Dn: scroll Alt+R: restart Alt+S: stop/start Ctrl+C: quit')
|
|
97
|
-
)
|
|
98
|
-
return new StyledText(chunks)
|
|
37
|
+
return new StyledText([plain('')])
|
|
99
38
|
}
|
|
100
39
|
|
|
101
40
|
private buildSearchContent(): StyledText {
|
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'
|