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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "1.1.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
@@ -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
- this.tabBar = new TabBar(this.renderer, this.names, this.processHexColors)
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, this.names, this.processHexColors)
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
- if (event.name === this.activePane) {
121
- this.updateScrollIndicator()
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
- this.statusBar.updateStatus(event.name, event.status)
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.names.length) {
211
+ if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
208
212
  this.tabBar.setSelectedIndex(num - 1)
209
- this.switchPane(this.names[num - 1])
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 next =
217
- key.name === 'right'
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.names[next])
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
- private updateScrollIndicator(): void {
303
- if (!this.activePane) return
304
- const pane = this.panes.get(this.activePane)
305
- if (!pane) return
306
- this.statusBar.setScrollIndicator(!pane.isAtBottom)
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 (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()
@@ -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, names: string[], colors?: Map<string, string>) {
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
- const chunks: TextChunk[] = []
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 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'