numux 1.23.2 → 1.25.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
@@ -2,6 +2,8 @@
2
2
 
3
3
  Terminal multiplexer with dependency orchestration. Run multiple processes in a tabbed TUI with a dependency graph controlling startup order.
4
4
 
5
+ Inspired by `sst dev` and `concurrently`
6
+
5
7
  ## Install
6
8
 
7
9
  Requires [Bun](https://bun.sh) >= 1.0.
@@ -167,7 +169,7 @@ Template properties (color, env, dependsOn, etc.) are inherited by all matched p
167
169
  | `-s, --sort <mode>` | Tab display order: `config` (default), `alphabetical`, `topological` |
168
170
  | `--no-watch` | Disable file watching even if config has `watch` patterns |
169
171
  | `-t, --timestamps` | Add `[HH:MM:SS]` timestamps to prefixed output |
170
- | `--log-dir <path>` | Write per-process output to `<path>/<name>.log` |
172
+ | `--log-dir <path>` | Persist logs to timestamped subdirs (`<path>/<timestamp>/<name>.log`) with a `latest` symlink. Path is printed on exit |
171
173
  | `--debug` | Log to `.numux/debug.log` |
172
174
  | `-h, --help` | Show help |
173
175
  | `-v, --version` | Show version |
@@ -194,6 +196,7 @@ Top-level options apply to all processes (process-level settings override):
194
196
  | `env` | `Record<string, string>` | Environment variables merged into all processes (process `env` overrides per key) |
195
197
  | `envFile` | `string \| string[] \| false` | `.env` file(s) for all processes (process `envFile` replaces if set; `false` disables) |
196
198
  | `showCommand` | `boolean` | Print the command being run as the first line of output (default: `true`) |
199
+ | `persistent` | `boolean` | Set to `false` to make all processes one-shot by default (default: `true`) |
197
200
  | `maxRestarts` | `number` | Restart limit for all processes (default: `Infinity`) |
198
201
  | `readyTimeout` | `number` | Ready timeout in ms for all processes |
199
202
  | `stopSignal` | `'SIGTERM' \| 'SIGINT' \| 'SIGHUP'` | Stop signal for all processes (default: `'SIGTERM'`) |
@@ -347,6 +350,23 @@ Unmatched references are left as-is (the shell will expand `$db` as empty + `.po
347
350
 
348
351
  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
352
 
353
+ | Key | Action |
354
+ |-----|--------|
355
+ | `←`/`→` or `1`-`9` | Switch tabs |
356
+ | `F` | Search current pane |
357
+ | `Tab` (in search) | Toggle between single-pane and all-process search |
358
+ | `Enter`/`Shift+Enter` | Next/previous match |
359
+ | `Esc` | Exit search |
360
+ | `R` | Restart current process |
361
+ | `Shift+R` | Restart all processes |
362
+ | `S` | Stop/start current process |
363
+ | `Y` | Copy all output |
364
+ | `L` | Clear pane |
365
+ | `G`/`Shift+G` | Scroll to top/bottom |
366
+ | `PageUp`/`PageDown` | Scroll by page |
367
+ | `Ctrl+Click` | Open link |
368
+ | `Ctrl+C` | Quit |
369
+
350
370
  ## Tab icons
351
371
 
352
372
  | 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.2",
39
+ version: "1.25.0",
40
40
  description: "Terminal multiplexer with dependency orchestration",
41
41
  type: "module",
42
42
  license: "MIT",
@@ -1046,6 +1046,7 @@ function validateConfig(raw, warnings) {
1046
1046
  const globalEnvFile = validateEnvFile(config.envFile);
1047
1047
  const globalMaxRestarts = typeof config.maxRestarts === "number" && config.maxRestarts >= 0 ? config.maxRestarts : undefined;
1048
1048
  const globalReadyTimeout = typeof config.readyTimeout === "number" && config.readyTimeout > 0 ? config.readyTimeout : undefined;
1049
+ const globalPersistent = typeof config.persistent === "boolean" ? config.persistent : undefined;
1049
1050
  const globalStopSignal = validateStopSignal(config.stopSignal);
1050
1051
  const globalErrorMatcher = validateErrorMatcher("(global)", config.errorMatcher);
1051
1052
  const globalWatch = validateStringOrStringArray(config.watch);
@@ -1106,7 +1107,7 @@ function validateConfig(raw, warnings) {
1106
1107
  }
1107
1108
  }
1108
1109
  }
1109
- const persistent = typeof p.persistent === "boolean" ? p.persistent : true;
1110
+ const persistent = typeof p.persistent === "boolean" ? p.persistent : globalPersistent ?? true;
1110
1111
  const readyPattern = p.readyPattern instanceof RegExp ? p.readyPattern : typeof p.readyPattern === "string" ? p.readyPattern : undefined;
1111
1112
  if (typeof readyPattern === "string") {
1112
1113
  try {
@@ -2320,6 +2321,197 @@ class Pane {
2320
2321
  }
2321
2322
  }
2322
2323
 
2324
+ // src/ui/search.ts
2325
+ class SearchController {
2326
+ mode = false;
2327
+ allMode = false;
2328
+ query = "";
2329
+ matches = [];
2330
+ index = -1;
2331
+ crossMatches = [];
2332
+ crossCounts = new Map;
2333
+ timer = null;
2334
+ logWriter;
2335
+ statusBar;
2336
+ tabBar;
2337
+ getActivePane;
2338
+ getPane;
2339
+ constructor(opts) {
2340
+ this.logWriter = opts.logWriter;
2341
+ this.statusBar = opts.statusBar;
2342
+ this.tabBar = opts.tabBar;
2343
+ this.getActivePane = opts.getActivePane;
2344
+ this.getPane = opts.getPane;
2345
+ }
2346
+ get isActive() {
2347
+ return this.mode;
2348
+ }
2349
+ get isAllMode() {
2350
+ return this.allMode;
2351
+ }
2352
+ get currentMatches() {
2353
+ return this.matches;
2354
+ }
2355
+ enter() {
2356
+ this.mode = true;
2357
+ this.query = "";
2358
+ this.matches = [];
2359
+ this.index = -1;
2360
+ this.allMode = false;
2361
+ this.crossMatches = [];
2362
+ this.crossCounts.clear();
2363
+ this.statusBar.setSearchMode(true);
2364
+ }
2365
+ exit() {
2366
+ this.mode = false;
2367
+ this.query = "";
2368
+ this.matches = [];
2369
+ this.index = -1;
2370
+ this.allMode = false;
2371
+ this.crossMatches = [];
2372
+ this.crossCounts.clear();
2373
+ if (this.timer) {
2374
+ clearTimeout(this.timer);
2375
+ this.timer = null;
2376
+ }
2377
+ const active = this.getActivePane();
2378
+ if (active) {
2379
+ this.getPane(active)?.clearHighlights();
2380
+ }
2381
+ this.tabBar.clearSearchMatches();
2382
+ this.statusBar.setSearchMode(false);
2383
+ }
2384
+ onPaneSwitch() {
2385
+ if (this.mode && this.allMode) {
2386
+ this.applyPaneMatches();
2387
+ }
2388
+ }
2389
+ refreshHighlights() {
2390
+ if (this.matches.length > 0) {
2391
+ this.updateHighlights();
2392
+ }
2393
+ }
2394
+ handleInput(key) {
2395
+ if (key.name === "escape") {
2396
+ this.exit();
2397
+ return;
2398
+ }
2399
+ if (key.name === "tab") {
2400
+ this.allMode = !this.allMode;
2401
+ if (!this.allMode) {
2402
+ this.crossMatches = [];
2403
+ this.crossCounts.clear();
2404
+ this.tabBar.clearSearchMatches();
2405
+ }
2406
+ this.scheduleSearch();
2407
+ return;
2408
+ }
2409
+ if (key.name === "return") {
2410
+ if (this.matches.length === 0)
2411
+ return;
2412
+ if (key.shift) {
2413
+ this.index = (this.index - 1 + this.matches.length) % this.matches.length;
2414
+ } else {
2415
+ this.index = (this.index + 1) % this.matches.length;
2416
+ }
2417
+ this.scrollToMatch();
2418
+ this.updateHighlights();
2419
+ return;
2420
+ }
2421
+ if (key.name === "backspace") {
2422
+ if (this.query.length > 0) {
2423
+ this.query = this.query.slice(0, -1);
2424
+ this.scheduleSearch();
2425
+ }
2426
+ return;
2427
+ }
2428
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
2429
+ this.query += key.sequence;
2430
+ this.scheduleSearch();
2431
+ }
2432
+ }
2433
+ dispose() {
2434
+ if (this.timer) {
2435
+ clearTimeout(this.timer);
2436
+ this.timer = null;
2437
+ }
2438
+ }
2439
+ scheduleSearch() {
2440
+ this.statusBar.setSearchMode(true, this.query, this.matches.length, this.index, this.crossInfo());
2441
+ if (this.timer)
2442
+ clearTimeout(this.timer);
2443
+ this.timer = setTimeout(() => {
2444
+ this.timer = null;
2445
+ this.runSearch();
2446
+ }, 100);
2447
+ }
2448
+ async runSearch() {
2449
+ const active = this.getActivePane();
2450
+ if (!active)
2451
+ return;
2452
+ const query = this.query;
2453
+ const allMode = this.allMode;
2454
+ if (allMode) {
2455
+ const allMatches = await this.logWriter.searchAll(query);
2456
+ if (!this.mode || this.query !== query || !this.allMode)
2457
+ return;
2458
+ this.crossMatches = allMatches;
2459
+ this.crossCounts.clear();
2460
+ for (const m of allMatches) {
2461
+ this.crossCounts.set(m.process, (this.crossCounts.get(m.process) ?? 0) + 1);
2462
+ }
2463
+ this.tabBar.setSearchMatches(this.crossCounts);
2464
+ this.applyPaneMatches();
2465
+ } else {
2466
+ const matches = await this.logWriter.search(active, query);
2467
+ if (!this.mode || this.query !== query || this.getActivePane() !== active)
2468
+ return;
2469
+ this.matches = matches;
2470
+ this.index = matches.length > 0 ? 0 : -1;
2471
+ this.updateHighlights();
2472
+ if (this.index >= 0)
2473
+ this.scrollToMatch();
2474
+ }
2475
+ }
2476
+ applyPaneMatches() {
2477
+ const active = this.getActivePane();
2478
+ if (!active)
2479
+ return;
2480
+ const paneMatches = this.crossMatches.filter((m) => m.process === active).map(({ line, start, end }) => ({ line, start, end }));
2481
+ this.matches = paneMatches;
2482
+ this.index = paneMatches.length > 0 ? 0 : -1;
2483
+ this.updateHighlights();
2484
+ if (this.index >= 0)
2485
+ this.scrollToMatch();
2486
+ }
2487
+ updateHighlights() {
2488
+ const active = this.getActivePane();
2489
+ if (!active)
2490
+ return;
2491
+ const pane = this.getPane(active);
2492
+ if (!pane)
2493
+ return;
2494
+ if (this.matches.length > 0) {
2495
+ pane.setHighlights(this.matches, this.index);
2496
+ } else {
2497
+ pane.clearHighlights();
2498
+ }
2499
+ this.statusBar.setSearchMode(true, this.query, this.matches.length, this.index, this.crossInfo());
2500
+ }
2501
+ scrollToMatch() {
2502
+ const active = this.getActivePane();
2503
+ if (!active || this.index < 0)
2504
+ return;
2505
+ const pane = this.getPane(active);
2506
+ if (!pane)
2507
+ return;
2508
+ pane.scrollToLine(this.matches[this.index].line);
2509
+ }
2510
+ crossInfo() {
2511
+ return this.allMode ? { totalMatches: this.crossMatches.length, processCount: this.crossCounts.size } : undefined;
2512
+ }
2513
+ }
2514
+
2323
2515
  // src/ui/status-bar.ts
2324
2516
  import { cyan, red, reverse, StyledText, TextRenderable, yellow } from "@opentui/core";
2325
2517
  function plain(text) {
@@ -2332,6 +2524,7 @@ class StatusBar {
2332
2524
  _searchQuery = "";
2333
2525
  _searchMatchCount = 0;
2334
2526
  _searchCurrentIndex = -1;
2527
+ _crossProcessInfo;
2335
2528
  _tempMessage = null;
2336
2529
  _tempTimer = null;
2337
2530
  constructor(renderer) {
@@ -2344,11 +2537,12 @@ class StatusBar {
2344
2537
  paddingX: 1
2345
2538
  });
2346
2539
  }
2347
- setSearchMode(active, query = "", matchCount = 0, currentIndex = -1) {
2540
+ setSearchMode(active, query = "", matchCount = 0, currentIndex = -1, crossProcessInfo) {
2348
2541
  this._searchMode = active;
2349
2542
  this._searchQuery = query;
2350
2543
  this._searchMatchCount = matchCount;
2351
2544
  this._searchCurrentIndex = currentIndex;
2545
+ this._crossProcessInfo = crossProcessInfo;
2352
2546
  this.renderable.content = this.buildContent();
2353
2547
  }
2354
2548
  showTemporaryMessage(message, duration = 2000) {
@@ -2373,6 +2567,7 @@ class StatusBar {
2373
2567
  }
2374
2568
  buildSearchContent() {
2375
2569
  const chunks = [];
2570
+ const isAllMode = !!this._crossProcessInfo;
2376
2571
  chunks.push(yellow("/"));
2377
2572
  if (this._searchQuery)
2378
2573
  chunks.push(plain(this._searchQuery));
@@ -2380,14 +2575,20 @@ class StatusBar {
2380
2575
  if (this._searchMatchCount === 0 && this._searchQuery) {
2381
2576
  chunks.push(plain(" "));
2382
2577
  chunks.push(red("no matches"));
2383
- chunks.push(plain(" Esc: close"));
2384
2578
  } else if (this._searchMatchCount > 0) {
2385
2579
  chunks.push(plain(" "));
2386
- chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
2387
- chunks.push(plain(" Enter/Shift+Enter: next/prev Esc: close"));
2580
+ if (isAllMode) {
2581
+ const { totalMatches, processCount } = this._crossProcessInfo;
2582
+ chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
2583
+ chunks.push(plain(` ${totalMatches} in ${processCount} process${processCount === 1 ? "" : "es"}`));
2584
+ } else {
2585
+ chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
2586
+ }
2587
+ chunks.push(plain(" Enter: next"));
2388
2588
  } else {
2389
- chunks.push(plain(" Enter: next Esc: close"));
2589
+ chunks.push(plain(" Enter: next"));
2390
2590
  }
2591
+ chunks.push(plain(` Tab: ${isAllMode ? "single" : "all"} Esc: close`));
2391
2592
  return new StyledText(chunks);
2392
2593
  }
2393
2594
  }
@@ -2435,12 +2636,13 @@ function getDisplayOrder(originalNames, statuses) {
2435
2636
  const terminal = originalNames.filter((n) => TERMINAL_STATUSES.has(statuses.get(n)));
2436
2637
  return [...active, ...terminal];
2437
2638
  }
2438
- function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses) {
2639
+ function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses, searchMatchProcesses) {
2439
2640
  return names.map((name) => {
2440
2641
  const status = statuses.get(name);
2441
2642
  const waiting = inputWaiting.has(name);
2442
2643
  const errored = erroredProcesses.has(name);
2443
- const statusHex = waiting ? "#ffaa00" : errored ? "#ff5555" : STATUS_ICON_HEX[status];
2644
+ const hasSearchMatch = searchMatchProcesses?.has(name);
2645
+ const statusHex = hasSearchMatch ? "#b58900" : waiting ? "#ffaa00" : errored ? "#ff5555" : STATUS_ICON_HEX[status];
2444
2646
  const processHex = processColors.get(name);
2445
2647
  return {
2446
2648
  iconHex: statusHex ?? processHex ?? "#888888",
@@ -2525,6 +2727,7 @@ class TabBar {
2525
2727
  processColors;
2526
2728
  inputWaiting = new Set;
2527
2729
  erroredProcesses = new Set;
2730
+ searchMatchCounts = new Map;
2528
2731
  constructor(renderer, names, colors) {
2529
2732
  this.originalNames = names;
2530
2733
  this.names = [...names];
@@ -2582,6 +2785,14 @@ class TabBar {
2582
2785
  this.erroredProcesses.delete(name);
2583
2786
  this.refreshOptions();
2584
2787
  }
2788
+ setSearchMatches(counts) {
2789
+ this.searchMatchCounts = counts;
2790
+ this.refreshOptions();
2791
+ }
2792
+ clearSearchMatches() {
2793
+ this.searchMatchCounts.clear();
2794
+ this.refreshOptions();
2795
+ }
2585
2796
  getNameAtIndex(index) {
2586
2797
  return this.names[index];
2587
2798
  }
@@ -2603,6 +2814,10 @@ class TabBar {
2603
2814
  this.updateOptionColors();
2604
2815
  }
2605
2816
  getDescription(name) {
2817
+ const matchCount = this.searchMatchCounts.get(name);
2818
+ if (matchCount != null && matchCount > 0) {
2819
+ return `${matchCount} match${matchCount === 1 ? "" : "es"}`;
2820
+ }
2606
2821
  if (this.inputWaiting.has(name))
2607
2822
  return "awaiting input";
2608
2823
  if (this.erroredProcesses.has(name))
@@ -2610,7 +2825,8 @@ class TabBar {
2610
2825
  return this.baseDescriptions.get(name) ?? "pending";
2611
2826
  }
2612
2827
  updateOptionColors() {
2613
- const resolved = resolveOptionColors(this.names, this.statuses, this.processColors, this.inputWaiting, this.erroredProcesses);
2828
+ const searchProcesses = this.searchMatchCounts.size > 0 ? new Set(this.searchMatchCounts.keys()) : undefined;
2829
+ const resolved = resolveOptionColors(this.names, this.statuses, this.processColors, this.inputWaiting, this.erroredProcesses, searchProcesses);
2614
2830
  const colors = resolved.map((c) => ({
2615
2831
  icon: parseColor(c.iconHex),
2616
2832
  name: c.nameHex ? parseColor(c.nameHex) : null
@@ -2635,6 +2851,7 @@ class App {
2635
2851
  panes = new Map;
2636
2852
  tabBar;
2637
2853
  statusBar;
2854
+ search;
2638
2855
  activePane = null;
2639
2856
  destroyed = false;
2640
2857
  names;
@@ -2644,11 +2861,6 @@ class App {
2644
2861
  config;
2645
2862
  logWriter;
2646
2863
  resizeTimer = null;
2647
- searchTimer = null;
2648
- searchMode = false;
2649
- searchQuery = "";
2650
- searchMatches = [];
2651
- searchIndex = -1;
2652
2864
  inputWaitTimers = new Map;
2653
2865
  awaitingInput = new Set;
2654
2866
  constructor(manager, config, logWriter) {
@@ -2698,6 +2910,14 @@ class App {
2698
2910
  flexGrow: 1,
2699
2911
  border: false
2700
2912
  });
2913
+ this.statusBar = new StatusBar(this.renderer);
2914
+ this.search = new SearchController({
2915
+ logWriter: this.logWriter,
2916
+ statusBar: this.statusBar,
2917
+ tabBar: this.tabBar,
2918
+ getActivePane: () => this.activePane,
2919
+ getPane: (name) => this.panes.get(name)
2920
+ });
2701
2921
  for (const name of this.names) {
2702
2922
  const interactive = this.config.processes[name].interactive === true;
2703
2923
  const pane = new Pane(this.renderer, name, termCols, termRows, interactive);
@@ -2710,14 +2930,13 @@ class App {
2710
2930
  this.statusBar.showTemporaryMessage(`Opening ${link.url}`);
2711
2931
  });
2712
2932
  pane.onScroll(() => {
2713
- if (this.searchMode && this.searchMatches.length > 0 && this.activePane === name) {
2714
- this.updateSearchHighlights();
2933
+ if (this.search.isActive && this.search.currentMatches.length > 0 && this.activePane === name) {
2934
+ this.search.refreshHighlights();
2715
2935
  }
2716
2936
  });
2717
2937
  this.panes.set(name, pane);
2718
2938
  paneContainer.add(pane.scrollBox);
2719
2939
  }
2720
- this.statusBar = new StatusBar(this.renderer);
2721
2940
  contentRow.add(sidebar);
2722
2941
  contentRow.add(paneContainer);
2723
2942
  layout.add(contentRow);
@@ -2759,8 +2978,8 @@ class App {
2759
2978
  this.renderer.keyInput.on("keypress", (key) => {
2760
2979
  log(key);
2761
2980
  if (key.ctrl && key.name === "c") {
2762
- if (this.searchMode) {
2763
- this.exitSearch();
2981
+ if (this.search.isActive) {
2982
+ this.search.exit();
2764
2983
  return;
2765
2984
  }
2766
2985
  this.shutdown().then(() => {
@@ -2768,8 +2987,8 @@ class App {
2768
2987
  });
2769
2988
  return;
2770
2989
  }
2771
- if (this.searchMode) {
2772
- this.handleSearchInput(key);
2990
+ if (this.search.isActive) {
2991
+ this.search.handleInput(key);
2773
2992
  return;
2774
2993
  }
2775
2994
  if (!this.activePane)
@@ -2794,7 +3013,7 @@ class App {
2794
3013
  return;
2795
3014
  }
2796
3015
  if (name === SHORTCUTS.search.key) {
2797
- this.enterSearch();
3016
+ this.search.enter();
2798
3017
  return;
2799
3018
  }
2800
3019
  if (name === SHORTCUTS.restart.key) {
@@ -2858,14 +3077,16 @@ class App {
2858
3077
  switchPane(name) {
2859
3078
  if (this.activePane === name)
2860
3079
  return;
2861
- if (this.searchMode) {
2862
- this.exitSearch();
3080
+ if (this.search.isActive && !this.search.isAllMode) {
3081
+ this.search.exit();
2863
3082
  }
2864
3083
  if (this.activePane) {
3084
+ this.panes.get(this.activePane)?.clearHighlights();
2865
3085
  this.panes.get(this.activePane)?.hide();
2866
3086
  }
2867
3087
  this.activePane = name;
2868
3088
  this.panes.get(name)?.show();
3089
+ this.search.onPaneSwitch();
2869
3090
  }
2870
3091
  checkInputWaiting(name, data) {
2871
3092
  const existing = this.inputWaitTimers.get(name);
@@ -2926,102 +3147,6 @@ class App {
2926
3147
  this.copyToClipboard(text);
2927
3148
  this.statusBar.showTemporaryMessage("Copied all output!");
2928
3149
  }
2929
- enterSearch() {
2930
- this.searchMode = true;
2931
- this.searchQuery = "";
2932
- this.searchMatches = [];
2933
- this.searchIndex = -1;
2934
- this.statusBar.setSearchMode(true);
2935
- }
2936
- exitSearch() {
2937
- this.searchMode = false;
2938
- this.searchQuery = "";
2939
- this.searchMatches = [];
2940
- this.searchIndex = -1;
2941
- if (this.searchTimer) {
2942
- clearTimeout(this.searchTimer);
2943
- this.searchTimer = null;
2944
- }
2945
- if (this.activePane) {
2946
- this.panes.get(this.activePane)?.clearHighlights();
2947
- }
2948
- this.statusBar.setSearchMode(false);
2949
- }
2950
- handleSearchInput(key) {
2951
- if (key.name === "escape") {
2952
- this.exitSearch();
2953
- return;
2954
- }
2955
- if (key.name === "return") {
2956
- if (this.searchMatches.length === 0)
2957
- return;
2958
- if (key.shift) {
2959
- this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
2960
- } else {
2961
- this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length;
2962
- }
2963
- this.scrollToCurrentMatch();
2964
- this.updateSearchHighlights();
2965
- return;
2966
- }
2967
- if (key.name === "backspace") {
2968
- if (this.searchQuery.length > 0) {
2969
- this.searchQuery = this.searchQuery.slice(0, -1);
2970
- this.scheduleSearch();
2971
- }
2972
- return;
2973
- }
2974
- if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
2975
- this.searchQuery += key.sequence;
2976
- this.scheduleSearch();
2977
- }
2978
- }
2979
- scheduleSearch() {
2980
- this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex);
2981
- if (this.searchTimer)
2982
- clearTimeout(this.searchTimer);
2983
- this.searchTimer = setTimeout(() => {
2984
- this.searchTimer = null;
2985
- this.runSearch();
2986
- }, 100);
2987
- }
2988
- async runSearch() {
2989
- if (!this.activePane)
2990
- return;
2991
- const query = this.searchQuery;
2992
- const activeName = this.activePane;
2993
- const matches = await this.logWriter.search(activeName, query);
2994
- if (!this.searchMode || this.searchQuery !== query || this.activePane !== activeName)
2995
- return;
2996
- this.searchMatches = matches;
2997
- this.searchIndex = matches.length > 0 ? 0 : -1;
2998
- this.updateSearchHighlights();
2999
- if (this.searchIndex >= 0) {
3000
- this.scrollToCurrentMatch();
3001
- }
3002
- }
3003
- updateSearchHighlights() {
3004
- if (!this.activePane)
3005
- return;
3006
- const pane = this.panes.get(this.activePane);
3007
- if (!pane)
3008
- return;
3009
- if (this.searchMatches.length > 0) {
3010
- pane.setHighlights(this.searchMatches, this.searchIndex);
3011
- } else {
3012
- pane.clearHighlights();
3013
- }
3014
- this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex);
3015
- }
3016
- scrollToCurrentMatch() {
3017
- if (!this.activePane || this.searchIndex < 0)
3018
- return;
3019
- const pane = this.panes.get(this.activePane);
3020
- if (!pane)
3021
- return;
3022
- const match = this.searchMatches[this.searchIndex];
3023
- pane.scrollToLine(match.line);
3024
- }
3025
3150
  async shutdown() {
3026
3151
  if (this.destroyed)
3027
3152
  return;
@@ -3030,10 +3155,7 @@ class App {
3030
3155
  clearTimeout(this.resizeTimer);
3031
3156
  this.resizeTimer = null;
3032
3157
  }
3033
- if (this.searchTimer) {
3034
- clearTimeout(this.searchTimer);
3035
- this.searchTimer = null;
3036
- }
3158
+ this.search.dispose();
3037
3159
  for (const timer of this.inputWaitTimers.values()) {
3038
3160
  clearTimeout(timer);
3039
3161
  }
@@ -3237,9 +3359,9 @@ ${DIM}Done in ${elapsed}${RESET}
3237
3359
  }
3238
3360
 
3239
3361
  // src/utils/log-writer.ts
3240
- import { closeSync, mkdirSync as mkdirSync2, openSync, rmSync, writeSync } from "fs";
3362
+ import { closeSync, mkdirSync as mkdirSync2, openSync, rmSync, symlinkSync, unlinkSync, writeSync } from "fs";
3241
3363
  import { tmpdir } from "os";
3242
- import { join } from "path";
3364
+ import { basename as basename2, join } from "path";
3243
3365
  class LogWriter {
3244
3366
  dir;
3245
3367
  isTemp;
@@ -3255,6 +3377,30 @@ class LogWriter {
3255
3377
  const dir = join(tmpdir(), `numux-${process.pid}`);
3256
3378
  return new LogWriter(dir, true);
3257
3379
  }
3380
+ static createPersistent(baseDir) {
3381
+ mkdirSync2(baseDir, { recursive: true });
3382
+ const now = new Date;
3383
+ const ts = now.toISOString().replace(/:/g, "-").replace(/\.\d+Z$/, "");
3384
+ const sessionDir = join(baseDir, ts);
3385
+ mkdirSync2(sessionDir, { recursive: true });
3386
+ const latestLink = join(baseDir, "latest");
3387
+ try {
3388
+ unlinkSync(latestLink);
3389
+ } catch {}
3390
+ try {
3391
+ symlinkSync(sessionDir, latestLink);
3392
+ } catch {}
3393
+ return new LogWriter(sessionDir, false);
3394
+ }
3395
+ get isTemporary() {
3396
+ return this.isTemp;
3397
+ }
3398
+ getDirectory() {
3399
+ return this.dir;
3400
+ }
3401
+ getProcessNames() {
3402
+ return [...this.files.keys()];
3403
+ }
3258
3404
  errored = false;
3259
3405
  handleEvent = (event) => {
3260
3406
  if (event.type !== "output" || this.errored)
@@ -3326,6 +3472,58 @@ class LogWriter {
3326
3472
  return [];
3327
3473
  }
3328
3474
  }
3475
+ async searchAll(query) {
3476
+ if (!query)
3477
+ return [];
3478
+ const paths = [...this.files.keys()].map((name) => join(this.dir, `${name}.log`));
3479
+ if (paths.length === 0)
3480
+ return [];
3481
+ try {
3482
+ const proc = Bun.spawn(["grep", "-inFH", query, ...paths], {
3483
+ stdout: "pipe",
3484
+ stderr: "ignore"
3485
+ });
3486
+ const output = await new Response(proc.stdout).text();
3487
+ await proc.exited;
3488
+ const matches = [];
3489
+ const lowerQuery = query.toLowerCase();
3490
+ for (const line of output.split(`
3491
+ `)) {
3492
+ if (!line)
3493
+ continue;
3494
+ const firstColon = line.indexOf(":");
3495
+ if (firstColon === -1)
3496
+ continue;
3497
+ const filePath = line.slice(0, firstColon);
3498
+ const rest = line.slice(firstColon + 1);
3499
+ const secondColon = rest.indexOf(":");
3500
+ if (secondColon === -1)
3501
+ continue;
3502
+ const lineNumber = Number.parseInt(rest.slice(0, secondColon), 10);
3503
+ if (Number.isNaN(lineNumber))
3504
+ continue;
3505
+ const fileName = basename2(filePath);
3506
+ const processName = fileName.replace(/\.log$/, "");
3507
+ const lineText = rest.slice(secondColon + 1).toLowerCase();
3508
+ let pos = 0;
3509
+ while (true) {
3510
+ const idx = lineText.indexOf(lowerQuery, pos);
3511
+ if (idx === -1)
3512
+ break;
3513
+ matches.push({
3514
+ process: processName,
3515
+ line: lineNumber - 1,
3516
+ start: idx,
3517
+ end: idx + query.length
3518
+ });
3519
+ pos = idx + 1;
3520
+ }
3521
+ }
3522
+ return matches;
3523
+ } catch {
3524
+ return [];
3525
+ }
3526
+ }
3329
3527
  truncate(name) {
3330
3528
  const fd = this.files.get(name);
3331
3529
  if (fd === undefined)
@@ -3362,6 +3560,10 @@ function setupShutdownHandlers(app, logWriter) {
3362
3560
  }
3363
3561
  shuttingDown = true;
3364
3562
  app.shutdown().finally(() => {
3563
+ if (logWriter && !logWriter.isTemporary) {
3564
+ process.stderr.write(`Logs saved to: ${logWriter.getDirectory()}
3565
+ `);
3566
+ }
3365
3567
  logWriter?.cleanup();
3366
3568
  process.exit(app.hasFailures() ? 1 : 0);
3367
3569
  });
@@ -3591,7 +3793,7 @@ async function main() {
3591
3793
  }
3592
3794
  const manager = new ProcessManager(config);
3593
3795
  const logDir = parsed.logDir ?? config.logDir;
3594
- const logWriter = logDir ? new LogWriter(logDir) : LogWriter.createTemp();
3796
+ const logWriter = logDir ? LogWriter.createPersistent(logDir) : LogWriter.createTemp();
3595
3797
  printWarnings(warnings);
3596
3798
  const usePrefix = parsed.prefix || config.prefix;
3597
3799
  if (usePrefix) {
package/dist/types.d.ts CHANGED
@@ -80,6 +80,12 @@ export interface NumuxConfig<K extends string = string> {
80
80
  maxRestarts?: number;
81
81
  /** Global ready timeout (ms), inherited by all processes */
82
82
  readyTimeout?: number;
83
+ /**
84
+ * Set to `false` to make all processes non-persistent (one-shot) by default.
85
+ * Individual processes can still override with their own `persistent` value.
86
+ * @default true
87
+ */
88
+ persistent?: boolean;
83
89
  /**
84
90
  * Global stop signal, inherited by all processes
85
91
  * @default 'SIGTERM'
@@ -155,3 +161,10 @@ export type ProcessEvent = {
155
161
  type: 'error';
156
162
  name: string;
157
163
  };
164
+ export interface KeyEvent {
165
+ ctrl: boolean;
166
+ shift: boolean;
167
+ meta: boolean;
168
+ name: string;
169
+ sequence: string;
170
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "1.23.2",
3
+ "version": "1.25.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",