@audiofool/moondog 0.1.0 → 0.2.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { CURSOR_MARKER, Editor, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
1
|
+
import { CURSOR_MARKER, Editor, Text, getCellDimensions, getKeybindings, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import { stripVTControlCharacters } from "node:util";
|
|
3
3
|
import { sanitizeTerminalText } from "./format-output.mjs";
|
|
4
|
-
import { LOGO_MOTION_FRAMES,
|
|
4
|
+
import { LOGO_MOTION_FRAMES, renderLunarRecordLayers, renderMoondogWordmark } from "./terminal-art.mjs";
|
|
5
|
+
import { N_, screenTranslator } from "../../i18n/index.mjs";
|
|
5
6
|
export { ListeningMenu } from "./listening-menu.mjs";
|
|
6
7
|
|
|
8
|
+
// The TUI sets its live translator once; components read it while rendering.
|
|
9
|
+
let tr = screenTranslator("en");
|
|
10
|
+
export function setBrandText(translator) { tr = translator; }
|
|
11
|
+
|
|
7
12
|
export function paintBrandLine(line, width, theme) {
|
|
8
13
|
const clipped = truncateToWidth(line, Math.max(1, width), "");
|
|
9
14
|
const padding = " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
@@ -25,7 +30,7 @@ export class BrandSurface {
|
|
|
25
30
|
}
|
|
26
31
|
handleInput(data) { this.component.handleInput?.(data); }
|
|
27
32
|
invalidate() {
|
|
28
|
-
this.paintCache
|
|
33
|
+
this.paintCache?.clear();
|
|
29
34
|
this.component.invalidate?.();
|
|
30
35
|
}
|
|
31
36
|
render(width) {
|
|
@@ -58,22 +63,28 @@ export class ListeningHeader {
|
|
|
58
63
|
invalidate() {}
|
|
59
64
|
render(width) {
|
|
60
65
|
const theme = this.getTheme();
|
|
61
|
-
const { profileReady, modelReady, spotifyReady, provider, model } = this.getStatus();
|
|
62
|
-
const title = theme.bold("MOONDOG") + (width >= 46 ? theme.muted(" / your listening room") : "");
|
|
66
|
+
const { profileReady, modelReady, spotifyReady, provider, model, effort, place } = this.getStatus();
|
|
63
67
|
const compact = width < 60;
|
|
64
68
|
const clean = (value) => sanitizeTerminalText(stripVTControlCharacters(String(value ?? "")))
|
|
65
69
|
.replace(/\s+/gu, " ").trim();
|
|
66
|
-
const modelName = clean(model);
|
|
70
|
+
const modelName = [clean(model), clean(effort)].filter(Boolean).join(" · ");
|
|
67
71
|
const providerName = clean(provider);
|
|
72
|
+
const placeName = clean(place);
|
|
73
|
+
const lead = " / ";
|
|
74
|
+
const placeRoom = width - (1 + visibleWidth("MOONDOG") + visibleWidth(lead));
|
|
75
|
+
const suffix = placeName && placeRoom >= 12
|
|
76
|
+
? theme.muted(lead + truncateToWidth(placeName, placeRoom, "…"))
|
|
77
|
+
: !placeName && width >= 46 ? theme.muted(`${lead}${tr("your listening room")}`) : "";
|
|
78
|
+
const title = theme.bold("MOONDOG") + suffix;
|
|
68
79
|
const profileLabel = profileReady
|
|
69
|
-
? compact ? "profile ready" : "local profile"
|
|
70
|
-
: compact ? "no history" : "bring your history";
|
|
71
|
-
const modelLabel = modelReady ? modelName || (compact ? "model ready" : "conversation ready") : "model offline";
|
|
80
|
+
? compact ? tr("profile ready") : tr("local profile")
|
|
81
|
+
: compact ? tr("no history") : tr("bring your history");
|
|
82
|
+
const modelLabel = modelReady ? modelName || (compact ? tr("model ready") : tr("conversation ready")) : tr("model offline");
|
|
72
83
|
const identity = modelReady && providerName ? `${providerName} / ${modelLabel}` : modelLabel;
|
|
73
84
|
const available = Math.max(1, width - 1);
|
|
74
85
|
// Keep the active model readable before spending space on optional status.
|
|
75
86
|
const candidates = [
|
|
76
|
-
[profileLabel, identity, ...(spotifyReady && !compact ? ["Spotify connected"] : [])],
|
|
87
|
+
[profileLabel, identity, ...(spotifyReady && !compact ? [tr("Spotify connected")] : [])],
|
|
77
88
|
[profileLabel, identity],
|
|
78
89
|
[profileLabel, modelLabel],
|
|
79
90
|
[identity],
|
|
@@ -88,14 +99,118 @@ export class ListeningHeader {
|
|
|
88
99
|
}
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Steps taken while answering. They stay on screen for this visit and are not saved.
|
|
104
|
+
*/
|
|
105
|
+
export class AgentWork {
|
|
106
|
+
constructor(getTheme, getPhase = () => 0) {
|
|
107
|
+
this.getTheme = getTheme;
|
|
108
|
+
this.getPhase = getPhase;
|
|
109
|
+
this.calls = [];
|
|
110
|
+
this.live = true;
|
|
111
|
+
this.settled = false;
|
|
112
|
+
this.elapsed = "";
|
|
113
|
+
}
|
|
114
|
+
invalidate() {}
|
|
115
|
+
note(key, label, state) {
|
|
116
|
+
const text = sanitizeTerminalText(label).replace(/\s+/gu, " ").trim() || tr("Music tool");
|
|
117
|
+
const existing = this.calls.find((call) => call.key === key);
|
|
118
|
+
if (existing) {
|
|
119
|
+
existing.label = text;
|
|
120
|
+
existing.state = state;
|
|
121
|
+
} else {
|
|
122
|
+
this.calls.push({ key, label: text, state });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
quiet() {
|
|
126
|
+
this.live = false;
|
|
127
|
+
}
|
|
128
|
+
settle(elapsed) {
|
|
129
|
+
this.live = false;
|
|
130
|
+
this.settled = true;
|
|
131
|
+
this.elapsed = elapsed;
|
|
132
|
+
}
|
|
133
|
+
summary() {
|
|
134
|
+
const count = (state) => this.calls.filter((call) => call.state === state).length;
|
|
135
|
+
const parts = [];
|
|
136
|
+
const done = count("completed");
|
|
137
|
+
const failed = count("failed");
|
|
138
|
+
const open = count("running");
|
|
139
|
+
if (done) parts.push(tr("{count} done", { count: done }));
|
|
140
|
+
if (failed) parts.push(tr("{count} didn't work", { count: failed }));
|
|
141
|
+
if (open) parts.push(tr("{count} not confirmed", { count: open }));
|
|
142
|
+
return [parts.join(", "), this.elapsed].filter(Boolean).join(" · ");
|
|
143
|
+
}
|
|
144
|
+
render(width) {
|
|
145
|
+
const theme = this.getTheme();
|
|
146
|
+
const line = (text, ink) => new Text(ink(text), 3, 0).render(width);
|
|
147
|
+
if (!this.calls.length) return this.live ? line("…", theme.faint) : [];
|
|
148
|
+
const phase = this.getPhase() % 4;
|
|
149
|
+
const liveMarks = ["·", "•", "·", "∙"];
|
|
150
|
+
const shown = this.calls.slice(-8);
|
|
151
|
+
const earlier = this.calls.length - shown.length;
|
|
152
|
+
const lines = earlier ? line(`· ${tr("{count} earlier", { count: earlier })}`, theme.faint) : [];
|
|
153
|
+
lines.push(...shown.flatMap((call) => {
|
|
154
|
+
const settledOpen = this.settled && call.state === "running";
|
|
155
|
+
const mark = call.state === "failed" ? "×"
|
|
156
|
+
: call.state === "completed" ? "✓"
|
|
157
|
+
: settledOpen ? "·"
|
|
158
|
+
: liveMarks[phase];
|
|
159
|
+
const ink = call.state === "failed" ? theme.error : call.state === "completed" ? theme.muted : theme.faint;
|
|
160
|
+
return line(`${mark} ${call.label}`, ink);
|
|
161
|
+
}));
|
|
162
|
+
if (this.settled) lines.push(...line(this.summary(), theme.faint));
|
|
163
|
+
return lines;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Pi's editor, except Enter and Tab never apply suggestions made for older text. */
|
|
168
|
+
export class FreshCompletionEditor extends Editor {
|
|
169
|
+
// Pi refreshes suggestions asynchronously. A key in the same input chunk as the
|
|
170
|
+
// last typed character still sees the list for the earlier text, and applying
|
|
171
|
+
// it with that shorter prefix garbles the line ("/theme pape" + "r" + Enter).
|
|
172
|
+
applyAutocompleteSuggestions(suggestions, state) {
|
|
173
|
+
super.applyAutocompleteSuggestions(suggestions, state);
|
|
174
|
+
this.suggestedFor = this.completionInput();
|
|
175
|
+
}
|
|
176
|
+
completionInput() {
|
|
177
|
+
return `${this.state.cursorLine}:${this.state.cursorCol}:${this.getText()}`;
|
|
178
|
+
}
|
|
179
|
+
handleInput(data) {
|
|
180
|
+
const keys = getKeybindings();
|
|
181
|
+
if (this.autocompleteState && this.suggestedFor !== this.completionInput()
|
|
182
|
+
&& (keys.matches(data, "tui.select.confirm") || keys.matches(data, "tui.input.tab"))) {
|
|
183
|
+
// Enter then submits exactly what was typed; Tab asks for fresh suggestions.
|
|
184
|
+
this.cancelAutocomplete();
|
|
185
|
+
}
|
|
186
|
+
super.handleInput(data);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
91
190
|
/** Keep Pi's editing, paste, IME, history, and autocomplete intact. */
|
|
92
|
-
export class ListeningEditor extends
|
|
191
|
+
export class ListeningEditor extends FreshCompletionEditor {
|
|
93
192
|
constructor(tui, theme, getTheme, getState) {
|
|
94
193
|
super(tui, theme, { paddingX: 1, autocompleteMaxVisible: 5 });
|
|
95
194
|
this.getTheme = getTheme;
|
|
96
195
|
this.getState = getState;
|
|
97
196
|
this.rowCount = 3;
|
|
98
197
|
}
|
|
198
|
+
recallEntries() {
|
|
199
|
+
return this.history.slice(0, 100);
|
|
200
|
+
}
|
|
201
|
+
replaceRecall(entries) {
|
|
202
|
+
const next = [];
|
|
203
|
+
for (const entry of entries ?? []) {
|
|
204
|
+
if (typeof entry !== "string") continue;
|
|
205
|
+
const text = entry.trim();
|
|
206
|
+
if (!text || next.at(-1) === text) continue;
|
|
207
|
+
next.push(text);
|
|
208
|
+
if (next.length === 100) break;
|
|
209
|
+
}
|
|
210
|
+
this.history = next;
|
|
211
|
+
this.exitHistoryBrowsing();
|
|
212
|
+
return next.slice();
|
|
213
|
+
}
|
|
99
214
|
// The sleeve measures this during the same frame. The signature is taken again
|
|
100
215
|
// after painting because the editor can adjust its own scroll while rendering.
|
|
101
216
|
renderSignature() {
|
|
@@ -126,13 +241,13 @@ export class ListeningEditor extends Editor {
|
|
|
126
241
|
const lines = super.render(width).map((line) => this.focused ? line : line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, "$1"));
|
|
127
242
|
const { busy, homeVisible, homeFocused } = this.getState();
|
|
128
243
|
if (!/[↑↓]/u.test(lines[0])) {
|
|
129
|
-
const label = busy ? "
|
|
244
|
+
const label = ` ${busy ? tr("NEXT") : homeFocused ? tr("TAB TO TYPE") : tr("YOU")} `;
|
|
130
245
|
lines[0] = width >= label.length + 4
|
|
131
246
|
? theme.faint("─ ") + theme.muted(label) + theme.faint("─".repeat(width - label.length - 2))
|
|
132
247
|
: theme.faint("─".repeat(width));
|
|
133
248
|
}
|
|
134
249
|
if (!busy && !this.getText() && lines.length === 3 && width >= 28) {
|
|
135
|
-
const hint = homeFocused ? "Esc returns to your next thought." : homeVisible ? "What have you been listening to?" : "
|
|
250
|
+
const hint = homeFocused ? tr("Esc returns to your next thought.") : homeVisible ? tr("What have you been listening to?") : tr("A song, a playlist, or what to play next.");
|
|
136
251
|
const text = truncateToWidth(hint, width - 2, "");
|
|
137
252
|
lines[1] = " " + (this.focused ? CURSOR_MARKER + theme.inverse(text[0]) : theme.faint(text[0])) + theme.faint(text.slice(1));
|
|
138
253
|
}
|
|
@@ -142,12 +257,22 @@ export class ListeningEditor extends Editor {
|
|
|
142
257
|
}
|
|
143
258
|
}
|
|
144
259
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
{ command: "
|
|
148
|
-
{ command: "
|
|
149
|
-
{ command: "
|
|
260
|
+
// Text is marked English and translated as the sleeve renders, so /language applies at once.
|
|
261
|
+
const homeActionText = [
|
|
262
|
+
{ command: "taste", label: N_("Listening profile"), short: N_("Profile"), description: N_("See what your listening reveals. Tell me what I missed.") },
|
|
263
|
+
{ command: "import", label: N_("Import your music"), short: N_("Import music"), description: N_("Bring songs, playlists or listening history.") },
|
|
264
|
+
{ command: "theme", label: N_("Appearance"), short: N_("Appearance"), description: N_("Paper, charcoal, or your terminal colors.") },
|
|
265
|
+
{ command: "help", label: N_("Help & commands"), short: N_("Help"), description: N_("Commands, connections and keyboard shortcuts.") },
|
|
150
266
|
];
|
|
267
|
+
export const homeActions = homeActionText.map(({ command, label, short, description }) => ({
|
|
268
|
+
command,
|
|
269
|
+
get label() { return tr.marked(label); },
|
|
270
|
+
get short() { return tr.marked(short); },
|
|
271
|
+
get description() { return tr.marked(description); },
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
// Rows of air between a stacked record and the title beneath it.
|
|
275
|
+
const STACK_GAP = 2;
|
|
151
276
|
|
|
152
277
|
const sleeveNotes = [
|
|
153
278
|
// Welcome to the Machine: https://www.pinkfloyd.com/albums/wish-you-were-here/
|
|
@@ -195,11 +320,9 @@ export class RecordSleeve {
|
|
|
195
320
|
this.lyricColumns = 0;
|
|
196
321
|
this.canAnimate = false;
|
|
197
322
|
this.cache = new Map();
|
|
198
|
-
this.colored = new Map();
|
|
199
323
|
}
|
|
200
324
|
invalidate() {
|
|
201
325
|
this.cache.clear();
|
|
202
|
-
this.colored.clear();
|
|
203
326
|
this.frame = undefined;
|
|
204
327
|
this.colorTheme = undefined;
|
|
205
328
|
}
|
|
@@ -230,8 +353,11 @@ export class RecordSleeve {
|
|
|
230
353
|
motionEnabled = this.environment.MOONDOG_MOTION !== "off" } = this.getState();
|
|
231
354
|
// Fill the viewport between the header and composer, independently of art size.
|
|
232
355
|
const rows = Math.max(1, this.terminal.rows - 2 - editorRows - (this.terminal.rows >= 20 ? 2 : 1));
|
|
356
|
+
// Pi learns the real cell size from terminals that answer it; others keep the 1:2 default.
|
|
357
|
+
const { widthPx, heightPx } = getCellDimensions();
|
|
358
|
+
const cellAspect = heightPx / widthPx;
|
|
233
359
|
const frameKey = [
|
|
234
|
-
width, rows, this.phase, this.lyricPosition, this.sleeveNote, focused ? 1 : 0, selected,
|
|
360
|
+
width, rows, cellAspect, this.phase, this.lyricPosition, this.sleeveNote, focused ? 1 : 0, selected,
|
|
235
361
|
motionEnabled ? 1 : 0, this.artMode, this.environment.TERM ?? "", theme.plain ? 1 : 0,
|
|
236
362
|
].join("\0");
|
|
237
363
|
if (this.frame?.key === frameKey && this.frame.theme === theme) {
|
|
@@ -246,145 +372,197 @@ export class RecordSleeve {
|
|
|
246
372
|
const paint = (line) => paintBrandLine(line, width, theme);
|
|
247
373
|
this.canAnimate = false;
|
|
248
374
|
this.lyricColumns = 0;
|
|
249
|
-
const
|
|
250
|
-
const narrow = width < 54;
|
|
251
|
-
const available = Math.max(1, width - 4);
|
|
252
|
-
const artWidth = Math.min(50, Math.floor(available * (narrow ? 0.40 : 0.48)));
|
|
253
|
-
const rightWidth = Math.max(1, available - artWidth - 3);
|
|
254
|
-
const actionLines = homeActions.map((action, index) => {
|
|
255
|
-
const label = narrow ? action.short : action.label;
|
|
256
|
-
const active = focused && selected === index;
|
|
257
|
-
const prefix = active ? theme.accent("› ") : theme.faint(`${index + 1} `);
|
|
258
|
-
return prefix + (active ? theme.inverse(theme.bold(` ${label} `)) : theme.text(` ${label} `));
|
|
259
|
-
});
|
|
260
|
-
const pixelTitle = rightWidth >= 41 && rows >= 20 && !["ascii", "text"].includes(this.artMode) && this.environment.TERM !== "dumb";
|
|
261
|
-
const withNote = (lines, columns) => {
|
|
375
|
+
const withNote = (lines, columns, budget) => {
|
|
262
376
|
const scrolling = motionEnabled && !theme.plain;
|
|
263
377
|
const text = `"${this.sleeveNote}"`;
|
|
264
|
-
const noteLines = ["", ...(scrolling ? [scrollingLyric(text, columns, this.lyricPosition)] : wrapTextWithAnsi(text, columns)).map(theme.muted)];
|
|
265
|
-
if (lines.length + noteLines.length >
|
|
378
|
+
const noteLines = ["", ...(scrolling ? [scrollingLyric(text, columns, this.lyricPosition)] : wrapTextWithAnsi(text, columns)).map((line) => hang(theme.muted(line)))];
|
|
379
|
+
if (lines.length + noteLines.length > budget) return lines;
|
|
266
380
|
this.canAnimate ||= scrolling;
|
|
267
381
|
this.lyricColumns = scrolling ? columns : 0;
|
|
268
382
|
return [...lines, ...noteLines];
|
|
269
383
|
};
|
|
270
|
-
const copy = withNote([
|
|
271
|
-
...(pixelTitle ? renderMoondogWordmark().map(theme.text) : [theme.bold(roomy ? "M O O N D O G" : "MOONDOG")]),
|
|
272
|
-
...(rows >= 12 ? [...wrapDescription("Your personal music agent.", rightWidth).map(theme.muted), ""] : [""]),
|
|
273
|
-
...actionLines,
|
|
274
|
-
...(roomy ? ["", ...wrapDescription(homeActions[selected].description, rightWidth).map(theme.muted)] : []),
|
|
275
|
-
], rightWidth);
|
|
276
384
|
if (rows < 7 || width < 34 || this.artMode === "off") {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
...actionLines,
|
|
282
|
-
], Math.max(1, width - 1)).map((line) => ` ${line}`);
|
|
385
|
+
const compact = withNote([hang(theme.bold("MOONDOG ◎")),
|
|
386
|
+
...(rows >= 9 && width >= 30 ? [hang(theme.muted(tr("Your personal music agent."))), ""] : []),
|
|
387
|
+
...tracklist(theme, { focused, selected, columns: width }),
|
|
388
|
+
], Math.max(1, width - 2), rows);
|
|
283
389
|
const top = Math.max(0, Math.floor((rows - compact.length) / 2));
|
|
284
390
|
return finish(Array.from({ length: rows }, (_, row) => paint(compact[row - top] ?? "")));
|
|
285
391
|
}
|
|
286
|
-
const height = Math.min(22, rows - (roomy ? 2 : 0));
|
|
287
392
|
const mode = this.artMode === "ascii" || this.artMode === "text" || this.environment.TERM === "dumb" ? "ascii" : "braille";
|
|
393
|
+
// Copy sits two columns in, so the tracklist's selection mark hangs in the gutter.
|
|
394
|
+
const title = (columns, budget, roomy) => columns >= 41 && budget >= 20 && mode === "braille"
|
|
395
|
+
? renderMoondogWordmark().map(theme.text) : [theme.bold(roomy ? "M O O N D O G" : "MOONDOG")];
|
|
396
|
+
const copyBlock = (columns, budget, roomy, note = true, reserve = roomy) => (note ? withNote : (lines) => lines)([
|
|
397
|
+
...title(columns, budget, roomy).map(hang),
|
|
398
|
+
...(budget >= 12 ? [...wrapDescription(tr("Your personal music agent."), columns).map((line) => hang(theme.muted(line))), ""] : [""]),
|
|
399
|
+
...tracklist(theme, { focused, selected, columns: columns + 2 }),
|
|
400
|
+
...(reserve ? ["", ...actionNote(focused ? homeActions[selected].description : "", columns).map((line) => hang(theme.muted(line)))] : []),
|
|
401
|
+
], columns, budget);
|
|
402
|
+
// Choose whichever arrangement gives the record the larger radius on this screen.
|
|
403
|
+
const stretch = cellAspect / 2;
|
|
404
|
+
const radius = (columns, height) => columns < 20 || height < 9 || mode === "ascii"
|
|
405
|
+
? Math.min(columns, height * stretch * 2) / 4
|
|
406
|
+
: Math.min((columns * 2 - 4) / 2.35, (height * 4 - 4) * stretch / 2.24);
|
|
407
|
+
const available = Math.max(1, width - 4);
|
|
408
|
+
const sideArt = Math.min(64, Math.floor(available * (width < 54 ? 0.40 : 0.48)));
|
|
409
|
+
const sideCopyWidth = Math.max(1, available - sideArt - 3);
|
|
410
|
+
const sideRoomy = width >= 76 && rows >= 14;
|
|
411
|
+
// Side by side, the lyric runs beneath the whole group, so leave it a row and a gap.
|
|
412
|
+
const sideHeight = Math.min(30, rows - 3);
|
|
413
|
+
const stackCopyWidth = Math.min(41, width - 6);
|
|
414
|
+
const stackCopy = copyBlock(stackCopyWidth, Math.max(1, rows - 13), rows >= 34);
|
|
415
|
+
const stackArt = Math.min(64, width - 4);
|
|
416
|
+
const stackHeight = Math.min(30, rows - stackCopy.length - STACK_GAP);
|
|
417
|
+
const stacked = stackHeight >= 9 && radius(stackArt, stackHeight) > radius(sideArt, sideHeight);
|
|
418
|
+
const artWidth = stacked ? stackArt : sideArt;
|
|
419
|
+
const height = stacked ? stackHeight : sideHeight;
|
|
420
|
+
const copy = stacked ? stackCopy : copyBlock(sideCopyWidth, rows, sideRoomy, false);
|
|
288
421
|
this.canAnimate ||= !theme.plain && artWidth >= 20 && height >= 9;
|
|
289
422
|
const phase = theme.plain ? 0 : this.phase;
|
|
290
|
-
const key = `${artWidth}:${height}:${mode}:${phase}`;
|
|
423
|
+
const key = `${artWidth}:${height}:${mode}:${phase}:${cellAspect}`;
|
|
424
|
+
if (this.colorTheme !== theme) {
|
|
425
|
+
this.cache.clear();
|
|
426
|
+
this.colorTheme = theme;
|
|
427
|
+
}
|
|
291
428
|
if (!this.cache.has(key)) {
|
|
292
429
|
if (this.cache.size >= LOGO_MOTION_FRAMES * 2) this.cache.clear();
|
|
293
|
-
|
|
430
|
+
const layers = renderLunarRecordLayers({ columns: artWidth, rows: height, style: mode, phase, cellAspect });
|
|
431
|
+
this.cache.set(key, { ...layers, lines: toneRecord(layers, theme) });
|
|
294
432
|
}
|
|
295
433
|
const art = this.cache.get(key);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
434
|
+
// Everything aligns on the disc itself; the tonearm reaching up and right is left to hang free.
|
|
435
|
+
const [discColumn] = art.center;
|
|
436
|
+
if (stacked) {
|
|
437
|
+
// The record is width-bound here, so drop the empty rows its box leaves above and below.
|
|
438
|
+
const inked = art.lines.map((line) => stripVTControlCharacters(line).trim() !== "");
|
|
439
|
+
const record = art.lines.slice(inked.indexOf(true), inked.lastIndexOf(true) + 1);
|
|
440
|
+
const axis = width / 2;
|
|
441
|
+
const artLeft = Math.max(0, Math.min(width - artWidth, Math.round(axis - discColumn)));
|
|
442
|
+
// Center the copy's text on the same axis; the hanging selection mark sits outside it.
|
|
443
|
+
const textWidth = Math.max(...copy.map((line) => visibleWidth(line))) - 2;
|
|
444
|
+
const copyLeft = Math.max(0, Math.round(axis - textWidth / 2) - 2);
|
|
445
|
+
const stack = [
|
|
446
|
+
...record.map((line) => " ".repeat(artLeft) + line),
|
|
447
|
+
...Array(STACK_GAP).fill(""),
|
|
448
|
+
...copy.map((line) => " ".repeat(copyLeft) + line),
|
|
449
|
+
];
|
|
450
|
+
const top = Math.max(0, Math.floor((rows - stack.length) / 2));
|
|
451
|
+
return finish(Array.from({ length: rows }, (_, row) => paint(stack[row - top] ?? "")));
|
|
299
452
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
453
|
+
// Side by side, positions start at the art box's top-left corner.
|
|
454
|
+
const inked = art.lines.map((line) => stripVTControlCharacters(line).trim() !== "");
|
|
455
|
+
const artRows = [inked.indexOf(true), inked.lastIndexOf(true)];
|
|
456
|
+
// The wordmark's middle row sits level with the dog's eye.
|
|
457
|
+
const titleRows = title(sideCopyWidth, rows, sideRoomy).length;
|
|
458
|
+
const eyeTop = Math.floor(art.eye[1]) - Math.floor((titleRows - 1) / 2);
|
|
459
|
+
const scrolling = motionEnabled && !theme.plain;
|
|
460
|
+
const note = `"${this.sleeveNote}"`;
|
|
461
|
+
const arrange = (lines, below) => {
|
|
462
|
+
// Keep the copy on screen even when the eye sits low in a short sleeve.
|
|
463
|
+
const top = Math.min(eyeTop, artRows[0] + rows - lines.length);
|
|
464
|
+
const bodyTop = Math.min(artRows[0], top);
|
|
465
|
+
const bodyBottom = Math.max(artRows[1], top + lines.length - 1);
|
|
466
|
+
const groupRight = artWidth + 1 + Math.max(...lines.map((line) => visibleWidth(line)));
|
|
467
|
+
return { copy: lines, copyTop: top, bodyTop, bodyBottom, groupRight, below };
|
|
468
|
+
};
|
|
469
|
+
// Center the group from the disc's left edge to the copy's right edge; the arm hangs free.
|
|
470
|
+
const groupLeft = Math.max(0, Math.floor(discColumn - art.radius[0]));
|
|
471
|
+
// Prefer the lyric beneath the whole group, entering at the title's right edge and leaving past
|
|
472
|
+
// the disc's left edge; a short sleeve keeps it in the copy column instead.
|
|
473
|
+
// The action note's reserved rows give way first, since they are blank until Tab.
|
|
474
|
+
const lean = sideRoomy ? copyBlock(sideCopyWidth, rows, sideRoomy, false, false) : copy;
|
|
475
|
+
const fits = (candidate, lyricRows) => candidate.bodyBottom - candidate.bodyTop + 2 + lyricRows <= rows;
|
|
476
|
+
let layout = arrange(copy, true);
|
|
477
|
+
let span = layout.groupRight - groupLeft;
|
|
478
|
+
const lyricLines = (columns) => (scrolling ? [scrollingLyric(note, columns, this.lyricPosition)] : wrapTextWithAnsi(note, columns)).map(theme.muted);
|
|
479
|
+
let lyric = lyricLines(span);
|
|
480
|
+
if (!fits(layout, lyric.length)) {
|
|
481
|
+
layout = arrange(lean, true);
|
|
482
|
+
span = layout.groupRight - groupLeft;
|
|
483
|
+
lyric = lyricLines(span);
|
|
484
|
+
}
|
|
485
|
+
if (fits(layout, lyric.length)) {
|
|
486
|
+
this.canAnimate ||= scrolling;
|
|
487
|
+
this.lyricColumns = scrolling ? span : 0;
|
|
488
|
+
} else {
|
|
489
|
+
layout = arrange(withNote(lean, sideCopyWidth, rows - Math.max(0, eyeTop - artRows[0])), false);
|
|
321
490
|
}
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
491
|
+
const { copyTop, bodyTop, bodyBottom, below } = layout;
|
|
492
|
+
// Center the record and copy on their own; the lyric then floats midway between the record's
|
|
493
|
+
// bottom and the input box, keeping a blank row above it when the sleeve is short.
|
|
494
|
+
const bodyHeight = bodyBottom - bodyTop + 1;
|
|
495
|
+
const needed = below ? lyric.length + 2 : 0;
|
|
496
|
+
const bodyRow = Math.max(0, Math.min(Math.floor((rows - bodyHeight) / 2), rows - bodyHeight - needed));
|
|
497
|
+
const originRow = bodyRow - bodyTop;
|
|
498
|
+
const space = rows - (bodyRow + bodyHeight);
|
|
499
|
+
const lyricRow = bodyRow + bodyHeight + Math.max(1, Math.floor((space - lyric.length) / 2));
|
|
500
|
+
const originColumn = Math.max(0, Math.floor((width - (layout.groupRight - groupLeft)) / 2) - groupLeft);
|
|
501
|
+
const lines = Array.from({ length: rows }, (_, row) => {
|
|
502
|
+
if (below && row >= lyricRow && row < lyricRow + lyric.length) return " ".repeat(originColumn + groupLeft) + lyric[row - lyricRow];
|
|
503
|
+
const at = row - originRow;
|
|
504
|
+
const left = art.lines[at] ?? "";
|
|
505
|
+
const right = layout.copy[at - copyTop] ?? "";
|
|
506
|
+
return " ".repeat(originColumn) + pad(left, artWidth) + " " + right;
|
|
328
507
|
});
|
|
329
508
|
return finish(lines.map(paint));
|
|
330
509
|
}
|
|
331
510
|
}
|
|
332
511
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
512
|
+
/** Ink each record layer separately: faint grooves, a grey surface, and the dog at full strength. */
|
|
513
|
+
function toneRecord({ lines, tones }, theme) {
|
|
514
|
+
const inks = [theme.faint, theme.faint, theme.muted, theme.accent];
|
|
515
|
+
return lines.map((line, row) => {
|
|
516
|
+
let result = "";
|
|
517
|
+
let run = "";
|
|
518
|
+
let current = 0;
|
|
519
|
+
Array.from(line).forEach((character, column) => {
|
|
520
|
+
const tone = character === " " ? current : tones[row][column] ?? 0;
|
|
521
|
+
if (tone !== current && run) {
|
|
522
|
+
result += inks[current](run);
|
|
523
|
+
run = "";
|
|
524
|
+
}
|
|
525
|
+
current = tone;
|
|
526
|
+
run += character;
|
|
527
|
+
});
|
|
528
|
+
return result + (run ? inks[current](run) : "");
|
|
529
|
+
});
|
|
342
530
|
}
|
|
343
531
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
532
|
+
/** The home actions as a sleeve tracklist: each row names the command that opens it. */
|
|
533
|
+
function tracklist(theme, { focused, selected, columns }) {
|
|
534
|
+
// Two columns hold the selection mark; the rest matches the pixel wordmark's width.
|
|
535
|
+
const width = Math.min(columns, 43);
|
|
536
|
+
const fits = (key) => homeActions.every((action) => visibleWidth(action[key]) + action.command.length + 8 <= width);
|
|
537
|
+
const key = fits("label") ? "label" : fits("short") ? "short" : null;
|
|
538
|
+
return homeActions.map((action, index) => {
|
|
539
|
+
const active = focused && selected === index;
|
|
540
|
+
const label = action[key ?? "short"];
|
|
541
|
+
const marker = active ? theme.accent("◉ ") : " ";
|
|
542
|
+
const name = active ? theme.selected(label) : focused ? theme.muted(label) : theme.text(label);
|
|
543
|
+
if (!key) return marker + name;
|
|
544
|
+
const command = `/${action.command}`;
|
|
545
|
+
const leader = "·".repeat(width - 4 - visibleWidth(label) - command.length);
|
|
546
|
+
return `${marker}${name} ${(active ? theme.accent : theme.faint)(leader)} ${(active ? theme.accent : theme.muted)(command)}`;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
360
549
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
550
|
+
/** Hold the tallest note's rows so moving through the list never shifts the sleeve. */
|
|
551
|
+
function actionNote(text, width) {
|
|
552
|
+
const rows = Math.max(...homeActions.map((action) => wrapDescription(action.description, width).length));
|
|
553
|
+
const lines = wrapDescription(text, width);
|
|
554
|
+
return [...lines, ...Array(rows - lines.length).fill("")];
|
|
555
|
+
}
|
|
366
556
|
|
|
367
|
-
|
|
557
|
+
function hang(line) {
|
|
558
|
+
return ` ${line}`;
|
|
559
|
+
}
|
|
368
560
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
- \`/commands\` or Ctrl+P - search every command; your message stays put
|
|
374
|
-
- Page Up and Page Down scroll the conversation. The header and your draft stay put
|
|
375
|
-
- \`/resume\` - pick up a saved conversation (type to filter, Enter opens)
|
|
376
|
-
- \`/new\` - start fresh; this conversation stays saved
|
|
377
|
-
- \`/status\`, \`/sources\`, \`/tools\`, \`/doctor\` - see what's connected and working
|
|
378
|
-
- \`/reload\` - reload model settings and sign-ins
|
|
379
|
-
- \`/help all\` - every command, including the ones for the shell
|
|
380
|
-
- \`/quit\` - leave
|
|
561
|
+
function wrapDescription(text, width) {
|
|
562
|
+
// Pi wraps by word and breaks text without spaces, such as Chinese or Japanese.
|
|
563
|
+
return wrapTextWithAnsi(text, Math.max(1, width)).map((line) => line.trimEnd());
|
|
564
|
+
}
|
|
381
565
|
|
|
382
|
-
|
|
383
|
-
Enter sends, and Shift+Enter starts a new line.
|
|
384
|
-
Tab or Enter accepts a suggestion.
|
|
385
|
-
Ctrl+C stops a model or web request, or leaves when nothing is running.
|
|
386
|
-
In the profile, type to filter, Tab switches lists, and Enter shows what you can do.
|
|
387
|
-
Ctrl+R refreshes it and Ctrl+O opens the full report.
|
|
388
|
-
Your profile works without a model.
|
|
389
|
-
Put quotes around paths, titles, and names that have spaces.`;
|
|
566
|
+
export function listeningHelp() {
|
|
567
|
+
return tr("# Your listening room\n\n## 01 / Listen and discover\n\nJust type to talk about music, ask for a playlist, or go exploring.\nTo talk, Moondog needs a model: `/auth` signs you in, then `/model` picks one.\nWhile an answer is coming, each step shows in the conversation, and the footer names the one in progress.\n\n- `/taste` - your profile: open any song or artist to see why it's there\n- `/taste report` - the whole picture on one page\n- `/import` - bring in your history, your library, or a playlist\n- `/lyrics` - the lyrics on the sleeve; `/lyrics sync` finds more\n- `/spotify` - play, queue, and make playlists in Spotify\n- Ask to move playback onto your iPhone, computer, or another Spotify device by name\n- `/web search <query>` or `/web read <url>` - look things up on public music sites\n\n## 02 / Make it yours\n\n- `/profile` - the same profile as `/taste`\n- `/profile corrections` - everything you've told me\n- `/profile retract <id>` - undo one of those\n- `/profile help` - the full set of profile commands\n- `/remember <text>`, `/memory`, `/forget <id>` - what I remember between conversations\n\n## 03 / Around the room\n\n- `/home` - back to the record sleeve; the conversation stays\n- `/theme paper|charcoal|terminal|auto` - change the look\n- `/art braille|ascii|off` - change the artwork\n- `/motion on|off` - turn the animation on or off\n- `/commands` or Ctrl+P - search every command; your message stays put\n- Page Up and Page Down scroll the conversation. The header and your draft stay put\n- Up and Down in the draft recall what you typed in this conversation\n- `/resume` - pick up a saved conversation (type to filter, Enter opens)\n- `/new` - start fresh; this conversation stays saved\n- `/status`, `/sources`, `/tools`, `/doctor` - see what's connected and working\n- `/reload` - reload model settings and sign-ins\n- `/update [--check] [--channel latest|beta]` - check for a release, or close this room and install it\n- `/help all` - every command, including the ones for the shell\n- `/language` - choose the language Moondog uses\n- `/quit` - leave\n\nTab moves to the menu on the home screen. Esc goes back to typing.\nEnter sends, and Shift+Enter starts a new line.\nTab or Enter accepts a suggestion.\nCtrl+C stops a model or web request, or leaves when nothing is running.\nIn the profile, type to filter, Tab switches lists, and Enter shows what you can do.\nCtrl+R refreshes it and Ctrl+O opens the full report.\nYour profile works without a model.\nPut quotes around paths, titles, and names that have spaces.");
|
|
390
568
|
}
|