@ciphore/radiocli 0.2.2 → 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 (40) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README.md +30 -0
  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/cli.js +4 -1
  12. package/dist/alarms/runner.js +43 -26
  13. package/dist/cli.js +60 -3
  14. package/dist/player/player-controller.js +19 -0
  15. package/dist/providers/provider-manager.js +5 -0
  16. package/dist/providers/radio-browser.js +4 -0
  17. package/dist/setup.js +71 -2
  18. package/dist/storage/store.js +33 -1
  19. package/dist/types.js +6 -0
  20. package/dist/ui/AdaptiveContent.js +24 -9
  21. package/dist/ui/App.js +299 -18
  22. package/dist/ui/AppContent.js +4 -4
  23. package/dist/ui/components/StationList.js +3 -5
  24. package/dist/ui/components/VersionIndicator.js +19 -0
  25. package/dist/ui/page-footer.js +4 -2
  26. package/dist/ui/screen-items.js +40 -9
  27. package/dist/ui/screens/AlarmsScreen.js +2 -1
  28. package/dist/ui/screens/CountriesScreen.js +8 -5
  29. package/dist/ui/screens/HomeScreen.js +3 -1
  30. package/dist/ui/screens/SettingsScreen.js +70 -53
  31. package/dist/ui/use-alarm-tui.js +4 -0
  32. package/dist/ui/use-app-input.js +29 -3
  33. package/dist/ui/visualizers/gallop.js +118 -0
  34. package/dist/ui/visualizers/horse-stride.js +20 -0
  35. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  36. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  37. package/dist/ui/visualizers/retro-receivers.js +4 -0
  38. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  39. package/dist/update-check.js +26 -7
  40. package/package.json +4 -1
package/dist/ui/App.js CHANGED
@@ -1,17 +1,19 @@
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';
9
+ import { defaultAgentControlSettings } from '../types.js';
8
10
  import { nextReceiverStyle, nextTheme, textDim, textMuted, themeAccent } from './theme.js';
9
11
  import { DisplayContext, resolveDisplayMode } from './display-context.js';
10
- import { homeItems, settingsItems } from './screen-items.js';
12
+ import { homeItems, settingsItemsForPage } from './screen-items.js';
11
13
  import { AppContent } from './AppContent.js';
12
14
  import { TopTabs } from './components/TopTabs.js';
13
15
  import { computeTerminalLayout } from './layout.js';
14
- import { displayWidth, truncate } from './format.js';
16
+ import { truncate } from './format.js';
15
17
  import { playbackFooterText, playbackStateForPendingStation } from './playback-footer.js';
16
18
  import { balancedFooterLegendRows, fullFooterRowCount, fullStatusFooterRows, microPlaybackControlsText, microShortcutFooterText, pageFooterText } from './page-footer.js';
17
19
  import { disableMouseReporting, enableMouseReporting, exploreCursorForMouseCell, shouldEnableMouseReporting } from './terminal-mouse.js';
@@ -22,15 +24,18 @@ import { isAirPlayBackendAvailable } from './airplay-settings.js';
22
24
  import { audioOutputLabel, resolvedAudioOutput } from './audio-output.js';
23
25
  import { copyToClipboard, openExternal } from './system-actions.js';
24
26
  import { appVersion } from '../version.js';
25
- import { checkForUpdate, installUpdate, shouldCheckForUpdate, updateCommandForInstall } from '../update-check.js';
27
+ import { automaticUpdateChecksAllowed, checkForUpdate, installUpdate, shouldCheckForUpdate, updateAvailableForVersion, updateCommandForInstall } from '../update-check.js';
26
28
  import { helpItemCount } from './help-content.js';
27
29
  import { EXIT_CONFIRMATION_MS, ctrlCExitDecision } from './exit-confirmation.js';
28
30
  import { createAlarmTuiService, serializeAlarmTuiService } from './alarm-tui-service.js';
29
31
  import { useAlarmTui } from './use-alarm-tui.js';
30
32
  import { toAsciiSafe } from './ascii.js';
31
33
  import { registerTuiPresence } from '../alarms/tui-presence.js';
34
+ import { configureMcpIntegrations } from '../agent/mcp-install.js';
35
+ import { startRadioSession } from '../agent/session.js';
32
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';
33
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;
@@ -44,9 +49,10 @@ const settingToggleLabel = {
44
49
  transparentBackground: 'Transparent background',
45
50
  asciiMode: 'ASCII-safe display',
46
51
  reduceMotion: 'Reduce motion',
47
- mouseSupport: 'Mouse and trackpad scrolling'
52
+ mouseSupport: 'Mouse and trackpad scrolling',
53
+ automaticUpdateChecks: 'Automatic update checks'
48
54
  };
49
- export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview }) {
55
+ export function App({ store: providedStore, providers: providedProviders, alarmService: providedAlarmService, alarmPreview, initialAgentCommand, mcpConfigurator = configureMcpIntegrations, updateChecker = checkForUpdate }) {
50
56
  const { exit } = useApp();
51
57
  const { stdin } = useStdin();
52
58
  const { stdout } = useStdout();
@@ -64,6 +70,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
64
70
  const [availableAirPlayDevices, setAvailableAirPlayDevices] = useState(() => player.detectedAirPlayDevices());
65
71
  const [screen, setScreen] = useState('home');
66
72
  const [selected, setSelected] = useState(0);
73
+ const [settingsPage, setSettingsPage] = useState('root');
67
74
  const [message, setMessage] = useState(null);
68
75
  const [footerMessage, setFooterMessage] = useState(null);
69
76
  const [countries, setCountries] = useState([]);
@@ -89,7 +96,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
89
96
  const [showDiagnostics, setShowDiagnostics] = useState(false);
90
97
  const [capturingTransportAction, setCapturingTransportAction] = useState(null);
91
98
  const announcedUpdateRef = useRef(false);
99
+ const automaticUpdateCheckStartedRef = useRef(false);
92
100
  const installingUpdateRef = useRef(false);
101
+ const configuringMcpRef = useRef(false);
93
102
  const displayStationsRef = useRef([]);
94
103
  const playbackQueueRef = useRef(null);
95
104
  const lastRawTransportAtRef = useRef(0);
@@ -99,6 +108,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
99
108
  const playStationRef = useRef(() => undefined);
100
109
  const screenRef = useRef(screen);
101
110
  const selectedRef = useRef(selected);
111
+ const settingsPageRef = useRef(settingsPage);
112
+ const selectedBySettingsPageRef = useRef({});
102
113
  const selectedByScreenRef = useRef({});
103
114
  const stationContextsRef = useRef(stationContexts);
104
115
  const lastStationContextKeyRef = useRef('explore');
@@ -118,6 +129,9 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
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, alarmS
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;
@@ -171,7 +186,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
171
186
  'alarm-ringing': 0,
172
187
  'airplay-settings': 0,
173
188
  'airplay-code': 1,
174
- settings: settingsItems.length,
189
+ settings: settingsItemsForPage(settingsPage).length,
175
190
  help: helpItemCount
176
191
  });
177
192
  itemCountsRef.current = {
@@ -191,7 +206,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
191
206
  'alarm-ringing': 0,
192
207
  'airplay-settings': availableAirPlayDevices.length,
193
208
  'airplay-code': 1,
194
- settings: settingsItems.length,
209
+ settings: settingsItemsForPage(settingsPage).length,
195
210
  help: helpItemCount
196
211
  };
197
212
  const displayStations = useMemo(() => applyStationFilters(stationContext.stations, filters), [filters, stationContext.stations]);
@@ -345,14 +360,16 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
345
360
  refreshProviderHealth();
346
361
  }, [refreshProviderHealth]);
347
362
  const refreshUpdateCheck = useCallback(async () => {
348
- const updateCheck = await checkForUpdate({ currentVersion: installedVersion });
363
+ const updateCheck = await updateChecker({ currentVersion: installedVersion });
349
364
  setLibrary(store.updateCheckState(updateCheck));
350
365
  return updateCheck;
351
- }, [installedVersion, store]);
366
+ }, [installedVersion, store, updateChecker]);
352
367
  useEffect(() => {
353
- if (!shouldCheckForUpdate(library.updateCheck)) {
368
+ if (automaticUpdateCheckStartedRef.current ||
369
+ !automaticUpdateChecksAllowed(library.settings.automaticUpdateChecks !== false)) {
354
370
  return;
355
371
  }
372
+ automaticUpdateCheckStartedRef.current = true;
356
373
  let cancelled = false;
357
374
  void refreshUpdateCheck().then(updateCheck => {
358
375
  if (cancelled) {
@@ -366,7 +383,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
366
383
  return () => {
367
384
  cancelled = true;
368
385
  };
369
- }, [library.updateCheck, refreshUpdateCheck]);
386
+ }, [library.settings.automaticUpdateChecks, refreshUpdateCheck]);
370
387
  const setStationContextFor = useCallback((key, context) => {
371
388
  setStationContexts(current => ({ ...current, [key]: context }));
372
389
  }, []);
@@ -414,6 +431,15 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
414
431
  const destination = screenRef.current === 'help' && next === 'home'
415
432
  ? helpReturnScreenRef.current
416
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
+ }
417
443
  selectedByScreenRef.current[screenRef.current] = selectedRef.current;
418
444
  const remembered = selectedByScreenRef.current[destination] ?? 0;
419
445
  const nextSelection = options.resetSelection ? 0 : remembered;
@@ -423,6 +449,22 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
423
449
  setMessage(null);
424
450
  }
425
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]);
426
468
  const openAirPlayCode = useCallback(() => {
427
469
  setAirPlayCode('');
428
470
  go('airplay-code', { resetSelection: true, clearMessage: false });
@@ -865,6 +907,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
865
907
  await player.play(station, resolved.url);
866
908
  if (requestId !== tuneRequestRef.current)
867
909
  return;
910
+ playingStationRef.current = station;
868
911
  setPlayingStation(station);
869
912
  setTuningStation(null);
870
913
  playbackQueueRef.current = queue;
@@ -1184,6 +1227,59 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1184
1227
  updateSettings({ [key]: next });
1185
1228
  setMessage(`${settingToggleLabel[key]} ${next ? 'on' : 'off'}.`);
1186
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]);
1187
1283
  const cycleSleepTimer = useCallback(() => {
1188
1284
  const currentMinutes = sleepUntil ? Math.round((sleepUntil - Date.now()) / 60000) : null;
1189
1285
  const next = nextSleepTimerMinutes(currentMinutes);
@@ -1273,7 +1369,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1273
1369
  return;
1274
1370
  }
1275
1371
  const currentUpdateCheck = store.snapshot().updateCheck ?? library.updateCheck;
1276
- if (!currentUpdateCheck?.updateAvailable) {
1372
+ if (!updateAvailableForVersion(currentUpdateCheck, installedVersion)) {
1277
1373
  setMessage('Checking for updates...');
1278
1374
  const latest = await refreshUpdateCheck();
1279
1375
  if (latest.error) {
@@ -1293,12 +1389,25 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1293
1389
  const result = await installUpdate(command.command);
1294
1390
  installingUpdateRef.current = false;
1295
1391
  if (result.ok) {
1296
- 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
+ }
1297
1406
  return;
1298
1407
  }
1299
1408
  const detail = result.output ? ` ${result.output.split('\n').at(-1)}` : '';
1300
1409
  setMessage(`Update install failed. Run manually: ${result.command}.${detail}`);
1301
- }, [installedVersion, library.updateCheck, refreshUpdateCheck, store]);
1410
+ }, [installedVersion, library.updateCheck, mcpConfigurator, refreshUpdateCheck, store]);
1302
1411
  const executeCommand = useCommandExecutor({
1303
1412
  beginLearningTransportKey,
1304
1413
  countries,
@@ -1350,6 +1459,160 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1350
1459
  void playStation(nextStation, { queue });
1351
1460
  }
1352
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]);
1353
1616
  useAppInput({
1354
1617
  adjustVolume,
1355
1618
  airPlayCode,
@@ -1378,6 +1641,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1378
1641
  openAdjacentTab,
1379
1642
  openAirPlayCode,
1380
1643
  openAirPlaySettings,
1644
+ openSettingsPage,
1645
+ closeSettingsPage,
1381
1646
  openScreen,
1382
1647
  handleAlarmInput: alarmTui.handleInput,
1383
1648
  openAlarmForStation: alarmTui.openForStation,
@@ -1394,9 +1659,12 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1394
1659
  void refreshAirPlayTargets();
1395
1660
  },
1396
1661
  resetLearnedTransportKeys,
1662
+ repairMcpIntegrations,
1663
+ setAgentIntegrationEnabled,
1397
1664
  runSearch,
1398
1665
  saveLearnedTransportKey,
1399
1666
  screen,
1667
+ settingsPage,
1400
1668
  searchQuery,
1401
1669
  selectedRef,
1402
1670
  selectedStationForInput,
@@ -1420,6 +1688,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1420
1688
  toggleMute,
1421
1689
  togglePause,
1422
1690
  toggleSetting,
1691
+ toggleAgentSetting,
1423
1692
  toggleNearbyLocation,
1424
1693
  toggleDirectoryVoting,
1425
1694
  toggleRadioGarden,
@@ -1449,7 +1718,8 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1449
1718
  editingCountryFilter,
1450
1719
  editingSearch,
1451
1720
  playbackBackend: playback.backend,
1452
- screen
1721
+ screen,
1722
+ settingsPage
1453
1723
  });
1454
1724
  const pageFooter = alarmTui.activeAlarms.length > 0 && screen !== 'alarm-ringing'
1455
1725
  ? `ALARM PLAYING · ! controls · ${basePageFooter}`
@@ -1468,8 +1738,7 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1468
1738
  ? microPlaybackControlsText(playback.backend)
1469
1739
  : pageFooter);
1470
1740
  const compactGlobalFooter = '←/→ tabs · ? help · q quit';
1471
- const versionLabel = `v${installedVersion}`;
1472
- const versionReserve = displayWidth(versionLabel) + 2;
1741
+ const versionReserve = versionIndicatorWidth(installedVersion, library.updateCheck) + 2;
1473
1742
  const fullStatusRows = fullStatusFooterRows(screen, message, footerMessage, playbackFooter);
1474
1743
  const fullLegendRows = balancedFooterLegendRows(pageFooter, globalFooter, frameWidth, 2, versionReserve);
1475
1744
  const compactStatus = footerMessage ?? message ?? playbackFooter;
@@ -1480,7 +1749,19 @@ export function App({ store: providedStore, providers: providedProviders, alarmS
1480
1749
  ? microFooter
1481
1750
  : microShortcutFooterText(microFooter, microLegendWidth);
1482
1751
  const footerText = (value) => displayMode.ascii ? toAsciiSafe(value) : value;
1483
- 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, 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(Text, { color: textDim, children: versionLabel })] })] })) : (_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(Text, { color: textDim, children: versionLabel })] }) : _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(Text, { color: textDim, children: versionLabel })] }, `legend-${index}`) : _jsx(Text, { color: index === 0 ? textMuted : textDim, children: footerText(row) }, `legend-${index}`))] }) })) })] }) }) }));
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.`;
1484
1765
  }
1485
1766
  function buildLibraryStations(library) {
1486
1767
  const stations = [];
@@ -15,7 +15,7 @@ import { AirPlayCodeScreen } from './screens/AirPlayCodeScreen.js';
15
15
  import { selectedAirPlayDevice } from './airplay-settings.js';
16
16
  import { AdaptiveContent } from './AdaptiveContent.js';
17
17
  import { AlarmsScreen, AlarmEditorScreen, AlarmPickerScreen, AlarmRingingScreen } from './screens/AlarmsScreen.js';
18
- export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui }) {
18
+ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends, countryFilter, diagnostics, displayStations, editingCountryFilter, editingSearch, exploreCursor, favoriteKeys, filterLabel, filteredCountries, frameWidth, layout, library, loadingCountries, loadingStations, nowPlaying, playback, playingStation, providerHealth, searchQuery, screen, settingsPage, selected, showDiagnostics, sleepLabel, stationContext, stationFavorite, stationTime, storePath, theme, updateCheck, alarmTui }) {
19
19
  if (layout.compact) {
20
20
  if (screen === 'alarms')
21
21
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -25,7 +25,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
25
25
  return _jsx(AlarmPickerScreen, { choices: alarmTui.pickerChoices, selected: selected, fallback: alarmTui.pickerFallback, theme: theme, width: frameWidth, height: layout.contentRows });
26
26
  if (screen === 'alarm-ringing')
27
27
  return _jsx(AlarmRingingScreen, { sessions: alarmTui.activeAlarms, alarms: library.alarms, selected: alarmTui.activeSelected, snoozeMinutes: alarmTui.snoozeMinutes, theme: theme, width: frameWidth, height: layout.contentRows });
28
- return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel }));
28
+ return (_jsx(AdaptiveContent, { mode: layout.mode === 'micro' ? 'micro' : 'compact', screen: screen, settingsPage: settingsPage, selected: selected, height: layout.contentRows, width: frameWidth, theme: theme, playback: playback, playingStation: playingStation, nowPlaying: nowPlaying, stations: displayStations, countries: filteredCountries, airPlayDevices: airPlayDevices, airPlayCode: airPlayCode, searchQuery: searchQuery, editingSearch: editingSearch, countryFilter: countryFilter, editingCountryFilter: editingCountryFilter, loadingCountries: loadingCountries, loadingStations: loadingStations, exploreCursor: exploreCursor, library: library, diagnostics: diagnostics, backends: backends, updateCheck: updateCheck, appVersion: appVersion, favoriteKeys: favoriteKeys, stationTitle: stationContext.title, stationError: stationContext.error, filterLabel: filterLabel, sleepLabel: sleepLabel }));
29
29
  }
30
30
  if (screen === 'alarms')
31
31
  return _jsx(AlarmsScreen, { alarms: library.alarms, selected: selected, runtime: alarmTui.runtime, verification: alarmTui.verification, deletingId: alarmTui.deletingId, busyAlarmIds: alarmTui.busyAlarmIds, theme: theme, width: frameWidth, height: layout.contentRows, mode: layout.mode });
@@ -39,7 +39,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
39
39
  return _jsx(HomeScreen, { selected: selected, theme: theme, library: library });
40
40
  }
41
41
  if (screen === 'countries') {
42
- return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth }));
42
+ return (_jsx(CountriesScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.countryRows, width: frameWidth, reduceMotion: Boolean(library.settings.reduceMotion) }));
43
43
  }
44
44
  if (screen === 'map') {
45
45
  return (_jsx(MapScreen, { countries: filteredCountries, selected: selected, loading: loadingCountries, filter: countryFilter, editingFilter: editingCountryFilter, theme: theme, pageSize: layout.mapCountryRows, mode: layout.mapMode, width: frameWidth }));
@@ -60,7 +60,7 @@ export function AppContent({ airPlayDevices, airPlayCode, appVersion, backends,
60
60
  return _jsx(StatsScreen, { library: library, theme: theme, width: frameWidth, height: layout.contentRows });
61
61
  }
62
62
  if (screen === 'settings') {
63
- return (_jsx(SettingsScreen, { selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
63
+ return (_jsx(SettingsScreen, { page: settingsPage, selected: selected, settings: library.settings, appVersion: appVersion, updateCheck: updateCheck, storePath: storePath, playback: playback, backends: backends, airPlayDevices: airPlayDevices, providerHealth: providerHealth, theme: theme, diagnostics: diagnostics, width: frameWidth, height: layout.contentRows }));
64
64
  }
65
65
  if (screen === 'airplay-settings') {
66
66
  return (_jsx(AirPlaySettingsScreen, { selected: selected, settings: library.settings, backends: backends, devices: airPlayDevices, theme: theme, width: frameWidth, height: layout.contentRows }));
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { displayWidth, stationLocation, stationTags, stationTech, truncate } from '../format.js';
3
+ import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
4
4
  import { textMuted, themeAccent } from '../theme.js';
5
5
  import { Menu, Pointer } from './Menu.js';
6
6
  import { visibleWindow } from '../list-window.js';
@@ -21,12 +21,10 @@ export function StationList({ stations, selected, theme, favorites, pageSize, wi
21
21
  const compactNameWidth = Math.max(1, width - 2 - (favorite ? 2 : 0));
22
22
  return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: compactNameWidth, active: active, reduceMotion: reduceMotion }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }));
23
23
  }
24
- const stationName = truncate(station.name, favorite ? Math.max(1, nameWidth - 2) : nameWidth);
24
+ const stationNameWidth = favorite ? Math.max(1, nameWidth - 2) : nameWidth;
25
25
  const titleWidth = nameWidth + 2;
26
- const titleUsed = displayWidth(stationName) + (favorite ? 2 : 0);
27
- const titlePadding = ' '.repeat(Math.max(1, titleWidth - titleUsed));
28
26
  const standardMetadata = `${stationLocation(station)} · ${stationTech(station)}`;
29
27
  const selectedMetadata = station.tags.length > 0 ? stationTags(station) : standardMetadata;
30
- return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: stationName }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null, _jsx(Text, { children: titlePadding }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: truncate(active ? selectedMetadata : standardMetadata, metaWidth) })] }));
28
+ return (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Box, { width: titleWidth, children: [_jsx(Box, { width: stationNameWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: _jsx(AdaptiveMarquee, { text: station.name, width: stationNameWidth, active: active, reduceMotion: reduceMotion }) }) }), favorite ? _jsx(Text, { color: "yellow", children: " \u2605" }) : null] }), _jsx(Text, { color: active ? themeAccent(theme) : textMuted, children: truncate(active ? selectedMetadata : standardMetadata, metaWidth) })] }));
31
29
  } })] }));
32
30
  }
@@ -0,0 +1,19 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { updateAvailableForVersion } from '../../update-check.js';
4
+ import { displayWidth } from '../format.js';
5
+ import { appBackground, textDim, themeAccent } from '../theme.js';
6
+ export function availableUpdateLabel(currentVersion, updateCheck) {
7
+ return updateAvailableForVersion(updateCheck, currentVersion) && updateCheck?.latestVersion
8
+ ? `v${updateCheck.latestVersion} available`
9
+ : undefined;
10
+ }
11
+ export function versionIndicatorWidth(currentVersion, updateCheck) {
12
+ const versionWidth = displayWidth(`v${currentVersion}`);
13
+ const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
14
+ return updateLabel ? displayWidth(updateLabel) + 3 + versionWidth : versionWidth;
15
+ }
16
+ export function VersionIndicator({ currentVersion, updateCheck, theme }) {
17
+ const updateLabel = availableUpdateLabel(currentVersion, updateCheck);
18
+ return (_jsxs(Box, { flexShrink: 0, children: [updateLabel ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: appBackground, backgroundColor: themeAccent(theme), bold: true, children: ` ${updateLabel} ` }), _jsx(Text, { children: " " })] })) : null, _jsx(Text, { color: textDim, children: `v${currentVersion}` })] }));
19
+ }
@@ -13,7 +13,7 @@ export function fullStatusFooterRows(screen, message, footerMessage, playbackSta
13
13
  { key: 'playback', text: footerMessage ?? playbackStatus ?? ' ' }
14
14
  ];
15
15
  }
16
- export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen }) {
16
+ export function pageFooterText({ capturingTransportAction, commandMode, commandText, editingCountryFilter, editingSearch, canEnterAirPlayCode, playbackBackend, screen, settingsPage = 'root' }) {
17
17
  if (capturingTransportAction) {
18
18
  return `Learn ${mediaActionLabel(capturingTransportAction)} key: press key · Esc cancel`;
19
19
  }
@@ -58,7 +58,9 @@ export function pageFooterText({ capturingTransportAction, commandMode, commandT
58
58
  return 'space/F8 pause · f favorite · m mute · s sleep · d diagnostics · b Overview';
59
59
  }
60
60
  if (screen === 'settings') {
61
- return 'Enter change selected · g Radio Garden · l location · x skip · o output · a AirPlay · r health · b Overview';
61
+ return settingsPage === 'root'
62
+ ? '↑/↓ choose · Enter open · o output · a AirPlay · b Overview'
63
+ : '↑/↓ choose · Enter change · b Settings · shortcuts still work';
62
64
  }
63
65
  if (screen === 'airplay-settings') {
64
66
  return canEnterAirPlayCode