@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.
- package/README.md +125 -0
- package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
- package/contracts/v1/capability-policy.schema.json +572 -0
- package/contracts/v1/definitions.schema.json +365 -0
- package/contracts/v1/library-track-observation.schema.json +138 -0
- package/contracts/v1/listening-event.schema.json +111 -0
- package/contracts/v1/profile-evidence.schema.json +237 -0
- package/contracts/v1/taste-event.schema.json +258 -0
- package/contracts/v1/tool-invocation.schema.json +318 -0
- package/contracts/v1/tool-receipt.schema.json +1066 -0
- package/contracts/v1/track-ref.schema.json +275 -0
- package/contracts/v2/profile-evidence.schema.json +284 -0
- package/package.json +105 -0
- package/scripts/contract-lib.mjs +361 -0
- package/scripts/contract-semantics.mjs +2630 -0
- package/scripts/demo-studio.mjs +58 -0
- package/scripts/moondog +52 -0
- package/scripts/moondog.mjs +954 -0
- package/src/core/apple-data-migration.mjs +50 -0
- package/src/core/apple-library-source-status.mjs +220 -0
- package/src/core/apple-projection-domain-services.mjs +2356 -0
- package/src/core/capability-catalog.mjs +577 -0
- package/src/core/listening-profile-domain-services.mjs +1010 -0
- package/src/core/lyric-library.mjs +121 -0
- package/src/core/lyric-profile.mjs +50 -0
- package/src/core/lyric-service.mjs +41 -0
- package/src/core/moondog-application.mjs +1827 -0
- package/src/core/state-directory.mjs +19 -0
- package/src/core/synthetic-domain-services.mjs +1485 -0
- package/src/core/uuid-v5.mjs +42 -0
- package/src/demo/fictional-spotify-history.mjs +400 -0
- package/src/demo/moondog-demo.mjs +355 -0
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
- package/src/demo/moondog-tasteprint-demo.mjs +631 -0
- package/src/importers/apple-music-library/import-batch.mjs +290 -0
- package/src/importers/apple-music-library/index.mjs +76 -0
- package/src/importers/apple-music-library/normalize.mjs +1126 -0
- package/src/importers/apple-music-library/parse-plist.mjs +357 -0
- package/src/importers/apple-music-library/projection-records.mjs +557 -0
- package/src/importers/apple-music-library/read-batch.mjs +276 -0
- package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
- package/src/importers/apple-music-library/stable-ids.mjs +19 -0
- package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
- package/src/integrations/apple-music/catalog.mjs +770 -0
- package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
- package/src/integrations/listenbrainz/history-file.mjs +560 -0
- package/src/integrations/lyrics/lrclib.mjs +55 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
- package/src/integrations/public-playlists.mjs +104 -0
- package/src/integrations/spotify/account-data-history.mjs +545 -0
- package/src/integrations/spotify/account-data-profile.mjs +918 -0
- package/src/integrations/spotify/authentication.mjs +791 -0
- package/src/integrations/spotify/catalog-resolver.mjs +657 -0
- package/src/integrations/spotify/connection.mjs +125 -0
- package/src/integrations/spotify/credential-store.mjs +26 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
- package/src/integrations/spotify/history-archive.mjs +59 -0
- package/src/integrations/spotify/index.mjs +80 -0
- package/src/integrations/spotify/recent-activity.mjs +198 -0
- package/src/integrations/spotify/resolution-cache.mjs +260 -0
- package/src/integrations/spotify/service.mjs +713 -0
- package/src/integrations/spotify/settings.mjs +89 -0
- package/src/integrations/spotify/web-api-client.mjs +761 -0
- package/src/integrations/web/codex-web.mjs +263 -0
- package/src/integrations/youtube-music/takeout.mjs +143 -0
- package/src/memory/local-memory-store.mjs +1705 -0
- package/src/memory/reflection-worker.mjs +87 -0
- package/src/profile/apple-library-import.mjs +28 -0
- package/src/profile/history-import.mjs +163 -0
- package/src/profile/listener-corrections.mjs +217 -0
- package/src/profile/listening-history-store.mjs +2055 -0
- package/src/profile/listening-profile-projection.mjs +2520 -0
- package/src/profile/local-music-data.mjs +812 -0
- package/src/profile/music-import-bundle.mjs +91 -0
- package/src/profile/music-providers.mjs +25 -0
- package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
- package/src/profile/spotify-archive-taste.mjs +482 -0
- package/src/runtime/pi/agent-runtime.mjs +6444 -0
- package/src/runtime/pi/authentication.mjs +104 -0
- package/src/runtime/pi/configured-runtime.mjs +98 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
- package/src/runtime/pi/model-catalog.mjs +62 -0
- package/src/runtime/pi/model-transport.mjs +117 -0
- package/src/runtime/pi/persistent-credential-store.mjs +670 -0
- package/src/runtime/pi/provider-registry.mjs +74 -0
- package/src/runtime/pi/runtime-settings.mjs +81 -0
- package/src/surfaces/cli/auth-command.mjs +355 -0
- package/src/surfaces/cli/brand-components.mjs +390 -0
- package/src/surfaces/cli/brand-theme.mjs +231 -0
- package/src/surfaces/cli/catalog-command.mjs +509 -0
- package/src/surfaces/cli/command-completions.mjs +113 -0
- package/src/surfaces/cli/data-command.mjs +214 -0
- package/src/surfaces/cli/format-output.mjs +750 -0
- package/src/surfaces/cli/history-import-view.mjs +445 -0
- package/src/surfaces/cli/import-guides.mjs +9 -0
- package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
- package/src/surfaces/cli/listening-menu.mjs +171 -0
- package/src/surfaces/cli/profile-command.mjs +290 -0
- package/src/surfaces/cli/spotify-command.mjs +849 -0
- package/src/surfaces/cli/taste-profile-model.mjs +306 -0
- package/src/surfaces/cli/taste-profile-view.mjs +272 -0
- package/src/surfaces/cli/terminal-art.mjs +295 -0
- package/src/surfaces/cli/tui.mjs +1714 -0
- package/src/surfaces/cli/web-command.mjs +52 -0
- package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
- package/src/surfaces/html/tasteprint.mjs +3575 -0
- package/src/surfaces/web/showcase.mjs +272 -0
- package/src/surfaces/web/studio-page.mjs +4148 -0
- package/src/surfaces/web/studio.mjs +2288 -0
|
@@ -0,0 +1,1714 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CombinedAutocompleteProvider,
|
|
3
|
+
Container,
|
|
4
|
+
Markdown,
|
|
5
|
+
matchesKey,
|
|
6
|
+
ProcessTerminal,
|
|
7
|
+
ScrollView,
|
|
8
|
+
Text,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
TuiAltScreen,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
VStack,
|
|
13
|
+
} from "@earendil-works/pi-tui";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
listPiModels,
|
|
17
|
+
listPiProviders,
|
|
18
|
+
} from "../../runtime/pi/model-catalog.mjs";
|
|
19
|
+
import { supportedAuthProviderIds } from "../../runtime/pi/authentication.mjs";
|
|
20
|
+
import {
|
|
21
|
+
formatLocalResult,
|
|
22
|
+
helpText,
|
|
23
|
+
sanitizeTerminalText,
|
|
24
|
+
} from "./format-output.mjs";
|
|
25
|
+
|
|
26
|
+
import { createMoondogTheme } from "./brand-theme.mjs";
|
|
27
|
+
import { LOGO_MOTION_INTERVAL_MS } from "./terminal-art.mjs";
|
|
28
|
+
import { buildTasteProfileModel } from "./taste-profile-model.mjs";
|
|
29
|
+
import { TasteProfileView } from "./taste-profile-view.mjs";
|
|
30
|
+
import { lyricTrackKey } from "../../core/lyric-profile.mjs";
|
|
31
|
+
import { HistoryImportView } from "./history-import-view.mjs";
|
|
32
|
+
import { IMPORT_GUIDE_URLS } from "./import-guides.mjs";
|
|
33
|
+
import { withCommandCompletions } from "./command-completions.mjs";
|
|
34
|
+
import {
|
|
35
|
+
BrandSurface,
|
|
36
|
+
ListeningEditor,
|
|
37
|
+
ListeningHeader,
|
|
38
|
+
ListeningMenu,
|
|
39
|
+
RecordSleeve,
|
|
40
|
+
homeActions,
|
|
41
|
+
listeningHelp,
|
|
42
|
+
paintBrandLine,
|
|
43
|
+
} from "./brand-components.mjs";
|
|
44
|
+
|
|
45
|
+
const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
46
|
+
|
|
47
|
+
/** Full-screen listening room. The conversation scrolls; the header and draft stay put. */
|
|
48
|
+
class ListeningRoom extends TuiAltScreen {
|
|
49
|
+
constructor(terminal, { mouse = true } = {}) {
|
|
50
|
+
super(terminal, false, undefined, { mouse, wheelScrollLines: 3 });
|
|
51
|
+
this.passViewportKeys = () => false;
|
|
52
|
+
this.cursorRow = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
extractCursorPosition(lines, height) {
|
|
56
|
+
const position = super.extractCursorPosition(lines, height);
|
|
57
|
+
this.cursorRow = position?.row ?? 0;
|
|
58
|
+
return position;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
handleViewportInput(data) {
|
|
62
|
+
if (this.passViewportKeys()) return undefined;
|
|
63
|
+
return super.handleViewportInput(data);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
doRender() {
|
|
67
|
+
super.doRender();
|
|
68
|
+
if (this.pendingReveal == null || this.conversationVisible?.() === false) return;
|
|
69
|
+
const line = this.pendingReveal;
|
|
70
|
+
this.pendingReveal = null;
|
|
71
|
+
this.revealScroll?.(line);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
captureRenderState() {
|
|
75
|
+
return {
|
|
76
|
+
previousLines: this.previousScreen ?? [],
|
|
77
|
+
previousWidth: this.previousScreenWidth,
|
|
78
|
+
previousHeight: this.previousScreenHeight,
|
|
79
|
+
previousViewportTop: 0,
|
|
80
|
+
hardwareCursorRow: this.cursorRow,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const slashCommands = [
|
|
86
|
+
{ name: "home", description: "Back to the record sleeve" },
|
|
87
|
+
{ name: "lyrics", description: "Your lyric library, or sync more songs" },
|
|
88
|
+
{ name: "import", description: "Bring in your listening history or library" },
|
|
89
|
+
{ name: "theme", description: "Paper, charcoal, or your terminal colors" },
|
|
90
|
+
{ name: "art", description: "Braille, ASCII, or a quiet opening" },
|
|
91
|
+
{ name: "motion", description: "Turn the animation on or off" },
|
|
92
|
+
{ name: "commands", description: "Search every command" },
|
|
93
|
+
{ name: "status", description: "See what's connected and ready" },
|
|
94
|
+
{ name: "sources", description: "See where your music data comes from" },
|
|
95
|
+
{ name: "profile", description: "Review, change, or undo your choices" },
|
|
96
|
+
{ name: "taste", description: "Your listening profile and the evidence behind it" },
|
|
97
|
+
{ name: "memory", description: "See what Moondog remembers" },
|
|
98
|
+
{ name: "tools", description: "See which music tools are available" },
|
|
99
|
+
{ name: "doctor", description: "Check your setup" },
|
|
100
|
+
{ name: "model", description: "Choose the model Moondog talks through" },
|
|
101
|
+
{ name: "auth", description: "Sign in to a model provider" },
|
|
102
|
+
{ name: "web", description: "Look things up on public music sites" },
|
|
103
|
+
{ name: "spotify", description: "Connect and control Spotify" },
|
|
104
|
+
{ name: "reload", description: "Reload model settings and sign-ins" },
|
|
105
|
+
{ name: "remember", description: "Ask Moondog to remember something" },
|
|
106
|
+
{ name: "forget", description: "Forget one memory by ID" },
|
|
107
|
+
{ name: "resume", description: "Pick up a saved conversation" },
|
|
108
|
+
{ name: "new", description: "Start fresh; this conversation stays saved" },
|
|
109
|
+
{ name: "help", description: "Commands and shortcuts" },
|
|
110
|
+
{ name: "quit", description: "Leave Moondog" },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
// Parse command arguments as text only. Never evaluate shell substitutions.
|
|
114
|
+
export function parseTuiArguments(text) {
|
|
115
|
+
const values = [];
|
|
116
|
+
let value = "";
|
|
117
|
+
let quote = null;
|
|
118
|
+
let started = false;
|
|
119
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
120
|
+
const character = text[index];
|
|
121
|
+
if (character === "\\" && quote !== "'") {
|
|
122
|
+
const next = text[index + 1];
|
|
123
|
+
if (next === undefined) throw new Error("Incomplete escape in command arguments.");
|
|
124
|
+
if (!quote || next === quote || next === "\\") {
|
|
125
|
+
value += next;
|
|
126
|
+
index += 1;
|
|
127
|
+
} else {
|
|
128
|
+
value += character;
|
|
129
|
+
}
|
|
130
|
+
started = true;
|
|
131
|
+
} else if (quote) {
|
|
132
|
+
if (character === quote) quote = null;
|
|
133
|
+
else value += character;
|
|
134
|
+
} else if (character === '"' || character === "'") {
|
|
135
|
+
quote = character;
|
|
136
|
+
started = true;
|
|
137
|
+
} else if (/\s/u.test(character)) {
|
|
138
|
+
if (started) values.push(value);
|
|
139
|
+
value = "";
|
|
140
|
+
started = false;
|
|
141
|
+
} else {
|
|
142
|
+
value += character;
|
|
143
|
+
started = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (quote) throw new Error("Unclosed quote in command arguments.");
|
|
147
|
+
if (started) values.push(value);
|
|
148
|
+
return values;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function offlineReply(runtimeStatus) {
|
|
152
|
+
const provider = runtimeStatus.provider ?? "openai-codex";
|
|
153
|
+
const [why, next] = runtimeStatus.reason === "provider_authentication_required"
|
|
154
|
+
? [`you're not signed in to ${provider}`, `Sign in with \`/auth ${provider}\`.`]
|
|
155
|
+
: runtimeStatus.reason === "model_not_configured" || !runtimeStatus.reason
|
|
156
|
+
? ["no model is connected", "Type `/model` to pick one."]
|
|
157
|
+
: [`I can't reach the model (\`${runtimeStatus.reason}\`)`, "Type `/model` to pick another, or `/reload` to try again."];
|
|
158
|
+
return `Obscured by clouds. I can't talk yet because ${why}.
|
|
159
|
+
|
|
160
|
+
${next}
|
|
161
|
+
|
|
162
|
+
Your profile doesn't need a model. \`/taste\`, \`/import\`, and \`/help\` all work right now.`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function elapsedTime(startedAt) {
|
|
166
|
+
const seconds = Math.max(0, Math.floor((performance.now() - startedAt) / 1000));
|
|
167
|
+
if (seconds < 60) return `${seconds}s`;
|
|
168
|
+
const minutes = Math.floor(seconds / 60);
|
|
169
|
+
return minutes < 60 ? `${minutes}m${seconds % 60}s` : `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function runMoondogTui({
|
|
173
|
+
application,
|
|
174
|
+
runtime,
|
|
175
|
+
terminal = new ProcessTerminal(),
|
|
176
|
+
signalTarget = process,
|
|
177
|
+
rebuildRuntime,
|
|
178
|
+
runAuth,
|
|
179
|
+
runSpotify,
|
|
180
|
+
runProfile,
|
|
181
|
+
runProfileAction,
|
|
182
|
+
runWeb,
|
|
183
|
+
lyrics,
|
|
184
|
+
prepareImport,
|
|
185
|
+
prepareRecentImport,
|
|
186
|
+
refreshImportedData,
|
|
187
|
+
openImportHelp,
|
|
188
|
+
providers = listPiProviders,
|
|
189
|
+
models = listPiModels,
|
|
190
|
+
environment = process.env,
|
|
191
|
+
}) {
|
|
192
|
+
let activeRuntime = runtime;
|
|
193
|
+
application.startNewSession?.("tui_start");
|
|
194
|
+
activeRuntime.reset();
|
|
195
|
+
let runtimeStatus = activeRuntime.publicStatus();
|
|
196
|
+
const sourceStatusTask = Promise.resolve().then(() => application.sourceStatus?.()).then(
|
|
197
|
+
() => null,
|
|
198
|
+
(error) => error,
|
|
199
|
+
);
|
|
200
|
+
let theme = createMoondogTheme({ environment });
|
|
201
|
+
const getTheme = () => theme;
|
|
202
|
+
const color = (name) => (text) => theme[name](text);
|
|
203
|
+
const accent = color("accent");
|
|
204
|
+
const green = color("success");
|
|
205
|
+
const yellow = color("warning");
|
|
206
|
+
const red = color("error");
|
|
207
|
+
const dim = color("muted");
|
|
208
|
+
const bold = color("bold");
|
|
209
|
+
const selectListTheme = Object.fromEntries(
|
|
210
|
+
Object.keys(theme.selectListTheme).map((key) => [key, (text) => theme.selectListTheme[key](text)]),
|
|
211
|
+
);
|
|
212
|
+
const editorTheme = { borderColor: color("faint"), selectList: selectListTheme };
|
|
213
|
+
const markdownTheme = Object.fromEntries(
|
|
214
|
+
Object.keys(theme.markdownTheme).map((key) => [key, (text) => theme.markdownTheme[key](text)]),
|
|
215
|
+
);
|
|
216
|
+
const textStyle = { color: color("text") };
|
|
217
|
+
const tui = new ListeningRoom(terminal, { mouse: environment.TERM !== "dumb" });
|
|
218
|
+
if (theme.plain) tui.setShowHardwareCursor(true);
|
|
219
|
+
const transcript = new Container();
|
|
220
|
+
let homeVisible = true;
|
|
221
|
+
let profileView = null;
|
|
222
|
+
let importView = null;
|
|
223
|
+
let importPage = "start";
|
|
224
|
+
let importReturnHome = false;
|
|
225
|
+
let preparedImport = null;
|
|
226
|
+
let importNeedsRefresh = false;
|
|
227
|
+
let importOrigin = "file";
|
|
228
|
+
let importProvider = null;
|
|
229
|
+
let importFileReturn = "start";
|
|
230
|
+
let importAuthController = null;
|
|
231
|
+
let profileReturnHome = false;
|
|
232
|
+
let profileSnapshot = null;
|
|
233
|
+
let profileNeedsRefresh = false;
|
|
234
|
+
let profileDiscoveryDraft = null;
|
|
235
|
+
let homeFocused = false;
|
|
236
|
+
let homeSelected = 0;
|
|
237
|
+
let busy = false;
|
|
238
|
+
let cancelWork = null;
|
|
239
|
+
let cancellationRequested = false;
|
|
240
|
+
let workStartedAt = 0;
|
|
241
|
+
let indicator = null;
|
|
242
|
+
let homeMotion = null;
|
|
243
|
+
let lyricController = null;
|
|
244
|
+
let lyricTask = Promise.resolve();
|
|
245
|
+
let homeLyric = null;
|
|
246
|
+
let homeLyricSeeds = null;
|
|
247
|
+
let lyricSubject = null;
|
|
248
|
+
let lyricError = null;
|
|
249
|
+
let motionEnabled = environment.MOONDOG_MOTION !== "off";
|
|
250
|
+
let phase = 0;
|
|
251
|
+
let footerText = "Ready.";
|
|
252
|
+
let footerColor = dim;
|
|
253
|
+
let localCommandController = null;
|
|
254
|
+
let startAttempted = false;
|
|
255
|
+
let cleanedUp = false;
|
|
256
|
+
let resolveStopped;
|
|
257
|
+
const stoppedPromise = new Promise((resolve) => { resolveStopped = resolve; });
|
|
258
|
+
const editor = new ListeningEditor(tui, editorTheme, getTheme, () => ({ busy, homeVisible, homeFocused }));
|
|
259
|
+
const sleeve = new RecordSleeve({ terminal, getTheme, environment, getState: () => ({
|
|
260
|
+
focused: homeFocused, selected: homeSelected, motionEnabled,
|
|
261
|
+
editorRows: editor.rowCount || 3,
|
|
262
|
+
}), getNextLyric: () => {
|
|
263
|
+
try {
|
|
264
|
+
homeLyric = homeLyricSeeds ? lyrics.selectHome(homeLyricSeeds) : null;
|
|
265
|
+
} catch (error) {
|
|
266
|
+
lyricError = sanitizeTerminalText(error.message);
|
|
267
|
+
}
|
|
268
|
+
return homeLyric?.text ?? null;
|
|
269
|
+
} });
|
|
270
|
+
const updateHomeLyrics = ({ sync = true, signal } = {}) => {
|
|
271
|
+
if (!lyrics || typeof application.getLyricSeeds !== "function" || cleanedUp) return Promise.resolve();
|
|
272
|
+
lyricController?.abort();
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
lyricController = controller;
|
|
275
|
+
const taskSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
276
|
+
// Serialize replacements so a profile change cannot leave an old fetch writing afterward.
|
|
277
|
+
lyricTask = lyricTask.then(async () => {
|
|
278
|
+
taskSignal.throwIfAborted();
|
|
279
|
+
const seeds = await application.getLyricSeeds();
|
|
280
|
+
taskSignal.throwIfAborted();
|
|
281
|
+
homeLyricSeeds = seeds;
|
|
282
|
+
if (lyricSubject !== seeds.subjectId || (homeLyric && !seeds.tracks.some((track) => lyricTrackKey(track) === homeLyric.trackKey))) {
|
|
283
|
+
homeLyric = null;
|
|
284
|
+
sleeve.setLyric(null);
|
|
285
|
+
}
|
|
286
|
+
lyricSubject = seeds.subjectId;
|
|
287
|
+
const chooseCached = () => {
|
|
288
|
+
if (taskSignal.aborted || cleanedUp || homeLyric) return;
|
|
289
|
+
homeLyric = lyrics.selectHome(seeds);
|
|
290
|
+
if (homeLyric) { sleeve.setLyric(homeLyric.text); tui.requestRender(); }
|
|
291
|
+
};
|
|
292
|
+
chooseCached();
|
|
293
|
+
lyricError = null;
|
|
294
|
+
if (sync) await lyrics.refresh(seeds, { signal: taskSignal, onUpdate: chooseCached });
|
|
295
|
+
}).catch((error) => {
|
|
296
|
+
if (!taskSignal.aborted) lyricError = sanitizeTerminalText(error.message);
|
|
297
|
+
});
|
|
298
|
+
return lyricTask;
|
|
299
|
+
};
|
|
300
|
+
const header = new ListeningHeader(getTheme, () => ({
|
|
301
|
+
profileReady: application.profileServicesReady?.() ?? false,
|
|
302
|
+
modelReady: runtimeStatus.state === "configured",
|
|
303
|
+
provider: runtimeStatus.provider,
|
|
304
|
+
model: runtimeStatus.model,
|
|
305
|
+
spotifyReady: application.spotifyReady?.() ?? false,
|
|
306
|
+
}));
|
|
307
|
+
const renderHeader = () => { header.invalidate(); tui.requestRender(); };
|
|
308
|
+
const synchronizeHomeMotion = () => {
|
|
309
|
+
const shouldRun = startAttempted && !cleanedUp && motionEnabled && !theme.plain &&
|
|
310
|
+
!busy && !profileView && !importView && homeVisible && !homeFocused && !tui.hasOverlay() && !editor.getText() && sleeve.canAnimate;
|
|
311
|
+
if (!shouldRun) {
|
|
312
|
+
clearInterval(homeMotion);
|
|
313
|
+
homeMotion = null;
|
|
314
|
+
} else if (!homeMotion) {
|
|
315
|
+
homeMotion = setInterval(() => { sleeve.advance(); tui.requestRender(); }, LOGO_MOTION_INTERVAL_MS);
|
|
316
|
+
homeMotion.unref?.();
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const conversation = new BrandSurface(transcript, getTheme);
|
|
320
|
+
const transcriptPane = {
|
|
321
|
+
invalidate() { conversation.invalidate(); },
|
|
322
|
+
render(width) {
|
|
323
|
+
const lines = conversation.render(width);
|
|
324
|
+
const footerRows = terminal.rows >= 20 ? 2 : 1;
|
|
325
|
+
const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - (editor.rowCount || 3));
|
|
326
|
+
while (lines.length < bodyRows) lines.push(paintBrandLine("", width, theme));
|
|
327
|
+
return lines;
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
const transcriptScroll = new ScrollView(transcriptPane, {
|
|
331
|
+
follow: "end",
|
|
332
|
+
primary: true,
|
|
333
|
+
scrollbar: "auto",
|
|
334
|
+
scrollbarStyle: (text) => getTheme().faint(text),
|
|
335
|
+
});
|
|
336
|
+
const stage = {
|
|
337
|
+
invalidate() {
|
|
338
|
+
sleeve.invalidate();
|
|
339
|
+
transcript.invalidate();
|
|
340
|
+
profileView?.invalidate();
|
|
341
|
+
importView?.invalidate();
|
|
342
|
+
},
|
|
343
|
+
render(width) {
|
|
344
|
+
// Measure the draft before the sleeve asks, so one frame uses the real height.
|
|
345
|
+
if (!profileView && !importView) editor.rowCount = editor.render(width).length;
|
|
346
|
+
const lines = importView ? importView.render(width)
|
|
347
|
+
: profileView ? profileView.render(width)
|
|
348
|
+
: homeVisible ? sleeve.render(width)
|
|
349
|
+
: transcriptPane.render(width);
|
|
350
|
+
synchronizeHomeMotion();
|
|
351
|
+
return lines;
|
|
352
|
+
},
|
|
353
|
+
[LAYOUT_NODE]() {
|
|
354
|
+
if (!homeVisible && !profileView && !importView) return transcriptScroll[LAYOUT_NODE]();
|
|
355
|
+
return undefined;
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
const composer = new BrandSurface(editor, getTheme);
|
|
359
|
+
const composerSlot = {
|
|
360
|
+
invalidate() { composer.invalidate(); },
|
|
361
|
+
render: (width) => profileView || importView ? [] : composer.render(width),
|
|
362
|
+
};
|
|
363
|
+
const shell = new VStack();
|
|
364
|
+
shell.addChild(header, { shrink: 0 });
|
|
365
|
+
shell.addChild(stage, { grow: 1, shrink: 1, minSize: 1 });
|
|
366
|
+
shell.addChild(composerSlot, { shrink: 0 });
|
|
367
|
+
tui.passViewportKeys = () => Boolean(profileView || importView);
|
|
368
|
+
tui.setLayoutRoot(shell);
|
|
369
|
+
shell.addChild({
|
|
370
|
+
invalidate() {},
|
|
371
|
+
render(width) {
|
|
372
|
+
const marker = theme.plain ? "." : busy && !tui.hasOverlay() ? ["◴", "◷", "◶", "◵"][phase % 4] : "◎";
|
|
373
|
+
const compactHint = terminal.rows < 20 && homeVisible && !busy && !profileView && !importView;
|
|
374
|
+
let statusText = compactHint && homeFocused ? "↑ ↓ choose · enter open · esc type"
|
|
375
|
+
: compactHint && editor.getText() ? "enter send · ctrl+p commands"
|
|
376
|
+
: compactHint && footerText === "Ready." ? "tab explore · type to talk" : footerText;
|
|
377
|
+
if (importView && width < 64) {
|
|
378
|
+
if (busy) statusText = footerText.startsWith("Reading the file") ? "Reading; nothing added yet."
|
|
379
|
+
: footerText.startsWith("History saved") ? "Saved; refreshing profile..." : "Saving history locally...";
|
|
380
|
+
else if (importView.state.error) statusText = "See the message above.";
|
|
381
|
+
else if (footerText.startsWith("Guide opened")) statusText = "Guide opened in browser.";
|
|
382
|
+
else statusText = importPage === "preview" ? "Ready; nothing added."
|
|
383
|
+
: importPage === "file" ? "Choose one history file." : "Choose a step; Esc goes back.";
|
|
384
|
+
}
|
|
385
|
+
if (profileView && !importView && width < 60) {
|
|
386
|
+
statusText = footerText.replace(/\. Profile refreshed\.$/u, "");
|
|
387
|
+
if (tui.hasOverlay()) statusText = "Enter chooses · Esc back";
|
|
388
|
+
else if (footerText.startsWith("Pick a song or artist.")) statusText = "Enter actions · Tab views · Esc back";
|
|
389
|
+
else if (footerText.startsWith("Here's the evidence.")) statusText = "Evidence · PgUp/PgDn scroll · Enter";
|
|
390
|
+
else if (footerText.startsWith("Imported.")) statusText = "Imported · Enter to review";
|
|
391
|
+
}
|
|
392
|
+
if (busy && !profileView && !importView && width >= 20) {
|
|
393
|
+
const elapsed = ` · ${elapsedTime(workStartedAt)}`;
|
|
394
|
+
statusText = truncateToWidth(statusText, width - 3 - visibleWidth(elapsed)) + elapsed;
|
|
395
|
+
}
|
|
396
|
+
if (terminal.rows < 20 && !busy && !profileView && !importView && editor.isShowingAutocomplete()) {
|
|
397
|
+
statusText = "↑↓ choose · Tab/Enter complete";
|
|
398
|
+
}
|
|
399
|
+
const lines = [paintBrandLine(` ${theme.faint(marker)} ${footerColor(statusText)}`, width, theme)];
|
|
400
|
+
if (terminal.rows >= 20) {
|
|
401
|
+
const keys = importView
|
|
402
|
+
? width < 64 ? busy ? "One moment..." : importPage === "file"
|
|
403
|
+
? "Tab path · ↵ inspect · Esc back" : "↑↓ ↵ · PgUp/Dn read · Esc back"
|
|
404
|
+
: busy ? "One moment..." : importPage === "file"
|
|
405
|
+
? "paste a path · tab completes · enter inspects · esc back"
|
|
406
|
+
: "↑↓ choose · enter select · PgUp/PgDn read · esc back"
|
|
407
|
+
: profileView
|
|
408
|
+
? busy ? "One moment..."
|
|
409
|
+
: width >= 76 ? "type filter · ↑↓ select · enter actions · tab views · esc back" : "↑↓ select · enter actions · tab views · esc back"
|
|
410
|
+
: busy ? cancellationRequested ? "cancelling · draft stays here"
|
|
411
|
+
: cancelWork ? "draft stays here · ctrl+c cancel"
|
|
412
|
+
: "draft stays here · waiting for command"
|
|
413
|
+
: editor.isShowingAutocomplete() ? "↑↓ choose · tab/enter complete · esc dismiss" : homeFocused
|
|
414
|
+
? "↑ ↓ choose · enter open · esc type"
|
|
415
|
+
: homeVisible && !editor.getText()
|
|
416
|
+
? width >= 60 ? "tab explore · ctrl+p commands enter send · shift+enter newline" : "tab explore · ctrl+p commands"
|
|
417
|
+
: width >= 72 ? "pgup history · ctrl+p · /home enter send · shift+enter newline" : "pgup history · /home";
|
|
418
|
+
lines.push(paintBrandLine(` ${theme.faint(keys)}`, width, theme));
|
|
419
|
+
}
|
|
420
|
+
return lines;
|
|
421
|
+
},
|
|
422
|
+
}, { shrink: 0 });
|
|
423
|
+
editor.setAutocompleteProvider(
|
|
424
|
+
new CombinedAutocompleteProvider(withCommandCompletions(slashCommands, {
|
|
425
|
+
providers, models, authProviderIds: supportedAuthProviderIds,
|
|
426
|
+
}), process.cwd(), null),
|
|
427
|
+
);
|
|
428
|
+
tui.setFocus(editor);
|
|
429
|
+
|
|
430
|
+
const setBusy = (value, cancel = null) => {
|
|
431
|
+
busy = value;
|
|
432
|
+
cancelWork = value ? cancel : null;
|
|
433
|
+
cancellationRequested = false;
|
|
434
|
+
if (value) workStartedAt = performance.now();
|
|
435
|
+
clearInterval(indicator);
|
|
436
|
+
indicator = null;
|
|
437
|
+
phase = 0;
|
|
438
|
+
if (busy && !cleanedUp) {
|
|
439
|
+
const animate = !theme.plain && motionEnabled;
|
|
440
|
+
indicator = setInterval(() => { if (animate) phase += 1; tui.requestRender(); }, animate ? 220 : 1000);
|
|
441
|
+
indicator.unref?.();
|
|
442
|
+
}
|
|
443
|
+
if (!cleanedUp) tui.requestRender();
|
|
444
|
+
};
|
|
445
|
+
const setFooter = (text, color = dim) => {
|
|
446
|
+
if (cleanedUp) return;
|
|
447
|
+
footerText = sanitizeTerminalText(text);
|
|
448
|
+
footerColor = color;
|
|
449
|
+
tui.requestRender();
|
|
450
|
+
};
|
|
451
|
+
const enterConversation = () => {
|
|
452
|
+
if (!homeVisible) return;
|
|
453
|
+
homeVisible = false;
|
|
454
|
+
homeFocused = false;
|
|
455
|
+
editor.focused = true;
|
|
456
|
+
tui.requestRender(true);
|
|
457
|
+
};
|
|
458
|
+
const addLabel = (label) => {
|
|
459
|
+
transcript.addChild({
|
|
460
|
+
invalidate() {},
|
|
461
|
+
render: (width) => new Text(`${theme.faint(label === "Moondog" ? "◎" : "›")} ${bold(label)}`, 1, 0).render(width),
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
const addUserMessage = (text) => {
|
|
465
|
+
addLabel("You");
|
|
466
|
+
transcript.addChild(new Text(text, 1, 1));
|
|
467
|
+
};
|
|
468
|
+
// A finished local message is read from the top. Several messages added before
|
|
469
|
+
// the next frame share the first anchor, once that frame has measured them.
|
|
470
|
+
tui.conversationVisible = () => !homeVisible && !profileView && !importView;
|
|
471
|
+
tui.revealScroll = (added) => {
|
|
472
|
+
transcriptScroll.scrollToEnd();
|
|
473
|
+
const hiddenAbove = added - transcriptScroll.viewportHeight;
|
|
474
|
+
if (hiddenAbove > 0) transcriptScroll.scrollBy(-hiddenAbove);
|
|
475
|
+
tui.requestRender();
|
|
476
|
+
};
|
|
477
|
+
const revealMessageStart = (added) => {
|
|
478
|
+
tui.pendingReveal = (tui.pendingReveal ?? 0) + added;
|
|
479
|
+
};
|
|
480
|
+
const addMoondogMessage = (markdown) => {
|
|
481
|
+
enterConversation();
|
|
482
|
+
const width = Math.max(1, transcriptScroll.getContentWidth(terminal.columns));
|
|
483
|
+
const before = conversation.render(width).length;
|
|
484
|
+
addLabel("Moondog");
|
|
485
|
+
transcript.addChild(new Markdown(sanitizeTerminalText(markdown), 1, 1, markdownTheme, textStyle));
|
|
486
|
+
revealMessageStart(Math.max(0, conversation.render(width).length - before));
|
|
487
|
+
tui.requestRender();
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const removeLifecycleListeners = () => {
|
|
491
|
+
signalTarget.removeListener("SIGTERM", handleSigterm);
|
|
492
|
+
signalTarget.removeListener("SIGHUP", handleSighup);
|
|
493
|
+
signalTarget.removeListener(
|
|
494
|
+
"uncaughtExceptionMonitor",
|
|
495
|
+
handleUncaughtFailure,
|
|
496
|
+
);
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const cleanup = () => {
|
|
500
|
+
if (cleanedUp) return;
|
|
501
|
+
cleanedUp = true;
|
|
502
|
+
clearInterval(indicator);
|
|
503
|
+
indicator = null;
|
|
504
|
+
clearInterval(homeMotion);
|
|
505
|
+
homeMotion = null;
|
|
506
|
+
localCommandController?.abort();
|
|
507
|
+
importAuthController?.abort();
|
|
508
|
+
lyricController?.abort();
|
|
509
|
+
preparedImport?.close?.();
|
|
510
|
+
preparedImport = null;
|
|
511
|
+
removeLifecycleListeners();
|
|
512
|
+
try {
|
|
513
|
+
if (startAttempted) tui.stop();
|
|
514
|
+
} catch (error) {
|
|
515
|
+
try {
|
|
516
|
+
terminal.showCursor();
|
|
517
|
+
} catch {}
|
|
518
|
+
try {
|
|
519
|
+
terminal.stop();
|
|
520
|
+
} catch {}
|
|
521
|
+
throw error;
|
|
522
|
+
} finally {
|
|
523
|
+
resolveStopped();
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const shutdown = () => {
|
|
528
|
+
cleanup();
|
|
529
|
+
// The spoken line that closes Eclipse, the last thing on The Dark Side of the Moon.
|
|
530
|
+
try {
|
|
531
|
+
terminal.write(`\n ${theme.faint("\"There is no dark side of the moon, really. Matter of fact, it's all dark.\"")}\n\n`);
|
|
532
|
+
} catch {}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const terminate = (exitCode) => {
|
|
536
|
+
try {
|
|
537
|
+
activeRuntime.abort();
|
|
538
|
+
} finally {
|
|
539
|
+
signalTarget.exitCode = exitCode;
|
|
540
|
+
cleanup();
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
function handleSigterm() {
|
|
545
|
+
terminate(143);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function handleSighup() {
|
|
549
|
+
terminate(129);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function handleUncaughtFailure() {
|
|
553
|
+
try {
|
|
554
|
+
activeRuntime.abort();
|
|
555
|
+
} catch {}
|
|
556
|
+
try {
|
|
557
|
+
cleanup();
|
|
558
|
+
} catch {}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
signalTarget.once("SIGTERM", handleSigterm);
|
|
562
|
+
signalTarget.once("SIGHUP", handleSighup);
|
|
563
|
+
signalTarget.once("uncaughtExceptionMonitor", handleUncaughtFailure);
|
|
564
|
+
|
|
565
|
+
const choose = async (items, currentValue, title) => {
|
|
566
|
+
if (items.length === 0) return undefined;
|
|
567
|
+
const menu = new ListeningMenu({ items, currentValue, title, getTheme, getRows: () => terminal.rows });
|
|
568
|
+
return await new Promise((resolve) => {
|
|
569
|
+
let overlay;
|
|
570
|
+
const finish = (value) => {
|
|
571
|
+
overlay.hide();
|
|
572
|
+
if (profileView) tui.setFocus(profileView);
|
|
573
|
+
else editor.focused = !homeFocused;
|
|
574
|
+
tui.requestRender();
|
|
575
|
+
resolve(value);
|
|
576
|
+
};
|
|
577
|
+
menu.onSelect = (item) => finish(item);
|
|
578
|
+
menu.onCancel = () => finish(undefined);
|
|
579
|
+
overlay = tui.showOverlay(menu, {
|
|
580
|
+
width: "86%",
|
|
581
|
+
maxHeight: "100%",
|
|
582
|
+
anchor: "center",
|
|
583
|
+
margin: 1,
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const closeProfile = () => {
|
|
589
|
+
if (!profileView) return;
|
|
590
|
+
profileView = null;
|
|
591
|
+
profileSnapshot = null;
|
|
592
|
+
homeVisible = profileReturnHome;
|
|
593
|
+
homeFocused = false;
|
|
594
|
+
tui.setFocus(editor);
|
|
595
|
+
setFooter("Profile closed · /taste opens it again.");
|
|
596
|
+
tui.requestRender(true);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const refreshProfile = async () => {
|
|
600
|
+
const snapshot = await application.getProfileSummary({ maxItems: 10 });
|
|
601
|
+
if (cleanedUp || !profileView) return;
|
|
602
|
+
profileSnapshot = snapshot;
|
|
603
|
+
profileView.setModel(buildTasteProfileModel(snapshot));
|
|
604
|
+
profileNeedsRefresh = false;
|
|
605
|
+
void updateHomeLyrics();
|
|
606
|
+
renderHeader();
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const profileTask = async (action) => {
|
|
610
|
+
if (busy || !profileView) return;
|
|
611
|
+
setBusy(true);
|
|
612
|
+
try {
|
|
613
|
+
await action();
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (!cleanedUp) setFooter(`That didn't work: ${error.message}`, red);
|
|
616
|
+
} finally {
|
|
617
|
+
setBusy(false);
|
|
618
|
+
if (!cleanedUp && profileView && !tui.hasOverlay()) tui.setFocus(profileView);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const inspectProfileSubject = async (subject) => {
|
|
623
|
+
if (profileNeedsRefresh) {
|
|
624
|
+
await refreshProfile();
|
|
625
|
+
setFooter("Profile updated. Pick a song or artist.", green);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const evidenceItems = subject.evidence?.length ? subject.evidence
|
|
629
|
+
: subject.evidenceId ? [{ id: subject.evidenceId, source: "From your profile" }] : [];
|
|
630
|
+
const selected = await choose([
|
|
631
|
+
...(subject.target?.entityType === "track" && application.musicSimilarityReady?.() ? [{
|
|
632
|
+
value: "discover", label: "Find more like this", description: "Start a request from this track that you can edit",
|
|
633
|
+
}] : []),
|
|
634
|
+
...(evidenceItems.length ? [{ value: "evidence", label: "Why it's here", description: "Where this comes from and what it can't tell me" }] : []),
|
|
635
|
+
...(typeof runProfileAction === "function" ? [
|
|
636
|
+
{ value: "like", label: "I like this", description: "Tell Moondog you like this " + subject.kind },
|
|
637
|
+
{ value: "avoid", label: "Keep it out", description: "Leave this " + subject.kind + " out of suggestions" },
|
|
638
|
+
...(subject.correctionId ? [{ value: "retract", label: "Undo my choice", description: "Go back to what your listening says" }] : []),
|
|
639
|
+
] : []),
|
|
640
|
+
{ value: "back", label: "Back", description: "Keep looking around" },
|
|
641
|
+
], undefined, subject.label);
|
|
642
|
+
if (!selected || selected.value === "back" || cleanedUp || !profileView) return;
|
|
643
|
+
if (selected.value === "discover") {
|
|
644
|
+
const draft = editor.getText();
|
|
645
|
+
if (draft.trimStart().startsWith("/")) {
|
|
646
|
+
setFooter("You have a command half-typed. Finish or clear it first.", yellow);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const target = subject.target;
|
|
650
|
+
const context = `Track: ${JSON.stringify(sanitizeTerminalText(target.label))}\nArtist: ${JSON.stringify(sanitizeTerminalText(target.artistCredit))}`;
|
|
651
|
+
const text = profileDiscoveryDraft && draft.includes(profileDiscoveryDraft.context)
|
|
652
|
+
? draft.replace(profileDiscoveryDraft.context, () => context)
|
|
653
|
+
: `${draft || "Find me 3 songs to go to from here, and tell me why each one."}\n\n${context}`;
|
|
654
|
+
profileDiscoveryDraft = { context, target: { ...target } };
|
|
655
|
+
editor.setText(text);
|
|
656
|
+
profileReturnHome = false;
|
|
657
|
+
closeProfile();
|
|
658
|
+
enterConversation();
|
|
659
|
+
setFooter(runtimeStatus.state === "configured"
|
|
660
|
+
? "Request ready · edit it, then press Enter."
|
|
661
|
+
: "Request ready · /model connects a model first.", runtimeStatus.state === "configured" ? green : yellow);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
if (selected.value === "evidence") {
|
|
665
|
+
let evidence = evidenceItems[0];
|
|
666
|
+
if (evidenceItems.length > 1) {
|
|
667
|
+
const choice = await choose(evidenceItems.map((item) => ({
|
|
668
|
+
value: item.id, label: item.source, description: "From your profile",
|
|
669
|
+
})), undefined, "Which evidence?");
|
|
670
|
+
if (!choice || cleanedUp) return;
|
|
671
|
+
evidence = evidenceItems.find((item) => item.id === choice.value);
|
|
672
|
+
}
|
|
673
|
+
const explanation = await application.explainProfileEvidence({ evidenceId: evidence.id });
|
|
674
|
+
if (cleanedUp || !profileView) return;
|
|
675
|
+
const detailLines = [
|
|
676
|
+
"Why it's here", evidence.source, "",
|
|
677
|
+
explanation.basis_summary,
|
|
678
|
+
"", "What it can't tell me", explanation.interpretation_limit,
|
|
679
|
+
...(Number.isFinite(explanation.confidence) ? ["", `How sure: ${Math.round(explanation.confidence * 100)}%`] : []),
|
|
680
|
+
"", ...subject.detailLines,
|
|
681
|
+
].filter((line) => typeof line === "string").map(sanitizeTerminalText);
|
|
682
|
+
const model = buildTasteProfileModel(profileSnapshot);
|
|
683
|
+
model.subjects = model.subjects.map((item) => item.key === subject.key ? { ...item, detailLines } : item);
|
|
684
|
+
profileView.setModel(model);
|
|
685
|
+
setFooter("Here's the evidence. PgUp/PgDn to scroll, Enter for actions.");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const target = subject.target;
|
|
689
|
+
const args = selected.value === "retract" ? ["retract", subject.correctionId]
|
|
690
|
+
: ["correct", ...(target.entityType === "track"
|
|
691
|
+
? ["--track", target.label, "--by", target.artistCredit]
|
|
692
|
+
: ["--artist", target.label]), `--${selected.value}`];
|
|
693
|
+
setFooter("Saving your choice...", yellow);
|
|
694
|
+
await runProfileAction(args);
|
|
695
|
+
if (cleanedUp || !profileView) return;
|
|
696
|
+
const receipt = selected.value === "retract" ? `Undone: ${subject.label}`
|
|
697
|
+
: `${selected.value === "like" ? "Liked" : "Kept out"}: ${subject.label}`;
|
|
698
|
+
const named = `${subject.label}${subject.kind === "track" ? ` by ${target.artistCredit}` : ""}`;
|
|
699
|
+
addMoondogMessage(selected.value === "retract"
|
|
700
|
+
? `Done. I'll read ${named} from your listening again, without your earlier choice.`
|
|
701
|
+
: selected.value === "like"
|
|
702
|
+
? `Noted. You like ${named}.\n\nYour listening history stays as it was.`
|
|
703
|
+
: `Noted. I'll leave ${named} out of suggestions.\n\nYour listening history stays as it was.`);
|
|
704
|
+
profileReturnHome = false;
|
|
705
|
+
profileNeedsRefresh = true;
|
|
706
|
+
try {
|
|
707
|
+
await refreshProfile();
|
|
708
|
+
setFooter(`${receipt}.`, green);
|
|
709
|
+
} catch (error) {
|
|
710
|
+
setFooter(`${receipt}. The profile didn't refresh; Ctrl+R tries again.`, yellow);
|
|
711
|
+
}
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
const openProfile = async ({ imported = false } = {}) => {
|
|
715
|
+
if (application.profileServicesReady?.() === false) {
|
|
716
|
+
addMoondogMessage("## Is there anybody out there?\n\nNot yet. Your profile starts with your listening history.\n\nType `/import`, pick your music service, and bring in your history, your library, or a public playlist. Then come back here to see what it says about you, and tell me what I got wrong.\n\nEverything stays on this machine. You don't need a model or a Spotify sign-in.");
|
|
717
|
+
setFooter("No profile yet. /import brings in your history.");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (typeof application.getProfileSummary !== "function") {
|
|
721
|
+
addMoondogMessage(formatLocalResult("taste", await application.runLocalCommand("taste", runtimeStatus)));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const snapshot = await application.getProfileSummary({ maxItems: 10 });
|
|
725
|
+
if (cleanedUp) return;
|
|
726
|
+
profileSnapshot = snapshot;
|
|
727
|
+
profileNeedsRefresh = false;
|
|
728
|
+
profileReturnHome = homeVisible;
|
|
729
|
+
profileView = new TasteProfileView({
|
|
730
|
+
model: buildTasteProfileModel(snapshot), getTheme,
|
|
731
|
+
getRows: () => Math.max(1, terminal.rows - 2 - (terminal.rows >= 20 ? 2 : 1)),
|
|
732
|
+
});
|
|
733
|
+
profileView.onSelect = (subject) => { void profileTask(() => inspectProfileSubject(subject)); };
|
|
734
|
+
profileView.onClose = closeProfile;
|
|
735
|
+
profileView.onRefresh = () => { void profileTask(async () => {
|
|
736
|
+
await refreshProfile();
|
|
737
|
+
setFooter("Profile updated.", green);
|
|
738
|
+
}); };
|
|
739
|
+
profileView.onReport = () => {
|
|
740
|
+
const snapshot = profileSnapshot;
|
|
741
|
+
closeProfile();
|
|
742
|
+
addMoondogMessage(formatLocalResult("taste", snapshot));
|
|
743
|
+
setFooter("Full report above. /taste opens the profile again.");
|
|
744
|
+
};
|
|
745
|
+
homeFocused = false;
|
|
746
|
+
tui.setFocus(profileView);
|
|
747
|
+
setFooter(imported ? "Imported. Here's your profile with everything so far." : "Pick a song or artist. Enter shows why it's here.", green);
|
|
748
|
+
tui.requestRender(true);
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const replaceRuntime = async (selection) => {
|
|
752
|
+
if (typeof rebuildRuntime !== "function") {
|
|
753
|
+
throw new Error("Runtime rebuilding is unavailable in this launch mode.");
|
|
754
|
+
}
|
|
755
|
+
const nextRuntime = await rebuildRuntime(selection);
|
|
756
|
+
const nextStatus = nextRuntime.publicStatus();
|
|
757
|
+
const previousRuntime = activeRuntime;
|
|
758
|
+
activeRuntime = nextRuntime;
|
|
759
|
+
runtimeStatus = nextStatus;
|
|
760
|
+
try {
|
|
761
|
+
previousRuntime.reset();
|
|
762
|
+
} catch {}
|
|
763
|
+
renderHeader();
|
|
764
|
+
tui.requestRender(true);
|
|
765
|
+
return nextStatus;
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
const selectModel = async (args) => {
|
|
769
|
+
if (args.length > 2) {
|
|
770
|
+
throw new Error("Usage: /model [provider] [model].");
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const availableProviders = providers()
|
|
774
|
+
.filter((provider) => provider.modelCount > 0)
|
|
775
|
+
.sort((left, right) => {
|
|
776
|
+
const priority = (provider) => {
|
|
777
|
+
if (provider.id === runtimeStatus.provider) return 0;
|
|
778
|
+
if (provider.id === "openai-codex") return 1;
|
|
779
|
+
return 2;
|
|
780
|
+
};
|
|
781
|
+
return (
|
|
782
|
+
priority(left) - priority(right) ||
|
|
783
|
+
left.name.localeCompare(right.name)
|
|
784
|
+
);
|
|
785
|
+
});
|
|
786
|
+
let providerId = args[0]?.toLowerCase();
|
|
787
|
+
let modelId = args[1];
|
|
788
|
+
while (true) {
|
|
789
|
+
if (providerId) {
|
|
790
|
+
if (!availableProviders.some((provider) => provider.id === providerId)) {
|
|
791
|
+
throw new Error(`Unknown Pi provider: ${providerId}`);
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
const selectedProvider = await choose(
|
|
795
|
+
availableProviders.map((provider) => ({
|
|
796
|
+
value: provider.id, label: provider.id,
|
|
797
|
+
description: `${provider.name} - ${provider.modelCount} models`,
|
|
798
|
+
})),
|
|
799
|
+
runtimeStatus.provider,
|
|
800
|
+
"Choose a model provider",
|
|
801
|
+
);
|
|
802
|
+
if (!selectedProvider) {
|
|
803
|
+
setFooter("Model unchanged.", yellow);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
providerId = selectedProvider.value;
|
|
807
|
+
}
|
|
808
|
+
const availableModels = models(providerId);
|
|
809
|
+
if (modelId) {
|
|
810
|
+
if (!availableModels.some((model) => model.id === modelId)) {
|
|
811
|
+
throw new Error(`Unknown ${providerId} model: ${modelId}`);
|
|
812
|
+
}
|
|
813
|
+
} else {
|
|
814
|
+
const selectedModel = await choose(
|
|
815
|
+
availableModels.map((model) => ({
|
|
816
|
+
value: model.id, label: model.id,
|
|
817
|
+
description: `${model.name}${model.reasoning ? " - reasoning" : ""}`,
|
|
818
|
+
})),
|
|
819
|
+
runtimeStatus.provider === providerId ? runtimeStatus.model : undefined,
|
|
820
|
+
`Choose a model for ${providerId}`,
|
|
821
|
+
);
|
|
822
|
+
if (!selectedModel) {
|
|
823
|
+
if (!args[0] && availableModels.length && !cleanedUp) {
|
|
824
|
+
providerId = undefined;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
setFooter("Model unchanged.", yellow);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
modelId = selectedModel.value;
|
|
831
|
+
}
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
setFooter(`Loading ${providerId}/${modelId}...`, yellow);
|
|
836
|
+
const status = await replaceRuntime({ provider: providerId, model: modelId });
|
|
837
|
+
addMoondogMessage(
|
|
838
|
+
status.state === "configured"
|
|
839
|
+
? `Now talking through \`${providerId}/${modelId}\`. This conversation and what I remember carry over.`
|
|
840
|
+
: status.reason === "provider_authentication_required"
|
|
841
|
+
? `Saved \`${providerId}/${modelId}\`, but you're not signed in yet. Run \`/auth ${providerId}\` to connect it.`
|
|
842
|
+
: `Saved \`${providerId}/${modelId}\`, but I can't reach it yet (\`${status.reason}\`).`,
|
|
843
|
+
);
|
|
844
|
+
setFooter(
|
|
845
|
+
status.state === "configured" ? "Model ready." : "Model saved, not connected yet.",
|
|
846
|
+
status.state === "configured" ? green : yellow,
|
|
847
|
+
);
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const authenticate = async (args) => {
|
|
851
|
+
if (args.length > 1) {
|
|
852
|
+
throw new Error("Use /auth or /auth <provider>. Type API keys only into the hidden prompt that follows.");
|
|
853
|
+
}
|
|
854
|
+
if (typeof runAuth !== "function") {
|
|
855
|
+
throw new Error("Sign-in isn't available when Moondog is started this way.");
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const availableProviders = providers().filter((provider) =>
|
|
859
|
+
supportedAuthProviderIds.includes(provider.id),
|
|
860
|
+
);
|
|
861
|
+
let providerId = args[0]?.toLowerCase() ?? runtimeStatus.provider;
|
|
862
|
+
if (!providerId || !supportedAuthProviderIds.includes(providerId)) {
|
|
863
|
+
if (args[0]) throw new Error("I can't sign in to that provider. Type /auth to see the ones I can.");
|
|
864
|
+
const selectedProvider = await choose(
|
|
865
|
+
availableProviders.map((provider) => ({
|
|
866
|
+
value: provider.id,
|
|
867
|
+
label: provider.name,
|
|
868
|
+
description: `${provider.id} - ${provider.id === "openai-codex" ? "ChatGPT sign-in" : "API key"}`,
|
|
869
|
+
})),
|
|
870
|
+
runtimeStatus.provider ?? "openai-codex",
|
|
871
|
+
"Sign in to a model provider",
|
|
872
|
+
);
|
|
873
|
+
if (!selectedProvider) {
|
|
874
|
+
setFooter("Sign-in cancelled.", yellow);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
providerId = selectedProvider.value;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
setFooter(`Opening ${providerId} sign-in...`, yellow);
|
|
881
|
+
tui.stop({ preserveScreen: true });
|
|
882
|
+
try {
|
|
883
|
+
await runAuth(providerId);
|
|
884
|
+
} finally {
|
|
885
|
+
if (!cleanedUp) {
|
|
886
|
+
tui.start();
|
|
887
|
+
// Pi may retain a queued render from before the authentication pause.
|
|
888
|
+
tui.requestRender(true);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (cleanedUp) return;
|
|
892
|
+
|
|
893
|
+
if (typeof rebuildRuntime === "function") {
|
|
894
|
+
const currentSelection = runtimeStatus.provider && runtimeStatus.model
|
|
895
|
+
? { provider: runtimeStatus.provider, model: runtimeStatus.model }
|
|
896
|
+
: undefined;
|
|
897
|
+
const status = await replaceRuntime(currentSelection);
|
|
898
|
+
addMoondogMessage(
|
|
899
|
+
status.state === "configured"
|
|
900
|
+
? `Signed in. Talking through \`${status.provider}/${status.model}\`.`
|
|
901
|
+
: `Signed in. Run \`/model ${providerId}\` to pick a model.`,
|
|
902
|
+
);
|
|
903
|
+
} else {
|
|
904
|
+
addMoondogMessage("Signed in. Restart Moondog to use it.");
|
|
905
|
+
}
|
|
906
|
+
setFooter("Signed in.", green);
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
const showImportedListeningProfile = async () => {
|
|
910
|
+
void updateHomeLyrics();
|
|
911
|
+
try {
|
|
912
|
+
const profile = await application.runLocalCommand("taste", runtimeStatus);
|
|
913
|
+
addMoondogMessage([
|
|
914
|
+
"Here's everything I can read so far, including anything you imported before.",
|
|
915
|
+
formatLocalResult("taste", profile),
|
|
916
|
+
"Type `/taste` to open any song or artist and see why it's here. `/taste report` brings this report back.",
|
|
917
|
+
].join("\n\n"));
|
|
918
|
+
if (typeof application.getProfileSummary === "function") await openProfile({ imported: true });
|
|
919
|
+
else setFooter("Imported. /taste opens your profile.", green);
|
|
920
|
+
} catch (error) {
|
|
921
|
+
addMoondogMessage(
|
|
922
|
+
`Your history was imported, but I couldn't show the profile: ${error.message}\n\nNothing was lost. Type \`/taste\` to try again.`,
|
|
923
|
+
);
|
|
924
|
+
setFooter("Imported. The profile didn't open; try /taste.", yellow);
|
|
925
|
+
}
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
const discardPreparedImport = () => {
|
|
929
|
+
preparedImport?.close?.();
|
|
930
|
+
preparedImport = null;
|
|
931
|
+
};
|
|
932
|
+
|
|
933
|
+
const setImportPage = (page, details = {}) => {
|
|
934
|
+
importPage = page;
|
|
935
|
+
importView?.setState({ page, origin: importOrigin, provider: importProvider, spotify: application.spotifyStatus?.(), ...details });
|
|
936
|
+
tui.requestRender(true);
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const closeImport = ({ restore = true } = {}) => {
|
|
940
|
+
discardPreparedImport();
|
|
941
|
+
importView = null;
|
|
942
|
+
if (restore) homeVisible = importReturnHome;
|
|
943
|
+
homeFocused = false;
|
|
944
|
+
tui.setFocus(profileView ?? editor);
|
|
945
|
+
setFooter("Import closed. Nothing changed.");
|
|
946
|
+
tui.requestRender(true);
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
const inspectImport = async (filePath) => {
|
|
950
|
+
discardPreparedImport();
|
|
951
|
+
importOrigin = "file";
|
|
952
|
+
const view = importView;
|
|
953
|
+
view.setPath(filePath);
|
|
954
|
+
setBusy(true);
|
|
955
|
+
setImportPage("working", { message: "Reading your file..." });
|
|
956
|
+
setFooter("Reading the file. Nothing is added until you say so.", yellow);
|
|
957
|
+
try {
|
|
958
|
+
if (typeof prepareImport !== "function") throw new Error("Reading files isn't available when Moondog is started this way.");
|
|
959
|
+
const prepared = await prepareImport(filePath, { provider: importProvider });
|
|
960
|
+
if (cleanedUp || importView !== view) { prepared.close?.(); return; }
|
|
961
|
+
if (!importProvider) {
|
|
962
|
+
importProvider = prepared.preview.kind === "library" ? "apple" : prepared.provider === "listenbrainz" ? "other" : ["youtube_music", "qq_music", "netease"].includes(prepared.provider) ? prepared.provider : "spotify";
|
|
963
|
+
importFileReturn = { apple: "appleQuick", other: "other", youtube_music: "youtube_music", qq_music: "qq_music", netease: "netease" }[importProvider] ?? "spotifyHistory";
|
|
964
|
+
}
|
|
965
|
+
preparedImport = prepared;
|
|
966
|
+
setImportPage("preview", { preview: prepared.preview });
|
|
967
|
+
setFooter("Here's what's inside. Nothing is added until you import it.", green);
|
|
968
|
+
} catch (error) {
|
|
969
|
+
if (!cleanedUp && importView === view) {
|
|
970
|
+
setImportPage("file", { error: sanitizeTerminalText(error.message) });
|
|
971
|
+
setFooter("Nothing imported. Your path is still there to fix.", yellow);
|
|
972
|
+
}
|
|
973
|
+
} finally {
|
|
974
|
+
setBusy(false);
|
|
975
|
+
if (!cleanedUp && importView === view) tui.setFocus(view);
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const inspectRecentListening = async () => {
|
|
980
|
+
discardPreparedImport();
|
|
981
|
+
importOrigin = "quick";
|
|
982
|
+
const view = importView;
|
|
983
|
+
setBusy(true);
|
|
984
|
+
setImportPage("working", { message: "Asking Spotify for your recent listening..." });
|
|
985
|
+
setFooter("Reading from Spotify. Nothing is added until you say so.", yellow);
|
|
986
|
+
try {
|
|
987
|
+
if (typeof prepareRecentImport !== "function") throw new Error("Recent listening isn't available when Moondog is started this way. A history ZIP still works.");
|
|
988
|
+
const prepared = await prepareRecentImport();
|
|
989
|
+
if (cleanedUp || importView !== view) { prepared.close?.(); return; }
|
|
990
|
+
if (!prepared.preview.listeningEvents) {
|
|
991
|
+
prepared.close?.();
|
|
992
|
+
setImportPage("empty");
|
|
993
|
+
setFooter("Nothing to import yet. Try again later, or bring a ZIP.");
|
|
994
|
+
} else {
|
|
995
|
+
preparedImport = prepared;
|
|
996
|
+
setImportPage("preview", { preview: prepared.preview });
|
|
997
|
+
setFooter("Here's your recent listening. Nothing is added until you import it.", green);
|
|
998
|
+
}
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
if (cleanedUp || importView !== view) return;
|
|
1001
|
+
const messages = {
|
|
1002
|
+
spotify_action_forbidden: "Spotify said no. Make sure this account is listed under User Management in your Spotify app, and that the app owner has Premium. A history ZIP works either way.",
|
|
1003
|
+
spotify_scope_insufficient: "Spotify needs one more permission. Reconnect Spotify to allow recent listening.",
|
|
1004
|
+
spotify_authentication_required: "Your Spotify sign-in has expired. Choose Reconnect Spotify.",
|
|
1005
|
+
spotify_quota_exceeded: "Your Spotify app has used up its quota for now. A downloaded history ZIP still works.",
|
|
1006
|
+
spotify_rate_limited: "Spotify wants us to slow down. Wait a minute and try again, or use a history ZIP.",
|
|
1007
|
+
};
|
|
1008
|
+
setImportPage("quick", { error: messages[error.code] ?? sanitizeTerminalText(error.message) });
|
|
1009
|
+
setFooter("Nothing imported. Your profile is unchanged.", yellow);
|
|
1010
|
+
} finally {
|
|
1011
|
+
setBusy(false);
|
|
1012
|
+
if (!cleanedUp && importView === view) tui.setFocus(view);
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const connectImportSpotify = async (clientId) => {
|
|
1017
|
+
const view = importView;
|
|
1018
|
+
const configuring = clientId !== undefined;
|
|
1019
|
+
setBusy(true);
|
|
1020
|
+
setImportPage("working", { message: configuring ? "Saving your Spotify app..." : "Finish signing in to Spotify in your browser. Ctrl+C cancels." });
|
|
1021
|
+
importAuthController = new AbortController();
|
|
1022
|
+
if (!configuring) tui.stop({ preserveScreen: true });
|
|
1023
|
+
try {
|
|
1024
|
+
if (typeof runSpotify !== "function") throw new Error("Spotify isn't available when Moondog is started this way.");
|
|
1025
|
+
await runSpotify(configuring ? ["configure", clientId] : ["login", "--history-only"], { signal: importAuthController.signal });
|
|
1026
|
+
if (cleanedUp || importView !== view) return;
|
|
1027
|
+
if (typeof rebuildRuntime === "function") await replaceRuntime();
|
|
1028
|
+
if (cleanedUp || importView !== view) return;
|
|
1029
|
+
setImportPage("quick", { message: configuring ? "App saved. Now connect Spotify." : "Spotify is connected. Next, preview your recent listening." });
|
|
1030
|
+
setFooter("Connected. Nothing imported yet.", green);
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
if (!cleanedUp && importView === view) {
|
|
1033
|
+
setImportPage(configuring ? "client" : "quick", { error: sanitizeTerminalText(error.message) });
|
|
1034
|
+
setFooter("Nothing imported. Try again, or use a history ZIP.", yellow);
|
|
1035
|
+
}
|
|
1036
|
+
} finally {
|
|
1037
|
+
importAuthController = null;
|
|
1038
|
+
if (!configuring && !cleanedUp) { tui.start(); tui.requestRender(true); }
|
|
1039
|
+
setBusy(false);
|
|
1040
|
+
if (!cleanedUp && importView === view) tui.setFocus(view);
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
const commitInspectedImport = async () => {
|
|
1045
|
+
if (!preparedImport) return;
|
|
1046
|
+
const pending = preparedImport;
|
|
1047
|
+
const preview = pending.preview;
|
|
1048
|
+
const view = importView;
|
|
1049
|
+
let saved = false;
|
|
1050
|
+
setBusy(true);
|
|
1051
|
+
setImportPage("working", { message: "Adding it to your profile..." });
|
|
1052
|
+
setFooter("Saving your history on this machine...", yellow);
|
|
1053
|
+
try {
|
|
1054
|
+
const receipt = await pending.commit();
|
|
1055
|
+
saved = true;
|
|
1056
|
+
importNeedsRefresh = receipt;
|
|
1057
|
+
discardPreparedImport();
|
|
1058
|
+
if (cleanedUp) return;
|
|
1059
|
+
const counts = preview.kind === "library"
|
|
1060
|
+
? receipt.already_imported ? "You've imported this library before, so nothing changed."
|
|
1061
|
+
: `Saved ${receipt.library_tracks ?? preview.tracks} songs from your library. A library shows what you keep, not when you played it, so no plays were added.`
|
|
1062
|
+
: receipt.already_imported || (receipt.inserted_events === 0 && !receipt.inserted_profile_evidence && !receipt.superseded_events)
|
|
1063
|
+
? "Already up to date. There was nothing new in this one."
|
|
1064
|
+
: `${receipt.inserted_events ?? 0} new ${receipt.inserted_events === 1 ? "play" : "plays"}${receipt.duplicate_events ? `, ${receipt.duplicate_events} I already had` : ""}.${receipt.inserted_profile_evidence ? ` Also ${receipt.inserted_profile_evidence} saved songs, follows, or playlist entries.` : ""}`;
|
|
1065
|
+
// Keep the durable result visible even if refreshing the runtime later fails.
|
|
1066
|
+
addMoondogMessage([
|
|
1067
|
+
preview.kind === "library" ? "## Your Apple Music library is in" : preview.kind === "collection" ? "## Your collection is in" : "## Your history is in",
|
|
1068
|
+
[preview.sourceLabel, preview.fileName].filter(Boolean).join(" · "),
|
|
1069
|
+
counts,
|
|
1070
|
+
...(receipt.superseded_events ? [`${receipt.superseded_events} plays you already had were replaced with more detailed versions.`] : []),
|
|
1071
|
+
"Everything from before is still here, including your choices. `/import` adds another source any time.",
|
|
1072
|
+
].join("\n\n"));
|
|
1073
|
+
setImportPage("working", { message: "Saved. Reading it..." });
|
|
1074
|
+
setFooter("History saved. Updating your profile...", yellow);
|
|
1075
|
+
await refreshImportedData?.(importNeedsRefresh);
|
|
1076
|
+
if (cleanedUp) return;
|
|
1077
|
+
if (typeof rebuildRuntime === "function") await replaceRuntime();
|
|
1078
|
+
if (cleanedUp) return;
|
|
1079
|
+
importNeedsRefresh = false;
|
|
1080
|
+
importView = null;
|
|
1081
|
+
homeVisible = false;
|
|
1082
|
+
await showImportedListeningProfile();
|
|
1083
|
+
} catch (error) {
|
|
1084
|
+
if (cleanedUp) return;
|
|
1085
|
+
if (saved) {
|
|
1086
|
+
importView = null;
|
|
1087
|
+
homeVisible = false;
|
|
1088
|
+
profileView = null;
|
|
1089
|
+
tui.setFocus(editor);
|
|
1090
|
+
addMoondogMessage(`Your history is saved, but I couldn't update the profile: ${sanitizeTerminalText(error.message)}\n\nNothing was lost. Try \`/reload\`, then \`/taste\`.`);
|
|
1091
|
+
setFooter("History saved. The profile needs another try.", yellow);
|
|
1092
|
+
} else {
|
|
1093
|
+
setImportPage("preview", { preview, error: sanitizeTerminalText(error.message) });
|
|
1094
|
+
setFooter("Not saved. See the message above.", yellow);
|
|
1095
|
+
}
|
|
1096
|
+
} finally {
|
|
1097
|
+
setBusy(false);
|
|
1098
|
+
if (!cleanedUp) tui.setFocus(importView === view ? view : profileView ?? editor);
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
const handleImportAction = async ({ type, path: filePath, clientId } = {}) => {
|
|
1103
|
+
if (busy || cleanedUp || !importView) return;
|
|
1104
|
+
if (type === "inspect") { await inspectImport(filePath); return; }
|
|
1105
|
+
if (type === "commit") { await commitInspectedImport(); return; }
|
|
1106
|
+
if (type === "recent") { await inspectRecentListening(); return; }
|
|
1107
|
+
if (type === "connectSpotify" || type === "configureSpotify") {
|
|
1108
|
+
await connectImportSpotify(type === "configureSpotify" ? clientId : undefined);
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (type === "close") { closeImport(); return; }
|
|
1112
|
+
if (type === "profile") {
|
|
1113
|
+
closeImport();
|
|
1114
|
+
await openProfile();
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (["openSpotify", "openSpotifySetup", "openAppleHelp", "openApplePrivacy", "openYouTube", "openQQ", "openNetEase"].includes(type)) {
|
|
1118
|
+
const destination = { openSpotify: "spotify", openSpotifySetup: "spotifySetup", openAppleHelp: "apple", openApplePrivacy: "applePrivacy", openYouTube: "youtube", openQQ: "qq", openNetEase: "netease" }[type];
|
|
1119
|
+
const url = IMPORT_GUIDE_URLS[destination];
|
|
1120
|
+
try {
|
|
1121
|
+
if (typeof openImportHelp !== "function") throw new Error("Can't open a browser from here.");
|
|
1122
|
+
await openImportHelp(destination);
|
|
1123
|
+
setFooter("Opened in your browser. Moondog will be here when you're back.");
|
|
1124
|
+
} catch {
|
|
1125
|
+
setImportPage(importPage, { error: `I couldn't open your browser. Here's the link: ${url}` });
|
|
1126
|
+
}
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (type === "back") {
|
|
1130
|
+
if (importPage === "start") { closeImport(); return; }
|
|
1131
|
+
if (importPage === "preview") {
|
|
1132
|
+
discardPreparedImport();
|
|
1133
|
+
setImportPage(importOrigin === "quick" ? "quick" : "file");
|
|
1134
|
+
} else setImportPage({ waiting: "spotifyHistory", file: importFileReturn, setup: "quick", client: "setup", empty: "quick", quick: "spotify", spotifyHistory: "spotify", appleQuick: "apple", appleHistory: "apple", youtubeQuick: "youtube_music", youtubeHistory: "youtube_music" }[importPage] ?? "start");
|
|
1135
|
+
} else if (["start", "file", "spotify", "waiting", "other", "quick", "setup", "client", "spotifyHistory", "apple", "appleQuick", "appleHistory", "youtube_music", "youtubeQuick", "youtubeHistory", "qq_music", "netease"].includes(type)) {
|
|
1136
|
+
discardPreparedImport();
|
|
1137
|
+
if (["spotify", "apple", "other", "youtube_music", "qq_music", "netease"].includes(type)) importProvider = type;
|
|
1138
|
+
if (type === "file" && !["preview", "file"].includes(importPage)) importFileReturn = importPage;
|
|
1139
|
+
setImportPage(type);
|
|
1140
|
+
}
|
|
1141
|
+
setFooter(importPage === "file"
|
|
1142
|
+
? "Paste or drag in a file. Tab completes the path, Enter reads it."
|
|
1143
|
+
: "Pick a step.");
|
|
1144
|
+
tui.setFocus(importView);
|
|
1145
|
+
};
|
|
1146
|
+
|
|
1147
|
+
const openImport = async (filePath) => {
|
|
1148
|
+
importProvider = null;
|
|
1149
|
+
importFileReturn = "start";
|
|
1150
|
+
if (!importView) {
|
|
1151
|
+
importReturnHome = homeVisible;
|
|
1152
|
+
homeFocused = false;
|
|
1153
|
+
importView = new HistoryImportView({
|
|
1154
|
+
tui, getTheme,
|
|
1155
|
+
getRows: () => Math.max(1, terminal.rows - 2 - (terminal.rows >= 20 ? 2 : 1)),
|
|
1156
|
+
profileReady: application.profileServicesReady?.() ?? false,
|
|
1157
|
+
onAction: (action) => {
|
|
1158
|
+
void handleImportAction(action).catch((error) => {
|
|
1159
|
+
if (!cleanedUp) setFooter(`That didn't work: ${error.message}`, red);
|
|
1160
|
+
});
|
|
1161
|
+
},
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
setImportPage("start");
|
|
1165
|
+
tui.setFocus(importView);
|
|
1166
|
+
setFooter("Welcome to the machine. Pick your music service.");
|
|
1167
|
+
if (filePath) await inspectImport(filePath);
|
|
1168
|
+
};
|
|
1169
|
+
|
|
1170
|
+
const runSpotifyCommand = async (args) => {
|
|
1171
|
+
if (typeof runSpotify !== "function") {
|
|
1172
|
+
throw new Error("Spotify isn't available when Moondog is started this way.");
|
|
1173
|
+
}
|
|
1174
|
+
const action = args[0]?.toLowerCase() ?? "help";
|
|
1175
|
+
const leavesTui = action === "login";
|
|
1176
|
+
setFooter(`Spotify ${action}...`, yellow);
|
|
1177
|
+
if (leavesTui) tui.stop({ preserveScreen: true });
|
|
1178
|
+
let markdown;
|
|
1179
|
+
try {
|
|
1180
|
+
markdown = await runSpotify(args);
|
|
1181
|
+
} finally {
|
|
1182
|
+
if (leavesTui && !cleanedUp) {
|
|
1183
|
+
tui.start();
|
|
1184
|
+
tui.requestRender(true);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (cleanedUp) return;
|
|
1188
|
+
|
|
1189
|
+
if (
|
|
1190
|
+
["configure", "login", "logout", "import-history", "sync-recent"].includes(action) &&
|
|
1191
|
+
typeof rebuildRuntime === "function"
|
|
1192
|
+
) {
|
|
1193
|
+
await replaceRuntime();
|
|
1194
|
+
} else {
|
|
1195
|
+
renderHeader();
|
|
1196
|
+
}
|
|
1197
|
+
addMoondogMessage(markdown || "Done.");
|
|
1198
|
+
if (action === "import-history") {
|
|
1199
|
+
await showImportedListeningProfile();
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
setFooter(`Spotify ${action}: done.`, green);
|
|
1203
|
+
};
|
|
1204
|
+
|
|
1205
|
+
const resumeConversation = async (args) => {
|
|
1206
|
+
if (args.length > 1) throw new Error("Use /resume, or /resume <conversation-id>.");
|
|
1207
|
+
if (typeof application.listSavedSessions !== "function" ||
|
|
1208
|
+
typeof application.resumeSession !== "function" ||
|
|
1209
|
+
typeof activeRuntime.restoreSession !== "function") {
|
|
1210
|
+
throw new Error("Saved conversations aren't available when Moondog is started this way.");
|
|
1211
|
+
}
|
|
1212
|
+
const currentId = application.ensureMemorySession?.()?.session_id;
|
|
1213
|
+
let selected = args[0] ? { value: args[0] } : undefined;
|
|
1214
|
+
if (!selected) {
|
|
1215
|
+
const sessions = application.listSavedSessions({ limit: 200 });
|
|
1216
|
+
if (!sessions.length) {
|
|
1217
|
+
setFooter("No saved conversations yet.");
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const formatTime = new Intl.DateTimeFormat(undefined, {
|
|
1221
|
+
year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
|
1222
|
+
});
|
|
1223
|
+
const items = sessions.map((session) => ({
|
|
1224
|
+
value: session.session_id,
|
|
1225
|
+
label: sanitizeTerminalText(session.title).replace(/\s+/gu, " ").trim() || "Untitled",
|
|
1226
|
+
description: `${formatTime.format(new Date(session.updated_at))} · ${session.turn_count} ${session.turn_count === 1 ? "message" : "messages"}${session.session_id === currentId ? " · open now" : ""}`,
|
|
1227
|
+
}));
|
|
1228
|
+
selected = await choose(items, currentId, "Pick up where you left off");
|
|
1229
|
+
if (cleanedUp) return;
|
|
1230
|
+
if (!selected) {
|
|
1231
|
+
setFooter("Staying here.");
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (selected.value === currentId) {
|
|
1236
|
+
setFooter("You're already in that one.");
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
application.resumeSession(selected.value);
|
|
1240
|
+
activeRuntime.restoreSession();
|
|
1241
|
+
const turns = application.currentSessionTurns({ limit: 40 });
|
|
1242
|
+
transcript.clear();
|
|
1243
|
+
homeVisible = turns.length === 0;
|
|
1244
|
+
homeFocused = false;
|
|
1245
|
+
editor.focused = true;
|
|
1246
|
+
for (const turn of turns) {
|
|
1247
|
+
if (turn.role === "user") addUserMessage(sanitizeTerminalText(turn.text));
|
|
1248
|
+
if (turn.role === "assistant") addMoondogMessage(turn.text);
|
|
1249
|
+
}
|
|
1250
|
+
const title = selected.label ?? turns.find((turn) => turn.role === "user")?.text ?? "Saved conversation";
|
|
1251
|
+
setFooter(`Back in: ${title.replace(/\s+/gu, " ")}`, green);
|
|
1252
|
+
tui.requestRender(true);
|
|
1253
|
+
};
|
|
1254
|
+
|
|
1255
|
+
const runLocalCommand = async (command, args = []) => {
|
|
1256
|
+
if (command === "lyrics") {
|
|
1257
|
+
if (args.length > 1 || (args[0] && args[0] !== "sync")) throw new Error("Use /lyrics, or /lyrics sync.");
|
|
1258
|
+
if (!lyrics) throw new Error("The lyric library isn't available when Moondog is started this way.");
|
|
1259
|
+
if (args[0] === "sync") await updateHomeLyrics({ signal: localCommandController?.signal });
|
|
1260
|
+
if (cleanedUp) return;
|
|
1261
|
+
const status = lyrics.status();
|
|
1262
|
+
addMoondogMessage([
|
|
1263
|
+
"## Your lyric library", "",
|
|
1264
|
+
`${status.ready} ${status.ready === 1 ? "song" : "songs"} with lyrics · ${status.instrumental} ${status.instrumental === 1 ? "instrumental" : "instrumentals"} · ${status.entries} looked up`,
|
|
1265
|
+
`Stored at ${status.path}`,
|
|
1266
|
+
`Syncing: ${status.refresh.state}`,
|
|
1267
|
+
...(lyricError ? [`Last problem: ${lyricError}`] : []),
|
|
1268
|
+
...(homeLyric ? ["", `On the sleeve now: ${homeLyric.title} · ${homeLyric.artist}`, `Lyrics from ${homeLyric.sourceUrl}`] : []),
|
|
1269
|
+
"", "`/lyrics sync` looks up more songs from your profile. `/home` takes you back to the sleeve.",
|
|
1270
|
+
].join("\n"));
|
|
1271
|
+
setFooter("Lyric library · /home to go back.");
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (command === "taste" || (command === "profile" && !args.length)) {
|
|
1275
|
+
if (args.length > 1 || (args[0] && args[0] !== "report")) throw new Error("Use /taste, or /taste report.");
|
|
1276
|
+
if (args[0] === "report") {
|
|
1277
|
+
addMoondogMessage(formatLocalResult("taste", await application.runLocalCommand("taste", runtimeStatus)));
|
|
1278
|
+
setFooter("Full report above. /taste opens the profile.");
|
|
1279
|
+
} else await openProfile();
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (command === "resume") {
|
|
1283
|
+
await resumeConversation(args);
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (command === "web") {
|
|
1287
|
+
if (typeof runWeb !== "function") throw new Error("Web lookups aren't available when Moondog is started this way.");
|
|
1288
|
+
setFooter("Looking it up with Codex... Ctrl+C cancels.", yellow);
|
|
1289
|
+
addMoondogMessage(await runWeb(args, { signal: localCommandController.signal }));
|
|
1290
|
+
setFooter("Done.", green);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
if (command === "home") {
|
|
1294
|
+
if (args.length) throw new Error("/home doesn't take anything after it.");
|
|
1295
|
+
homeVisible = true;
|
|
1296
|
+
homeFocused = false;
|
|
1297
|
+
sleeve.invalidate();
|
|
1298
|
+
setFooter("Say anything, or press Tab to look around.");
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
if (command === "theme") {
|
|
1302
|
+
if (args.length > 1 || (args[0] && !["auto", "paper", "charcoal", "terminal"].includes(args[0]))) {
|
|
1303
|
+
throw new Error("Use /theme, or /theme paper, charcoal, terminal, or auto.");
|
|
1304
|
+
}
|
|
1305
|
+
let mode = args[0];
|
|
1306
|
+
if (!mode) {
|
|
1307
|
+
const selected = await choose([
|
|
1308
|
+
{ value: "paper", label: "Paper", description: "Warm ivory and charcoal ink" },
|
|
1309
|
+
{ value: "charcoal", label: "Charcoal", description: "A quiet room, warm light" },
|
|
1310
|
+
{ value: "terminal", label: "Terminal", description: "Keep your terminal's colors" },
|
|
1311
|
+
{ value: "auto", label: "Auto", description: "Follow MOONDOG_THEME, or guess from your terminal" },
|
|
1312
|
+
], theme.mode, "Pick a look");
|
|
1313
|
+
mode = selected?.value;
|
|
1314
|
+
}
|
|
1315
|
+
if (!mode) { setFooter("Theme unchanged."); return; }
|
|
1316
|
+
theme = createMoondogTheme({ mode, environment });
|
|
1317
|
+
transcript.invalidate();
|
|
1318
|
+
sleeve.invalidate();
|
|
1319
|
+
setFooter(`${theme.mode === "paper" ? "Paper" : theme.mode === "charcoal" ? "Charcoal" : "Terminal"} theme, for this session.`);
|
|
1320
|
+
tui.requestRender(true);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (command === "art") {
|
|
1324
|
+
if (args.length > 1 || (args[0] && !["auto", "braille", "ascii", "off", "text"].includes(args[0]))) {
|
|
1325
|
+
throw new Error("Use /art, or /art braille, ascii, off, or auto.");
|
|
1326
|
+
}
|
|
1327
|
+
sleeve.setArtMode(args[0] ?? (sleeve.artMode === "ascii" ? "braille" : "ascii"));
|
|
1328
|
+
homeVisible = true;
|
|
1329
|
+
setFooter(sleeve.artMode === "off" ? "A quiet opening, no artwork." : `${sleeve.artMode === "ascii" || sleeve.artMode === "text" ? "ASCII" : "Braille"} artwork.`);
|
|
1330
|
+
return;
|
|
1331
|
+
}
|
|
1332
|
+
if (command === "motion") {
|
|
1333
|
+
if (args.length > 1 || (args[0] && !["on", "off"].includes(args[0]))) throw new Error("Use /motion, or /motion on or off.");
|
|
1334
|
+
motionEnabled = args[0] ? args[0] === "on" : !motionEnabled;
|
|
1335
|
+
setFooter(motionEnabled ? "Animation on." : "Animation off.");
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
if (command === "import") {
|
|
1339
|
+
await openImport(args[0]);
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (command === "help") {
|
|
1343
|
+
if (args.length > 1 || (args[0] && args[0] !== "all")) throw new Error("Use /help, or /help all.");
|
|
1344
|
+
addMoondogMessage(args[0] === "all" ? helpText() : listeningHelp());
|
|
1345
|
+
setFooter("Type / to browse every command.");
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (command === "model") {
|
|
1349
|
+
await selectModel(args);
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
if (command === "auth") {
|
|
1353
|
+
await authenticate(args);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (command === "spotify") {
|
|
1357
|
+
await runSpotifyCommand(args);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (command === "profile" && args.length > 0) {
|
|
1361
|
+
if (typeof runProfile !== "function") {
|
|
1362
|
+
throw new Error("Changing your profile isn't available when Moondog is started this way.");
|
|
1363
|
+
}
|
|
1364
|
+
addMoondogMessage(await runProfile(args));
|
|
1365
|
+
void updateHomeLyrics();
|
|
1366
|
+
if (["correct", "retract"].includes(args[0])) {
|
|
1367
|
+
try {
|
|
1368
|
+
await openProfile();
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
addMoondogMessage(`Your choice is saved, but I couldn't update the profile: ${error.message}\n\nType \`/taste\` to try again.`);
|
|
1371
|
+
setFooter("Choice saved. The profile needs another try.", yellow);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
setFooter("Done.", green);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (command === "reload") {
|
|
1379
|
+
if (importNeedsRefresh) await refreshImportedData?.(importNeedsRefresh);
|
|
1380
|
+
const status = await replaceRuntime();
|
|
1381
|
+
importNeedsRefresh = false;
|
|
1382
|
+
addMoondogMessage(
|
|
1383
|
+
status.state === "configured"
|
|
1384
|
+
? `Reloaded \`${status.provider}/${status.model}\`. This conversation and what I remember carry over.`
|
|
1385
|
+
: `Reloaded, but no model is connected (\`${status.reason}\`). Your profile still works.`,
|
|
1386
|
+
);
|
|
1387
|
+
setFooter("Reloaded.", status.state === "configured" ? green : yellow);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
if (command === "remember") {
|
|
1391
|
+
const supportedKinds = new Set([
|
|
1392
|
+
"fact",
|
|
1393
|
+
"preference",
|
|
1394
|
+
"constraint",
|
|
1395
|
+
"goal",
|
|
1396
|
+
]);
|
|
1397
|
+
const requestedKind = args[0]?.toLowerCase();
|
|
1398
|
+
const kind = supportedKinds.has(requestedKind) ? requestedKind : "fact";
|
|
1399
|
+
const text = (kind === requestedKind ? args.slice(1) : args)
|
|
1400
|
+
.join(" ")
|
|
1401
|
+
.trim();
|
|
1402
|
+
if (!text) {
|
|
1403
|
+
throw new Error(
|
|
1404
|
+
"Tell me what to remember, like /remember preference no live albums.",
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
const memory = application.rememberMemory({ text, kind });
|
|
1408
|
+
addMoondogMessage(
|
|
1409
|
+
`${memory.created ? "I'll remember that" : "I already knew that"}: ${memory.text}\n\nTo forget it later: \`/forget ${memory.memory_id}\``,
|
|
1410
|
+
);
|
|
1411
|
+
setFooter("Remembered.", green);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (command === "forget") {
|
|
1415
|
+
if (args.length !== 1) throw new Error("Tell me which memory, like /forget <memory-id>.");
|
|
1416
|
+
const result = application.forgetMemory(args[0]);
|
|
1417
|
+
addMoondogMessage(
|
|
1418
|
+
result.forgotten
|
|
1419
|
+
? `Forgotten: \`${result.memory_id}\`.`
|
|
1420
|
+
: `I don't have a memory called \`${result.memory_id}\`.`,
|
|
1421
|
+
);
|
|
1422
|
+
setFooter(result.forgotten ? "Forgotten." : "No such memory.", yellow);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (command === "new") {
|
|
1426
|
+
if (args.length) throw new Error("/new doesn't take anything after it.");
|
|
1427
|
+
application.startNewSession?.("user_new");
|
|
1428
|
+
activeRuntime.reset();
|
|
1429
|
+
profileDiscoveryDraft = null;
|
|
1430
|
+
transcript.clear();
|
|
1431
|
+
homeVisible = true;
|
|
1432
|
+
homeFocused = false;
|
|
1433
|
+
sleeve.invalidate();
|
|
1434
|
+
setFooter("A fresh start. /resume brings back the last one.", green);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const result = await application.runLocalCommand(command, runtimeStatus);
|
|
1438
|
+
addMoondogMessage(formatLocalResult(command, result));
|
|
1439
|
+
setFooter("Ready.");
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
const openCommandPalette = async () => {
|
|
1443
|
+
const selected = await choose(slashCommands.map(({ name, description }) => ({
|
|
1444
|
+
value: name, label: `/${name}`, description,
|
|
1445
|
+
})), undefined, "Commands");
|
|
1446
|
+
if (!selected || cleanedUp) return;
|
|
1447
|
+
if (profileView) closeProfile();
|
|
1448
|
+
homeFocused = false;
|
|
1449
|
+
editor.focused = true;
|
|
1450
|
+
if (["web", "remember", "forget", "spotify", "art", "motion"].includes(selected.value)) {
|
|
1451
|
+
if (editor.getText()) {
|
|
1452
|
+
setFooter(`Your message is still here. Send or clear it, then use /${selected.value}.`);
|
|
1453
|
+
} else {
|
|
1454
|
+
editor.setText(`/${selected.value} `);
|
|
1455
|
+
setFooter("Finish the command, then press Enter.");
|
|
1456
|
+
}
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (selected.value === "import") {
|
|
1460
|
+
await openImport();
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (selected.value !== "commands") await editor.onSubmit(`/${selected.value}`);
|
|
1464
|
+
};
|
|
1465
|
+
|
|
1466
|
+
editor.onSubmit = async (rawValue) => {
|
|
1467
|
+
if (busy) return;
|
|
1468
|
+
const value = sanitizeTerminalText(rawValue).trim();
|
|
1469
|
+
if (!value) return;
|
|
1470
|
+
|
|
1471
|
+
if (value.startsWith("/")) {
|
|
1472
|
+
const [rawCommand = "", ...args] = value.slice(1).trim().split(/\s+/u);
|
|
1473
|
+
const command = rawCommand.toLowerCase();
|
|
1474
|
+
if (command === "quit" || command === "exit") {
|
|
1475
|
+
shutdown();
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (!slashCommands.some((entry) => entry.name === command)) {
|
|
1479
|
+
enterConversation();
|
|
1480
|
+
addMoondogMessage(`I don't know \`/${command}\`. Type \`/help\` to see what I do know.`);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
if (command === "commands") {
|
|
1484
|
+
if (args.length) { addMoondogMessage("`/commands` doesn't take anything after it."); return; }
|
|
1485
|
+
await openCommandPalette();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
try {
|
|
1489
|
+
if (!["home", "theme", "art", "motion", "model", "resume", "new", "taste", "profile", "import"].includes(command)) enterConversation();
|
|
1490
|
+
if (command !== "auth") editor.addToHistory(value);
|
|
1491
|
+
localCommandController = ["web", "lyrics"].includes(command) ? new AbortController() : null;
|
|
1492
|
+
const controller = localCommandController;
|
|
1493
|
+
setBusy(true, controller ? () => controller.abort() : null);
|
|
1494
|
+
setFooter(`/${command}...`, yellow);
|
|
1495
|
+
editor.disableSubmit = true;
|
|
1496
|
+
const argumentText = value.slice(1).trim().slice(rawCommand.length).trim();
|
|
1497
|
+
const parsedArgs = command === "import" ? argumentText ? [argumentText] : []
|
|
1498
|
+
: ["profile", "spotify", "web"].includes(command)
|
|
1499
|
+
? parseTuiArguments(argumentText)
|
|
1500
|
+
: args;
|
|
1501
|
+
await runLocalCommand(command, parsedArgs);
|
|
1502
|
+
} catch (error) {
|
|
1503
|
+
if (cleanedUp) return;
|
|
1504
|
+
enterConversation();
|
|
1505
|
+
const cancelled = localCommandController?.signal.aborted;
|
|
1506
|
+
addMoondogMessage(cancelled ? command === "web" ? "Stopped the lookup." : "Stopped the lyric sync." : `That didn't work: ${error.message}`);
|
|
1507
|
+
setFooter(cancelled ? "Cancelled." : `/${command} didn't work.`, cancelled ? yellow : red);
|
|
1508
|
+
} finally {
|
|
1509
|
+
localCommandController = null;
|
|
1510
|
+
setBusy(false);
|
|
1511
|
+
editor.disableSubmit = false;
|
|
1512
|
+
if (!cleanedUp) tui.requestRender();
|
|
1513
|
+
}
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const profileSeed = profileDiscoveryDraft && value.includes(profileDiscoveryDraft.context)
|
|
1518
|
+
? profileDiscoveryDraft.target : undefined;
|
|
1519
|
+
if (profileSeed && runtimeStatus.state !== "configured") {
|
|
1520
|
+
editor.setText(rawValue);
|
|
1521
|
+
setFooter("Your request is still here · /model connects a model first.", yellow);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
// Retain the binding for history recall, only while its exact metadata is visible.
|
|
1525
|
+
if (!profileSeed) profileDiscoveryDraft = null;
|
|
1526
|
+
enterConversation();
|
|
1527
|
+
editor.addToHistory(value);
|
|
1528
|
+
addUserMessage(value);
|
|
1529
|
+
if (runtimeStatus.state !== "configured") {
|
|
1530
|
+
addMoondogMessage(offlineReply(runtimeStatus));
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
setBusy(true, () => activeRuntime.abort());
|
|
1535
|
+
editor.disableSubmit = true;
|
|
1536
|
+
setFooter("Thinking...", yellow);
|
|
1537
|
+
transcriptScroll.scrollToEnd();
|
|
1538
|
+
addLabel("Moondog");
|
|
1539
|
+
const response = new Markdown("", 1, 1, markdownTheme, textStyle);
|
|
1540
|
+
transcript.addChild(response);
|
|
1541
|
+
let streamedText = "";
|
|
1542
|
+
const toolCalls = new Map();
|
|
1543
|
+
let legacyToolId = 0;
|
|
1544
|
+
const updateTool = (tool, state) => {
|
|
1545
|
+
if (cleanedUp) return;
|
|
1546
|
+
const label = sanitizeTerminalText(typeof tool === "string" ? tool : tool?.label ?? "Music tool")
|
|
1547
|
+
.replace(/\s+/gu, " ").trim() || "Music tool";
|
|
1548
|
+
// Runtime events carry call IDs; retain support for older label-only callbacks.
|
|
1549
|
+
const key = tool?.toolCallId ?? (state !== "running"
|
|
1550
|
+
? [...toolCalls].find(([, call]) => call.state === "running" && call.label === label)?.[0]
|
|
1551
|
+
: undefined) ?? `label-${++legacyToolId}`;
|
|
1552
|
+
toolCalls.set(key, { label, state });
|
|
1553
|
+
if (cancellationRequested) return;
|
|
1554
|
+
const pending = [...toolCalls.values()].filter((call) => call.state === "running");
|
|
1555
|
+
if (pending.length) {
|
|
1556
|
+
setFooter(pending.length > 1 ? `${pending.length} running · ${pending[0].label}` : `${pending[0].label}...`, yellow);
|
|
1557
|
+
} else {
|
|
1558
|
+
setFooter(state === "failed" ? `${label} didn't work · carrying on...` : "Putting it together...", state === "failed" ? yellow : dim);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
try {
|
|
1563
|
+
const result = await activeRuntime.prompt(value, {
|
|
1564
|
+
...(profileSeed ? { profileSeed } : {}),
|
|
1565
|
+
onTextDelta: (delta) => {
|
|
1566
|
+
if (cleanedUp) return;
|
|
1567
|
+
streamedText += sanitizeTerminalText(delta);
|
|
1568
|
+
response.setText(streamedText);
|
|
1569
|
+
tui.requestRender();
|
|
1570
|
+
},
|
|
1571
|
+
onTextReplace: (replacement) => {
|
|
1572
|
+
if (cleanedUp) return;
|
|
1573
|
+
streamedText = sanitizeTerminalText(replacement);
|
|
1574
|
+
response.setText(streamedText);
|
|
1575
|
+
tui.requestRender();
|
|
1576
|
+
},
|
|
1577
|
+
onModelRetry: ({ attempt, maxRetries }) => {
|
|
1578
|
+
if (!cancellationRequested) setFooter(`Can't reach the model. Trying again (${attempt}/${maxRetries})...`, yellow);
|
|
1579
|
+
},
|
|
1580
|
+
onToolStart: (tool) => updateTool(tool, "running"),
|
|
1581
|
+
onToolEnd: (tool) => updateTool(tool, tool?.isError ? "failed" : "completed"),
|
|
1582
|
+
});
|
|
1583
|
+
if (cleanedUp) return;
|
|
1584
|
+
if (result.status === "aborted") {
|
|
1585
|
+
if (streamedText.length === 0 && result.text) {
|
|
1586
|
+
streamedText = sanitizeTerminalText(result.text);
|
|
1587
|
+
response.setText(streamedText);
|
|
1588
|
+
}
|
|
1589
|
+
setFooter("Cancelled.", yellow);
|
|
1590
|
+
} else {
|
|
1591
|
+
setFooter("Ready.", green);
|
|
1592
|
+
}
|
|
1593
|
+
} catch (error) {
|
|
1594
|
+
if (cleanedUp) return;
|
|
1595
|
+
const connectionFailed = error.code === "model_connection_failed";
|
|
1596
|
+
const errorText = connectionFailed
|
|
1597
|
+
? `${sanitizeTerminalText(error.message)}\n\n${error.toolsExecuted
|
|
1598
|
+
? "Something already ran before the connection dropped. Check what happened before you ask again."
|
|
1599
|
+
: "Press ↑ to bring your message back, then Enter to try again."}`
|
|
1600
|
+
: `Something went wrong: ${sanitizeTerminalText(error.message)}`;
|
|
1601
|
+
const appendedError = connectionFailed ? errorText : `_${errorText}_`;
|
|
1602
|
+
response.setText(
|
|
1603
|
+
streamedText.length > 0 ? `${streamedText}\n\n${appendedError}` : errorText,
|
|
1604
|
+
);
|
|
1605
|
+
setFooter(connectionFailed
|
|
1606
|
+
? error.toolsExecuted
|
|
1607
|
+
? "Lost the model. Check what ran before retrying."
|
|
1608
|
+
: "Lost the model. ↑ brings your message back."
|
|
1609
|
+
: "The model couldn't answer.", red);
|
|
1610
|
+
} finally {
|
|
1611
|
+
if (toolCalls.size && !cleanedUp) {
|
|
1612
|
+
const calls = [...toolCalls.values()];
|
|
1613
|
+
const counts = ["completed", "failed", "running"].flatMap((state) => {
|
|
1614
|
+
const count = calls.filter((call) => call.state === state).length;
|
|
1615
|
+
return count ? [`${count} ${{ completed: "done", failed: "failed", running: "unfinished" }[state]}`] : [];
|
|
1616
|
+
});
|
|
1617
|
+
const summary = `Tools · ${counts.join(", ")} · ${elapsedTime(workStartedAt)}`;
|
|
1618
|
+
const labels = [...new Set(calls.map((call) => call.label))].join(" · ");
|
|
1619
|
+
transcript.addChild({
|
|
1620
|
+
invalidate() {},
|
|
1621
|
+
render(width) {
|
|
1622
|
+
return new Text(theme.faint(truncateToWidth(`${summary} · ${labels}`, Math.max(1, width - 2))), 1, 0).render(width);
|
|
1623
|
+
},
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
setBusy(false);
|
|
1627
|
+
editor.disableSubmit = false;
|
|
1628
|
+
if (!cleanedUp) tui.requestRender();
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
|
|
1632
|
+
tui.addInputListener((data) => {
|
|
1633
|
+
if (tui.hasOverlay()) return undefined;
|
|
1634
|
+
if (importView) {
|
|
1635
|
+
if (busy) return { consume: true };
|
|
1636
|
+
if (matchesKey(data, "ctrl+c")) {
|
|
1637
|
+
closeImport();
|
|
1638
|
+
return { consume: true };
|
|
1639
|
+
}
|
|
1640
|
+
return undefined;
|
|
1641
|
+
}
|
|
1642
|
+
if (!busy && matchesKey(data, "ctrl+p")) {
|
|
1643
|
+
void openCommandPalette();
|
|
1644
|
+
return { consume: true };
|
|
1645
|
+
}
|
|
1646
|
+
if (profileView) {
|
|
1647
|
+
if (busy) return { consume: true };
|
|
1648
|
+
if (matchesKey(data, "ctrl+c")) { closeProfile(); return { consume: true }; }
|
|
1649
|
+
return undefined;
|
|
1650
|
+
}
|
|
1651
|
+
if (!busy && homeVisible && !editor.getText()) {
|
|
1652
|
+
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
|
|
1653
|
+
homeFocused = !homeFocused;
|
|
1654
|
+
editor.focused = !homeFocused;
|
|
1655
|
+
tui.requestRender();
|
|
1656
|
+
return { consume: true };
|
|
1657
|
+
}
|
|
1658
|
+
if (homeFocused) {
|
|
1659
|
+
if (matchesKey(data, "up") || matchesKey(data, "down")) {
|
|
1660
|
+
homeSelected = (homeSelected + (matchesKey(data, "up") ? -1 : 1) + homeActions.length) % homeActions.length;
|
|
1661
|
+
tui.requestRender();
|
|
1662
|
+
return { consume: true };
|
|
1663
|
+
}
|
|
1664
|
+
if (matchesKey(data, "enter")) {
|
|
1665
|
+
homeFocused = false;
|
|
1666
|
+
editor.focused = true;
|
|
1667
|
+
editor.onSubmit(`/${homeActions[homeSelected].command}`);
|
|
1668
|
+
return { consume: true };
|
|
1669
|
+
}
|
|
1670
|
+
if (matchesKey(data, "escape")) {
|
|
1671
|
+
homeFocused = false;
|
|
1672
|
+
editor.focused = true;
|
|
1673
|
+
tui.requestRender();
|
|
1674
|
+
return { consume: true };
|
|
1675
|
+
}
|
|
1676
|
+
// Printable input and bracketed paste go untouched to the same editor.
|
|
1677
|
+
if (!matchesKey(data, "ctrl+c")) {
|
|
1678
|
+
homeFocused = false;
|
|
1679
|
+
editor.focused = true;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
if (!matchesKey(data, "ctrl+c")) return undefined;
|
|
1684
|
+
if (busy) {
|
|
1685
|
+
if (cancelWork) {
|
|
1686
|
+
if (!cancellationRequested) {
|
|
1687
|
+
cancellationRequested = true;
|
|
1688
|
+
setFooter("Stopping...", yellow);
|
|
1689
|
+
cancelWork();
|
|
1690
|
+
}
|
|
1691
|
+
} else {
|
|
1692
|
+
setFooter("This can't be stopped halfway. Almost there...", yellow);
|
|
1693
|
+
}
|
|
1694
|
+
} else {
|
|
1695
|
+
shutdown();
|
|
1696
|
+
}
|
|
1697
|
+
return { consume: true };
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
setFooter("New conversation · /resume history");
|
|
1701
|
+
void sourceStatusTask.then((error) => {
|
|
1702
|
+
if (cleanedUp || !error || footerText !== "New conversation · /resume history") return;
|
|
1703
|
+
setFooter(`Couldn't read the music library: ${sanitizeTerminalText(error.message)}`, yellow);
|
|
1704
|
+
});
|
|
1705
|
+
try {
|
|
1706
|
+
startAttempted = true;
|
|
1707
|
+
tui.start();
|
|
1708
|
+
void updateHomeLyrics();
|
|
1709
|
+
await stoppedPromise;
|
|
1710
|
+
} finally {
|
|
1711
|
+
cleanup();
|
|
1712
|
+
await lyricTask;
|
|
1713
|
+
}
|
|
1714
|
+
}
|