numux 1.4.0 → 1.5.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/README.md CHANGED
@@ -264,7 +264,7 @@ Persistent processes that crash are auto-restarted with exponential backoff (1s
264
264
  | `Alt+L` | Clear active pane output |
265
265
  | `Alt+1`–`Alt+9` | Jump to tab |
266
266
  | `Alt+Left/Right` | Cycle tabs |
267
- | `Up/Down` | Scroll output 1 line (non-interactive panes) |
267
+ | `Up/Down` | Navigate between tabs |
268
268
  | `PageUp/PageDown` | Scroll output by page (non-interactive panes) |
269
269
  | `Home/End` | Scroll to top/bottom (non-interactive panes) |
270
270
  | `Alt+PageUp/PageDown` | Scroll output up/down |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  ".": "./src/config.ts"
28
28
  },
29
29
  "scripts": {
30
- "dev": "bun run src/index.ts",
30
+ "dev": "cd example && bun run dev --debug",
31
31
  "test": "bun test",
32
32
  "typecheck": "bunx tsc --noEmit",
33
33
  "lint": "biome check .",
package/src/ui/app.ts CHANGED
@@ -2,6 +2,7 @@ import { BoxRenderable, type CliRenderer, createCliRenderer } from '@opentui/cor
2
2
  import type { ProcessManager } from '../process/manager'
3
3
  import type { ResolvedNumuxConfig } from '../types'
4
4
  import { buildProcessHexColorMap } from '../utils/color'
5
+ import { log } from '../utils/logger'
5
6
  import { Pane, type SearchMatch } from './pane'
6
7
  import { StatusBar } from './status-bar'
7
8
  import { TabBar } from './tabs'
@@ -149,6 +150,8 @@ export class App {
149
150
  this.renderer.keyInput.on(
150
151
  'keypress',
151
152
  (key: { ctrl: boolean; shift: boolean; meta: boolean; name: string; sequence: string }) => {
153
+ log(key)
154
+
152
155
  // Ctrl+C: quit (always works)
153
156
  if (key.ctrl && key.name === 'c') {
154
157
  if (this.searchMode) {
@@ -167,30 +170,34 @@ export class App {
167
170
  return
168
171
  }
169
172
 
170
- // Alt+Shift combos
171
- if (key.meta && key.shift && !key.ctrl) {
172
- // Alt+Shift+R: restart all processes
173
- if (key.name === 'r') {
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') {
174
183
  this.manager.restartAll(this.termCols, this.termRows)
175
184
  return
176
185
  }
177
- }
178
186
 
179
- if (key.meta && !key.ctrl && !key.shift) {
180
- // Alt+F: enter search mode
181
- if (key.name === 'f' && this.activePane) {
187
+ // F: enter search mode
188
+ if (name === 'f') {
182
189
  this.enterSearch()
183
190
  return
184
191
  }
185
192
 
186
- // Alt+R: restart active process
187
- if (key.name === 'r' && this.activePane) {
193
+ // R: restart active process
194
+ if (name === 'r') {
188
195
  this.manager.restart(this.activePane, this.termCols, this.termRows)
189
196
  return
190
197
  }
191
198
 
192
- // Alt+S: stop/start active process
193
- if (key.name === 's' && this.activePane) {
199
+ // S: stop/start active process
200
+ if (name === 's') {
194
201
  const state = this.manager.getState(this.activePane)
195
202
  if (state?.status === 'stopped' || state?.status === 'finished' || state?.status === 'failed') {
196
203
  this.manager.start(this.activePane, this.termCols, this.termRows)
@@ -200,80 +207,61 @@ export class App {
200
207
  return
201
208
  }
202
209
 
203
- // Alt+L: clear active pane
204
- if (key.name === 'l' && this.activePane) {
210
+ // L: clear active pane
211
+ if (name === 'l') {
205
212
  this.panes.get(this.activePane)?.clear()
206
213
  return
207
214
  }
208
215
 
209
- // Alt+1-9: jump to tab (uses display order from tab bar)
210
- const num = Number.parseInt(key.name, 10)
216
+ // 1-9: jump to tab (uses display order from tab bar)
217
+ const num = Number.parseInt(name, 10)
211
218
  if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
212
219
  this.tabBar.setSelectedIndex(num - 1)
213
220
  this.switchPane(this.tabBar.getNameAtIndex(num - 1))
214
221
  return
215
222
  }
216
223
 
217
- // Alt+Left/Right: cycle tabs
218
- if (key.name === 'left' || key.name === 'right') {
224
+ // Left/Right: cycle tabs
225
+ if (name === 'left' || name === 'right') {
219
226
  const current = this.tabBar.getSelectedIndex()
220
227
  const count = this.tabBar.count
221
- const next = key.name === 'right' ? (current + 1) % count : (current - 1 + count) % count
228
+ const next = name === 'right' ? (current + 1) % count : (current - 1 + count) % count
222
229
  this.tabBar.setSelectedIndex(next)
223
230
  this.switchPane(this.tabBar.getNameAtIndex(next))
224
231
  return
225
232
  }
226
233
 
227
- // Alt+PageUp/PageDown: scroll output
228
- if (this.activePane && (key.name === 'pageup' || key.name === 'pagedown')) {
234
+ // PageUp/PageDown: scroll by page
235
+ if (name === 'pageup' || name === 'pagedown') {
229
236
  const pane = this.panes.get(this.activePane)
230
237
  const delta = this.termRows - 2
231
- pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
238
+ pane?.scrollBy(name === 'pageup' ? -delta : delta)
232
239
  return
233
240
  }
234
241
 
235
- // Alt+Home/End: scroll to top/bottom
236
- if (this.activePane && key.name === 'home') {
242
+ // Home/End: scroll to top/bottom
243
+ if (name === 'home') {
237
244
  this.panes.get(this.activePane)?.scrollToTop()
238
245
  return
239
246
  }
240
- if (this.activePane && key.name === 'end') {
247
+ if (name === 'end') {
241
248
  this.panes.get(this.activePane)?.scrollToBottom()
242
249
  return
243
250
  }
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
251
  return
265
252
  }
266
253
 
267
- // Forward all other input to the active process
254
+ // Forward all other input to the active process (interactive mode)
268
255
  if (key.sequence) {
269
256
  this.manager.write(this.activePane, key.sequence)
270
257
  }
271
258
  }
272
259
  )
273
260
 
274
- // Show first pane
261
+ // Show first pane and focus sidebar for keyboard navigation
275
262
  if (this.names.length > 0) {
276
263
  this.switchPane(this.names[0])
264
+ this.tabBar.focus()
277
265
  }
278
266
 
279
267
  // Start all processes
@@ -34,7 +34,9 @@ export class StatusBar {
34
34
  if (this._searchMode) {
35
35
  return this.buildSearchContent()
36
36
  }
37
- return new StyledText([plain('')])
37
+ return new StyledText([
38
+ plain('\u2190\u2192/1-9: tabs R: restart S: stop/start F: search L: clear Ctrl+C: quit')
39
+ ])
38
40
  }
39
41
 
40
42
  private buildSearchContent(): StyledText {
@@ -3,6 +3,7 @@ import { resolve } from 'node:path'
3
3
 
4
4
  let enabled = false
5
5
  let logFile = ''
6
+ let debugCallback: ((line: string) => void) | null = null
6
7
 
7
8
  export function enableDebugLog(dir?: string): void {
8
9
  const logDir = dir ?? resolve(process.cwd(), '.numux')
@@ -13,12 +14,18 @@ export function enableDebugLog(dir?: string): void {
13
14
  enabled = true
14
15
  }
15
16
 
16
- export function log(message: string, ...args: unknown[]): void {
17
+ export function setDebugCallback(cb: ((line: string) => void) | null): void {
18
+ debugCallback = cb
19
+ }
20
+ export function log(...args: unknown[]): void {
17
21
  if (!enabled) return
18
22
  try {
19
23
  const timestamp = new Date().toISOString()
20
- const formatted = args.length > 0 ? `${message} ${args.map(a => JSON.stringify(a)).join(' ')}` : message
21
- appendFileSync(logFile, `[${timestamp}] ${formatted}\n`)
24
+ const formatted =
25
+ args.length > 0 ? `${args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}` : ''
26
+ const line = `[${timestamp}] ${formatted}`
27
+ appendFileSync(logFile, `${line}\n`)
28
+ debugCallback?.(line)
22
29
  } catch {
23
30
  // Disk errors in debug logging should not crash the app
24
31
  enabled = false
@@ -29,4 +36,5 @@ export function log(message: string, ...args: unknown[]): void {
29
36
  export function _resetLogger(): void {
30
37
  enabled = false
31
38
  logFile = ''
39
+ debugCallback = null
32
40
  }