@audiofool/moondog 0.1.0 → 0.2.0-beta.2

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 (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. 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, renderLunarRecord, renderMoondogWordmark } from "./terminal-art.mjs";
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 = undefined;
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 Editor {
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 ? " DRAFT " : homeFocused ? " TAB TO TYPE " : " YOU ";
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?" : "Keep the conversation going...";
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
- export const homeActions = [
146
- { command: "taste", label: "Listening profile", short: "Profile", description: "See what your listening reveals. Tell me what I missed." },
147
- { command: "import", label: "Import your music", short: "Import music", description: "Bring songs, playlists or listening history." },
148
- { command: "theme", label: "Appearance", short: "Appearance", description: "Paper, charcoal, or your terminal colors." },
149
- { command: "help", label: "Help & commands", short: "Help", description: "Commands, connections and keyboard shortcuts." },
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 roomy = width >= 76 && rows >= 14;
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 > rows) return lines;
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
- this.canAnimate = false;
278
- this.lyricColumns = 0;
279
- const compact = withNote([theme.bold("MOONDOG ◎"),
280
- ...(rows >= 9 && width >= 30 ? [theme.muted("Your personal music agent."), ""] : []),
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
- this.cache.set(key, renderLunarRecord({ columns: artWidth, rows: height, style: mode, phase }));
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
- if (this.colorTheme !== theme) {
297
- this.colored.clear();
298
- this.colorTheme = theme;
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
- let colored = this.colored.get(key);
301
- if (!colored) {
302
- colored = art.map((ink) => {
303
- let run = "";
304
- let foreground = false;
305
- let left = "";
306
- for (const character of ink) {
307
- const mask = character.codePointAt(0) - 0x2800;
308
- const density = mask >= 0 && mask <= 255 ? mask.toString(2).replaceAll("0", "").length : 1;
309
- const next = density >= 4;
310
- if (next !== foreground && run) {
311
- left += (foreground ? theme.text : theme.muted)(run);
312
- run = "";
313
- }
314
- foreground = next;
315
- run += character;
316
- }
317
- return left + (foreground ? theme.text : theme.muted)(run);
318
- });
319
- if (this.colored.size > LOGO_MOTION_FRAMES * 4) this.colored.clear();
320
- this.colored.set(key, colored);
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 count = rows;
323
- const copyTop = Math.max(0, Math.floor((count - copy.length) / 2));
324
- const artTop = Math.max(0, Math.floor((count - colored.length) / 2));
325
- const lines = Array.from({ length: count }, (_, row) => {
326
- const left = colored[row - artTop] ?? "";
327
- return ` ${pad(left, artWidth)} ${copy[row - copyTop] ?? ""}`;
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
- function wrapDescription(text, width) {
334
- const lines = [];
335
- let line = "";
336
- for (const word of text.split(" ")) {
337
- if (line && visibleWidth(`${line} ${word}`) > width) { lines.push(line); line = ""; }
338
- line += (line ? " " : "") + word;
339
- }
340
- if (line) lines.push(line);
341
- return lines;
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
- export function listeningHelp() {
345
- return `# Your listening room
346
-
347
- ## 01 / Listen and discover
348
-
349
- Just type to talk about music, ask for a playlist, or go exploring.
350
- To talk, Moondog needs a model: \`/auth\` signs you in, then \`/model\` picks one.
351
-
352
- - \`/taste\` - your profile: open any song or artist to see why it's there
353
- - \`/taste report\` - the whole picture on one page
354
- - \`/import\` - bring in your history, your library, or a playlist
355
- - \`/lyrics\` - the lyrics on the sleeve; \`/lyrics sync\` finds more
356
- - \`/spotify\` - play, queue, and make playlists in Spotify
357
- - \`/web search <query>\` or \`/web read <url>\` - look things up on public music sites
358
-
359
- ## 02 / Make it yours
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
- - \`/profile\` - the same profile as \`/taste\`
362
- - \`/profile corrections\` - everything you've told me
363
- - \`/profile retract <id>\` - undo one of those
364
- - \`/profile help\` - the full set of profile commands
365
- - \`/remember <text>\`, \`/memory\`, \`/forget <id>\` - what I remember between conversations
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
- ## 03 / Around the room
557
+ function hang(line) {
558
+ return ` ${line}`;
559
+ }
368
560
 
369
- - \`/home\` - back to the record sleeve; the conversation stays
370
- - \`/theme paper|charcoal|terminal|auto\` - change the look
371
- - \`/art braille|ascii|off\` - change the artwork
372
- - \`/motion on|off\` - turn the animation on or off
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
- Tab moves to the menu on the home screen. Esc goes back to typing.
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
  }