@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
@@ -6,12 +6,14 @@ import { panelBorderStyle, useDisplay } from '../display-context.js';
6
6
  import { toAsciiSafe } from '../ascii.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
9
+ import { useReceiverPulse } from '../receiver-animation.js';
9
10
  // Terminal rows are roughly twice as tall as columns are wide. One vertical
10
11
  // row and three horizontal columns produce a closer optical inset around the
11
12
  // highest and lowest visible text than a mechanically equal cell count.
12
13
  const receiverPaddingX = 3;
13
14
  const receiverPaddingY = 1;
14
- export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
15
+ export function NowPlayingScreen({ station, playback, metadata, theme, favorite, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
16
+ const pulse = useReceiverPulse();
15
17
  const { panel: panelBackground, ascii } = useDisplay();
16
18
  // In ASCII mode route every rendered string through the glyph mapper so no
17
19
  // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
@@ -75,7 +77,9 @@ export function receiverStationIdentity(station, width) {
75
77
  function renderSegments(segments) {
76
78
  let offset = 0;
77
79
  return segments.map(segment => {
78
- const key = `${offset}-${segment.color}-${segment.backgroundColor ?? ''}-${segment.bold ? 'bold' : ''}`;
80
+ // Keep identity tied to geometry, not animated color. Color-bearing keys
81
+ // forced React to unmount and recreate hundreds of Text nodes per frame.
82
+ const key = offset;
79
83
  offset += segment.text.length;
80
84
  return (_jsx(Text, { color: segment.color, backgroundColor: segment.backgroundColor, bold: segment.bold, children: segment.text }, key));
81
85
  });
@@ -1,73 +1,68 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
+ import { defaultAgentControlSettings } from '../../types.js';
3
4
  import { Menu, Pointer } from '../components/Menu.js';
4
5
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
6
  import { truncate } from '../format.js';
6
- import { settingsItems, settingsSectionFor } from '../screen-items.js';
7
+ import { settingsGroup, settingsGroups, settingsItemsForPage } from '../screen-items.js';
7
8
  import { textMuted, themeAccent } from '../theme.js';
8
9
  import { playbackBackendCapabilities } from '../../player/backend-install.js';
9
10
  import { airPlayReceiverSettingValue } from '../airplay-settings.js';
10
11
  import { audioOutputLabel, audioOutputSettingValue } from '../audio-output.js';
11
12
  import { updateStatusText } from '../../update-check.js';
12
- export function SettingsScreen({ selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
13
+ export function SettingsScreen({ page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height }) {
13
14
  const accent = themeAccent(theme);
14
15
  const lineWidth = Math.max(32, width - 4);
15
- const menuWindow = settingsMenuWindow(selected, height);
16
- const selectedItem = settingsItems[selected];
17
- const activeSection = settingsSectionFor(selectedItem);
18
- const detail = settingDetail({
19
- item: selectedItem,
20
- appVersion,
21
- updateCheck,
22
- storePath,
23
- playback,
24
- providerHealth,
25
- diagnostics,
26
- lineWidth
27
- });
16
+ const group = settingsGroup(page);
17
+ const pageItems = settingsItemsForPage(page);
18
+ const menuWindow = settingsMenuWindow(pageItems, selected, height);
19
+ const selectedItem = pageItems[selected];
20
+ const detail = page === 'root' && selectedItem !== 'Check for updates'
21
+ ? settingsGroupsDescription(selectedItem)
22
+ : settingDetail({
23
+ item: selectedItem,
24
+ appVersion,
25
+ updateCheck,
26
+ storePath,
27
+ playback,
28
+ providerHealth,
29
+ diagnostics,
30
+ lineWidth
31
+ });
28
32
  const valueGap = 3;
29
33
  const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
30
34
  const valueWidth = Math.max(12, Math.min(34, lineWidth - labelWidth - valueGap - 2));
31
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: activeSection, width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: menuWindow.selectedOffset, keyFor: ({ item }) => item, render: (item, _index, active) => {
32
- const label = settingLabel(item.item, updateCheck);
33
- const value = settingValue(item.item, settings, diagnostics, backends, airPlayDevices, updateCheck);
34
- return (_jsxs(Box, { flexDirection: "column", children: [item.showSection ? (_jsx(Text, { color: textMuted, bold: true, children: item.section })) : null, _jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: truncate(label, labelWidth) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: truncate(value ?? '', valueWidth) }) })] })] }));
35
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Settings", subtitle: group ? `Settings › ${group.label}` : 'Choose a category', width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: menuWindow.items, selected: menuWindow.selectedOffset, keyFor: item => item, render: (item, _index, active) => {
36
+ const label = settingLabel(item, updateCheck, appVersion);
37
+ const value = page === 'root'
38
+ ? settingsRootValue(item)
39
+ : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion);
40
+ return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: truncate(label, labelWidth) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: truncate(value ?? '', valueWidth) }) })] }));
35
41
  } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: truncate(detail, lineWidth) })] })] }));
36
42
  }
37
- function settingsMenuWindow(selected, height) {
43
+ function settingsMenuWindow(items, selected, height) {
38
44
  const reservedRows = 6;
39
45
  const maxRows = height ? Math.max(5, height - reservedRows) : Number.POSITIVE_INFINITY;
40
- const clampedSelected = Math.min(Math.max(selected, 0), settingsItems.length - 1);
41
- const pages = [];
42
- let page = [];
43
- let pageRows = 0;
44
- let previousSection = '';
45
- settingsItems.forEach((item, index) => {
46
- const section = settingsSectionFor(item);
47
- let showSection = section !== previousSection;
48
- let rowCost = 1 + (showSection ? 1 : 0);
49
- if (page.length > 0 && pageRows + rowCost > maxRows) {
50
- pages.push(page);
51
- page = [];
52
- pageRows = 0;
53
- previousSection = '';
54
- showSection = true;
55
- rowCost = 2;
56
- }
57
- page.push({ item, section, showSection, index });
58
- pageRows += rowCost;
59
- previousSection = section;
60
- });
61
- if (page.length > 0) {
62
- pages.push(page);
63
- }
64
- const selectedPage = pages.find(candidate => candidate.some(entry => entry.index === clampedSelected)) ?? pages[0] ?? [];
46
+ const clampedSelected = Math.min(Math.max(selected, 0), items.length - 1);
47
+ const pageSize = Number.isFinite(maxRows) ? Math.max(1, Math.floor(maxRows)) : items.length;
48
+ const pageStart = Math.floor(clampedSelected / pageSize) * pageSize;
49
+ const selectedPage = items.slice(pageStart, pageStart + pageSize);
65
50
  return {
66
- items: selectedPage,
67
- selectedOffset: Math.max(0, selectedPage.findIndex(entry => entry.index === clampedSelected))
51
+ items: [...selectedPage],
52
+ selectedOffset: clampedSelected - pageStart
68
53
  };
69
54
  }
70
- export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck) {
55
+ function settingsRootValue(item) {
56
+ const group = settingsGroupByLabel(item);
57
+ return group ? `${group.items.length} settings ›` : undefined;
58
+ }
59
+ function settingsGroupByLabel(label) {
60
+ return settingsGroups.find(group => group.label === label);
61
+ }
62
+ function settingsGroupsDescription(label) {
63
+ return label ? settingsGroupByLabel(label)?.description ?? 'Press Enter to open this category.' : '';
64
+ }
65
+ export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion) {
71
66
  switch (item) {
72
67
  case 'Cycle display color':
73
68
  return settings.theme;
@@ -100,8 +95,18 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
100
95
  return settings.reduceMotion ? 'on' : 'off';
101
96
  case 'Mouse and trackpad scrolling':
102
97
  return settings.mouseSupport === false ? 'off' : 'auto';
98
+ case 'Automatically check for updates':
99
+ return settings.automaticUpdateChecks === false ? 'off' : 'on';
100
+ case 'Allow local agent control':
101
+ return (settings.agentControl ?? defaultAgentControlSettings).enabled ? 'on' : 'off';
102
+ case 'Install or repair MCP integrations':
103
+ return 'run repair';
104
+ case 'Open TUI for agent playback':
105
+ return (settings.agentControl ?? defaultAgentControlSettings).openUiOnPlay ? 'on' : 'off';
106
+ case 'Show Now Playing for agent playback':
107
+ return (settings.agentControl ?? defaultAgentControlSettings).focusNowPlaying ? 'on' : 'off';
103
108
  case 'Check for updates':
104
- return updateStatusText(updateCheck);
109
+ return updateStatusText(updateCheck, currentVersion);
105
110
  case 'Reset learned media keys':
106
111
  return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
107
112
  case 'Export preferences and library':
@@ -112,9 +117,9 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
112
117
  return undefined;
113
118
  }
114
119
  }
115
- export function settingLabel(item, updateCheck) {
116
- if (item === 'Check for updates' && updateCheck?.updateAvailable) {
117
- return 'Install update';
120
+ export function settingLabel(item, updateCheck, currentVersion) {
121
+ if (item === 'Check for updates') {
122
+ return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now';
118
123
  }
119
124
  return settingDisplayLabels[item] ?? item;
120
125
  }
@@ -135,7 +140,10 @@ function settingDetail(input) {
135
140
  return health.length ? health.map(([provider, status]) => `${provider}: ${status}`).join(' · ') : 'Press Enter to check station providers.';
136
141
  }
137
142
  if (item === 'Check for updates') {
138
- return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck)}`;
143
+ return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck, appVersion)}`;
144
+ }
145
+ if (item === 'Automatically check for updates') {
146
+ return 'When enabled, RadioCLI checks the registry once whenever the app launches.';
139
147
  }
140
148
  if (item === 'Export preferences and library' || item === 'Import preferences and library') {
141
149
  return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
@@ -149,5 +157,14 @@ function settingDetail(input) {
149
157
  if (item === 'Reduce motion') {
150
158
  return 'Freezes animated receiver displays for calmer, lower-power rendering.';
151
159
  }
160
+ if (item === 'Allow local agent control') {
161
+ return 'Press Enter to set up or remove agent and Codex Voice control across detected clients.';
162
+ }
163
+ if (item === 'Install or repair MCP integrations') {
164
+ return 'Checks and repairs every detected client. Restart open Codex or agent clients afterward.';
165
+ }
166
+ if (item === 'Open TUI for agent playback') {
167
+ return 'Opt in to a separate terminal window. On macOS, the agent app must approve a one-time Automation prompt.';
168
+ }
152
169
  return 'Press Enter to change this setting.';
153
170
  }
@@ -3,9 +3,10 @@ import { Box, Text } from 'ink';
3
3
  import { StationList } from '../components/StationList.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { textMuted } from '../theme.js';
6
- export function StationScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, pageSize, width }) {
6
+ import { truncate } from '../format.js';
7
+ export function StationScreen({ title, subtitle, stations, selected, loading, error, theme, favorites, filterLabel, pageSize, width }) {
7
8
  const empty = stationEmptyState(title);
8
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: textMuted, children: "Loading stations\u2026" }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: empty.title, emptyHint: empty.hint })) : null] })] }));
9
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: title, subtitle: subtitle, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [loading ? _jsx(Text, { color: textMuted, children: "Loading stations\u2026" }) : null, !loading && error ? _jsx(Text, { color: textMuted, children: truncate(error, width) }) : null, (!loading || stations.length > 0) && (stations.length > 0 || !error) ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: empty.title, emptyHint: empty.hint })) : null] })] }));
9
10
  }
10
11
  function stationEmptyState(title) {
11
12
  if (title.toLowerCase().includes('library')) {
@@ -0,0 +1,10 @@
1
+ import { clamp } from './app-state.js';
2
+ export function commitImmediateSelection(selectionRef, setSelection, next, itemCount) {
3
+ const selected = clamp(next, itemCount - 1);
4
+ // Update the event-facing value before React's next render. Slow terminals
5
+ // can deliver Enter immediately after an arrow key while rendered props are
6
+ // still one selection behind.
7
+ selectionRef.current = selected;
8
+ setSelection(selected);
9
+ return selected;
10
+ }
@@ -1,5 +1,6 @@
1
1
  import { cosmoCoordinateForCell } from './cosmo-world-map.js';
2
2
  import { computeExploreMapLayout } from './explore-map-layout.js';
3
+ import { adaptiveExploreFrameMetrics, computeAdaptiveExploreLayout } from './adaptive-explore-layout.js';
3
4
  export const enableMouseReporting = '\u001B[?1000h\u001B[?1006h';
4
5
  export const disableMouseReporting = '\u001B[?1006l\u001B[?1000l';
5
6
  const sgrMousePattern = /\u001B\[<(\d+);(\d+);(\d+)([Mm])/g;
@@ -32,7 +33,8 @@ export function parseTerminalMouseEvents(input) {
32
33
  }
33
34
  const selectableMouseScreens = new Set([
34
35
  'home', 'countries', 'map', 'stations', 'search', 'nearby', 'explore',
35
- 'library', 'settings', 'help', 'airplay-settings'
36
+ 'library', 'settings', 'help', 'airplay-settings', 'alarms', 'alarm-editor',
37
+ 'alarm-picker', 'alarm-ringing'
36
38
  ]);
37
39
  export function shouldEnableMouseReporting(screen, itemCount, visibleRows, mouseSupport = true) {
38
40
  if (!mouseSupport || !selectableMouseScreens.has(screen))
@@ -60,14 +62,27 @@ export function wheelScrollDelta(events) {
60
62
  }
61
63
  export function exploreCursorForMouseCell(x, y, frameWidth, layout) {
62
64
  if (layout.compact) {
63
- return null;
65
+ const mode = layout.mode === 'micro' ? 'micro' : 'compact';
66
+ const frame = adaptiveExploreFrameMetrics(mode, layout.contentRows);
67
+ if (frame.bodyRows <= 0)
68
+ return null;
69
+ const mapLayout = computeAdaptiveExploreLayout(mode, frameWidth, frame.bodyRows);
70
+ const contentLeft = layout.horizontalPadding + 1;
71
+ const mapInnerLeft = contentLeft + mapLayout.mapOffsetX;
72
+ const mapInnerTop = 1 + frame.headerRows + frame.headerGap;
73
+ const col = Math.floor(x - mapInnerLeft);
74
+ const row = Math.floor(y - mapInnerTop);
75
+ if (col < 0 || col >= mapLayout.mapColumns || row < 0 || row >= mapLayout.mapRows)
76
+ return null;
77
+ const coordinate = cosmoCoordinateForCell(col, row, mapLayout.mapColumns, mapLayout.mapRows);
78
+ return { latitude: coordinate.lat, longitude: coordinate.lon };
64
79
  }
65
80
  const mapLayout = computeExploreMapLayout(frameWidth, layout.contentRows, layout.stationRows);
66
81
  const contentLeft = 2;
67
82
  const contentTop = layout.topRows + 1;
68
83
  const mapOuterLeft = contentLeft;
69
84
  const mapOuterTop = contentTop + mapLayout.headerRows + 1;
70
- const mapInnerLeft = mapOuterLeft + 1;
85
+ const mapInnerLeft = mapOuterLeft + 1 + mapLayout.mapOffsetX;
71
86
  const mapInnerTop = mapOuterTop + 1;
72
87
  const col = Math.floor(x - mapInnerLeft);
73
88
  const row = Math.floor(y - mapInnerTop);