@audiofool/moondog 0.1.0

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 (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,306 @@
1
+ import { sanitizeTerminalText } from "./format-output.mjs";
2
+
3
+ const MAX_ITEMS_PER_SOURCE = 10;
4
+
5
+ function items(value) {
6
+ return Array.isArray(value) ? value.slice(0, MAX_ITEMS_PER_SOURCE) : [];
7
+ }
8
+
9
+ function inline(value) {
10
+ return typeof value === "string"
11
+ ? sanitizeTerminalText(value).replace(/\s+/gu, " ").trim()
12
+ : "";
13
+ }
14
+
15
+ function identity(value) {
16
+ return inline(value).normalize("NFKC").toLocaleLowerCase("und");
17
+ }
18
+
19
+ function identifier(value) {
20
+ return typeof value === "string" && value.trim() && value.length <= 128 &&
21
+ value === inline(value) ? value : undefined;
22
+ }
23
+
24
+ function count(value) {
25
+ return Number.isSafeInteger(value) && value >= 0
26
+ ? value.toLocaleString("en-US")
27
+ : undefined;
28
+ }
29
+
30
+ function amount(value) {
31
+ return Number.isFinite(value) && value >= 0
32
+ ? value.toLocaleString("en-US", { maximumFractionDigits: 1 })
33
+ : undefined;
34
+ }
35
+
36
+ function date(value) {
37
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}/u.test(value) &&
38
+ Number.isFinite(Date.parse(value)) ? inline(value).slice(0, 10) : "";
39
+ }
40
+
41
+ function targetFor(item, kind) {
42
+ const label = kind === "artist" ? item?.name ?? item?.label : item?.label;
43
+ if (!inline(label) || !["artist", "track"].includes(kind)) return null;
44
+ if (kind === "track" && !inline(item?.artist_credit)) return null;
45
+ return {
46
+ entityType: kind,
47
+ label,
48
+ ...(kind === "track" ? { artistCredit: item.artist_credit } : {}),
49
+ };
50
+ }
51
+
52
+ function subjectKey(target) {
53
+ return JSON.stringify([
54
+ target.entityType,
55
+ identity(target.label),
56
+ ...(target.entityType === "track" ? [identity(target.artistCredit)] : []),
57
+ ]);
58
+ }
59
+
60
+ function plural(value, one, many = `${one}s`) {
61
+ return `${count(value)} ${value === 1 ? one : many}`;
62
+ }
63
+
64
+ function itemMetrics(item) {
65
+ const values = [];
66
+ const known = (field) => count(item?.[field]) !== undefined;
67
+ if (known("play_count")) values.push(plural(item.play_count, "play"));
68
+ if (known("engaged_play_count") && item.engaged_play_count !== item.play_count) {
69
+ values.push(`${count(item.engaged_play_count)} heard properly`);
70
+ }
71
+ if (known("distinct_tracks")) values.push(plural(item.distinct_tracks, "track"));
72
+ if (known("explicit_skips") && item.explicit_skips > 0) values.push(`skipped ${plural(item.explicit_skips, "time")}`);
73
+ if (known("playlist_count")) values.push(`on ${plural(item.playlist_count, "playlist")}`);
74
+ if (known("quiet_days")) values.push(`quiet for ${plural(item.quiet_days, "day")}`);
75
+ if (known("return_count")) values.push(`came back ${item.return_count === 1 ? "once" : `${count(item.return_count)} times`}`);
76
+ if (known("longest_gap_days")) values.push(`longest time away ${plural(item.longest_gap_days, "day")}`);
77
+ if (known("maximum_consecutive_plays")) values.push(`up to ${count(item.maximum_consecutive_plays)} in a row`);
78
+ if (known("burst_count") && item.burst_count > 1) values.push(`${count(item.burst_count)} separate runs`);
79
+ const minutes = amount(item?.listening_minutes);
80
+ if (minutes !== undefined) values.push(`${minutes} min`);
81
+ const lastPlayed = date(item?.last_played_at);
82
+ if (lastPlayed) values.push(`last played ${lastPlayed}`);
83
+ if (Number.isFinite(item?.confidence) && item.confidence >= 0 && item.confidence <= 1) {
84
+ values.push(`${Math.round(item.confidence * 100)}% sure`);
85
+ }
86
+ return values;
87
+ }
88
+
89
+ function overview(profile) {
90
+ const coverage = profile?.coverage ?? {};
91
+ const lines = [];
92
+ const durationUnavailable = coverage.effective_listening_events > 0 && coverage.events_with_played_duration === 0;
93
+ const listening = [
94
+ [count(coverage.effective_listening_events), coverage.effective_listening_events === 1 ? "play" : "plays"],
95
+ [durationUnavailable ? undefined : amount(coverage.listening_hours), "h"],
96
+ [count(coverage.listening_tracks), coverage.listening_tracks === 1 ? "track" : "tracks"],
97
+ ].filter(([value]) => value !== undefined).map(([value, label]) => `${value} ${label}`);
98
+ if (durationUnavailable) listening.push("listening time unknown");
99
+ if (listening.length && !(coverage.effective_listening_events === 0 && (coverage.tracks_observed > 0 || coverage.collection_tracks > 0))) {
100
+ lines.push(listening.join(" · "));
101
+ }
102
+ if (Number.isSafeInteger(coverage.tracks_observed) && coverage.tracks_observed > 0) {
103
+ const loved = count(coverage.loved_or_favorited);
104
+ lines.push(`Apple Music library: ${plural(coverage.tracks_observed, "track")}${
105
+ loved === undefined ? "" : ` · ${loved} loved`
106
+ }`);
107
+ }
108
+ const source = profile?.listening_source ?? {};
109
+ const collections = items(coverage.collection_sources).filter((collection) => collection.tracks > 0);
110
+ if (collections.length > 1) lines.push(`${count(coverage.collection_tracks)} songs across ${collections.length} services`);
111
+ for (const collection of collections) {
112
+ if (collection.tracks > 0) lines.push(`${inline(collection.label)}: ${plural(collection.tracks, "song")}`);
113
+ }
114
+ const providers = items(source.providers).map(inline).filter(Boolean).map((provider) => ({
115
+ spotify: "Spotify",
116
+ listenbrainz: "ListenBrainz",
117
+ lastfm: "Last.fm",
118
+ apple_music: "Apple Music",
119
+ youtube_music: "YouTube Music",
120
+ })[provider] ?? provider);
121
+ const earliest = date(source.listening_range?.earliest);
122
+ const latest = date(source.listening_range?.latest);
123
+ const range = earliest && latest ? `${earliest} to ${latest}` : earliest || latest;
124
+ if (providers.length || range) {
125
+ lines.push([providers.length ? `${providers.join(", ")} history` : "History", range]
126
+ .filter(Boolean).join(" · "));
127
+ }
128
+ const captured = date(profile?.source?.captured_at);
129
+ if (captured) lines.push(`Library as of ${captured}`);
130
+ const active = count(coverage.active_listener_assertions);
131
+ if (coverage.active_listener_assertions > 0) lines.push(`${plural(coverage.active_listener_assertions, "choice")} you've made`);
132
+ return lines;
133
+ }
134
+
135
+ const sourceNames = {
136
+ "Apple Music library preference": "In your Apple Music library",
137
+ "Apple Music aggregate play count": "Apple Music play count",
138
+ };
139
+
140
+ function explicitKind(item) {
141
+ if (["artist", "track"].includes(item?.entity_type)) return item.entity_type;
142
+ if (item?.track_ref_id || item?.track_ref?.track_ref_id) return "track";
143
+ if (inline(item?.name) && !item?.artist_credit) return "artist";
144
+ return null;
145
+ }
146
+
147
+ /** Build a private, bounded review view from structured profile evidence. */
148
+ export function buildTasteProfileModel(profile = {}) {
149
+ const metrics = (item) => itemMetrics(profile.coverage?.events_with_played_duration === 0
150
+ ? { ...item, listening_minutes: undefined } : item);
151
+ const subjects = new Map();
152
+ const seenDetails = new Map();
153
+ const assertions = profile?.listener_assertions ?? {};
154
+ const behavior = profile?.listening_behavior ?? {};
155
+ const curated = profile?.curated_preferences ?? {};
156
+
157
+ function add(item, kind, source, detail, { assertion = false } = {}) {
158
+ const target = targetFor(item, kind);
159
+ if (!target) return;
160
+ const key = subjectKey(target);
161
+ let subject = subjects.get(key);
162
+ if (!subject) {
163
+ subject = {
164
+ key,
165
+ label: inline(target.label),
166
+ subtitle: kind === "track" ? inline(target.artistCredit) : "Artist",
167
+ kind,
168
+ detailLines: [],
169
+ target,
170
+ evidence: [],
171
+ };
172
+ subjects.set(key, subject);
173
+ seenDetails.set(key, new Set());
174
+ }
175
+ const evidenceId = identifier(item?.evidence_id);
176
+ if (evidenceId && !subject.evidence.some((evidence) => evidence.id === evidenceId)) {
177
+ subject.evidence.push({ id: evidenceId, source: inline(source) });
178
+ subject.evidenceId ??= evidenceId;
179
+ }
180
+ if (assertion && ["like", "avoid"].includes(item?.stance) && !subject.stance) {
181
+ subject.stance = item.stance;
182
+ const correctionId = identifier(item.correction_id);
183
+ if (correctionId) subject.correctionId = correctionId;
184
+ }
185
+ const line = inline(detail);
186
+ if (line && !seenDetails.get(key).has(line)) {
187
+ subject.detailLines.push(line);
188
+ seenDetails.get(key).add(line);
189
+ }
190
+ }
191
+
192
+ const activeAssertions = [
193
+ ...items(assertions.active),
194
+ ...items(assertions.preferences),
195
+ ...items(assertions.avoids),
196
+ ];
197
+ const seenAssertions = new Set();
198
+ for (const item of activeAssertions) {
199
+ if (!["like", "avoid"].includes(item?.stance)) continue;
200
+ const target = targetFor(item, item.entity_type);
201
+ if (!target) continue;
202
+ const key = subjectKey(target);
203
+ if (seenAssertions.has(key)) continue;
204
+ seenAssertions.add(key);
205
+ const when = date(item.asserted_at);
206
+ const note = inline(item.note);
207
+ add(item, item.entity_type, "Your choice", `You said: ${
208
+ item.stance === "like" ? "I like this" : "keep this out"
209
+ }${when ? ` · ${when}` : ""}${note ? ` · "${note}"` : ""}`, { assertion: true });
210
+ }
211
+
212
+ const recentDays = count(behavior.context?.recent_window_days);
213
+ const recentSource = recentDays ? `Lately (last ${recentDays} days)` : "Lately";
214
+ for (const [field, kind, source] of [
215
+ ["repeat_tracks", "track", "Most played"],
216
+ ["enduring_artists", "artist", "Across the years"],
217
+ ["recent_tracks", "track", recentSource],
218
+ ["recent_artists", "artist", recentSource],
219
+ ["rediscovery_tracks", "track", "Gone quiet"],
220
+ ["historical_return_tracks", "track", "Came back"],
221
+ ["back_to_back_tracks", "track", "Played in a row"],
222
+ ]) {
223
+ for (const item of items(behavior[field])) {
224
+ const details = metrics(item);
225
+ add(item, kind, source, `${source}${details.length ? `: ${details.join(" · ")}` : ""}`);
226
+ }
227
+ }
228
+ for (const item of items(behavior.time_capsule_tracks)) {
229
+ const year = count(item?.capsule_year);
230
+ const details = metrics({
231
+ play_count: item?.year_play_count,
232
+ engaged_play_count: item?.year_engaged_play_count,
233
+ listening_minutes: item?.year_listening_minutes,
234
+ explicit_skips: item?.year_explicit_skips,
235
+ });
236
+ add(item, "track", "Song of the year", `${year ? `Your song of ${item.capsule_year}` : "Song of the year"}${
237
+ details.length ? `: ${details.join(" · ")}` : ""
238
+ }`);
239
+ }
240
+ for (const [field, kind, source] of [
241
+ ["saved_tracks", "track", "Saved in your Spotify library"],
242
+ ["playlist_anchors", "track", "On your Spotify playlists"],
243
+ ["followed_artists", "artist", "Followed on Spotify"],
244
+ ]) {
245
+ for (const item of items(curated[field])) {
246
+ const details = metrics(item);
247
+ const label = source.replace("Spotify", inline(item.source_label) || "Spotify");
248
+ add(item, kind, label, `${label}${details.length ? `: ${details.join(" · ")}` : ""}`);
249
+ }
250
+ }
251
+ for (const item of items(curated.avoids)) {
252
+ const target = targetFor(item, item?.entity_type);
253
+ if (target && !seenAssertions.has(subjectKey(target))) {
254
+ add(item, item.entity_type, "Disliked in your music service", "You disliked this in your music service. You can still change it here.");
255
+ }
256
+ }
257
+ for (const [field, kind] of [["artists", "artist"], ["tracks", "track"]]) {
258
+ for (const item of items(profile?.provider_signals?.[field])) {
259
+ const rank = count(item?.best_rank);
260
+ const details = [...(rank ? [`best at #${rank}`] : []), ...metrics(item)];
261
+ add(item, kind, "Service top list", `In your service's top list${details.length ? `: ${details.join(" · ")}` : ""}`);
262
+ }
263
+ }
264
+ for (const [field, fallback] of [["strong_preferences", "From your library"], ["familiarity", "Familiar from your library"]]) {
265
+ for (const item of items(profile?.[field])) {
266
+ const kind = explicitKind(item);
267
+ if (!kind) continue;
268
+ const target = targetFor(item, kind);
269
+ if (!target) continue;
270
+ const existing = subjects.get(subjectKey(target));
271
+ if (item.evidence_id && existing?.evidence.some((evidence) => evidence.id === item.evidence_id)) continue;
272
+ const source = sourceNames[inline(item.source)] ?? (inline(item.source) || fallback);
273
+ const observed = date(item.observed_at);
274
+ const details = [inline(item.signal).replace(/,? non-computed rating/u, ", rated"), ...metrics(item), ...(observed ? [`as of ${observed}`] : [])].filter(Boolean);
275
+ add(item, kind, source, `${source}${details.length ? `: ${details.join(" · ")}` : ""}`);
276
+ }
277
+ }
278
+ for (const item of items(profile?.artist_facets)) {
279
+ const target = targetFor(item, "artist");
280
+ if (!target) continue;
281
+ const existing = subjects.get(subjectKey(target));
282
+ if (item.evidence_id && existing?.evidence.some((evidence) => evidence.id === item.evidence_id)) continue;
283
+ add(item, "artist", "Through a track", "Shows up through one of their tracks.");
284
+ }
285
+
286
+ for (const subject of subjects.values()) {
287
+ if (subject.kind !== "track") continue;
288
+ const artist = subjects.get(subjectKey({ entityType: "artist", label: subject.target.artistCredit }));
289
+ if (artist?.stance !== "avoid") continue;
290
+ subject.detailLines.unshift(`You asked me to keep ${artist.label} out.${
291
+ subject.stance === "like" ? " That still applies, even though you like this track." : ""
292
+ }`);
293
+ if (artist.evidenceId && !subject.evidence.some((evidence) => evidence.id === artist.evidenceId)) {
294
+ subject.evidence.push({ id: artist.evidenceId, source: "Your choice for the artist" });
295
+ }
296
+ }
297
+
298
+ return {
299
+ summaryLines: overview(profile),
300
+ subjects: [...subjects.values()],
301
+ emptyLines: [
302
+ "Nothing to look at yet.",
303
+ "Import your history or library and it will show up here.",
304
+ ],
305
+ };
306
+ }
@@ -0,0 +1,272 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import {
3
+ Input,
4
+ getKeybindings,
5
+ matchesKey,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ wrapTextWithAnsi,
9
+ } from "@earendil-works/pi-tui";
10
+
11
+ import { sanitizeTerminalText } from "./format-output.mjs";
12
+
13
+ const categories = [
14
+ { id: "all", label: "All" },
15
+ { id: "track", label: "Tracks" },
16
+ { id: "artist", label: "Artists" },
17
+ { id: "choices", label: "Your choices" },
18
+ ];
19
+
20
+ function clean(value) {
21
+ return sanitizeTerminalText(stripVTControlCharacters(String(value ?? ""))).replaceAll("\t", " ");
22
+ }
23
+
24
+ function inline(value) { return clean(value).replace(/\s+/gu, " ").trim(); }
25
+
26
+ function fit(value, width) {
27
+ const clipped = truncateToWidth(value, Math.max(1, width), "");
28
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
29
+ }
30
+
31
+ function paint(value, width, theme) {
32
+ const line = fit(value, width);
33
+ // Only remove SGR styling in plain mode; Pi's cursor marker positions the IME.
34
+ return theme.plain ? line.replace(/\u001b\[[\d;:]*m/g, "") : theme.background(theme.text(line));
35
+ }
36
+
37
+ function ends(left, right, width) {
38
+ const tailWidth = visibleWidth(right);
39
+ if (tailWidth + 3 >= width) return truncateToWidth(left, width, "");
40
+ const head = truncateToWidth(left, width - tailWidth - 2, "");
41
+ return head + " ".repeat(width - visibleWidth(head) - tailWidth) + right;
42
+ }
43
+
44
+ function stanceLabel(subject) {
45
+ return subject?.stance === "like" ? "You like this" : subject?.stance === "avoid" ? "You asked me to keep this out" : "";
46
+ }
47
+
48
+ /** A terminal-native, searchable profile with a live reading beside its subjects. */
49
+ export class TasteProfileView {
50
+ constructor({ model, getTheme, getRows }) {
51
+ this.getTheme = getTheme;
52
+ this.getRows = getRows;
53
+ this.input = new Input();
54
+ this.category = "all";
55
+ this.query = "";
56
+ this.selectedKey = undefined;
57
+ this.detailOffset = 0;
58
+ this.detailPageSize = 1;
59
+ this.detailMaxOffset = 0;
60
+ this.pasteActive = false;
61
+ this.pasteTail = "";
62
+ this.onSelect = undefined;
63
+ this.onClose = undefined;
64
+ this.onRefresh = undefined;
65
+ this.onReport = undefined;
66
+ this.setModel(model);
67
+ }
68
+
69
+ get focused() { return this.input.focused; }
70
+ set focused(value) { this.input.focused = value; }
71
+
72
+ invalidate() { this.input.invalidate(); }
73
+
74
+ setModel(model) {
75
+ this.model = model ?? {};
76
+ const subjects = Array.isArray(this.model.subjects) ? this.model.subjects : [];
77
+ this.entries = subjects.filter((subject) => subject && typeof subject === "object").map((subject) => ({
78
+ subject,
79
+ search: [subject.label, subject.subtitle, subject.kind, stanceLabel(subject), ...(subject.detailLines ?? [])]
80
+ .map(inline).join(" ").toLowerCase(),
81
+ }));
82
+ this.filterSubjects();
83
+ }
84
+
85
+ filterSubjects() {
86
+ const tokens = inline(this.query).toLowerCase().split(/\s+/u).filter(Boolean);
87
+ this.filtered = this.entries.filter(({ subject, search }) => {
88
+ const categoryMatches = this.category === "all" || this.category === subject.kind ||
89
+ (this.category === "choices" && ["like", "avoid"].includes(subject.stance));
90
+ return categoryMatches && tokens.every((token) => search.includes(token));
91
+ }).map(({ subject }) => subject);
92
+ if (!this.filtered.some((subject) => subject.key === this.selectedKey)) {
93
+ this.selectedKey = this.filtered[0]?.key;
94
+ this.detailOffset = 0;
95
+ }
96
+ }
97
+
98
+ getSelectedItem() {
99
+ return this.filtered.find((subject) => subject.key === this.selectedKey) ?? null;
100
+ }
101
+
102
+ updateInput(data) {
103
+ const previous = this.input.getValue();
104
+ this.input.handleInput(data);
105
+ if (previous !== this.input.getValue()) {
106
+ this.query = this.input.getValue();
107
+ this.filterSubjects();
108
+ }
109
+ }
110
+
111
+ handleInput(data) {
112
+ if (data.includes("\x1b[200~")) this.pasteActive = true;
113
+ if (this.pasteActive) {
114
+ this.updateInput(data);
115
+ const paste = this.pasteTail + data;
116
+ this.pasteActive = !paste.includes("\x1b[201~");
117
+ this.pasteTail = this.pasteActive ? paste.slice(-5) : "";
118
+ return;
119
+ }
120
+ const keys = getKeybindings();
121
+ if (keys.matches(data, "tui.select.cancel")) { this.onClose?.(); return; }
122
+ if (matchesKey(data, "ctrl+r")) { this.onRefresh?.(); return; }
123
+ if (matchesKey(data, "ctrl+o")) { this.onReport?.(); return; }
124
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
125
+ const index = categories.findIndex((category) => category.id === this.category);
126
+ this.category = categories[(index + (matchesKey(data, "shift+tab") ? -1 : 1) + categories.length) % categories.length].id;
127
+ this.filterSubjects();
128
+ this.detailOffset = 0;
129
+ return;
130
+ }
131
+ if (keys.matches(data, "tui.select.up") || keys.matches(data, "tui.select.down")) {
132
+ if (this.filtered.length) {
133
+ const index = this.filtered.findIndex((subject) => subject.key === this.selectedKey);
134
+ this.selectedKey = this.filtered[(index + (keys.matches(data, "tui.select.up") ? -1 : 1) + this.filtered.length) % this.filtered.length].key;
135
+ this.detailOffset = 0;
136
+ }
137
+ return;
138
+ }
139
+ if (keys.matches(data, "tui.select.confirm")) {
140
+ const selected = this.getSelectedItem();
141
+ if (selected) this.onSelect?.(selected);
142
+ return;
143
+ }
144
+ if (keys.matches(data, "tui.select.pageUp") || keys.matches(data, "tui.select.pageDown")) {
145
+ const direction = keys.matches(data, "tui.select.pageUp") ? -1 : 1;
146
+ this.detailOffset = Math.max(0, Math.min(this.detailMaxOffset, this.detailOffset + direction * Math.max(1, this.detailPageSize - 1)));
147
+ return;
148
+ }
149
+ this.updateInput(data);
150
+ }
151
+
152
+ renderCategories(width, theme) {
153
+ if (width < 34) {
154
+ const label = categories.find((category) => category.id === this.category)?.label ?? "All";
155
+ return theme.bold(`‹ ${label} ›`) + (width >= 24 ? theme.faint(" tab change") : "");
156
+ }
157
+ return categories.map(({ id, label }) => {
158
+ const text = width < 38 && id === "choices" ? "Choices" : label;
159
+ return id === this.category ? theme.inverse(theme.bold(` ${text} `)) : theme.muted(text);
160
+ }).join(width >= 34 ? " " : " ");
161
+ }
162
+
163
+ renderList(width, height, theme, { compact = false } = {}) {
164
+ if (height <= 0) return [];
165
+ const lineHeight = compact ? 1 : 2;
166
+ const capacity = Math.max(1, Math.floor(height / lineHeight));
167
+ const selectedIndex = this.filtered.findIndex((subject) => subject.key === this.selectedKey);
168
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(capacity / 2), this.filtered.length - capacity));
169
+ const lines = [];
170
+ for (const subject of this.filtered.slice(start, start + capacity)) {
171
+ const selected = subject.key === this.selectedKey;
172
+ const marker = selected ? "› " : " ";
173
+ const label = inline(subject.label) || (subject.kind === "artist" ? "Artist" : "Track");
174
+ const stance = subject.stance === "like" ? "+" : subject.stance === "avoid" ? "−" : "";
175
+ const main = ends(marker + label, stance, width);
176
+ lines.push(selected ? theme.inverse(theme.bold(fit(main, width))) : theme.text(main));
177
+ if (!compact && lines.length < height) lines.push(theme.muted(` ${inline(subject.subtitle) || (subject.kind === "artist" ? "Artist" : "Track")}`));
178
+ }
179
+ return Array.from({ length: height }, (_, index) => lines[index] ?? "");
180
+ }
181
+
182
+ renderDetail(width, height, theme) {
183
+ if (height <= 0) return [];
184
+ const subject = this.getSelectedItem();
185
+ if (!subject) return Array(height).fill("");
186
+ const heading = theme.bold(inline(subject.label) || "Selected");
187
+ const subtitle = inline(subject.subtitle);
188
+ const stance = stanceLabel(subject);
189
+ const prefix = [heading];
190
+ if (subtitle && height >= 4) prefix.push(theme.muted(subtitle));
191
+ if (stance && height >= 6) prefix.push(theme.accent(stance));
192
+ if (height >= 8) prefix.push("");
193
+ const sourceLines = Array.isArray(subject.detailLines) && subject.detailLines.length
194
+ ? subject.detailLines
195
+ : ["Nothing more to show for this one yet."];
196
+ const detail = sourceLines.flatMap((line) => wrapTextWithAnsi(clean(line), Math.max(1, width)));
197
+ const available = Math.max(0, height - prefix.length);
198
+ const scrolls = detail.length > available;
199
+ const contentHeight = Math.max(0, available - (scrolls && available >= 2 ? 1 : 0));
200
+ this.detailPageSize = Math.max(1, contentHeight);
201
+ this.detailMaxOffset = Math.max(0, detail.length - contentHeight);
202
+ this.detailOffset = Math.min(this.detailOffset, this.detailMaxOffset);
203
+ const visible = detail.slice(this.detailOffset, this.detailOffset + contentHeight).map(theme.text);
204
+ const lines = [...prefix, ...visible];
205
+ if (scrolls && available >= 2) {
206
+ const range = `${this.detailOffset + 1}–${Math.min(detail.length, this.detailOffset + contentHeight)}/${detail.length}`;
207
+ lines.push(theme.faint(ends("PgUp/PgDn to scroll", range, width)));
208
+ }
209
+ return Array.from({ length: height }, (_, index) => lines[index] ?? "");
210
+ }
211
+
212
+ renderEmpty(width, height, theme) {
213
+ this.detailOffset = 0;
214
+ this.detailMaxOffset = 0;
215
+ const filtered = this.entries.length > 0;
216
+ const lines = filtered
217
+ ? ["Nothing matches that.", "", "Try fewer words, or press Tab for another list."]
218
+ : (Array.isArray(this.model.emptyLines) && this.model.emptyLines.length
219
+ ? this.model.emptyLines
220
+ : ["Nothing to look at yet.", "", "Import your history or library and it will show up here."]);
221
+ const wrapped = lines.flatMap((line) => wrapTextWithAnsi(clean(line), width));
222
+ return Array.from({ length: height }, (_, index) => index === 0
223
+ ? theme.bold(wrapped[index] ?? "") : theme.muted(wrapped[index] ?? ""));
224
+ }
225
+
226
+ render(width) {
227
+ const columns = Math.max(1, Math.floor(width));
228
+ const requestedRows = Number(this.getRows());
229
+ const height = Number.isFinite(requestedRows) ? Math.max(0, Math.floor(requestedRows)) : 0;
230
+ if (!height) return [];
231
+ const theme = this.getTheme();
232
+ const inset = columns >= 8 ? 1 : 0;
233
+ const inner = Math.max(1, columns - inset * 2);
234
+ const position = this.filtered.findIndex((subject) => subject.key === this.selectedKey) + 1;
235
+ const count = `${Math.max(0, position)}/${this.filtered.length}`;
236
+ const heading = theme.bold(inner >= 24 + count.length ? "Your listening profile" : "Your profile");
237
+ const lines = [ends(heading, theme.muted(count), inner)];
238
+ if (height >= 10) {
239
+ const summaries = Array.isArray(this.model.summaryLines) ? this.model.summaryLines : [];
240
+ const maxSummary = height >= 22 ? 2 : 1;
241
+ lines.push(...summaries.slice(0, maxSummary).map((line) => theme.muted(inline(line))));
242
+ }
243
+ if (height >= 5) lines.push(inner >= 76
244
+ ? ends(this.renderCategories(inner, theme), theme.faint("Ctrl+R refresh · Ctrl+O report"), inner)
245
+ : this.renderCategories(inner, theme));
246
+ if (height >= 2) {
247
+ const searchLabel = inner >= 26 ? theme.faint("Search ") : "";
248
+ lines.push(searchLabel + this.input.render(Math.max(1, inner - visibleWidth(searchLabel)))[0]);
249
+ }
250
+ if (height >= 7) lines.push(theme.faint("─".repeat(inner)));
251
+ const remaining = Math.max(0, height - lines.length);
252
+ if (!this.filtered.length) {
253
+ lines.push(...this.renderEmpty(inner, remaining, theme));
254
+ } else if (columns >= 90 && remaining >= 7) {
255
+ const leftWidth = Math.max(24, Math.floor((inner - 3) * 0.42));
256
+ const rightWidth = Math.max(1, inner - leftWidth - 3);
257
+ const list = this.renderList(leftWidth, remaining, theme);
258
+ const detail = this.renderDetail(rightWidth, remaining, theme);
259
+ for (let row = 0; row < remaining; row += 1) {
260
+ lines.push(fit(list[row], leftWidth) + theme.faint(" │ ") + fit(detail[row], rightWidth));
261
+ }
262
+ } else if (remaining >= 6) {
263
+ const listHeight = Math.max(2, Math.min(5, Math.floor(remaining * 0.32)));
264
+ lines.push(...this.renderList(inner, listHeight, theme, { compact: true }));
265
+ lines.push(theme.faint("─".repeat(inner)));
266
+ lines.push(...this.renderDetail(inner, remaining - listHeight - 1, theme));
267
+ } else {
268
+ lines.push(...this.renderList(inner, remaining, theme, { compact: true }));
269
+ }
270
+ return Array.from({ length: height }, (_, index) => paint(" ".repeat(inset) + (lines[index] ?? ""), columns, theme));
271
+ }
272
+ }