@ciphore/radiocli 0.1.5 → 0.1.6

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.
@@ -19,6 +19,10 @@ export const settingsItems = [
19
19
  'Volume down',
20
20
  'Mute or unmute',
21
21
  'Toggle skip broken streams',
22
+ 'Resume last station on launch',
23
+ 'Transparent background',
24
+ 'ASCII-safe display',
25
+ 'Reduce motion',
22
26
  'Refresh provider health',
23
27
  'Learn previous media key',
24
28
  'Learn play/pause media key',
@@ -5,14 +5,18 @@ import { StationList } from '../components/StationList.js';
5
5
  import { buildCosmoWorldMap } from '../cosmo-world-map.js';
6
6
  import { computeExploreMapLayout } from '../explore-map-layout.js';
7
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
8
- import { exploreMapLand, mapMarker, panelBackground, panelBorder, themeAccent } from '../theme.js';
8
+ import { exploreMapLand, mapMarker, panelBorder, themeAccent } from '../theme.js';
9
+ import { panelBorderStyle, useDisplay } from '../display-context.js';
10
+ import { toAsciiSafe } from '../ascii.js';
9
11
  export function ExploreScreen({ title, subtitle, stations, selected, loading, theme, favorites, filterLabel, cursor, pageSize, width, height }) {
12
+ const { panel: panelBackground, ascii } = useDisplay();
10
13
  const { contentWidth, headerRows, bodyRows, split, listPanelWidth, mapPanelWidth, mapRows, mapColumns, listRows, listPageSize } = computeExploreMapLayout(width, height, pageSize);
11
14
  const cursorMarker = React.useMemo(() => [{ lat: cursor.latitude, lon: cursor.longitude, selected: true }], [cursor.latitude, cursor.longitude]);
12
15
  const map = React.useMemo(() => buildCosmoWorldMap(mapColumns, mapRows, cursorMarker), [mapColumns, mapRows, cursorMarker]);
13
- 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: "single", borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: mapPanelWidth, height: split ? bodyRows : mapRows + 2, flexDirection: "column", children: map.map((row, index) => (_jsx(CosmoMapLine, { row: row, theme: theme }, `map-${index}`))) }), _jsxs(Box, { marginLeft: split ? 1 : 0, marginTop: split ? 0 : 1, borderStyle: "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: "gray", children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: "gray", children: loading ? 'Loading stations…' : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
16
+ 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: 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: "gray", children: stations.length.toLocaleString() })] }), _jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { color: "gray", children: loading ? (ascii ? toAsciiSafe('Loading stations…') : 'Loading stations…') : ' ' }) }), !loading ? (_jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: listPageSize, width: Math.max(42, listPanelWidth - 2) })) : null] })] })] }));
14
17
  }
15
18
  function CosmoMapLine({ row, theme }) {
19
+ const { ascii } = useDisplay();
16
20
  const chunks = [];
17
21
  for (const cell of row.cells) {
18
22
  const previous = chunks.at(-1);
@@ -27,7 +31,7 @@ function CosmoMapLine({ row, theme }) {
27
31
  return (_jsx(Box, { children: chunks.map(chunk => {
28
32
  const key = `${offset}-${chunk.kind}`;
29
33
  offset += chunk.text.length;
30
- return (_jsx(Text, { color: cosmoMapColor(chunk.kind, theme), children: chunk.text }, key));
34
+ return (_jsx(Text, { color: cosmoMapColor(chunk.kind, theme), children: ascii ? toAsciiSafe(chunk.text) : chunk.text }, key));
31
35
  }) }));
32
36
  }
33
37
  function cosmoMapColor(kind, theme) {
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { themeAccent } from '../theme.js';
4
+ import { ScreenHeader } from '../components/ScreenHeader.js';
5
+ import { commandHelp, keyHelpSections } from '../help-content.js';
6
+ import { truncate } from '../format.js';
7
+ export function HelpScreen({ theme, width }) {
8
+ const accent = themeAccent(theme);
9
+ const keyColumnWidth = 16;
10
+ const lineWidth = Math.max(28, width - 2);
11
+ return (_jsxs(Box, { flexDirection: "column", 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: "row", gap: 4, flexWrap: "wrap", children: keyHelpSections.map(section => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: accent, bold: true, children: section.title }), section.entries.map(entry => (_jsxs(Text, { children: [_jsx(Text, { color: accent, children: entry.keys.padEnd(keyColumnWidth) }), _jsx(Text, { color: "gray", children: entry.description })] }, entry.keys)))] }, section.title))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: accent, bold: true, children: "Commands (press : then type)" }), _jsx(Box, { flexDirection: "row", flexWrap: "wrap", columnGap: 3, children: commandHelp.map(command => (_jsx(Text, { color: "gray", children: truncate(`:${command.name}${command.args ? ` ${command.args}` : ''}`, lineWidth) }, command.name))) })] })] }));
12
+ }
@@ -1,12 +1,16 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { mapLand, mapWater, themeAccent, themeContributionColors } from '../theme.js';
4
+ import { useDisplay } from '../display-context.js';
5
+ import { toAsciiSafe } from '../ascii.js';
4
6
  import { visibleWindow } from '../list-window.js';
5
7
  import { Menu, Pointer } from '../components/Menu.js';
6
8
  import { ScreenHeader } from '../components/ScreenHeader.js';
7
9
  import { truncate } from '../format.js';
8
10
  import { buildWorldMap } from '../world-map.js';
9
11
  export function MapScreen({ countries, selected, loading, filter, editingFilter, theme, pageSize, mode, width }) {
12
+ const { ascii } = useDisplay();
13
+ const asciify = (value) => (ascii ? toAsciiSafe(value) : value);
10
14
  const contentWidth = Math.max(52, width - 2);
11
15
  const topCountries = Array.from(countries).sort((a, b) => b.stationCount - a.stationCount).slice(0, mode === 'full' ? 10 : 6);
12
16
  const selectedCountry = countries[selected];
@@ -14,9 +18,10 @@ export function MapScreen({ countries, selected, loading, filter, editingFilter,
14
18
  const graph = buildWorldMap(countries, selectedCountry, mode, contentWidth);
15
19
  const topWidth = mode === 'full' ? 50 : contentWidth;
16
20
  const listWidth = Math.max(28, Math.min(contentWidth - topWidth - 4, 56));
17
- 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 || 'all'}` }), loading ? _jsx(Text, { color: "gray", children: "Loading country density\u2026" }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsxs(Text, { color: "gray", children: [graph.plotted, " plotted \u00B7 ", graph.unplotted, " unplaced \u00B7 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: "gray", children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: "gray", children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? `${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}` : 'none' })] }), _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 }), _jsxs(Text, { color: "gray", children: [" \u00B7 ", 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 || 'all'}` }), loading ? _jsx(Text, { color: "gray", children: asciify('Loading country density…') }) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: graph.rows.map(row => (_jsx(MapLine, { row: row, theme: theme }, row.cells.map(cell => cell.char).join('')))) }), _jsx(Text, { color: "gray", 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: "gray", children: "Highest station counts" }), topCountries.map(country => (_jsxs(Text, { children: [_jsx(Text, { color: themeAccent(theme), children: country.code.padEnd(3) }), _jsx(Text, { children: truncate(country.name, 30).padEnd(31) }), _jsx(Text, { color: "gray", children: country.stationCount.toLocaleString().padStart(7) })] }, country.code)))] }), _jsxs(Box, { marginLeft: mode === 'full' ? 3 : 0, marginTop: mode === 'full' ? 0 : 1, width: listWidth, flexDirection: "column", children: [_jsxs(Text, { children: ["Selected:", ' ', _jsx(Text, { color: themeAccent(theme), children: selectedCountry ? asciify(`${selectedCountry.name} · ${selectedCountry.stationCount.toLocaleString()}`) : 'none' })] }), _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: "gray", children: asciify(` · ${truncate(country.name, Math.max(8, listWidth - 8))}`) })] })) })] })] })] }));
18
22
  }
19
23
  function MapLine({ row, theme }) {
24
+ const { ascii } = useDisplay();
20
25
  const chunks = [];
21
26
  for (const cell of row.cells) {
22
27
  const previous = chunks[chunks.length - 1];
@@ -33,7 +38,7 @@ function MapLine({ row, theme }) {
33
38
  keyedChunks.push({ key: `${offset}-${chunk.kind}`, ...chunk });
34
39
  offset += chunk.text.length;
35
40
  }
36
- return (_jsx(Box, { children: keyedChunks.map(chunk => (_jsx(Text, { color: mapColor(chunk.kind, theme), children: chunk.text }, chunk.key))) }));
41
+ return (_jsx(Box, { children: keyedChunks.map(chunk => (_jsx(Text, { color: mapColor(chunk.kind, theme), children: ascii ? toAsciiSafe(chunk.text) : chunk.text }, chunk.key))) }));
37
42
  }
38
43
  function mapColor(kind, theme) {
39
44
  const colors = themeContributionColors(theme);
@@ -1,10 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { stationLocation, stationTags, stationTech, truncate } from '../format.js';
4
- import { panelBackground, themeAccent } from '../theme.js';
4
+ import { themeAccent } from '../theme.js';
5
+ import { panelBorderStyle, useDisplay } from '../display-context.js';
6
+ import { toAsciiSafe } from '../ascii.js';
5
7
  import { ScreenHeader } from '../components/ScreenHeader.js';
6
8
  import { buildVisualizer, visualizerHeight } from '../visualizers/receiver-visualizers.js';
7
- export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, sleepLabel, showDiagnostics, stationTime, receiverStyle, width, height }) {
9
+ export function NowPlayingScreen({ station, playback, metadata, theme, favorite, pulse, diagnostics, sleepLabel, showDiagnostics, stationTime, receiverStyle, trackHistory, width, height }) {
10
+ const { panel: panelBackground, ascii } = useDisplay();
11
+ // In ASCII mode route every rendered string through the glyph mapper so no
12
+ // braille, block, box-drawing, or punctuation (·, ★) leaks to the terminal.
13
+ const a = (value) => (ascii ? toAsciiSafe(value) : value);
14
+ const stationTracks = recentTracksForStation(trackHistory, station, 3);
8
15
  const accent = themeAccent(theme);
9
16
  const panelWidth = Math.max(62, width);
10
17
  const panelHeight = Math.max(10, height);
@@ -27,9 +34,25 @@ export function NowPlayingScreen({ station, playback, metadata, theme, favorite,
27
34
  ? truncate(metadata.title, Math.max(8, innerWidth - 12))
28
35
  : 'Waiting for ICY track metadata';
29
36
  const dialLabel = receiverDialLabel(station);
30
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Now playing", width: panelWidth, theme: theme }), _jsxs(Box, { borderStyle: "round", borderColor: accent, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, flexDirection: "column", paddingX: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: dialLabel }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: stationName }) }), _jsx(Text, { color: "gray", children: truncate(stationPlace, innerWidth) }), _jsx(Box, { flexDirection: "column", children: visualRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
37
+ const renderRows = ascii ? visualRows.map(asciifyVisualRow) : visualRows;
38
+ const favoriteText = favorite ? '★ Favorite' : '☆ Favorite';
39
+ 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: 2, paddingY: 1, width: panelWidth, height: panelHeight, children: [_jsxs(Box, { justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: accent, bold: true, children: a(dialLabel) }), _jsx(Text, { color: accent, children: "RADIOCLI" }), _jsx(Text, { color: accent, children: playback.state.toUpperCase() })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: accent, bold: true, children: a(stationName) }) }), _jsx(Text, { color: "gray", children: a(truncate(stationPlace, innerWidth)) }), _jsx(Box, { flexDirection: "column", children: renderRows.map((row, index) => (_jsx(Text, { color: row.segments ? undefined : row.color, children: row.segments
31
40
  ? renderSegments(row.segments)
32
- : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : 'gray', children: metadataLine }), _jsx(Text, { color: favorite ? 'yellow' : 'gray', children: favorite ? '★ Favorite' : '☆ Favorite' })] }), _jsx(Text, { color: "gray", children: infoLine }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Diagnostics" }), _jsxs(Text, { color: "gray", children: ["Stream: ", diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'] }), _jsxs(Text, { color: "gray", children: ["Station time: ", stationTime] }), _jsxs(Text, { color: "gray", children: ["Started: ", diagnostics.startedAt ? new Date(diagnostics.startedAt).toLocaleTimeString() : 'not playing', " \u00B7 available ", diagnostics.availableBackends.join(', ') || 'none'] })] })) : null] })] }));
41
+ : row.text }, `${index}-${row.color}-${row.text}`))) }), _jsxs(Box, { marginTop: 1, justifyContent: "space-between", width: innerWidth, children: [_jsx(Text, { color: metadata?.title ? accent : 'gray', children: a(metadataLine) }), _jsx(Text, { color: favorite ? 'yellow' : 'gray', children: a(favoriteText) })] }), _jsx(Text, { color: "gray", children: a(infoLine) }), showDiagnostics ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Diagnostics" }), _jsx(Text, { color: "gray", children: a(`Stream: ${diagnostics.streamUrl ? truncate(diagnostics.streamUrl, innerWidth - 8) : 'none'}`) }), _jsx(Text, { color: "gray", children: a(`Station time: ${stationTime}`) }), _jsx(Text, { color: "gray", 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: "gray", children: "Recent tracks" }), stationTracks.map(track => (_jsx(Text, { color: "gray", children: a(`· ${truncate(track.title, innerWidth - 2)}`) }, `${track.at}-${track.title}`)))] })) : null] })) : null] })] }));
42
+ }
43
+ function asciifyVisualRow(row) {
44
+ return {
45
+ ...row,
46
+ text: toAsciiSafe(row.text),
47
+ segments: row.segments?.map(segment => ({ ...segment, text: toAsciiSafe(segment.text) }))
48
+ };
49
+ }
50
+ export function recentTracksForStation(history, station, limit) {
51
+ if (!station) {
52
+ return [];
53
+ }
54
+ const key = `${station.provider}:${station.id}`;
55
+ return history.filter(track => track.stationKey === key).slice(0, limit);
33
56
  }
34
57
  export function receiverDialLabel(station) {
35
58
  if (!station) {
@@ -2,11 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { StationList } from '../components/StationList.js';
4
4
  import { ScreenHeader } from '../components/ScreenHeader.js';
5
- import { panelBackground, panelBorder, themeAccent } from '../theme.js';
5
+ import { panelBorder, themeAccent } from '../theme.js';
6
+ import { panelBorderStyle, useDisplay } from '../display-context.js';
6
7
  import { truncate } from '../format.js';
7
8
  export function SearchScreen({ query, editing, loading, stations, selected, theme, favorites, experimentalOn, filterLabel, pageSize, width }) {
9
+ const { panel: panelBackground, ascii } = useDisplay();
8
10
  const contentWidth = Math.max(40, width);
9
11
  const inputWidth = Math.max(34, Math.min(contentWidth, 96));
10
12
  const inputTextWidth = Math.max(8, inputWidth - 8);
11
- 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: "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) : 'gray', children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : 'gray', children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: "gray", children: "Searching public station directories\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] }));
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) : 'gray', children: editing ? '› ' : ' ' }), _jsx(Text, { color: query ? themeAccent(theme) : 'gray', children: truncate(query || 'Search stations, genres, languages, places…', inputTextWidth) })] }), loading ? _jsx(Text, { color: "gray", children: "Searching public station directories\u2026" }) : null, !loading ? _jsx(StationList, { stations: stations, selected: selected, theme: theme, favorites: favorites, pageSize: pageSize, width: width }) : null] }));
12
14
  }
@@ -38,6 +38,14 @@ function settingValue(item, settings, diagnostics, backends, airPlayDevices) {
38
38
  return diagnostics.muted ? 'muted' : `vol ${diagnostics.volume}`;
39
39
  case 'Toggle skip broken streams':
40
40
  return settings.skipBrokenStreams ? 'on' : 'off';
41
+ case 'Resume last station on launch':
42
+ return settings.resumeOnLaunch ? 'on' : 'off';
43
+ case 'Transparent background':
44
+ return settings.transparentBackground ? 'on' : 'off';
45
+ case 'ASCII-safe display':
46
+ return settings.asciiMode ? 'on' : 'off';
47
+ case 'Reduce motion':
48
+ return settings.reduceMotion ? 'on' : 'off';
41
49
  case 'Reset learned media keys':
42
50
  return `prev ${settings.mediaKeys.previous.length} · play ${settings.mediaKeys.playPause.length} · next ${settings.mediaKeys.next.length}`;
43
51
  default:
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { computeListeningStats } from '../../activity/stats.js';
4
- import { panelBackground, panelBorder, textHighlight, themeAccent, themeContributionColors } from '../theme.js';
4
+ import { panelBorder, textHighlight, themeAccent, themeContributionColors } from '../theme.js';
5
+ import { panelBorderStyle, useDisplay } from '../display-context.js';
5
6
  import { ScreenHeader } from '../components/ScreenHeader.js';
6
7
  import { truncate } from '../format.js';
7
8
  const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
8
9
  export function StatsScreen({ library, theme, width, height }) {
10
+ const { panel: panelBackground, ascii } = useDisplay();
9
11
  const stats = computeListeningStats(library.activity.sessions);
10
12
  const contentWidth = Math.max(20, width - 4);
11
13
  const graph = buildContributionGraph(stats.days, contentWidth);
@@ -15,7 +17,7 @@ export function StatsScreen({ library, theme, width, height }) {
15
17
  const metricWidth = Math.max(28, Math.floor((contentWidth - 2) / 2));
16
18
  const favoriteWidth = Math.max(8, metricWidth - 18);
17
19
  const compact = height < 30;
18
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 1, borderStyle: "single", borderColor: panelBorder, borderBackgroundColor: panelBackground, backgroundColor: panelBackground, width: width, flexDirection: "column", children: [_jsx(Box, { children: _jsx(Text, { color: themeAccent(theme), bold: true, children: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, row.key)))] })] }), _jsxs(Box, { marginTop: 1, borderStyle: "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}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: ["\u2588\u2588", _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: "gray", children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
20
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ScreenHeader, { title: "Listening stats", subtitle: "Local listening history \u00B7 never leaves this machine", width: width, theme: theme, right: `${formatHours(totalHours)} · ${stats.sessions.toLocaleString()} sessions` }), _jsxs(Box, { marginTop: 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: "Activity \u2014 last 53 weeks" }) }), _jsxs(Box, { marginTop: compact ? 0 : 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: [" ", graph.months] }), graph.rows.map(row => (_jsxs(Box, { children: [_jsx(Text, { color: "gray", children: row.label.padEnd(5) }), row.cells.map(cell => (_jsx(Text, { color: graphColors[cell.level], children: cell.text }, cell.key)))] }, 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}`, 'Station threshold', '>= 120s total', metricWidth, theme)] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "gray", children: "Less \u00B7 " }), graphColors.map(color => (_jsxs(Text, { color: color, children: ["\u2588\u2588", _jsx(Text, { children: " " })] }, color))), _jsx(Text, { color: "gray", children: "More" })] }), !compact ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: textHighlight, children: stats.totalSeconds > 0
19
21
  ? `Your total listening time is ${formatHours(totalHours)} across public radio streams.`
20
22
  : 'Start a station to begin filling the listening graph.' }) })) : null] })] }));
21
23
  }
@@ -0,0 +1,114 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { DisplayContext, resolveDisplayMode } from '../display-context.js';
5
+ import { HelpScreen } from './HelpScreen.js';
6
+ import { NowPlayingScreen } from './NowPlayingScreen.js';
7
+ import { SettingsScreen } from './SettingsScreen.js';
8
+ import { ExploreScreen } from './ExploreScreen.js';
9
+ import { settingsItems } from '../screen-items.js';
10
+ import { defaultExploreCursor } from '../app-state.js';
11
+ const station = {
12
+ id: 'station-1',
13
+ provider: 'radio-browser',
14
+ name: 'KEXP 90.3 FM',
15
+ country: 'United States',
16
+ tags: ['indie'],
17
+ codec: 'MP3',
18
+ bitrate: 128
19
+ };
20
+ const playback = {
21
+ backend: 'mpv',
22
+ state: 'idle',
23
+ volume: 70,
24
+ muted: false,
25
+ ready: false
26
+ };
27
+ const diagnostics = {
28
+ backend: 'mpv',
29
+ availableBackends: ['mpv'],
30
+ preferredBackend: 'auto',
31
+ active: false,
32
+ volume: 70,
33
+ muted: false,
34
+ ready: false
35
+ };
36
+ const trackHistory = [
37
+ { title: 'Bjork - Joga', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '2' },
38
+ { title: 'Aphex Twin - Avril 14th', stationKey: 'radio-browser:station-1', stationName: 'KEXP 90.3 FM', at: '1' }
39
+ ];
40
+ function renderNowPlaying(asciiMode, showDiagnostics) {
41
+ const mode = resolveDisplayMode({ asciiMode }, {});
42
+ return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(NowPlayingScreen, { station: station, playback: playback, metadata: null, theme: "green", favorite: true, pulse: 0, diagnostics: diagnostics, sleepLabel: "Sleep off", showDiagnostics: showDiagnostics, stationTime: "12:00", receiverStyle: "pulse-grid", trackHistory: trackHistory, width: 72, height: 30 }) }));
43
+ }
44
+ const settings = {
45
+ theme: 'green',
46
+ receiverStyle: 'pulse-grid',
47
+ volume: 70,
48
+ enableRadioGarden: false,
49
+ enableNearbyLocation: false,
50
+ preferredBackend: 'auto',
51
+ tuneTimeoutSeconds: 12,
52
+ skipBrokenStreams: true,
53
+ mediaKeys: { previous: [], playPause: [], next: [] },
54
+ resumeOnLaunch: true,
55
+ asciiMode: true,
56
+ reduceMotion: false,
57
+ transparentBackground: false
58
+ };
59
+ describe('SettingsScreen rendering', () => {
60
+ it('renders the new display and playback toggles with their values', () => {
61
+ const settingsIndex = Math.max(0, settingsItems.indexOf('Resume last station on launch'));
62
+ const { lastFrame } = render(_jsx(SettingsScreen, { selected: settingsIndex, settings: settings, storePath: "/tmp/radiocli.json", playback: playback, backends: ['mpv'], airPlayDevices: [], providerHealth: {}, theme: "green", diagnostics: diagnostics, width: 80 }));
63
+ const frame = lastFrame() ?? '';
64
+ expect(frame).toContain('Resume last station on launch');
65
+ expect(frame).toContain('ASCII-safe display');
66
+ expect(frame).toContain('Reduce motion');
67
+ expect(frame).toContain('Transparent background');
68
+ });
69
+ });
70
+ function renderExplore(asciiMode) {
71
+ const mode = resolveDisplayMode({ asciiMode }, {});
72
+ return render(_jsx(DisplayContext.Provider, { value: mode, children: _jsx(ExploreScreen, { title: "Explore", subtitle: "Move a map cursor through geotagged stations", stations: [station], selected: 0, loading: false, theme: "green", favorites: new Set(), filterLabel: "", cursor: defaultExploreCursor, pageSize: 8, width: 100, height: 24 }) }));
73
+ }
74
+ describe('Explore world map rendering', () => {
75
+ it('rasterizes land with braille glyphs by default', () => {
76
+ const frame = renderExplore(false).lastFrame() ?? '';
77
+ expect(/[⠀-⣿]/.test(frame)).toBe(true);
78
+ });
79
+ it('replaces braille with ASCII in ASCII-safe mode', () => {
80
+ const frame = renderExplore(true).lastFrame() ?? '';
81
+ expect(/[⠀-⣿]/.test(frame)).toBe(false);
82
+ });
83
+ });
84
+ describe('HelpScreen rendering', () => {
85
+ it('lists keybinding sections and : commands', () => {
86
+ const { lastFrame } = render(_jsx(HelpScreen, { theme: "green", width: 80 }));
87
+ const frame = lastFrame() ?? '';
88
+ expect(frame).toContain('Navigation');
89
+ expect(frame).toContain('Playback');
90
+ expect(frame).toContain(':search');
91
+ expect(frame).toContain(':doctor');
92
+ expect(frame).toContain('Toggle this help');
93
+ });
94
+ });
95
+ describe('NowPlayingScreen rendering', () => {
96
+ it('shows recent tracks for the tuned station when diagnostics are open', () => {
97
+ const { lastFrame } = renderNowPlaying(false, true);
98
+ const frame = lastFrame() ?? '';
99
+ expect(frame).toContain('Bjork - Joga');
100
+ expect(frame).toContain('UNITED STATES');
101
+ });
102
+ it('uses Unicode box borders by default', () => {
103
+ const { lastFrame } = renderNowPlaying(false, false);
104
+ const frame = lastFrame() ?? '';
105
+ expect(/[\u2500-\u257f]/.test(frame)).toBe(true);
106
+ });
107
+ it('emits only ASCII characters in ASCII-safe mode', () => {
108
+ const { lastFrame } = renderNowPlaying(true, true);
109
+ const frame = lastFrame() ?? '';
110
+ expect(frame).toContain('Bjork - Joga');
111
+ // No braille, block, box-drawing, or punctuation glyphs survive ASCII mode.
112
+ expect(/[^\x00-\x7f]/.test(frame)).toBe(false);
113
+ });
114
+ });
@@ -0,0 +1,58 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { resolveCommand } from '../player/command.js';
3
+ // The command used to open a URL in the platform's default handler.
4
+ export function openExternalCommand(platform = process.platform) {
5
+ if (platform === 'darwin') {
6
+ return { command: 'open', args: [] };
7
+ }
8
+ if (platform === 'win32') {
9
+ // The empty "" is the window title argument `start` expects before the URL.
10
+ return { command: 'cmd', args: ['/c', 'start', ''] };
11
+ }
12
+ return { command: 'xdg-open', args: [] };
13
+ }
14
+ // Candidate clipboard tools per platform, in priority order. pbcopy/clip ship
15
+ // with macOS/Windows; Linux/BSD rely on whichever of these is installed.
16
+ export function clipboardCommands(platform = process.platform) {
17
+ if (platform === 'darwin') {
18
+ return [{ command: 'pbcopy', args: [] }];
19
+ }
20
+ if (platform === 'win32') {
21
+ return [{ command: 'clip', args: [] }];
22
+ }
23
+ return [
24
+ { command: 'wl-copy', args: [] },
25
+ { command: 'xclip', args: ['-selection', 'clipboard'] },
26
+ { command: 'xsel', args: ['--clipboard', '--input'] }
27
+ ];
28
+ }
29
+ export function openExternal(url, platform = process.platform) {
30
+ const { command, args } = openExternalCommand(platform);
31
+ try {
32
+ const child = spawn(resolveCommand(command) ?? command, [...args, url], { stdio: 'ignore', detached: true });
33
+ child.on('error', () => undefined);
34
+ child.unref();
35
+ }
36
+ catch {
37
+ // Opening a browser is best-effort; never crash the TUI over it.
38
+ }
39
+ }
40
+ export function copyToClipboard(text, platform = process.platform) {
41
+ for (const { command, args } of clipboardCommands(platform)) {
42
+ const resolved = resolveCommand(command);
43
+ if (!resolved) {
44
+ continue;
45
+ }
46
+ try {
47
+ const child = spawn(resolved, args, { stdio: ['pipe', 'ignore', 'ignore'] });
48
+ child.on('error', () => undefined);
49
+ child.stdin.write(text);
50
+ child.stdin.end();
51
+ return true;
52
+ }
53
+ catch {
54
+ continue;
55
+ }
56
+ }
57
+ return false;
58
+ }
@@ -3,7 +3,8 @@ import { useInput } from 'ink';
3
3
  import { homeItems, settingsItems } from './screen-items.js';
4
4
  import { applyTextInput, clamp, favoriteTarget, isEditableInput, isPlainPrintableInput, mediaTransportActionForInput, shouldHandleKeyboardEvent } from './app-state.js';
5
5
  import { parseSgrMouseEvents, primaryMousePress } from './terminal-mouse.js';
6
- export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
6
+ import { completeCommand } from './help-content.js';
7
+ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, beginLearningTransportKey, capturingTransportAction, commandMode, commandText, copyStationUrl, openStationHomepage, currentItemCount, cycleDisplayColor, cycleAudioOutput, cycleReceiverStyle, cycleSleepTimer, editingCountryFilter, editingSearch, executeCommand, filteredCountries, go, lastRawTransportAtRef, lastSubmittedSearchRef, loadCountry, openAdjacentTab, openAirPlayCode, openAirPlaySettings, openScreen, playAdjacent, playStation, player, playingStation, recallSearchHistory, moveExploreCursor, moveExploreCursorToCell, refreshAirPlayTargets, refreshProviderHealth, resetLearnedTransportKeys, runSearch, saveLearnedTransportKey, screen, searchQuery, selected, selectedStation, selectAirPlayDeviceAt, setCapturingTransportAction, setAirPlayCode, setCommandMode, setCommandText, setCountryFilter, setEditingCountryFilter, setEditingSearch, setMessage, setSearchQuery, setSelected, setShowDiagnostics, submitAirPlayCode, settingsRef, shutdown, stdin, toggleFavorite, toggleMute, togglePause, toggleSetting, toggleNearbyLocation, toggleRadioGarden, toggleSkipBrokenStreams }) {
7
8
  useEffect(() => {
8
9
  const onData = (data) => {
9
10
  const rawInput = String(data);
@@ -93,6 +94,11 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
93
94
  setCommandMode(false);
94
95
  return;
95
96
  }
97
+ if (key.tab) {
98
+ // Complete the command name only while still typing it (no args yet).
99
+ setCommandText(value => (/\s/.test(value) ? value : completeCommand(value)));
100
+ return;
101
+ }
96
102
  if (isEditableInput(input, key)) {
97
103
  setCommandText(value => applyTextInput(value, input, key));
98
104
  }
@@ -132,6 +138,14 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
132
138
  setEditingSearch(false);
133
139
  return;
134
140
  }
141
+ if (key.upArrow) {
142
+ recallSearchHistory('older');
143
+ return;
144
+ }
145
+ if (key.downArrow) {
146
+ recallSearchHistory('newer');
147
+ return;
148
+ }
135
149
  if (isEditableInput(input, key)) {
136
150
  setSearchQuery(value => applyTextInput(value, input, key));
137
151
  setEditingSearch(true);
@@ -175,6 +189,10 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
175
189
  shutdown();
176
190
  return;
177
191
  }
192
+ if (input === '?') {
193
+ go(screen === 'help' ? 'home' : 'help');
194
+ return;
195
+ }
178
196
  if (input.startsWith(':')) {
179
197
  const seed = input.slice(1).replace(/[\r\n]+$/g, '');
180
198
  if (/[\r\n]/.test(input)) {
@@ -301,6 +319,14 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
301
319
  toggleFavorite(favoriteTarget(screen, selectedStation, playingStation));
302
320
  return;
303
321
  }
322
+ if (input === 'O') {
323
+ openStationHomepage(favoriteTarget(screen, selectedStation, playingStation));
324
+ return;
325
+ }
326
+ if (input === 'y') {
327
+ void copyStationUrl(favoriteTarget(screen, selectedStation, playingStation));
328
+ return;
329
+ }
304
330
  if (input === ' ') {
305
331
  togglePause();
306
332
  return;
@@ -376,6 +402,18 @@ export function useAppInput({ adjustVolume, airPlayCode, canEnterAirPlayCode, be
376
402
  else if (item === 'Toggle skip broken streams') {
377
403
  toggleSkipBrokenStreams();
378
404
  }
405
+ else if (item === 'Resume last station on launch') {
406
+ toggleSetting('resumeOnLaunch');
407
+ }
408
+ else if (item === 'Transparent background') {
409
+ toggleSetting('transparentBackground');
410
+ }
411
+ else if (item === 'ASCII-safe display') {
412
+ toggleSetting('asciiMode');
413
+ }
414
+ else if (item === 'Reduce motion') {
415
+ toggleSetting('reduceMotion');
416
+ }
379
417
  else if (item === 'Refresh provider health') {
380
418
  refreshProviderHealth();
381
419
  setMessage('Provider health refreshed.');
@@ -141,6 +141,28 @@ export function useCommandExecutor({ beginLearningTransportKey, countries, go, l
141
141
  await player.stop();
142
142
  return;
143
143
  }
144
+ if (name === 'doctor') {
145
+ const backends = player.refreshDetectedBackends();
146
+ setMessage(`Playback backends: ${backends.join(', ') || 'none — install mpv, then run radiocli doctor'}.`);
147
+ return;
148
+ }
149
+ if (name === 'help') {
150
+ go('help');
151
+ return;
152
+ }
153
+ if (name === 'resume' || name === 'ascii' || name === 'motion' || name === 'background') {
154
+ const key = name === 'resume'
155
+ ? 'resumeOnLaunch'
156
+ : name === 'ascii'
157
+ ? 'asciiMode'
158
+ : name === 'motion'
159
+ ? 'reduceMotion'
160
+ : 'transparentBackground';
161
+ const enabled = value === '' ? !settingsRef.current[key] : value === 'on' || value === 'true' || value === '1';
162
+ updateSettings({ [key]: enabled });
163
+ setMessage(`${name} ${enabled ? 'on' : 'off'}.`);
164
+ return;
165
+ }
144
166
  setMessage(`Unknown command: ${name}`);
145
167
  }, [
146
168
  beginLearningTransportKey,
@@ -0,0 +1,20 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ let cachedVersion = null;
4
+ export function appVersion() {
5
+ if (cachedVersion) {
6
+ return cachedVersion;
7
+ }
8
+ try {
9
+ const packageJsonPath = fileURLToPath(new URL('../package.json', import.meta.url));
10
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
11
+ cachedVersion = parsed.version ?? '0.0.0';
12
+ }
13
+ catch {
14
+ cachedVersion = '0.0.0';
15
+ }
16
+ return cachedVersion;
17
+ }
18
+ export function userAgent(suffix = '') {
19
+ return `radiocli/${appVersion()}${suffix}`;
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ciphore/radiocli",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "A terminal-first world radio receiver built with Ink, mpv, and resilient public-radio providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -75,6 +75,7 @@
75
75
  "devDependencies": {
76
76
  "@types/node": "^25.9.1",
77
77
  "@types/react": "^19.2.15",
78
+ "ink-testing-library": "^4.0.0",
78
79
  "knip": "^6.14.2",
79
80
  "publint": "^0.3.21",
80
81
  "tsx": "^4.22.3",