numux 2.16.2 → 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.2" "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.2",
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);
@@ -3576,8 +3776,10 @@ class Pane {
3576
3776
  _onScroll = null;
3577
3777
  _onCopy = null;
3578
3778
  _onLinkClick = null;
3579
- constructor(renderer, name, cols, rows, interactive = false) {
3779
+ theme;
3780
+ constructor(renderer, name, cols, rows, interactive = false, theme = DARK_THEME) {
3580
3781
  this.renderer = renderer;
3782
+ this.theme = theme;
3581
3783
  this.scrollBox = new ScrollBoxRenderable(renderer, {
3582
3784
  id: `pane-${name}`,
3583
3785
  flexGrow: 1,
@@ -3585,7 +3787,13 @@ class Pane {
3585
3787
  stickyScroll: true,
3586
3788
  stickyStart: "bottom",
3587
3789
  visible: false,
3588
- onMouseScroll: () => this._onScroll?.()
3790
+ onMouseScroll: () => this._onScroll?.(),
3791
+ scrollbarOptions: {
3792
+ trackOptions: {
3793
+ backgroundColor: theme.scrollTrackBg,
3794
+ foregroundColor: theme.scrollThumbBg
3795
+ }
3796
+ }
3589
3797
  });
3590
3798
  this.terminal = new TailingTerminal(renderer, {
3591
3799
  id: `term-${name}`,
@@ -3721,7 +3929,7 @@ class Pane {
3721
3929
  line: m.line,
3722
3930
  start: m.start,
3723
3931
  end: m.end,
3724
- backgroundColor: i === currentIndex ? "#b58900" : "#073642"
3932
+ backgroundColor: i === currentIndex ? this.theme.searchCurrentBg : this.theme.searchMatchBg
3725
3933
  });
3726
3934
  }
3727
3935
  this.terminal.highlights = regions;
@@ -4026,11 +4234,11 @@ class StatusBar {
4026
4234
  _tempMessage = null;
4027
4235
  _tempTimer = null;
4028
4236
  _inputMode = false;
4029
- constructor(renderer) {
4237
+ constructor(renderer, theme = DARK_THEME) {
4030
4238
  this.renderable = new BoxRenderable2(renderer, {
4031
4239
  id: "status-bar",
4032
4240
  width: "100%",
4033
- backgroundColor: "#1a1a1a",
4241
+ backgroundColor: theme.statusBarBg,
4034
4242
  paddingX: 1,
4035
4243
  minHeight: 1
4036
4244
  });
@@ -4038,6 +4246,7 @@ class StatusBar {
4038
4246
  id: "status-bar-text",
4039
4247
  width: "100%",
4040
4248
  wrapMode: "word",
4249
+ fg: theme.statusBarText,
4041
4250
  content: this.buildContent()
4042
4251
  });
4043
4252
  this.text.selectable = false;
@@ -4123,13 +4332,15 @@ var STATUS_ICONS = {
4123
4332
  failed: "\u2716",
4124
4333
  skipped: "\u2298"
4125
4334
  };
4126
- var STATUS_ICON_HEX = {
4127
- ready: "#00cc00",
4128
- finished: "#66aa66",
4129
- failed: "#ff5555",
4130
- stopped: "#888888",
4131
- skipped: "#888888"
4132
- };
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
+ }
4133
4344
  var TERMINAL_STATUSES = new Set(["finished", "stopped", "failed", "skipped"]);
4134
4345
  function getDisplayOrder(originalNames, statuses) {
4135
4346
  const active = originalNames.filter((n) => !TERMINAL_STATUSES.has(statuses.get(n)));
@@ -4149,16 +4360,17 @@ function formatDescription(status, exitCode, restartCount) {
4149
4360
  }
4150
4361
  return desc;
4151
4362
  }
4152
- function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses, searchMatchProcesses) {
4363
+ function resolveOptionColors(names, statuses, processColors, inputWaiting, erroredProcesses, theme, searchMatchProcesses) {
4364
+ const statusIconHex = getStatusIconHex(theme);
4153
4365
  return names.map((name) => {
4154
4366
  const status = statuses.get(name);
4155
4367
  const waiting = inputWaiting.has(name);
4156
4368
  const errored = erroredProcesses.has(name);
4157
4369
  const hasSearchMatch = searchMatchProcesses?.has(name);
4158
- 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];
4159
4371
  const processHex = processColors.get(name);
4160
4372
  return {
4161
- iconHex: statusHex ?? processHex ?? "#888888",
4373
+ iconHex: statusHex ?? processHex ?? theme.iconDefault,
4162
4374
  nameHex: processHex ?? null
4163
4375
  };
4164
4376
  });
@@ -4242,13 +4454,15 @@ class TabBar {
4242
4454
  inputWaiting = new Set;
4243
4455
  erroredProcesses = new Set;
4244
4456
  searchMatchCounts = new Map;
4245
- constructor(renderer, names, colors, reorderByStatus = false) {
4457
+ theme;
4458
+ constructor(renderer, names, colors, theme = DARK_THEME, reorderByStatus = false) {
4246
4459
  this.originalNames = names;
4247
4460
  this.names = [...names];
4248
4461
  this.reorderByStatus = reorderByStatus;
4249
4462
  this.statuses = new Map(names.map((n) => [n, "pending"]));
4250
4463
  this.baseDescriptions = new Map(names.map((n) => [n, "pending"]));
4251
4464
  this.processColors = colors ?? new Map;
4465
+ this.theme = theme;
4252
4466
  this.renderable = new ColoredSelectRenderable(renderer, {
4253
4467
  id: "tab-bar",
4254
4468
  width: "100%",
@@ -4257,9 +4471,13 @@ class TabBar {
4257
4471
  name: formatTab(n, "pending"),
4258
4472
  description: "pending"
4259
4473
  })),
4260
- selectedBackgroundColor: "#334455",
4261
- selectedTextColor: "#fff",
4262
- 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,
4263
4481
  showDescription: true,
4264
4482
  wrapSelection: true
4265
4483
  });
@@ -4343,7 +4561,7 @@ class TabBar {
4343
4561
  }
4344
4562
  updateOptionColors() {
4345
4563
  const searchProcesses = this.searchMatchCounts.size > 0 ? new Set(this.searchMatchCounts.keys()) : undefined;
4346
- 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);
4347
4565
  const colors = resolved.map((c) => ({
4348
4566
  icon: parseColor(c.iconHex),
4349
4567
  name: c.nameHex ? parseColor(c.nameHex) : null
@@ -4376,6 +4594,7 @@ class App {
4376
4594
  sidebarWidth = 20;
4377
4595
  config;
4378
4596
  logWriter;
4597
+ theme = DARK_THEME;
4379
4598
  resizeTimer = null;
4380
4599
  inputWaitTimers = new Map;
4381
4600
  awaitingInput = new Set;
@@ -4386,6 +4605,9 @@ class App {
4386
4605
  this.names = manager.getProcessNames();
4387
4606
  }
4388
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}`);
4389
4611
  this.renderer = await createCliRenderer({
4390
4612
  exitOnCtrlC: false,
4391
4613
  useMouse: true,
@@ -4404,8 +4626,8 @@ class App {
4404
4626
  height: "100%",
4405
4627
  border: false
4406
4628
  });
4407
- const processHexColors = buildProcessHexColorMap(this.names, this.config);
4408
- 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");
4409
4631
  const contentRow = new BoxRenderable3(this.renderer, {
4410
4632
  id: "content-row",
4411
4633
  flexDirection: "row",
@@ -4418,7 +4640,8 @@ class App {
4418
4640
  width: this.sidebarWidth,
4419
4641
  height: "100%",
4420
4642
  border: ["right"],
4421
- borderColor: "#444"
4643
+ borderColor: this.theme.sidebarBorder,
4644
+ backgroundColor: this.theme.sidebarBg
4422
4645
  });
4423
4646
  sidebar.add(this.tabBar.renderable);
4424
4647
  const paneContainer = new BoxRenderable3(this.renderer, {
@@ -4426,8 +4649,8 @@ class App {
4426
4649
  flexGrow: 1,
4427
4650
  border: false
4428
4651
  });
4429
- this.statusBar = new StatusBar(this.renderer);
4430
- this.helpOverlay = new HelpOverlay(this.renderer);
4652
+ this.statusBar = new StatusBar(this.renderer, this.theme);
4653
+ this.helpOverlay = new HelpOverlay(this.renderer, this.theme);
4431
4654
  this.search = new SearchController({
4432
4655
  logWriter: this.logWriter,
4433
4656
  statusBar: this.statusBar,
@@ -4437,7 +4660,7 @@ class App {
4437
4660
  });
4438
4661
  for (const name of this.names) {
4439
4662
  const interactive = this.config.processes[name].interactive === true;
4440
- const pane = new Pane(this.renderer, name, termCols, termRows, interactive);
4663
+ const pane = new Pane(this.renderer, name, termCols, termRows, interactive, this.theme);
4441
4664
  if (this.config.timestamps) {
4442
4665
  pane.setTimestamps(this.config.timestamps);
4443
4666
  }
@@ -5455,6 +5678,9 @@ async function main() {
5455
5678
  if (parsed.only || parsed.exclude) {
5456
5679
  config = filterConfig(config, parsed.only, parsed.exclude);
5457
5680
  }
5681
+ if (parsed.theme) {
5682
+ config.theme = parsed.theme;
5683
+ }
5458
5684
  if (parsed.autoColors) {
5459
5685
  for (const [name, proc] of Object.entries(config.processes)) {
5460
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.2",
3
+ "version": "2.17.0",
4
4
  "description": "Terminal multiplexer with dependency orchestration",
5
5
  "type": "module",
6
6
  "license": "MIT",