numux 1.4.0 → 1.5.1

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/src/types.ts DELETED
@@ -1,57 +0,0 @@
1
- export interface NumuxProcessConfig {
2
- command: string
3
- cwd?: string
4
- env?: Record<string, string>
5
- envFile?: string | string[] // .env file path(s) to load
6
- dependsOn?: string[]
7
- readyPattern?: string
8
- persistent?: boolean // default true, false = one-shot
9
- maxRestarts?: number // default Infinity, limit auto-restart attempts
10
- readyTimeout?: number // ms to wait for readyPattern before failing (default: none)
11
- delay?: number // ms to wait before starting the process (default: none)
12
- condition?: string // env var name (prefix with ! to negate); process skipped if condition is falsy
13
- stopSignal?: 'SIGTERM' | 'SIGINT' | 'SIGHUP' // signal for graceful stop (default: SIGTERM)
14
- color?: string | string[]
15
- watch?: string | string[] // Glob patterns — restart process when matching files change
16
- interactive?: boolean // default false — when true, keyboard input is forwarded to the process
17
- }
18
-
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 */
23
- export interface NumuxConfig {
24
- cwd?: string // Global working directory, inherited by all processes
25
- env?: Record<string, string> // Global env vars, merged into each process (process-level overrides)
26
- envFile?: string | string[] // Global .env file(s), inherited by processes without their own envFile
27
- processes: Record<string, NumuxProcessConfig | NumuxScriptPattern | string>
28
- }
29
-
30
- /** Validated config with all shorthand expanded to full objects */
31
- export interface ResolvedNumuxConfig {
32
- processes: Record<string, NumuxProcessConfig>
33
- }
34
-
35
- export type ProcessStatus =
36
- | 'pending'
37
- | 'starting'
38
- | 'ready'
39
- | 'running'
40
- | 'stopping'
41
- | 'stopped'
42
- | 'finished'
43
- | 'failed'
44
- | 'skipped'
45
-
46
- export interface ProcessState {
47
- name: string
48
- config: NumuxProcessConfig
49
- status: ProcessStatus
50
- exitCode: number | null
51
- restartCount: number
52
- }
53
-
54
- export type ProcessEvent =
55
- | { type: 'status'; name: string; status: ProcessStatus }
56
- | { type: 'output'; name: string; data: Uint8Array }
57
- | { type: 'exit'; name: string; code: number | null }
package/src/ui/app.ts DELETED
@@ -1,454 +0,0 @@
1
- import { BoxRenderable, type CliRenderer, createCliRenderer } from '@opentui/core'
2
- import type { ProcessManager } from '../process/manager'
3
- import type { ResolvedNumuxConfig } from '../types'
4
- import { buildProcessHexColorMap } from '../utils/color'
5
- import { Pane, type SearchMatch } from './pane'
6
- import { StatusBar } from './status-bar'
7
- import { TabBar } from './tabs'
8
-
9
- export class App {
10
- private renderer!: CliRenderer
11
- private manager: ProcessManager
12
- private panes = new Map<string, Pane>()
13
- private tabBar!: TabBar
14
- private statusBar!: StatusBar
15
- private activePane: string | null = null
16
- private destroyed = false
17
- private names: string[]
18
- private termCols = 80
19
- private termRows = 24
20
- private sidebarWidth = 20
21
-
22
- private config: ResolvedNumuxConfig
23
-
24
- private resizeTimer: ReturnType<typeof setTimeout> | null = null
25
-
26
- // Search state
27
- private searchMode = false
28
- private searchQuery = ''
29
- private searchMatches: SearchMatch[] = []
30
- private searchIndex = -1
31
-
32
- // Input-waiting detection for interactive processes
33
- private inputWaitTimers = new Map<string, ReturnType<typeof setTimeout>>()
34
- private awaitingInput = new Set<string>()
35
-
36
- constructor(manager: ProcessManager, config: ResolvedNumuxConfig) {
37
- this.manager = manager
38
- this.config = config
39
- this.names = manager.getProcessNames()
40
- }
41
-
42
- async start(): Promise<void> {
43
- this.renderer = await createCliRenderer({
44
- exitOnCtrlC: false,
45
- useMouse: true
46
- })
47
-
48
- const { width, height } = this.renderer
49
- const maxNameLen = Math.max(...this.names.map(n => n.length))
50
- this.sidebarWidth = Math.min(30, Math.max(16, maxNameLen + 5))
51
- this.termCols = Math.max(40, width - this.sidebarWidth - 2)
52
- this.termRows = Math.max(5, height - 2)
53
- const { termCols, termRows } = this
54
-
55
- // Layout root
56
- const layout = new BoxRenderable(this.renderer, {
57
- id: 'root',
58
- flexDirection: 'column',
59
- width: '100%',
60
- height: '100%',
61
- border: false
62
- })
63
-
64
- // Tab bar (vertical sidebar)
65
- const processHexColors = buildProcessHexColorMap(this.names, this.config)
66
- this.tabBar = new TabBar(this.renderer, this.names, processHexColors)
67
-
68
- // Content row: sidebar | pane
69
- const contentRow = new BoxRenderable(this.renderer, {
70
- id: 'content-row',
71
- flexDirection: 'row',
72
- flexGrow: 1,
73
- width: '100%',
74
- border: false
75
- })
76
-
77
- const sidebar = new BoxRenderable(this.renderer, {
78
- id: 'sidebar',
79
- width: this.sidebarWidth,
80
- height: '100%',
81
- border: ['right'],
82
- borderColor: '#444'
83
- })
84
- sidebar.add(this.tabBar.renderable)
85
-
86
- // Pane container
87
- const paneContainer = new BoxRenderable(this.renderer, {
88
- id: 'pane-container',
89
- flexGrow: 1,
90
- border: false
91
- })
92
-
93
- // Create a pane per process
94
- for (const name of this.names) {
95
- const interactive = this.config.processes[name].interactive === true
96
- const pane = new Pane(this.renderer, name, termCols, termRows, interactive)
97
- this.panes.set(name, pane)
98
- paneContainer.add(pane.scrollBox)
99
- }
100
-
101
- // Status bar (only visible during search)
102
- this.statusBar = new StatusBar(this.renderer)
103
-
104
- // Assemble layout
105
- contentRow.add(sidebar)
106
- contentRow.add(paneContainer)
107
- layout.add(contentRow)
108
- layout.add(this.statusBar.renderable)
109
- this.renderer.root.add(layout)
110
-
111
- // Wire tab events (mouse clicks)
112
- this.tabBar.onSelect((_index, name) => this.switchPane(name))
113
- this.tabBar.onSelectionChanged((_index, name) => this.switchPane(name))
114
-
115
- // Wire process events
116
- this.manager.on(event => {
117
- if (this.destroyed) return
118
- if (event.type === 'output') {
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
- }
124
- } else if (event.type === 'status') {
125
- const state = this.manager.getState(event.name)
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
- }
131
- }
132
- })
133
-
134
- // Handle resize (debounced to avoid excessive PTY resize calls)
135
- this.renderer.on('resize', (w: number, h: number) => {
136
- this.termCols = Math.max(40, w - this.sidebarWidth - 2)
137
- this.termRows = Math.max(5, h - 2)
138
- if (this.resizeTimer) clearTimeout(this.resizeTimer)
139
- this.resizeTimer = setTimeout(() => {
140
- this.resizeTimer = null
141
- for (const pane of this.panes.values()) {
142
- pane.resize(this.termCols, this.termRows)
143
- }
144
- this.manager.resizeAll(this.termCols, this.termRows)
145
- }, 50)
146
- })
147
-
148
- // Global keyboard handler
149
- this.renderer.keyInput.on(
150
- 'keypress',
151
- (key: { ctrl: boolean; shift: boolean; meta: boolean; name: string; sequence: string }) => {
152
- // Ctrl+C: quit (always works)
153
- if (key.ctrl && key.name === 'c') {
154
- if (this.searchMode) {
155
- this.exitSearch()
156
- return
157
- }
158
- this.shutdown().then(() => {
159
- process.exit(this.hasFailures() ? 1 : 0)
160
- })
161
- return
162
- }
163
-
164
- // Search mode input handling
165
- if (this.searchMode) {
166
- this.handleSearchInput(key)
167
- return
168
- }
169
-
170
- // Alt+Shift combos
171
- if (key.meta && key.shift && !key.ctrl) {
172
- // Alt+Shift+R: restart all processes
173
- if (key.name === 'r') {
174
- this.manager.restartAll(this.termCols, this.termRows)
175
- return
176
- }
177
- }
178
-
179
- if (key.meta && !key.ctrl && !key.shift) {
180
- // Alt+F: enter search mode
181
- if (key.name === 'f' && this.activePane) {
182
- this.enterSearch()
183
- return
184
- }
185
-
186
- // Alt+R: restart active process
187
- if (key.name === 'r' && this.activePane) {
188
- this.manager.restart(this.activePane, this.termCols, this.termRows)
189
- return
190
- }
191
-
192
- // Alt+S: stop/start active process
193
- if (key.name === 's' && this.activePane) {
194
- const state = this.manager.getState(this.activePane)
195
- if (state?.status === 'stopped' || state?.status === 'finished' || state?.status === 'failed') {
196
- this.manager.start(this.activePane, this.termCols, this.termRows)
197
- } else {
198
- this.manager.stop(this.activePane)
199
- }
200
- return
201
- }
202
-
203
- // Alt+L: clear active pane
204
- if (key.name === 'l' && this.activePane) {
205
- this.panes.get(this.activePane)?.clear()
206
- return
207
- }
208
-
209
- // Alt+1-9: jump to tab (uses display order from tab bar)
210
- const num = Number.parseInt(key.name, 10)
211
- if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
212
- this.tabBar.setSelectedIndex(num - 1)
213
- this.switchPane(this.tabBar.getNameAtIndex(num - 1))
214
- return
215
- }
216
-
217
- // Alt+Left/Right: cycle tabs
218
- if (key.name === 'left' || key.name === 'right') {
219
- const current = this.tabBar.getSelectedIndex()
220
- const count = this.tabBar.count
221
- const next = key.name === 'right' ? (current + 1) % count : (current - 1 + count) % count
222
- this.tabBar.setSelectedIndex(next)
223
- this.switchPane(this.tabBar.getNameAtIndex(next))
224
- return
225
- }
226
-
227
- // Alt+PageUp/PageDown: scroll output
228
- if (this.activePane && (key.name === 'pageup' || key.name === 'pagedown')) {
229
- const pane = this.panes.get(this.activePane)
230
- const delta = this.termRows - 2
231
- pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
232
- return
233
- }
234
-
235
- // Alt+Home/End: scroll to top/bottom
236
- if (this.activePane && key.name === 'home') {
237
- this.panes.get(this.activePane)?.scrollToTop()
238
- return
239
- }
240
- if (this.activePane && key.name === 'end') {
241
- this.panes.get(this.activePane)?.scrollToBottom()
242
- return
243
- }
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
- return
265
- }
266
-
267
- // Forward all other input to the active process
268
- if (key.sequence) {
269
- this.manager.write(this.activePane, key.sequence)
270
- }
271
- }
272
- )
273
-
274
- // Show first pane
275
- if (this.names.length > 0) {
276
- this.switchPane(this.names[0])
277
- }
278
-
279
- // Start all processes
280
- await this.manager.startAll(termCols, termRows)
281
- }
282
-
283
- private switchPane(name: string): void {
284
- if (this.activePane === name) return
285
- // Clear search when switching panes
286
- if (this.searchMode) {
287
- this.exitSearch()
288
- }
289
- if (this.activePane) {
290
- this.panes.get(this.activePane)?.hide()
291
- }
292
- this.activePane = name
293
- this.panes.get(name)?.show()
294
- }
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
-
335
- private enterSearch(): void {
336
- this.searchMode = true
337
- this.searchQuery = ''
338
- this.searchMatches = []
339
- this.searchIndex = -1
340
- this.statusBar.setSearchMode(true)
341
- }
342
-
343
- private exitSearch(): void {
344
- this.searchMode = false
345
- this.searchQuery = ''
346
- this.searchMatches = []
347
- this.searchIndex = -1
348
- if (this.activePane) {
349
- this.panes.get(this.activePane)?.clearHighlights()
350
- }
351
- this.statusBar.setSearchMode(false)
352
- }
353
-
354
- private handleSearchInput(key: {
355
- ctrl: boolean
356
- shift: boolean
357
- meta: boolean
358
- name: string
359
- sequence: string
360
- }): void {
361
- if (key.name === 'escape') {
362
- this.exitSearch()
363
- return
364
- }
365
-
366
- if (key.name === 'return') {
367
- // Enter: next match, Shift+Enter: previous match
368
- if (this.searchMatches.length === 0) return
369
- if (key.shift) {
370
- this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length
371
- } else {
372
- this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length
373
- }
374
- this.scrollToCurrentMatch()
375
- this.updateSearchHighlights()
376
- return
377
- }
378
-
379
- if (key.name === 'backspace') {
380
- if (this.searchQuery.length > 0) {
381
- this.searchQuery = this.searchQuery.slice(0, -1)
382
- this.runSearch()
383
- }
384
- return
385
- }
386
-
387
- // Printable character
388
- if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
389
- this.searchQuery += key.sequence
390
- this.runSearch()
391
- }
392
- }
393
-
394
- private runSearch(): void {
395
- if (!this.activePane) return
396
- const pane = this.panes.get(this.activePane)
397
- if (!pane) return
398
-
399
- this.searchMatches = pane.search(this.searchQuery)
400
- this.searchIndex = this.searchMatches.length > 0 ? 0 : -1
401
-
402
- this.updateSearchHighlights()
403
- if (this.searchIndex >= 0) {
404
- this.scrollToCurrentMatch()
405
- }
406
- }
407
-
408
- private updateSearchHighlights(): void {
409
- if (!this.activePane) return
410
- const pane = this.panes.get(this.activePane)
411
- if (!pane) return
412
-
413
- if (this.searchMatches.length > 0) {
414
- pane.setHighlights(this.searchMatches, this.searchIndex)
415
- } else {
416
- pane.clearHighlights()
417
- }
418
- this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex)
419
- }
420
-
421
- private scrollToCurrentMatch(): void {
422
- if (!this.activePane || this.searchIndex < 0) return
423
- const pane = this.panes.get(this.activePane)
424
- if (!pane) return
425
- const match = this.searchMatches[this.searchIndex]
426
- pane.scrollToLine(match.line)
427
- }
428
-
429
- async shutdown(): Promise<void> {
430
- if (this.destroyed) return
431
- this.destroyed = true
432
- if (this.resizeTimer) {
433
- clearTimeout(this.resizeTimer)
434
- this.resizeTimer = null
435
- }
436
- // Clear all input-waiting timers
437
- for (const timer of this.inputWaitTimers.values()) {
438
- clearTimeout(timer)
439
- }
440
- this.inputWaitTimers.clear()
441
- await this.manager.stopAll()
442
- for (const pane of this.panes.values()) {
443
- pane.destroy()
444
- }
445
- if (!this.renderer.isDestroyed) {
446
- this.renderer.destroy()
447
- }
448
- }
449
-
450
- /** Check if any process ended in a failed state */
451
- hasFailures(): boolean {
452
- return this.manager.getAllStates().some(s => s.status === 'failed')
453
- }
454
- }
package/src/ui/pane.ts DELETED
@@ -1,125 +0,0 @@
1
- import { type CliRenderer, ScrollBoxRenderable } from '@opentui/core'
2
- import { GhosttyTerminalRenderable, type HighlightRegion } from 'ghostty-opentui/terminal-buffer'
3
-
4
- export interface SearchMatch {
5
- line: number
6
- start: number
7
- end: number
8
- }
9
-
10
- export class Pane {
11
- readonly scrollBox: ScrollBoxRenderable
12
- readonly terminal: GhosttyTerminalRenderable
13
- private decoder = new TextDecoder()
14
-
15
- private _onScroll: (() => void) | null = null
16
-
17
- constructor(renderer: CliRenderer, name: string, cols: number, rows: number, interactive = false) {
18
- this.scrollBox = new ScrollBoxRenderable(renderer, {
19
- id: `pane-${name}`,
20
- flexGrow: 1,
21
- width: '100%',
22
- stickyScroll: true,
23
- stickyStart: 'bottom',
24
- visible: false,
25
- onMouseScroll: () => this._onScroll?.()
26
- })
27
-
28
- this.terminal = new GhosttyTerminalRenderable(renderer, {
29
- id: `term-${name}`,
30
- cols,
31
- rows,
32
- persistent: true,
33
- showCursor: interactive,
34
- trimEnd: true
35
- })
36
-
37
- this.scrollBox.add(this.terminal)
38
- }
39
-
40
- feed(data: Uint8Array): void {
41
- const text = this.decoder.decode(data, { stream: true })
42
- this.terminal.feed(text)
43
- }
44
-
45
- resize(cols: number, rows: number): void {
46
- this.terminal.cols = cols
47
- this.terminal.rows = rows
48
- }
49
-
50
- get isAtBottom(): boolean {
51
- const { scrollTop, scrollHeight, viewport } = this.scrollBox
52
- if (scrollHeight <= 0) return true
53
- return scrollTop >= scrollHeight - viewport.height - 2
54
- }
55
-
56
- scrollBy(delta: number): void {
57
- this.scrollBox.scrollBy(delta)
58
- }
59
-
60
- scrollToTop(): void {
61
- this.scrollBox.scrollTo(0)
62
- }
63
-
64
- scrollToBottom(): void {
65
- this.scrollBox.scrollTo(this.scrollBox.scrollHeight)
66
- }
67
-
68
- onScroll(handler: () => void): void {
69
- this._onScroll = handler
70
- }
71
-
72
- show(): void {
73
- this.scrollBox.visible = true
74
- }
75
-
76
- hide(): void {
77
- this.scrollBox.visible = false
78
- }
79
-
80
- search(query: string): SearchMatch[] {
81
- if (!query) return []
82
- const text = this.terminal.getText()
83
- const lines = text.split('\n')
84
- const matches: SearchMatch[] = []
85
- const lowerQuery = query.toLowerCase()
86
- for (let line = 0; line < lines.length; line++) {
87
- const lowerLine = lines[line].toLowerCase()
88
- let pos = 0
89
- while (true) {
90
- const idx = lowerLine.indexOf(lowerQuery, pos)
91
- if (idx === -1) break
92
- matches.push({ line, start: idx, end: idx + query.length })
93
- pos = idx + 1
94
- }
95
- }
96
- return matches
97
- }
98
-
99
- setHighlights(matches: SearchMatch[], currentIndex: number): void {
100
- const regions: HighlightRegion[] = matches.map((m, i) => ({
101
- line: m.line,
102
- start: m.start,
103
- end: m.end,
104
- backgroundColor: i === currentIndex ? '#b58900' : '#073642'
105
- }))
106
- this.terminal.highlights = regions
107
- }
108
-
109
- clearHighlights(): void {
110
- this.terminal.highlights = undefined
111
- }
112
-
113
- scrollToLine(line: number): void {
114
- const pos = this.terminal.getScrollPositionForLine(line)
115
- this.scrollBox.scrollTo(pos)
116
- }
117
-
118
- clear(): void {
119
- this.terminal.reset()
120
- }
121
-
122
- destroy(): void {
123
- this.terminal.destroy()
124
- }
125
- }