numux 1.23.1 → 1.24.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
@@ -167,7 +167,7 @@ Template properties (color, env, dependsOn, etc.) are inherited by all matched p
167
167
  | `-s, --sort <mode>` | Tab display order: `config` (default), `alphabetical`, `topological` |
168
168
  | `--no-watch` | Disable file watching even if config has `watch` patterns |
169
169
  | `-t, --timestamps` | Add `[HH:MM:SS]` timestamps to prefixed output |
170
- | `--log-dir <path>` | Write per-process output to `<path>/<name>.log` |
170
+ | `--log-dir <path>` | Persist logs to timestamped subdirs (`<path>/<timestamp>/<name>.log`) with a `latest` symlink. Path is printed on exit |
171
171
  | `--debug` | Log to `.numux/debug.log` |
172
172
  | `-h, --help` | Show help |
173
173
  | `-v, --version` | Show version |
@@ -347,6 +347,23 @@ Unmatched references are left as-is (the shell will expand `$db` as empty + `.po
347
347
 
348
348
  Keybindings are shown in the status bar at the bottom of the app. Panes are readonly by default — keyboard input is not forwarded to processes. Set `interactive: true` on processes that need stdin (REPLs, shells, etc.).
349
349
 
350
+ | Key | Action |
351
+ |-----|--------|
352
+ | `←`/`→` or `1`-`9` | Switch tabs |
353
+ | `F` | Search current pane |
354
+ | `Tab` (in search) | Toggle between single-pane and all-process search |
355
+ | `Enter`/`Shift+Enter` | Next/previous match |
356
+ | `Esc` | Exit search |
357
+ | `R` | Restart current process |
358
+ | `Shift+R` | Restart all processes |
359
+ | `S` | Stop/start current process |
360
+ | `Y` | Copy all output |
361
+ | `L` | Clear pane |
362
+ | `G`/`Shift+G` | Scroll to top/bottom |
363
+ | `PageUp`/`PageDown` | Scroll by page |
364
+ | `Ctrl+Click` | Open link |
365
+ | `Ctrl+C` | Quit |
366
+
350
367
  ## Tab icons
351
368
 
352
369
  | Icon | Status |
package/dist/numux.js CHANGED
@@ -36,7 +36,7 @@ var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports,
36
36
  var require_package = __commonJS((exports, module) => {
37
37
  module.exports = {
38
38
  name: "numux",
39
- version: "1.23.1",
39
+ version: "1.24.0",
40
40
  description: "Terminal multiplexer with dependency orchestration",
41
41
  type: "module",
42
42
  license: "MIT",
@@ -1059,6 +1059,11 @@ function validateConfig(raw, warnings) {
1059
1059
  globalEnv = config.env;
1060
1060
  }
1061
1061
  const sort = validateSort(config.sort);
1062
+ const prefix = config.prefix === true ? true : undefined;
1063
+ const timestamps = config.timestamps === true ? true : undefined;
1064
+ const killOthers = config.killOthers === true ? true : undefined;
1065
+ const noWatch = config.noWatch === true ? true : undefined;
1066
+ const logDir = typeof config.logDir === "string" && config.logDir.trim() ? config.logDir.trim() : undefined;
1062
1067
  const validated = {};
1063
1068
  for (const name of names) {
1064
1069
  let proc = processes[name];
@@ -1156,7 +1161,15 @@ function validateConfig(raw, warnings) {
1156
1161
  showCommand
1157
1162
  };
1158
1163
  }
1159
- return { ...sort ? { sort } : {}, processes: validated };
1164
+ return {
1165
+ ...sort ? { sort } : {},
1166
+ ...prefix ? { prefix } : {},
1167
+ ...timestamps ? { timestamps } : {},
1168
+ ...killOthers ? { killOthers } : {},
1169
+ ...noWatch ? { noWatch } : {},
1170
+ ...logDir ? { logDir } : {},
1171
+ processes: validated
1172
+ };
1160
1173
  }
1161
1174
  function validateStringOrStringArray(value) {
1162
1175
  if (typeof value === "string")
@@ -2307,6 +2320,197 @@ class Pane {
2307
2320
  }
2308
2321
  }
2309
2322
 
2323
+ // src/ui/search.ts
2324
+ class SearchController {
2325
+ mode = false;
2326
+ allMode = false;
2327
+ query = "";
2328
+ matches = [];
2329
+ index = -1;
2330
+ crossMatches = [];
2331
+ crossCounts = new Map;
2332
+ timer = null;
2333
+ logWriter;
2334
+ statusBar;
2335
+ tabBar;
2336
+ getActivePane;
2337
+ getPane;
2338
+ constructor(opts) {
2339
+ this.logWriter = opts.logWriter;
2340
+ this.statusBar = opts.statusBar;
2341
+ this.tabBar = opts.tabBar;
2342
+ this.getActivePane = opts.getActivePane;
2343
+ this.getPane = opts.getPane;
2344
+ }
2345
+ get isActive() {
2346
+ return this.mode;
2347
+ }
2348
+ get isAllMode() {
2349
+ return this.allMode;
2350
+ }
2351
+ get currentMatches() {
2352
+ return this.matches;
2353
+ }
2354
+ enter() {
2355
+ this.mode = true;
2356
+ this.query = "";
2357
+ this.matches = [];
2358
+ this.index = -1;
2359
+ this.allMode = false;
2360
+ this.crossMatches = [];
2361
+ this.crossCounts.clear();
2362
+ this.statusBar.setSearchMode(true);
2363
+ }
2364
+ exit() {
2365
+ this.mode = false;
2366
+ this.query = "";
2367
+ this.matches = [];
2368
+ this.index = -1;
2369
+ this.allMode = false;
2370
+ this.crossMatches = [];
2371
+ this.crossCounts.clear();
2372
+ if (this.timer) {
2373
+ clearTimeout(this.timer);
2374
+ this.timer = null;
2375
+ }
2376
+ const active = this.getActivePane();
2377
+ if (active) {
2378
+ this.getPane(active)?.clearHighlights();
2379
+ }
2380
+ this.tabBar.clearSearchMatches();
2381
+ this.statusBar.setSearchMode(false);
2382
+ }
2383
+ onPaneSwitch() {
2384
+ if (this.mode && this.allMode) {
2385
+ this.applyPaneMatches();
2386
+ }
2387
+ }
2388
+ refreshHighlights() {
2389
+ if (this.matches.length > 0) {
2390
+ this.updateHighlights();
2391
+ }
2392
+ }
2393
+ handleInput(key) {
2394
+ if (key.name === "escape") {
2395
+ this.exit();
2396
+ return;
2397
+ }
2398
+ if (key.name === "tab") {
2399
+ this.allMode = !this.allMode;
2400
+ if (!this.allMode) {
2401
+ this.crossMatches = [];
2402
+ this.crossCounts.clear();
2403
+ this.tabBar.clearSearchMatches();
2404
+ }
2405
+ this.scheduleSearch();
2406
+ return;
2407
+ }
2408
+ if (key.name === "return") {
2409
+ if (this.matches.length === 0)
2410
+ return;
2411
+ if (key.shift) {
2412
+ this.index = (this.index - 1 + this.matches.length) % this.matches.length;
2413
+ } else {
2414
+ this.index = (this.index + 1) % this.matches.length;
2415
+ }
2416
+ this.scrollToMatch();
2417
+ this.updateHighlights();
2418
+ return;
2419
+ }
2420
+ if (key.name === "backspace") {
2421
+ if (this.query.length > 0) {
2422
+ this.query = this.query.slice(0, -1);
2423
+ this.scheduleSearch();
2424
+ }
2425
+ return;
2426
+ }
2427
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
2428
+ this.query += key.sequence;
2429
+ this.scheduleSearch();
2430
+ }
2431
+ }
2432
+ dispose() {
2433
+ if (this.timer) {
2434
+ clearTimeout(this.timer);
2435
+ this.timer = null;
2436
+ }
2437
+ }
2438
+ scheduleSearch() {
2439
+ this.statusBar.setSearchMode(true, this.query, this.matches.length, this.index, this.crossInfo());
2440
+ if (this.timer)
2441
+ clearTimeout(this.timer);
2442
+ this.timer = setTimeout(() => {
2443
+ this.timer = null;
2444
+ this.runSearch();
2445
+ }, 100);
2446
+ }
2447
+ async runSearch() {
2448
+ const active = this.getActivePane();
2449
+ if (!active)
2450
+ return;
2451
+ const query = this.query;
2452
+ const allMode = this.allMode;
2453
+ if (allMode) {
2454
+ const allMatches = await this.logWriter.searchAll(query);
2455
+ if (!this.mode || this.query !== query || !this.allMode)
2456
+ return;
2457
+ this.crossMatches = allMatches;
2458
+ this.crossCounts.clear();
2459
+ for (const m of allMatches) {
2460
+ this.crossCounts.set(m.process, (this.crossCounts.get(m.process) ?? 0) + 1);
2461
+ }
2462
+ this.tabBar.setSearchMatches(this.crossCounts);
2463
+ this.applyPaneMatches();
2464
+ } else {
2465
+ const matches = await this.logWriter.search(active, query);
2466
+ if (!this.mode || this.query !== query || this.getActivePane() !== active)
2467
+ return;
2468
+ this.matches = matches;
2469
+ this.index = matches.length > 0 ? 0 : -1;
2470
+ this.updateHighlights();
2471
+ if (this.index >= 0)
2472
+ this.scrollToMatch();
2473
+ }
2474
+ }
2475
+ applyPaneMatches() {
2476
+ const active = this.getActivePane();
2477
+ if (!active)
2478
+ return;
2479
+ const paneMatches = this.crossMatches.filter((m) => m.process === active).map(({ line, start, end }) => ({ line, start, end }));
2480
+ this.matches = paneMatches;
2481
+ this.index = paneMatches.length > 0 ? 0 : -1;
2482
+ this.updateHighlights();
2483
+ if (this.index >= 0)
2484
+ this.scrollToMatch();
2485
+ }
2486
+ updateHighlights() {
2487
+ const active = this.getActivePane();
2488
+ if (!active)
2489
+ return;
2490
+ const pane = this.getPane(active);
2491
+ if (!pane)
2492
+ return;
2493
+ if (this.matches.length > 0) {
2494
+ pane.setHighlights(this.matches, this.index);
2495
+ } else {
2496
+ pane.clearHighlights();
2497
+ }
2498
+ this.statusBar.setSearchMode(true, this.query, this.matches.length, this.index, this.crossInfo());
2499
+ }
2500
+ scrollToMatch() {
2501
+ const active = this.getActivePane();
2502
+ if (!active || this.index < 0)
2503
+ return;
2504
+ const pane = this.getPane(active);
2505
+ if (!pane)
2506
+ return;
2507
+ pane.scrollToLine(this.matches[this.index].line);
2508
+ }
2509
+ crossInfo() {
2510
+ return this.allMode ? { totalMatches: this.crossMatches.length, processCount: this.crossCounts.size } : undefined;
2511
+ }
2512
+ }
2513
+
2310
2514
  // src/ui/status-bar.ts
2311
2515
  import { cyan, red, reverse, StyledText, TextRenderable, yellow } from "@opentui/core";
2312
2516
  function plain(text) {
@@ -2319,6 +2523,7 @@ class StatusBar {
2319
2523
  _searchQuery = "";
2320
2524
  _searchMatchCount = 0;
2321
2525
  _searchCurrentIndex = -1;
2526
+ _crossProcessInfo;
2322
2527
  _tempMessage = null;
2323
2528
  _tempTimer = null;
2324
2529
  constructor(renderer) {
@@ -2331,11 +2536,12 @@ class StatusBar {
2331
2536
  paddingX: 1
2332
2537
  });
2333
2538
  }
2334
- setSearchMode(active, query = "", matchCount = 0, currentIndex = -1) {
2539
+ setSearchMode(active, query = "", matchCount = 0, currentIndex = -1, crossProcessInfo) {
2335
2540
  this._searchMode = active;
2336
2541
  this._searchQuery = query;
2337
2542
  this._searchMatchCount = matchCount;
2338
2543
  this._searchCurrentIndex = currentIndex;
2544
+ this._crossProcessInfo = crossProcessInfo;
2339
2545
  this.renderable.content = this.buildContent();
2340
2546
  }
2341
2547
  showTemporaryMessage(message, duration = 2000) {
@@ -2360,6 +2566,7 @@ class StatusBar {
2360
2566
  }
2361
2567
  buildSearchContent() {
2362
2568
  const chunks = [];
2569
+ const isAllMode = !!this._crossProcessInfo;
2363
2570
  chunks.push(yellow("/"));
2364
2571
  if (this._searchQuery)
2365
2572
  chunks.push(plain(this._searchQuery));
@@ -2367,14 +2574,20 @@ class StatusBar {
2367
2574
  if (this._searchMatchCount === 0 && this._searchQuery) {
2368
2575
  chunks.push(plain(" "));
2369
2576
  chunks.push(red("no matches"));
2370
- chunks.push(plain(" Esc: close"));
2371
2577
  } else if (this._searchMatchCount > 0) {
2372
2578
  chunks.push(plain(" "));
2373
- chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
2374
- chunks.push(plain(" Enter/Shift+Enter: next/prev Esc: close"));
2579
+ if (isAllMode) {
2580
+ const { totalMatches, processCount } = this._crossProcessInfo;
2581
+ chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
2582
+ chunks.push(plain(` ${totalMatches} in ${processCount} process${processCount === 1 ? "" : "es"}`));
2583
+ } else {
2584
+ chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
2585
+ }
2586
+ chunks.push(plain(" Enter: next"));
2375
2587
  } else {
2376
- chunks.push(plain(" Enter: next Esc: close"));
2588
+ chunks.push(plain(" Enter: next"));
2377
2589
  }
2590
+ chunks.push(plain(` Tab: ${isAllMode ? "single" : "all"} Esc: close`));
2378
2591
  return new StyledText(chunks);
2379
2592
  }
2380
2593
  }
@@ -2422,12 +2635,13 @@ function getDisplayOrder(originalNames, statuses) {
2422
2635
  const terminal = originalNames.filter((n) => TERMINAL_STATUSES.has(statuses.get(n)));
2423
2636
  return [...active, ...terminal];
2424
2637
  }
2425
- function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses) {
2638
+ function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses, searchMatchProcesses) {
2426
2639
  return names.map((name) => {
2427
2640
  const status = statuses.get(name);
2428
2641
  const waiting = inputWaiting.has(name);
2429
2642
  const errored = erroredProcesses.has(name);
2430
- const statusHex = waiting ? "#ffaa00" : errored ? "#ff5555" : STATUS_ICON_HEX[status];
2643
+ const hasSearchMatch = searchMatchProcesses?.has(name);
2644
+ const statusHex = hasSearchMatch ? "#b58900" : waiting ? "#ffaa00" : errored ? "#ff5555" : STATUS_ICON_HEX[status];
2431
2645
  const processHex = processColors.get(name);
2432
2646
  return {
2433
2647
  iconHex: statusHex ?? processHex ?? "#888888",
@@ -2512,6 +2726,7 @@ class TabBar {
2512
2726
  processColors;
2513
2727
  inputWaiting = new Set;
2514
2728
  erroredProcesses = new Set;
2729
+ searchMatchCounts = new Map;
2515
2730
  constructor(renderer, names, colors) {
2516
2731
  this.originalNames = names;
2517
2732
  this.names = [...names];
@@ -2569,6 +2784,14 @@ class TabBar {
2569
2784
  this.erroredProcesses.delete(name);
2570
2785
  this.refreshOptions();
2571
2786
  }
2787
+ setSearchMatches(counts) {
2788
+ this.searchMatchCounts = counts;
2789
+ this.refreshOptions();
2790
+ }
2791
+ clearSearchMatches() {
2792
+ this.searchMatchCounts.clear();
2793
+ this.refreshOptions();
2794
+ }
2572
2795
  getNameAtIndex(index) {
2573
2796
  return this.names[index];
2574
2797
  }
@@ -2590,6 +2813,10 @@ class TabBar {
2590
2813
  this.updateOptionColors();
2591
2814
  }
2592
2815
  getDescription(name) {
2816
+ const matchCount = this.searchMatchCounts.get(name);
2817
+ if (matchCount != null && matchCount > 0) {
2818
+ return `${matchCount} match${matchCount === 1 ? "" : "es"}`;
2819
+ }
2593
2820
  if (this.inputWaiting.has(name))
2594
2821
  return "awaiting input";
2595
2822
  if (this.erroredProcesses.has(name))
@@ -2597,7 +2824,8 @@ class TabBar {
2597
2824
  return this.baseDescriptions.get(name) ?? "pending";
2598
2825
  }
2599
2826
  updateOptionColors() {
2600
- const resolved = resolveOptionColors(this.names, this.statuses, this.processColors, this.inputWaiting, this.erroredProcesses);
2827
+ const searchProcesses = this.searchMatchCounts.size > 0 ? new Set(this.searchMatchCounts.keys()) : undefined;
2828
+ const resolved = resolveOptionColors(this.names, this.statuses, this.processColors, this.inputWaiting, this.erroredProcesses, searchProcesses);
2601
2829
  const colors = resolved.map((c) => ({
2602
2830
  icon: parseColor(c.iconHex),
2603
2831
  name: c.nameHex ? parseColor(c.nameHex) : null
@@ -2622,6 +2850,7 @@ class App {
2622
2850
  panes = new Map;
2623
2851
  tabBar;
2624
2852
  statusBar;
2853
+ search;
2625
2854
  activePane = null;
2626
2855
  destroyed = false;
2627
2856
  names;
@@ -2631,11 +2860,6 @@ class App {
2631
2860
  config;
2632
2861
  logWriter;
2633
2862
  resizeTimer = null;
2634
- searchTimer = null;
2635
- searchMode = false;
2636
- searchQuery = "";
2637
- searchMatches = [];
2638
- searchIndex = -1;
2639
2863
  inputWaitTimers = new Map;
2640
2864
  awaitingInput = new Set;
2641
2865
  constructor(manager, config, logWriter) {
@@ -2685,6 +2909,14 @@ class App {
2685
2909
  flexGrow: 1,
2686
2910
  border: false
2687
2911
  });
2912
+ this.statusBar = new StatusBar(this.renderer);
2913
+ this.search = new SearchController({
2914
+ logWriter: this.logWriter,
2915
+ statusBar: this.statusBar,
2916
+ tabBar: this.tabBar,
2917
+ getActivePane: () => this.activePane,
2918
+ getPane: (name) => this.panes.get(name)
2919
+ });
2688
2920
  for (const name of this.names) {
2689
2921
  const interactive = this.config.processes[name].interactive === true;
2690
2922
  const pane = new Pane(this.renderer, name, termCols, termRows, interactive);
@@ -2697,14 +2929,13 @@ class App {
2697
2929
  this.statusBar.showTemporaryMessage(`Opening ${link.url}`);
2698
2930
  });
2699
2931
  pane.onScroll(() => {
2700
- if (this.searchMode && this.searchMatches.length > 0 && this.activePane === name) {
2701
- this.updateSearchHighlights();
2932
+ if (this.search.isActive && this.search.currentMatches.length > 0 && this.activePane === name) {
2933
+ this.search.refreshHighlights();
2702
2934
  }
2703
2935
  });
2704
2936
  this.panes.set(name, pane);
2705
2937
  paneContainer.add(pane.scrollBox);
2706
2938
  }
2707
- this.statusBar = new StatusBar(this.renderer);
2708
2939
  contentRow.add(sidebar);
2709
2940
  contentRow.add(paneContainer);
2710
2941
  layout.add(contentRow);
@@ -2746,8 +2977,8 @@ class App {
2746
2977
  this.renderer.keyInput.on("keypress", (key) => {
2747
2978
  log(key);
2748
2979
  if (key.ctrl && key.name === "c") {
2749
- if (this.searchMode) {
2750
- this.exitSearch();
2980
+ if (this.search.isActive) {
2981
+ this.search.exit();
2751
2982
  return;
2752
2983
  }
2753
2984
  this.shutdown().then(() => {
@@ -2755,8 +2986,8 @@ class App {
2755
2986
  });
2756
2987
  return;
2757
2988
  }
2758
- if (this.searchMode) {
2759
- this.handleSearchInput(key);
2989
+ if (this.search.isActive) {
2990
+ this.search.handleInput(key);
2760
2991
  return;
2761
2992
  }
2762
2993
  if (!this.activePane)
@@ -2781,7 +3012,7 @@ class App {
2781
3012
  return;
2782
3013
  }
2783
3014
  if (name === SHORTCUTS.search.key) {
2784
- this.enterSearch();
3015
+ this.search.enter();
2785
3016
  return;
2786
3017
  }
2787
3018
  if (name === SHORTCUTS.restart.key) {
@@ -2845,14 +3076,16 @@ class App {
2845
3076
  switchPane(name) {
2846
3077
  if (this.activePane === name)
2847
3078
  return;
2848
- if (this.searchMode) {
2849
- this.exitSearch();
3079
+ if (this.search.isActive && !this.search.isAllMode) {
3080
+ this.search.exit();
2850
3081
  }
2851
3082
  if (this.activePane) {
3083
+ this.panes.get(this.activePane)?.clearHighlights();
2852
3084
  this.panes.get(this.activePane)?.hide();
2853
3085
  }
2854
3086
  this.activePane = name;
2855
3087
  this.panes.get(name)?.show();
3088
+ this.search.onPaneSwitch();
2856
3089
  }
2857
3090
  checkInputWaiting(name, data) {
2858
3091
  const existing = this.inputWaitTimers.get(name);
@@ -2913,102 +3146,6 @@ class App {
2913
3146
  this.copyToClipboard(text);
2914
3147
  this.statusBar.showTemporaryMessage("Copied all output!");
2915
3148
  }
2916
- enterSearch() {
2917
- this.searchMode = true;
2918
- this.searchQuery = "";
2919
- this.searchMatches = [];
2920
- this.searchIndex = -1;
2921
- this.statusBar.setSearchMode(true);
2922
- }
2923
- exitSearch() {
2924
- this.searchMode = false;
2925
- this.searchQuery = "";
2926
- this.searchMatches = [];
2927
- this.searchIndex = -1;
2928
- if (this.searchTimer) {
2929
- clearTimeout(this.searchTimer);
2930
- this.searchTimer = null;
2931
- }
2932
- if (this.activePane) {
2933
- this.panes.get(this.activePane)?.clearHighlights();
2934
- }
2935
- this.statusBar.setSearchMode(false);
2936
- }
2937
- handleSearchInput(key) {
2938
- if (key.name === "escape") {
2939
- this.exitSearch();
2940
- return;
2941
- }
2942
- if (key.name === "return") {
2943
- if (this.searchMatches.length === 0)
2944
- return;
2945
- if (key.shift) {
2946
- this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
2947
- } else {
2948
- this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length;
2949
- }
2950
- this.scrollToCurrentMatch();
2951
- this.updateSearchHighlights();
2952
- return;
2953
- }
2954
- if (key.name === "backspace") {
2955
- if (this.searchQuery.length > 0) {
2956
- this.searchQuery = this.searchQuery.slice(0, -1);
2957
- this.scheduleSearch();
2958
- }
2959
- return;
2960
- }
2961
- if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
2962
- this.searchQuery += key.sequence;
2963
- this.scheduleSearch();
2964
- }
2965
- }
2966
- scheduleSearch() {
2967
- this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex);
2968
- if (this.searchTimer)
2969
- clearTimeout(this.searchTimer);
2970
- this.searchTimer = setTimeout(() => {
2971
- this.searchTimer = null;
2972
- this.runSearch();
2973
- }, 100);
2974
- }
2975
- async runSearch() {
2976
- if (!this.activePane)
2977
- return;
2978
- const query = this.searchQuery;
2979
- const activeName = this.activePane;
2980
- const matches = await this.logWriter.search(activeName, query);
2981
- if (!this.searchMode || this.searchQuery !== query || this.activePane !== activeName)
2982
- return;
2983
- this.searchMatches = matches;
2984
- this.searchIndex = matches.length > 0 ? 0 : -1;
2985
- this.updateSearchHighlights();
2986
- if (this.searchIndex >= 0) {
2987
- this.scrollToCurrentMatch();
2988
- }
2989
- }
2990
- updateSearchHighlights() {
2991
- if (!this.activePane)
2992
- return;
2993
- const pane = this.panes.get(this.activePane);
2994
- if (!pane)
2995
- return;
2996
- if (this.searchMatches.length > 0) {
2997
- pane.setHighlights(this.searchMatches, this.searchIndex);
2998
- } else {
2999
- pane.clearHighlights();
3000
- }
3001
- this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex);
3002
- }
3003
- scrollToCurrentMatch() {
3004
- if (!this.activePane || this.searchIndex < 0)
3005
- return;
3006
- const pane = this.panes.get(this.activePane);
3007
- if (!pane)
3008
- return;
3009
- const match = this.searchMatches[this.searchIndex];
3010
- pane.scrollToLine(match.line);
3011
- }
3012
3149
  async shutdown() {
3013
3150
  if (this.destroyed)
3014
3151
  return;
@@ -3017,10 +3154,7 @@ class App {
3017
3154
  clearTimeout(this.resizeTimer);
3018
3155
  this.resizeTimer = null;
3019
3156
  }
3020
- if (this.searchTimer) {
3021
- clearTimeout(this.searchTimer);
3022
- this.searchTimer = null;
3023
- }
3157
+ this.search.dispose();
3024
3158
  for (const timer of this.inputWaitTimers.values()) {
3025
3159
  clearTimeout(timer);
3026
3160
  }
@@ -3224,9 +3358,9 @@ ${DIM}Done in ${elapsed}${RESET}
3224
3358
  }
3225
3359
 
3226
3360
  // src/utils/log-writer.ts
3227
- import { closeSync, mkdirSync as mkdirSync2, openSync, rmSync, writeSync } from "fs";
3361
+ import { closeSync, mkdirSync as mkdirSync2, openSync, rmSync, symlinkSync, unlinkSync, writeSync } from "fs";
3228
3362
  import { tmpdir } from "os";
3229
- import { join } from "path";
3363
+ import { basename as basename2, join } from "path";
3230
3364
  class LogWriter {
3231
3365
  dir;
3232
3366
  isTemp;
@@ -3242,6 +3376,30 @@ class LogWriter {
3242
3376
  const dir = join(tmpdir(), `numux-${process.pid}`);
3243
3377
  return new LogWriter(dir, true);
3244
3378
  }
3379
+ static createPersistent(baseDir) {
3380
+ mkdirSync2(baseDir, { recursive: true });
3381
+ const now = new Date;
3382
+ const ts = now.toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "");
3383
+ const sessionDir = join(baseDir, ts);
3384
+ mkdirSync2(sessionDir, { recursive: true });
3385
+ const latestLink = join(baseDir, "latest");
3386
+ try {
3387
+ unlinkSync(latestLink);
3388
+ } catch {}
3389
+ try {
3390
+ symlinkSync(sessionDir, latestLink);
3391
+ } catch {}
3392
+ return new LogWriter(sessionDir, false);
3393
+ }
3394
+ get isTemporary() {
3395
+ return this.isTemp;
3396
+ }
3397
+ getDirectory() {
3398
+ return this.dir;
3399
+ }
3400
+ getProcessNames() {
3401
+ return [...this.files.keys()];
3402
+ }
3245
3403
  errored = false;
3246
3404
  handleEvent = (event) => {
3247
3405
  if (event.type !== "output" || this.errored)
@@ -3313,6 +3471,58 @@ class LogWriter {
3313
3471
  return [];
3314
3472
  }
3315
3473
  }
3474
+ async searchAll(query) {
3475
+ if (!query)
3476
+ return [];
3477
+ const paths = [...this.files.keys()].map((name) => join(this.dir, `${name}.log`));
3478
+ if (paths.length === 0)
3479
+ return [];
3480
+ try {
3481
+ const proc = Bun.spawn(["grep", "-inFH", query, ...paths], {
3482
+ stdout: "pipe",
3483
+ stderr: "ignore"
3484
+ });
3485
+ const output = await new Response(proc.stdout).text();
3486
+ await proc.exited;
3487
+ const matches = [];
3488
+ const lowerQuery = query.toLowerCase();
3489
+ for (const line of output.split(`
3490
+ `)) {
3491
+ if (!line)
3492
+ continue;
3493
+ const firstColon = line.indexOf(":");
3494
+ if (firstColon === -1)
3495
+ continue;
3496
+ const filePath = line.slice(0, firstColon);
3497
+ const rest = line.slice(firstColon + 1);
3498
+ const secondColon = rest.indexOf(":");
3499
+ if (secondColon === -1)
3500
+ continue;
3501
+ const lineNumber = Number.parseInt(rest.slice(0, secondColon), 10);
3502
+ if (Number.isNaN(lineNumber))
3503
+ continue;
3504
+ const fileName = basename2(filePath);
3505
+ const processName = fileName.replace(/\.log$/, "");
3506
+ const lineText = rest.slice(secondColon + 1).toLowerCase();
3507
+ let pos = 0;
3508
+ while (true) {
3509
+ const idx = lineText.indexOf(lowerQuery, pos);
3510
+ if (idx === -1)
3511
+ break;
3512
+ matches.push({
3513
+ process: processName,
3514
+ line: lineNumber - 1,
3515
+ start: idx,
3516
+ end: idx + query.length
3517
+ });
3518
+ pos = idx + 1;
3519
+ }
3520
+ }
3521
+ return matches;
3522
+ } catch {
3523
+ return [];
3524
+ }
3525
+ }
3316
3526
  truncate(name) {
3317
3527
  const fd = this.files.get(name);
3318
3528
  if (fd === undefined)
@@ -3349,6 +3559,10 @@ function setupShutdownHandlers(app, logWriter) {
3349
3559
  }
3350
3560
  shuttingDown = true;
3351
3561
  app.shutdown().finally(() => {
3562
+ if (logWriter && !logWriter.isTemporary) {
3563
+ process.stderr.write(`Logs saved to: ${logWriter.getDirectory()}
3564
+ `);
3565
+ }
3352
3566
  logWriter?.cleanup();
3353
3567
  process.exit(app.hasFailures() ? 1 : 0);
3354
3568
  });
@@ -3561,7 +3775,7 @@ async function main() {
3561
3775
  proc.envFile = parsed.envFile;
3562
3776
  }
3563
3777
  }
3564
- if (parsed.noWatch) {
3778
+ if (parsed.noWatch || config.noWatch) {
3565
3779
  for (const proc of Object.values(config.processes)) {
3566
3780
  delete proc.watch;
3567
3781
  }
@@ -3577,9 +3791,11 @@ async function main() {
3577
3791
  }
3578
3792
  }
3579
3793
  const manager = new ProcessManager(config);
3580
- const logWriter = parsed.logDir ? new LogWriter(parsed.logDir) : LogWriter.createTemp();
3794
+ const logDir = parsed.logDir ?? config.logDir;
3795
+ const logWriter = logDir ? LogWriter.createPersistent(logDir) : LogWriter.createTemp();
3581
3796
  printWarnings(warnings);
3582
- if (parsed.prefix) {
3797
+ const usePrefix = parsed.prefix || config.prefix;
3798
+ if (usePrefix) {
3583
3799
  if (!parsed.noRestart) {
3584
3800
  for (const proc of Object.values(config.processes)) {
3585
3801
  proc.maxRestarts ??= 0;
@@ -3587,8 +3803,8 @@ async function main() {
3587
3803
  }
3588
3804
  const display = new PrefixDisplay(manager, config, {
3589
3805
  logWriter,
3590
- killOthers: parsed.killOthers,
3591
- timestamps: parsed.timestamps
3806
+ killOthers: parsed.killOthers || config.killOthers,
3807
+ timestamps: parsed.timestamps || config.timestamps
3592
3808
  });
3593
3809
  await display.start();
3594
3810
  } else {
package/dist/types.d.ts CHANGED
@@ -95,6 +95,25 @@ export interface NumuxConfig<K extends string = string> {
95
95
  * @default 'config'
96
96
  */
97
97
  sort?: SortOrder;
98
+ /**
99
+ * Use prefixed output mode instead of TUI (for CI/scripts)
100
+ * @default false
101
+ */
102
+ prefix?: boolean;
103
+ /** Add timestamps to prefixed output lines (only applies when `prefix` is true) */
104
+ timestamps?: boolean;
105
+ /**
106
+ * Kill all processes when any one exits
107
+ * @default false
108
+ */
109
+ killOthers?: boolean;
110
+ /**
111
+ * Disable file watching even if processes have watch patterns
112
+ * @default false
113
+ */
114
+ noWatch?: boolean;
115
+ /** Directory to write per-process log files */
116
+ logDir?: string;
98
117
  processes: Record<K, NumuxProcessConfig<K> | NumuxScriptPattern<K> | string>;
99
118
  }
100
119
  export type SortOrder = 'config' | 'alphabetical' | 'topological';
@@ -105,6 +124,11 @@ export interface ResolvedProcessConfig extends Omit<NumuxProcessConfig, 'depends
105
124
  /** Validated config with all shorthand expanded to full objects */
106
125
  export interface ResolvedNumuxConfig {
107
126
  sort?: SortOrder;
127
+ prefix?: boolean;
128
+ timestamps?: boolean;
129
+ killOthers?: boolean;
130
+ noWatch?: boolean;
131
+ logDir?: string;
108
132
  processes: Record<string, ResolvedProcessConfig>;
109
133
  }
110
134
  export type ProcessStatus = 'pending' | 'starting' | 'ready' | 'running' | 'stopping' | 'stopped' | 'finished' | 'failed' | 'skipped';
@@ -131,3 +155,10 @@ export type ProcessEvent = {
131
155
  type: 'error';
132
156
  name: string;
133
157
  };
158
+ export interface KeyEvent {
159
+ ctrl: boolean;
160
+ shift: boolean;
161
+ meta: boolean;
162
+ name: string;
163
+ sequence: string;
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "1.23.1",
3
+ "version": "1.24.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",