numux 2.16.1 → 2.17.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
@@ -228,6 +228,7 @@ export default defineConfig({
228
228
  | `--no-watch` | Disable file watching even if config has watch patterns |
229
229
  | `-t,` `--timestamps` `[<format>]` | Add timestamps to output (default HH:mm:ss.SSS, or pass a format string) |
230
230
  | `--log-dir` `<path>` | Write per-process logs to directory |
231
+ | `--theme` `<light|dark|auto>` | TUI theme (auto detects terminal background) |
231
232
  | `--debug` | Enable debug logging to .numux/debug.log |
232
233
  | `-h,` `--help` | Show this help |
233
234
  | `-v,` `--version` | Show version |
@@ -284,6 +285,7 @@ Top-level options apply to all processes (process-level settings override):
284
285
  | `killOthersOnFail` | `boolean` | Kill all processes when any one exits with a non-zero exit code |
285
286
  | `noWatch` | `boolean` | Disable file watching even if processes have watch patterns |
286
287
  | `logDir` | `string` | Directory to write per-process log files |
288
+ | `theme` | `ThemePref` | TUI color theme. `'auto'` detects the terminal background via OSC 11 (falling back to `COLORFGBG` then dark). `'light'`/`'dark'` skip detection. |
287
289
  <!-- /generated:config-global -->
288
290
 
289
291
  ```ts
package/dist/man/numux.1 CHANGED
@@ -1,4 +1,4 @@
1
- .TH "NUMUX" "1" "May 2026" "2.16.1" "numux manual"
1
+ .TH "NUMUX" "1" "May 2026" "2.17.0" "numux manual"
2
2
  .SH "NAME"
3
3
  \fBnumux\fR
4
4
  .P
@@ -349,6 +349,12 @@ Write per\-process logs to directory
349
349
  T}
350
350
  _
351
351
  T{
352
+ \fB\-\-theme\fP `<light
353
+ T}|T{
354
+ dark
355
+ T}
356
+ _
357
+ T{
352
358
  \fB\-\-debug\fP
353
359
  T}|T{
354
360
  Enable debug logging to \.numux/debug\.log
@@ -538,6 +544,14 @@ T}|T{
538
544
  T}|T{
539
545
  Directory to write per\-process log files
540
546
  T}
547
+ _
548
+ T{
549
+ \fBtheme\fP
550
+ T}|T{
551
+ \fBThemePref\fP
552
+ T}|T{
553
+ TUI color theme\. \fB&#39;auto&#39;\fP detects the terminal background via OSC 11 (falling back to \fBCOLORFGBG\fP then dark)\. \fB&#39;light&#39;\fP/\fB&#39;dark&#39;\fP skip detection\.
554
+ T}
541
555
  .TE
542
556
  <!\-\- /generated:config\-global \-\->
543
557
 
package/dist/numux.js CHANGED
@@ -246,6 +246,7 @@ export default defineConfig({
246
246
  | \`--no-watch\` | Disable file watching even if config has watch patterns |
247
247
  | \`-t,\` \`--timestamps\` \`[<format>]\` | Add timestamps to output (default HH:mm:ss.SSS, or pass a format string) |
248
248
  | \`--log-dir\` \`<path>\` | Write per-process logs to directory |
249
+ | \`--theme\` \`<light|dark|auto>\` | TUI theme (auto detects terminal background) |
249
250
  | \`--debug\` | Enable debug logging to .numux/debug.log |
250
251
  | \`-h,\` \`--help\` | Show this help |
251
252
  | \`-v,\` \`--version\` | Show version |
@@ -290,6 +291,7 @@ numux logs api | tail -f # Follow process log output
290
291
  | \`killOthersOnFail\` | \`boolean\` | Kill all processes when any one exits with a non-zero exit code |
291
292
  | \`noWatch\` | \`boolean\` | Disable file watching even if processes have watch patterns |
292
293
  | \`logDir\` | \`string\` | Directory to write per-process log files |
294
+ | \`theme\` | \`ThemePref\` | TUI color theme. \`'auto'\` detects the terminal background via OSC 11 (falling back to \`COLORFGBG\` then dark). \`'light'\`/\`'dark'\` skip detection. |
293
295
  <!-- /generated:config-global -->
294
296
 
295
297
  \`\`\`ts
@@ -548,7 +550,7 @@ var init_help = __esm(() => {
548
550
  var require_package = __commonJS((exports, module) => {
549
551
  module.exports = {
550
552
  name: "numux",
551
- version: "2.16.1",
553
+ version: "2.17.0",
552
554
  description: "Terminal multiplexer with dependency orchestration",
553
555
  type: "module",
554
556
  license: "MIT",
@@ -876,6 +878,20 @@ var FLAGS = [
876
878
  valueName: "<path>",
877
879
  completionHint: "directory"
878
880
  },
881
+ {
882
+ type: "value",
883
+ long: "--theme",
884
+ key: "theme",
885
+ description: "TUI theme (auto detects terminal background)",
886
+ valueName: "<light|dark|auto>",
887
+ completionHint: "none",
888
+ parse(raw, flag) {
889
+ if (raw !== "light" && raw !== "dark" && raw !== "auto") {
890
+ throw new Error(`${flag} must be light, dark, or auto. Got "${raw}"`);
891
+ }
892
+ return raw;
893
+ }
894
+ },
879
895
  {
880
896
  type: "boolean",
881
897
  long: "--debug",
@@ -1704,10 +1720,11 @@ function resolveColor(color) {
1704
1720
  return color[0];
1705
1721
  return;
1706
1722
  }
1707
- function buildProcessColorMap(names, config) {
1723
+ function buildProcessColorMap(names, config, palette = DEFAULT_PALETTE) {
1708
1724
  const map = new Map;
1709
1725
  if ("NO_COLOR" in process.env)
1710
1726
  return map;
1727
+ const ansiPalette = palette === DEFAULT_PALETTE ? DEFAULT_ANSI_COLORS : palette.map(hexToAnsi);
1711
1728
  let paletteIndex = 0;
1712
1729
  for (const name of names) {
1713
1730
  const explicit = resolveColor(config.processes[name]?.color);
@@ -1716,15 +1733,15 @@ function buildProcessColorMap(names, config) {
1716
1733
  if (hex)
1717
1734
  map.set(name, hexToAnsi(hex));
1718
1735
  else
1719
- map.set(name, DEFAULT_ANSI_COLORS[paletteIndex++ % DEFAULT_ANSI_COLORS.length]);
1736
+ map.set(name, ansiPalette[paletteIndex++ % ansiPalette.length]);
1720
1737
  } else {
1721
- map.set(name, DEFAULT_ANSI_COLORS[paletteIndex % DEFAULT_ANSI_COLORS.length]);
1738
+ map.set(name, ansiPalette[paletteIndex % ansiPalette.length]);
1722
1739
  paletteIndex++;
1723
1740
  }
1724
1741
  }
1725
1742
  return map;
1726
1743
  }
1727
- function buildProcessHexColorMap(names, config) {
1744
+ function buildProcessHexColorMap(names, config, palette = DEFAULT_PALETTE) {
1728
1745
  const map = new Map;
1729
1746
  if ("NO_COLOR" in process.env)
1730
1747
  return map;
@@ -1736,9 +1753,9 @@ function buildProcessHexColorMap(names, config) {
1736
1753
  if (hex)
1737
1754
  map.set(name, hex);
1738
1755
  else
1739
- map.set(name, DEFAULT_PALETTE[paletteIndex++ % DEFAULT_PALETTE.length]);
1756
+ map.set(name, palette[paletteIndex++ % palette.length]);
1740
1757
  } else {
1741
- map.set(name, DEFAULT_PALETTE[paletteIndex % DEFAULT_PALETTE.length]);
1758
+ map.set(name, palette[paletteIndex % palette.length]);
1742
1759
  paletteIndex++;
1743
1760
  }
1744
1761
  }
@@ -1783,6 +1800,7 @@ function validateConfig(raw, _warnings) {
1783
1800
  const killOthersOnFail = config.killOthersOnFail === true ? true : undefined;
1784
1801
  const noWatch = config.noWatch === true ? true : undefined;
1785
1802
  const logDir = typeof config.logDir === "string" && config.logDir.trim() ? config.logDir.trim() : undefined;
1803
+ const theme = validateTheme(config.theme);
1786
1804
  const validated = {};
1787
1805
  for (const name of names) {
1788
1806
  let proc = processes[name];
@@ -1881,9 +1899,19 @@ function validateConfig(raw, _warnings) {
1881
1899
  ...killOthersOnFail ? { killOthersOnFail } : {},
1882
1900
  ...noWatch ? { noWatch } : {},
1883
1901
  ...logDir ? { logDir } : {},
1902
+ ...theme ? { theme } : {},
1884
1903
  processes: validated
1885
1904
  };
1886
1905
  }
1906
+ var VALID_THEME_VALUES = new Set(["light", "dark", "auto"]);
1907
+ function validateTheme(value) {
1908
+ if (value === undefined)
1909
+ return;
1910
+ if (typeof value !== "string" || !VALID_THEME_VALUES.has(value)) {
1911
+ throw new Error(`theme must be one of: light, dark, auto. Got "${String(value)}"`);
1912
+ }
1913
+ return value;
1914
+ }
1887
1915
  function validateStringOrStringArray(value) {
1888
1916
  if (typeof value === "string")
1889
1917
  return value;
@@ -2949,6 +2977,178 @@ function setupShutdownHandlers(app, logWriter) {
2949
2977
  });
2950
2978
  }
2951
2979
 
2980
+ // src/utils/theme.ts
2981
+ var DARK_THEME = {
2982
+ mode: "dark",
2983
+ statusBarBg: "#1a1a1a",
2984
+ statusBarText: "#cccccc",
2985
+ helpBackdropBg: "#000000",
2986
+ helpBoxBg: "#1a1a2e",
2987
+ helpBorder: "#444444",
2988
+ helpText: "#cccccc",
2989
+ sidebarBg: "#1a1a1a",
2990
+ sidebarBorder: "#444444",
2991
+ tabSelectedBg: "#334455",
2992
+ tabSelectedText: "#ffffff",
2993
+ tabText: "#888888",
2994
+ tabDescriptionText: "#888888",
2995
+ tabSelectedDescriptionText: "#cccccc",
2996
+ scrollTrackBg: "#252527",
2997
+ scrollThumbBg: "#9a9ea3",
2998
+ searchCurrentBg: "#b58900",
2999
+ searchMatchBg: "#073642",
3000
+ palette: ["#00cccc", "#cccc00", "#cc00cc", "#5577ff", "#00cc00", "#ff5555", "#ffa500", "#cc88ff"],
3001
+ status: {
3002
+ ready: "#00cc00",
3003
+ failed: "#ff5555",
3004
+ stopped: "#888888",
3005
+ finished: "#66aa66",
3006
+ skipped: "#888888"
3007
+ },
3008
+ inputWaiting: "#ffaa00",
3009
+ errorIndicator: "#ff5555",
3010
+ searchMatchTab: "#b58900",
3011
+ iconDefault: "#888888"
3012
+ };
3013
+ var LIGHT_THEME = {
3014
+ mode: "light",
3015
+ statusBarBg: "#e8e8e8",
3016
+ statusBarText: "#000000",
3017
+ helpBackdropBg: "#ffffff",
3018
+ helpBoxBg: "#f5f5f5",
3019
+ helpBorder: "#aaaaaa",
3020
+ helpText: "#1a1a1a",
3021
+ sidebarBg: "#f0f0f0",
3022
+ sidebarBorder: "#aaaaaa",
3023
+ tabSelectedBg: "#7a9bbf",
3024
+ tabSelectedText: "#ffffff",
3025
+ tabText: "#444444",
3026
+ tabDescriptionText: "#666666",
3027
+ tabSelectedDescriptionText: "#e8e8e8",
3028
+ scrollTrackBg: "#d0d0d0",
3029
+ scrollThumbBg: "#888888",
3030
+ searchCurrentBg: "#ffaa33",
3031
+ searchMatchBg: "#d0e4b8",
3032
+ palette: ["#008888", "#886600", "#880088", "#0033aa", "#006600", "#aa0000", "#cc5500", "#6622aa"],
3033
+ status: {
3034
+ ready: "#006600",
3035
+ failed: "#aa0000",
3036
+ stopped: "#666666",
3037
+ finished: "#2a7a2a",
3038
+ skipped: "#666666"
3039
+ },
3040
+ inputWaiting: "#cc7a00",
3041
+ errorIndicator: "#aa0000",
3042
+ searchMatchTab: "#cc7a00",
3043
+ iconDefault: "#666666"
3044
+ };
3045
+ function themeFor(mode) {
3046
+ return mode === "light" ? LIGHT_THEME : DARK_THEME;
3047
+ }
3048
+ function relativeLuminance(r, g, b) {
3049
+ const norm = (c) => {
3050
+ const s = c / 255;
3051
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
3052
+ };
3053
+ return 0.2126 * norm(r) + 0.7152 * norm(g) + 0.0722 * norm(b);
3054
+ }
3055
+ function isLightRgb(r, g, b) {
3056
+ return relativeLuminance(r, g, b) > 0.5;
3057
+ }
3058
+ function parseOSC11Response(data) {
3059
+ const match = data.match(/rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i);
3060
+ if (!match)
3061
+ return null;
3062
+ const scale = (hex) => {
3063
+ if (hex.length === 0 || hex.length > 4)
3064
+ return Number.NaN;
3065
+ const val = Number.parseInt(hex, 16);
3066
+ if (Number.isNaN(val))
3067
+ return Number.NaN;
3068
+ const max = 16 ** hex.length - 1;
3069
+ return Math.round(val / max * 255);
3070
+ };
3071
+ const r = scale(match[1]);
3072
+ const g = scale(match[2]);
3073
+ const b = scale(match[3]);
3074
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b))
3075
+ return null;
3076
+ return { r, g, b };
3077
+ }
3078
+ function parseColorFgBg(value) {
3079
+ if (!value)
3080
+ return null;
3081
+ const parts = value.split(";");
3082
+ if (parts.length < 2)
3083
+ return null;
3084
+ const bgRaw = parts[parts.length - 1].trim();
3085
+ const bg = Number.parseInt(bgRaw, 10);
3086
+ if (Number.isNaN(bg))
3087
+ return null;
3088
+ return bg >= 7 && bg <= 15 ? "light" : "dark";
3089
+ }
3090
+ function queryOSC11(timeoutMs = 100) {
3091
+ const stdin = process.stdin;
3092
+ const stdout = process.stdout;
3093
+ if (!(stdin.isTTY && stdout.isTTY))
3094
+ return Promise.resolve(null);
3095
+ return new Promise((resolve8) => {
3096
+ let settled = false;
3097
+ let buf = "";
3098
+ let timer = null;
3099
+ const wasRaw = stdin.isRaw;
3100
+ const finish = (result) => {
3101
+ if (settled)
3102
+ return;
3103
+ settled = true;
3104
+ if (timer)
3105
+ clearTimeout(timer);
3106
+ stdin.off("data", onData);
3107
+ try {
3108
+ if (!wasRaw)
3109
+ stdin.setRawMode(false);
3110
+ } catch {}
3111
+ stdin.pause();
3112
+ resolve8(result);
3113
+ };
3114
+ const onData = (chunk) => {
3115
+ buf += chunk.toString("utf8");
3116
+ const match = buf.match(/\x1b\]1[01];rgb:[0-9a-f/]+(?:\x07|\x1b\\)/i);
3117
+ if (!match)
3118
+ return;
3119
+ const parsed = parseOSC11Response(match[0]);
3120
+ if (!parsed) {
3121
+ finish(null);
3122
+ return;
3123
+ }
3124
+ finish(isLightRgb(parsed.r, parsed.g, parsed.b) ? "light" : "dark");
3125
+ };
3126
+ try {
3127
+ stdin.setRawMode(true);
3128
+ stdin.resume();
3129
+ stdin.on("data", onData);
3130
+ timer = setTimeout(() => finish(null), timeoutMs);
3131
+ stdout.write("\x1B]11;?\x1B\\");
3132
+ } catch {
3133
+ finish(null);
3134
+ }
3135
+ });
3136
+ }
3137
+ async function detectThemeMode(timeoutMs = 100) {
3138
+ const osc = await queryOSC11(timeoutMs);
3139
+ if (osc)
3140
+ return osc;
3141
+ return parseColorFgBg(process.env.COLORFGBG);
3142
+ }
3143
+ async function resolveTheme(pref = "auto") {
3144
+ if (pref === "light")
3145
+ return LIGHT_THEME;
3146
+ if (pref === "dark")
3147
+ return DARK_THEME;
3148
+ const detected = await detectThemeMode();
3149
+ return themeFor(detected ?? "dark");
3150
+ }
3151
+
2952
3152
  // src/ui/help-overlay.ts
2953
3153
  import { BoxRenderable, TextRenderable } from "@opentui/core";
2954
3154
 
@@ -3002,7 +3202,7 @@ var STATUS_BAR_TEXT = STATUS_HINTS_COMPACT.map((h) => {
3002
3202
  class HelpOverlay {
3003
3203
  renderable;
3004
3204
  textRenderable;
3005
- constructor(renderer) {
3205
+ constructor(renderer, theme = DARK_THEME) {
3006
3206
  this.renderable = new BoxRenderable(renderer, {
3007
3207
  id: "help-overlay",
3008
3208
  position: "absolute",
@@ -3018,7 +3218,7 @@ class HelpOverlay {
3018
3218
  position: "absolute",
3019
3219
  width: "100%",
3020
3220
  height: "100%",
3021
- backgroundColor: "#000000",
3221
+ backgroundColor: theme.helpBackdropBg,
3022
3222
  opacity: 0.7
3023
3223
  });
3024
3224
  const box = new BoxRenderable(renderer, {
@@ -3026,9 +3226,9 @@ class HelpOverlay {
3026
3226
  flexDirection: "column",
3027
3227
  padding: 1,
3028
3228
  paddingX: 5,
3029
- backgroundColor: "#1a1a2e",
3229
+ backgroundColor: theme.helpBoxBg,
3030
3230
  border: true,
3031
- borderColor: "#444",
3231
+ borderColor: theme.helpBorder,
3032
3232
  zIndex: 101
3033
3233
  });
3034
3234
  const lines = [
@@ -3045,7 +3245,7 @@ class HelpOverlay {
3045
3245
  id: "help-text",
3046
3246
  content: lines.join(`
3047
3247
  `),
3048
- fg: "#cccccc"
3248
+ fg: theme.helpText
3049
3249
  });
3050
3250
  box.add(this.textRenderable);
3051
3251
  this.renderable.add(backdrop);
@@ -3556,7 +3756,7 @@ function openLink(link) {
3556
3756
  }
3557
3757
 
3558
3758
  // src/ui/pane.ts
3559
- var RENDER_LIMIT = 5000;
3759
+ var RENDER_LIMIT = 1500;
3560
3760
  var MAX_SCROLLBACK_LINES = 1e6;
3561
3761
  var MAX_BUFFER_BYTES = 500 * 1024 * 1024;
3562
3762
 
@@ -3570,11 +3770,16 @@ class Pane {
3570
3770
  _timestampFormat = null;
3571
3771
  lineTimestamps = [];
3572
3772
  lineCounter = 0;
3773
+ signedLineCount = 0;
3774
+ timestampUpdateTimer = null;
3775
+ static TIMESTAMP_UPDATE_DEBOUNCE_MS = 32;
3573
3776
  _onScroll = null;
3574
3777
  _onCopy = null;
3575
3778
  _onLinkClick = null;
3576
- constructor(renderer, name, cols, rows, interactive = false) {
3779
+ theme;
3780
+ constructor(renderer, name, cols, rows, interactive = false, theme = DARK_THEME) {
3577
3781
  this.renderer = renderer;
3782
+ this.theme = theme;
3578
3783
  this.scrollBox = new ScrollBoxRenderable(renderer, {
3579
3784
  id: `pane-${name}`,
3580
3785
  flexGrow: 1,
@@ -3582,7 +3787,13 @@ class Pane {
3582
3787
  stickyScroll: true,
3583
3788
  stickyStart: "bottom",
3584
3789
  visible: false,
3585
- onMouseScroll: () => this._onScroll?.()
3790
+ onMouseScroll: () => this._onScroll?.(),
3791
+ scrollbarOptions: {
3792
+ trackOptions: {
3793
+ backgroundColor: theme.scrollTrackBg,
3794
+ foregroundColor: theme.scrollThumbBg
3795
+ }
3796
+ }
3586
3797
  });
3587
3798
  this.terminal = new TailingTerminal(renderer, {
3588
3799
  id: `term-${name}`,
@@ -3630,6 +3841,8 @@ class Pane {
3630
3841
  this.bytesFed = 0;
3631
3842
  this.lineTimestamps = [];
3632
3843
  this.lineCounter = 0;
3844
+ this.signedLineCount = 0;
3845
+ this.timestampGutter?.clearAllLineSigns();
3633
3846
  }
3634
3847
  const now = Date.now();
3635
3848
  if (this.lineCounter === 0) {
@@ -3644,10 +3857,18 @@ class Pane {
3644
3857
  }
3645
3858
  const text = this.decoder.decode(data, { stream: true });
3646
3859
  this.terminal.feed(text);
3647
- if (this._timestampFormat) {
3648
- this.updateTimestampSigns();
3860
+ if (this._timestampFormat && this.lineTimestamps.length !== this.signedLineCount) {
3861
+ this.scheduleTimestampUpdate();
3649
3862
  }
3650
3863
  }
3864
+ scheduleTimestampUpdate() {
3865
+ if (this.timestampUpdateTimer)
3866
+ return;
3867
+ this.timestampUpdateTimer = setTimeout(() => {
3868
+ this.timestampUpdateTimer = null;
3869
+ this.updateTimestampSigns();
3870
+ }, Pane.TIMESTAMP_UPDATE_DEBOUNCE_MS);
3871
+ }
3651
3872
  resize(cols, rows) {
3652
3873
  this.terminal.cols = cols;
3653
3874
  this.terminal.rows = rows;
@@ -3708,7 +3929,7 @@ class Pane {
3708
3929
  line: m.line,
3709
3930
  start: m.start,
3710
3931
  end: m.end,
3711
- backgroundColor: i === currentIndex ? "#b58900" : "#073642"
3932
+ backgroundColor: i === currentIndex ? this.theme.searchCurrentBg : this.theme.searchMatchBg
3712
3933
  });
3713
3934
  }
3714
3935
  this.terminal.highlights = regions;
@@ -3725,6 +3946,11 @@ class Pane {
3725
3946
  this.bytesFed = 0;
3726
3947
  this.lineTimestamps = [];
3727
3948
  this.lineCounter = 0;
3949
+ this.signedLineCount = 0;
3950
+ if (this.timestampUpdateTimer) {
3951
+ clearTimeout(this.timestampUpdateTimer);
3952
+ this.timestampUpdateTimer = null;
3953
+ }
3728
3954
  if (this._timestampFormat) {
3729
3955
  this.timestampGutter?.clearAllLineSigns();
3730
3956
  }
@@ -3736,6 +3962,11 @@ class Pane {
3736
3962
  if (wasEnabled === isEnabled && this._timestampFormat === newFormat)
3737
3963
  return;
3738
3964
  this._timestampFormat = newFormat;
3965
+ if (this.timestampUpdateTimer) {
3966
+ clearTimeout(this.timestampUpdateTimer);
3967
+ this.timestampUpdateTimer = null;
3968
+ }
3969
+ this.signedLineCount = 0;
3739
3970
  if (isEnabled && !wasEnabled) {
3740
3971
  this.scrollBox.remove(this.terminal.id);
3741
3972
  const gutterWidth = (newFormat?.length ?? 8) + 1;
@@ -3775,8 +4006,14 @@ class Pane {
3775
4006
  signs.set(i, { before: formatTimestamp(new Date(this.lineTimestamps[i]), fmt) });
3776
4007
  }
3777
4008
  this.timestampGutter.setLineSigns(signs);
4009
+ this.signedLineCount = this.lineTimestamps.length;
3778
4010
  }
3779
4011
  destroy() {
4012
+ if (this.timestampUpdateTimer) {
4013
+ clearTimeout(this.timestampUpdateTimer);
4014
+ this.timestampUpdateTimer = null;
4015
+ }
4016
+ this.timestampGutter?.clearTarget();
3780
4017
  this.terminal.destroy();
3781
4018
  }
3782
4019
  }
@@ -3997,11 +4234,11 @@ class StatusBar {
3997
4234
  _tempMessage = null;
3998
4235
  _tempTimer = null;
3999
4236
  _inputMode = false;
4000
- constructor(renderer) {
4237
+ constructor(renderer, theme = DARK_THEME) {
4001
4238
  this.renderable = new BoxRenderable2(renderer, {
4002
4239
  id: "status-bar",
4003
4240
  width: "100%",
4004
- backgroundColor: "#1a1a1a",
4241
+ backgroundColor: theme.statusBarBg,
4005
4242
  paddingX: 1,
4006
4243
  minHeight: 1
4007
4244
  });
@@ -4009,6 +4246,7 @@ class StatusBar {
4009
4246
  id: "status-bar-text",
4010
4247
  width: "100%",
4011
4248
  wrapMode: "word",
4249
+ fg: theme.statusBarText,
4012
4250
  content: this.buildContent()
4013
4251
  });
4014
4252
  this.text.selectable = false;
@@ -4094,13 +4332,15 @@ var STATUS_ICONS = {
4094
4332
  failed: "\u2716",
4095
4333
  skipped: "\u2298"
4096
4334
  };
4097
- var STATUS_ICON_HEX = {
4098
- ready: "#00cc00",
4099
- finished: "#66aa66",
4100
- failed: "#ff5555",
4101
- stopped: "#888888",
4102
- skipped: "#888888"
4103
- };
4335
+ function getStatusIconHex(theme) {
4336
+ return {
4337
+ ready: theme.status.ready,
4338
+ finished: theme.status.finished,
4339
+ failed: theme.status.failed,
4340
+ stopped: theme.status.stopped,
4341
+ skipped: theme.status.skipped
4342
+ };
4343
+ }
4104
4344
  var TERMINAL_STATUSES = new Set(["finished", "stopped", "failed", "skipped"]);
4105
4345
  function getDisplayOrder(originalNames, statuses) {
4106
4346
  const active = originalNames.filter((n) => !TERMINAL_STATUSES.has(statuses.get(n)));
@@ -4120,16 +4360,17 @@ function formatDescription(status, exitCode, restartCount) {
4120
4360
  }
4121
4361
  return desc;
4122
4362
  }
4123
- function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses, searchMatchProcesses) {
4363
+ function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses, theme, searchMatchProcesses) {
4364
+ const statusIconHex = getStatusIconHex(theme);
4124
4365
  return names.map((name) => {
4125
4366
  const status = statuses.get(name);
4126
4367
  const waiting = inputWaiting.has(name);
4127
4368
  const errored = erroredProcesses.has(name);
4128
4369
  const hasSearchMatch = searchMatchProcesses?.has(name);
4129
- const statusHex = hasSearchMatch ? "#b58900" : waiting ? "#ffaa00" : errored ? "#ff5555" : STATUS_ICON_HEX[status];
4370
+ const statusHex = hasSearchMatch ? theme.searchMatchTab : waiting ? theme.inputWaiting : errored ? theme.errorIndicator : statusIconHex[status];
4130
4371
  const processHex = processColors.get(name);
4131
4372
  return {
4132
- iconHex: statusHex ?? processHex ?? "#888888",
4373
+ iconHex: statusHex ?? processHex ?? theme.iconDefault,
4133
4374
  nameHex: processHex ?? null
4134
4375
  };
4135
4376
  });
@@ -4213,13 +4454,15 @@ class TabBar {
4213
4454
  inputWaiting = new Set;
4214
4455
  erroredProcesses = new Set;
4215
4456
  searchMatchCounts = new Map;
4216
- constructor(renderer, names, colors, reorderByStatus = false) {
4457
+ theme;
4458
+ constructor(renderer, names, colors, theme = DARK_THEME, reorderByStatus = false) {
4217
4459
  this.originalNames = names;
4218
4460
  this.names = [...names];
4219
4461
  this.reorderByStatus = reorderByStatus;
4220
4462
  this.statuses = new Map(names.map((n) => [n, "pending"]));
4221
4463
  this.baseDescriptions = new Map(names.map((n) => [n, "pending"]));
4222
4464
  this.processColors = colors ?? new Map;
4465
+ this.theme = theme;
4223
4466
  this.renderable = new ColoredSelectRenderable(renderer, {
4224
4467
  id: "tab-bar",
4225
4468
  width: "100%",
@@ -4228,9 +4471,13 @@ class TabBar {
4228
4471
  name: formatTab(n, "pending"),
4229
4472
  description: "pending"
4230
4473
  })),
4231
- selectedBackgroundColor: "#334455",
4232
- selectedTextColor: "#fff",
4233
- textColor: "#888",
4474
+ backgroundColor: theme.sidebarBg,
4475
+ focusedBackgroundColor: theme.sidebarBg,
4476
+ selectedBackgroundColor: theme.tabSelectedBg,
4477
+ selectedTextColor: theme.tabSelectedText,
4478
+ textColor: theme.tabText,
4479
+ descriptionColor: theme.tabDescriptionText,
4480
+ selectedDescriptionColor: theme.tabSelectedDescriptionText,
4234
4481
  showDescription: true,
4235
4482
  wrapSelection: true
4236
4483
  });
@@ -4314,7 +4561,7 @@ class TabBar {
4314
4561
  }
4315
4562
  updateOptionColors() {
4316
4563
  const searchProcesses = this.searchMatchCounts.size > 0 ? new Set(this.searchMatchCounts.keys()) : undefined;
4317
- const resolved = resolveOptionColors(this.names, this.statuses, this.processColors, this.inputWaiting, this.erroredProcesses, searchProcesses);
4564
+ const resolved = resolveOptionColors(this.names, this.statuses, this.processColors, this.inputWaiting, this.erroredProcesses, this.theme, searchProcesses);
4318
4565
  const colors = resolved.map((c) => ({
4319
4566
  icon: parseColor(c.iconHex),
4320
4567
  name: c.nameHex ? parseColor(c.nameHex) : null
@@ -4347,6 +4594,7 @@ class App {
4347
4594
  sidebarWidth = 20;
4348
4595
  config;
4349
4596
  logWriter;
4597
+ theme = DARK_THEME;
4350
4598
  resizeTimer = null;
4351
4599
  inputWaitTimers = new Map;
4352
4600
  awaitingInput = new Set;
@@ -4357,6 +4605,9 @@ class App {
4357
4605
  this.names = manager.getProcessNames();
4358
4606
  }
4359
4607
  async start() {
4608
+ log(`theme detect: pref=${this.config.theme ?? "auto"} stdin.isTTY=${process.stdin.isTTY} stdout.isTTY=${process.stdout.isTTY} COLORFGBG=${process.env.COLORFGBG ?? "(unset)"}`);
4609
+ this.theme = await resolveTheme(this.config.theme);
4610
+ log(`theme resolved: ${this.theme.mode}`);
4360
4611
  this.renderer = await createCliRenderer({
4361
4612
  exitOnCtrlC: false,
4362
4613
  useMouse: true,
@@ -4375,8 +4626,8 @@ class App {
4375
4626
  height: "100%",
4376
4627
  border: false
4377
4628
  });
4378
- const processHexColors = buildProcessHexColorMap(this.names, this.config);
4379
- this.tabBar = new TabBar(this.renderer, this.names, processHexColors, this.config.sort === "status");
4629
+ const processHexColors = buildProcessHexColorMap(this.names, this.config, this.theme.palette);
4630
+ this.tabBar = new TabBar(this.renderer, this.names, processHexColors, this.theme, this.config.sort === "status");
4380
4631
  const contentRow = new BoxRenderable3(this.renderer, {
4381
4632
  id: "content-row",
4382
4633
  flexDirection: "row",
@@ -4389,7 +4640,8 @@ class App {
4389
4640
  width: this.sidebarWidth,
4390
4641
  height: "100%",
4391
4642
  border: ["right"],
4392
- borderColor: "#444"
4643
+ borderColor: this.theme.sidebarBorder,
4644
+ backgroundColor: this.theme.sidebarBg
4393
4645
  });
4394
4646
  sidebar.add(this.tabBar.renderable);
4395
4647
  const paneContainer = new BoxRenderable3(this.renderer, {
@@ -4397,8 +4649,8 @@ class App {
4397
4649
  flexGrow: 1,
4398
4650
  border: false
4399
4651
  });
4400
- this.statusBar = new StatusBar(this.renderer);
4401
- this.helpOverlay = new HelpOverlay(this.renderer);
4652
+ this.statusBar = new StatusBar(this.renderer, this.theme);
4653
+ this.helpOverlay = new HelpOverlay(this.renderer, this.theme);
4402
4654
  this.search = new SearchController({
4403
4655
  logWriter: this.logWriter,
4404
4656
  statusBar: this.statusBar,
@@ -4408,7 +4660,7 @@ class App {
4408
4660
  });
4409
4661
  for (const name of this.names) {
4410
4662
  const interactive = this.config.processes[name].interactive === true;
4411
- const pane = new Pane(this.renderer, name, termCols, termRows, interactive);
4663
+ const pane = new Pane(this.renderer, name, termCols, termRows, interactive, this.theme);
4412
4664
  if (this.config.timestamps) {
4413
4665
  pane.setTimestamps(this.config.timestamps);
4414
4666
  }
@@ -5426,6 +5678,9 @@ async function main() {
5426
5678
  if (parsed.only || parsed.exclude) {
5427
5679
  config = filterConfig(config, parsed.only, parsed.exclude);
5428
5680
  }
5681
+ if (parsed.theme) {
5682
+ config.theme = parsed.theme;
5683
+ }
5429
5684
  if (parsed.autoColors) {
5430
5685
  for (const [name, proc] of Object.entries(config.processes)) {
5431
5686
  if (!proc.color) {
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Color } from './utils/color';
2
+ import type { ThemePref } from './utils/theme';
2
3
  export interface NumuxProcessConfig<K extends string = string> {
3
4
  /** Shell command to run. Supports `$dep.group` references from dependency capture groups */
4
5
  command: string;
@@ -122,6 +123,12 @@ export interface NumuxConfig<K extends string = string> {
122
123
  noWatch?: boolean;
123
124
  /** Directory to write per-process log files */
124
125
  logDir?: string;
126
+ /**
127
+ * TUI color theme. `'auto'` detects the terminal background via OSC 11 (falling back
128
+ * to `COLORFGBG` then dark). `'light'`/`'dark'` skip detection.
129
+ * @default 'auto'
130
+ */
131
+ theme?: ThemePref;
125
132
  processes: Record<K, NumuxProcessConfig<K> | NumuxScriptPattern<K> | string | true>;
126
133
  }
127
134
  export type SortOrder = 'config' | 'alphabetical' | 'topological' | 'status';
@@ -138,6 +145,7 @@ export interface ResolvedNumuxConfig {
138
145
  killOthersOnFail?: boolean;
139
146
  noWatch?: boolean;
140
147
  logDir?: string;
148
+ theme?: ThemePref;
141
149
  processes: Record<string, ResolvedProcessConfig>;
142
150
  }
143
151
  export type ProcessStatus = 'pending' | 'starting' | 'ready' | 'running' | 'stopping' | 'stopped' | 'finished' | 'failed' | 'skipped';
@@ -34,7 +34,7 @@ export declare const ANSI_RESET = "\u001B[0m";
34
34
  export declare function stripAnsi(str: string): string;
35
35
  /** Pick a deterministic color from the default palette based on the process name */
36
36
  export declare function colorFromName(name: string): Color;
37
- /** Build a map of process names to ANSI color codes, using explicit config colors or a default palette. */
38
- export declare function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string>;
37
+ /** Build a map of process names to ANSI color codes, using explicit config colors or a palette. */
38
+ export declare function buildProcessColorMap(names: string[], config: ResolvedNumuxConfig, palette?: readonly string[]): Map<string, string>;
39
39
  /** Build a map of process names to hex color strings (for StyledText rendering). */
40
- export declare function buildProcessHexColorMap(names: string[], config: ResolvedNumuxConfig): Map<string, string>;
40
+ export declare function buildProcessHexColorMap(names: string[], config: ResolvedNumuxConfig, palette?: readonly string[]): Map<string, string>;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Light/dark theme resolution. Detects terminal background via OSC 11
3
+ * query, falling back to COLORFGBG env var, then to dark. Explicit
4
+ * user config (`theme: 'light' | 'dark'`) skips detection entirely.
5
+ */
6
+ export type ThemeMode = 'light' | 'dark';
7
+ export type ThemePref = ThemeMode | 'auto';
8
+ export interface StatusColors {
9
+ ready: string;
10
+ failed: string;
11
+ stopped: string;
12
+ finished: string;
13
+ skipped: string;
14
+ }
15
+ export interface Theme {
16
+ mode: ThemeMode;
17
+ statusBarBg: string;
18
+ statusBarText: string;
19
+ helpBackdropBg: string;
20
+ helpBoxBg: string;
21
+ helpBorder: string;
22
+ helpText: string;
23
+ sidebarBg: string;
24
+ sidebarBorder: string;
25
+ tabSelectedBg: string;
26
+ tabSelectedText: string;
27
+ tabText: string;
28
+ tabDescriptionText: string;
29
+ tabSelectedDescriptionText: string;
30
+ scrollTrackBg: string;
31
+ scrollThumbBg: string;
32
+ searchCurrentBg: string;
33
+ searchMatchBg: string;
34
+ palette: readonly string[];
35
+ status: StatusColors;
36
+ inputWaiting: string;
37
+ errorIndicator: string;
38
+ searchMatchTab: string;
39
+ iconDefault: string;
40
+ }
41
+ export declare const DARK_THEME: Theme;
42
+ export declare const LIGHT_THEME: Theme;
43
+ export declare function themeFor(mode: ThemeMode): Theme;
44
+ /** WCAG relative luminance (0–1). */
45
+ export declare function relativeLuminance(r: number, g: number, b: number): number;
46
+ export declare function isLightRgb(r: number, g: number, b: number): boolean;
47
+ /**
48
+ * Parse OSC 11 response body. Accepts `rgb:RRRR/GGGG/BBBB` (4 hex digits,
49
+ * xterm/Ghostty/kitty/alacritty/iTerm2) or 2-digit short form. Returns null
50
+ * on malformed input.
51
+ */
52
+ export declare function parseOSC11Response(data: string): {
53
+ r: number;
54
+ g: number;
55
+ b: number;
56
+ } | null;
57
+ /**
58
+ * Parse COLORFGBG env var (e.g. `"15;0"` = white fg on black bg = dark).
59
+ * Convention: bg index ≥7 is light, otherwise dark. iTerm2 sometimes sets
60
+ * the middle field to `default`; we read the last segment as the bg.
61
+ */
62
+ export declare function parseColorFgBg(value: string | undefined): ThemeMode | null;
63
+ /**
64
+ * Query the terminal's background color via OSC 11. Resolves to `null` on
65
+ * non-TTY, timeout, or unparseable response. Runs synchronously-ish in under
66
+ * `timeoutMs` (default 100ms). Must be called before any renderer takes
67
+ * over stdin.
68
+ */
69
+ export declare function queryOSC11(timeoutMs?: number): Promise<ThemeMode | null>;
70
+ export declare function detectThemeMode(timeoutMs?: number): Promise<ThemeMode | null>;
71
+ /**
72
+ * Resolve final theme. Explicit `'light'`/`'dark'` skips detection;
73
+ * `'auto'` (or undefined) runs detection, falling back to dark.
74
+ */
75
+ export declare function resolveTheme(pref?: ThemePref | undefined): Promise<Theme>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "numux",
3
- "version": "2.16.1",
3
+ "version": "2.17.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",