numux 0.0.1 → 1.0.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/src/ui/app.ts ADDED
@@ -0,0 +1,424 @@
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
+ private processHexColors: Map<string, string>
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
+ constructor(manager: ProcessManager, config: ResolvedNumuxConfig) {
34
+ this.manager = manager
35
+ this.config = config
36
+ this.names = manager.getProcessNames()
37
+ this.processHexColors = buildProcessHexColorMap(this.names, config)
38
+ }
39
+
40
+ async start(): Promise<void> {
41
+ this.renderer = await createCliRenderer({
42
+ exitOnCtrlC: false,
43
+ useMouse: true
44
+ })
45
+
46
+ const { width, height } = this.renderer
47
+ const maxNameLen = Math.max(...this.names.map(n => n.length))
48
+ this.sidebarWidth = Math.min(30, Math.max(16, maxNameLen + 5))
49
+ this.termCols = Math.max(40, width - this.sidebarWidth - 2)
50
+ this.termRows = Math.max(5, height - 2)
51
+ const { termCols, termRows } = this
52
+
53
+ // Layout root
54
+ const layout = new BoxRenderable(this.renderer, {
55
+ id: 'root',
56
+ flexDirection: 'column',
57
+ width: '100%',
58
+ height: '100%',
59
+ border: false
60
+ })
61
+
62
+ // Tab bar (vertical sidebar)
63
+ this.tabBar = new TabBar(this.renderer, this.names)
64
+
65
+ // Content row: sidebar | pane
66
+ const contentRow = new BoxRenderable(this.renderer, {
67
+ id: 'content-row',
68
+ flexDirection: 'row',
69
+ flexGrow: 1,
70
+ width: '100%',
71
+ border: false
72
+ })
73
+
74
+ const sidebar = new BoxRenderable(this.renderer, {
75
+ id: 'sidebar',
76
+ width: this.sidebarWidth,
77
+ height: '100%',
78
+ border: ['right'],
79
+ borderColor: '#444'
80
+ })
81
+ sidebar.add(this.tabBar.renderable)
82
+
83
+ // Pane container
84
+ const paneContainer = new BoxRenderable(this.renderer, {
85
+ id: 'pane-container',
86
+ flexGrow: 1,
87
+ border: false
88
+ })
89
+
90
+ // Create a pane per process
91
+ for (const name of this.names) {
92
+ const interactive = this.config.processes[name].interactive === true
93
+ const pane = new Pane(this.renderer, name, termCols, termRows, interactive)
94
+ pane.onScroll(() => {
95
+ if (name === this.activePane) this.updateScrollIndicator()
96
+ })
97
+ this.panes.set(name, pane)
98
+ paneContainer.add(pane.scrollBox)
99
+ }
100
+
101
+ // Status bar
102
+ this.statusBar = new StatusBar(this.renderer, this.names, this.processHexColors)
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
+ if (event.name === this.activePane) {
121
+ this.updateScrollIndicator()
122
+ }
123
+ } else if (event.type === 'status') {
124
+ const state = this.manager.getState(event.name)
125
+ this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount)
126
+ this.statusBar.updateStatus(event.name, event.status)
127
+ }
128
+ })
129
+
130
+ // Handle resize (debounced to avoid excessive PTY resize calls)
131
+ this.renderer.on('resize', (w: number, h: number) => {
132
+ this.termCols = Math.max(40, w - this.sidebarWidth - 2)
133
+ this.termRows = Math.max(5, h - 2)
134
+ if (this.resizeTimer) clearTimeout(this.resizeTimer)
135
+ this.resizeTimer = setTimeout(() => {
136
+ this.resizeTimer = null
137
+ for (const pane of this.panes.values()) {
138
+ pane.resize(this.termCols, this.termRows)
139
+ }
140
+ this.manager.resizeAll(this.termCols, this.termRows)
141
+ }, 50)
142
+ })
143
+
144
+ // Global keyboard handler
145
+ this.renderer.keyInput.on(
146
+ 'keypress',
147
+ (key: { ctrl: boolean; shift: boolean; meta: boolean; name: string; sequence: string }) => {
148
+ // Ctrl+C: quit (always works)
149
+ if (key.ctrl && key.name === 'c') {
150
+ if (this.searchMode) {
151
+ this.exitSearch()
152
+ return
153
+ }
154
+ this.shutdown().then(() => {
155
+ process.exit(this.hasFailures() ? 1 : 0)
156
+ })
157
+ return
158
+ }
159
+
160
+ // Search mode input handling
161
+ if (this.searchMode) {
162
+ this.handleSearchInput(key)
163
+ return
164
+ }
165
+
166
+ // Alt+Shift combos
167
+ if (key.meta && key.shift && !key.ctrl) {
168
+ // Alt+Shift+R: restart all processes
169
+ if (key.name === 'r') {
170
+ this.manager.restartAll(this.termCols, this.termRows)
171
+ return
172
+ }
173
+ }
174
+
175
+ if (key.meta && !key.ctrl && !key.shift) {
176
+ // Alt+F: enter search mode
177
+ if (key.name === 'f' && this.activePane) {
178
+ this.enterSearch()
179
+ return
180
+ }
181
+
182
+ // Alt+R: restart active process
183
+ if (key.name === 'r' && this.activePane) {
184
+ this.manager.restart(this.activePane, this.termCols, this.termRows)
185
+ return
186
+ }
187
+
188
+ // Alt+S: stop/start active process
189
+ if (key.name === 's' && this.activePane) {
190
+ const state = this.manager.getState(this.activePane)
191
+ if (state?.status === 'stopped' || state?.status === 'failed') {
192
+ this.manager.start(this.activePane, this.termCols, this.termRows)
193
+ } else {
194
+ this.manager.stop(this.activePane)
195
+ }
196
+ return
197
+ }
198
+
199
+ // Alt+L: clear active pane
200
+ if (key.name === 'l' && this.activePane) {
201
+ this.panes.get(this.activePane)?.clear()
202
+ return
203
+ }
204
+
205
+ // Alt+1-9: jump to tab
206
+ const num = Number.parseInt(key.name, 10)
207
+ if (num >= 1 && num <= 9 && num <= this.names.length) {
208
+ this.tabBar.setSelectedIndex(num - 1)
209
+ this.switchPane(this.names[num - 1])
210
+ return
211
+ }
212
+
213
+ // Alt+Left/Right: cycle tabs
214
+ if (key.name === 'left' || key.name === 'right') {
215
+ 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
+ this.tabBar.setSelectedIndex(next)
221
+ this.switchPane(this.names[next])
222
+ return
223
+ }
224
+
225
+ // Alt+PageUp/PageDown: scroll output
226
+ if (this.activePane && (key.name === 'pageup' || key.name === 'pagedown')) {
227
+ const pane = this.panes.get(this.activePane)
228
+ const delta = this.termRows - 2
229
+ pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
230
+ this.updateScrollIndicator()
231
+ return
232
+ }
233
+
234
+ // Alt+Home/End: scroll to top/bottom
235
+ if (this.activePane && key.name === 'home') {
236
+ this.panes.get(this.activePane)?.scrollToTop()
237
+ this.updateScrollIndicator()
238
+ return
239
+ }
240
+ if (this.activePane && key.name === 'end') {
241
+ this.panes.get(this.activePane)?.scrollToBottom()
242
+ this.updateScrollIndicator()
243
+ return
244
+ }
245
+ }
246
+
247
+ if (!this.activePane) return
248
+
249
+ const isInteractive = this.config.processes[this.activePane]?.interactive === true
250
+
251
+ // Non-interactive panes: arrow keys scroll, all other input is dropped
252
+ if (!isInteractive) {
253
+ if (key.name === 'up' || key.name === 'down') {
254
+ const pane = this.panes.get(this.activePane)
255
+ pane?.scrollBy(key.name === 'up' ? -1 : 1)
256
+ this.updateScrollIndicator()
257
+ } else if (key.name === 'pageup' || key.name === 'pagedown') {
258
+ const pane = this.panes.get(this.activePane)
259
+ const delta = this.termRows - 2
260
+ pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
261
+ this.updateScrollIndicator()
262
+ } else if (key.name === 'home') {
263
+ this.panes.get(this.activePane)?.scrollToTop()
264
+ this.updateScrollIndicator()
265
+ } else if (key.name === 'end') {
266
+ this.panes.get(this.activePane)?.scrollToBottom()
267
+ this.updateScrollIndicator()
268
+ }
269
+ return
270
+ }
271
+
272
+ // Forward all other input to the active process
273
+ if (key.sequence) {
274
+ this.manager.write(this.activePane, key.sequence)
275
+ }
276
+ }
277
+ )
278
+
279
+ // Show first pane
280
+ if (this.names.length > 0) {
281
+ this.switchPane(this.names[0])
282
+ }
283
+
284
+ // Start all processes
285
+ await this.manager.startAll(termCols, termRows)
286
+ }
287
+
288
+ private switchPane(name: string): void {
289
+ if (this.activePane === name) return
290
+ // Clear search when switching panes
291
+ if (this.searchMode) {
292
+ this.exitSearch()
293
+ }
294
+ if (this.activePane) {
295
+ this.panes.get(this.activePane)?.hide()
296
+ }
297
+ this.activePane = name
298
+ this.panes.get(name)?.show()
299
+ this.updateScrollIndicator()
300
+ }
301
+
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)
307
+ }
308
+
309
+ private enterSearch(): void {
310
+ this.searchMode = true
311
+ this.searchQuery = ''
312
+ this.searchMatches = []
313
+ this.searchIndex = -1
314
+ this.statusBar.setSearchMode(true)
315
+ }
316
+
317
+ private exitSearch(): void {
318
+ this.searchMode = false
319
+ this.searchQuery = ''
320
+ this.searchMatches = []
321
+ this.searchIndex = -1
322
+ if (this.activePane) {
323
+ this.panes.get(this.activePane)?.clearHighlights()
324
+ }
325
+ this.statusBar.setSearchMode(false)
326
+ }
327
+
328
+ private handleSearchInput(key: {
329
+ ctrl: boolean
330
+ shift: boolean
331
+ meta: boolean
332
+ name: string
333
+ sequence: string
334
+ }): void {
335
+ if (key.name === 'escape') {
336
+ this.exitSearch()
337
+ return
338
+ }
339
+
340
+ if (key.name === 'return') {
341
+ // Enter: next match, Shift+Enter: previous match
342
+ if (this.searchMatches.length === 0) return
343
+ if (key.shift) {
344
+ this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length
345
+ } else {
346
+ this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length
347
+ }
348
+ this.scrollToCurrentMatch()
349
+ this.updateSearchHighlights()
350
+ return
351
+ }
352
+
353
+ if (key.name === 'backspace') {
354
+ if (this.searchQuery.length > 0) {
355
+ this.searchQuery = this.searchQuery.slice(0, -1)
356
+ this.runSearch()
357
+ }
358
+ return
359
+ }
360
+
361
+ // Printable character
362
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
363
+ this.searchQuery += key.sequence
364
+ this.runSearch()
365
+ }
366
+ }
367
+
368
+ private runSearch(): void {
369
+ if (!this.activePane) return
370
+ const pane = this.panes.get(this.activePane)
371
+ if (!pane) return
372
+
373
+ this.searchMatches = pane.search(this.searchQuery)
374
+ this.searchIndex = this.searchMatches.length > 0 ? 0 : -1
375
+
376
+ this.updateSearchHighlights()
377
+ if (this.searchIndex >= 0) {
378
+ this.scrollToCurrentMatch()
379
+ }
380
+ }
381
+
382
+ private updateSearchHighlights(): void {
383
+ if (!this.activePane) return
384
+ const pane = this.panes.get(this.activePane)
385
+ if (!pane) return
386
+
387
+ if (this.searchMatches.length > 0) {
388
+ pane.setHighlights(this.searchMatches, this.searchIndex)
389
+ } else {
390
+ pane.clearHighlights()
391
+ }
392
+ this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex)
393
+ }
394
+
395
+ private scrollToCurrentMatch(): void {
396
+ if (!this.activePane || this.searchIndex < 0) return
397
+ const pane = this.panes.get(this.activePane)
398
+ if (!pane) return
399
+ const match = this.searchMatches[this.searchIndex]
400
+ pane.scrollToLine(match.line)
401
+ this.updateScrollIndicator()
402
+ }
403
+
404
+ async shutdown(): Promise<void> {
405
+ if (this.destroyed) return
406
+ this.destroyed = true
407
+ if (this.resizeTimer) {
408
+ clearTimeout(this.resizeTimer)
409
+ this.resizeTimer = null
410
+ }
411
+ await this.manager.stopAll()
412
+ for (const pane of this.panes.values()) {
413
+ pane.destroy()
414
+ }
415
+ if (!this.renderer.isDestroyed) {
416
+ this.renderer.destroy()
417
+ }
418
+ }
419
+
420
+ /** Check if any process ended in a failed state */
421
+ hasFailures(): boolean {
422
+ return this.manager.getAllStates().some(s => s.status === 'failed')
423
+ }
424
+ }
package/src/ui/pane.ts ADDED
@@ -0,0 +1,125 @@
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
+ }