numux 1.23.2 → 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.2",
39
+ version: "1.24.0",
40
40
  description: "Terminal multiplexer with dependency orchestration",
41
41
  type: "module",
42
42
  license: "MIT",
@@ -2320,6 +2320,197 @@ class Pane {
2320
2320
  }
2321
2321
  }
2322
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
+
2323
2514
  // src/ui/status-bar.ts
2324
2515
  import { cyan, red, reverse, StyledText, TextRenderable, yellow } from "@opentui/core";
2325
2516
  function plain(text) {
@@ -2332,6 +2523,7 @@ class StatusBar {
2332
2523
  _searchQuery = "";
2333
2524
  _searchMatchCount = 0;
2334
2525
  _searchCurrentIndex = -1;
2526
+ _crossProcessInfo;
2335
2527
  _tempMessage = null;
2336
2528
  _tempTimer = null;
2337
2529
  constructor(renderer) {
@@ -2344,11 +2536,12 @@ class StatusBar {
2344
2536
  paddingX: 1
2345
2537
  });
2346
2538
  }
2347
- setSearchMode(active, query = "", matchCount = 0, currentIndex = -1) {
2539
+ setSearchMode(active, query = "", matchCount = 0, currentIndex = -1, crossProcessInfo) {
2348
2540
  this._searchMode = active;
2349
2541
  this._searchQuery = query;
2350
2542
  this._searchMatchCount = matchCount;
2351
2543
  this._searchCurrentIndex = currentIndex;
2544
+ this._crossProcessInfo = crossProcessInfo;
2352
2545
  this.renderable.content = this.buildContent();
2353
2546
  }
2354
2547
  showTemporaryMessage(message, duration = 2000) {
@@ -2373,6 +2566,7 @@ class StatusBar {
2373
2566
  }
2374
2567
  buildSearchContent() {
2375
2568
  const chunks = [];
2569
+ const isAllMode = !!this._crossProcessInfo;
2376
2570
  chunks.push(yellow("/"));
2377
2571
  if (this._searchQuery)
2378
2572
  chunks.push(plain(this._searchQuery));
@@ -2380,14 +2574,20 @@ class StatusBar {
2380
2574
  if (this._searchMatchCount === 0 && this._searchQuery) {
2381
2575
  chunks.push(plain(" "));
2382
2576
  chunks.push(red("no matches"));
2383
- chunks.push(plain(" Esc: close"));
2384
2577
  } else if (this._searchMatchCount > 0) {
2385
2578
  chunks.push(plain(" "));
2386
- chunks.push(cyan(`${this._searchCurrentIndex + 1}/${this._searchMatchCount}`));
2387
- 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"));
2388
2587
  } else {
2389
- chunks.push(plain(" Enter: next Esc: close"));
2588
+ chunks.push(plain(" Enter: next"));
2390
2589
  }
2590
+ chunks.push(plain(` Tab: ${isAllMode ? "single" : "all"} Esc: close`));
2391
2591
  return new StyledText(chunks);
2392
2592
  }
2393
2593
  }
@@ -2435,12 +2635,13 @@ function getDisplayOrder(originalNames, statuses) {
2435
2635
  const terminal = originalNames.filter((n) => TERMINAL_STATUSES.has(statuses.get(n)));
2436
2636
  return [...active, ...terminal];
2437
2637
  }
2438
- function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses) {
2638
+ function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses, searchMatchProcesses) {
2439
2639
  return names.map((name) => {
2440
2640
  const status = statuses.get(name);
2441
2641
  const waiting = inputWaiting.has(name);
2442
2642
  const errored = erroredProcesses.has(name);
2443
- 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];
2444
2645
  const processHex = processColors.get(name);
2445
2646
  return {
2446
2647
  iconHex: statusHex ?? processHex ?? "#888888",
@@ -2525,6 +2726,7 @@ class TabBar {
2525
2726
  processColors;
2526
2727
  inputWaiting = new Set;
2527
2728
  erroredProcesses = new Set;
2729
+ searchMatchCounts = new Map;
2528
2730
  constructor(renderer, names, colors) {
2529
2731
  this.originalNames = names;
2530
2732
  this.names = [...names];
@@ -2582,6 +2784,14 @@ class TabBar {
2582
2784
  this.erroredProcesses.delete(name);
2583
2785
  this.refreshOptions();
2584
2786
  }
2787
+ setSearchMatches(counts) {
2788
+ this.searchMatchCounts = counts;
2789
+ this.refreshOptions();
2790
+ }
2791
+ clearSearchMatches() {
2792
+ this.searchMatchCounts.clear();
2793
+ this.refreshOptions();
2794
+ }
2585
2795
  getNameAtIndex(index) {
2586
2796
  return this.names[index];
2587
2797
  }
@@ -2603,6 +2813,10 @@ class TabBar {
2603
2813
  this.updateOptionColors();
2604
2814
  }
2605
2815
  getDescription(name) {
2816
+ const matchCount = this.searchMatchCounts.get(name);
2817
+ if (matchCount != null && matchCount > 0) {
2818
+ return `${matchCount} match${matchCount === 1 ? "" : "es"}`;
2819
+ }
2606
2820
  if (this.inputWaiting.has(name))
2607
2821
  return "awaiting input";
2608
2822
  if (this.erroredProcesses.has(name))
@@ -2610,7 +2824,8 @@ class TabBar {
2610
2824
  return this.baseDescriptions.get(name) ?? "pending";
2611
2825
  }
2612
2826
  updateOptionColors() {
2613
- 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);
2614
2829
  const colors = resolved.map((c) => ({
2615
2830
  icon: parseColor(c.iconHex),
2616
2831
  name: c.nameHex ? parseColor(c.nameHex) : null
@@ -2635,6 +2850,7 @@ class App {
2635
2850
  panes = new Map;
2636
2851
  tabBar;
2637
2852
  statusBar;
2853
+ search;
2638
2854
  activePane = null;
2639
2855
  destroyed = false;
2640
2856
  names;
@@ -2644,11 +2860,6 @@ class App {
2644
2860
  config;
2645
2861
  logWriter;
2646
2862
  resizeTimer = null;
2647
- searchTimer = null;
2648
- searchMode = false;
2649
- searchQuery = "";
2650
- searchMatches = [];
2651
- searchIndex = -1;
2652
2863
  inputWaitTimers = new Map;
2653
2864
  awaitingInput = new Set;
2654
2865
  constructor(manager, config, logWriter) {
@@ -2698,6 +2909,14 @@ class App {
2698
2909
  flexGrow: 1,
2699
2910
  border: false
2700
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
+ });
2701
2920
  for (const name of this.names) {
2702
2921
  const interactive = this.config.processes[name].interactive === true;
2703
2922
  const pane = new Pane(this.renderer, name, termCols, termRows, interactive);
@@ -2710,14 +2929,13 @@ class App {
2710
2929
  this.statusBar.showTemporaryMessage(`Opening ${link.url}`);
2711
2930
  });
2712
2931
  pane.onScroll(() => {
2713
- if (this.searchMode && this.searchMatches.length > 0 && this.activePane === name) {
2714
- this.updateSearchHighlights();
2932
+ if (this.search.isActive && this.search.currentMatches.length > 0 && this.activePane === name) {
2933
+ this.search.refreshHighlights();
2715
2934
  }
2716
2935
  });
2717
2936
  this.panes.set(name, pane);
2718
2937
  paneContainer.add(pane.scrollBox);
2719
2938
  }
2720
- this.statusBar = new StatusBar(this.renderer);
2721
2939
  contentRow.add(sidebar);
2722
2940
  contentRow.add(paneContainer);
2723
2941
  layout.add(contentRow);
@@ -2759,8 +2977,8 @@ class App {
2759
2977
  this.renderer.keyInput.on("keypress", (key) => {
2760
2978
  log(key);
2761
2979
  if (key.ctrl && key.name === "c") {
2762
- if (this.searchMode) {
2763
- this.exitSearch();
2980
+ if (this.search.isActive) {
2981
+ this.search.exit();
2764
2982
  return;
2765
2983
  }
2766
2984
  this.shutdown().then(() => {
@@ -2768,8 +2986,8 @@ class App {
2768
2986
  });
2769
2987
  return;
2770
2988
  }
2771
- if (this.searchMode) {
2772
- this.handleSearchInput(key);
2989
+ if (this.search.isActive) {
2990
+ this.search.handleInput(key);
2773
2991
  return;
2774
2992
  }
2775
2993
  if (!this.activePane)
@@ -2794,7 +3012,7 @@ class App {
2794
3012
  return;
2795
3013
  }
2796
3014
  if (name === SHORTCUTS.search.key) {
2797
- this.enterSearch();
3015
+ this.search.enter();
2798
3016
  return;
2799
3017
  }
2800
3018
  if (name === SHORTCUTS.restart.key) {
@@ -2858,14 +3076,16 @@ class App {
2858
3076
  switchPane(name) {
2859
3077
  if (this.activePane === name)
2860
3078
  return;
2861
- if (this.searchMode) {
2862
- this.exitSearch();
3079
+ if (this.search.isActive && !this.search.isAllMode) {
3080
+ this.search.exit();
2863
3081
  }
2864
3082
  if (this.activePane) {
3083
+ this.panes.get(this.activePane)?.clearHighlights();
2865
3084
  this.panes.get(this.activePane)?.hide();
2866
3085
  }
2867
3086
  this.activePane = name;
2868
3087
  this.panes.get(name)?.show();
3088
+ this.search.onPaneSwitch();
2869
3089
  }
2870
3090
  checkInputWaiting(name, data) {
2871
3091
  const existing = this.inputWaitTimers.get(name);
@@ -2926,102 +3146,6 @@ class App {
2926
3146
  this.copyToClipboard(text);
2927
3147
  this.statusBar.showTemporaryMessage("Copied all output!");
2928
3148
  }
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
3149
  async shutdown() {
3026
3150
  if (this.destroyed)
3027
3151
  return;
@@ -3030,10 +3154,7 @@ class App {
3030
3154
  clearTimeout(this.resizeTimer);
3031
3155
  this.resizeTimer = null;
3032
3156
  }
3033
- if (this.searchTimer) {
3034
- clearTimeout(this.searchTimer);
3035
- this.searchTimer = null;
3036
- }
3157
+ this.search.dispose();
3037
3158
  for (const timer of this.inputWaitTimers.values()) {
3038
3159
  clearTimeout(timer);
3039
3160
  }
@@ -3237,9 +3358,9 @@ ${DIM}Done in ${elapsed}${RESET}
3237
3358
  }
3238
3359
 
3239
3360
  // src/utils/log-writer.ts
3240
- import { closeSync, mkdirSync as mkdirSync2, openSync, rmSync, writeSync } from "fs";
3361
+ import { closeSync, mkdirSync as mkdirSync2, openSync, rmSync, symlinkSync, unlinkSync, writeSync } from "fs";
3241
3362
  import { tmpdir } from "os";
3242
- import { join } from "path";
3363
+ import { basename as basename2, join } from "path";
3243
3364
  class LogWriter {
3244
3365
  dir;
3245
3366
  isTemp;
@@ -3255,6 +3376,30 @@ class LogWriter {
3255
3376
  const dir = join(tmpdir(), `numux-${process.pid}`);
3256
3377
  return new LogWriter(dir, true);
3257
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
+ }
3258
3403
  errored = false;
3259
3404
  handleEvent = (event) => {
3260
3405
  if (event.type !== "output" || this.errored)
@@ -3326,6 +3471,58 @@ class LogWriter {
3326
3471
  return [];
3327
3472
  }
3328
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
+ }
3329
3526
  truncate(name) {
3330
3527
  const fd = this.files.get(name);
3331
3528
  if (fd === undefined)
@@ -3362,6 +3559,10 @@ function setupShutdownHandlers(app, logWriter) {
3362
3559
  }
3363
3560
  shuttingDown = true;
3364
3561
  app.shutdown().finally(() => {
3562
+ if (logWriter && !logWriter.isTemporary) {
3563
+ process.stderr.write(`Logs saved to: ${logWriter.getDirectory()}
3564
+ `);
3565
+ }
3365
3566
  logWriter?.cleanup();
3366
3567
  process.exit(app.hasFailures() ? 1 : 0);
3367
3568
  });
@@ -3591,7 +3792,7 @@ async function main() {
3591
3792
  }
3592
3793
  const manager = new ProcessManager(config);
3593
3794
  const logDir = parsed.logDir ?? config.logDir;
3594
- const logWriter = logDir ? new LogWriter(logDir) : LogWriter.createTemp();
3795
+ const logWriter = logDir ? LogWriter.createPersistent(logDir) : LogWriter.createTemp();
3595
3796
  printWarnings(warnings);
3596
3797
  const usePrefix = parsed.prefix || config.prefix;
3597
3798
  if (usePrefix) {
package/dist/types.d.ts CHANGED
@@ -155,3 +155,10 @@ export type ProcessEvent = {
155
155
  type: 'error';
156
156
  name: string;
157
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.2",
3
+ "version": "1.24.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",