numux 1.5.0 → 1.5.2

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,442 +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 { log } from '../utils/logger'
6
- import { Pane, type SearchMatch } from './pane'
7
- import { StatusBar } from './status-bar'
8
- import { TabBar } from './tabs'
9
-
10
- export class App {
11
- private renderer!: CliRenderer
12
- private manager: ProcessManager
13
- private panes = new Map<string, Pane>()
14
- private tabBar!: TabBar
15
- private statusBar!: StatusBar
16
- private activePane: string | null = null
17
- private destroyed = false
18
- private names: string[]
19
- private termCols = 80
20
- private termRows = 24
21
- private sidebarWidth = 20
22
-
23
- private config: ResolvedNumuxConfig
24
-
25
- private resizeTimer: ReturnType<typeof setTimeout> | null = null
26
-
27
- // Search state
28
- private searchMode = false
29
- private searchQuery = ''
30
- private searchMatches: SearchMatch[] = []
31
- private searchIndex = -1
32
-
33
- // Input-waiting detection for interactive processes
34
- private inputWaitTimers = new Map<string, ReturnType<typeof setTimeout>>()
35
- private awaitingInput = new Set<string>()
36
-
37
- constructor(manager: ProcessManager, config: ResolvedNumuxConfig) {
38
- this.manager = manager
39
- this.config = config
40
- this.names = manager.getProcessNames()
41
- }
42
-
43
- async start(): Promise<void> {
44
- this.renderer = await createCliRenderer({
45
- exitOnCtrlC: false,
46
- useMouse: true
47
- })
48
-
49
- const { width, height } = this.renderer
50
- const maxNameLen = Math.max(...this.names.map(n => n.length))
51
- this.sidebarWidth = Math.min(30, Math.max(16, maxNameLen + 5))
52
- this.termCols = Math.max(40, width - this.sidebarWidth - 2)
53
- this.termRows = Math.max(5, height - 2)
54
- const { termCols, termRows } = this
55
-
56
- // Layout root
57
- const layout = new BoxRenderable(this.renderer, {
58
- id: 'root',
59
- flexDirection: 'column',
60
- width: '100%',
61
- height: '100%',
62
- border: false
63
- })
64
-
65
- // Tab bar (vertical sidebar)
66
- const processHexColors = buildProcessHexColorMap(this.names, this.config)
67
- this.tabBar = new TabBar(this.renderer, this.names, processHexColors)
68
-
69
- // Content row: sidebar | pane
70
- const contentRow = new BoxRenderable(this.renderer, {
71
- id: 'content-row',
72
- flexDirection: 'row',
73
- flexGrow: 1,
74
- width: '100%',
75
- border: false
76
- })
77
-
78
- const sidebar = new BoxRenderable(this.renderer, {
79
- id: 'sidebar',
80
- width: this.sidebarWidth,
81
- height: '100%',
82
- border: ['right'],
83
- borderColor: '#444'
84
- })
85
- sidebar.add(this.tabBar.renderable)
86
-
87
- // Pane container
88
- const paneContainer = new BoxRenderable(this.renderer, {
89
- id: 'pane-container',
90
- flexGrow: 1,
91
- border: false
92
- })
93
-
94
- // Create a pane per process
95
- for (const name of this.names) {
96
- const interactive = this.config.processes[name].interactive === true
97
- const pane = new Pane(this.renderer, name, termCols, termRows, interactive)
98
- this.panes.set(name, pane)
99
- paneContainer.add(pane.scrollBox)
100
- }
101
-
102
- // Status bar (only visible during search)
103
- this.statusBar = new StatusBar(this.renderer)
104
-
105
- // Assemble layout
106
- contentRow.add(sidebar)
107
- contentRow.add(paneContainer)
108
- layout.add(contentRow)
109
- layout.add(this.statusBar.renderable)
110
- this.renderer.root.add(layout)
111
-
112
- // Wire tab events (mouse clicks)
113
- this.tabBar.onSelect((_index, name) => this.switchPane(name))
114
- this.tabBar.onSelectionChanged((_index, name) => this.switchPane(name))
115
-
116
- // Wire process events
117
- this.manager.on(event => {
118
- if (this.destroyed) return
119
- if (event.type === 'output') {
120
- this.panes.get(event.name)?.feed(event.data)
121
- // Detect input-waiting for interactive processes
122
- if (this.config.processes[event.name]?.interactive) {
123
- this.checkInputWaiting(event.name, event.data)
124
- }
125
- } else if (event.type === 'status') {
126
- const state = this.manager.getState(event.name)
127
- this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount)
128
- // Clear input-waiting on non-active statuses
129
- if (event.status !== 'running' && event.status !== 'ready') {
130
- this.clearInputWaiting(event.name)
131
- }
132
- }
133
- })
134
-
135
- // Handle resize (debounced to avoid excessive PTY resize calls)
136
- this.renderer.on('resize', (w: number, h: number) => {
137
- this.termCols = Math.max(40, w - this.sidebarWidth - 2)
138
- this.termRows = Math.max(5, h - 2)
139
- if (this.resizeTimer) clearTimeout(this.resizeTimer)
140
- this.resizeTimer = setTimeout(() => {
141
- this.resizeTimer = null
142
- for (const pane of this.panes.values()) {
143
- pane.resize(this.termCols, this.termRows)
144
- }
145
- this.manager.resizeAll(this.termCols, this.termRows)
146
- }, 50)
147
- })
148
-
149
- // Global keyboard handler
150
- this.renderer.keyInput.on(
151
- 'keypress',
152
- (key: { ctrl: boolean; shift: boolean; meta: boolean; name: string; sequence: string }) => {
153
- log(key)
154
-
155
- // Ctrl+C: quit (always works)
156
- if (key.ctrl && key.name === 'c') {
157
- if (this.searchMode) {
158
- this.exitSearch()
159
- return
160
- }
161
- this.shutdown().then(() => {
162
- process.exit(this.hasFailures() ? 1 : 0)
163
- })
164
- return
165
- }
166
-
167
- // Search mode input handling
168
- if (this.searchMode) {
169
- this.handleSearchInput(key)
170
- return
171
- }
172
-
173
- if (!this.activePane) return
174
-
175
- const isInteractive = this.config.processes[this.activePane]?.interactive === true
176
-
177
- // Non-interactive panes: plain keys act as shortcuts
178
- if (!isInteractive) {
179
- const name = key.name.toLowerCase()
180
-
181
- // Shift+R: restart all processes
182
- if (key.shift && name === 'r') {
183
- this.manager.restartAll(this.termCols, this.termRows)
184
- return
185
- }
186
-
187
- // F: enter search mode
188
- if (name === 'f') {
189
- this.enterSearch()
190
- return
191
- }
192
-
193
- // R: restart active process
194
- if (name === 'r') {
195
- this.manager.restart(this.activePane, this.termCols, this.termRows)
196
- return
197
- }
198
-
199
- // S: stop/start active process
200
- if (name === 's') {
201
- const state = this.manager.getState(this.activePane)
202
- if (state?.status === 'stopped' || state?.status === 'finished' || state?.status === 'failed') {
203
- this.manager.start(this.activePane, this.termCols, this.termRows)
204
- } else {
205
- this.manager.stop(this.activePane)
206
- }
207
- return
208
- }
209
-
210
- // L: clear active pane
211
- if (name === 'l') {
212
- this.panes.get(this.activePane)?.clear()
213
- return
214
- }
215
-
216
- // 1-9: jump to tab (uses display order from tab bar)
217
- const num = Number.parseInt(name, 10)
218
- if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
219
- this.tabBar.setSelectedIndex(num - 1)
220
- this.switchPane(this.tabBar.getNameAtIndex(num - 1))
221
- return
222
- }
223
-
224
- // Left/Right: cycle tabs
225
- if (name === 'left' || name === 'right') {
226
- const current = this.tabBar.getSelectedIndex()
227
- const count = this.tabBar.count
228
- const next = name === 'right' ? (current + 1) % count : (current - 1 + count) % count
229
- this.tabBar.setSelectedIndex(next)
230
- this.switchPane(this.tabBar.getNameAtIndex(next))
231
- return
232
- }
233
-
234
- // PageUp/PageDown: scroll by page
235
- if (name === 'pageup' || name === 'pagedown') {
236
- const pane = this.panes.get(this.activePane)
237
- const delta = this.termRows - 2
238
- pane?.scrollBy(name === 'pageup' ? -delta : delta)
239
- return
240
- }
241
-
242
- // Home/End: scroll to top/bottom
243
- if (name === 'home') {
244
- this.panes.get(this.activePane)?.scrollToTop()
245
- return
246
- }
247
- if (name === 'end') {
248
- this.panes.get(this.activePane)?.scrollToBottom()
249
- return
250
- }
251
- return
252
- }
253
-
254
- // Forward all other input to the active process (interactive mode)
255
- if (key.sequence) {
256
- this.manager.write(this.activePane, key.sequence)
257
- }
258
- }
259
- )
260
-
261
- // Show first pane and focus sidebar for keyboard navigation
262
- if (this.names.length > 0) {
263
- this.switchPane(this.names[0])
264
- this.tabBar.focus()
265
- }
266
-
267
- // Start all processes
268
- await this.manager.startAll(termCols, termRows)
269
- }
270
-
271
- private switchPane(name: string): void {
272
- if (this.activePane === name) return
273
- // Clear search when switching panes
274
- if (this.searchMode) {
275
- this.exitSearch()
276
- }
277
- if (this.activePane) {
278
- this.panes.get(this.activePane)?.hide()
279
- }
280
- this.activePane = name
281
- this.panes.get(name)?.show()
282
- }
283
-
284
- /** Detect when an interactive process is likely waiting for user input */
285
- private checkInputWaiting(name: string, data: Uint8Array): void {
286
- // Clear existing timer
287
- const existing = this.inputWaitTimers.get(name)
288
- if (existing) clearTimeout(existing)
289
-
290
- // If we were showing awaiting input, clear it since new output arrived
291
- if (this.awaitingInput.has(name)) {
292
- this.awaitingInput.delete(name)
293
- this.tabBar.setInputWaiting(name, false)
294
- }
295
-
296
- // If the last byte is not a newline, the process may be showing a prompt
297
- const lastByte = data[data.length - 1]
298
- if (lastByte !== 0x0a && lastByte !== 0x0d) {
299
- const timer = setTimeout(() => {
300
- this.inputWaitTimers.delete(name)
301
- const state = this.manager.getState(name)
302
- if (state && (state.status === 'running' || state.status === 'ready')) {
303
- this.awaitingInput.add(name)
304
- this.tabBar.setInputWaiting(name, true)
305
- }
306
- }, 200)
307
- this.inputWaitTimers.set(name, timer)
308
- }
309
- }
310
-
311
- private clearInputWaiting(name: string): void {
312
- const timer = this.inputWaitTimers.get(name)
313
- if (timer) {
314
- clearTimeout(timer)
315
- this.inputWaitTimers.delete(name)
316
- }
317
- if (this.awaitingInput.has(name)) {
318
- this.awaitingInput.delete(name)
319
- this.tabBar.setInputWaiting(name, false)
320
- }
321
- }
322
-
323
- private enterSearch(): void {
324
- this.searchMode = true
325
- this.searchQuery = ''
326
- this.searchMatches = []
327
- this.searchIndex = -1
328
- this.statusBar.setSearchMode(true)
329
- }
330
-
331
- private exitSearch(): void {
332
- this.searchMode = false
333
- this.searchQuery = ''
334
- this.searchMatches = []
335
- this.searchIndex = -1
336
- if (this.activePane) {
337
- this.panes.get(this.activePane)?.clearHighlights()
338
- }
339
- this.statusBar.setSearchMode(false)
340
- }
341
-
342
- private handleSearchInput(key: {
343
- ctrl: boolean
344
- shift: boolean
345
- meta: boolean
346
- name: string
347
- sequence: string
348
- }): void {
349
- if (key.name === 'escape') {
350
- this.exitSearch()
351
- return
352
- }
353
-
354
- if (key.name === 'return') {
355
- // Enter: next match, Shift+Enter: previous match
356
- if (this.searchMatches.length === 0) return
357
- if (key.shift) {
358
- this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length
359
- } else {
360
- this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length
361
- }
362
- this.scrollToCurrentMatch()
363
- this.updateSearchHighlights()
364
- return
365
- }
366
-
367
- if (key.name === 'backspace') {
368
- if (this.searchQuery.length > 0) {
369
- this.searchQuery = this.searchQuery.slice(0, -1)
370
- this.runSearch()
371
- }
372
- return
373
- }
374
-
375
- // Printable character
376
- if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
377
- this.searchQuery += key.sequence
378
- this.runSearch()
379
- }
380
- }
381
-
382
- private runSearch(): void {
383
- if (!this.activePane) return
384
- const pane = this.panes.get(this.activePane)
385
- if (!pane) return
386
-
387
- this.searchMatches = pane.search(this.searchQuery)
388
- this.searchIndex = this.searchMatches.length > 0 ? 0 : -1
389
-
390
- this.updateSearchHighlights()
391
- if (this.searchIndex >= 0) {
392
- this.scrollToCurrentMatch()
393
- }
394
- }
395
-
396
- private updateSearchHighlights(): void {
397
- if (!this.activePane) return
398
- const pane = this.panes.get(this.activePane)
399
- if (!pane) return
400
-
401
- if (this.searchMatches.length > 0) {
402
- pane.setHighlights(this.searchMatches, this.searchIndex)
403
- } else {
404
- pane.clearHighlights()
405
- }
406
- this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex)
407
- }
408
-
409
- private scrollToCurrentMatch(): void {
410
- if (!this.activePane || this.searchIndex < 0) return
411
- const pane = this.panes.get(this.activePane)
412
- if (!pane) return
413
- const match = this.searchMatches[this.searchIndex]
414
- pane.scrollToLine(match.line)
415
- }
416
-
417
- async shutdown(): Promise<void> {
418
- if (this.destroyed) return
419
- this.destroyed = true
420
- if (this.resizeTimer) {
421
- clearTimeout(this.resizeTimer)
422
- this.resizeTimer = null
423
- }
424
- // Clear all input-waiting timers
425
- for (const timer of this.inputWaitTimers.values()) {
426
- clearTimeout(timer)
427
- }
428
- this.inputWaitTimers.clear()
429
- await this.manager.stopAll()
430
- for (const pane of this.panes.values()) {
431
- pane.destroy()
432
- }
433
- if (!this.renderer.isDestroyed) {
434
- this.renderer.destroy()
435
- }
436
- }
437
-
438
- /** Check if any process ended in a failed state */
439
- hasFailures(): boolean {
440
- return this.manager.getAllStates().some(s => s.status === 'failed')
441
- }
442
- }
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
- }