@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
package/dist/ui/App.js CHANGED
@@ -1,20 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { Box, Text, useApp, useStdin, useStdout, useWindowSize } from 'ink';
4
5
  import { ProviderManager } from '../providers/provider-manager.js';
5
6
  import { PlayerController } from '../player/player-controller.js';
6
7
  import { playbackBackendInstallHint, playbackBackendLabel } from '../player/backend-install.js';
7
8
  import { JsonLibraryStore, stationKey } from '../storage/store.js';
8
- import { receiverStyleNames } from '../types.js';
9
+ import { defaultAgentControlSettings } from '../types.js';
9
10
  import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
10
11
  import { DisplayContext, resolveDisplayMode } from './display-context.js';
11
- import { homeItems, settingsItems } from './screen-items.js';
12
+ import { homeItems, settingsItemsForPage } from './screen-items.js';
12
13
  import { AppContent } from './AppContent.js';
13
14
  import { TopTabs } from './components/TopTabs.js';
14
15
  import { computeTerminalLayout } from './layout.js';
15
16
  import { truncate } from './format.js';
16
17
  import { playbackFooterText, playbackStateForPendingStation } from './playback-footer.js';
17
- import { fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, pageFooterText } from './page-footer.js';
18
+ import { balancedFooterLegendRows, fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, microShortcutFooterText, pageFooterText } from './page-footer.js';
18
19
  import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting } from './terminal-mouse.js';
19
20
  import { useAppInput } from './use-app-input.js';
20
21
  import { useCommandExecutor } from './use-command-executor.js';
@@ -23,14 +24,18 @@ import { isAirPlayBackendAvailable } from './airplay-settings.js';
23
24
  import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
24
25
  import { copyToClipboard, openExternal } from './system-actions.js';
25
26
  import { appVersion } from '../version.js';
26
- import { checkForUpdate, installUpdate, shouldCheckForUpdate, updateCommandForInstall } from '../update-check.js';
27
+ import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
27
28
  import { helpItemCount } from './help-content.js';
28
29
  import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
29
- import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextReceiverPulse, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldAnimateReceiver, shouldResetReceiverPulse, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
30
- const LIVE_RECEIVER_STYLES = new Set(receiverStyleNames);
31
- const LIVE_RECEIVER_PULSE_MS = 80;
32
- const SKYLINE_RECEIVER_PULSE_MS = 120;
33
- const AMBIENT_RECEIVER_PULSE_MS = 140;
30
+ import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
31
+ import { useAlarmTui } from './use-alarm-tui.js';
32
+ import { toAsciiSafe } from './ascii.js';
33
+ import { registerTuiPresence } from '../alarms/tui-presence.js';
34
+ import { configureMcpIntegrations } from '../agent/mcp-install.js';
35
+ import { startRadioSession } from '../agent/session.js';
36
+ import { activeTabForScreen, addMediaKeyBinding, applyStationFilters, clamp, clampVolume, defaultExploreCursor, formatExploreCursor, formatFilterLabel, formatTimeLeft, initialStationContexts, mediaActionLabel, moveExploreCursor as shiftExploreCursor, nextSleepTimerMinutes, normalizeMediaKeyBindings, shouldSkipAfterTuneError, stationApproximateTime, stationContextKeyForScreen, topTabs } from './app-state.js';
37
+ import { ReceiverAnimationProvider } from './receiver-animation.js';
38
+ import { VersionIndicator, versionIndicatorWidth } from './components/VersionIndicator.js';
34
39
  const LOADING_SPINNER_MS = 120;
35
40
  const VISUALIZER_MESSAGE_MS = 4500;
36
41
  const LISTENING_HEARTBEAT_MS = 30_000;
@@ -38,20 +43,23 @@ const COUNTRY_STATIONS_PAGE_SIZE = 120;
38
43
  const COUNTRY_STATIONS_LOAD_AHEAD = 12;
39
44
  const SEARCH_RESULTS_PAGE_SIZE = 90;
40
45
  const SEARCH_RESULTS_LOAD_AHEAD = 12;
46
+ const BROKEN_STREAM_AUTO_SKIP_DELAY_MS = 1000;
41
47
  const settingToggleLabel = {
42
48
  resumeOnLaunch: 'Resume on launch',
43
49
  transparentBackground: 'Transparent background',
44
50
  asciiMode: 'ASCII-safe display',
45
51
  reduceMotion: 'Reduce motion',
46
- mouseSupport: 'Mouse and trackpad scrolling'
52
+ mouseSupport: 'Mouse and trackpad scrolling',
53
+ automaticUpdateChecks: 'Automatic update checks'
47
54
  };
48
- export function App({ store: providedStore, providers: providedProviders }) {
55
+ export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate }) {
49
56
  const { exit } = useApp();
50
57
  const { stdin } = useStdin();
51
58
  const { stdout } = useStdout();
52
59
  const { columns, rows } = useWindowSize();
53
60
  const store = useMemo(() => providedStore ?? new JsonLibraryStore(), [providedStore]);
54
61
  const providers = useMemo(() => providedProviders ?? new ProviderManager(), [providedProviders]);
62
+ const alarmService = useMemo(() => providedAlarmService ? serializeAlarmTuiService(providedAlarmService) : createAlarmTuiService(), [providedAlarmService]);
55
63
  const installedVersion = useMemo(() => appVersion(), []);
56
64
  const [library, setLibrary] = useState(() => store.snapshot());
57
65
  const settingsRef = useRef(library.settings);
@@ -62,6 +70,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
62
70
  const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
63
71
  const [screen, setScreen] = useState('home');
64
72
  const [selected, setSelected] = useState(0);
73
+ const [settingsPage, setSettingsPage] = useState('root');
65
74
  const [message, setMessage] = useState(null);
66
75
  const [footerMessage, setFooterMessage] = useState(null);
67
76
  const [countries, setCountries] = useState([]);
@@ -78,7 +87,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
78
87
  const [location, setLocation] = useState(null);
79
88
  const [exploreCursor, setExploreCursor] = useState(defaultExploreCursor);
80
89
  const [providerHealth, setProviderHealth] = useState({});
81
- const [pulse, setPulse] = useState(0);
82
90
  const [spinnerFrame, setSpinnerFrame] = useState(0);
83
91
  const [commandMode, setCommandMode] = useState(false);
84
92
  const [commandText, setCommandText] = useState('');
@@ -88,7 +96,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
88
96
  const [showDiagnostics, setShowDiagnostics] = useState(false);
89
97
  const [capturingTransportAction, setCapturingTransportAction] = useState(null);
90
98
  const announcedUpdateRef = useRef(false);
99
+ const automaticUpdateCheckStartedRef = useRef(false);
91
100
  const installingUpdateRef = useRef(false);
101
+ const configuringMcpRef = useRef(false);
92
102
  const displayStationsRef = useRef([]);
93
103
  const playbackQueueRef = useRef(null);
94
104
  const lastRawTransportAtRef = useRef(0);
@@ -98,6 +108,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
98
108
  const playStationRef = useRef(() => undefined);
99
109
  const screenRef = useRef(screen);
100
110
  const selectedRef = useRef(selected);
111
+ const settingsPageRef = useRef(settingsPage);
112
+ const selectedBySettingsPageRef = useRef({});
101
113
  const selectedByScreenRef = useRef({});
102
114
  const stationContextsRef = useRef(stationContexts);
103
115
  const lastStationContextKeyRef = useRef('explore');
@@ -109,7 +121,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
109
121
  const transientMessageTimerRef = useRef(null);
110
122
  const transientFooterMessageTimerRef = useRef(null);
111
123
  const exitConfirmationUntilRef = useRef(0);
112
- const receiverPulseSnapshotRef = useRef(null);
113
124
  const helpReturnScreenRef = useRef('home');
114
125
  const countriesLoadAttemptedRef = useRef(false);
115
126
  const tuneRequestRef = useRef(0);
@@ -118,6 +129,9 @@ export function App({ store: providedStore, providers: providedProviders }) {
118
129
  const nearbyRequestRef = useRef(0);
119
130
  const skipBrokenTimerRef = useRef(null);
120
131
  const volumeRequestRef = useRef(0);
132
+ const agentHandlerRef = useRef(async () => {
133
+ throw new Error('RadioCLI is still starting.');
134
+ });
121
135
  const theme = library.settings.theme;
122
136
  const displayMode = useMemo(() => resolveDisplayMode(library.settings), [library.settings.transparentBackground, library.settings.asciiMode, library.settings.reduceMotion]);
123
137
  const favoriteKeys = useMemo(() => new Set(library.favorites.map(stationKey)), [library.favorites]);
@@ -141,6 +155,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
141
155
  }), [library, libraryStations, stationContexts]);
142
156
  screenRef.current = screen;
143
157
  selectedRef.current = selected;
158
+ settingsPageRef.current = settingsPage;
144
159
  loadingStationsRef.current = loadingStations;
145
160
  stationContextsRef.current = activeStationContexts;
146
161
  exploreCursorRef.current = exploreCursor;
@@ -165,9 +180,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
165
180
  'now-playing': 1,
166
181
  library: 0,
167
182
  stats: 1,
183
+ alarms: 0,
184
+ 'alarm-editor': 0,
185
+ 'alarm-picker': 0,
186
+ 'alarm-ringing': 0,
168
187
  'airplay-settings': 0,
169
188
  'airplay-code': 1,
170
- settings: settingsItems.length,
189
+ settings: settingsItemsForPage(settingsPage).length,
171
190
  help: helpItemCount
172
191
  });
173
192
  itemCountsRef.current = {
@@ -181,9 +200,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
181
200
  'now-playing': 1,
182
201
  library: stationCounts.library,
183
202
  stats: 1,
203
+ alarms: library.alarms.length,
204
+ 'alarm-editor': 0,
205
+ 'alarm-picker': 0,
206
+ 'alarm-ringing': 0,
184
207
  'airplay-settings': availableAirPlayDevices.length,
185
208
  'airplay-code': 1,
186
- settings: settingsItems.length,
209
+ settings: settingsItemsForPage(settingsPage).length,
187
210
  help: helpItemCount
188
211
  };
189
212
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
@@ -204,6 +227,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
204
227
  !editingCountryFilter &&
205
228
  shouldEnableMouseReporting(screen, itemCountsRef.current[screen] ?? 0, mouseVisibleRows(screen, layout), library.settings.mouseSupport !== false);
206
229
  useEffect(() => player.onChange(setPlayback), [player]);
230
+ useEffect(() => stdin.isTTY ? registerTuiPresence() : undefined, [stdin]);
207
231
  const playingStationRef = useRef(null);
208
232
  playingStationRef.current = playingStation;
209
233
  const activeListeningStationRef = useRef(null);
@@ -259,33 +283,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
259
283
  lastStationContextKeyRef.current = renderedStationContextKey;
260
284
  }
261
285
  }, [renderedStationContextKey, screen, selected]);
262
- useEffect(() => {
263
- const current = {
264
- screen,
265
- receiverStyle: library.settings.receiverStyle,
266
- playbackState: playback.state,
267
- playbackReady: playback.ready
268
- };
269
- if (shouldResetReceiverPulse(receiverPulseSnapshotRef.current, current)) {
270
- setPulse(0);
271
- }
272
- receiverPulseSnapshotRef.current = current;
273
- }, [library.settings.receiverStyle, playback.ready, playback.state, screen]);
274
- useEffect(() => {
275
- if (!shouldAnimateReceiver(screen, playback) ||
276
- library.settings.reduceMotion ||
277
- process.env.RADIOCLI_DISABLE_ANIMATION === '1' ||
278
- process.env.RADIO_ATLAS_DISABLE_ANIMATION === '1') {
279
- return;
280
- }
281
- const intervalMs = library.settings.receiverStyle === 'skyline'
282
- ? SKYLINE_RECEIVER_PULSE_MS
283
- : LIVE_RECEIVER_STYLES.has(library.settings.receiverStyle)
284
- ? LIVE_RECEIVER_PULSE_MS
285
- : AMBIENT_RECEIVER_PULSE_MS;
286
- const timer = setInterval(() => setPulse(nextReceiverPulse), intervalMs);
287
- return () => clearInterval(timer);
288
- }, [library.settings.receiverStyle, library.settings.reduceMotion, playback.ready, playback.state, screen]);
289
286
  useEffect(() => {
290
287
  if (footerPlayback.state !== 'loading' ||
291
288
  library.settings.reduceMotion ||
@@ -363,14 +360,16 @@ export function App({ store: providedStore, providers: providedProviders }) {
363
360
  refreshProviderHealth();
364
361
  }, [refreshProviderHealth]);
365
362
  const refreshUpdateCheck = useCallback(async () => {
366
- const updateCheck = await checkForUpdate({ currentVersion: installedVersion });
363
+ const updateCheck = await updateChecker({ currentVersion: installedVersion });
367
364
  setLibrary(store.updateCheckState(updateCheck));
368
365
  return updateCheck;
369
- }, [installedVersion, store]);
366
+ }, [installedVersion, store, updateChecker]);
370
367
  useEffect(() => {
371
- if (!shouldCheckForUpdate(library.updateCheck)) {
368
+ if (automaticUpdateCheckStartedRef.current ||
369
+ !automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
372
370
  return;
373
371
  }
372
+ automaticUpdateCheckStartedRef.current = true;
374
373
  let cancelled = false;
375
374
  void refreshUpdateCheck().then(updateCheck => {
376
375
  if (cancelled) {
@@ -384,7 +383,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
384
383
  return () => {
385
384
  cancelled = true;
386
385
  };
387
- }, [library.updateCheck, refreshUpdateCheck]);
386
+ }, [library.settings.automaticUpdateChecks, refreshUpdateCheck]);
388
387
  const setStationContextFor = useCallback((key, context) => {
389
388
  setStationContexts(current => ({ ...current, [key]: context }));
390
389
  }, []);
@@ -432,18 +431,40 @@ export function App({ store: providedStore, providers: providedProviders }) {
432
431
  const destination = screenRef.current === 'help' && next === 'home'
433
432
  ? helpReturnScreenRef.current
434
433
  : next;
434
+ if (destination === 'settings' &&
435
+ screenRef.current !== 'settings' &&
436
+ screenRef.current !== 'airplay-settings' &&
437
+ screenRef.current !== 'airplay-code') {
438
+ settingsPageRef.current = 'root';
439
+ setSettingsPage('root');
440
+ itemCountsRef.current.settings = settingsItemsForPage('root').length;
441
+ selectedByScreenRef.current.settings = selectedBySettingsPageRef.current.root ?? 0;
442
+ }
435
443
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
436
444
  const remembered = selectedByScreenRef.current[destination] ?? 0;
437
445
  const nextSelection = options.resetSelection ? 0 : remembered;
438
- if (destination === 'now-playing' && screenRef.current !== 'now-playing') {
439
- setPulse(0);
440
- }
441
446
  setScreen(destination);
442
447
  setSelected(clamp(nextSelection, (itemCountsRef.current[destination] ?? 0) - 1));
443
448
  if (options.clearMessage !== false) {
444
449
  setMessage(null);
445
450
  }
446
451
  }, []);
452
+ const openSettingsPage = useCallback((next) => {
453
+ selectedBySettingsPageRef.current[settingsPageRef.current] = selectedRef.current;
454
+ settingsPageRef.current = next;
455
+ setSettingsPage(next);
456
+ const nextSelection = clamp(selectedBySettingsPageRef.current[next] ?? 0, settingsItemsForPage(next).length - 1);
457
+ selectedRef.current = nextSelection;
458
+ setSelected(nextSelection);
459
+ setMessage(null);
460
+ }, []);
461
+ const closeSettingsPage = useCallback(() => {
462
+ if (settingsPageRef.current === 'root') {
463
+ go('home');
464
+ return;
465
+ }
466
+ openSettingsPage('root');
467
+ }, [go, openSettingsPage]);
447
468
  const openAirPlayCode = useCallback(() => {
448
469
  setAirPlayCode('');
449
470
  go('airplay-code', { resetSelection: true, clearMessage: false });
@@ -488,7 +509,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
488
509
  setStationContextFor('explore', {
489
510
  title: 'Explore world',
490
511
  subtitle: `Scanning all geotagged stations near ${formatExploreCursor(cursor)}`,
491
- stations: previousExploreStations
512
+ stations: previousExploreStations,
513
+ error: undefined
492
514
  });
493
515
  try {
494
516
  const stations = await providers.nearby(exploreCursorLocation(cursor), 90);
@@ -512,7 +534,13 @@ export function App({ store: providedStore, providers: providedProviders }) {
512
534
  if (requestId !== exploreRequestRef.current) {
513
535
  return;
514
536
  }
515
- setMessage(error instanceof Error ? error.message : 'Could not load world stations.');
537
+ const loadError = discoveryLoadError('Could not load world stations.', error);
538
+ setStationContextFor('explore', {
539
+ ...stationContextsRef.current.explore,
540
+ subtitle: `World station directory unavailable near ${formatExploreCursor(cursor)}`,
541
+ error: loadError
542
+ });
543
+ setMessage(loadError);
516
544
  }
517
545
  finally {
518
546
  if (requestId === exploreRequestRef.current) {
@@ -765,6 +793,10 @@ export function App({ store: providedStore, providers: providedProviders }) {
765
793
  nearbyRequestRef.current = requestId;
766
794
  setLoadingStations(true);
767
795
  setMessage(null);
796
+ setStationContextFor('nearby', {
797
+ ...stationContextsRef.current.nearby,
798
+ error: undefined
799
+ });
768
800
  go('nearby', { resetSelection: stationContextsRef.current.nearby.stations.length === 0 });
769
801
  try {
770
802
  if (!settingsRef.current.enableNearbyLocation) {
@@ -775,7 +807,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
775
807
  setStationContextFor('nearby', {
776
808
  title: 'Nearby',
777
809
  subtitle: 'IP-based location is off. Enable it in Settings or use :location on.',
778
- stations: []
810
+ stations: [],
811
+ error: undefined
779
812
  });
780
813
  return;
781
814
  }
@@ -791,7 +824,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
791
824
  setStationContextFor('nearby', {
792
825
  title: 'Nearby',
793
826
  subtitle: 'Location detection was unavailable',
794
- stations: []
827
+ stations: [],
828
+ error: 'Could not determine an approximate location.'
795
829
  });
796
830
  return;
797
831
  }
@@ -807,7 +841,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
807
841
  catch (error) {
808
842
  if (requestId !== nearbyRequestRef.current)
809
843
  return;
810
- setMessage(error instanceof Error ? error.message : 'Could not load nearby stations.');
844
+ const loadError = discoveryLoadError('Could not load nearby stations.', error);
845
+ setStationContextFor('nearby', {
846
+ ...stationContextsRef.current.nearby,
847
+ error: loadError
848
+ });
849
+ setMessage(loadError);
811
850
  }
812
851
  finally {
813
852
  if (requestId === nearbyRequestRef.current)
@@ -841,6 +880,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
841
880
  selectedByScreenRef.current[queue.sourceScreen] = index;
842
881
  }
843
882
  if (screenRef.current === queue.sourceScreen) {
883
+ selectedRef.current = index;
844
884
  setSelected(index);
845
885
  }
846
886
  }, []);
@@ -867,6 +907,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
867
907
  await player.play(station, resolved.url);
868
908
  if (requestId !== tuneRequestRef.current)
869
909
  return;
910
+ playingStationRef.current = station;
870
911
  setPlayingStation(station);
871
912
  setTuningStation(null);
872
913
  playbackQueueRef.current = queue;
@@ -899,7 +940,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
899
940
  skipBrokenTimerRef.current = setTimeout(() => {
900
941
  skipBrokenTimerRef.current = null;
901
942
  playStationRef.current(nextStation, { ...options, queue });
902
- }, 250);
943
+ }, BROKEN_STREAM_AUTO_SKIP_DELAY_MS);
903
944
  return;
904
945
  }
905
946
  setTuningStation(null);
@@ -923,6 +964,14 @@ export function App({ store: providedStore, providers: providedProviders }) {
923
964
  }
924
965
  }, [playStation, store]);
925
966
  playStationRef.current = playStation;
967
+ const cancelPendingAutoSkip = useCallback(() => {
968
+ if (!skipBrokenTimerRef.current)
969
+ return;
970
+ clearTimeout(skipBrokenTimerRef.current);
971
+ skipBrokenTimerRef.current = null;
972
+ setTuningStation(null);
973
+ setMessage('Auto-skip canceled. Choose a station and press Enter.');
974
+ }, []);
926
975
  const showTransientFooterMessage = useCallback((nextMessage, durationMs = VISUALIZER_MESSAGE_MS) => {
927
976
  if (transientFooterMessageTimerRef.current) {
928
977
  clearTimeout(transientFooterMessageTimerRef.current);
@@ -1045,7 +1094,6 @@ export function App({ store: providedStore, providers: providedProviders }) {
1045
1094
  }, [showTransientMessage, updateSettings]);
1046
1095
  const cycleReceiverStyle = useCallback(() => {
1047
1096
  const receiverStyle = nextReceiverStyle(settingsRef.current.receiverStyle);
1048
- setPulse(0);
1049
1097
  updateSettings({ receiverStyle });
1050
1098
  showTransientMessage(`Receiver style: ${receiverStyle}`);
1051
1099
  }, [showTransientMessage, updateSettings]);
@@ -1066,7 +1114,8 @@ export function App({ store: providedStore, providers: providedProviders }) {
1066
1114
  const current = stationContextsRef.current.nearby;
1067
1115
  setStationContextFor('nearby', {
1068
1116
  ...current,
1069
- subtitle: 'Location lookup off · showing the last loaded results'
1117
+ subtitle: 'Location lookup off · showing the last loaded results',
1118
+ error: undefined
1070
1119
  });
1071
1120
  }
1072
1121
  }, [loadNearby, setStationContextFor, updateSettings]);
@@ -1178,6 +1227,59 @@ export function App({ store: providedStore, providers: providedProviders }) {
1178
1227
  updateSettings({ [key]: next });
1179
1228
  setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
1180
1229
  }, [updateSettings]);
1230
+ const toggleAgentSetting = useCallback((key) => {
1231
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1232
+ const next = !agentControl[key];
1233
+ updateSettings({ agentControl: { ...agentControl, [key]: next } });
1234
+ setMessage(`Agent ${key === 'openUiOnPlay' ? 'TUI launch' : 'Now Playing focus'} ${next ? 'on' : 'off'}.`);
1235
+ }, [updateSettings]);
1236
+ const setAgentIntegrationEnabled = useCallback(async () => {
1237
+ if (configuringMcpRef.current) {
1238
+ setMessage('Agent integration setup is already in progress.');
1239
+ return;
1240
+ }
1241
+ configuringMcpRef.current = true;
1242
+ const enabled = !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled;
1243
+ setMessage(enabled ? 'Setting up agent and Codex Voice control...' : 'Disabling agent and voice control...');
1244
+ try {
1245
+ const results = await mcpConfigurator(enabled, mcpRuntime(), null);
1246
+ const failed = results.filter(item => item.status === 'failed');
1247
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1248
+ updateSettings({ agentControl: { ...agentControl, enabled } });
1249
+ setMessage(integrationResultMessage(enabled, results, failed));
1250
+ }
1251
+ catch (error) {
1252
+ if (!enabled) {
1253
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1254
+ updateSettings({ agentControl: { ...agentControl, enabled: false } });
1255
+ }
1256
+ setMessage(`Agent integration ${enabled ? 'setup' : 'removal'} failed: ${error instanceof Error ? error.message : String(error)}`);
1257
+ }
1258
+ finally {
1259
+ configuringMcpRef.current = false;
1260
+ }
1261
+ }, [mcpConfigurator, updateSettings]);
1262
+ const repairMcpIntegrations = useCallback(async () => {
1263
+ if (configuringMcpRef.current) {
1264
+ setMessage('Agent integration setup is already in progress.');
1265
+ return;
1266
+ }
1267
+ configuringMcpRef.current = true;
1268
+ setMessage('Checking and repairing agent integrations...');
1269
+ try {
1270
+ const results = await mcpConfigurator(true, mcpRuntime(), null);
1271
+ const failed = results.filter(item => item.status === 'failed');
1272
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1273
+ updateSettings({ agentControl: { ...agentControl, enabled: true } });
1274
+ setMessage(integrationResultMessage(true, results, failed));
1275
+ }
1276
+ catch (error) {
1277
+ setMessage(`MCP repair failed: ${error instanceof Error ? error.message : String(error)}`);
1278
+ }
1279
+ finally {
1280
+ configuringMcpRef.current = false;
1281
+ }
1282
+ }, [mcpConfigurator, updateSettings]);
1181
1283
  const cycleSleepTimer = useCallback(() => {
1182
1284
  const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
1183
1285
  const next = nextSleepTimerMinutes(currentMinutes);
@@ -1187,6 +1289,23 @@ export function App({ store: providedStore, providers: providedProviders }) {
1187
1289
  go('library', { resetSelection: false });
1188
1290
  }, [go]);
1189
1291
  const selectedStation = displayStations[selected] ?? null;
1292
+ const selectedStationForInput = useCallback(() => displayStationsRef.current[selectedRef.current] ?? null, []);
1293
+ const alarmTui = useAlarmTui({
1294
+ service: alarmService,
1295
+ store,
1296
+ library,
1297
+ setLibrary,
1298
+ screen,
1299
+ selected,
1300
+ setSelected,
1301
+ go,
1302
+ setMessage,
1303
+ playingStation,
1304
+ previewStation: alarmPreview ?? (station => playStation(station))
1305
+ });
1306
+ for (const alarmScreen of ['alarms', 'alarm-editor', 'alarm-picker', 'alarm-ringing']) {
1307
+ itemCountsRef.current[alarmScreen] = alarmTui.itemCount(alarmScreen) ?? 0;
1308
+ }
1190
1309
  const openScreen = useCallback((target) => {
1191
1310
  if (target === 'explore') {
1192
1311
  if (stationContextsRef.current.explore.stations.length > 0) {
@@ -1250,7 +1369,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
1250
1369
  return;
1251
1370
  }
1252
1371
  const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
1253
- if (!currentUpdateCheck?.updateAvailable) {
1372
+ if (!updateAvailableForVersion(currentUpdateCheck, installedVersion)) {
1254
1373
  setMessage('Checking for updates...');
1255
1374
  const latest = await refreshUpdateCheck();
1256
1375
  if (latest.error) {
@@ -1270,12 +1389,25 @@ export function App({ store: providedStore, providers: providedProviders }) {
1270
1389
  const result = await installUpdate(command.command);
1271
1390
  installingUpdateRef.current = false;
1272
1391
  if (result.ok) {
1273
- setMessage(`Update installed. Restart RadioCLI to use the new version.`);
1392
+ const agentControl = settingsRef.current.agentControl ?? defaultAgentControlSettings;
1393
+ if (agentControl.enabled) {
1394
+ try {
1395
+ const repaired = await mcpConfigurator(true, mcpRuntime(), null);
1396
+ const suffix = repaired.some(item => item.status === 'failed') ? ' Some MCP clients need manual repair.' : ' MCP integrations repaired.';
1397
+ setMessage(`Update installed.${suffix} Restart RadioCLI and open agent clients.`);
1398
+ }
1399
+ catch {
1400
+ setMessage('Update installed. MCP repair failed; run radiocli mcp repair, then restart RadioCLI and open agent clients.');
1401
+ }
1402
+ }
1403
+ else {
1404
+ setMessage('Update installed. Restart RadioCLI to use the new version.');
1405
+ }
1274
1406
  return;
1275
1407
  }
1276
1408
  const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
1277
1409
  setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
1278
- }, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
1410
+ }, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
1279
1411
  const executeCommand = useCommandExecutor({
1280
1412
  beginLearningTransportKey,
1281
1413
  countries,
@@ -1327,10 +1459,165 @@ export function App({ store: providedStore, providers: providedProviders }) {
1327
1459
  void playStation(nextStation, { queue });
1328
1460
  }
1329
1461
  }, [playStation, playingStation, queueContainsStation, queueFromCurrentList, rememberQueueSelection]);
1462
+ agentHandlerRef.current = async (command) => {
1463
+ const sessionStatus = () => ({
1464
+ owner: 'tui',
1465
+ playback: player.getState(),
1466
+ station: playingStationRef.current,
1467
+ queue: playbackQueueRef.current?.stations ?? [],
1468
+ output: {
1469
+ preferredBackend: settingsRef.current.preferredBackend,
1470
+ preferredAirPlayDevice: settingsRef.current.preferredAirPlayDevice
1471
+ }
1472
+ });
1473
+ const respond = (message, ok = true, data) => ({ ok, message, status: sessionStatus(), ...(data ? { data } : {}) });
1474
+ if (command.type === 'status')
1475
+ return respond(playingStationRef.current ? `${player.getState().state}: ${playingStationRef.current.name}` : 'RadioCLI is idle.');
1476
+ if (command.type === 'play') {
1477
+ if (command.ifPlaying === 'keep' && ['playing', 'paused', 'loading'].includes(player.getState().state)) {
1478
+ return respond(`Kept current station ${playingStationRef.current?.name ?? ''}.`);
1479
+ }
1480
+ const queue = { title: 'Agent selection', sourceScreen: 'now-playing', sourceContextKey: null, stations: command.queue?.length ? command.queue : [command.station] };
1481
+ await playStation(command.station, { queue, openNowPlaying: command.openNowPlaying });
1482
+ const state = player.getState();
1483
+ const playingRequestedStation = playingStationRef.current
1484
+ && stationKey(playingStationRef.current) === stationKey(command.station)
1485
+ && state.state === 'playing'
1486
+ && state.ready;
1487
+ return playingRequestedStation
1488
+ ? respond(`Playing ${command.station.name}.`)
1489
+ : respond(state.state === 'error' ? state.message ?? `Could not play ${command.station.name}.` : `Could not play ${command.station.name}.`, false);
1490
+ }
1491
+ if (command.type === 'pause') {
1492
+ const control = await player.pause();
1493
+ return respond(control.message ?? 'Paused.', control.ok);
1494
+ }
1495
+ if (command.type === 'resume') {
1496
+ const control = await player.resume();
1497
+ return respond(control.message ?? 'Resumed.', control.ok);
1498
+ }
1499
+ if (command.type === 'stop') {
1500
+ tuneRequestRef.current += 1;
1501
+ setLibrary(store.finishActiveListeningSession());
1502
+ await player.stop();
1503
+ playingStationRef.current = null;
1504
+ setPlayingStation(null);
1505
+ setTuningStation(null);
1506
+ playbackQueueRef.current = null;
1507
+ return respond('Stopped RadioCLI.');
1508
+ }
1509
+ if (command.type === 'alarm-preempt') {
1510
+ tuneRequestRef.current += 1;
1511
+ setLibrary(store.finishActiveListeningSession());
1512
+ await player.stop();
1513
+ playingStationRef.current = null;
1514
+ setPlayingStation(null);
1515
+ setTuningStation(null);
1516
+ playbackQueueRef.current = null;
1517
+ return respond('Interactive playback yielded to the alarm.');
1518
+ }
1519
+ if (command.type === 'set-volume') {
1520
+ const control = await player.setVolume(clampVolume(command.volume));
1521
+ if (control.ok)
1522
+ updateSettings({ volume: player.getState().volume });
1523
+ return respond(control.message ?? `Volume ${player.getState().volume}.`, control.ok);
1524
+ }
1525
+ if (command.type === 'set-muted') {
1526
+ const control = await player.setMuted(command.muted);
1527
+ return respond(control.message ?? (command.muted ? 'Muted.' : 'Unmuted.'), control.ok);
1528
+ }
1529
+ if (command.type === 'set-favorite') {
1530
+ const station = command.station ?? playingStationRef.current;
1531
+ if (!station)
1532
+ return respond('No active station to favorite.', false);
1533
+ const current = store.isFavorite(station);
1534
+ if (current !== command.favorite)
1535
+ toggleFavorite(station);
1536
+ return respond(`${command.favorite ? 'Favorited' : 'Removed favorite'}: ${station.name}.`);
1537
+ }
1538
+ if (command.type === 'airplay-list') {
1539
+ const devices = await refreshAirPlayTargets(false);
1540
+ return respond(devices.length ? `${devices.length} AirPlay receiver(s) found.` : 'No AirPlay receivers found.', true, devices);
1541
+ }
1542
+ if (command.type === 'airplay-select') {
1543
+ const devices = await refreshAirPlayTargets(false);
1544
+ const device = devices.find(item => item.id === command.deviceId);
1545
+ if (!device)
1546
+ return respond('AirPlay receiver not found. Refresh and use an exact receiver ID.', false, devices);
1547
+ if (device.local) {
1548
+ const backend = preferredLocalPlaybackBackend(availableBackends);
1549
+ if (!backend)
1550
+ return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
1551
+ updateSettings({ preferredBackend: backend });
1552
+ if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1553
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1554
+ }
1555
+ return respond(`Audio output set to this device (${backend}).`);
1556
+ }
1557
+ if (!isAirPlayBackendAvailable(availableBackends))
1558
+ return respond('AirPlay playback is unavailable; run radiocli doctor.', false);
1559
+ updateSettings({ preferredAirPlayDevice: device.id });
1560
+ updateSettings({ preferredBackend: 'airplay' });
1561
+ if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1562
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1563
+ }
1564
+ return respond(`Audio output set to AirPlay receiver ${device.name}.`);
1565
+ }
1566
+ if (command.type === 'airplay-local') {
1567
+ const backend = preferredLocalPlaybackBackend(availableBackends);
1568
+ if (!backend)
1569
+ return respond('No local playback backend is available. Run radiocli setup to install mpv.', false);
1570
+ updateSettings({ preferredBackend: backend });
1571
+ if (playingStationRef.current && shouldRetuneForAudioOutput(player.getState().state)) {
1572
+ await playStation(playingStationRef.current, { queue: playbackQueueRef.current ?? queueFromCurrentList(playingStationRef.current) });
1573
+ }
1574
+ return respond(`Audio output set to this device (${backend}).`);
1575
+ }
1576
+ if (command.type === 'airplay-passcode') {
1577
+ const control = player.submitAirPlayPasscode(command.code);
1578
+ return respond(control.message ?? 'AirPlay code sent.', control.ok);
1579
+ }
1580
+ if (command.type === 'update-settings') {
1581
+ updateSettings(command.settings);
1582
+ return respond('RadioCLI settings updated.');
1583
+ }
1584
+ const active = playingStationRef.current;
1585
+ const queue = playbackQueueRef.current;
1586
+ if (!active || !queue?.stations.length)
1587
+ return respond('No playback queue is available.', false);
1588
+ const currentIndex = queue.stations.findIndex(item => stationKey(item) === stationKey(active));
1589
+ const delta = command.type === 'next' ? 1 : -1;
1590
+ const next = queue.stations[(Math.max(0, currentIndex) + delta + queue.stations.length) % queue.stations.length];
1591
+ if (!next)
1592
+ return respond('No adjacent station is available.', false);
1593
+ await playStation(next, { queue });
1594
+ return respond(`Playing ${next.name}.`);
1595
+ };
1596
+ useEffect(() => {
1597
+ if (!stdin.isTTY || !(settingsRef.current.agentControl ?? defaultAgentControlSettings).enabled)
1598
+ return;
1599
+ let disposed = false;
1600
+ let close;
1601
+ void startRadioSession(command => agentHandlerRef.current(command)).then(async (session) => {
1602
+ if (disposed) {
1603
+ await session.close();
1604
+ return;
1605
+ }
1606
+ close = session.close;
1607
+ if (initialAgentCommand)
1608
+ await agentHandlerRef.current(initialAgentCommand);
1609
+ }).catch(error => setMessage(error instanceof Error ? error.message : 'Agent controls could not start.'));
1610
+ return () => {
1611
+ disposed = true;
1612
+ if (close)
1613
+ void close();
1614
+ };
1615
+ }, [initialAgentCommand, library.settings.agentControl?.enabled, stdin]);
1330
1616
  useAppInput({
1331
1617
  adjustVolume,
1332
1618
  airPlayCode,
1333
1619
  canEnterAirPlayCode,
1620
+ cancelPendingAutoSkip,
1334
1621
  copyStationUrl,
1335
1622
  openStationHomepage,
1336
1623
  beginLearningTransportKey,
@@ -1354,7 +1641,12 @@ export function App({ store: providedStore, providers: providedProviders }) {
1354
1641
  openAdjacentTab,
1355
1642
  openAirPlayCode,
1356
1643
  openAirPlaySettings,
1644
+ openSettingsPage,
1645
+ closeSettingsPage,
1357
1646
  openScreen,
1647
+ handleAlarmInput: alarmTui.handleInput,
1648
+ openAlarmForStation: alarmTui.openForStation,
1649
+ openActiveAlarms: alarmTui.openActive,
1358
1650
  playAdjacent,
1359
1651
  playStation,
1360
1652
  player,
@@ -1367,12 +1659,15 @@ export function App({ store: providedStore, providers: providedProviders }) {
1367
1659
  void refreshAirPlayTargets();
1368
1660
  },
1369
1661
  resetLearnedTransportKeys,
1662
+ repairMcpIntegrations,
1663
+ setAgentIntegrationEnabled,
1370
1664
  runSearch,
1371
1665
  saveLearnedTransportKey,
1372
1666
  screen,
1667
+ settingsPage,
1373
1668
  searchQuery,
1374
- selected,
1375
- selectedStation,
1669
+ selectedRef,
1670
+ selectedStationForInput,
1376
1671
  selectAirPlayDeviceAt,
1377
1672
  setCapturingTransportAction,
1378
1673
  setCommandMode,
@@ -1393,6 +1688,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
1393
1688
  toggleMute,
1394
1689
  togglePause,
1395
1690
  toggleSetting,
1691
+ toggleAgentSetting,
1396
1692
  toggleNearbyLocation,
1397
1693
  toggleDirectoryVoting,
1398
1694
  toggleRadioGarden,
@@ -1414,7 +1710,7 @@ export function App({ store: providedStore, providers: providedProviders }) {
1414
1710
  width: frameWidth,
1415
1711
  spinnerFrame
1416
1712
  });
1417
- const pageFooter = pageFooterText({
1713
+ const basePageFooter = pageFooterText({
1418
1714
  canEnterAirPlayCode,
1419
1715
  capturingTransportAction,
1420
1716
  commandMode,
@@ -1422,15 +1718,18 @@ export function App({ store: providedStore, providers: providedProviders }) {
1422
1718
  editingCountryFilter,
1423
1719
  editingSearch,
1424
1720
  playbackBackend: playback.backend,
1425
- screen
1721
+ screen,
1722
+ settingsPage
1426
1723
  });
1724
+ const pageFooter = alarmTui.activeAlarms.length > 0 && screen !== 'alarm-ringing'
1725
+ ? `ALARM PLAYING · ! controls · ${basePageFooter}`
1726
+ : basePageFooter;
1427
1727
  const pageFooterOwnsCompactRow = Boolean(commandMode ||
1428
1728
  capturingTransportAction ||
1429
1729
  editingCountryFilter ||
1430
1730
  (screen === 'search' && editingSearch) ||
1431
1731
  screen === 'airplay-code');
1432
1732
  const hasActiveMicroPlayback = Boolean(footerStation && (footerPlayback.state === 'playing' || footerPlayback.state === 'paused'));
1433
- const compactFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow ? pageFooter : playbackFooter ?? pageFooter);
1434
1733
  const microFooter = footerMessage ?? message ?? (pageFooterOwnsCompactRow
1435
1734
  ? pageFooter
1436
1735
  : footerPlayback.state === 'loading' && playbackFooter
@@ -1438,9 +1737,31 @@ export function App({ store: providedStore, providers: providedProviders }) {
1438
1737
  : hasActiveMicroPlayback
1439
1738
  ? microPlaybackControlsText(playback.backend)
1440
1739
  : pageFooter);
1441
- const compactGlobalFooter = `←/→ tabs · ? help · q quit · v${installedVersion}`;
1740
+ const compactGlobalFooter = '←/→ tabs · ? help · q quit';
1741
+ const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
1442
1742
  const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
1443
- return (_jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, pulse: pulse, searchQuery: searchQuery, screen: screen, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: truncate(statusRow.text, frameWidth) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: truncate(pageFooter, frameWidth) }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: truncate(globalFooter, Math.max(1, frameWidth - installedVersion.length - 3)) }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { color: textDim, children: ["v", installedVersion] })] })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || message ? themeAccent(theme) : textMuted, children: truncate(layout.mode === 'micro' ? microFooter : compactFooter, frameWidth) }), layout.footerRows > 1 ? _jsx(Text, { color: textDim, children: truncate(compactGlobalFooter, frameWidth) }) : null] })) })] }) }));
1743
+ const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
1744
+ const compactStatus = footerMessage ?? message ?? playbackFooter;
1745
+ const compactLegendRowCount = Math.max(1, layout.footerRows - (compactStatus ? 1 : 0));
1746
+ const compactLegendRows = balancedFooterLegendRows(pageFooter, compactGlobalFooter, frameWidth, compactLegendRowCount, versionReserve);
1747
+ const microLegendWidth = Math.max(1, frameWidth - versionReserve);
1748
+ const microLegend = commandMode || capturingTransportAction || footerMessage || message
1749
+ ? microFooter
1750
+ : microShortcutFooterText(microFooter, microLegendWidth);
1751
+ const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
1752
+ return (_jsx(ReceiverAnimationProvider, { screen: screen, playback: playback, receiverStyle: library.settings.receiverStyle, reduceMotion: Boolean(library.settings.reduceMotion), children: _jsx(DisplayContext.Provider, { value: displayMode, children: _jsxs(Box, { flexDirection: "column", paddingX: layout.horizontalPadding, height: layout.rows, width: layout.columns, overflow: "hidden", backgroundColor: displayMode.app, children: [hasTopTabs ? (_jsx(Box, { height: 3, flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(TopTabs, { tabs: topTabs, active: activeTabForScreen(screen), theme: theme, width: frameWidth, backendLabel: playbackBackendLabel(playback.backend) }) })) : null, _jsx(Box, { height: layout.contentRows, width: frameWidth, flexDirection: "column", overflowY: "hidden", flexShrink: 0, backgroundColor: displayMode.app, children: _jsx(AppContent, { airPlayDevices: availableAirPlayDevices, airPlayCode: airPlayCode, appVersion: installedVersion, backends: availableBackends, countryFilter: countryFilter, diagnostics: diagnostics, displayStations: displayStations, editingCountryFilter: editingCountryFilter, editingSearch: editingSearch, favoriteKeys: favoriteKeys, filterLabel: filterLabel, filteredCountries: filteredCountries, frameWidth: frameWidth, layout: layout, library: library, loadingCountries: loadingCountries, loadingStations: loadingStations, nowPlaying: nowPlaying, playback: footerPlayback, playingStation: footerStation, providerHealth: providerHealth, searchQuery: searchQuery, screen: screen, settingsPage: settingsPage, selected: selected, showDiagnostics: showDiagnostics, sleepLabel: sleepLabel, stationContext: stationContext, exploreCursor: exploreCursor, stationFavorite: store.isFavorite(footerStation), stationTime: stationApproximateTime(footerStation), storePath: store.filePath, theme: theme, updateCheck: library.updateCheck, alarmTui: alarmTui }) }), _jsx(Box, { height: layout.footerRows, width: frameWidth, flexDirection: "column", flexShrink: 0, backgroundColor: displayMode.app, children: layout.mode === 'full' ? (_jsxs(_Fragment, { children: [fullStatusRows.map(statusRow => (_jsx(Text, { color: themeAccent(theme), children: footerText(truncate(statusRow.text, frameWidth)) }, statusRow.key))), _jsx(Text, { color: commandMode || capturingTransportAction ? themeAccent(theme) : textMuted, children: footerText(fullLegendRows[0] ?? ' ') }), _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(fullLegendRows[1] ?? ' ') }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] })] })) : (_jsx(_Fragment, { children: layout.mode === 'micro' ? _jsxs(Box, { children: [_jsx(Text, { color: commandMode || capturingTransportAction || footerMessage || message ? themeAccent(theme) : textMuted, children: footerText(truncate(microLegend, microLegendWidth)) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }) : _jsxs(_Fragment, { children: [compactStatus ? _jsx(Text, { color: footerMessage || message ? themeAccent(theme) : textMuted, children: footerText(truncate(compactStatus, frameWidth)) }) : null, compactLegendRows.map((row, index) => index === compactLegendRows.length - 1 ? _jsxs(Box, { children: [_jsx(Text, { color: textDim, children: footerText(row) }), _jsx(Box, { flexGrow: 1 }), _jsx(VersionIndicator, { currentVersion: installedVersion, updateCheck: library.updateCheck, theme: theme })] }, `legend-${index}`) : _jsx(Text, { color: index === 0 ? textMuted : textDim, children: footerText(row) }, `legend-${index}`))] }) })) })] }) }) }));
1753
+ }
1754
+ function mcpRuntime() {
1755
+ return { nodePath: process.execPath, cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)) };
1756
+ }
1757
+ function integrationResultMessage(enabled, results, failed) {
1758
+ if (failed.length > 0) {
1759
+ return `${enabled ? 'Agent control enabled' : 'Agent control disabled'}, but ${failed.map(item => item.client).join(', ')} need attention. Run radiocli mcp status for details.`;
1760
+ }
1761
+ const configured = results.filter(item => item.status === (enabled ? 'configured' : 'removed')).map(item => item.client);
1762
+ if (!enabled)
1763
+ return 'Agent and voice control disabled; detected MCP registrations were removed.';
1764
+ return `Agent and voice control ready${configured.length ? ` for ${configured.join(', ')}` : ''}. Restart open Codex or agent clients, then ask them to control RadioCLI.`;
1444
1765
  }
1445
1766
  function buildLibraryStations(library) {
1446
1767
  const stations = [];
@@ -1512,6 +1833,13 @@ function formatDistanceKm(distanceKm) {
1512
1833
  }
1513
1834
  return `${Math.round(distanceKm).toLocaleString()} km`;
1514
1835
  }
1836
+ function discoveryLoadError(fallback, error) {
1837
+ const detail = error instanceof Error ? error.message.trim() : '';
1838
+ if (!detail || detail === fallback || fallback.includes(detail)) {
1839
+ return fallback;
1840
+ }
1841
+ return `${fallback} ${detail}`;
1842
+ }
1515
1843
  function nextAvailablePlaybackBackend(current, backends) {
1516
1844
  const options = ['auto'];
1517
1845
  for (const backend of ['mpv', 'ffplay', 'vlc', 'airplay']) {
@@ -1557,6 +1885,12 @@ function librarySubtitle(library) {
1557
1885
  function mouseVisibleRows(screen, layout) {
1558
1886
  if (screen === 'help')
1559
1887
  return Math.max(3, layout.contentRows - 4);
1888
+ if (screen === 'alarms' || screen === 'alarm-editor')
1889
+ return Math.max(1, layout.contentRows - 7);
1890
+ if (screen === 'alarm-picker')
1891
+ return Math.max(1, layout.contentRows - 5);
1892
+ if (screen === 'alarm-ringing')
1893
+ return Math.max(1, layout.contentRows - 7);
1560
1894
  if (layout.compact)
1561
1895
  return Math.max(1, layout.contentRows - 3);
1562
1896
  if (screen === 'countries')