@ciphore/radiocli 0.2.2 → 0.2.4

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 (96) hide show
  1. package/CHANGELOG.md +124 -1
  2. package/CONTRIBUTING.md +36 -6
  3. package/README.md +84 -10
  4. package/dist/agent/alarm-service.js +210 -0
  5. package/dist/agent/cli.js +193 -0
  6. package/dist/agent/headless-host.js +165 -0
  7. package/dist/agent/launcher.js +10 -0
  8. package/dist/agent/mcp-install.js +467 -0
  9. package/dist/agent/mcp-server.js +139 -0
  10. package/dist/agent/service.js +347 -0
  11. package/dist/agent/session.js +232 -0
  12. package/dist/alarms/active-session.js +9 -12
  13. package/dist/alarms/cli.js +4 -1
  14. package/dist/alarms/guard.js +79 -36
  15. package/dist/alarms/inhibitor.js +27 -22
  16. package/dist/alarms/power-guard-store.js +2 -10
  17. package/dist/alarms/runner.js +96 -48
  18. package/dist/alarms/schedule.js +9 -2
  19. package/dist/alarms/scheduler.js +194 -52
  20. package/dist/alarms/setup-verification.js +1 -2
  21. package/dist/alarms/system-volume-ownership.js +267 -0
  22. package/dist/alarms/system-volume.js +155 -14
  23. package/dist/alarms/terminal-launcher.js +76 -136
  24. package/dist/alarms/tui-presence.js +2 -4
  25. package/dist/cli.js +157 -35
  26. package/dist/platform/capabilities.js +53 -0
  27. package/dist/platform/desktop.js +36 -0
  28. package/dist/{player/command.js → platform/executables.js} +43 -9
  29. package/dist/platform/ipc.js +14 -0
  30. package/dist/platform/launch-command.js +135 -0
  31. package/dist/platform/loopback.js +44 -0
  32. package/dist/platform/network.js +312 -0
  33. package/dist/platform/packages.js +216 -0
  34. package/dist/platform/paths.js +40 -0
  35. package/dist/platform/runtime.js +67 -0
  36. package/dist/platform/shell.js +24 -0
  37. package/dist/platform/storage.js +48 -0
  38. package/dist/platform/support.js +214 -0
  39. package/dist/platform/terminal.js +63 -0
  40. package/dist/platform/terminals.js +203 -0
  41. package/dist/player/airplay-discovery.js +4 -2
  42. package/dist/player/backend-install.js +13 -94
  43. package/dist/player/command-diagnostics.js +2 -2
  44. package/dist/player/mpv-ipc-client.js +2 -1
  45. package/dist/player/player-controller.js +220 -31
  46. package/dist/providers/cache.js +4 -26
  47. package/dist/providers/provider-manager.js +5 -0
  48. package/dist/providers/radio-browser.js +156 -54
  49. package/dist/providers/radio-garden.js +15 -22
  50. package/dist/setup.js +149 -152
  51. package/dist/storage/store.js +138 -53
  52. package/dist/streams/import-stream.js +163 -0
  53. package/dist/types.js +6 -0
  54. package/dist/ui/AdaptiveContent.js +54 -30
  55. package/dist/ui/App.js +466 -98
  56. package/dist/ui/AppContent.js +4 -4
  57. package/dist/ui/app-state.js +8 -1
  58. package/dist/ui/ascii.js +11 -2
  59. package/dist/ui/components/AdaptiveMarquee.js +6 -3
  60. package/dist/ui/components/Logo.js +5 -2
  61. package/dist/ui/components/Menu.js +2 -2
  62. package/dist/ui/components/ScreenHeader.js +1 -1
  63. package/dist/ui/components/StationList.js +8 -8
  64. package/dist/ui/components/TopTabs.js +1 -1
  65. package/dist/ui/components/VersionIndicator.js +19 -0
  66. package/dist/ui/display-context.js +8 -11
  67. package/dist/ui/help-content.js +5 -5
  68. package/dist/ui/layout.js +5 -2
  69. package/dist/ui/page-footer.js +5 -3
  70. package/dist/ui/screen-items.js +42 -11
  71. package/dist/ui/screen-meta.js +1 -1
  72. package/dist/ui/screens/AirPlayCodeScreen.js +6 -2
  73. package/dist/ui/screens/AirPlaySettingsScreen.js +7 -3
  74. package/dist/ui/screens/AlarmsScreen.js +2 -1
  75. package/dist/ui/screens/CountriesScreen.js +8 -5
  76. package/dist/ui/screens/ExploreScreen.js +2 -1
  77. package/dist/ui/screens/HelpScreen.js +5 -1
  78. package/dist/ui/screens/HomeScreen.js +7 -1
  79. package/dist/ui/screens/MapScreen.js +1 -1
  80. package/dist/ui/screens/NowPlayingScreen.js +4 -4
  81. package/dist/ui/screens/SearchScreen.js +3 -1
  82. package/dist/ui/screens/SettingsScreen.js +80 -56
  83. package/dist/ui/screens/StatsScreen.js +3 -1
  84. package/dist/ui/system-actions.js +80 -52
  85. package/dist/ui/terminal-renderer.js +16 -0
  86. package/dist/ui/use-alarm-tui.js +40 -21
  87. package/dist/ui/use-app-input.js +69 -23
  88. package/dist/ui/use-command-executor.js +31 -7
  89. package/dist/ui/visualizers/gallop.js +118 -0
  90. package/dist/ui/visualizers/horse-stride.js +20 -0
  91. package/dist/ui/visualizers/receiver-style-registry.js +12 -2
  92. package/dist/ui/visualizers/receiver-visualizers.js +3 -0
  93. package/dist/ui/visualizers/retro-receivers.js +4 -0
  94. package/dist/ui/visualizers/terminal-receivers.js +57 -0
  95. package/dist/update-check.js +33 -23
  96. package/package.json +4 -1
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import React from 'react';
3
3
  import { Box, Text } from 'ink';
4
+ import { formatExploreCursor } from '../app-state.js';
4
5
  import { StationList } from '../components/StationList.js';
5
6
  import { buildCosmoWorldMap } from '../cosmo-world-map.js';
6
7
  import { computeExploreMapLayout } from '../explore-map-layout.js';
@@ -14,7 +15,7 @@ export function ExploreScreen({ title, subtitle, stations, selected, loading, er
14
15
  const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, mapOffsetX, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
15
16
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
16
17
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
17
- return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
18
+ return (_jsxs(Box, { flexDirection: "column", height: height, width: contentWidth, overflow: "hidden", flexShrink: 0, children: [_jsx(Box, { height: headerRows, flexDirection: "column", flexShrink: 0, children: _jsx(ScreenHeader, { title: title, subtitle: subtitle, width: contentWidth, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }) }), _jsxs(Box, { marginTop: 1, height: bodyRows, width: contentWidth, flexDirection: split ? 'row' : 'column', flexShrink: 0, children: [_jsx(Box, { borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", "aria-label": `World map cursor: ${formatExploreCursor(cursor)}`, children: _jsx(Box, { marginLeft: mapOffsetX, flexDirection: "column", flexShrink: 0, children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: listPanelWidth, height: split ? bodyRows : Math.max(5, listRows + 2), flexDirection: "column", children: [_jsxs(Box, { justifyContent: "space-between", width: Math.max(20, listPanelWidth - 2), children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Stations" }), _jsx(Text, { color: textMuted, children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: textMuted, children: loading
18
19
  ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…')
19
20
  : error
20
21
  ? truncate(ascii ? toAsciiSafe(error) : error, Math.max(1, listPanelWidth - 2))
@@ -5,7 +5,11 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { commandHelp, keyHelpSections } from '../help-content.js';
6
6
  import { padDisplayEnd, truncate } from '../format.js';
7
7
  import { visibleWindow } from '../list-window.js';
8
+ import { useDisplay } from '../display-context.js';
9
+ import { toAsciiSafe } from '../ascii.js';
8
10
  export function HelpScreen({ theme, width, height, selected }) {
11
+ const { ascii } = useDisplay();
12
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
9
13
  const accent = themeAccent(theme);
10
14
  const keyColumnWidth = 16;
11
15
  const lineWidth = Math.max(28, width - 2);
@@ -22,5 +26,5 @@ export function HelpScreen({ theme, width, height, selected }) {
22
26
  }))
23
27
  ];
24
28
  const window = visibleWindow(rows, selected, Math.max(3, height - 4));
25
- return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth) }), _jsx(Text, { color: textMuted, children: truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth)) })] }, row.key))) }), _jsxs(Text, { color: textMuted, children: ["Showing ", window.start + 1, "-", window.end, " of ", rows.length, " \u00B7 \u2191/\u2193 scroll"] })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", height: height, overflow: "hidden", children: [_jsx(ScreenHeader, { title: "Help", subtitle: "Keyboard shortcuts and : commands \u00B7 b or Esc to close", width: width, theme: theme }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: window.items.map(row => row.heading ? (_jsx(Text, { color: accent, bold: true, children: row.heading }, row.key)) : (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: a(padDisplayEnd(truncate(row.keys ?? '', keyColumnWidth - 1), keyColumnWidth)) }), _jsx(Text, { color: textMuted, children: a(truncate(row.description ?? '', Math.max(1, lineWidth - keyColumnWidth))) })] }, row.key))) }), _jsx(Text, { color: textMuted, children: a(`Showing ${window.start + 1}-${window.end} of ${rows.length} · ↑/↓ scroll`) })] }));
26
30
  }
@@ -4,6 +4,12 @@ import { Logo } from '../components/Logo.js';
4
4
  import { Menu, Pointer } from '../components/Menu.js';
5
5
  import { textMuted, themeAccent } from '../theme.js';
6
6
  import { homeItems } from '../screen-items.js';
7
+ import { displayWidth } from '../format.js';
8
+ import { useDisplay } from '../display-context.js';
9
+ import { toAsciiSafe } from '../ascii.js';
7
10
  export function HomeScreen({ selected, theme, library }) {
8
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }), _jsxs(Text, { color: textMuted, children: [" \u00B7 ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: textMuted, children: [library.recent.length, " recent \u00B7 ", library.favorites.length, " favorites \u00B7 ", library.imported.length, " imported"] }) })] }));
11
+ const { ascii } = useDisplay();
12
+ const summary = `${library.recent.length} recent · ${library.favorites.length} favorites · ${library.imported.length} imported`;
13
+ const titleWidth = Math.max(...homeItems.map(item => displayWidth(item.label))) + 2;
14
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Logo, {}), _jsx(Box, { marginTop: 1, flexDirection: "column", children: _jsx(Menu, { items: homeItems, selected: selected, keyFor: item => item.screen, render: (item, index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsxs(Text, { color: textMuted, children: [index + 1, " "] }), _jsx(Box, { width: titleWidth, children: _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: item.label }) }), _jsxs(Text, { color: textMuted, children: [" ", item.detail] })] })) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textMuted, children: ascii ? toAsciiSafe(summary) : summary }) })] }));
9
15
  }
@@ -18,7 +18,7 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
18
18
  const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
19
19
  const topWidth = mode === 'full' ? 50 : contentWidth;
20
20
  const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
21
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
21
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "World map", subtitle: editingFilter ? 'Filtering country list — type to narrow' : 'Station density by country', width: contentWidth, theme: theme, right: filter ? `filter: ${filter}` : undefined }), loading ? _jsx(Text, { color: textMuted, children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", "aria-hidden": true, children: graph.rows.map((row, index) => (_jsx(MapLine, { row: row, theme: theme }, `map-row-${index}`))) }), _jsx(Text, { color: textMuted, children: asciify(`${graph.plotted} plotted · ${graph.unplotted} unplaced · labels show highest station counts`) }), _jsxs(Box, { marginTop: 1, flexDirection: mode === 'full' ? 'row' : 'column', children: [_jsxs(Box, { width: topWidth, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: padDisplayEnd(truncate(country.name, 30), 31) }), _jsx(Text, { color: textMuted, children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsx(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: _jsx(Menu, { items: window.items, selected: selected - window.start, keyFor: country => country.code, render: (country, _index, active) => (_jsxs(Box, { children: [_jsx(Pointer, { active: active }), _jsx(Text, { color: active ? themeAccent(theme) : undefined, bold: active, children: country.code }), _jsx(Text, { color: textMuted, children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) }) })] })] }));
22
22
  }
23
23
  function MapLine({ row, theme }) {
24
24
  const { ascii } = useDisplay();
@@ -14,7 +14,7 @@ const receiverPaddingX = 3;
14
14
  const receiverPaddingY = 1;
15
15
  export function NowPlayingScreen({ station, playback, metadata, theme, favorite, diagnostics, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
16
16
  const pulse = useReceiverPulse();
17
- const { panel: panelBackground, ascii } = useDisplay();
17
+ const { panel: panelBackground, ascii, screenReader } = useDisplay();
18
18
  // In ASCII mode route every rendered string through the glyph mapper so no
19
19
  // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
20
20
  const a = (value) => (ascii ? toAsciiSafe(value) : value);
@@ -28,7 +28,7 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
28
28
  // visualization equally separated from the header and track title.
29
29
  const visualizerGutterRows = 1;
30
30
  const visualHeight = visualizerHeight(receiverStyle, panelHeight - (showDiagnostics ? 10 : 5) - visualizerGutterRows, innerWidth);
31
- const visualRows = buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
31
+ const visualRows = screenReader ? [] : buildVisualizer(receiverStyle, pulse, innerWidth, visualHeight, station, playback, theme);
32
32
  const receiverState = playback.state.toUpperCase();
33
33
  const identityWidth = Math.max(8, innerWidth - displayWidth(receiverState) - 2);
34
34
  const stationIdentity = receiverStationIdentity(station, identityWidth);
@@ -42,9 +42,9 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
42
42
  : infoFallback;
43
43
  const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
44
44
  const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
45
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, children: receiverState })] }), _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
45
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: panelBorderStyle(ascii), borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: receiverPaddingX, paddingY: receiverPaddingY, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsxs(Box, { width: identityWidth, overflow: "hidden", "aria-label": `Station: ${station?.name ?? 'No station tuned'}${station ? `, ${stationLocation(station)}` : ''}`, children: [_jsx(Text, { color: accent, bold: true, children: a(stationIdentity.name) }), stationIdentity.location ? _jsx(Text, { color: textMuted, children: a(` · ${stationIdentity.location}`) }) : null] }), _jsx(Text, { color: accent, "aria-label": `Playback: ${playback.state}, ${playback.muted ? 'muted' : `volume ${playback.volume}`}`, children: receiverState })] }), !screenReader ? _jsx(Box, { marginTop: visualizerGutterRows, flexDirection: "column", flexShrink: 1, overflow: "hidden", "aria-hidden": true, children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
46
46
  ? renderSegments(row.segments)
47
- : row.text }, index))) }), _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
47
+ : row.text }, index))) }) : null, _jsxs(Box, { marginTop: visualizerGutterRows, justifyContent: "space-between", width: innerWidth, flexShrink: 0, children: [_jsx(Text, { color: metadata?.title ? accent : textMuted, "aria-label": metadata?.title ? `Track: ${metadata.title}` : undefined, children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : textMuted, "aria-label": favorite ? 'Favorite' : 'Not a favorite', children: a(favoriteText) })] }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, children: "Diagnostics" }), _jsx(Text, { color: textMuted, children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: textMuted, children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: textMuted, children: a(`Started: ${diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing'} · available ${diagnostics.availableBackends.join(', ') || 'none'}`) }), stationTracks.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: textMuted, children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
48
48
  }
49
49
  function asciifyVisualRow(row) {
50
50
  return {
@@ -5,10 +5,12 @@ import { ScreenHeader } from '../components/ScreenHeader.js';
5
5
  import { panelBorder, textMuted, themeAccent } from '../theme.js';
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { truncate } from '../format.js';
8
+ import { toAsciiSafe } from '../ascii.js';
8
9
  export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
9
10
  const { panel: panelBackground, ascii } = useDisplay();
11
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
10
12
  const contentWidth = Math.max(40, width);
11
13
  const inputWidth = Math.max(34, Math.min(contentWidth, 96));
12
14
  const inputTextWidth = Math.max(8, inputWidth - 8);
13
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: textMuted, children: "Searching station directories\u2026" }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: query ? `No matches for “${query}”.` : 'Search for a station, genre, language, or place.', emptyHint: query ? 'Try fewer words or clear active filters.' : 'Type above and press Enter.' })) : null] }));
15
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Search", subtitle: `Radio Browser${experimentalOn ? ' + Radio Garden experimental' : ''}`, width: width, theme: theme, right: filterLabel === 'none' ? undefined : `filters: ${filterLabel}` }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: editing ? themeAccent(theme) : panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: inputWidth, height: 3, marginBottom: 1, flexShrink: 0, children: [_jsx(Text, { color: editing ? themeAccent(theme) : textMuted, children: a(editing ? '› ' : ' ') }), _jsx(Text, { color: query ? themeAccent(theme) : textMuted, children: a(truncate(query || 'Search stations, genres, languages, places…', inputTextWidth)) })] }), loading ? _jsx(Text, { color: textMuted, children: a('Searching station directories…') }) : null, !loading || stations.length > 0 ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width, emptyTitle: query ? `No matches for “${query}”.` : 'Search for a station, genre, language, or place.', emptyHint: query ? 'Try fewer words or clear active filters.' : 'Type above and press Enter.' })) : null] }));
14
16
  }
@@ -1,73 +1,72 @@
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
+ import { useDisplay } from '../display-context.js';
14
+ import { toAsciiSafe } from '../ascii.js';
15
+ export function SettingsScreen({ page = 'root', selected, settings, appVersion, updateCheck, storePath, playback, backends, airPlayDevices, providerHealth, theme, diagnostics, width, height, airPlaySupported = true }) {
16
+ const { ascii } = useDisplay();
17
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
13
18
  const accent = themeAccent(theme);
14
19
  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
- });
20
+ const group = settingsGroup(page);
21
+ const pageItems = settingsItemsForPage(page);
22
+ const menuWindow = settingsMenuWindow(pageItems, selected, height);
23
+ const selectedItem = pageItems[selected];
24
+ const detail = page === 'root' && selectedItem !== 'Check for updates'
25
+ ? settingsGroupsDescription(selectedItem)
26
+ : settingDetail({
27
+ item: selectedItem,
28
+ appVersion,
29
+ updateCheck,
30
+ storePath,
31
+ playback,
32
+ providerHealth,
33
+ diagnostics,
34
+ lineWidth
35
+ });
28
36
  const valueGap = 3;
29
37
  const labelWidth = Math.min(34, Math.max(20, lineWidth - valueGap - 24));
30
38
  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
- } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: truncate(detail, lineWidth) })] })] }));
39
+ 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) => {
40
+ const label = settingLabel(item, updateCheck, appVersion, airPlaySupported);
41
+ const value = page === 'root'
42
+ ? settingsRootValue(item)
43
+ : settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, appVersion);
44
+ return (_jsxs(Box, { width: lineWidth, children: [_jsx(Pointer, { active: active }), _jsx(Box, { width: labelWidth, children: _jsx(Text, { color: active ? accent : undefined, bold: active, children: a(truncate(label, labelWidth)) }) }), _jsx(Box, { width: valueGap }), _jsx(Box, { width: valueWidth, children: _jsx(Text, { color: value ? accent : textMuted, children: a(truncate(value ?? '', valueWidth)) }) })] }));
45
+ } }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: textMuted, bold: true, children: "Selected" }), _jsx(Text, { color: textMuted, children: a(truncate(detail, lineWidth)) })] })] }));
36
46
  }
37
- function settingsMenuWindow(selected, height) {
47
+ function settingsMenuWindow(items, selected, height) {
38
48
  const reservedRows = 6;
39
49
  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] ?? [];
50
+ const clampedSelected = Math.min(Math.max(selected, 0), items.length - 1);
51
+ const pageSize = Number.isFinite(maxRows) ? Math.max(1, Math.floor(maxRows)) : items.length;
52
+ const pageStart = Math.floor(clampedSelected / pageSize) * pageSize;
53
+ const selectedPage = items.slice(pageStart, pageStart + pageSize);
65
54
  return {
66
- items: selectedPage,
67
- selectedOffset: Math.max(0, selectedPage.findIndex(entry => entry.index === clampedSelected))
55
+ items: [...selectedPage],
56
+ selectedOffset: clampedSelected - pageStart
68
57
  };
69
58
  }
70
- export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck) {
59
+ function settingsRootValue(item) {
60
+ const group = settingsGroupByLabel(item);
61
+ return group ? `${group.items.length} settings ›` : undefined;
62
+ }
63
+ function settingsGroupByLabel(label) {
64
+ return settingsGroups.find(group => group.label === label);
65
+ }
66
+ function settingsGroupsDescription(label) {
67
+ return label ? settingsGroupByLabel(label)?.description ?? 'Press Enter to open this category.' : '';
68
+ }
69
+ export function settingValue(item, settings, diagnostics, backends, airPlayDevices, updateCheck, currentVersion) {
71
70
  switch (item) {
72
71
  case 'Cycle display color':
73
72
  return settings.theme;
@@ -100,21 +99,34 @@ export function settingValue(item, settings, diagnostics, backends, airPlayDevic
100
99
  return settings.reduceMotion ? 'on' : 'off';
101
100
  case 'Mouse and trackpad scrolling':
102
101
  return settings.mouseSupport === false ? 'off' : 'auto';
102
+ case 'Automatically check for updates':
103
+ return settings.automaticUpdateChecks === false ? 'off' : 'on';
104
+ case 'Allow local agent control':
105
+ return (settings.agentControl ?? defaultAgentControlSettings).enabled ? 'on' : 'off';
106
+ case 'Install or repair MCP integrations':
107
+ return 'run repair';
108
+ case 'Open TUI for agent playback':
109
+ return (settings.agentControl ?? defaultAgentControlSettings).openUiOnPlay ? 'on' : 'off';
110
+ case 'Show Now Playing for agent playback':
111
+ return (settings.agentControl ?? defaultAgentControlSettings).focusNowPlaying ? 'on' : 'off';
103
112
  case 'Check for updates':
104
- return updateStatusText(updateCheck);
113
+ return updateStatusText(updateCheck, currentVersion);
105
114
  case 'Reset learned media keys':
106
115
  return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
107
116
  case 'Export preferences and library':
108
117
  return 'JSON backup';
109
- case 'Import preferences and library':
118
+ case 'Restore preferences and library':
110
119
  return 'restore JSON backup';
111
120
  default:
112
121
  return undefined;
113
122
  }
114
123
  }
115
- export function settingLabel(item, updateCheck) {
116
- if (item === 'Check for updates' && updateCheck?.updateAvailable) {
117
- return 'Install update';
124
+ export function settingLabel(item, updateCheck, currentVersion, airPlaySupported = true) {
125
+ if (item === 'AirPlay receiver' && !airPlaySupported) {
126
+ return 'AirPlay receiver (macOS only)';
127
+ }
128
+ if (item === 'Check for updates') {
129
+ return updateStatusText(updateCheck, currentVersion).endsWith('available') ? 'Install update' : 'Check now';
118
130
  }
119
131
  return settingDisplayLabels[item] ?? item;
120
132
  }
@@ -135,9 +147,12 @@ function settingDetail(input) {
135
147
  return health.length ? health.map(([provider, status]) => `${provider}: ${status}`).join(' · ') : 'Press Enter to check station providers.';
136
148
  }
137
149
  if (item === 'Check for updates') {
138
- return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck)}`;
150
+ return `RadioCLI v${appVersion} · ${updateStatusText(updateCheck, appVersion)}`;
151
+ }
152
+ if (item === 'Automatically check for updates') {
153
+ return 'When enabled, RadioCLI checks the registry once whenever the app launches.';
139
154
  }
140
- if (item === 'Export preferences and library' || item === 'Import preferences and library') {
155
+ if (item === 'Export preferences and library' || item === 'Restore preferences and library') {
141
156
  return `Library file: ${truncate(storePath, Math.max(8, lineWidth - 14))}`;
142
157
  }
143
158
  if (item === 'Share favorite votes with Radio Browser') {
@@ -149,5 +164,14 @@ function settingDetail(input) {
149
164
  if (item === 'Reduce motion') {
150
165
  return 'Freezes animated receiver displays for calmer, lower-power rendering.';
151
166
  }
167
+ if (item === 'Allow local agent control') {
168
+ return 'Press Enter to set up or remove agent and Codex Voice control across detected clients.';
169
+ }
170
+ if (item === 'Install or repair MCP integrations') {
171
+ return 'Checks and repairs every detected client. Restart open Codex or agent clients afterward.';
172
+ }
173
+ if (item === 'Open TUI for agent playback') {
174
+ return 'Opt in to a separate terminal window. On macOS, the agent app must approve a one-time Automation prompt.';
175
+ }
152
176
  return 'Press Enter to change this setting.';
153
177
  }
@@ -6,6 +6,7 @@ import { panelBorder, textMuted, themeAccent, themeContributionColors } from '..
6
6
  import { panelBorderStyle, useDisplay } from '../display-context.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
8
  import { truncate } from '../format.js';
9
+ import { toAsciiSafe } from '../ascii.js';
9
10
  const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
10
11
  const dayLabelWidth = 5;
11
12
  // Two terminal columns are approximately one row high in common monospace
@@ -15,6 +16,7 @@ const heatmapCell = ' ';
15
16
  const heatmapGap = '';
16
17
  export function StatsScreen({ library, theme, width, height }) {
17
18
  const { panel: panelBackground, ascii } = useDisplay();
19
+ const a = (value) => ascii ? toAsciiSafe(value) : value;
18
20
  const stats = computeListeningStats(library.activity.sessions);
19
21
  const contentWidth = Math.max(20, width - 4);
20
22
  const graph = buildContributionGraph(stats.days, contentWidth, height);
@@ -24,7 +26,7 @@ export function StatsScreen({ library, theme, width, height }) {
24
26
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
25
27
  const favoriteWidth = Math.max(8, metricWidth - 18);
26
28
  const compact = height < 30;
27
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsxs(Text, { color: themeAccent(theme), bold: true, children: ["Activity \u2014 ", graph.year] }) }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', truncate(favorite, favoriteWidth), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: textMuted, children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme }), _jsxs(Box, { marginTop: 1, paddingBottom: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: a(`Activity ${graph.year}`) }) }), _jsxs(Box, { flexDirection: "column", "aria-label": `Activity graph: ${stats.activeDays} active days in ${graph.year}`, children: [_jsxs(Text, { color: textMuted, children: [' '.repeat(dayLabelWidth), graph.months] }), graph.rows.map(row => (_jsx(React.Fragment, { children: Array.from({ length: graph.tileHeight }, (_, tileLine) => (_jsxs(Box, { children: [_jsx(Text, { color: textMuted, children: tileLine === 0 ? row.label.padEnd(dayLabelWidth) : ' '.repeat(dayLabelWidth) }), renderContributionCells(row.cells, graphColors, graph.cellGap)] }, `${row.key}-${tileLine}`))) }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: panelBorderStyle(ascii, 'single'), borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Text, { color: themeAccent(theme), bold: true, children: "Summary" }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", width: contentWidth, children: [metricPair('Favorite station', a(truncate(favorite, favoriteWidth)), 'Total hours listened', formatHours(totalHours), metricWidth, theme), metricPair('Sessions', stats.sessions.toLocaleString(), 'Longest streak', formatDays(stats.longestStreak), metricWidth, theme), metricPair('Current streak', formatDays(stats.currentStreak), 'Stations listened', stats.listenedStationCount.toLocaleString(), metricWidth, theme), metricPair('Active days', `${stats.activeDays}/${stats.totalTrackedDays}`, 'Stations counted after', '2 min', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, "aria-hidden": true, children: [_jsx(Text, { color: textMuted, children: a('Less · ') }), graphColors.map(color => (_jsxs(React.Fragment, { children: [renderLegendCell(color, graph.cellText, graph.cellGap), _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: textMuted, children: "More" })] })] })] }));
28
30
  }
29
31
  function renderContributionCells(cells, graphColors, cellGap) {
30
32
  const runs = [];
@@ -1,65 +1,93 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { resolveCommand } from '../player/command.js';
2
+ import { resolveCommand } from '../platform/executables.js';
3
3
  import { safeExternalHttpUrl } from '../safety.js';
4
- // The command used to open a URL in the platform's default handler.
5
- export function openExternalCommand(platform = process.platform) {
6
- if (platform === 'darwin') {
7
- return { command: 'open', args: [] };
8
- }
9
- if (platform === 'win32') {
10
- // Direct invocation avoids cmd.exe interpreting URL query characters.
11
- return { command: 'explorer', args: [] };
12
- }
13
- return { command: 'xdg-open', args: [] };
14
- }
15
- // Candidate clipboard tools per platform, in priority order. pbcopy/clip ship
16
- // with macOS/Windows; Linux/BSD rely on whichever of these is installed.
17
- export function clipboardCommands(platform = process.platform) {
18
- if (platform === 'darwin') {
19
- return [{ command: 'pbcopy', args: [] }];
20
- }
21
- if (platform === 'win32') {
22
- return [{ command: 'clip', args: [] }];
23
- }
24
- return [
25
- { command: 'wl-copy', args: [] },
26
- { command: 'xclip', args: ['-selection', 'clipboard'] },
27
- { command: 'xsel', args: ['--clipboard', '--input'] }
28
- ];
29
- }
30
- export function openExternal(url, platform = process.platform) {
4
+ import { browserCommands, clipboardCandidates, hasGraphicalSession } from '../platform/desktop.js';
5
+ import { identifyPlatform } from '../platform/runtime.js';
6
+ import { waitForLaunch } from '../platform/launch-command.js';
7
+ export async function openExternal(url, platform = process.platform, deps = {}) {
31
8
  const safeUrl = safeExternalHttpUrl(url);
32
- if (!safeUrl) {
9
+ const env = deps.env ?? process.env;
10
+ const host = identifyPlatform({ platform, env });
11
+ if (!safeUrl || !hasGraphicalSession(host, env))
33
12
  return false;
13
+ for (const { command, args } of browserCommands(host)) {
14
+ const executable = (deps.resolve ?? resolveCommand)(command);
15
+ if (!executable)
16
+ continue;
17
+ try {
18
+ // xdg-open may run for the browser's entire lifetime. Observe startup
19
+ // failure, then let the desktop application own its detached process.
20
+ const child = (deps.spawn ?? spawn)(executable, [...args, safeUrl], { stdio: 'ignore', env, detached: true, windowsHide: true });
21
+ await waitForLaunch(child);
22
+ return true;
23
+ }
24
+ catch { /* Try the next available opener after a launch failure. */ }
34
25
  }
35
- const { command, args } = openExternalCommand(platform);
36
- try {
37
- const child = spawn(resolveCommand(command) ?? command, [...args, safeUrl], { stdio: 'ignore', detached: true });
38
- child.on('error', () => undefined);
39
- child.unref();
40
- return true;
41
- }
42
- catch {
43
- // Opening a browser is best-effort; never crash the TUI over it.
26
+ return false;
27
+ }
28
+ export async function copyToClipboard(text, platform = process.platform, deps = {}) {
29
+ const env = deps.env ?? process.env;
30
+ const host = identifyPlatform({ platform, env });
31
+ if (!hasGraphicalSession(host, env))
44
32
  return false;
33
+ for (const { command, args } of clipboardCandidates(host)) {
34
+ const executable = (deps.resolve ?? resolveCommand)(command);
35
+ if (executable && await runHelper(executable, args, text, deps))
36
+ return true;
45
37
  }
38
+ return false;
46
39
  }
47
- export function copyToClipboard(text, platform = process.platform) {
48
- for (const { command, args } of clipboardCommands(platform)) {
49
- const resolved = resolveCommand(command);
50
- if (!resolved) {
51
- continue;
52
- }
40
+ function runHelper(command, args, input, deps) {
41
+ return new Promise(resolve => {
42
+ let child;
43
+ let settled = false;
44
+ let stopping = false;
45
+ let forceTimer;
46
+ let cleanupTimer;
47
+ const signal = (value) => {
48
+ try {
49
+ child?.kill(value);
50
+ }
51
+ catch { /* The failed action remains false; cleanup is still bounded. */ }
52
+ };
53
+ const finish = (ok) => {
54
+ if (settled)
55
+ return;
56
+ settled = true;
57
+ clearTimeout(timer);
58
+ clearTimeout(forceTimer);
59
+ clearTimeout(cleanupTimer);
60
+ resolve(ok);
61
+ };
62
+ const stop = () => {
63
+ if (settled || stopping)
64
+ return;
65
+ stopping = true;
66
+ forceTimer = setTimeout(() => signal('SIGKILL'), 250);
67
+ cleanupTimer = setTimeout(() => {
68
+ // Even an uninterruptible OS process must not keep RadioCLI alive.
69
+ child?.unref();
70
+ finish(false);
71
+ }, 1_000);
72
+ child?.stdin?.destroy();
73
+ signal('SIGTERM');
74
+ };
75
+ const timer = setTimeout(stop, deps.timeoutMs ?? 5000);
53
76
  try {
54
- const child = spawn(resolved, args, { stdio: ['pipe', 'ignore', 'ignore'] });
55
- child.on('error', () => undefined);
56
- child.stdin.write(text);
57
- child.stdin.end();
58
- return true;
77
+ child = (deps.spawn ?? spawn)(command, args, { stdio: ['pipe', 'ignore', 'ignore'], env: deps.env ?? process.env, windowsHide: true });
78
+ child.on('error', () => { if (child?.pid)
79
+ stop();
80
+ else
81
+ finish(false); });
82
+ child.once('close', code => finish(!stopping && code === 0));
83
+ child.stdin?.on('error', stop);
84
+ child.stdin?.end(input);
59
85
  }
60
86
  catch {
61
- continue;
87
+ if (child?.pid)
88
+ stop();
89
+ else
90
+ finish(false);
62
91
  }
63
- }
64
- return false;
92
+ });
65
93
  }
@@ -0,0 +1,16 @@
1
+ import { createRequire } from 'node:module';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { resolveTerminalCapabilities } from '../platform/terminal.js';
4
+ /**
5
+ * Ink uses Chalk's shared instance for both text and panel fills. FORCE_COLOR
6
+ * is a minimum in Chalk, so setting it cannot cap a Windows/CI/truecolor host
7
+ * at 16 or 256 colors. Set the instance's level before the first Ink render.
8
+ * Resolve from Ink itself so nested dependency installations work as well.
9
+ */
10
+ export async function configureTerminalRenderer(env = process.env, evidence = {}) {
11
+ const terminal = resolveTerminalCapabilities(env, evidence);
12
+ const inkRequire = createRequire(import.meta.resolve('ink'));
13
+ const { default: chalk } = await import(pathToFileURL(inkRequire.resolve('chalk')).href);
14
+ chalk.level = terminal.colorLevel;
15
+ return terminal;
16
+ }
@@ -180,6 +180,40 @@ export function useAlarmTui(params) {
180
180
  }
181
181
  await refreshRuntime();
182
182
  }, [refreshRuntime, service, setMessage]);
183
+ const deleteAlarm = useCallback(async (alarm) => {
184
+ let phase = 'disable';
185
+ let message;
186
+ try {
187
+ // Persist the safe state before touching native jobs. A failed cleanup
188
+ // can then be retried without another write in the error handler.
189
+ const disabled = store.toggleAlarm(alarm.id, false);
190
+ const disabledSnapshot = store.snapshot();
191
+ alarmStateRef.current = disabledSnapshot.alarms;
192
+ setLibrary(disabledSnapshot);
193
+ phase = 'native';
194
+ await service.remove(disabled);
195
+ phase = 'definition';
196
+ store.removeAlarm(alarm.id);
197
+ const snapshot = store.snapshot();
198
+ alarmStateRef.current = snapshot.alarms;
199
+ setLibrary(snapshot);
200
+ setSelected(value => clamp(value, snapshot.alarms.length + 1));
201
+ message = `Deleted ${alarm.label}.`;
202
+ }
203
+ catch (error) {
204
+ message = phase === 'disable'
205
+ ? `Delete canceled: ${messageOf(error)}. Alarm and native jobs unchanged.`
206
+ : phase === 'native'
207
+ ? `Delete cleanup failed: ${messageOf(error)}. ${alarm.label} was kept disabled; press r to repair.`
208
+ : `Native cleanup completed. Save failed: ${messageOf(error)}. ${alarm.label} was kept disabled; retry deletion.`;
209
+ }
210
+ finally {
211
+ busyAlarmIdsRef.current.delete(alarm.id);
212
+ setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
213
+ }
214
+ await refreshRuntime();
215
+ setMessage(message);
216
+ }, [refreshRuntime, service, setLibrary, setMessage, setSelected, store]);
183
217
  const saveDraft = useCallback(() => {
184
218
  if (!draft || savingRef.current)
185
219
  return;
@@ -343,26 +377,7 @@ export function useAlarmTui(params) {
343
377
  setDeletingId(undefined);
344
378
  busyAlarmIdsRef.current.add(alarm.id);
345
379
  setBusyAlarmIds(new Set(busyAlarmIdsRef.current));
346
- void service.remove(alarm).then(() => {
347
- if (store.getAlarm(alarm.id))
348
- store.removeAlarm(alarm.id);
349
- const snapshot = store.snapshot();
350
- alarmStateRef.current = snapshot.alarms;
351
- setLibrary(snapshot);
352
- setSelected(value => clamp(value, snapshot.alarms.length + 1));
353
- setMessage(`Deleted ${alarm.label}.`);
354
- }).catch(error => {
355
- const existing = store.getAlarm(alarm.id);
356
- if (existing) {
357
- const disabled = store.toggleAlarm(alarm.id, false);
358
- const snapshot = store.snapshot();
359
- alarmStateRef.current = snapshot.alarms;
360
- setLibrary(snapshot);
361
- setMessage(`Delete cleanup failed; ${disabled.label} was kept disabled so Repair can retry: ${messageOf(error)}`);
362
- }
363
- else
364
- setMessage(`Delete cleanup failed after the local alarm disappeared: ${messageOf(error)}`);
365
- }).finally(() => { busyAlarmIdsRef.current.delete(alarm.id); setBusyAlarmIds(new Set(busyAlarmIdsRef.current)); void refreshRuntime(); });
380
+ void deleteAlarm(alarm).catch(error => setMessage(`Unexpected alarm deletion failure: ${messageOf(error)}`));
366
381
  return true;
367
382
  }
368
383
  if (input !== 'x') {
@@ -461,6 +476,10 @@ export function useAlarmTui(params) {
461
476
  if (!active)
462
477
  return false;
463
478
  if (key.return) {
479
+ if (active.status.state === 'starting') {
480
+ setMessage(`Still preparing ${active.status.stationName}; handoff will be available once playback starts.`);
481
+ return true;
482
+ }
464
483
  if (pendingActiveActionsRef.current.has(active.key)) {
465
484
  setMessage(`An alarm control request is already pending for ${active.status.stationName}.`);
466
485
  return true;
@@ -673,7 +692,7 @@ export function useAlarmTui(params) {
673
692
  return true;
674
693
  }
675
694
  return false;
676
- }, [activeAlarms, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
695
+ }, [activeAlarms, deleteAlarm, deletingId, draft, editSelected, editingField, editorControl, editorField, go, library.alarms, openForStation, pickerChoices, pickerFallback, previewStation, refreshActive, refreshRuntime, runVerification, saveDraft, screen, selected, service, setLibrary, setMessage, setSelected, snoozeMinutes, store, syncPersisted, timeSegment, verification, visibleFields, weekdayIndex]);
677
696
  const openActive = useCallback(() => {
678
697
  if (!activeAlarms.length) {
679
698
  setMessage('No alarm is currently playing.');