numux 1.0.0 → 1.2.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.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
 
@@ -34,7 +33,6 @@ export class App {
34
33
  this.manager = manager
35
34
  this.config = config
36
35
  this.names = manager.getProcessNames()
37
- this.processHexColors = buildProcessHexColorMap(this.names, config)
38
36
  }
39
37
 
40
38
  async start(): Promise<void> {
@@ -60,7 +58,8 @@ export class App {
60
58
  })
61
59
 
62
60
  // Tab bar (vertical sidebar)
63
- this.tabBar = new TabBar(this.renderer, this.names)
61
+ const processHexColors = buildProcessHexColorMap(this.names, this.config)
62
+ this.tabBar = new TabBar(this.renderer, this.names, processHexColors)
64
63
 
65
64
  // Content row: sidebar | pane
66
65
  const contentRow = new BoxRenderable(this.renderer, {
@@ -91,15 +90,12 @@ export class App {
91
90
  for (const name of this.names) {
92
91
  const interactive = this.config.processes[name].interactive === true
93
92
  const pane = new Pane(this.renderer, name, termCols, termRows, interactive)
94
- pane.onScroll(() => {
95
- if (name === this.activePane) this.updateScrollIndicator()
96
- })
97
93
  this.panes.set(name, pane)
98
94
  paneContainer.add(pane.scrollBox)
99
95
  }
100
96
 
101
- // Status bar
102
- this.statusBar = new StatusBar(this.renderer, this.names, this.processHexColors)
97
+ // Status bar (only visible during search)
98
+ this.statusBar = new StatusBar(this.renderer)
103
99
 
104
100
  // Assemble layout
105
101
  contentRow.add(sidebar)
@@ -117,13 +113,9 @@ export class App {
117
113
  if (this.destroyed) return
118
114
  if (event.type === 'output') {
119
115
  this.panes.get(event.name)?.feed(event.data)
120
- if (event.name === this.activePane) {
121
- this.updateScrollIndicator()
122
- }
123
116
  } else if (event.type === 'status') {
124
117
  const state = this.manager.getState(event.name)
125
118
  this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount)
126
- this.statusBar.updateStatus(event.name, event.status)
127
119
  }
128
120
  })
129
121
 
@@ -227,19 +219,16 @@ export class App {
227
219
  const pane = this.panes.get(this.activePane)
228
220
  const delta = this.termRows - 2
229
221
  pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
230
- this.updateScrollIndicator()
231
222
  return
232
223
  }
233
224
 
234
225
  // Alt+Home/End: scroll to top/bottom
235
226
  if (this.activePane && key.name === 'home') {
236
227
  this.panes.get(this.activePane)?.scrollToTop()
237
- this.updateScrollIndicator()
238
228
  return
239
229
  }
240
230
  if (this.activePane && key.name === 'end') {
241
231
  this.panes.get(this.activePane)?.scrollToBottom()
242
- this.updateScrollIndicator()
243
232
  return
244
233
  }
245
234
  }
@@ -253,18 +242,14 @@ export class App {
253
242
  if (key.name === 'up' || key.name === 'down') {
254
243
  const pane = this.panes.get(this.activePane)
255
244
  pane?.scrollBy(key.name === 'up' ? -1 : 1)
256
- this.updateScrollIndicator()
257
245
  } else if (key.name === 'pageup' || key.name === 'pagedown') {
258
246
  const pane = this.panes.get(this.activePane)
259
247
  const delta = this.termRows - 2
260
248
  pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
261
- this.updateScrollIndicator()
262
249
  } else if (key.name === 'home') {
263
250
  this.panes.get(this.activePane)?.scrollToTop()
264
- this.updateScrollIndicator()
265
251
  } else if (key.name === 'end') {
266
252
  this.panes.get(this.activePane)?.scrollToBottom()
267
- this.updateScrollIndicator()
268
253
  }
269
254
  return
270
255
  }
@@ -296,14 +281,6 @@ export class App {
296
281
  }
297
282
  this.activePane = name
298
283
  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
284
  }
308
285
 
309
286
  private enterSearch(): void {
@@ -398,7 +375,6 @@ export class App {
398
375
  if (!pane) return
399
376
  const match = this.searchMatches[this.searchIndex]
400
377
  pane.scrollToLine(match.line)
401
- this.updateScrollIndicator()
402
378
  }
403
379
 
404
380
  async shutdown(): Promise<void> {
@@ -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
@@ -1,4 +1,11 @@
1
- import { type CliRenderer, SelectRenderable, SelectRenderableEvents } from '@opentui/core'
1
+ import {
2
+ type CliRenderer,
3
+ type OptimizedBuffer,
4
+ parseColor,
5
+ type RGBA,
6
+ SelectRenderable,
7
+ SelectRenderableEvents
8
+ } from '@opentui/core'
2
9
  import type { ProcessStatus } from '../types'
3
10
 
4
11
  const STATUS_ICONS: Record<ProcessStatus, string> = {
@@ -12,18 +19,82 @@ const STATUS_ICONS: Record<ProcessStatus, string> = {
12
19
  skipped: '⊘'
13
20
  }
14
21
 
22
+ /** Status-specific icon colors (override process colors) */
23
+ const STATUS_ICON_HEX: Partial<Record<ProcessStatus, string>> = {
24
+ ready: '#00cc00',
25
+ failed: '#ff5555',
26
+ stopped: '#888888',
27
+ skipped: '#888888'
28
+ }
29
+
30
+ interface OptionColors {
31
+ icon: RGBA | null
32
+ name: RGBA | null
33
+ }
34
+
35
+ /**
36
+ * SelectRenderable subclass that supports per-option coloring.
37
+ * The base SelectRenderable draws all option text with a single color.
38
+ * This overrides renderSelf to repaint the icon and name with individual
39
+ * RGBA colors after the base render.
40
+ */
41
+ class ColoredSelectRenderable extends SelectRenderable {
42
+ private _optionColors: OptionColors[] = []
43
+
44
+ setOptionColors(colors: OptionColors[]): void {
45
+ this._optionColors = colors
46
+ this.requestRender()
47
+ }
48
+
49
+ protected renderSelf(buffer: OptimizedBuffer, deltaTime: number): void {
50
+ const wasDirty = this.isDirty
51
+ super.renderSelf(buffer, deltaTime)
52
+ if (wasDirty && this.frameBuffer && this._optionColors.length > 0) {
53
+ this.colorizeOptions()
54
+ }
55
+ }
56
+
57
+ private colorizeOptions(): void {
58
+ const fb = this.frameBuffer!
59
+ // Access internal layout state (private in TS, accessible at runtime)
60
+ const scrollOffset = (this as any).scrollOffset as number
61
+ const maxVisibleItems = (this as any).maxVisibleItems as number
62
+ const linesPerItem = (this as any).linesPerItem as number
63
+ const options = this.options
64
+ const visibleCount = Math.min(maxVisibleItems, options.length - scrollOffset)
65
+
66
+ for (let i = 0; i < visibleCount; i++) {
67
+ const actualIndex = scrollOffset + i
68
+ const colors = this._optionColors[actualIndex]
69
+ if (!colors) continue
70
+ const itemY = i * linesPerItem
71
+ // Layout: "▶ ○ name" or " ○ name" (drawText at x=1, prefix 2 chars)
72
+ // Icon at x=3, space at x=4, name starts at x=5
73
+ const optName = options[actualIndex].name
74
+ if (colors.icon) {
75
+ fb.drawText(optName.charAt(0), 3, itemY, colors.icon)
76
+ }
77
+ if (colors.name) {
78
+ fb.drawText(optName.slice(2), 5, itemY, colors.name)
79
+ }
80
+ }
81
+ }
82
+ }
83
+
15
84
  export class TabBar {
16
- readonly renderable: SelectRenderable
85
+ readonly renderable: ColoredSelectRenderable
17
86
  private names: string[]
18
87
  private statuses: Map<string, ProcessStatus>
19
88
  private descriptions: Map<string, string>
89
+ private processColors: Map<string, string>
20
90
 
21
- constructor(renderer: CliRenderer, names: string[]) {
91
+ constructor(renderer: CliRenderer, names: string[], colors?: Map<string, string>) {
22
92
  this.names = names
23
93
  this.statuses = new Map(names.map(n => [n, 'pending' as ProcessStatus]))
24
94
  this.descriptions = new Map(names.map(n => [n, 'pending']))
95
+ this.processColors = colors ?? new Map()
25
96
 
26
- this.renderable = new SelectRenderable(renderer, {
97
+ this.renderable = new ColoredSelectRenderable(renderer, {
27
98
  id: 'tab-bar',
28
99
  width: '100%',
29
100
  height: '100%',
@@ -37,6 +108,7 @@ export class TabBar {
37
108
  showDescription: true,
38
109
  wrapSelection: true
39
110
  })
111
+ this.updateOptionColors()
40
112
  }
41
113
 
42
114
  onSelect(handler: (index: number, name: string) => void): void {
@@ -58,6 +130,20 @@ export class TabBar {
58
130
  name: this.formatTab(n, this.statuses.get(n)!),
59
131
  description: this.descriptions.get(n)!
60
132
  }))
133
+ this.updateOptionColors()
134
+ }
135
+
136
+ private updateOptionColors(): void {
137
+ const colors = this.names.map(name => {
138
+ const status = this.statuses.get(name)!
139
+ const statusHex = STATUS_ICON_HEX[status]
140
+ const processHex = this.processColors.get(name)
141
+ return {
142
+ icon: parseColor(statusHex ?? processHex ?? '#888888'),
143
+ name: processHex ? parseColor(processHex) : null
144
+ }
145
+ })
146
+ this.renderable.setOptionColors(colors)
61
147
  }
62
148
 
63
149
  private formatDescription(status: ProcessStatus, exitCode?: number | null, restartCount?: number): string {