@ciphore/radiocli 0.2.1 → 0.2.3

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +133 -1
  2. package/README.md +76 -6
  3. package/dist/agent/alarm-service.js +210 -0
  4. package/dist/agent/cli.js +193 -0
  5. package/dist/agent/headless-host.js +143 -0
  6. package/dist/agent/launcher.js +71 -0
  7. package/dist/agent/mcp-install.js +467 -0
  8. package/dist/agent/mcp-server.js +139 -0
  9. package/dist/agent/service.js +347 -0
  10. package/dist/agent/session.js +248 -0
  11. package/dist/alarms/active-session.js +183 -0
  12. package/dist/alarms/cli.js +312 -0
  13. package/dist/alarms/guard.js +343 -0
  14. package/dist/alarms/inhibitor.js +48 -0
  15. package/dist/alarms/power-guard-store.js +169 -0
  16. package/dist/alarms/runner.js +342 -0
  17. package/dist/alarms/runtime-health.js +79 -0
  18. package/dist/alarms/schedule.js +149 -0
  19. package/dist/alarms/scheduler.js +250 -0
  20. package/dist/alarms/setup-verification.js +187 -0
  21. package/dist/alarms/system-volume.js +43 -0
  22. package/dist/alarms/terminal-launcher.js +181 -0
  23. package/dist/alarms/tui-presence.js +38 -0
  24. package/dist/cli.js +113 -5
  25. package/dist/player/backend-install.js +2 -1
  26. package/dist/player/command-diagnostics.js +27 -0
  27. package/dist/player/command.js +123 -62
  28. package/dist/player/player-controller.js +32 -2
  29. package/dist/providers/provider-manager.js +5 -0
  30. package/dist/providers/radio-browser.js +36 -6
  31. package/dist/setup.js +462 -0
  32. package/dist/storage/store.js +262 -2
  33. package/dist/types.js +6 -0
  34. package/dist/ui/AdaptiveContent.js +111 -26
  35. package/dist/ui/App.js +401 -67
  36. package/dist/ui/AppContent.js +24 -7
  37. package/dist/ui/adaptive-explore-layout.js +47 -0
  38. package/dist/ui/alarm-editor.js +174 -0
  39. package/dist/ui/alarm-tui-service.js +84 -0
  40. package/dist/ui/app-state.js +3 -0
  41. package/dist/ui/ascii.js +8 -0
  42. package/dist/ui/components/AdaptiveMarquee.js +28 -0
  43. package/dist/ui/components/StationList.js +10 -5
  44. package/dist/ui/components/VersionIndicator.js +19 -0
  45. package/dist/ui/cosmo-world-map.js +5 -2
  46. package/dist/ui/explore-map-layout.js +18 -6
  47. package/dist/ui/format.js +21 -0
  48. package/dist/ui/help-content.js +14 -2
  49. package/dist/ui/layout.js +1 -1
  50. package/dist/ui/page-footer.js +120 -2
  51. package/dist/ui/receiver-animation.js +68 -0
  52. package/dist/ui/screen-items.js +41 -9
  53. package/dist/ui/screen-meta.js +4 -0
  54. package/dist/ui/screens/AlarmsScreen.js +202 -0
  55. package/dist/ui/screens/CountriesScreen.js +8 -5
  56. package/dist/ui/screens/ExploreScreen.js +8 -3
  57. package/dist/ui/screens/HomeScreen.js +3 -1
  58. package/dist/ui/screens/NowPlayingScreen.js +6 -2
  59. package/dist/ui/screens/SettingsScreen.js +70 -53
  60. package/dist/ui/screens/StationScreen.js +3 -2
  61. package/dist/ui/selection-state.js +10 -0
  62. package/dist/ui/terminal-mouse.js +18 -3
  63. package/dist/ui/use-alarm-tui.js +727 -0
  64. package/dist/ui/use-app-input.js +107 -46
  65. package/dist/ui/visualizers/gallop.js +118 -0
  66. package/dist/ui/visualizers/horse-stride.js +20 -0
  67. package/dist/ui/visualizers/receiver-style-registry.js +14 -7
  68. package/dist/ui/visualizers/receiver-visualizers.js +233 -128
  69. package/dist/ui/visualizers/retro-receivers.js +4 -0
  70. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  71. package/dist/update-check.js +26 -7
  72. package/package.json +6 -1
@@ -1,6 +1,8 @@
1
1
  import { themeAccent, themeContributionColors } from '../theme.js';
2
+ import { retroReceiverBuilders } from './retro-receivers.js';
2
3
  import { receiverStyleMetadata } from './receiver-style-registry.js';
3
4
  const receiverStyleBuilders = {
5
+ ...retroReceiverBuilders,
4
6
  ultracode: buildUltracode,
5
7
  'motion-contour': buildMotionContour,
6
8
  leds: buildLeds,
@@ -13,7 +15,7 @@ const receiverStyleBuilders = {
13
15
  starfield: buildAsciiStarfield,
14
16
  mesh: buildMesh,
15
17
  ribbon: buildRibbon,
16
- mirror: buildMirror,
18
+ equalizer: buildEqualizer,
17
19
  tunnel: buildTunnel,
18
20
  kaleidoscope: buildKaleidoscope,
19
21
  constellation: buildConstellation,
@@ -89,6 +91,7 @@ function buildMicroVisualizer(style, pulse, width, height, theme) {
89
91
  // the circular braille rings) while fixed-size and minimum-size scenes use the
90
92
  // supersampled fit path below.
91
93
  const microNativeScaleStyles = new Set([
94
+ ...Object.keys(retroReceiverBuilders),
92
95
  'pulse-grid',
93
96
  'hex-pulse',
94
97
  'galaxy',
@@ -170,9 +173,76 @@ function buildFlatZeroSignal(width, requestedHeight, theme) {
170
173
  color: rowIndex === height - 1 ? accent : '#767676'
171
174
  }));
172
175
  }
176
+ function buildEqualizer(pulse, width, height, theme) {
177
+ const accent = themeAccent(theme);
178
+ const bandWidth = 3;
179
+ const bandCount = Math.floor((width - 2) / bandWidth);
180
+ const levels = Array.from({ length: bandCount }, (_, i) => {
181
+ const low = Math.sin(i * 0.18 + pulse * 0.46);
182
+ const mid = Math.cos(i * 0.32 - pulse * 0.28);
183
+ const high = Math.sin(i * 0.45 + pulse * 0.68);
184
+ const normalized = (low * 0.4 + mid * 0.35 + high * 0.25 + 1) / 2;
185
+ const eased = Math.pow(Math.max(0, Math.min(1, normalized)), 1.4);
186
+ return Math.round(eased * height);
187
+ });
188
+ const peaks = Array.from({ length: bandCount }, (_, i) => {
189
+ let maxLvl = 0;
190
+ for (let k = 0; k < 8; k++) {
191
+ const p = (pulse - k + 240) % 240;
192
+ const low = Math.sin(i * 0.18 + p * 0.46);
193
+ const mid = Math.cos(i * 0.32 - p * 0.28);
194
+ const high = Math.sin(i * 0.45 + p * 0.68);
195
+ const normalized = (low * 0.4 + mid * 0.35 + high * 0.25 + 1) / 2;
196
+ const eased = Math.pow(Math.max(0, Math.min(1, normalized)), 1.4);
197
+ const lvl = Math.round(eased * height);
198
+ if (lvl > maxLvl) {
199
+ maxLvl = lvl;
200
+ }
201
+ }
202
+ return maxLvl;
203
+ });
204
+ const rows = [];
205
+ const colors = themeContributionColors(theme);
206
+ for (let rowIndex = 0; rowIndex < height; rowIndex++) {
207
+ const threshold = height - rowIndex;
208
+ let rowText = ' ';
209
+ for (let i = 0; i < bandCount; i++) {
210
+ const lvl = levels[i];
211
+ const peak = peaks[i];
212
+ let bandChar = ' ';
213
+ if (lvl >= threshold) {
214
+ bandChar = '██';
215
+ }
216
+ else if (peak === threshold) {
217
+ bandChar = '◆◆';
218
+ }
219
+ else {
220
+ bandChar = '··';
221
+ }
222
+ rowText += bandChar + ' ';
223
+ }
224
+ let color = colors[2] ?? accent;
225
+ if (rowIndex < Math.max(1, height * 0.3)) {
226
+ color = '#ff5f87';
227
+ }
228
+ else if (rowIndex < Math.max(2, height * 0.6)) {
229
+ color = colors[4] ?? accent;
230
+ }
231
+ else {
232
+ color = colors[3] ?? accent;
233
+ }
234
+ rows.push({
235
+ text: rowText.padEnd(width).slice(0, width),
236
+ color
237
+ });
238
+ }
239
+ return rows;
240
+ }
173
241
  const ultracodeWavelength = 20;
174
242
  const ultracodeTravelPerPulse = 80 * 0.03;
175
243
  const ultracodeVioletRamp = ['#3e1676', '#491e87', '#542799', '#5f2faa', '#6b37bc', '#763fcd', '#8148df', '#8c50f0'];
244
+ const ultracodeDistanceCache = new Map();
245
+ const ultracodeRampCache = new Map();
176
246
  function buildUltracode(pulse, width, height, theme) {
177
247
  const renderWidth = Math.max(1, width);
178
248
  const renderHeight = Math.max(1, height);
@@ -180,25 +250,43 @@ function buildUltracode(pulse, width, height, theme) {
180
250
  const ramp = ultracodeRippleRamp(theme);
181
251
  const selectedColor = ramp[ramp.length - 1] ?? accent;
182
252
  const travel = pulse * ultracodeTravelPerPulse;
183
- const originColumn = Math.floor(renderWidth / 2);
184
- const originRow = Math.floor(renderHeight / 2);
253
+ const distances = ultracodeDistances(renderWidth, renderHeight);
254
+ const blank = ' '.repeat(renderWidth);
185
255
  const rows = Array.from({ length: renderHeight }, (_, rowIndex) => {
186
- const cells = Array.from({ length: renderWidth }, (_, columnIndex) => {
187
- const level = ultracodeRippleLevel(ultracodeDistance(columnIndex, rowIndex, originColumn, originRow), travel, ramp.length);
188
- return {
189
- text: ' ',
190
- color: level === null ? accent : selectedColor,
191
- backgroundColor: level === null ? undefined : ramp[level] ?? selectedColor
192
- };
193
- });
194
- return lineFromCells(cells, accent);
256
+ const segments = [];
257
+ for (let columnIndex = 0; columnIndex < renderWidth; columnIndex += 1) {
258
+ const level = ultracodeRippleLevel(distances[rowIndex * renderWidth + columnIndex] ?? 0, travel, ramp.length);
259
+ const color = level === null ? accent : selectedColor;
260
+ const backgroundColor = level === null ? undefined : ramp[level] ?? selectedColor;
261
+ const previous = segments.at(-1);
262
+ if (previous?.color === color && previous.backgroundColor === backgroundColor && previous.bold === undefined) {
263
+ previous.text += ' ';
264
+ }
265
+ else {
266
+ segments.push({ text: ' ', color, backgroundColor, bold: undefined });
267
+ }
268
+ }
269
+ return { text: blank, color: accent, segments };
195
270
  });
196
271
  return rows;
197
272
  }
198
- function ultracodeDistance(column, row, originColumn, originRow) {
199
- const dx = column - originColumn;
200
- const dy = (row - originRow) * 2;
201
- return Math.sqrt(dx * dx + dy * dy);
273
+ function ultracodeDistances(width, height) {
274
+ const key = `${width}x${height}`;
275
+ const cached = ultracodeDistanceCache.get(key);
276
+ if (cached)
277
+ return cached;
278
+ const originColumn = Math.floor(width / 2);
279
+ const originRow = Math.floor(height / 2);
280
+ const distances = new Float64Array(width * height);
281
+ for (let row = 0; row < height; row += 1) {
282
+ for (let column = 0; column < width; column += 1) {
283
+ const dx = column - originColumn;
284
+ const dy = (row - originRow) * 2;
285
+ distances[row * width + column] = Math.sqrt(dx * dx + dy * dy);
286
+ }
287
+ }
288
+ rememberGeometry(ultracodeDistanceCache, key, distances);
289
+ return distances;
202
290
  }
203
291
  function ultracodeRippleLevel(distance, travel, rampLength) {
204
292
  if (distance > travel) {
@@ -212,10 +300,15 @@ function ultracodeRippleRamp(theme) {
212
300
  if (theme === 'violet') {
213
301
  return ultracodeVioletRamp;
214
302
  }
303
+ const cached = ultracodeRampCache.get(theme);
304
+ if (cached)
305
+ return cached;
215
306
  const colors = themeContributionColors(theme);
216
307
  const start = colors[1] ?? '#1c1c1c';
217
308
  const end = themeAccent(theme);
218
- return Array.from({ length: 8 }, (_, index) => interpolateHex(start, end, index / 7));
309
+ const ramp = Array.from({ length: 8 }, (_, index) => interpolateHex(start, end, index / 7));
310
+ ultracodeRampCache.set(theme, ramp);
311
+ return ramp;
219
312
  }
220
313
  function interpolateHex(startHex, endHex, amount) {
221
314
  const start = hexToRgb(startHex);
@@ -481,6 +574,17 @@ function buildAsciiFire(pulse, width, height, theme) {
481
574
  // A few additional overlapping plumes keep the center lively without
482
575
  // widening the compact hearth silhouette.
483
576
  const plumeCount = Math.max(7, Math.min(11, Math.round(width / 13)));
577
+ const plumes = Array.from({ length: plumeCount }, (_, index) => {
578
+ const phase = hashNoise(index, 3, 0) * Math.PI * 2;
579
+ return {
580
+ index,
581
+ phase,
582
+ baseCenter: fireLeft + ((index + 0.5) / plumeCount) * fireSpan,
583
+ flameHeight: 0.46 + hashNoise(index, 4, 0) * 0.39 + bass * 0.045 + Math.sin(pulse * 0.052 + phase) * 0.055,
584
+ swaySpeed: 0.038 + hashNoise(index, 5, 0) * 0.02,
585
+ baseHalfWidth: fireSpan * (0.095 + hashNoise(index, 6, 0) * 0.035)
586
+ };
587
+ });
484
588
  const embers = Array.from({ length: Math.max(4, Math.round(width / 18)) }, (_, index) => {
485
589
  const cycle = height + 5 + Math.floor(hashNoise(index, 18, 0) * height);
486
590
  const travel = (pulse * (0.13 + hashNoise(index, 19, 0) * 0.1) + hashNoise(index, 20, 0) * cycle) % cycle;
@@ -492,32 +596,33 @@ function buildAsciiFire(pulse, width, height, theme) {
492
596
  });
493
597
  for (let y = 0; y < height; y += 1) {
494
598
  const rise = (height - 1 - y) / Math.max(1, height - 1);
599
+ const activePlumes = plumes.flatMap(plume => {
600
+ if (rise > plume.flameHeight)
601
+ return [];
602
+ const vertical = rise / Math.max(0.01, plume.flameHeight);
603
+ const sway = Math.sin(pulse * plume.swaySpeed + plume.phase + vertical * 3.4) *
604
+ (0.005 + vertical * 0.026) +
605
+ Math.sin(vertical * 8.5 - pulse * 0.09 + plume.index) * vertical * 0.008;
606
+ return [{
607
+ ...plume,
608
+ vertical,
609
+ center: plume.baseCenter + sway,
610
+ halfWidth: Math.max(0.002, plume.baseHalfWidth * (0.18 + 0.82 * Math.pow(1 - vertical, 0.7)))
611
+ }];
612
+ });
495
613
  for (let x = 0; x < width; x += 1) {
496
614
  const nx = x / Math.max(1, width - 1);
497
615
  let heat = 0;
498
- for (let index = 0; index < plumeCount; index += 1) {
499
- const phase = hashNoise(index, 3, 0) * Math.PI * 2;
500
- const baseCenter = fireLeft + ((index + 0.5) / plumeCount) * fireSpan;
501
- const flameHeight = 0.46 + hashNoise(index, 4, 0) * 0.39 + bass * 0.045 + Math.sin(pulse * 0.052 + phase) * 0.055;
502
- if (rise > flameHeight) {
503
- continue;
504
- }
505
- const vertical = rise / Math.max(0.01, flameHeight);
506
- const sway = Math.sin(pulse * (0.038 + hashNoise(index, 5, 0) * 0.02) + phase + vertical * 3.4) *
507
- (0.005 + vertical * 0.026) +
508
- Math.sin(vertical * 8.5 - pulse * 0.09 + index) * vertical * 0.008;
509
- const center = baseCenter + sway;
510
- const baseHalfWidth = fireSpan * (0.095 + hashNoise(index, 6, 0) * 0.035);
511
- const halfWidth = Math.max(0.002, baseHalfWidth * (0.18 + 0.82 * Math.pow(1 - vertical, 0.7)));
512
- const lateral = Math.abs(nx - center) / halfWidth;
616
+ for (const plume of activePlumes) {
617
+ const lateral = Math.abs(nx - plume.center) / plume.halfWidth;
513
618
  if (lateral >= 1.15) {
514
619
  continue;
515
620
  }
516
- const body = Math.exp(-lateral * lateral * 1.7) * (1 - vertical * 0.3);
517
- const upwardCurl = Math.sin(x * 0.31 + rise * 13 - pulse * 0.17 + phase) * 0.1 +
621
+ const body = Math.exp(-lateral * lateral * 1.7) * (1 - plume.vertical * 0.3);
622
+ const upwardCurl = Math.sin(x * 0.31 + rise * 13 - pulse * 0.17 + plume.phase) * 0.1 +
518
623
  Math.sin(x * 0.67 - rise * 19 - pulse * 0.11) * 0.055;
519
- const breakup = hashNoise(Math.floor(x / 2), Math.floor(y / 2), index) * 0.07;
520
- const contribution = Math.max(0, body + upwardCurl + breakup - vertical * 0.1);
624
+ const breakup = hashNoise(Math.floor(x / 2), Math.floor(y / 2), plume.index) * 0.07;
625
+ const contribution = Math.max(0, body + upwardCurl + breakup - plume.vertical * 0.1);
521
626
  heat += contribution * 0.68;
522
627
  }
523
628
  const sideEnvelope = Math.sin(clampNumber((nx - fireLeft) / fireSpan, 0, 1) * Math.PI);
@@ -724,16 +829,13 @@ function buildAsciiDonut(pulse, width, height, theme) {
724
829
  function buildAsciiStarfield(pulse, width, height, theme) {
725
830
  const accent = themeAccent(theme);
726
831
  const ramp = themeContributionColors(theme);
727
- const cx = (width - 1) / 2;
728
- const cy = (height - 1) / 2;
832
+ const geometry = starfieldGeometry(width, height);
729
833
  // Dense multi-layer warp field: dust nebula + mid stars + near streaks + flares
730
834
  return Array.from({ length: height }, (_, y) => {
731
835
  const cells = Array.from({ length: width }, (_, x) => {
732
- const dx = x - cx;
733
- const dy = (y - cy) * 2.05;
734
- const angle = Math.atan2(dy, dx);
735
- const dist = Math.sqrt(dx * dx + dy * dy) + 0.001;
736
- const maxDist = Math.sqrt(cx * cx + (cy * 2.05) ** 2);
836
+ const cellIndex = y * width + x;
837
+ const angle = geometry.angle[cellIndex] ?? 0;
838
+ const dist = geometry.distance[cellIndex] ?? 0.001;
737
839
  // Soft nebula dust so the void isn't empty
738
840
  const nebula = 0.22 * Math.sin(angle * 3 + pulse * 0.03) +
739
841
  0.18 * Math.sin(dist * 0.12 - pulse * 0.04 + angle * 2) +
@@ -747,17 +849,14 @@ function buildAsciiStarfield(pulse, width, height, theme) {
747
849
  for (let layer = 0; layer < 4; layer += 1) {
748
850
  const layerSpeed = 0.35 + layer * 0.55;
749
851
  // Quantize angle into star "lanes" so stars feel discrete
750
- const laneCount = 48 + layer * 36;
751
- const lane = Math.floor(((angle + Math.PI) / (Math.PI * 2)) * laneCount);
752
- const laneAngle = (lane / laneCount) * Math.PI * 2 - Math.PI;
753
- const angDiff = Math.abs(Math.atan2(Math.sin(angle - laneAngle), Math.cos(angle - laneAngle)));
852
+ const angDiff = geometry.angularDifference[layer]?.[cellIndex] ?? Math.PI;
754
853
  if (angDiff > 0.04 + layer * 0.01) {
755
854
  continue;
756
855
  }
757
- const seed = lane * 17 + layer * 91;
758
- const phase = hashNoise(seed, layer, 0);
856
+ const seed = geometry.seed[layer]?.[cellIndex] ?? 0;
857
+ const phase = geometry.phase[layer]?.[cellIndex] ?? 0;
759
858
  const depth = ((pulse * layerSpeed * 0.45 + phase * 40 + seed) % 100) / 100;
760
- const starDist = Math.pow(depth, 1.55) * maxDist * (0.15 + phase * 0.85);
859
+ const starDist = Math.pow(depth, 1.55) * geometry.maxDistance * (0.15 + phase * 0.85);
761
860
  const along = Math.abs(dist - starDist);
762
861
  // Near stars elongate into streaks
763
862
  const streak = layer >= 2 ? 0.8 + depth * 4.5 : 0.35 + depth * 1.2;
@@ -861,48 +960,6 @@ function buildRibbon(pulse, width, height, theme) {
861
960
  }
862
961
  return grid.map(row => lineFromCells(row, accent));
863
962
  }
864
- function buildMirror(pulse, width, height, theme) {
865
- const grid = emptyMotionGrid(width, height, '#0a0e16');
866
- const colors = themeContributionColors(theme);
867
- const accent = themeAccent(theme);
868
- const baseY = Math.round((height - 1) * 0.66);
869
- const bandWidth = 2;
870
- const bands = Math.ceil((width + 1) / bandWidth);
871
- const sample = (band, p) => clampNumber((Math.sin(band * 0.4 + p * 0.3) * 0.42 +
872
- Math.sin(band * 0.13 - p * 0.18) * 0.34 +
873
- Math.sin(band * 0.66 + p * 0.52) * 0.24 +
874
- 1) /
875
- 2, 0, 1);
876
- for (let band = 0; band < bands; band += 1) {
877
- const x = band * bandWidth;
878
- const up = Math.round(sample(band, pulse) * baseY);
879
- let peak = 0;
880
- for (let k = 0; k < 10; k += 1) {
881
- peak = Math.max(peak, Math.round(sample(band, pulse - k) * baseY));
882
- }
883
- for (let d = 0; d <= up; d += 1) {
884
- const frac = d / Math.max(1, baseY);
885
- const colorIndex = Math.min(colors.length - 1, 1 + Math.round(frac * (colors.length - 2)));
886
- paintCell(grid, x, baseY - d, '█', colors[colorIndex] ?? accent);
887
- }
888
- const reflection = Math.round(up * 0.45);
889
- for (let d = 1; d <= reflection; d += 1) {
890
- paintCell(grid, x, baseY + d, d > reflection - 1 ? '░' : '▒', '#27313d');
891
- }
892
- if (peak > 0) {
893
- paintCell(grid, x, baseY - peak, '▀', '#ffffff');
894
- }
895
- }
896
- const baseline = grid[baseY];
897
- if (baseline) {
898
- for (let x = 0; x < width; x += 1) {
899
- if (baseline[x]?.text === ' ') {
900
- paintCell(grid, x, baseY, '─', '#1d2530');
901
- }
902
- }
903
- }
904
- return grid.map(row => lineFromCells(row, accent));
905
- }
906
963
  function buildTunnel(pulse, width, height, theme) {
907
964
  const grid = emptyMotionGrid(width, height, '#04060a');
908
965
  const colors = themeContributionColors(theme);
@@ -995,30 +1052,28 @@ function buildConstellation(pulse, width, height, theme) {
995
1052
  return grid.map(row => lineFromCells(row, accent));
996
1053
  }
997
1054
  function buildPulseGrid(pulse, width, height, theme) {
998
- const canvas = createBraille(width, height);
1055
+ const grid = emptyMotionGrid(width, height, '#0a0e16');
999
1056
  const accent = themeAccent(theme);
1000
1057
  const colors = themeContributionColors(theme);
1001
- const dotsWide = Math.max(1, width * 2);
1002
- const dotsHigh = Math.max(1, height * 4);
1003
- const cx = (dotsWide - 1) / 2;
1004
- const cy = (dotsHigh - 1) / 2;
1005
- const ringGap = Math.max(5, Math.min(dotsWide, dotsHigh) * 0.24);
1006
- const travel = (pulse * 0.65) % ringGap;
1007
- const stroke = Math.max(0.65, ringGap * 0.1);
1008
- for (let y = 0; y < dotsHigh; y += 1) {
1009
- for (let x = 0; x < dotsWide; x += 1) {
1010
- const radius = Math.hypot(x - cx, y - cy);
1011
- const phase = ((radius - travel) % ringGap + ringGap) % ringGap;
1012
- const distanceToRing = Math.min(phase, ringGap - phase);
1013
- if (distanceToRing > stroke)
1058
+ const cx = (width - 1) / 2;
1059
+ const cy = (height - 1) / 2;
1060
+ const levels = ['·', '∘', '•', '●', '◉'];
1061
+ for (let y = 0; y < height; y += 1) {
1062
+ for (let x = 0; x < width; x += 1) {
1063
+ if (x % 2 === 1) {
1014
1064
  continue;
1015
- const ring = Math.floor((radius + ringGap - travel) / ringGap);
1016
- const intensity = 1 - distanceToRing / stroke;
1017
- const colorIndex = Math.min(colors.length - 1, Math.max(1, colors.length - 1 - (ring % Math.max(1, colors.length - 1))));
1018
- brailleSet(canvas, x, y, intensity > 0.82 ? '#ffffff' : colors[colorIndex] ?? accent);
1065
+ }
1066
+ const dx = (x - cx) / 2.0;
1067
+ const dy = y - cy;
1068
+ const r = Math.sqrt(dx * dx + dy * dy);
1069
+ const wave = Math.sin(r * 0.7 - pulse * 0.3) * 0.5 + 0.5;
1070
+ const value = clampNumber(wave, 0, 1);
1071
+ const levelIndex = Math.min(levels.length - 1, Math.floor(value * levels.length));
1072
+ const colorIndex = Math.min(colors.length - 1, Math.max(1, Math.round(value * (colors.length - 1))));
1073
+ paintCell(grid, x, y, levels[levelIndex] ?? '·', value > 0.16 ? colors[colorIndex] ?? accent : '#1a2029');
1019
1074
  }
1020
1075
  }
1021
- return brailleToLines(canvas, accent);
1076
+ return grid.map(row => lineFromCells(row, accent));
1022
1077
  }
1023
1078
  function buildLissajous(pulse, width, height, theme) {
1024
1079
  const grid = emptyMotionGrid(width, height, '#04070d');
@@ -1986,29 +2041,31 @@ function buildAurora(pulse, width, height, theme) {
1986
2041
  const curtains = 6;
1987
2042
  return Array.from({ length: height }, (_, y) => {
1988
2043
  const ny = y / Math.max(1, height - 1);
2044
+ const curtainRows = Array.from({ length: curtains }, (_, c) => {
2045
+ const fold = 0.08 +
2046
+ c * 0.15 +
2047
+ 0.06 * Math.sin(pulse * 0.035 + c * 1.9) +
2048
+ 0.03 * Math.sin(y * 0.28 + pulse * 0.05 + c) +
2049
+ 0.02 * Math.sin(y * 0.55 - pulse * 0.04 + c * 0.7);
2050
+ const halfWidth = 0.045 + 0.02 * Math.sin(pulse * 0.03 + c) + 0.015 * Math.sin(y * 0.4 + c);
2051
+ const vertical = 0.35 +
2052
+ 0.65 * Math.sin(Math.PI * clampNumber(ny * 1.15 - 0.05, 0, 1)) *
2053
+ (0.7 + 0.3 * Math.sin(pulse * 0.06 + c * 1.1 + y * 0.15));
2054
+ return { c, fold, halfWidth, vertical };
2055
+ });
1989
2056
  const cells = Array.from({ length: width }, (_, x) => {
1990
2057
  const nx = x / Math.max(1, width - 1);
1991
2058
  let field = 0;
1992
2059
  let filament = 0;
1993
- for (let c = 0; c < curtains; c += 1) {
1994
- // Curtain folds drift slowly; each sheet has different phase & width
1995
- const fold = 0.08 +
1996
- c * 0.15 +
1997
- 0.06 * Math.sin(pulse * 0.035 + c * 1.9) +
1998
- 0.03 * Math.sin(y * 0.28 + pulse * 0.05 + c) +
1999
- 0.02 * Math.sin(y * 0.55 - pulse * 0.04 + c * 0.7);
2000
- const halfWidth = 0.045 + 0.02 * Math.sin(pulse * 0.03 + c) + 0.015 * Math.sin(y * 0.4 + c);
2001
- const dist = Math.abs(nx - fold);
2060
+ for (const curtain of curtainRows) {
2061
+ // Curtain folds drift slowly; each sheet has different phase & width.
2062
+ const dist = Math.abs(nx - curtain.fold);
2002
2063
  // Soft body of the curtain (filled, not holey)
2003
- const body = Math.exp(-Math.pow(dist / halfWidth, 2));
2004
- // Vertical brightness: bright mid-band, dimmer top & bottom edge
2005
- const vertical = 0.35 +
2006
- 0.65 * Math.sin(Math.PI * clampNumber(ny * 1.15 - 0.05, 0, 1)) *
2007
- (0.7 + 0.3 * Math.sin(pulse * 0.06 + c * 1.1 + y * 0.15));
2008
- field += body * vertical * (0.85 + 0.15 * Math.sin(pulse * 0.08 + c));
2064
+ const body = Math.exp(-Math.pow(dist / curtain.halfWidth, 2));
2065
+ field += body * curtain.vertical * (0.85 + 0.15 * Math.sin(pulse * 0.08 + curtain.c));
2009
2066
  // Fine vertical filaments inside the curtain
2010
2067
  if (body > 0.15) {
2011
- const micro = Math.sin(x * (2.8 + c * 0.4) + pulse * 0.25 + y * 0.1);
2068
+ const micro = Math.sin(x * (2.8 + curtain.c * 0.4) + pulse * 0.25 + y * 0.1);
2012
2069
  filament += body * Math.max(0, micro) * 0.35;
2013
2070
  }
2014
2071
  }
@@ -4140,9 +4197,10 @@ function rgbColor(red, green, blue) {
4140
4197
  return `#${channel(red)}${channel(green)}${channel(blue)}`;
4141
4198
  }
4142
4199
  function lineFromCells(cells, fallbackColor) {
4143
- const text = cells.map(cell => cell.text).join('');
4200
+ let text = '';
4144
4201
  const segments = [];
4145
4202
  for (const cell of cells) {
4203
+ text += cell.text;
4146
4204
  const invisibleForeground = cell.text.trim() === '' && cell.backgroundColor === undefined;
4147
4205
  const color = invisibleForeground ? fallbackColor : cell.color;
4148
4206
  const bold = invisibleForeground ? undefined : cell.bold;
@@ -4310,6 +4368,53 @@ function hashNoise(x, y, pulse) {
4310
4368
  const value = Math.sin(x * 12.9898 + y * 78.233 + pulse * 0.037) * 43758.5453;
4311
4369
  return value - Math.floor(value);
4312
4370
  }
4371
+ const STARFIELD_GEOMETRY_CACHE_LIMIT = 4;
4372
+ const starfieldGeometryCache = new Map();
4373
+ function starfieldGeometry(width, height) {
4374
+ const key = `${width}x${height}`;
4375
+ const cached = starfieldGeometryCache.get(key);
4376
+ if (cached)
4377
+ return cached;
4378
+ const size = Math.max(0, width * height);
4379
+ const angle = new Float64Array(size);
4380
+ const distance = new Float64Array(size);
4381
+ const angularDifference = Array.from({ length: 4 }, () => new Float64Array(size));
4382
+ const seed = Array.from({ length: 4 }, () => new Int32Array(size));
4383
+ const phase = Array.from({ length: 4 }, () => new Float64Array(size));
4384
+ const cx = (width - 1) / 2;
4385
+ const cy = (height - 1) / 2;
4386
+ const maxDistance = Math.sqrt(cx * cx + (cy * 2.05) ** 2);
4387
+ for (let y = 0; y < height; y += 1) {
4388
+ for (let x = 0; x < width; x += 1) {
4389
+ const index = y * width + x;
4390
+ const dx = x - cx;
4391
+ const dy = (y - cy) * 2.05;
4392
+ const cellAngle = Math.atan2(dy, dx);
4393
+ angle[index] = cellAngle;
4394
+ distance[index] = Math.sqrt(dx * dx + dy * dy) + 0.001;
4395
+ for (let layer = 0; layer < 4; layer += 1) {
4396
+ const laneCount = 48 + layer * 36;
4397
+ const lane = Math.floor(((cellAngle + Math.PI) / (Math.PI * 2)) * laneCount);
4398
+ const laneAngle = (lane / laneCount) * Math.PI * 2 - Math.PI;
4399
+ angularDifference[layer][index] = Math.abs(Math.atan2(Math.sin(cellAngle - laneAngle), Math.cos(cellAngle - laneAngle)));
4400
+ const cellSeed = lane * 17 + layer * 91;
4401
+ seed[layer][index] = cellSeed;
4402
+ phase[layer][index] = hashNoise(cellSeed, layer, 0);
4403
+ }
4404
+ }
4405
+ }
4406
+ const geometry = { angle, distance, maxDistance, angularDifference, seed, phase };
4407
+ rememberGeometry(starfieldGeometryCache, key, geometry);
4408
+ return geometry;
4409
+ }
4410
+ function rememberGeometry(cache, key, value) {
4411
+ if (cache.size >= STARFIELD_GEOMETRY_CACHE_LIMIT) {
4412
+ const oldest = cache.keys().next().value;
4413
+ if (oldest !== undefined)
4414
+ cache.delete(oldest);
4415
+ }
4416
+ cache.set(key, value);
4417
+ }
4313
4418
  function winterPrecipitationSpeed(index) {
4314
4419
  return 0.5 + hashNoise(index, 51, 0) * 0.8;
4315
4420
  }
@@ -0,0 +1,4 @@
1
+ import { terminalReceiverBuilders } from './terminal-receivers.js';
2
+ export const retroReceiverBuilders = {
3
+ ...terminalReceiverBuilders
4
+ };
@@ -0,0 +1,57 @@
1
+ import { buildGallopFine } from './gallop.js';
2
+ import { panelBackground, themeAccent, themeContributionColors } from '../theme.js';
3
+ const clamp = (n) => Math.max(0, Math.min(1, n));
4
+ function blend(a, b, t) {
5
+ return '#' + [1, 3, 5].map(offset => Math.round(parseInt(a.slice(offset, offset + 2), 16) * (1 - t) + parseInt(b.slice(offset, offset + 2), 16) * t).toString(16).padStart(2, '0')).join('');
6
+ }
7
+ /** A cell canvas, not a bitmap: glyphs, whole-cell color and intentional empty space. */
8
+ function canvas(width, height, theme) {
9
+ const w = Math.max(1, Math.floor(width));
10
+ const h = Math.max(1, Math.floor(height));
11
+ const accent = themeAccent(theme);
12
+ const contributions = themeContributionColors(theme);
13
+ const ramp = [panelBackground, blend(panelBackground, contributions[1], 0.55),
14
+ ...contributions.slice(1), blend(accent, '#ffffff', 0.36), '#ffffff'];
15
+ const cells = Array.from({ length: h }, () => Array.from({ length: w }, () => ({ text: ' ', color: accent })));
16
+ const paint = (x, y, text, level, background = false, color) => {
17
+ const row = Math.round(y);
18
+ const col = Math.round(x);
19
+ if (row < 0 || row >= h || col < 0 || col >= w)
20
+ return;
21
+ const ink = color ?? ramp[Math.max(0, Math.min(ramp.length - 1, Math.round(level)))];
22
+ cells[row][col] = background ? { text: ' ', color: ink, backgroundColor: ink } : { text, color: ink };
23
+ };
24
+ const finish = () => cells.map(row => {
25
+ const segments = [];
26
+ for (const cell of row) {
27
+ const last = segments.at(-1);
28
+ if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor)
29
+ last.text += cell.text;
30
+ else
31
+ segments.push({ ...cell });
32
+ }
33
+ return { text: row.map(c => c.text).join(''), color: accent, segments };
34
+ });
35
+ return { w, h, ramp, paint, finish };
36
+ }
37
+ // Interleaved wavefronts, folded through each other. Entire cells are the light source.
38
+ const crossfade = (pulse, width, height, theme) => {
39
+ const c = canvas(width, height, theme);
40
+ const t = pulse * 0.08;
41
+ const shades = Array.from({ length: 18 }, (_, i) => blend(c.ramp[2], c.ramp[5], i / 17));
42
+ for (let y = 0; y < c.h; y++) {
43
+ for (let x = 0; x < c.w; x++) {
44
+ const u = x / c.w * 2 - 1;
45
+ const v = y / c.h * 2 - 1;
46
+ const a = u * 8.5 + v * 3 + Math.sin(v * 3.4 - t * 0.7) * 2 - t * 1.1;
47
+ const b = v * 5 - u * 2 + Math.sin(u * 3 + t * 0.5) * 1.5 + t * 0.75;
48
+ const value = clamp(0.5 + 0.28 * Math.cos(a) + 0.24 * Math.cos(b));
49
+ c.paint(x, y, ' ', 0, true, shades[Math.round(value * 17)]);
50
+ }
51
+ }
52
+ return c.finish();
53
+ };
54
+ export const terminalReceiverBuilders = {
55
+ crossfade,
56
+ 'gallop-fine': buildGallopFine
57
+ };
@@ -40,17 +40,26 @@ export function shouldCheckForUpdate(updateCheck, now = Date.now()) {
40
40
  const checkedAt = Date.parse(updateCheck.checkedAt);
41
41
  return !Number.isFinite(checkedAt) || now - checkedAt >= UPDATE_CHECK_INTERVAL_MS;
42
42
  }
43
- export function updateStatusText(updateCheck) {
43
+ export function automaticUpdateChecksAllowed(enabled = true) {
44
+ return enabled && process.env.RADIOCLI_DISABLE_UPDATE_CHECK !== '1' && process.env.CI !== 'true';
45
+ }
46
+ export function updateAvailableForVersion(updateCheck, currentVersion = updateCheck?.currentVersion) {
47
+ return Boolean(updateCheck?.updateAvailable &&
48
+ updateCheck.latestVersion &&
49
+ currentVersion &&
50
+ compareSemver(updateCheck.latestVersion, currentVersion) > 0);
51
+ }
52
+ export function updateStatusText(updateCheck, currentVersion) {
44
53
  if (!updateCheck) {
45
54
  return 'not checked yet';
46
55
  }
47
- if (updateCheck.updateAvailable && updateCheck.latestVersion) {
56
+ if (updateAvailableForVersion(updateCheck, currentVersion) && updateCheck.latestVersion) {
48
57
  return `v${updateCheck.latestVersion} available`;
49
58
  }
50
59
  if (updateCheck.error) {
51
60
  return `check failed: ${updateCheck.error}`;
52
61
  }
53
- return updateCheck.latestVersion ? `current at v${updateCheck.latestVersion}` : 'not checked yet';
62
+ return updateCheck.latestVersion ? `current at v${currentVersion ?? updateCheck.latestVersion}` : 'not checked yet';
54
63
  }
55
64
  export function updateCommandForInstall(entryPath = process.argv[1]) {
56
65
  const resolved = resolvePath(entryPath);
@@ -58,16 +67,21 @@ export function updateCommandForInstall(entryPath = process.argv[1]) {
58
67
  if (/\/(?:opt\/homebrew|usr\/local)\/(?:Cellar|Homebrew)\//.test(haystack) || /\/\.linuxbrew\/(?:Cellar|Homebrew)\//.test(haystack)) {
59
68
  return { method: 'homebrew', command: 'brew update && brew upgrade radiocli' };
60
69
  }
61
- if (/\/node_modules\/@ciphore\/radiocli\//.test(haystack)) {
70
+ if (/[\\/](?:pnpm|\.pnpm-global)[\\/]/.test(haystack)) {
71
+ return { method: 'pnpm', command: 'pnpm add -g @ciphore/radiocli@latest' };
72
+ }
73
+ if (/[\\/]\.bun[\\/]install[\\/]global[\\/]/.test(haystack)) {
74
+ return { method: 'bun', command: 'bun add -g @ciphore/radiocli@latest' };
75
+ }
76
+ if (/[\\/]node_modules[\\/]@ciphore[\\/]radiocli[\\/]/.test(haystack)) {
62
77
  return { method: 'npm', command: 'npm install -g @ciphore/radiocli@latest' };
63
78
  }
64
79
  return { method: 'unknown', command: 'npm install -g @ciphore/radiocli@latest' };
65
80
  }
66
81
  export function installUpdate(command = updateCommandForInstall().command) {
67
82
  return new Promise(resolve => {
68
- const shell = process.platform === 'win32' ? 'cmd.exe' : 'sh';
69
- const args = process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-lc', command];
70
- const child = spawn(shell, args, { stdio: ['ignore', 'pipe', 'pipe'] });
83
+ const shell = updateShellForPlatform(process.platform, command);
84
+ const child = spawn(shell.command, shell.args, { stdio: ['ignore', 'pipe', 'pipe'] });
71
85
  const chunks = [];
72
86
  child.stdout.on('data', chunk => chunks.push(Buffer.from(chunk)));
73
87
  child.stderr.on('data', chunk => chunks.push(Buffer.from(chunk)));
@@ -80,6 +94,11 @@ export function installUpdate(command = updateCommandForInstall().command) {
80
94
  });
81
95
  });
82
96
  }
97
+ export function updateShellForPlatform(platform, command) {
98
+ return platform === 'win32'
99
+ ? { command: 'cmd.exe', args: ['/d', '/s', '/c', command] }
100
+ : { command: 'sh', args: ['-lc', command] };
101
+ }
83
102
  export function compareSemver(left, right) {
84
103
  const leftParts = semverParts(left);
85
104
  const rightParts = semverParts(right);