@audiofool/moondog 0.1.0 → 0.2.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +156 -68
- package/contracts/v1/definitions.schema.json +1 -169
- package/package.json +7 -23
- package/scripts/contract-lib.mjs +0 -7
- package/scripts/contract-semantics.mjs +0 -1904
- package/scripts/moondog.mjs +285 -74
- package/src/core/apple-library-source-status.mjs +0 -16
- package/src/core/apple-projection-domain-services.mjs +253 -53
- package/src/core/application-update.mjs +320 -0
- package/src/core/capability-catalog.mjs +290 -55
- package/src/core/listener-avoids.mjs +101 -0
- package/src/core/listening-now.mjs +108 -0
- package/src/core/listening-profile-domain-services.mjs +189 -41
- package/src/core/listening-upkeep.mjs +31 -0
- package/src/core/moondog-application.mjs +2115 -107
- package/src/core/playback-preferences.mjs +97 -0
- package/src/core/spotify-listening-intent.mjs +124 -0
- package/src/core/synthetic-domain-services.mjs +24 -3
- package/src/core/track-title.mjs +107 -0
- package/src/demo/moondog-demo.mjs +4 -22
- package/src/i18n/index.mjs +285 -0
- package/src/i18n/messages/de.mjs +135 -0
- package/src/i18n/messages/en.mjs +136 -0
- package/src/i18n/messages/es.mjs +135 -0
- package/src/i18n/messages/fr.mjs +135 -0
- package/src/i18n/messages/it.mjs +135 -0
- package/src/i18n/messages/ja.mjs +135 -0
- package/src/i18n/messages/ko.mjs +135 -0
- package/src/i18n/messages/pt.mjs +135 -0
- package/src/i18n/messages/zh-Hant.mjs +135 -0
- package/src/i18n/messages/zh.mjs +135 -0
- package/src/i18n/preferences.mjs +34 -0
- package/src/i18n/screen/es.mjs +967 -0
- package/src/i18n/screen/ja.mjs +967 -0
- package/src/i18n/screen/pt.mjs +967 -0
- package/src/i18n/screen/zh.mjs +967 -0
- package/src/importers/apple-music-library/index.mjs +0 -16
- package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
- package/src/integrations/apple-music/catalog.mjs +166 -14
- package/src/integrations/apple-music/music-app.mjs +232 -0
- package/src/integrations/apple-music/storefront.mjs +28 -0
- package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
- package/src/integrations/lastfm/history.mjs +221 -0
- package/src/integrations/lastfm/settings.mjs +75 -0
- package/src/integrations/netease/ncm-cli-player.mjs +328 -0
- package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
- package/src/integrations/public-playlists.mjs +2 -1
- package/src/integrations/spotify/account-data-profile.mjs +14 -6
- package/src/integrations/spotify/authentication.mjs +149 -15
- package/src/integrations/spotify/catalog-resolver.mjs +9 -6
- package/src/integrations/spotify/connection.mjs +2 -1
- package/src/integrations/spotify/credential-store.mjs +4 -0
- package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
- package/src/integrations/spotify/history-archive.mjs +1 -1
- package/src/integrations/spotify/library-snapshot.mjs +227 -0
- package/src/integrations/spotify/recent-activity.mjs +2 -0
- package/src/integrations/spotify/service.mjs +873 -69
- package/src/integrations/spotify/web-api-client.mjs +410 -127
- package/src/integrations/web/brave-web.mjs +45 -0
- package/src/integrations/web/codex-web.mjs +15 -82
- package/src/integrations/web/deepseek-web.mjs +81 -0
- package/src/integrations/web/web-research.mjs +119 -0
- package/src/integrations/web/web-result.mjs +215 -0
- package/src/integrations/youtube-music/takeout.mjs +3 -1
- package/src/memory/local-memory-store.mjs +542 -57
- package/src/profile/history-import.mjs +42 -6
- package/src/profile/listener-corrections.mjs +22 -0
- package/src/profile/listener-profile-build.mjs +461 -0
- package/src/profile/listener-profile-store.mjs +130 -0
- package/src/profile/listener-profile-verification.mjs +68 -0
- package/src/profile/listener-profile-worker.mjs +179 -0
- package/src/profile/listening-history-store.mjs +640 -17
- package/src/profile/listening-profile-projection.mjs +454 -86
- package/src/profile/local-music-data.mjs +1 -1
- package/src/profile/moondog-suggestions.mjs +184 -0
- package/src/profile/music-providers.mjs +11 -7
- package/src/profile/profile-exploration.mjs +92 -0
- package/src/profile/recorded-measures.mjs +24 -0
- package/src/profile/spotify-archive-taste.mjs +0 -4
- package/src/runtime/pi/agent-runtime.mjs +2536 -692
- package/src/runtime/pi/authentication.mjs +34 -1
- package/src/runtime/pi/configured-runtime.mjs +22 -6
- package/src/runtime/pi/conversation-name.mjs +53 -0
- package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
- package/src/runtime/pi/model-catalog.mjs +28 -5
- package/src/runtime/pi/model-settings.mjs +202 -0
- package/src/runtime/pi/model-transport.mjs +10 -0
- package/src/runtime/pi/persistent-credential-store.mjs +67 -31
- package/src/runtime/pi/profile-build-runtime.mjs +161 -0
- package/src/runtime/pi/public-model-catalog.mjs +288 -0
- package/src/runtime/pi/runtime-settings.mjs +10 -2
- package/src/runtime/pi/skills/listener-profile/SKILL.md +48 -0
- package/src/runtime/pi/tool-trace.mjs +37 -0
- package/src/surfaces/cli/auth-command.mjs +12 -4
- package/src/surfaces/cli/brand-components.mjs +310 -132
- package/src/surfaces/cli/brand-theme.mjs +51 -28
- package/src/surfaces/cli/command-completions.mjs +58 -42
- package/src/surfaces/cli/format-output.mjs +199 -388
- package/src/surfaces/cli/history-import-view.mjs +292 -139
- package/src/surfaces/cli/import-guides.mjs +2 -0
- package/src/surfaces/cli/listener-profile-command.mjs +92 -0
- package/src/surfaces/cli/listening-menu.mjs +7 -4
- package/src/surfaces/cli/profile-command.mjs +30 -21
- package/src/surfaces/cli/seasons-chart.mjs +87 -0
- package/src/surfaces/cli/spotify-command.mjs +54 -6
- package/src/surfaces/cli/taste-profile-model.mjs +78 -79
- package/src/surfaces/cli/taste-profile-view.mjs +33 -26
- package/src/surfaces/cli/terminal-art.mjs +140 -35
- package/src/surfaces/cli/tui.mjs +1066 -357
- package/src/surfaces/cli/update-command.mjs +32 -0
- package/src/surfaces/cli/web-command.mjs +53 -14
- package/src/surfaces/html/tasteprint.mjs +43 -70
- package/contracts/v1/capability-policy.schema.json +0 -572
- package/contracts/v1/profile-evidence.schema.json +0 -237
- package/contracts/v1/tool-invocation.schema.json +0 -318
- package/contracts/v1/tool-receipt.schema.json +0 -1066
- package/scripts/demo-studio.mjs +0 -58
- package/src/core/apple-data-migration.mjs +0 -50
- package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
- package/src/demo/moondog-tasteprint-demo.mjs +0 -631
- package/src/integrations/spotify/index.mjs +0 -80
- package/src/surfaces/web/showcase.mjs +0 -272
- package/src/surfaces/web/studio-page.mjs +0 -4148
- package/src/surfaces/web/studio.mjs +0 -2288
package/src/surfaces/cli/tui.mjs
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { LOCALES, N_, liveScreenTranslator, normalizeLocale } from "../../i18n/index.mjs";
|
|
1
2
|
import {
|
|
2
3
|
CombinedAutocompleteProvider,
|
|
3
4
|
Container,
|
|
5
|
+
getCapabilities,
|
|
6
|
+
hyperlink,
|
|
4
7
|
Markdown,
|
|
5
8
|
matchesKey,
|
|
6
9
|
ProcessTerminal,
|
|
@@ -16,14 +19,17 @@ import {
|
|
|
16
19
|
listPiModels,
|
|
17
20
|
listPiProviders,
|
|
18
21
|
} from "../../runtime/pi/model-catalog.mjs";
|
|
19
|
-
import {
|
|
22
|
+
import { getPublicPiModelCatalog } from "../../runtime/pi/public-model-catalog.mjs";
|
|
23
|
+
import { serviceKeyProviders, supportedAuthProviderIds } from "../../runtime/pi/authentication.mjs";
|
|
20
24
|
import {
|
|
21
25
|
formatLocalResult,
|
|
22
26
|
helpText,
|
|
27
|
+
safeInlineText,
|
|
23
28
|
sanitizeTerminalText,
|
|
24
29
|
} from "./format-output.mjs";
|
|
25
30
|
|
|
26
31
|
import { createMoondogTheme } from "./brand-theme.mjs";
|
|
32
|
+
import { SeasonsChart } from "./seasons-chart.mjs";
|
|
27
33
|
import { LOGO_MOTION_INTERVAL_MS } from "./terminal-art.mjs";
|
|
28
34
|
import { buildTasteProfileModel } from "./taste-profile-model.mjs";
|
|
29
35
|
import { TasteProfileView } from "./taste-profile-view.mjs";
|
|
@@ -32,6 +38,7 @@ import { HistoryImportView } from "./history-import-view.mjs";
|
|
|
32
38
|
import { IMPORT_GUIDE_URLS } from "./import-guides.mjs";
|
|
33
39
|
import { withCommandCompletions } from "./command-completions.mjs";
|
|
34
40
|
import {
|
|
41
|
+
AgentWork,
|
|
35
42
|
BrandSurface,
|
|
36
43
|
ListeningEditor,
|
|
37
44
|
ListeningHeader,
|
|
@@ -40,6 +47,7 @@ import {
|
|
|
40
47
|
homeActions,
|
|
41
48
|
listeningHelp,
|
|
42
49
|
paintBrandLine,
|
|
50
|
+
setBrandText,
|
|
43
51
|
} from "./brand-components.mjs";
|
|
44
52
|
|
|
45
53
|
const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
|
|
@@ -83,31 +91,37 @@ class ListeningRoom extends TuiAltScreen {
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
const slashCommands = [
|
|
86
|
-
{ name: "
|
|
87
|
-
{ name: "
|
|
88
|
-
{ name: "
|
|
89
|
-
{ name: "
|
|
90
|
-
{ name: "
|
|
91
|
-
{ name: "
|
|
92
|
-
{ name: "
|
|
93
|
-
{ name: "
|
|
94
|
-
{ name: "
|
|
95
|
-
{ name: "
|
|
96
|
-
{ name: "
|
|
97
|
-
{ name: "
|
|
98
|
-
{ name: "
|
|
99
|
-
{ name: "
|
|
100
|
-
{ name: "
|
|
101
|
-
{ name: "
|
|
102
|
-
{ name: "
|
|
103
|
-
{ name: "
|
|
104
|
-
{ name: "
|
|
105
|
-
{ name: "
|
|
106
|
-
{ name: "
|
|
107
|
-
{ name: "
|
|
108
|
-
{ name: "
|
|
109
|
-
{ name: "
|
|
110
|
-
{ name: "
|
|
94
|
+
{ name: "update", description: N_("Check for or install a Moondog release") },
|
|
95
|
+
{ name: "home", description: N_("Back to the record sleeve") },
|
|
96
|
+
{ name: "lyrics", description: N_("Your lyric library, or sync more songs") },
|
|
97
|
+
{ name: "import", description: N_("Bring in your listening history or library") },
|
|
98
|
+
{ name: "theme", description: N_("Paper, charcoal, or your terminal colors") },
|
|
99
|
+
{ name: "language", description: N_("The language Moondog replies in") },
|
|
100
|
+
{ name: "art", description: N_("Braille, ASCII, or a quiet opening") },
|
|
101
|
+
{ name: "motion", description: N_("Turn the animation on or off") },
|
|
102
|
+
{ name: "commands", description: N_("Search every command") },
|
|
103
|
+
{ name: "status", description: N_("See what's connected and ready") },
|
|
104
|
+
{ name: "sources", description: N_("See where your music data comes from") },
|
|
105
|
+
{ name: "profile", description: N_("Review, change, or undo your choices") },
|
|
106
|
+
{ name: "taste", description: N_("Your listening profile and the evidence behind it") },
|
|
107
|
+
{ name: "memory", description: N_("See what Moondog remembers") },
|
|
108
|
+
{ name: "tools", description: N_("See which music tools are available") },
|
|
109
|
+
{ name: "doctor", description: N_("Check your setup") },
|
|
110
|
+
{ name: "model", description: N_("Choose the model Moondog talks through") },
|
|
111
|
+
{ name: "effort", description: N_("Choose how hard the model thinks") },
|
|
112
|
+
{ name: "auth", description: N_("Sign in to a model or web search service") },
|
|
113
|
+
{ name: "web", description: N_("Look things up on public music sites") },
|
|
114
|
+
{ name: "spotify", description: N_("Connect and control Spotify") },
|
|
115
|
+
{ name: "skip", description: N_("Skip to the next song preview") },
|
|
116
|
+
{ name: "stop", description: N_("Stop the song previews") },
|
|
117
|
+
{ name: "reload", description: N_("Reload model settings and sign-ins") },
|
|
118
|
+
{ name: "remember", description: N_("Ask Moondog to remember something") },
|
|
119
|
+
{ name: "forget", description: N_("Forget one memory by ID") },
|
|
120
|
+
{ name: "resume", description: N_("Pick up a saved conversation") },
|
|
121
|
+
{ name: "rewind", description: N_("Edit an earlier message on a new branch · Alt+R") },
|
|
122
|
+
{ name: "new", description: N_("Start fresh; this conversation stays saved") },
|
|
123
|
+
{ name: "help", description: N_("Commands and shortcuts") },
|
|
124
|
+
{ name: "quit", description: N_("Leave Moondog") },
|
|
111
125
|
];
|
|
112
126
|
|
|
113
127
|
// Parse command arguments as text only. Never evaluate shell substitutions.
|
|
@@ -148,18 +162,14 @@ export function parseTuiArguments(text) {
|
|
|
148
162
|
return values;
|
|
149
163
|
}
|
|
150
164
|
|
|
151
|
-
function offlineReply(runtimeStatus) {
|
|
165
|
+
function offlineReply(runtimeStatus, tr) {
|
|
152
166
|
const provider = runtimeStatus.provider ?? "openai-codex";
|
|
153
167
|
const [why, next] = runtimeStatus.reason === "provider_authentication_required"
|
|
154
|
-
? [
|
|
168
|
+
? [tr("you're not signed in to {provider}", { provider }), tr("Sign in with `/auth {provider}`.", { provider })]
|
|
155
169
|
: runtimeStatus.reason === "model_not_configured" || !runtimeStatus.reason
|
|
156
|
-
? ["no model is connected", "Type `/model` to pick one."]
|
|
157
|
-
: [
|
|
158
|
-
return
|
|
159
|
-
|
|
160
|
-
${next}
|
|
161
|
-
|
|
162
|
-
Your profile doesn't need a model. \`/taste\`, \`/import\`, and \`/help\` all work right now.`;
|
|
170
|
+
? [tr("no model is connected"), tr("Type `/model` to pick one.")]
|
|
171
|
+
: [tr("I can't reach the model (`{reason}`)", { reason: runtimeStatus.reason }), tr("Type `/model` to pick another, or `/reload` to try again.")];
|
|
172
|
+
return tr("Obscured by clouds. I can't talk yet because {why}.\n\n{next}\n\nYour profile doesn't need a model. `/taste`, `/import`, and `/help` all work right now.", { why, next });
|
|
163
173
|
}
|
|
164
174
|
|
|
165
175
|
function elapsedTime(startedAt) {
|
|
@@ -180,15 +190,37 @@ export async function runMoondogTui({
|
|
|
180
190
|
runProfile,
|
|
181
191
|
runProfileAction,
|
|
182
192
|
runWeb,
|
|
193
|
+
checkUpdates,
|
|
194
|
+
runUpdate,
|
|
183
195
|
lyrics,
|
|
184
196
|
prepareImport,
|
|
185
197
|
prepareRecentImport,
|
|
186
198
|
refreshImportedData,
|
|
187
199
|
openImportHelp,
|
|
200
|
+
lastfm,
|
|
188
201
|
providers = listPiProviders,
|
|
189
202
|
models = listPiModels,
|
|
203
|
+
refreshModels,
|
|
204
|
+
saveLanguage,
|
|
205
|
+
// The terminal's preview player. The room shows what it plays (subscribe) and
|
|
206
|
+
// gives the listener /skip and /stop.
|
|
207
|
+
previewPlayer,
|
|
190
208
|
environment = process.env,
|
|
191
209
|
}) {
|
|
210
|
+
if (providers === listPiProviders && models === listPiModels) {
|
|
211
|
+
const catalog = await getPublicPiModelCatalog(environment);
|
|
212
|
+
providers = () => catalog.providers();
|
|
213
|
+
models = id => catalog.models(id);
|
|
214
|
+
refreshModels ??= options => catalog.refresh(options);
|
|
215
|
+
}
|
|
216
|
+
// Screen text follows the listener's language and switches with /language.
|
|
217
|
+
const tr = liveScreenTranslator(() => application.locale);
|
|
218
|
+
setBrandText(tr);
|
|
219
|
+
// /skip and /stop exist only where the room controls the previews itself.
|
|
220
|
+
const previewControls = typeof previewPlayer?.subscribe === "function" &&
|
|
221
|
+
typeof previewPlayer.skip === "function" && typeof previewPlayer.stop === "function";
|
|
222
|
+
const commands = slashCommands.filter(({ name }) => previewControls || !["skip", "stop"].includes(name));
|
|
223
|
+
const localizedCommands = commands.map(({ name, description }) => ({ name, get description() { return tr.marked(description); } }));
|
|
192
224
|
let activeRuntime = runtime;
|
|
193
225
|
application.startNewSession?.("tui_start");
|
|
194
226
|
activeRuntime.reset();
|
|
@@ -201,9 +233,10 @@ export async function runMoondogTui({
|
|
|
201
233
|
const getTheme = () => theme;
|
|
202
234
|
const color = (name) => (text) => theme[name](text);
|
|
203
235
|
const accent = color("accent");
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
const
|
|
236
|
+
// Status stays black and white; prism colors mark only success, caution, and failure.
|
|
237
|
+
const success = color("success");
|
|
238
|
+
const warning = color("warning");
|
|
239
|
+
const failure = color("error");
|
|
207
240
|
const dim = color("muted");
|
|
208
241
|
const bold = color("bold");
|
|
209
242
|
const selectListTheme = Object.fromEntries(
|
|
@@ -218,6 +251,7 @@ export async function runMoondogTui({
|
|
|
218
251
|
if (theme.plain) tui.setShowHardwareCursor(true);
|
|
219
252
|
const transcript = new Container();
|
|
220
253
|
let homeVisible = true;
|
|
254
|
+
let conversationTitle = "";
|
|
221
255
|
let profileView = null;
|
|
222
256
|
let importView = null;
|
|
223
257
|
let importPage = "start";
|
|
@@ -228,6 +262,9 @@ export async function runMoondogTui({
|
|
|
228
262
|
let importProvider = null;
|
|
229
263
|
let importFileReturn = "start";
|
|
230
264
|
let importAuthController = null;
|
|
265
|
+
// Reading Last.fm can take minutes, so Esc can stop it; the access state says whether a key is set.
|
|
266
|
+
let lastfmController = null;
|
|
267
|
+
let lastfmAccess = null;
|
|
231
268
|
let profileReturnHome = false;
|
|
232
269
|
let profileSnapshot = null;
|
|
233
270
|
let profileNeedsRefresh = false;
|
|
@@ -235,6 +272,19 @@ export async function runMoondogTui({
|
|
|
235
272
|
let homeFocused = false;
|
|
236
273
|
let homeSelected = 0;
|
|
237
274
|
let busy = false;
|
|
275
|
+
// A reply another Moondog window or terminal is writing in this conversation: { id, response, text }.
|
|
276
|
+
let elsewhere = null;
|
|
277
|
+
let watchTimer = null;
|
|
278
|
+
let activeSubmission = null;
|
|
279
|
+
let rewindPending = false;
|
|
280
|
+
let queuePaused = false;
|
|
281
|
+
const messageQueue = [];
|
|
282
|
+
const queuedLineCount = () => messageQueue.length === 0
|
|
283
|
+
? 0
|
|
284
|
+
: Math.min(messageQueue.length, 4) + (messageQueue.length > 4 ? 1 : 0);
|
|
285
|
+
// The preview now playing sits on its own line above the draft.
|
|
286
|
+
let nowPlaying = null;
|
|
287
|
+
const nowPlayingRows = () => (nowPlaying && !profileView && !importView ? 1 : 0);
|
|
238
288
|
let cancelWork = null;
|
|
239
289
|
let cancellationRequested = false;
|
|
240
290
|
let workStartedAt = 0;
|
|
@@ -248,7 +298,10 @@ export async function runMoondogTui({
|
|
|
248
298
|
let lyricError = null;
|
|
249
299
|
let motionEnabled = environment.MOONDOG_MOTION !== "off";
|
|
250
300
|
let phase = 0;
|
|
251
|
-
let footerText = "Ready.";
|
|
301
|
+
let footerText = tr("Ready.");
|
|
302
|
+
let updateNotice = "";
|
|
303
|
+
let exitRequest;
|
|
304
|
+
const updateCheckController = new AbortController();
|
|
252
305
|
let footerColor = dim;
|
|
253
306
|
let localCommandController = null;
|
|
254
307
|
let startAttempted = false;
|
|
@@ -256,9 +309,31 @@ export async function runMoondogTui({
|
|
|
256
309
|
let resolveStopped;
|
|
257
310
|
const stoppedPromise = new Promise((resolve) => { resolveStopped = resolve; });
|
|
258
311
|
const editor = new ListeningEditor(tui, editorTheme, getTheme, () => ({ busy, homeVisible, homeFocused }));
|
|
312
|
+
const recallBySession = new Map();
|
|
313
|
+
const sessionId = () => application.ensureMemorySession?.()?.session_id ?? null;
|
|
314
|
+
const storeRecall = () => {
|
|
315
|
+
const id = sessionId();
|
|
316
|
+
if (id) recallBySession.set(id, editor.recallEntries());
|
|
317
|
+
};
|
|
318
|
+
const recallFromTurns = () => {
|
|
319
|
+
const turns = application.currentConversationTurns?.({ limit: 100 }) ?? application.currentSessionTurns?.({ limit: 100 }) ?? [];
|
|
320
|
+
const history = [];
|
|
321
|
+
for (let index = turns.length - 1; index >= 0 && history.length < 100; index -= 1) {
|
|
322
|
+
const turn = turns[index];
|
|
323
|
+
if (turn?.role !== "user" || typeof turn.text !== "string") continue;
|
|
324
|
+
const text = turn.text.trim();
|
|
325
|
+
if (!text || history.at(-1) === text) continue;
|
|
326
|
+
history.push(text);
|
|
327
|
+
}
|
|
328
|
+
return history;
|
|
329
|
+
};
|
|
330
|
+
const installRecall = (id, entries) => {
|
|
331
|
+
const history = editor.replaceRecall(entries);
|
|
332
|
+
if (id) recallBySession.set(id, history);
|
|
333
|
+
};
|
|
259
334
|
const sleeve = new RecordSleeve({ terminal, getTheme, environment, getState: () => ({
|
|
260
335
|
focused: homeFocused, selected: homeSelected, motionEnabled,
|
|
261
|
-
editorRows: editor.rowCount || 3,
|
|
336
|
+
editorRows: (editor.rowCount || 3) + queuedLineCount() + nowPlayingRows(),
|
|
262
337
|
}), getNextLyric: () => {
|
|
263
338
|
try {
|
|
264
339
|
homeLyric = homeLyricSeeds ? lyrics.selectHome(homeLyricSeeds) : null;
|
|
@@ -302,7 +377,9 @@ export async function runMoondogTui({
|
|
|
302
377
|
modelReady: runtimeStatus.state === "configured",
|
|
303
378
|
provider: runtimeStatus.provider,
|
|
304
379
|
model: runtimeStatus.model,
|
|
380
|
+
effort: runtimeStatus.thinking_level,
|
|
305
381
|
spotifyReady: application.spotifyReady?.() ?? false,
|
|
382
|
+
place: homeVisible ? "" : conversationTitle,
|
|
306
383
|
}));
|
|
307
384
|
const renderHeader = () => { header.invalidate(); tui.requestRender(); };
|
|
308
385
|
const synchronizeHomeMotion = () => {
|
|
@@ -322,7 +399,7 @@ export async function runMoondogTui({
|
|
|
322
399
|
render(width) {
|
|
323
400
|
const lines = conversation.render(width);
|
|
324
401
|
const footerRows = terminal.rows >= 20 ? 2 : 1;
|
|
325
|
-
const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - (editor.rowCount || 3));
|
|
402
|
+
const bodyRows = Math.max(1, terminal.rows - 2 - footerRows - queuedLineCount() - nowPlayingRows() - (editor.rowCount || 3));
|
|
326
403
|
while (lines.length < bodyRows) lines.push(paintBrandLine("", width, theme));
|
|
327
404
|
return lines;
|
|
328
405
|
},
|
|
@@ -358,7 +435,41 @@ export async function runMoondogTui({
|
|
|
358
435
|
const composer = new BrandSurface(editor, getTheme);
|
|
359
436
|
const composerSlot = {
|
|
360
437
|
invalidate() { composer.invalidate(); },
|
|
361
|
-
render
|
|
438
|
+
render(width) {
|
|
439
|
+
if (profileView || importView) return [];
|
|
440
|
+
const lines = [];
|
|
441
|
+
if (nowPlaying) {
|
|
442
|
+
const { track, position, count, type } = nowPlaying;
|
|
443
|
+
const prefix = ` ${theme.plain ? ">" : "♪"} `;
|
|
444
|
+
// Where the terminal can't open links, the note in the conversation shows the address.
|
|
445
|
+
const apple = getCapabilities().hyperlinks ? hyperlink("Apple Music", track.catalog_url) : "Apple Music";
|
|
446
|
+
const state = type === "loading" ? tr("loading") : type === "paused" ? tr("paused") : "";
|
|
447
|
+
const controls = ` ${position}/${count}${state ? ` · ${state}` : ""} · ${apple} · /skip · /stop`;
|
|
448
|
+
const song = sanitizeTerminalText(`${track.title} · ${track.artist_credit}`).replace(/\s+/gu, " ").trim();
|
|
449
|
+
const room = width - visibleWidth(prefix) - visibleWidth(controls);
|
|
450
|
+
lines.push(paintBrandLine(room >= 12
|
|
451
|
+
? theme.accent(prefix) + theme.text(truncateToWidth(song, room, "…")) + theme.faint(controls)
|
|
452
|
+
: theme.accent(prefix) + theme.text(truncateToWidth(song, Math.max(1, width - visibleWidth(prefix)), "…")), width, theme));
|
|
453
|
+
}
|
|
454
|
+
if (messageQueue.length) {
|
|
455
|
+
const hidden = Math.max(0, messageQueue.length - 4);
|
|
456
|
+
const shown = messageQueue.slice(-4);
|
|
457
|
+
const firstNumber = messageQueue.length - shown.length + 1;
|
|
458
|
+
if (hidden) lines.push(paintBrandLine(theme.faint(` ${tr.n(hidden, "{count} more queued", "{count} more queued")}`), width, theme));
|
|
459
|
+
shown.forEach((text, index) => {
|
|
460
|
+
const number = String(firstNumber + index);
|
|
461
|
+
const prefix = ` ${number} `;
|
|
462
|
+
const message = text.replace(/\s+/gu, " ").trim();
|
|
463
|
+
lines.push(paintBrandLine(
|
|
464
|
+
theme.faint(prefix) + theme.muted(truncateToWidth(message, Math.max(1, width - visibleWidth(prefix)), "…")),
|
|
465
|
+
width,
|
|
466
|
+
theme,
|
|
467
|
+
));
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
lines.push(...composer.render(width));
|
|
471
|
+
return lines;
|
|
472
|
+
},
|
|
362
473
|
};
|
|
363
474
|
const shell = new VStack();
|
|
364
475
|
shell.addChild(header, { shrink: 0 });
|
|
@@ -369,60 +480,73 @@ export async function runMoondogTui({
|
|
|
369
480
|
shell.addChild({
|
|
370
481
|
invalidate() {},
|
|
371
482
|
render(width) {
|
|
372
|
-
const
|
|
483
|
+
const spinning = !theme.plain && busy && !tui.hasOverlay();
|
|
484
|
+
// One moon mark in every frame. The prism walks its color while Moondog works.
|
|
485
|
+
// The clock glyphs sat on different baselines, so the mark jumped up and down.
|
|
486
|
+
const marker = theme.plain ? "." : "◎";
|
|
487
|
+
const markerInk = spinning ? theme.prism[phase % theme.prism.length] : theme.faint;
|
|
373
488
|
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;
|
|
489
|
+
let statusText = compactHint && homeFocused ? tr("↑ ↓ choose · enter open · esc type")
|
|
490
|
+
: compactHint && editor.getText() ? tr("enter send · ctrl+p commands")
|
|
491
|
+
: compactHint && footerText === tr("Ready.") ? tr("tab explore · type to talk") : footerText;
|
|
377
492
|
if (importView && width < 64) {
|
|
378
|
-
if (busy) statusText = footerText
|
|
379
|
-
: footerText
|
|
380
|
-
else if (importView.state.error) statusText = "See the message above.";
|
|
381
|
-
else if (footerText
|
|
382
|
-
else statusText = importPage === "preview" ? "Ready; nothing added."
|
|
383
|
-
: importPage === "file" ? "Choose one history file." : "Choose a step; Esc goes back.";
|
|
493
|
+
if (busy) statusText = footerText === tr("Reading the file. Nothing is added until you say so.") ? tr("Reading; nothing added yet.")
|
|
494
|
+
: footerText === tr("History saved. Updating your profile...") ? tr("Saved; refreshing profile...") : tr("Saving history locally...");
|
|
495
|
+
else if (importView.state.error) statusText = tr("See the message above.");
|
|
496
|
+
else if (footerText === tr("Opened in your browser. Moondog will be here when you're back.")) statusText = tr("Guide opened in browser.");
|
|
497
|
+
else statusText = importPage === "preview" ? tr("Ready; nothing added.")
|
|
498
|
+
: importPage === "file" ? tr("Choose one history file.") : tr("Choose a step; Esc goes back.");
|
|
384
499
|
}
|
|
385
500
|
if (profileView && !importView && width < 60) {
|
|
386
|
-
statusText = footerText
|
|
387
|
-
if (tui.hasOverlay()) statusText = "Enter chooses · Esc back";
|
|
388
|
-
else if (footerText
|
|
389
|
-
else if (footerText
|
|
390
|
-
else if (footerText
|
|
501
|
+
statusText = footerText;
|
|
502
|
+
if (tui.hasOverlay()) statusText = tr("Enter chooses · Esc back");
|
|
503
|
+
else if (footerText === tr("Pick a song or artist. Enter shows why it's here.")) statusText = tr("Enter actions · Tab views · Esc back");
|
|
504
|
+
else if (footerText === tr("Here's the evidence. PgUp/PgDn to scroll, Enter for actions.")) statusText = tr("Evidence · PgUp/PgDn scroll · Enter");
|
|
505
|
+
else if (footerText === tr("Imported. Here's your profile with everything so far.")) statusText = tr("Imported · Enter to review");
|
|
391
506
|
}
|
|
392
507
|
if (busy && !profileView && !importView && width >= 20) {
|
|
393
508
|
const elapsed = ` · ${elapsedTime(workStartedAt)}`;
|
|
394
509
|
statusText = truncateToWidth(statusText, width - 3 - visibleWidth(elapsed)) + elapsed;
|
|
395
510
|
}
|
|
396
511
|
if (terminal.rows < 20 && !busy && !profileView && !importView && editor.isShowingAutocomplete()) {
|
|
397
|
-
statusText = "↑↓ choose · Tab/Enter complete";
|
|
512
|
+
statusText = tr("↑↓ choose · Tab/Enter complete");
|
|
398
513
|
}
|
|
399
|
-
|
|
514
|
+
if (updateNotice && homeVisible && [tr("Ready."), tr("New conversation · /resume history")].includes(footerText) && !editor.getText() && !busy && !profileView && !importView && !tui.hasOverlay() && !editor.isShowingAutocomplete()) statusText = updateNotice;
|
|
515
|
+
const lines = [paintBrandLine(` ${markerInk(marker)} ${footerColor(statusText)}`, width, theme)];
|
|
400
516
|
if (terminal.rows >= 20) {
|
|
401
517
|
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"
|
|
518
|
+
? width < 64 ? busy ? tr("One moment...") : importPage === "file"
|
|
519
|
+
? tr("Tab path · ↵ inspect · Esc back") : tr("↑↓ ↵ · PgUp/Dn read · Esc back")
|
|
520
|
+
: busy ? tr("One moment...") : importPage === "file"
|
|
521
|
+
? tr("paste a path · tab completes · enter inspects · esc back")
|
|
522
|
+
: tr("↑↓ choose · enter select · PgUp/PgDn read · esc back")
|
|
407
523
|
: 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
|
|
411
|
-
|
|
412
|
-
:
|
|
413
|
-
|
|
414
|
-
|
|
524
|
+
? busy ? tr("One moment...")
|
|
525
|
+
: width >= 76 ? tr("type filter · ↑↓ select · enter actions · tab views · esc back") : tr("↑↓ select · enter actions · tab views · esc back")
|
|
526
|
+
: busy ? cancellationRequested
|
|
527
|
+
? messageQueue.length ? tr("stopping · the queue waits") : tr("stopping")
|
|
528
|
+
: messageQueue.length
|
|
529
|
+
? cancelWork ? tr.n(messageQueue.length, "{count} queued · up edits the last · ctrl+c cancel", "{count} queued · up edits the last · ctrl+c cancel")
|
|
530
|
+
: elsewhere ? tr.n(messageQueue.length, "{count} queued · up edits the last · answering in another window", "{count} queued · up edits the last · answering in another window")
|
|
531
|
+
: tr.n(messageQueue.length, "{count} queued · up edits the last · waiting for command", "{count} queued · up edits the last · waiting for command")
|
|
532
|
+
: cancelWork ? tr("enter queues the next one · ctrl+c cancel")
|
|
533
|
+
: elsewhere ? tr("enter queues the next one · answering in another window")
|
|
534
|
+
: tr("enter queues the next one · waiting for command")
|
|
535
|
+
: editor.isShowingAutocomplete() ? tr("↑↓ choose · tab/enter complete · esc dismiss") : homeFocused
|
|
536
|
+
? tr("↑ ↓ choose · enter open · esc type")
|
|
415
537
|
: homeVisible && !editor.getText()
|
|
416
|
-
? width >= 60 ? "tab explore · ctrl+p commands enter send · shift+enter newline" : "tab explore · ctrl+p commands"
|
|
417
|
-
:
|
|
538
|
+
? width >= 60 ? tr("tab explore · ctrl+p commands enter send · shift+enter newline") : tr("tab explore · ctrl+p commands")
|
|
539
|
+
: queuePaused && messageQueue.length ? tr("queued drafts paused · up edits the last")
|
|
540
|
+
: width >= 72 ? tr("enter send · shift+enter newline · alt+r rewind") : tr("enter send · /rewind");
|
|
418
541
|
lines.push(paintBrandLine(` ${theme.faint(keys)}`, width, theme));
|
|
419
542
|
}
|
|
420
543
|
return lines;
|
|
421
544
|
},
|
|
422
545
|
}, { shrink: 0 });
|
|
423
546
|
editor.setAutocompleteProvider(
|
|
424
|
-
new CombinedAutocompleteProvider(withCommandCompletions(
|
|
425
|
-
|
|
547
|
+
new CombinedAutocompleteProvider(withCommandCompletions(localizedCommands, {
|
|
548
|
+
// Service keys, such as Brave Search, have no models, so only /auth offers them.
|
|
549
|
+
providers: () => [...providers(), ...serviceKeyProviders], models, authProviderIds: supportedAuthProviderIds, tr,
|
|
426
550
|
}), process.cwd(), null),
|
|
427
551
|
);
|
|
428
552
|
tui.setFocus(editor);
|
|
@@ -462,7 +586,9 @@ export async function runMoondogTui({
|
|
|
462
586
|
});
|
|
463
587
|
};
|
|
464
588
|
const addUserMessage = (text) => {
|
|
465
|
-
|
|
589
|
+
const title = text.replace(/\s+/gu, " ").trim();
|
|
590
|
+
if (!conversationTitle && title) conversationTitle = Array.from(title).slice(0, 160).join("");
|
|
591
|
+
addLabel(tr("You"));
|
|
466
592
|
transcript.addChild(new Text(text, 1, 1));
|
|
467
593
|
};
|
|
468
594
|
// A finished local message is read from the top. Several messages added before
|
|
@@ -477,15 +603,80 @@ export async function runMoondogTui({
|
|
|
477
603
|
const revealMessageStart = (added) => {
|
|
478
604
|
tui.pendingReveal = (tui.pendingReveal ?? 0) + added;
|
|
479
605
|
};
|
|
480
|
-
|
|
606
|
+
// A message is Markdown, or a list of Markdown parts and components such as charts.
|
|
607
|
+
const addMoondogMessage = (message) => {
|
|
481
608
|
enterConversation();
|
|
482
609
|
const width = Math.max(1, transcriptScroll.getContentWidth(terminal.columns));
|
|
483
610
|
const before = conversation.render(width).length;
|
|
484
611
|
addLabel("Moondog");
|
|
485
|
-
|
|
612
|
+
for (const part of Array.isArray(message) ? message : [message]) {
|
|
613
|
+
transcript.addChild(typeof part === "string" ? new Markdown(sanitizeTerminalText(part), 1, 1, markdownTheme, textStyle) : part);
|
|
614
|
+
}
|
|
486
615
|
revealMessageStart(Math.max(0, conversation.render(width).length - before));
|
|
487
616
|
tui.requestRender();
|
|
488
617
|
};
|
|
618
|
+
// The report's seasons draw as a chart in place of their list; the heading and note stay.
|
|
619
|
+
// `before` and `after` are Markdown that frames the report in the same message.
|
|
620
|
+
const addTasteReport = (value, { before, after } = {}) => {
|
|
621
|
+
const markdown = [before, formatLocalResult("taste", value, tr), after].filter(Boolean).join("\n\n");
|
|
622
|
+
const seasons = value?.listening_behavior?.listening_seasons?.seasons ?? [];
|
|
623
|
+
const heading = `\n## ${tr("Seasons")}\n`;
|
|
624
|
+
const start = markdown.indexOf(heading);
|
|
625
|
+
if (seasons.length < 2 || start < 0) return addMoondogMessage(markdown);
|
|
626
|
+
const next = markdown.indexOf("\n## ", start + heading.length);
|
|
627
|
+
const end = next < 0 ? markdown.length : next;
|
|
628
|
+
const intro = markdown.slice(start, end).split("\n").filter((line) => !line.startsWith("- ")).join("\n");
|
|
629
|
+
// Each Markdown part already pads a line above and below, so the chart sits one blank
|
|
630
|
+
// line from its neighbours, like any other section.
|
|
631
|
+
addMoondogMessage([
|
|
632
|
+
(markdown.slice(0, start) + intro).trimEnd(),
|
|
633
|
+
new SeasonsChart(seasons, { getTheme, tr, environment }),
|
|
634
|
+
...(end < markdown.length ? [markdown.slice(end).trimStart()] : []),
|
|
635
|
+
]);
|
|
636
|
+
};
|
|
637
|
+
// Each preview sits next to its song on Apple Music. The note joins the reply in progress;
|
|
638
|
+
// terminals that support it show each link as clickable "Apple Music" text.
|
|
639
|
+
// A song that fails to load is marked in the note, so silence is never left unexplained.
|
|
640
|
+
let previewNote = null;
|
|
641
|
+
const previewNoteText = ({ tracks, album, failed }) => {
|
|
642
|
+
const link = (url) => `[Apple Music](${String(url).replaceAll("(", "%28").replaceAll(")", "%29")})`;
|
|
643
|
+
const lines = [`${theme.plain ? "" : "♪ "}${tr("30-second previews from Apple Music")}`];
|
|
644
|
+
if (album?.title) {
|
|
645
|
+
lines.push([`${safeInlineText(album.title)}${album.artist_credit ? ` - ${safeInlineText(album.artist_credit)}` : ""}`,
|
|
646
|
+
...(album.catalog_url ? [link(album.catalog_url)] : [])].join(" · "));
|
|
647
|
+
}
|
|
648
|
+
lines.push("", ...tracks.map((track, index) => {
|
|
649
|
+
const artist = track.artist_credit && track.artist_credit !== album?.artist_credit ? ` - ${safeInlineText(track.artist_credit)}` : "";
|
|
650
|
+
return `${index + 1}. ${safeInlineText(track.title)}${artist} · ${link(track.catalog_url)}${failed.has(index) ? ` · _${tr("couldn't load")}_` : ""}`;
|
|
651
|
+
}));
|
|
652
|
+
if (tracks.length && failed.size === tracks.length) lines.push("", `_${tr("None of these previews could load. Check your connection, then ask again.")}_`);
|
|
653
|
+
return sanitizeTerminalText(lines.join("\n"));
|
|
654
|
+
};
|
|
655
|
+
const addPreviewNote = ({ tracks = [], album = null } = {}) => {
|
|
656
|
+
previewNote = { tracks, album, failed: new Set() };
|
|
657
|
+
previewNote.markdown = new Markdown(previewNoteText(previewNote), 1, 1, markdownTheme, textStyle);
|
|
658
|
+
transcript.addChild(previewNote.markdown);
|
|
659
|
+
tui.requestRender();
|
|
660
|
+
};
|
|
661
|
+
const markPreviewFailed = ({ position } = {}) => {
|
|
662
|
+
if (!previewNote || !Number.isInteger(position) || position < 1 || position > previewNote.tracks.length) return;
|
|
663
|
+
previewNote.failed.add(position - 1);
|
|
664
|
+
previewNote.markdown.setText(previewNoteText(previewNote));
|
|
665
|
+
};
|
|
666
|
+
const stopWatchingPreviews = previewControls ? previewPlayer.subscribe((event) => {
|
|
667
|
+
if (cleanedUp) return;
|
|
668
|
+
if (event?.type === "queue") addPreviewNote(event);
|
|
669
|
+
else if (event?.type === "failed") markPreviewFailed(event);
|
|
670
|
+
else if (["loading", "playing", "paused"].includes(event?.type)) nowPlaying = event;
|
|
671
|
+
else if (event?.type === "stopped") nowPlaying = null;
|
|
672
|
+
tui.requestRender();
|
|
673
|
+
}) : null;
|
|
674
|
+
// These act at once, even while Moondog is answering.
|
|
675
|
+
const controlPreviews = (command) => {
|
|
676
|
+
const acted = command === "stop" ? previewPlayer.stop() : previewPlayer.skip();
|
|
677
|
+
if (!busy) setFooter(!acted ? tr("No previews are playing.") : command === "stop" ? tr("Previews stopped.") : tr("Skipped."));
|
|
678
|
+
tui.requestRender();
|
|
679
|
+
};
|
|
489
680
|
|
|
490
681
|
const removeLifecycleListeners = () => {
|
|
491
682
|
signalTarget.removeListener("SIGTERM", handleSigterm);
|
|
@@ -498,14 +689,19 @@ export async function runMoondogTui({
|
|
|
498
689
|
|
|
499
690
|
const cleanup = () => {
|
|
500
691
|
if (cleanedUp) return;
|
|
692
|
+
try { application.conversationPending?.({ draft: editor.getText(), queued: [...messageQueue] }); } catch { /* Terminal cleanup must still run when storage is unavailable. */ }
|
|
501
693
|
cleanedUp = true;
|
|
694
|
+
clearTimeout(watchTimer);
|
|
695
|
+
stopWatchingPreviews?.();
|
|
502
696
|
clearInterval(indicator);
|
|
503
697
|
indicator = null;
|
|
504
698
|
clearInterval(homeMotion);
|
|
505
699
|
homeMotion = null;
|
|
506
700
|
localCommandController?.abort();
|
|
507
701
|
importAuthController?.abort();
|
|
702
|
+
lastfmController?.abort();
|
|
508
703
|
lyricController?.abort();
|
|
704
|
+
updateCheckController.abort();
|
|
509
705
|
preparedImport?.close?.();
|
|
510
706
|
preparedImport = null;
|
|
511
707
|
removeLifecycleListeners();
|
|
@@ -564,7 +760,7 @@ export async function runMoondogTui({
|
|
|
564
760
|
|
|
565
761
|
const choose = async (items, currentValue, title) => {
|
|
566
762
|
if (items.length === 0) return undefined;
|
|
567
|
-
const menu = new ListeningMenu({ items, currentValue, title, getTheme, getRows: () => terminal.rows });
|
|
763
|
+
const menu = new ListeningMenu({ items, currentValue, title, getTheme, getText: () => tr, getRows: () => terminal.rows });
|
|
568
764
|
return await new Promise((resolve) => {
|
|
569
765
|
let overlay;
|
|
570
766
|
const finish = (value) => {
|
|
@@ -592,7 +788,7 @@ export async function runMoondogTui({
|
|
|
592
788
|
homeVisible = profileReturnHome;
|
|
593
789
|
homeFocused = false;
|
|
594
790
|
tui.setFocus(editor);
|
|
595
|
-
setFooter("Profile closed · /taste opens it again.");
|
|
791
|
+
setFooter(tr("Profile closed · /taste opens it again."));
|
|
596
792
|
tui.requestRender(true);
|
|
597
793
|
};
|
|
598
794
|
|
|
@@ -600,7 +796,7 @@ export async function runMoondogTui({
|
|
|
600
796
|
const snapshot = await application.getProfileSummary({ maxItems: 10 });
|
|
601
797
|
if (cleanedUp || !profileView) return;
|
|
602
798
|
profileSnapshot = snapshot;
|
|
603
|
-
profileView.setModel(buildTasteProfileModel(snapshot));
|
|
799
|
+
profileView.setModel(buildTasteProfileModel(snapshot, tr));
|
|
604
800
|
profileNeedsRefresh = false;
|
|
605
801
|
void updateHomeLyrics();
|
|
606
802
|
renderHeader();
|
|
@@ -612,7 +808,7 @@ export async function runMoondogTui({
|
|
|
612
808
|
try {
|
|
613
809
|
await action();
|
|
614
810
|
} catch (error) {
|
|
615
|
-
if (!cleanedUp) setFooter(
|
|
811
|
+
if (!cleanedUp) setFooter(tr("That didn't work: {error}", { error: error.message }), failure);
|
|
616
812
|
} finally {
|
|
617
813
|
setBusy(false);
|
|
618
814
|
if (!cleanedUp && profileView && !tui.hasOverlay()) tui.setFocus(profileView);
|
|
@@ -622,67 +818,70 @@ export async function runMoondogTui({
|
|
|
622
818
|
const inspectProfileSubject = async (subject) => {
|
|
623
819
|
if (profileNeedsRefresh) {
|
|
624
820
|
await refreshProfile();
|
|
625
|
-
setFooter("Profile updated. Pick a song or artist.",
|
|
821
|
+
setFooter(tr("Profile updated. Pick a song or artist."), success);
|
|
626
822
|
return;
|
|
627
823
|
}
|
|
628
824
|
const evidenceItems = subject.evidence?.length ? subject.evidence
|
|
629
|
-
: subject.evidenceId ? [{ id: subject.evidenceId, source: "From your profile" }] : [];
|
|
825
|
+
: subject.evidenceId ? [{ id: subject.evidenceId, source: tr("From your profile") }] : [];
|
|
630
826
|
const selected = await choose([
|
|
631
827
|
...(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",
|
|
828
|
+
value: "discover", label: tr("Find more like this"), description: tr("Start a request from this track that you can edit"),
|
|
633
829
|
}] : []),
|
|
634
|
-
...(evidenceItems.length ? [{ value: "evidence", label: "Why it's here", description: "Where this comes from and what it can't tell me" }] : []),
|
|
830
|
+
...(evidenceItems.length ? [{ value: "evidence", label: tr("Why it's here"), description: tr("Where this comes from and what it can't tell me") }] : []),
|
|
635
831
|
...(typeof runProfileAction === "function" ? [
|
|
636
|
-
{ value: "like", label: "I like this", description: "Tell Moondog you like this "
|
|
637
|
-
{ value: "avoid", label: "Keep it out", description: "Leave this
|
|
638
|
-
...(subject.correctionId ? [{ value: "retract", label: "Undo my choice", description: "Go back to what your listening says" }] : []),
|
|
832
|
+
{ value: "like", label: tr("I like this"), description: subject.kind === "track" ? tr("Tell Moondog you like this track") : tr("Tell Moondog you like this artist") },
|
|
833
|
+
{ value: "avoid", label: tr("Keep it out"), description: subject.kind === "track" ? tr("Leave this track out of suggestions") : tr("Leave this artist out of suggestions") },
|
|
834
|
+
...(subject.correctionId ? [{ value: "retract", label: tr("Undo my choice"), description: tr("Go back to what your listening says") }] : []),
|
|
639
835
|
] : []),
|
|
640
|
-
{ value: "back", label: "Back", description: "Keep looking around" },
|
|
836
|
+
{ value: "back", label: tr("Back"), description: tr("Keep looking around") },
|
|
641
837
|
], undefined, subject.label);
|
|
642
838
|
if (!selected || selected.value === "back" || cleanedUp || !profileView) return;
|
|
643
839
|
if (selected.value === "discover") {
|
|
644
840
|
const draft = editor.getText();
|
|
645
841
|
if (draft.trimStart().startsWith("/")) {
|
|
646
|
-
setFooter("You have a command half-typed. Finish or clear it first.",
|
|
842
|
+
setFooter(tr("You have a command half-typed. Finish or clear it first."), warning);
|
|
647
843
|
return;
|
|
648
844
|
}
|
|
649
845
|
const target = subject.target;
|
|
650
846
|
const context = `Track: ${JSON.stringify(sanitizeTerminalText(target.label))}\nArtist: ${JSON.stringify(sanitizeTerminalText(target.artistCredit))}`;
|
|
651
847
|
const text = profileDiscoveryDraft && draft.includes(profileDiscoveryDraft.context)
|
|
652
848
|
? 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}`;
|
|
849
|
+
: `${draft || tr("Find me 3 songs to go to from here, and tell me why each one.")}\n\n${context}`;
|
|
654
850
|
profileDiscoveryDraft = { context, target: { ...target } };
|
|
655
851
|
editor.setText(text);
|
|
656
852
|
profileReturnHome = false;
|
|
657
853
|
closeProfile();
|
|
658
854
|
enterConversation();
|
|
659
855
|
setFooter(runtimeStatus.state === "configured"
|
|
660
|
-
? "Request ready · edit it, then press Enter."
|
|
661
|
-
: "Request ready · /model connects a model first.", runtimeStatus.state === "configured" ?
|
|
856
|
+
? tr("Request ready · edit it, then press Enter.")
|
|
857
|
+
: tr("Request ready · /model connects a model first."), runtimeStatus.state === "configured" ? success : warning);
|
|
662
858
|
return;
|
|
663
859
|
}
|
|
664
860
|
if (selected.value === "evidence") {
|
|
665
861
|
let evidence = evidenceItems[0];
|
|
666
862
|
if (evidenceItems.length > 1) {
|
|
667
863
|
const choice = await choose(evidenceItems.map((item) => ({
|
|
668
|
-
value: item.id, label: item.source, description: "From your profile",
|
|
669
|
-
})), undefined, "Which evidence?");
|
|
864
|
+
value: item.id, label: item.source, description: tr("From your profile"),
|
|
865
|
+
})), undefined, tr("Which evidence?"));
|
|
670
866
|
if (!choice || cleanedUp) return;
|
|
671
867
|
evidence = evidenceItems.find((item) => item.id === choice.value);
|
|
672
868
|
}
|
|
673
869
|
const explanation = await application.explainProfileEvidence({ evidenceId: evidence.id });
|
|
674
870
|
if (cleanedUp || !profileView) return;
|
|
675
|
-
const
|
|
676
|
-
"Why it's here", evidence.source, "",
|
|
871
|
+
const explanationLines = [
|
|
872
|
+
tr("Why it's here"), evidence.source, "",
|
|
677
873
|
explanation.basis_summary,
|
|
678
|
-
"", "What it can't tell me", explanation.interpretation_limit,
|
|
679
|
-
...(Number.isFinite(explanation.confidence) ? ["",
|
|
680
|
-
"",
|
|
681
|
-
]
|
|
682
|
-
|
|
683
|
-
model
|
|
874
|
+
"", tr("What it can't tell me"), explanation.interpretation_limit,
|
|
875
|
+
...(Number.isFinite(explanation.confidence) ? ["", tr("How sure: {percent}%", { percent: Math.round(explanation.confidence * 100) })] : []),
|
|
876
|
+
"",
|
|
877
|
+
];
|
|
878
|
+
// Add to the rebuilt subject's own lines; the selected subject may still show an earlier explanation.
|
|
879
|
+
const model = buildTasteProfileModel(profileSnapshot, tr);
|
|
880
|
+
model.subjects = model.subjects.map((item) => item.key === subject.key ? {
|
|
881
|
+
...item, detailLines: [...explanationLines, ...item.detailLines].filter((line) => typeof line === "string").map(sanitizeTerminalText),
|
|
882
|
+
} : item);
|
|
684
883
|
profileView.setModel(model);
|
|
685
|
-
setFooter("Here's the evidence. PgUp/PgDn to scroll, Enter for actions.");
|
|
884
|
+
setFooter(tr("Here's the evidence. PgUp/PgDn to scroll, Enter for actions."));
|
|
686
885
|
return;
|
|
687
886
|
}
|
|
688
887
|
const target = subject.target;
|
|
@@ -690,35 +889,35 @@ export async function runMoondogTui({
|
|
|
690
889
|
: ["correct", ...(target.entityType === "track"
|
|
691
890
|
? ["--track", target.label, "--by", target.artistCredit]
|
|
692
891
|
: ["--artist", target.label]), `--${selected.value}`];
|
|
693
|
-
setFooter("Saving your choice..."
|
|
892
|
+
setFooter(tr("Saving your choice..."));
|
|
694
893
|
await runProfileAction(args);
|
|
695
894
|
if (cleanedUp || !profileView) return;
|
|
696
|
-
const receipt = selected.value === "retract" ?
|
|
697
|
-
:
|
|
698
|
-
const named =
|
|
895
|
+
const receipt = selected.value === "retract" ? tr("Undone: {name}", { name: subject.label })
|
|
896
|
+
: selected.value === "like" ? tr("Liked: {name}", { name: subject.label }) : tr("Kept out: {name}", { name: subject.label });
|
|
897
|
+
const named = subject.kind === "track" ? tr("{title} by {artist}", { title: subject.label, artist: target.artistCredit }) : subject.label;
|
|
699
898
|
addMoondogMessage(selected.value === "retract"
|
|
700
|
-
?
|
|
899
|
+
? tr("Done. I'll read {name} from your listening again, without your earlier choice.", { name: named })
|
|
701
900
|
: selected.value === "like"
|
|
702
|
-
?
|
|
703
|
-
:
|
|
901
|
+
? tr("Noted. You like {name}.\n\nYour listening history stays as it was.", { name: named })
|
|
902
|
+
: tr("Noted. I'll leave {name} out of suggestions.\n\nYour listening history stays as it was.", { name: named }));
|
|
704
903
|
profileReturnHome = false;
|
|
705
904
|
profileNeedsRefresh = true;
|
|
706
905
|
try {
|
|
707
906
|
await refreshProfile();
|
|
708
|
-
setFooter(
|
|
907
|
+
setFooter(tr("{receipt}.", { receipt }), success);
|
|
709
908
|
} catch (error) {
|
|
710
|
-
setFooter(
|
|
909
|
+
setFooter(tr("{receipt}. The profile didn't refresh; Ctrl+R tries again.", { receipt }), warning);
|
|
711
910
|
}
|
|
712
911
|
};
|
|
713
912
|
|
|
714
913
|
const openProfile = async ({ imported = false } = {}) => {
|
|
715
914
|
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.");
|
|
915
|
+
addMoondogMessage(tr("## 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."));
|
|
916
|
+
setFooter(tr("No profile yet. /import brings in your history."));
|
|
718
917
|
return;
|
|
719
918
|
}
|
|
720
919
|
if (typeof application.getProfileSummary !== "function") {
|
|
721
|
-
|
|
920
|
+
addTasteReport(await application.runLocalCommand("taste", runtimeStatus));
|
|
722
921
|
return;
|
|
723
922
|
}
|
|
724
923
|
const snapshot = await application.getProfileSummary({ maxItems: 10 });
|
|
@@ -727,30 +926,32 @@ export async function runMoondogTui({
|
|
|
727
926
|
profileNeedsRefresh = false;
|
|
728
927
|
profileReturnHome = homeVisible;
|
|
729
928
|
profileView = new TasteProfileView({
|
|
730
|
-
model: buildTasteProfileModel(snapshot), getTheme,
|
|
929
|
+
model: buildTasteProfileModel(snapshot, tr), getTheme, getText: () => tr,
|
|
731
930
|
getRows: () => Math.max(1, terminal.rows - 2 - (terminal.rows >= 20 ? 2 : 1)),
|
|
732
931
|
});
|
|
733
932
|
profileView.onSelect = (subject) => { void profileTask(() => inspectProfileSubject(subject)); };
|
|
734
933
|
profileView.onClose = closeProfile;
|
|
735
934
|
profileView.onRefresh = () => { void profileTask(async () => {
|
|
736
935
|
await refreshProfile();
|
|
737
|
-
setFooter("Profile updated.",
|
|
936
|
+
setFooter(tr("Profile updated."), success);
|
|
738
937
|
}); };
|
|
739
938
|
profileView.onReport = () => {
|
|
740
939
|
const snapshot = profileSnapshot;
|
|
741
940
|
closeProfile();
|
|
742
|
-
|
|
743
|
-
setFooter("Full report above. /taste opens the profile again.");
|
|
941
|
+
addTasteReport(snapshot);
|
|
942
|
+
setFooter(tr("Full report above. /taste opens the profile again."));
|
|
744
943
|
};
|
|
745
944
|
homeFocused = false;
|
|
746
945
|
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.",
|
|
946
|
+
setFooter(imported ? tr("Imported. Here's your profile with everything so far.") : tr("Pick a song or artist. Enter shows why it's here."), success);
|
|
748
947
|
tui.requestRender(true);
|
|
749
948
|
};
|
|
750
949
|
|
|
950
|
+
// A model switch keeps the saved effort; the new runtime limits it to what the model supports.
|
|
951
|
+
const runtimeSelection = (provider, model, effort) => ({ provider, model, ...(effort ? { effort } : {}) });
|
|
751
952
|
const replaceRuntime = async (selection) => {
|
|
752
953
|
if (typeof rebuildRuntime !== "function") {
|
|
753
|
-
throw new Error("Runtime rebuilding is unavailable in this launch mode.");
|
|
954
|
+
throw new Error(tr("Runtime rebuilding is unavailable in this launch mode."));
|
|
754
955
|
}
|
|
755
956
|
const nextRuntime = await rebuildRuntime(selection);
|
|
756
957
|
const nextStatus = nextRuntime.publicStatus();
|
|
@@ -765,9 +966,29 @@ export async function runMoondogTui({
|
|
|
765
966
|
return nextStatus;
|
|
766
967
|
};
|
|
767
968
|
|
|
969
|
+
const refreshCatalog = async (options) => {
|
|
970
|
+
try { return await refreshModels(options); }
|
|
971
|
+
catch (error) {
|
|
972
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
973
|
+
return [{ provider: options.provider ?? "catalog", state: "failed" }];
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
768
977
|
const selectModel = async (args) => {
|
|
769
|
-
if (args.length > 2)
|
|
770
|
-
|
|
978
|
+
if (args.length > 2) throw new Error(tr("Usage: /model [provider] [model], or /model refresh [provider]."));
|
|
979
|
+
const describeRefresh = results => results.map(result => [`${result.provider}: ${result.state}`,
|
|
980
|
+
result.count !== undefined ? ` (${tr.n(result.count, "{count} current model", "{count} current models")})` : "",
|
|
981
|
+
result.skipped ? `; ${tr.n(result.skipped, "{count} incompatible entry skipped", "{count} incompatible entries skipped")}` : "",
|
|
982
|
+
result.state === "failed" ? `; ${tr("previous catalog retained")}` : ""].join("")).join("\n");
|
|
983
|
+
if (args[0]?.toLowerCase() === "refresh") {
|
|
984
|
+
if (!refreshModels) throw new Error(tr("Catalog refresh is unavailable in this launch mode."));
|
|
985
|
+
setFooter(tr("Refreshing public model metadata... Ctrl+C cancels."));
|
|
986
|
+
const results = await refreshCatalog({ provider: args[1]?.toLowerCase(), force: true, signal: localCommandController?.signal });
|
|
987
|
+
if (cleanedUp) return;
|
|
988
|
+
enterConversation();
|
|
989
|
+
addMoondogMessage(tr("Model choices refreshed from Pi’s public catalog (pi.dev).\n\n{results}\n\nYour selected model and conversation are unchanged.", { results: describeRefresh(results) }));
|
|
990
|
+
setFooter(tr("Catalog checked. /model opens the updated choices."));
|
|
991
|
+
return;
|
|
771
992
|
}
|
|
772
993
|
|
|
773
994
|
const availableProviders = providers()
|
|
@@ -788,43 +1009,51 @@ export async function runMoondogTui({
|
|
|
788
1009
|
while (true) {
|
|
789
1010
|
if (providerId) {
|
|
790
1011
|
if (!availableProviders.some((provider) => provider.id === providerId)) {
|
|
791
|
-
throw new Error(
|
|
1012
|
+
throw new Error(tr("Unknown Pi provider: {provider}", { provider: providerId }));
|
|
792
1013
|
}
|
|
793
1014
|
} else {
|
|
794
1015
|
const selectedProvider = await choose(
|
|
795
1016
|
availableProviders.map((provider) => ({
|
|
796
1017
|
value: provider.id, label: provider.id,
|
|
797
|
-
description: `${provider.name} - ${provider.modelCount} models`,
|
|
1018
|
+
description: `${provider.name} - ${tr.n(provider.modelCount, "{count} model", "{count} models")}`,
|
|
798
1019
|
})),
|
|
799
1020
|
runtimeStatus.provider,
|
|
800
|
-
"Choose a model provider",
|
|
1021
|
+
tr("Choose a model provider"),
|
|
801
1022
|
);
|
|
802
1023
|
if (!selectedProvider) {
|
|
803
|
-
setFooter("Model unchanged."
|
|
1024
|
+
setFooter(tr("Model unchanged."));
|
|
804
1025
|
return;
|
|
805
1026
|
}
|
|
806
1027
|
providerId = selectedProvider.value;
|
|
807
1028
|
}
|
|
1029
|
+
let catalogNote = "";
|
|
1030
|
+
if (refreshModels) {
|
|
1031
|
+
setFooter(tr("Checking public model catalog... Ctrl+C cancels."));
|
|
1032
|
+
const results = await refreshCatalog({ provider: providerId, signal: localCommandController?.signal });
|
|
1033
|
+
if (cleanedUp) return;
|
|
1034
|
+
if (results.some(result => result.state === "failed")) catalogNote = tr(" (cached/bundled fallback)");
|
|
1035
|
+
setFooter(results.some(result => result.state === "failed") ? tr("Catalog unavailable; showing retained choices. /model refresh retries.") : tr("Choose a model. Esc goes back."));
|
|
1036
|
+
}
|
|
808
1037
|
const availableModels = models(providerId);
|
|
809
1038
|
if (modelId) {
|
|
810
1039
|
if (!availableModels.some((model) => model.id === modelId)) {
|
|
811
|
-
throw new Error(
|
|
1040
|
+
throw new Error(tr("Unknown {provider} model: {model}", { provider: providerId, model: modelId }));
|
|
812
1041
|
}
|
|
813
1042
|
} else {
|
|
814
1043
|
const selectedModel = await choose(
|
|
815
1044
|
availableModels.map((model) => ({
|
|
816
1045
|
value: model.id, label: model.id,
|
|
817
|
-
description: `${model.name}${model.reasoning ? " - reasoning" : ""}`,
|
|
1046
|
+
description: `${model.name}${model.reasoning ? tr(" - reasoning") : ""}${model.catalogNote ? ` - ${model.catalogNote}` : ""}`,
|
|
818
1047
|
})),
|
|
819
1048
|
runtimeStatus.provider === providerId ? runtimeStatus.model : undefined,
|
|
820
|
-
|
|
1049
|
+
tr("Choose a model for {provider}{note}", { provider: providerId, note: catalogNote }),
|
|
821
1050
|
);
|
|
822
1051
|
if (!selectedModel) {
|
|
823
1052
|
if (!args[0] && availableModels.length && !cleanedUp) {
|
|
824
1053
|
providerId = undefined;
|
|
825
1054
|
continue;
|
|
826
1055
|
}
|
|
827
|
-
setFooter("Model unchanged."
|
|
1056
|
+
setFooter(tr("Model unchanged."));
|
|
828
1057
|
return;
|
|
829
1058
|
}
|
|
830
1059
|
modelId = selectedModel.value;
|
|
@@ -832,52 +1061,97 @@ export async function runMoondogTui({
|
|
|
832
1061
|
break;
|
|
833
1062
|
}
|
|
834
1063
|
|
|
835
|
-
|
|
836
|
-
|
|
1064
|
+
localCommandController?.signal.throwIfAborted();
|
|
1065
|
+
// Saving settings and replacing a runtime is one existing switch transaction.
|
|
1066
|
+
// Only the preceding catalog check is cancellable; never claim an aborted
|
|
1067
|
+
// switch after its settings write or authentication work has begun.
|
|
1068
|
+
setBusy(true);
|
|
1069
|
+
setFooter(tr("Loading {model}...", { model: `${providerId}/${modelId}` }));
|
|
1070
|
+
const status = await replaceRuntime(runtimeSelection(providerId, modelId, runtimeStatus.effort));
|
|
837
1071
|
addMoondogMessage(
|
|
838
1072
|
status.state === "configured"
|
|
839
|
-
?
|
|
1073
|
+
? tr("Now talking through `{model}`. This conversation and what I remember carry over.", { model: `${providerId}/${modelId}` })
|
|
840
1074
|
: status.reason === "provider_authentication_required"
|
|
841
|
-
?
|
|
842
|
-
:
|
|
1075
|
+
? tr("Saved `{model}`, but you're not signed in yet. Run `/auth {provider}` to connect it.", { model: `${providerId}/${modelId}`, provider: providerId })
|
|
1076
|
+
: tr("Saved `{model}`, but I can't reach it yet (`{reason}`).", { model: `${providerId}/${modelId}`, reason: status.reason }),
|
|
843
1077
|
);
|
|
844
1078
|
setFooter(
|
|
845
|
-
status.state === "configured" ? "Model ready." : "Model saved, not connected yet.",
|
|
846
|
-
status.state === "configured" ?
|
|
1079
|
+
status.state === "configured" ? tr("Model ready.") : tr("Model saved, not connected yet."),
|
|
1080
|
+
status.state === "configured" ? success : warning,
|
|
847
1081
|
);
|
|
848
1082
|
};
|
|
849
1083
|
|
|
1084
|
+
const selectEffort = async (args) => {
|
|
1085
|
+
if (args.length > 1) throw new Error(tr("Use /effort, or /effort <level>."));
|
|
1086
|
+
if (runtimeStatus.state !== "configured") throw new Error(tr("Choose a model with /model first."));
|
|
1087
|
+
const model = `${runtimeStatus.provider}/${runtimeStatus.model}`;
|
|
1088
|
+
const levels = runtimeStatus.effort_levels ?? [];
|
|
1089
|
+
if (levels.length < 2) throw new Error(tr("`{model}` has no effort levels to choose from.", { model }));
|
|
1090
|
+
let effort = args[0]?.toLowerCase();
|
|
1091
|
+
if (effort && effort !== "default" && !levels.includes(effort)) {
|
|
1092
|
+
throw new Error(tr("`{model}` supports: {levels}, or default.", { model, levels: levels.join(", ") }));
|
|
1093
|
+
}
|
|
1094
|
+
if (!effort) {
|
|
1095
|
+
const descriptions = {
|
|
1096
|
+
off: tr("No extra thinking"),
|
|
1097
|
+
minimal: tr("The least thinking"),
|
|
1098
|
+
low: tr("Quick answers"),
|
|
1099
|
+
medium: tr("Between speed and depth"),
|
|
1100
|
+
high: tr("Thinks more before answering"),
|
|
1101
|
+
xhigh: tr("Thinks longer; slower and costs more"),
|
|
1102
|
+
max: tr("Thinks the most; slowest and costs the most"),
|
|
1103
|
+
};
|
|
1104
|
+
const selected = await choose([
|
|
1105
|
+
{ value: "default", label: "default", description: tr("Moondog's default: thinking off where the model allows it") },
|
|
1106
|
+
...levels.map(level => ({ value: level, label: level, description: descriptions[level] ?? "" })),
|
|
1107
|
+
], runtimeStatus.effort ?? "default", tr("Choose how hard {model} thinks", { model }));
|
|
1108
|
+
if (!selected) {
|
|
1109
|
+
setFooter(tr("Effort unchanged."));
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
effort = selected.value;
|
|
1113
|
+
}
|
|
1114
|
+
setBusy(true);
|
|
1115
|
+
const status = await replaceRuntime(runtimeSelection(runtimeStatus.provider, runtimeStatus.model,
|
|
1116
|
+
effort === "default" ? undefined : effort));
|
|
1117
|
+
addMoondogMessage(status.thinking_level
|
|
1118
|
+
? tr("`{model}` now thinks at `{effort}`. This conversation carries over.", { model, effort: status.thinking_level })
|
|
1119
|
+
: tr("`{model}` is back to Moondog's default effort.", { model }));
|
|
1120
|
+
setFooter(tr("Effort saved."), success);
|
|
1121
|
+
};
|
|
1122
|
+
|
|
850
1123
|
const authenticate = async (args) => {
|
|
851
1124
|
if (args.length > 1) {
|
|
852
|
-
throw new Error("Use /auth or /auth <provider>. Type API keys only into the hidden prompt that follows.");
|
|
1125
|
+
throw new Error(tr("Use /auth or /auth <provider>. Type API keys only into the hidden prompt that follows."));
|
|
853
1126
|
}
|
|
854
1127
|
if (typeof runAuth !== "function") {
|
|
855
|
-
throw new Error("Sign-in isn't available when Moondog is started this way.");
|
|
1128
|
+
throw new Error(tr("Sign-in isn't available when Moondog is started this way."));
|
|
856
1129
|
}
|
|
857
1130
|
|
|
858
|
-
const availableProviders = providers().filter((provider) =>
|
|
1131
|
+
const availableProviders = [...providers(), ...serviceKeyProviders].filter((provider) =>
|
|
859
1132
|
supportedAuthProviderIds.includes(provider.id),
|
|
860
1133
|
);
|
|
1134
|
+
const serviceKey = (id) => serviceKeyProviders.find((provider) => provider.id === id);
|
|
861
1135
|
let providerId = args[0]?.toLowerCase() ?? runtimeStatus.provider;
|
|
862
1136
|
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.");
|
|
1137
|
+
if (args[0]) throw new Error(tr("I can't sign in to that provider. Type /auth to see the ones I can."));
|
|
864
1138
|
const selectedProvider = await choose(
|
|
865
1139
|
availableProviders.map((provider) => ({
|
|
866
1140
|
value: provider.id,
|
|
867
1141
|
label: provider.name,
|
|
868
|
-
description: `${provider.id} - ${provider.id === "openai-codex" ? "ChatGPT sign-in" : "API key"}`,
|
|
1142
|
+
description: `${provider.id} - ${provider.id === "openai-codex" ? tr("ChatGPT sign-in") : serviceKey(provider.id) ? tr("Web search key") : tr("API key")}`,
|
|
869
1143
|
})),
|
|
870
1144
|
runtimeStatus.provider ?? "openai-codex",
|
|
871
|
-
"Sign in to a model
|
|
1145
|
+
tr("Sign in to a model or web search service"),
|
|
872
1146
|
);
|
|
873
1147
|
if (!selectedProvider) {
|
|
874
|
-
setFooter("Sign-in cancelled."
|
|
1148
|
+
setFooter(tr("Sign-in cancelled."));
|
|
875
1149
|
return;
|
|
876
1150
|
}
|
|
877
1151
|
providerId = selectedProvider.value;
|
|
878
1152
|
}
|
|
879
1153
|
|
|
880
|
-
setFooter(
|
|
1154
|
+
setFooter(tr("Opening {provider} sign-in...", { provider: providerId }));
|
|
881
1155
|
tui.stop({ preserveScreen: true });
|
|
882
1156
|
try {
|
|
883
1157
|
await runAuth(providerId);
|
|
@@ -892,36 +1166,40 @@ export async function runMoondogTui({
|
|
|
892
1166
|
|
|
893
1167
|
if (typeof rebuildRuntime === "function") {
|
|
894
1168
|
const currentSelection = runtimeStatus.provider && runtimeStatus.model
|
|
895
|
-
?
|
|
1169
|
+
? runtimeSelection(runtimeStatus.provider, runtimeStatus.model, runtimeStatus.effort)
|
|
896
1170
|
: undefined;
|
|
897
1171
|
const status = await replaceRuntime(currentSelection);
|
|
1172
|
+
const search = application.webLookup?.("search");
|
|
898
1173
|
addMoondogMessage(
|
|
899
|
-
|
|
900
|
-
?
|
|
901
|
-
|
|
1174
|
+
serviceKey(providerId)
|
|
1175
|
+
? search
|
|
1176
|
+
? tr("Saved the {service} key. Web search goes through {provider}.", { service: serviceKey(providerId).name, provider: search.name })
|
|
1177
|
+
: tr("Saved the {service} key, but web search still isn't ready. Run `/web status` to see why.", { service: serviceKey(providerId).name })
|
|
1178
|
+
: status.state === "configured"
|
|
1179
|
+
? tr("Signed in. Talking through `{model}`.", { model: `${status.provider}/${status.model}` })
|
|
1180
|
+
: tr("Signed in. Run `/model {provider}` to pick a model.", { provider: providerId }),
|
|
902
1181
|
);
|
|
903
1182
|
} else {
|
|
904
|
-
addMoondogMessage("Signed in. Restart Moondog to use it.");
|
|
1183
|
+
addMoondogMessage(tr("Signed in. Restart Moondog to use it."));
|
|
905
1184
|
}
|
|
906
|
-
setFooter("Signed in.",
|
|
1185
|
+
setFooter(tr("Signed in."), success);
|
|
907
1186
|
};
|
|
908
1187
|
|
|
909
1188
|
const showImportedListeningProfile = async () => {
|
|
910
1189
|
void updateHomeLyrics();
|
|
911
1190
|
try {
|
|
912
1191
|
const profile = await application.runLocalCommand("taste", runtimeStatus);
|
|
913
|
-
|
|
914
|
-
"Here's everything I can read so far, including anything you imported before.",
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
].join("\n\n"));
|
|
1192
|
+
addTasteReport(profile, {
|
|
1193
|
+
before: tr("Here's everything I can read so far, including anything you imported before."),
|
|
1194
|
+
after: tr("Type `/taste` to open any song or artist and see why it's here. `/taste report` brings this report back."),
|
|
1195
|
+
});
|
|
918
1196
|
if (typeof application.getProfileSummary === "function") await openProfile({ imported: true });
|
|
919
|
-
else setFooter("Imported. /taste opens your profile.",
|
|
1197
|
+
else setFooter(tr("Imported. /taste opens your profile."), success);
|
|
920
1198
|
} catch (error) {
|
|
921
1199
|
addMoondogMessage(
|
|
922
|
-
|
|
1200
|
+
tr("Your history was imported, but I couldn't show the profile: {error}\n\nNothing was lost. Type `/taste` to try again.", { error: error.message }),
|
|
923
1201
|
);
|
|
924
|
-
setFooter("Imported. The profile didn't open; try /taste.",
|
|
1202
|
+
setFooter(tr("Imported. The profile didn't open; try /taste."), warning);
|
|
925
1203
|
}
|
|
926
1204
|
};
|
|
927
1205
|
|
|
@@ -932,7 +1210,7 @@ export async function runMoondogTui({
|
|
|
932
1210
|
|
|
933
1211
|
const setImportPage = (page, details = {}) => {
|
|
934
1212
|
importPage = page;
|
|
935
|
-
importView?.setState({ page, origin: importOrigin, provider: importProvider, spotify: application.spotifyStatus?.(), ...details });
|
|
1213
|
+
importView?.setState({ page, origin: importOrigin, provider: importProvider, spotify: application.spotifyStatus?.(), lastfm: lastfmAccess, ...details });
|
|
936
1214
|
tui.requestRender(true);
|
|
937
1215
|
};
|
|
938
1216
|
|
|
@@ -942,7 +1220,7 @@ export async function runMoondogTui({
|
|
|
942
1220
|
if (restore) homeVisible = importReturnHome;
|
|
943
1221
|
homeFocused = false;
|
|
944
1222
|
tui.setFocus(profileView ?? editor);
|
|
945
|
-
setFooter("Import closed. Nothing changed.");
|
|
1223
|
+
setFooter(tr("Import closed. Nothing changed."));
|
|
946
1224
|
tui.requestRender(true);
|
|
947
1225
|
};
|
|
948
1226
|
|
|
@@ -952,10 +1230,10 @@ export async function runMoondogTui({
|
|
|
952
1230
|
const view = importView;
|
|
953
1231
|
view.setPath(filePath);
|
|
954
1232
|
setBusy(true);
|
|
955
|
-
setImportPage("working", { message: "Reading your file..." });
|
|
956
|
-
setFooter("Reading the file. Nothing is added until you say so."
|
|
1233
|
+
setImportPage("working", { message: tr("Reading your file...") });
|
|
1234
|
+
setFooter(tr("Reading the file. Nothing is added until you say so."));
|
|
957
1235
|
try {
|
|
958
|
-
if (typeof prepareImport !== "function") throw new Error("Reading files isn't available when Moondog is started this way.");
|
|
1236
|
+
if (typeof prepareImport !== "function") throw new Error(tr("Reading files isn't available when Moondog is started this way."));
|
|
959
1237
|
const prepared = await prepareImport(filePath, { provider: importProvider });
|
|
960
1238
|
if (cleanedUp || importView !== view) { prepared.close?.(); return; }
|
|
961
1239
|
if (!importProvider) {
|
|
@@ -964,11 +1242,11 @@ export async function runMoondogTui({
|
|
|
964
1242
|
}
|
|
965
1243
|
preparedImport = prepared;
|
|
966
1244
|
setImportPage("preview", { preview: prepared.preview });
|
|
967
|
-
setFooter("Here's what's inside. Nothing is added until you import it.",
|
|
1245
|
+
setFooter(tr("Here's what's inside. Nothing is added until you import it."), success);
|
|
968
1246
|
} catch (error) {
|
|
969
1247
|
if (!cleanedUp && importView === view) {
|
|
970
1248
|
setImportPage("file", { error: sanitizeTerminalText(error.message) });
|
|
971
|
-
setFooter("Nothing imported. Your path is still there to fix.",
|
|
1249
|
+
setFooter(tr("Nothing imported. Your path is still there to fix."), warning);
|
|
972
1250
|
}
|
|
973
1251
|
} finally {
|
|
974
1252
|
setBusy(false);
|
|
@@ -981,32 +1259,106 @@ export async function runMoondogTui({
|
|
|
981
1259
|
importOrigin = "quick";
|
|
982
1260
|
const view = importView;
|
|
983
1261
|
setBusy(true);
|
|
984
|
-
setImportPage("working", { message: "Asking Spotify for your recent listening..." });
|
|
985
|
-
setFooter("Reading from Spotify. Nothing is added until you say so."
|
|
1262
|
+
setImportPage("working", { message: tr("Asking Spotify for your library and recent listening...") });
|
|
1263
|
+
setFooter(tr("Reading from Spotify. Nothing is added until you say so."));
|
|
1264
|
+
const stages = {
|
|
1265
|
+
saved_tracks: count => tr("Reading your Spotify library: {count} saved songs...", { count }),
|
|
1266
|
+
saved_albums: count => tr("Reading your Spotify library: {count} saved albums...", { count }),
|
|
1267
|
+
followed_artists: count => tr("Reading your Spotify library: {count} followed artists...", { count }),
|
|
1268
|
+
playlists: count => tr("Reading your Spotify library: {count} playlists...", { count }),
|
|
1269
|
+
playlist_items: count => tr("Reading your Spotify library: {count} playlist songs...", { count }),
|
|
1270
|
+
top_items: count => tr("Reading your Spotify library: {count} top artists and tracks...", { count }),
|
|
1271
|
+
};
|
|
1272
|
+
const onProgress = ({ stage, count }) => {
|
|
1273
|
+
if (cleanedUp || importView !== view || !stages[stage]) return;
|
|
1274
|
+
setImportPage("working", { message: stages[stage](tr.number(count)) });
|
|
1275
|
+
};
|
|
986
1276
|
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();
|
|
1277
|
+
if (typeof prepareRecentImport !== "function") throw new Error(tr("Recent listening isn't available when Moondog is started this way. A history ZIP still works."));
|
|
1278
|
+
const prepared = await prepareRecentImport({ onProgress });
|
|
989
1279
|
if (cleanedUp || importView !== view) { prepared.close?.(); return; }
|
|
990
|
-
if (!prepared.preview.listeningEvents) {
|
|
1280
|
+
if (!prepared.preview.listeningEvents && !prepared.preview.library?.profileEvidence) {
|
|
991
1281
|
prepared.close?.();
|
|
992
1282
|
setImportPage("empty");
|
|
993
|
-
setFooter("Nothing to import yet. Try again later, or bring a ZIP.");
|
|
1283
|
+
setFooter(tr("Nothing to import yet. Try again later, or bring a ZIP."));
|
|
994
1284
|
} else {
|
|
995
1285
|
preparedImport = prepared;
|
|
996
1286
|
setImportPage("preview", { preview: prepared.preview });
|
|
997
|
-
setFooter("Here's
|
|
1287
|
+
setFooter(tr("Here's what Spotify has. Nothing is added until you import it."), success);
|
|
998
1288
|
}
|
|
999
1289
|
} catch (error) {
|
|
1000
1290
|
if (cleanedUp || importView !== view) return;
|
|
1001
1291
|
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
|
|
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.",
|
|
1292
|
+
spotify_action_forbidden: tr("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."),
|
|
1293
|
+
spotify_scope_insufficient: tr("Spotify needs one more permission. Choose Reconnect Spotify."),
|
|
1294
|
+
spotify_authentication_required: tr("Your Spotify sign-in has expired. Choose Reconnect Spotify."),
|
|
1295
|
+
spotify_quota_exceeded: tr("Your Spotify app has used up its quota for now. A downloaded history ZIP still works."),
|
|
1296
|
+
spotify_rate_limited: tr("Spotify wants us to slow down. Wait a minute and try again, or use a history ZIP."),
|
|
1007
1297
|
};
|
|
1008
1298
|
setImportPage("quick", { error: messages[error.code] ?? sanitizeTerminalText(error.message) });
|
|
1009
|
-
setFooter("Nothing imported. Your profile is unchanged."
|
|
1299
|
+
setFooter(tr("Nothing imported. Your profile is unchanged."));
|
|
1300
|
+
} finally {
|
|
1301
|
+
setBusy(false);
|
|
1302
|
+
if (!cleanedUp && importView === view) tui.setFocus(view);
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
// Last.fm is read by username, newest scrobbles first, with live counts; Esc stops it.
|
|
1307
|
+
const inspectLastfm = async (username) => {
|
|
1308
|
+
discardPreparedImport();
|
|
1309
|
+
importOrigin = "lastfm";
|
|
1310
|
+
const view = importView;
|
|
1311
|
+
const controller = new AbortController();
|
|
1312
|
+
lastfmController = controller;
|
|
1313
|
+
setBusy(true);
|
|
1314
|
+
setImportPage("working", { message: tr("Asking Last.fm for your scrobbles..."), cancellable: true });
|
|
1315
|
+
setFooter(tr("Reading from Last.fm. Nothing is added until you say so."));
|
|
1316
|
+
const onProgress = ({ stage, count, total }) => {
|
|
1317
|
+
if (cleanedUp || importView !== view || controller.signal.aborted) return;
|
|
1318
|
+
const message = stage === "loved"
|
|
1319
|
+
? tr("Reading your loved tracks: {count}...", { count: tr.number(count) })
|
|
1320
|
+
: total > 0
|
|
1321
|
+
? tr("Reading your scrobbles: {count} of {total}...", { count: tr.number(count), total: tr.number(total) })
|
|
1322
|
+
: tr("Reading your scrobbles: {count}...", { count: tr.number(count) });
|
|
1323
|
+
setImportPage("working", { message, cancellable: true });
|
|
1324
|
+
};
|
|
1325
|
+
try {
|
|
1326
|
+
if (typeof prepareImport !== "function") throw new Error(tr("Reading Last.fm isn't available when Moondog is started this way."));
|
|
1327
|
+
const prepared = await prepareImport(username, { provider: "lastfm", onProgress, signal: controller.signal });
|
|
1328
|
+
if (cleanedUp || importView !== view) { prepared.close?.(); return; }
|
|
1329
|
+
preparedImport = prepared;
|
|
1330
|
+
setImportPage("preview", { preview: prepared.preview });
|
|
1331
|
+
setFooter(tr("Here's what Last.fm has. Nothing is added until you import it."), success);
|
|
1332
|
+
} catch (error) {
|
|
1333
|
+
if (cleanedUp || importView !== view) return;
|
|
1334
|
+
if (controller.signal.aborted) {
|
|
1335
|
+
setImportPage("lastfmUser");
|
|
1336
|
+
setFooter(tr("Stopped. Nothing imported."));
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const access = ["lastfm_key_missing", "lastfm_key_invalid"].includes(error?.code);
|
|
1340
|
+
setImportPage(access ? "lastfmSetup" : "lastfmUser", { error: sanitizeTerminalText(error.message) });
|
|
1341
|
+
setFooter(tr("Nothing imported. Your profile is unchanged."), warning);
|
|
1342
|
+
} finally {
|
|
1343
|
+
if (lastfmController === controller) lastfmController = null;
|
|
1344
|
+
setBusy(false);
|
|
1345
|
+
if (!cleanedUp && importView === view) tui.setFocus(view);
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
const configureLastfm = async (apiKey) => {
|
|
1350
|
+
const view = importView;
|
|
1351
|
+
setBusy(true);
|
|
1352
|
+
try {
|
|
1353
|
+
if (typeof lastfm?.save !== "function") throw new Error(tr("Last.fm access isn't available when Moondog is started this way."));
|
|
1354
|
+
lastfmAccess = await lastfm.save(apiKey);
|
|
1355
|
+
if (cleanedUp || importView !== view) return;
|
|
1356
|
+
setImportPage("lastfmUser");
|
|
1357
|
+
setFooter(tr("Last.fm access saved. Now type your username."), success);
|
|
1358
|
+
} catch (error) {
|
|
1359
|
+
if (cleanedUp || importView !== view) return;
|
|
1360
|
+
setImportPage("lastfmKey", { error: sanitizeTerminalText(error.message) });
|
|
1361
|
+
setFooter(tr("Nothing saved. Your key is still there to fix."), warning);
|
|
1010
1362
|
} finally {
|
|
1011
1363
|
setBusy(false);
|
|
1012
1364
|
if (!cleanedUp && importView === view) tui.setFocus(view);
|
|
@@ -1017,25 +1369,45 @@ export async function runMoondogTui({
|
|
|
1017
1369
|
const view = importView;
|
|
1018
1370
|
const configuring = clientId !== undefined;
|
|
1019
1371
|
setBusy(true);
|
|
1020
|
-
|
|
1021
|
-
importAuthController =
|
|
1022
|
-
|
|
1372
|
+
const controller = new AbortController();
|
|
1373
|
+
importAuthController = controller;
|
|
1374
|
+
setImportPage(configuring ? "working" : "signin", configuring ? { message: tr("Saving your Spotify app...") } : {});
|
|
1375
|
+
if (!configuring) setFooter(tr("Waiting for you to sign in to Spotify."));
|
|
1376
|
+
// Sign-in prints its authorization link; show it on the sign-in page instead of the raw terminal.
|
|
1377
|
+
const signinOutput = {
|
|
1378
|
+
write(value) {
|
|
1379
|
+
const url = String(value).match(/https:\/\/accounts\.spotify\.com\/\S+/u)?.[0];
|
|
1380
|
+
if (url && !cleanedUp && importView === view && importPage === "signin") setImportPage("signin", { url });
|
|
1381
|
+
},
|
|
1382
|
+
};
|
|
1023
1383
|
try {
|
|
1024
|
-
if (typeof runSpotify !== "function") throw new Error("Spotify isn't available when Moondog is started this way.");
|
|
1025
|
-
|
|
1384
|
+
if (typeof runSpotify !== "function") throw new Error(tr("Spotify isn't available when Moondog is started this way."));
|
|
1385
|
+
// One sign-in covers import and listening control, so a later connect never narrows it.
|
|
1386
|
+
await runSpotify(configuring ? ["configure", clientId] : ["login"], { signal: controller.signal, stderr: signinOutput });
|
|
1026
1387
|
if (cleanedUp || importView !== view) return;
|
|
1027
1388
|
if (typeof rebuildRuntime === "function") await replaceRuntime();
|
|
1028
1389
|
if (cleanedUp || importView !== view) return;
|
|
1029
|
-
|
|
1030
|
-
|
|
1390
|
+
if (!configuring) {
|
|
1391
|
+
// A development-mode app refuses accounts missing from User Management, and every account
|
|
1392
|
+
// when its owner lacks Premium. Find out now, not halfway through an import.
|
|
1393
|
+
setImportPage("working", { message: tr("Checking your Spotify account...") });
|
|
1394
|
+
await runSpotify(["account"], { signal: controller.signal });
|
|
1395
|
+
if (cleanedUp || importView !== view) return;
|
|
1396
|
+
}
|
|
1397
|
+
setImportPage("quick", { message: configuring ? tr("App saved. Now connect Spotify.") : tr("Spotify is connected. Next, preview what Moondog will bring in.") });
|
|
1398
|
+
setFooter(configuring ? tr("App saved. Nothing imported yet.") : tr("Connected. Nothing imported yet."), success);
|
|
1031
1399
|
} catch (error) {
|
|
1032
1400
|
if (!cleanedUp && importView === view) {
|
|
1033
|
-
|
|
1034
|
-
|
|
1401
|
+
const message = controller.signal.aborted
|
|
1402
|
+
? tr("Sign-in cancelled. If Spotify showed an error, check the redirect URI in your Spotify app, or choose Change Client ID.")
|
|
1403
|
+
: error?.code === "spotify_action_forbidden"
|
|
1404
|
+
? tr("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.")
|
|
1405
|
+
: sanitizeTerminalText(error.message);
|
|
1406
|
+
setImportPage(configuring ? "client" : "quick", { error: message });
|
|
1407
|
+
setFooter(tr("Nothing imported. Try again, or use a history ZIP."), warning);
|
|
1035
1408
|
}
|
|
1036
1409
|
} finally {
|
|
1037
1410
|
importAuthController = null;
|
|
1038
|
-
if (!configuring && !cleanedUp) { tui.start(); tui.requestRender(true); }
|
|
1039
1411
|
setBusy(false);
|
|
1040
1412
|
if (!cleanedUp && importView === view) tui.setFocus(view);
|
|
1041
1413
|
}
|
|
@@ -1048,30 +1420,50 @@ export async function runMoondogTui({
|
|
|
1048
1420
|
const view = importView;
|
|
1049
1421
|
let saved = false;
|
|
1050
1422
|
setBusy(true);
|
|
1051
|
-
setImportPage("working", { message: "Adding it to your profile..." });
|
|
1052
|
-
setFooter("Saving your history on this machine..."
|
|
1423
|
+
setImportPage("working", { message: tr("Adding it to your profile...") });
|
|
1424
|
+
setFooter(tr("Saving your history on this machine..."));
|
|
1053
1425
|
try {
|
|
1054
1426
|
const receipt = await pending.commit();
|
|
1055
1427
|
saved = true;
|
|
1056
1428
|
importNeedsRefresh = receipt;
|
|
1057
1429
|
discardPreparedImport();
|
|
1058
1430
|
if (cleanedUp) return;
|
|
1431
|
+
const insertedEvents = receipt.inserted_events ?? 0;
|
|
1432
|
+
const supersededEvents = receipt.superseded_events ?? 0;
|
|
1433
|
+
// Scrobbles that repeat another service's plays are kept but count once.
|
|
1434
|
+
const mirroredEvents = receipt.mirrored_events ?? 0;
|
|
1435
|
+
const playDelta = receipt.effective_event_delta ?? insertedEvents - supersededEvents;
|
|
1436
|
+
const playCountChange = playDelta === 0 ? tr("The profile's play count is unchanged.")
|
|
1437
|
+
: playDelta > 0 ? tr.n(playDelta, "The profile has {count} more play.", "The profile has {count} more plays.")
|
|
1438
|
+
: tr.n(-playDelta, "The profile has {count} fewer play after reconciling overlaps.", "The profile has {count} fewer plays after reconciling overlaps.");
|
|
1439
|
+
const evidence = receipt.inserted_profile_evidence ? preview.library
|
|
1440
|
+
? tr.n(receipt.inserted_profile_evidence, "{count} saved song, album, follow, playlist entry, or Spotify top pick.", "{count} saved songs, albums, follows, playlist entries, and Spotify top picks.")
|
|
1441
|
+
: tr.n(receipt.inserted_profile_evidence, "{count} saved song, follow, or playlist entry.", "{count} saved songs, follows, or playlist entries.") : null;
|
|
1059
1442
|
const counts = preview.kind === "library"
|
|
1060
|
-
? receipt.already_imported ? "You've imported this library before, so nothing changed."
|
|
1061
|
-
:
|
|
1062
|
-
:
|
|
1063
|
-
? "Already up to date. There was nothing new in this one."
|
|
1064
|
-
:
|
|
1443
|
+
? receipt.already_imported ? tr("You've imported this library before, so nothing changed.")
|
|
1444
|
+
: tr.n(receipt.library_tracks ?? preview.tracks, "Saved {count} song from your library. A library shows what you keep, not when you played it, so no plays were added.", "Saved {count} songs from your library. A library shows what you keep, not when you played it, so no plays were added.")
|
|
1445
|
+
: insertedEvents === 0 && !receipt.inserted_profile_evidence && !supersededEvents && !mirroredEvents
|
|
1446
|
+
? tr("Already up to date. There was nothing new in this one.")
|
|
1447
|
+
: [
|
|
1448
|
+
...(insertedEvents || supersededEvents || mirroredEvents || !evidence ? [supersededEvents || mirroredEvents
|
|
1449
|
+
? `${tr.n(insertedEvents, "Saved {count} new listening record.", "Saved {count} new listening records.")} ${playCountChange}`
|
|
1450
|
+
: receipt.duplicate_events
|
|
1451
|
+
? tr("{plays}, {duplicates} I already had.", { plays: tr.n(insertedEvents, "{count} new play", "{count} new plays"), duplicates: tr.number(receipt.duplicate_events) })
|
|
1452
|
+
: tr.n(insertedEvents, "{count} new play.", "{count} new plays.")] : []),
|
|
1453
|
+
...(evidence ? [insertedEvents || supersededEvents ? tr("Also {items}", { items: evidence }) : evidence] : []),
|
|
1454
|
+
].join(" ");
|
|
1065
1455
|
// Keep the durable result visible even if refreshing the runtime later fails.
|
|
1066
1456
|
addMoondogMessage([
|
|
1067
|
-
preview.kind === "library" ? "## Your Apple Music library is in" : preview.kind === "collection" ? "## Your collection is in"
|
|
1068
|
-
|
|
1457
|
+
preview.kind === "library" ? tr("## Your Apple Music library is in") : preview.kind === "collection" ? tr("## Your collection is in")
|
|
1458
|
+
: preview.library ? tr("## Your Spotify is in") : tr("## Your history is in"),
|
|
1459
|
+
[view?.previewSource(preview)[0] ?? preview.sourceLabel, preview.fileName].filter(Boolean).join(" · "),
|
|
1069
1460
|
counts,
|
|
1070
|
-
...(
|
|
1071
|
-
"
|
|
1461
|
+
...(supersededEvents ? [tr.n(supersededEvents, "Reconciled {count} overlapping record, keeping the more detailed listening evidence.", "Reconciled {count} overlapping records, keeping the more detailed listening evidence.")] : []),
|
|
1462
|
+
...(mirroredEvents ? [tr.n(mirroredEvents, "{count} play was already here from another service, so it counts once.", "{count} plays were already here from another service, so they count once.")] : []),
|
|
1463
|
+
tr("Everything from before is still here, including your choices. `/import` adds another source any time."),
|
|
1072
1464
|
].join("\n\n"));
|
|
1073
|
-
setImportPage("working", { message: "Saved. Reading it..." });
|
|
1074
|
-
setFooter("History saved. Updating your profile..."
|
|
1465
|
+
setImportPage("working", { message: tr("Saved. Reading it...") });
|
|
1466
|
+
setFooter(tr("History saved. Updating your profile..."));
|
|
1075
1467
|
await refreshImportedData?.(importNeedsRefresh);
|
|
1076
1468
|
if (cleanedUp) return;
|
|
1077
1469
|
if (typeof rebuildRuntime === "function") await replaceRuntime();
|
|
@@ -1087,11 +1479,11 @@ export async function runMoondogTui({
|
|
|
1087
1479
|
homeVisible = false;
|
|
1088
1480
|
profileView = null;
|
|
1089
1481
|
tui.setFocus(editor);
|
|
1090
|
-
addMoondogMessage(
|
|
1091
|
-
setFooter("History saved. The profile needs another try.",
|
|
1482
|
+
addMoondogMessage(tr("Your history is saved, but I couldn't update the profile: {error}\n\nNothing was lost. Try `/reload`, then `/taste`.", { error: sanitizeTerminalText(error.message) }));
|
|
1483
|
+
setFooter(tr("History saved. The profile needs another try."), warning);
|
|
1092
1484
|
} else {
|
|
1093
1485
|
setImportPage("preview", { preview, error: sanitizeTerminalText(error.message) });
|
|
1094
|
-
setFooter("Not saved. See the message above.",
|
|
1486
|
+
setFooter(tr("Not saved. See the message above."), warning);
|
|
1095
1487
|
}
|
|
1096
1488
|
} finally {
|
|
1097
1489
|
setBusy(false);
|
|
@@ -1099,11 +1491,13 @@ export async function runMoondogTui({
|
|
|
1099
1491
|
}
|
|
1100
1492
|
};
|
|
1101
1493
|
|
|
1102
|
-
const handleImportAction = async ({ type, path: filePath, clientId } = {}) => {
|
|
1494
|
+
const handleImportAction = async ({ type, path: filePath, clientId, username, apiKey } = {}) => {
|
|
1103
1495
|
if (busy || cleanedUp || !importView) return;
|
|
1104
1496
|
if (type === "inspect") { await inspectImport(filePath); return; }
|
|
1105
1497
|
if (type === "commit") { await commitInspectedImport(); return; }
|
|
1106
1498
|
if (type === "recent") { await inspectRecentListening(); return; }
|
|
1499
|
+
if (type === "inspectLastfm") { await inspectLastfm(username); return; }
|
|
1500
|
+
if (type === "configureLastfm") { await configureLastfm(apiKey); return; }
|
|
1107
1501
|
if (type === "connectSpotify" || type === "configureSpotify") {
|
|
1108
1502
|
await connectImportSpotify(type === "configureSpotify" ? clientId : undefined);
|
|
1109
1503
|
return;
|
|
@@ -1114,15 +1508,15 @@ export async function runMoondogTui({
|
|
|
1114
1508
|
await openProfile();
|
|
1115
1509
|
return;
|
|
1116
1510
|
}
|
|
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];
|
|
1511
|
+
if (["openSpotify", "openSpotifySetup", "openAppleHelp", "openApplePrivacy", "openYouTube", "openQQ", "openNetEase", "openLastfm", "openLastfmApi"].includes(type)) {
|
|
1512
|
+
const destination = { openSpotify: "spotify", openSpotifySetup: "spotifySetup", openAppleHelp: "apple", openApplePrivacy: "applePrivacy", openYouTube: "youtube", openQQ: "qq", openNetEase: "netease", openLastfm: "lastfm", openLastfmApi: "lastfmApi" }[type];
|
|
1119
1513
|
const url = IMPORT_GUIDE_URLS[destination];
|
|
1120
1514
|
try {
|
|
1121
|
-
if (typeof openImportHelp !== "function") throw new Error("Can't open a browser from here.");
|
|
1515
|
+
if (typeof openImportHelp !== "function") throw new Error(tr("Can't open a browser from here."));
|
|
1122
1516
|
await openImportHelp(destination);
|
|
1123
|
-
setFooter("Opened in your browser. Moondog will be here when you're back.");
|
|
1517
|
+
setFooter(tr("Opened in your browser. Moondog will be here when you're back."));
|
|
1124
1518
|
} catch {
|
|
1125
|
-
setImportPage(importPage, { error:
|
|
1519
|
+
setImportPage(importPage, { error: tr("I couldn't open your browser. Here's the link: {url}", { url }) });
|
|
1126
1520
|
}
|
|
1127
1521
|
return;
|
|
1128
1522
|
}
|
|
@@ -1130,17 +1524,22 @@ export async function runMoondogTui({
|
|
|
1130
1524
|
if (importPage === "start") { closeImport(); return; }
|
|
1131
1525
|
if (importPage === "preview") {
|
|
1132
1526
|
discardPreparedImport();
|
|
1133
|
-
setImportPage(
|
|
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)) {
|
|
1527
|
+
setImportPage({ quick: "quick", lastfm: "lastfmUser" }[importOrigin] ?? "file");
|
|
1528
|
+
} 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", lastfmSetup: "lastfm", lastfmUser: "lastfm", lastfmKey: "lastfmSetup" }[importPage] ?? "start");
|
|
1529
|
+
} else if (["start", "file", "spotify", "waiting", "other", "quick", "setup", "client", "spotifyHistory", "apple", "appleQuick", "appleHistory", "youtube_music", "youtubeQuick", "youtubeHistory", "qq_music", "netease", "lastfm", "lastfmSetup", "lastfmUser", "lastfmKey"].includes(type)) {
|
|
1136
1530
|
discardPreparedImport();
|
|
1137
|
-
if (["spotify", "apple", "other", "youtube_music", "qq_music", "netease"].includes(type)) importProvider = type;
|
|
1531
|
+
if (["spotify", "apple", "other", "youtube_music", "qq_music", "netease", "lastfm"].includes(type)) importProvider = type;
|
|
1138
1532
|
if (type === "file" && !["preview", "file"].includes(importPage)) importFileReturn = importPage;
|
|
1533
|
+
// Whether a Last.fm key is set decides the first step, so ask each time the page opens.
|
|
1534
|
+
if (type === "lastfm") lastfmAccess = await lastfm?.status?.().catch(() => null) ?? null;
|
|
1535
|
+
if (cleanedUp || !importView) return;
|
|
1139
1536
|
setImportPage(type);
|
|
1140
1537
|
}
|
|
1141
|
-
setFooter(
|
|
1142
|
-
|
|
1143
|
-
: "
|
|
1538
|
+
setFooter({
|
|
1539
|
+
file: tr("Paste or drag in a file. Tab completes the path, Enter reads it."),
|
|
1540
|
+
lastfmUser: tr("Type your Last.fm username, then press Enter."),
|
|
1541
|
+
lastfmKey: tr("Paste the API key, then press Enter."),
|
|
1542
|
+
}[importPage] ?? tr("Pick a step."));
|
|
1144
1543
|
tui.setFocus(importView);
|
|
1145
1544
|
};
|
|
1146
1545
|
|
|
@@ -1151,29 +1550,29 @@ export async function runMoondogTui({
|
|
|
1151
1550
|
importReturnHome = homeVisible;
|
|
1152
1551
|
homeFocused = false;
|
|
1153
1552
|
importView = new HistoryImportView({
|
|
1154
|
-
tui, getTheme,
|
|
1553
|
+
tui, getTheme, getText: () => tr,
|
|
1155
1554
|
getRows: () => Math.max(1, terminal.rows - 2 - (terminal.rows >= 20 ? 2 : 1)),
|
|
1156
1555
|
profileReady: application.profileServicesReady?.() ?? false,
|
|
1157
1556
|
onAction: (action) => {
|
|
1158
1557
|
void handleImportAction(action).catch((error) => {
|
|
1159
|
-
if (!cleanedUp) setFooter(
|
|
1558
|
+
if (!cleanedUp) setFooter(tr("That didn't work: {error}", { error: error.message }), failure);
|
|
1160
1559
|
});
|
|
1161
1560
|
},
|
|
1162
1561
|
});
|
|
1163
1562
|
}
|
|
1164
1563
|
setImportPage("start");
|
|
1165
1564
|
tui.setFocus(importView);
|
|
1166
|
-
setFooter("Welcome to the machine. Pick your music service.");
|
|
1565
|
+
setFooter(tr("Welcome to the machine. Pick your music service."));
|
|
1167
1566
|
if (filePath) await inspectImport(filePath);
|
|
1168
1567
|
};
|
|
1169
1568
|
|
|
1170
1569
|
const runSpotifyCommand = async (args) => {
|
|
1171
1570
|
if (typeof runSpotify !== "function") {
|
|
1172
|
-
throw new Error("Spotify isn't available when Moondog is started this way.");
|
|
1571
|
+
throw new Error(tr("Spotify isn't available when Moondog is started this way."));
|
|
1173
1572
|
}
|
|
1174
1573
|
const action = args[0]?.toLowerCase() ?? "help";
|
|
1175
1574
|
const leavesTui = action === "login";
|
|
1176
|
-
setFooter(`Spotify ${action}
|
|
1575
|
+
setFooter(`Spotify ${action}...`);
|
|
1177
1576
|
if (leavesTui) tui.stop({ preserveScreen: true });
|
|
1178
1577
|
let markdown;
|
|
1179
1578
|
try {
|
|
@@ -1187,34 +1586,81 @@ export async function runMoondogTui({
|
|
|
1187
1586
|
if (cleanedUp) return;
|
|
1188
1587
|
|
|
1189
1588
|
if (
|
|
1190
|
-
["configure", "login", "logout", "import-history", "sync-recent"].includes(action) &&
|
|
1589
|
+
["configure", "login", "logout", "import-history", "sync-recent", "sync-library"].includes(action) &&
|
|
1191
1590
|
typeof rebuildRuntime === "function"
|
|
1192
1591
|
) {
|
|
1193
1592
|
await replaceRuntime();
|
|
1194
1593
|
} else {
|
|
1195
1594
|
renderHeader();
|
|
1196
1595
|
}
|
|
1197
|
-
addMoondogMessage(markdown || "Done.");
|
|
1198
|
-
if (
|
|
1596
|
+
addMoondogMessage(markdown || tr("Done."));
|
|
1597
|
+
if (["import-history", "sync-library"].includes(action)) {
|
|
1199
1598
|
await showImportedListeningProfile();
|
|
1200
1599
|
return;
|
|
1201
1600
|
}
|
|
1202
|
-
setFooter(`Spotify ${action}: done.`,
|
|
1601
|
+
setFooter(`Spotify ${action}: done.`, success);
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1604
|
+
const savePendingConversation = () => application.conversationPending?.({ draft: editor.getText(), queued: [...messageQueue] });
|
|
1605
|
+
const restorePendingConversation = () => {
|
|
1606
|
+
const pending = application.conversationPending?.();
|
|
1607
|
+
if (!pending) return;
|
|
1608
|
+
editor.setText(pending.draft);
|
|
1609
|
+
messageQueue.splice(0, messageQueue.length, ...pending.queued);
|
|
1610
|
+
queuePaused = pending.queued.length > 0;
|
|
1611
|
+
releaseQueue = false;
|
|
1612
|
+
};
|
|
1613
|
+
const rewindConversation = async (args) => {
|
|
1614
|
+
if (args.length > 1) throw new Error(tr("Use /rewind or /rewind latest."));
|
|
1615
|
+
if (typeof application.conversationEntries !== 'function' || typeof application.rewindConversation !== 'function') {
|
|
1616
|
+
throw new Error(tr("Conversation rewind isn't available in this runtime."));
|
|
1617
|
+
}
|
|
1618
|
+
const entries = application.conversationEntries({ limit: 200 });
|
|
1619
|
+
if (!entries.length) { setFooter(tr("No earlier messages yet.")); return; }
|
|
1620
|
+
let selected = args[0] === 'latest' ? { value: entries.at(-1).entry_id } : args[0] ? { value: args[0] } : null;
|
|
1621
|
+
if (!selected) selected = await choose(entries.map((entry, index) => ({
|
|
1622
|
+
value: entry.entry_id,
|
|
1623
|
+
label: `${index + 1}. ${sanitizeTerminalText(entry.user_text).replace(/\s+/gu, ' ').trim()}`,
|
|
1624
|
+
description: `${entry.outcome} · ${entry.created_at.slice(0, 16).replace('T', ' ')}`,
|
|
1625
|
+
})).reverse(), entries.at(-1).entry_id, tr("Rewind to a message · original stays saved"));
|
|
1626
|
+
if (cleanedUp) return;
|
|
1627
|
+
if (!selected) { setFooter(tr("Rewind cancelled. Queued drafts stay paused.")); return; }
|
|
1628
|
+
storeRecall();
|
|
1629
|
+
savePendingConversation();
|
|
1630
|
+
const branch = typeof activeRuntime.rewindTo === 'function'
|
|
1631
|
+
? await activeRuntime.rewindTo(selected.value)
|
|
1632
|
+
: application.rewindConversation(selected.value);
|
|
1633
|
+
if (typeof activeRuntime.rewindTo !== 'function') activeRuntime.restoreSession?.();
|
|
1634
|
+
profileDiscoveryDraft = null;
|
|
1635
|
+
conversationTitle = '';
|
|
1636
|
+
transcript.clear();
|
|
1637
|
+
homeVisible = false;
|
|
1638
|
+
homeFocused = false;
|
|
1639
|
+
editor.focused = true;
|
|
1640
|
+
for (const turn of application.currentConversationTurns({ limit: 40 })) {
|
|
1641
|
+
if (turn.role === 'user') addUserMessage(sanitizeTerminalText(turn.text));
|
|
1642
|
+
else addMoondogMessage(turn.text);
|
|
1643
|
+
}
|
|
1644
|
+
installRecall(branch.session_id, recallFromTurns());
|
|
1645
|
+
restorePendingConversation();
|
|
1646
|
+
addMoondogMessage(tr("Branched before this message. Edit it below, then send. The original is in /resume. Spotify actions and saved preferences have not been undone; inspect the original receipts before repeating an action."));
|
|
1647
|
+
setFooter(tr("Earlier message restored · edit before sending · /resume keeps both branches"), success);
|
|
1648
|
+
tui.requestRender(true);
|
|
1203
1649
|
};
|
|
1204
1650
|
|
|
1205
1651
|
const resumeConversation = async (args) => {
|
|
1206
|
-
if (args.length > 1) throw new Error("Use /resume, or /resume <conversation-id>.");
|
|
1652
|
+
if (args.length > 1) throw new Error(tr("Use /resume, or /resume <conversation-id>."));
|
|
1207
1653
|
if (typeof application.listSavedSessions !== "function" ||
|
|
1208
1654
|
typeof application.resumeSession !== "function" ||
|
|
1209
1655
|
typeof activeRuntime.restoreSession !== "function") {
|
|
1210
|
-
throw new Error("Saved conversations aren't available when Moondog is started this way.");
|
|
1656
|
+
throw new Error(tr("Saved conversations aren't available when Moondog is started this way."));
|
|
1211
1657
|
}
|
|
1212
1658
|
const currentId = application.ensureMemorySession?.()?.session_id;
|
|
1213
1659
|
let selected = args[0] ? { value: args[0] } : undefined;
|
|
1214
1660
|
if (!selected) {
|
|
1215
1661
|
const sessions = application.listSavedSessions({ limit: 200 });
|
|
1216
1662
|
if (!sessions.length) {
|
|
1217
|
-
setFooter("No saved conversations yet.");
|
|
1663
|
+
setFooter(tr("No saved conversations yet."));
|
|
1218
1664
|
return;
|
|
1219
1665
|
}
|
|
1220
1666
|
const formatTime = new Intl.DateTimeFormat(undefined, {
|
|
@@ -1222,117 +1668,166 @@ export async function runMoondogTui({
|
|
|
1222
1668
|
});
|
|
1223
1669
|
const items = sessions.map((session) => ({
|
|
1224
1670
|
value: session.session_id,
|
|
1225
|
-
label: sanitizeTerminalText(session.title).replace(/\s+/gu, " ").trim() || "Untitled"
|
|
1226
|
-
description:
|
|
1671
|
+
label: `${session.parent_session_id ? 'Branch · ' : ''}${sanitizeTerminalText(session.title).replace(/\s+/gu, " ").trim() || "Untitled"}`,
|
|
1672
|
+
description: [formatTime.format(new Date(session.updated_at)), tr.n(session.turn_count, "{count} message", "{count} messages"),
|
|
1673
|
+
...(session.session_id === currentId ? [tr("open now")] : []),
|
|
1674
|
+
...(session.parent_session_id && session.last_message ? [sanitizeTerminalText(session.last_message).replace(/\s+/gu, ' ').slice(0, 100)] : [])].join(" · "),
|
|
1227
1675
|
}));
|
|
1228
|
-
selected = await choose(items, currentId, "Pick up where you left off");
|
|
1676
|
+
selected = await choose(items, currentId, tr("Pick up where you left off"));
|
|
1229
1677
|
if (cleanedUp) return;
|
|
1230
1678
|
if (!selected) {
|
|
1231
|
-
setFooter("Staying here.");
|
|
1679
|
+
setFooter(tr("Staying here."));
|
|
1232
1680
|
return;
|
|
1233
1681
|
}
|
|
1234
1682
|
}
|
|
1235
1683
|
if (selected.value === currentId) {
|
|
1236
|
-
setFooter("You're already in that one.");
|
|
1684
|
+
setFooter(tr("You're already in that one."));
|
|
1237
1685
|
return;
|
|
1238
1686
|
}
|
|
1687
|
+
storeRecall();
|
|
1688
|
+
try { savePendingConversation(); } catch { /* A conversation deleted elsewhere must not keep the listener in it. */ }
|
|
1689
|
+
conversationTitle = "";
|
|
1239
1690
|
application.resumeSession(selected.value);
|
|
1240
|
-
|
|
1241
|
-
|
|
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);
|
|
1691
|
+
if (!recallBySession.has(selected.value)) {
|
|
1692
|
+
recallBySession.set(selected.value, recallFromTurns());
|
|
1249
1693
|
}
|
|
1250
|
-
|
|
1251
|
-
|
|
1694
|
+
editor.replaceRecall(recallBySession.get(selected.value));
|
|
1695
|
+
activeRuntime.restoreSession();
|
|
1696
|
+
restorePendingConversation();
|
|
1697
|
+
const turns = drawSavedConversation();
|
|
1698
|
+
const savedTitle = selected.label ? undefined
|
|
1699
|
+
: application.listSavedSessions({ limit: 200 }).find((session) => session.session_id === selected.value)?.title;
|
|
1700
|
+
const title = selected.label ?? savedTitle ?? turns.find((turn) => turn.role === "user")?.text ?? tr("Saved conversation");
|
|
1701
|
+
const resumed = String(title).replace(/\s+/gu, " ").trim();
|
|
1702
|
+
if (resumed && resumed !== tr("Saved conversation")) conversationTitle = Array.from(resumed).slice(0, 160).join("");
|
|
1703
|
+
setFooter(tr("Back in: {title}", { title: resumed || tr("Saved conversation") }), success);
|
|
1252
1704
|
tui.requestRender(true);
|
|
1253
1705
|
};
|
|
1254
1706
|
|
|
1255
1707
|
const runLocalCommand = async (command, args = []) => {
|
|
1708
|
+
if (command === "update") {
|
|
1709
|
+
if (!runUpdate) throw new Error(tr("Use moondog update from your shell for this installation."));
|
|
1710
|
+
const result = await runUpdate(args, { signal: localCommandController?.signal });
|
|
1711
|
+
if (cleanedUp) return;
|
|
1712
|
+
localCommandController?.signal.throwIfAborted();
|
|
1713
|
+
updateNotice = "";
|
|
1714
|
+
addMoondogMessage(result.text);
|
|
1715
|
+
if (result.update) { exitRequest = { update: result.update }; shutdown(); }
|
|
1716
|
+
else setFooter(tr("Update check finished."));
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1256
1719
|
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.");
|
|
1720
|
+
if (args.length > 1 || (args[0] && args[0] !== "sync")) throw new Error(tr("Use /lyrics, or /lyrics sync."));
|
|
1721
|
+
if (!lyrics) throw new Error(tr("The lyric library isn't available when Moondog is started this way."));
|
|
1259
1722
|
if (args[0] === "sync") await updateHomeLyrics({ signal: localCommandController?.signal });
|
|
1260
1723
|
if (cleanedUp) return;
|
|
1261
1724
|
const status = lyrics.status();
|
|
1262
1725
|
addMoondogMessage([
|
|
1263
|
-
"## Your lyric library", "",
|
|
1264
|
-
|
|
1265
|
-
|
|
1726
|
+
tr("## Your lyric library"), "",
|
|
1727
|
+
[tr.n(status.ready, "{count} song with lyrics", "{count} songs with lyrics"), tr.n(status.instrumental, "{count} instrumental", "{count} instrumentals"),
|
|
1728
|
+
tr("{count} looked up", { count: tr.number(status.entries) })].join(" · "),
|
|
1729
|
+
tr("Stored at {path}", { path: status.path }),
|
|
1266
1730
|
`Syncing: ${status.refresh.state}`,
|
|
1267
|
-
...(lyricError ? [
|
|
1268
|
-
...(homeLyric ? ["",
|
|
1269
|
-
"", "`/lyrics sync` looks up more songs from your profile. `/home` takes you back to the sleeve.",
|
|
1731
|
+
...(lyricError ? [tr("Last problem: {error}", { error: lyricError })] : []),
|
|
1732
|
+
...(homeLyric ? ["", tr("On the sleeve now: {title} · {artist}", { title: homeLyric.title, artist: homeLyric.artist }), tr("Lyrics from {url}", { url: homeLyric.sourceUrl })] : []),
|
|
1733
|
+
"", tr("`/lyrics sync` looks up more songs from your profile. `/home` takes you back to the sleeve."),
|
|
1270
1734
|
].join("\n"));
|
|
1271
|
-
setFooter("Lyric library · /home to go back.");
|
|
1735
|
+
setFooter(tr("Lyric library · /home to go back."));
|
|
1272
1736
|
return;
|
|
1273
1737
|
}
|
|
1274
1738
|
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.");
|
|
1739
|
+
if (args.length > 1 || (args[0] && args[0] !== "report")) throw new Error(tr("Use /taste, or /taste report."));
|
|
1276
1740
|
if (args[0] === "report") {
|
|
1277
|
-
|
|
1278
|
-
setFooter("Full report above. /taste opens the profile.");
|
|
1741
|
+
addTasteReport(await application.runLocalCommand("taste", runtimeStatus));
|
|
1742
|
+
setFooter(tr("Full report above. /taste opens the profile."));
|
|
1279
1743
|
} else await openProfile();
|
|
1280
1744
|
return;
|
|
1281
1745
|
}
|
|
1746
|
+
if (command === "rewind") {
|
|
1747
|
+
await rewindConversation(args);
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1282
1750
|
if (command === "resume") {
|
|
1283
1751
|
await resumeConversation(args);
|
|
1284
1752
|
return;
|
|
1285
1753
|
}
|
|
1286
1754
|
if (command === "web") {
|
|
1287
|
-
if (typeof runWeb !== "function") throw new Error("Web lookups aren't available when Moondog is started this way.");
|
|
1288
|
-
|
|
1755
|
+
if (typeof runWeb !== "function") throw new Error(tr("Web lookups aren't available when Moondog is started this way."));
|
|
1756
|
+
const lookup = ["search", "read"].includes(args[0]) ? application.webLookup?.(args[0]) : null;
|
|
1757
|
+
if (lookup) setFooter(tr("Looking it up with {provider}... Ctrl+C cancels.", { provider: lookup.name }));
|
|
1289
1758
|
addMoondogMessage(await runWeb(args, { signal: localCommandController.signal }));
|
|
1290
|
-
setFooter("Done.",
|
|
1759
|
+
setFooter(tr("Done."), success);
|
|
1291
1760
|
return;
|
|
1292
1761
|
}
|
|
1293
1762
|
if (command === "home") {
|
|
1294
|
-
if (args.length) throw new Error("/home doesn't take anything after it.");
|
|
1763
|
+
if (args.length) throw new Error(tr("/home doesn't take anything after it."));
|
|
1295
1764
|
homeVisible = true;
|
|
1296
1765
|
homeFocused = false;
|
|
1297
1766
|
sleeve.invalidate();
|
|
1298
|
-
setFooter("Say anything, or press Tab to look around.");
|
|
1767
|
+
setFooter(tr("Say anything, or press Tab to look around."));
|
|
1299
1768
|
return;
|
|
1300
1769
|
}
|
|
1301
1770
|
if (command === "theme") {
|
|
1302
1771
|
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.");
|
|
1772
|
+
throw new Error(tr("Use /theme, or /theme paper, charcoal, terminal, or auto."));
|
|
1304
1773
|
}
|
|
1305
1774
|
let mode = args[0];
|
|
1306
1775
|
if (!mode) {
|
|
1307
1776
|
const selected = await choose([
|
|
1308
|
-
{ value: "paper", label: "Paper", description: "
|
|
1309
|
-
{ value: "charcoal", label: "Charcoal", description: "
|
|
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");
|
|
1777
|
+
{ value: "paper", label: "Paper", description: tr("Black ink on white paper") },
|
|
1778
|
+
{ value: "charcoal", label: "Charcoal", description: tr("Moonlight on a black sky") },
|
|
1779
|
+
{ value: "terminal", label: "Terminal", description: tr("Keep your terminal's colors") },
|
|
1780
|
+
{ value: "auto", label: "Auto", description: tr("Follow MOONDOG_THEME, or guess from your terminal") },
|
|
1781
|
+
], theme.mode, tr("Pick a look"));
|
|
1313
1782
|
mode = selected?.value;
|
|
1314
1783
|
}
|
|
1315
|
-
if (!mode) { setFooter("Theme unchanged."); return; }
|
|
1784
|
+
if (!mode) { setFooter(tr("Theme unchanged.")); return; }
|
|
1316
1785
|
theme = createMoondogTheme({ mode, environment });
|
|
1317
1786
|
transcript.invalidate();
|
|
1318
1787
|
sleeve.invalidate();
|
|
1319
|
-
setFooter(
|
|
1788
|
+
setFooter(theme.mode === "paper" ? tr("Paper theme, for this session.") : theme.mode === "charcoal" ? tr("Charcoal theme, for this session.") : tr("Terminal theme, for this session."));
|
|
1320
1789
|
tui.requestRender(true);
|
|
1321
1790
|
return;
|
|
1322
1791
|
}
|
|
1792
|
+
if (command === "language") {
|
|
1793
|
+
const wanted = args.join(" ").trim().toLocaleLowerCase();
|
|
1794
|
+
const match = Object.entries(LOCALES).find(([code, value]) =>
|
|
1795
|
+
[code.toLocaleLowerCase(), value.name.toLocaleLowerCase(), value.nativeName.toLocaleLowerCase()].includes(wanted));
|
|
1796
|
+
if (wanted && !match) throw new Error(tr("Use /language, or /language {codes}.", { codes: Object.keys(LOCALES).join(", ") }));
|
|
1797
|
+
let locale = match?.[0];
|
|
1798
|
+
if (!locale) {
|
|
1799
|
+
const selected = await choose(Object.entries(LOCALES).map(([code, value]) => ({
|
|
1800
|
+
value: code, label: value.nativeName, description: value.name,
|
|
1801
|
+
})), application.locale, tr("Reply language"));
|
|
1802
|
+
locale = selected?.value;
|
|
1803
|
+
}
|
|
1804
|
+
if (!locale) { setFooter(tr("Language unchanged.")); return; }
|
|
1805
|
+
application.locale = locale;
|
|
1806
|
+
// A chosen language also becomes the conversation's, so the next short reply follows it.
|
|
1807
|
+
application.conversationLocale = locale;
|
|
1808
|
+
await saveLanguage?.(locale);
|
|
1809
|
+
const pinned = normalizeLocale(environment.MOONDOG_LANGUAGE);
|
|
1810
|
+
transcript.invalidate();
|
|
1811
|
+
sleeve.invalidate();
|
|
1812
|
+
renderHeader();
|
|
1813
|
+
setFooter(pinned && pinned !== locale
|
|
1814
|
+
? tr("{language}: replies use it unless you write in another language. MOONDOG_LANGUAGE still sets the next start.", { language: LOCALES[locale].nativeName })
|
|
1815
|
+
: tr("{language}: replies use it unless you write in another language.", { language: LOCALES[locale].nativeName }), success);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1323
1818
|
if (command === "art") {
|
|
1324
1819
|
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.");
|
|
1820
|
+
throw new Error(tr("Use /art, or /art braille, ascii, off, or auto."));
|
|
1326
1821
|
}
|
|
1327
1822
|
sleeve.setArtMode(args[0] ?? (sleeve.artMode === "ascii" ? "braille" : "ascii"));
|
|
1328
1823
|
homeVisible = true;
|
|
1329
|
-
setFooter(sleeve.artMode === "off" ? "A quiet opening, no artwork." :
|
|
1824
|
+
setFooter(sleeve.artMode === "off" ? tr("A quiet opening, no artwork.") : sleeve.artMode === "ascii" || sleeve.artMode === "text" ? tr("ASCII artwork.") : tr("Braille artwork."));
|
|
1330
1825
|
return;
|
|
1331
1826
|
}
|
|
1332
1827
|
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.");
|
|
1828
|
+
if (args.length > 1 || (args[0] && !["on", "off"].includes(args[0]))) throw new Error(tr("Use /motion, or /motion on or off."));
|
|
1334
1829
|
motionEnabled = args[0] ? args[0] === "on" : !motionEnabled;
|
|
1335
|
-
setFooter(motionEnabled ? "Animation on." : "Animation off.");
|
|
1830
|
+
setFooter(motionEnabled ? tr("Animation on.") : tr("Animation off."));
|
|
1336
1831
|
return;
|
|
1337
1832
|
}
|
|
1338
1833
|
if (command === "import") {
|
|
@@ -1340,15 +1835,19 @@ export async function runMoondogTui({
|
|
|
1340
1835
|
return;
|
|
1341
1836
|
}
|
|
1342
1837
|
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.");
|
|
1838
|
+
if (args.length > 1 || (args[0] && args[0] !== "all")) throw new Error(tr("Use /help, or /help all."));
|
|
1839
|
+
addMoondogMessage(args[0] === "all" ? helpText(tr) : listeningHelp());
|
|
1840
|
+
setFooter(tr("Type / to browse every command."));
|
|
1346
1841
|
return;
|
|
1347
1842
|
}
|
|
1348
1843
|
if (command === "model") {
|
|
1349
1844
|
await selectModel(args);
|
|
1350
1845
|
return;
|
|
1351
1846
|
}
|
|
1847
|
+
if (command === "effort") {
|
|
1848
|
+
await selectEffort(args);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1352
1851
|
if (command === "auth") {
|
|
1353
1852
|
await authenticate(args);
|
|
1354
1853
|
return;
|
|
@@ -1359,20 +1858,29 @@ export async function runMoondogTui({
|
|
|
1359
1858
|
}
|
|
1360
1859
|
if (command === "profile" && args.length > 0) {
|
|
1361
1860
|
if (typeof runProfile !== "function") {
|
|
1362
|
-
throw new Error("Changing your profile isn't available when Moondog is started this way.");
|
|
1861
|
+
throw new Error(tr("Changing your profile isn't available when Moondog is started this way."));
|
|
1363
1862
|
}
|
|
1364
|
-
addMoondogMessage(await runProfile(args
|
|
1863
|
+
addMoondogMessage(await runProfile(args, {
|
|
1864
|
+
signal: localCommandController?.signal,
|
|
1865
|
+
tr,
|
|
1866
|
+
onProgress: progress => setFooter(progress.model_retry
|
|
1867
|
+
? tr("Retrying the model, {attempt} of {max}. Ctrl+C cancels and keeps the saved draft.", { attempt: progress.model_retry.attempt, max: progress.model_retry.maxRetries })
|
|
1868
|
+
: progress.phase === "check" ? tr("Checking the draft for contradictions. Ctrl+C keeps the draft.")
|
|
1869
|
+
: progress.phase === "revision" ? tr("Fixing what the check found. Ctrl+C keeps the draft.")
|
|
1870
|
+
: progress.phase === "complete" ? tr("Saving your listening profile.")
|
|
1871
|
+
: tr("Reading your music and writing your profile. Ctrl+C cancels.")),
|
|
1872
|
+
}));
|
|
1365
1873
|
void updateHomeLyrics();
|
|
1366
1874
|
if (["correct", "retract"].includes(args[0])) {
|
|
1367
1875
|
try {
|
|
1368
1876
|
await openProfile();
|
|
1369
1877
|
} catch (error) {
|
|
1370
|
-
addMoondogMessage(
|
|
1371
|
-
setFooter("Choice saved. The profile needs another try.",
|
|
1878
|
+
addMoondogMessage(tr("Your choice is saved, but I couldn't update the profile: {error}\n\nType `/taste` to try again.", { error: error.message }));
|
|
1879
|
+
setFooter(tr("Choice saved. The profile needs another try."), warning);
|
|
1372
1880
|
return;
|
|
1373
1881
|
}
|
|
1374
1882
|
}
|
|
1375
|
-
setFooter("Done.",
|
|
1883
|
+
setFooter(tr("Done."), success);
|
|
1376
1884
|
return;
|
|
1377
1885
|
}
|
|
1378
1886
|
if (command === "reload") {
|
|
@@ -1381,10 +1889,10 @@ export async function runMoondogTui({
|
|
|
1381
1889
|
importNeedsRefresh = false;
|
|
1382
1890
|
addMoondogMessage(
|
|
1383
1891
|
status.state === "configured"
|
|
1384
|
-
?
|
|
1385
|
-
:
|
|
1892
|
+
? tr("Reloaded `{model}`. This conversation and what I remember carry over.", { model: `${status.provider}/${status.model}` })
|
|
1893
|
+
: tr("Reloaded, but no model is connected (`{reason}`). Your profile still works.", { reason: status.reason }),
|
|
1386
1894
|
);
|
|
1387
|
-
setFooter("Reloaded.", status.state === "configured" ?
|
|
1895
|
+
setFooter(tr("Reloaded."), status.state === "configured" ? success : warning);
|
|
1388
1896
|
return;
|
|
1389
1897
|
}
|
|
1390
1898
|
if (command === "remember") {
|
|
@@ -1401,58 +1909,67 @@ export async function runMoondogTui({
|
|
|
1401
1909
|
.trim();
|
|
1402
1910
|
if (!text) {
|
|
1403
1911
|
throw new Error(
|
|
1404
|
-
"Tell me what to remember, like /remember preference no live albums.",
|
|
1912
|
+
tr("Tell me what to remember, like /remember preference no live albums."),
|
|
1405
1913
|
);
|
|
1406
1914
|
}
|
|
1407
1915
|
const memory = application.rememberMemory({ text, kind });
|
|
1408
1916
|
addMoondogMessage(
|
|
1409
|
-
|
|
1917
|
+
memory.created
|
|
1918
|
+
? tr("I'll remember that: {text}\n\nTo forget it later: `/forget {id}`", { text: memory.text, id: memory.memory_id })
|
|
1919
|
+
: tr("I already knew that: {text}\n\nTo forget it later: `/forget {id}`", { text: memory.text, id: memory.memory_id }),
|
|
1410
1920
|
);
|
|
1411
|
-
setFooter("Remembered.",
|
|
1921
|
+
setFooter(tr("Remembered."), success);
|
|
1412
1922
|
return;
|
|
1413
1923
|
}
|
|
1414
1924
|
if (command === "forget") {
|
|
1415
|
-
if (args.length !== 1) throw new Error("Tell me which memory, like /forget <memory-id>.");
|
|
1925
|
+
if (args.length !== 1) throw new Error(tr("Tell me which memory, like /forget <memory-id>."));
|
|
1416
1926
|
const result = application.forgetMemory(args[0]);
|
|
1417
1927
|
addMoondogMessage(
|
|
1418
1928
|
result.forgotten
|
|
1419
|
-
?
|
|
1420
|
-
:
|
|
1929
|
+
? tr("Forgotten: `{id}`.", { id: result.memory_id })
|
|
1930
|
+
: tr("I don't have a memory called `{id}`.", { id: result.memory_id }),
|
|
1421
1931
|
);
|
|
1422
|
-
setFooter(result.forgotten ? "Forgotten." : "No such memory.",
|
|
1932
|
+
setFooter(result.forgotten ? tr("Forgotten.") : tr("No such memory."), warning);
|
|
1423
1933
|
return;
|
|
1424
1934
|
}
|
|
1425
1935
|
if (command === "new") {
|
|
1426
|
-
if (args.length) throw new Error("/new doesn't take anything after it.");
|
|
1936
|
+
if (args.length) throw new Error(tr("/new doesn't take anything after it."));
|
|
1937
|
+
storeRecall();
|
|
1938
|
+
try { savePendingConversation(); } catch { /* A conversation deleted elsewhere must not keep the listener in it. */ }
|
|
1427
1939
|
application.startNewSession?.("user_new");
|
|
1940
|
+
editor.setText('');
|
|
1941
|
+
messageQueue.splice(0);
|
|
1942
|
+
queuePaused = false;
|
|
1943
|
+
installRecall(sessionId(), []);
|
|
1428
1944
|
activeRuntime.reset();
|
|
1429
1945
|
profileDiscoveryDraft = null;
|
|
1946
|
+
conversationTitle = "";
|
|
1430
1947
|
transcript.clear();
|
|
1431
1948
|
homeVisible = true;
|
|
1432
1949
|
homeFocused = false;
|
|
1433
1950
|
sleeve.invalidate();
|
|
1434
|
-
setFooter("A fresh start. /resume brings back the last one.",
|
|
1951
|
+
setFooter(tr("A fresh start. /resume brings back the last one."), success);
|
|
1435
1952
|
return;
|
|
1436
1953
|
}
|
|
1437
1954
|
const result = await application.runLocalCommand(command, runtimeStatus);
|
|
1438
|
-
addMoondogMessage(formatLocalResult(command, result));
|
|
1439
|
-
setFooter("Ready.");
|
|
1955
|
+
addMoondogMessage(formatLocalResult(command, result, tr));
|
|
1956
|
+
setFooter(tr("Ready."));
|
|
1440
1957
|
};
|
|
1441
1958
|
|
|
1442
1959
|
const openCommandPalette = async () => {
|
|
1443
|
-
const selected = await choose(
|
|
1960
|
+
const selected = await choose(localizedCommands.map(({ name, description }) => ({
|
|
1444
1961
|
value: name, label: `/${name}`, description,
|
|
1445
|
-
})), undefined, "Commands");
|
|
1962
|
+
})), undefined, tr("Commands"));
|
|
1446
1963
|
if (!selected || cleanedUp) return;
|
|
1447
1964
|
if (profileView) closeProfile();
|
|
1448
1965
|
homeFocused = false;
|
|
1449
1966
|
editor.focused = true;
|
|
1450
1967
|
if (["web", "remember", "forget", "spotify", "art", "motion"].includes(selected.value)) {
|
|
1451
1968
|
if (editor.getText()) {
|
|
1452
|
-
setFooter(
|
|
1969
|
+
setFooter(tr("Your message is still here. Send or clear it, then use /{command}.", { command: selected.value }));
|
|
1453
1970
|
} else {
|
|
1454
1971
|
editor.setText(`/${selected.value} `);
|
|
1455
|
-
setFooter("Finish the command, then press Enter.");
|
|
1972
|
+
setFooter(tr("Finish the command, then press Enter."));
|
|
1456
1973
|
}
|
|
1457
1974
|
return;
|
|
1458
1975
|
}
|
|
@@ -1463,8 +1980,13 @@ export async function runMoondogTui({
|
|
|
1463
1980
|
if (selected.value !== "commands") await editor.onSubmit(`/${selected.value}`);
|
|
1464
1981
|
};
|
|
1465
1982
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1983
|
+
let releaseQueue = true;
|
|
1984
|
+
const settleQueue = () => {
|
|
1985
|
+
if (busy || cleanedUp || !releaseQueue || messageQueue.length === 0) return;
|
|
1986
|
+
const next = messageQueue.shift();
|
|
1987
|
+
void editor.onSubmit(next);
|
|
1988
|
+
};
|
|
1989
|
+
const submitListeningTurn = async (rawValue) => {
|
|
1468
1990
|
const value = sanitizeTerminalText(rawValue).trim();
|
|
1469
1991
|
if (!value) return;
|
|
1470
1992
|
|
|
@@ -1475,24 +1997,27 @@ export async function runMoondogTui({
|
|
|
1475
1997
|
shutdown();
|
|
1476
1998
|
return;
|
|
1477
1999
|
}
|
|
1478
|
-
if (
|
|
2000
|
+
if (previewControls && (command === "skip" || command === "stop")) {
|
|
2001
|
+
controlPreviews(command);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (!commands.some((entry) => entry.name === command)) {
|
|
1479
2005
|
enterConversation();
|
|
1480
|
-
addMoondogMessage(
|
|
2006
|
+
addMoondogMessage(tr("I don't know `/{command}`. Type `/help` to see what I do know.", { command }));
|
|
1481
2007
|
return;
|
|
1482
2008
|
}
|
|
1483
2009
|
if (command === "commands") {
|
|
1484
|
-
if (args.length) { addMoondogMessage("`/commands` doesn't take anything after it."); return; }
|
|
2010
|
+
if (args.length) { addMoondogMessage(tr("`/commands` doesn't take anything after it.")); return; }
|
|
1485
2011
|
await openCommandPalette();
|
|
1486
2012
|
return;
|
|
1487
2013
|
}
|
|
1488
2014
|
try {
|
|
1489
|
-
if (!["home", "theme", "art", "motion", "model", "resume", "new", "taste", "profile", "import"].includes(command)) enterConversation();
|
|
2015
|
+
if (!["home", "theme", "art", "motion", "model", "resume", "rewind", "new", "taste", "profile", "import"].includes(command)) enterConversation();
|
|
1490
2016
|
if (command !== "auth") editor.addToHistory(value);
|
|
1491
|
-
localCommandController = ["web", "lyrics"].includes(command) ? new AbortController() : null;
|
|
2017
|
+
localCommandController = ["web", "lyrics", "model", "update", "profile"].includes(command) ? new AbortController() : null;
|
|
1492
2018
|
const controller = localCommandController;
|
|
1493
2019
|
setBusy(true, controller ? () => controller.abort() : null);
|
|
1494
|
-
setFooter(`/${command}
|
|
1495
|
-
editor.disableSubmit = true;
|
|
2020
|
+
setFooter(`/${command}...`);
|
|
1496
2021
|
const argumentText = value.slice(1).trim().slice(rawCommand.length).trim();
|
|
1497
2022
|
const parsedArgs = command === "import" ? argumentText ? [argumentText] : []
|
|
1498
2023
|
: ["profile", "spotify", "web"].includes(command)
|
|
@@ -1503,12 +2028,13 @@ export async function runMoondogTui({
|
|
|
1503
2028
|
if (cleanedUp) return;
|
|
1504
2029
|
enterConversation();
|
|
1505
2030
|
const cancelled = localCommandController?.signal.aborted;
|
|
1506
|
-
|
|
1507
|
-
|
|
2031
|
+
// Ctrl+C during a sign-in is the listener's choice, not a failure.
|
|
2032
|
+
const signInCancelled = !cancelled && ["auth_login_cancelled", "spotify_auth_aborted"].includes(error?.code);
|
|
2033
|
+
addMoondogMessage(signInCancelled ? tr("Sign-in cancelled.") : cancelled ? command === "web" ? tr("Stopped the lookup.") : command === "model" ? tr("Stopped the catalog check. Model unchanged.") : command === "update" ? tr("Stopped the update check. No update installed.") : command === "profile" ? tr("Stopped the profile build. Saved progress and your previous reading are kept. Use /profile build to continue.") : tr("Stopped the lyric sync.") : tr("That didn't work: {error}", { error: error.message }));
|
|
2034
|
+
setFooter(cancelled || signInCancelled ? tr("Cancelled.") : tr("/{command} didn't work.", { command }), cancelled || signInCancelled ? dim : failure);
|
|
1508
2035
|
} finally {
|
|
1509
2036
|
localCommandController = null;
|
|
1510
2037
|
setBusy(false);
|
|
1511
|
-
editor.disableSubmit = false;
|
|
1512
2038
|
if (!cleanedUp) tui.requestRender();
|
|
1513
2039
|
}
|
|
1514
2040
|
return;
|
|
@@ -1517,25 +2043,27 @@ export async function runMoondogTui({
|
|
|
1517
2043
|
const profileSeed = profileDiscoveryDraft && value.includes(profileDiscoveryDraft.context)
|
|
1518
2044
|
? profileDiscoveryDraft.target : undefined;
|
|
1519
2045
|
if (profileSeed && runtimeStatus.state !== "configured") {
|
|
2046
|
+
releaseQueue = false;
|
|
1520
2047
|
editor.setText(rawValue);
|
|
1521
|
-
setFooter("Your request is still here · /model connects a model first.",
|
|
2048
|
+
setFooter(tr("Your request is still here · /model connects a model first."), warning);
|
|
1522
2049
|
return;
|
|
1523
2050
|
}
|
|
1524
2051
|
// Retain the binding for history recall, only while its exact metadata is visible.
|
|
1525
2052
|
if (!profileSeed) profileDiscoveryDraft = null;
|
|
1526
|
-
enterConversation();
|
|
1527
2053
|
editor.addToHistory(value);
|
|
1528
2054
|
addUserMessage(value);
|
|
2055
|
+
enterConversation();
|
|
1529
2056
|
if (runtimeStatus.state !== "configured") {
|
|
1530
|
-
addMoondogMessage(offlineReply(runtimeStatus));
|
|
2057
|
+
addMoondogMessage(offlineReply(runtimeStatus, tr));
|
|
1531
2058
|
return;
|
|
1532
2059
|
}
|
|
1533
2060
|
|
|
1534
2061
|
setBusy(true, () => activeRuntime.abort());
|
|
1535
|
-
|
|
1536
|
-
setFooter("Thinking...", yellow);
|
|
2062
|
+
setFooter(tr("Thinking..."));
|
|
1537
2063
|
transcriptScroll.scrollToEnd();
|
|
1538
2064
|
addLabel("Moondog");
|
|
2065
|
+
const work = new AgentWork(getTheme, () => phase);
|
|
2066
|
+
transcript.addChild(work);
|
|
1539
2067
|
const response = new Markdown("", 1, 1, markdownTheme, textStyle);
|
|
1540
2068
|
transcript.addChild(response);
|
|
1541
2069
|
let streamedText = "";
|
|
@@ -1543,19 +2071,23 @@ export async function runMoondogTui({
|
|
|
1543
2071
|
let legacyToolId = 0;
|
|
1544
2072
|
const updateTool = (tool, state) => {
|
|
1545
2073
|
if (cleanedUp) return;
|
|
1546
|
-
const label = sanitizeTerminalText(typeof tool === "string" ? tool : tool?.label ?? "Music tool")
|
|
1547
|
-
.replace(/\s+/gu, " ").trim() || "Music tool";
|
|
2074
|
+
const label = sanitizeTerminalText(tr.marked(typeof tool === "string" ? tool : tool?.label ?? "Music tool"))
|
|
2075
|
+
.replace(/\s+/gu, " ").trim() || tr("Music tool");
|
|
1548
2076
|
// Runtime events carry call IDs; retain support for older label-only callbacks.
|
|
1549
2077
|
const key = tool?.toolCallId ?? (state !== "running"
|
|
1550
2078
|
? [...toolCalls].find(([, call]) => call.state === "running" && call.label === label)?.[0]
|
|
1551
2079
|
: undefined) ?? `label-${++legacyToolId}`;
|
|
1552
2080
|
toolCalls.set(key, { label, state });
|
|
1553
|
-
|
|
2081
|
+
work.note(key, label, state);
|
|
2082
|
+
if (cancellationRequested) {
|
|
2083
|
+
tui.requestRender();
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
1554
2086
|
const pending = [...toolCalls.values()].filter((call) => call.state === "running");
|
|
1555
2087
|
if (pending.length) {
|
|
1556
|
-
setFooter(pending.length > 1 ?
|
|
2088
|
+
setFooter(pending.length > 1 ? tr("{count} running · {label}", { count: pending.length, label: pending[0].label }) : `${pending[0].label}...`);
|
|
1557
2089
|
} else {
|
|
1558
|
-
setFooter(state === "failed" ?
|
|
2090
|
+
setFooter(state === "failed" ? tr("{label} didn't work · carrying on...", { label }) : tr("Thinking..."), state === "failed" ? warning : dim);
|
|
1559
2091
|
}
|
|
1560
2092
|
};
|
|
1561
2093
|
|
|
@@ -1564,6 +2096,7 @@ export async function runMoondogTui({
|
|
|
1564
2096
|
...(profileSeed ? { profileSeed } : {}),
|
|
1565
2097
|
onTextDelta: (delta) => {
|
|
1566
2098
|
if (cleanedUp) return;
|
|
2099
|
+
work.quiet();
|
|
1567
2100
|
streamedText += sanitizeTerminalText(delta);
|
|
1568
2101
|
response.setText(streamedText);
|
|
1569
2102
|
tui.requestRender();
|
|
@@ -1571,14 +2104,21 @@ export async function runMoondogTui({
|
|
|
1571
2104
|
onTextReplace: (replacement) => {
|
|
1572
2105
|
if (cleanedUp) return;
|
|
1573
2106
|
streamedText = sanitizeTerminalText(replacement);
|
|
2107
|
+
if (streamedText.trim()) work.quiet();
|
|
1574
2108
|
response.setText(streamedText);
|
|
1575
2109
|
tui.requestRender();
|
|
1576
2110
|
},
|
|
1577
2111
|
onModelRetry: ({ attempt, maxRetries }) => {
|
|
1578
|
-
if (!cancellationRequested) setFooter(
|
|
2112
|
+
if (!cancellationRequested) setFooter(tr("Can't reach the model. Trying again ({attempt}/{max})...", { attempt, max: maxRetries }), warning);
|
|
1579
2113
|
},
|
|
1580
2114
|
onToolStart: (tool) => updateTool(tool, "running"),
|
|
1581
2115
|
onToolEnd: (tool) => updateTool(tool, tool?.isError ? "failed" : "completed"),
|
|
2116
|
+
// The model names a new conversation after its first reply; the header shows that name from then on.
|
|
2117
|
+
onConversationNamed: ({ session_id, title }) => {
|
|
2118
|
+
if (cleanedUp || application.memorySession?.session_id !== session_id) return;
|
|
2119
|
+
conversationTitle = Array.from(title).slice(0, 160).join("");
|
|
2120
|
+
renderHeader();
|
|
2121
|
+
},
|
|
1582
2122
|
});
|
|
1583
2123
|
if (cleanedUp) return;
|
|
1584
2124
|
if (result.status === "aborted") {
|
|
@@ -1586,53 +2126,205 @@ export async function runMoondogTui({
|
|
|
1586
2126
|
streamedText = sanitizeTerminalText(result.text);
|
|
1587
2127
|
response.setText(streamedText);
|
|
1588
2128
|
}
|
|
1589
|
-
setFooter("Cancelled."
|
|
2129
|
+
setFooter(tr("Cancelled."));
|
|
2130
|
+
} else if (result.status === "interrupted") {
|
|
2131
|
+
response.setText(sanitizeTerminalText(result.text));
|
|
2132
|
+
setFooter(tr("Response interrupted. Spotify results are shown."), warning);
|
|
1590
2133
|
} else {
|
|
1591
|
-
setFooter("
|
|
2134
|
+
setFooter(tr("Up recalls this conversation."), success);
|
|
1592
2135
|
}
|
|
1593
2136
|
} catch (error) {
|
|
1594
2137
|
if (cleanedUp) return;
|
|
1595
2138
|
const connectionFailed = error.code === "model_connection_failed";
|
|
1596
2139
|
const errorText = connectionFailed
|
|
1597
2140
|
? `${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
|
-
:
|
|
2141
|
+
? tr("Something already ran before the connection dropped. Check what happened before you ask again.")
|
|
2142
|
+
: tr("Press ↑ to bring your message back, then Enter to try again.")}`
|
|
2143
|
+
: tr("Something went wrong: {error}", { error: sanitizeTerminalText(error.message) });
|
|
1601
2144
|
const appendedError = connectionFailed ? errorText : `_${errorText}_`;
|
|
1602
2145
|
response.setText(
|
|
1603
2146
|
streamedText.length > 0 ? `${streamedText}\n\n${appendedError}` : errorText,
|
|
1604
2147
|
);
|
|
1605
2148
|
setFooter(connectionFailed
|
|
1606
2149
|
? 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.",
|
|
2150
|
+
? tr("Lost the model. Check what ran before retrying.")
|
|
2151
|
+
: tr("Lost the model. ↑ brings your message back.")
|
|
2152
|
+
: tr("The model couldn't answer."), failure);
|
|
1610
2153
|
} finally {
|
|
1611
|
-
if (
|
|
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
|
-
}
|
|
2154
|
+
if (!cleanedUp && work.calls.length) work.settle(elapsedTime(workStartedAt));
|
|
1626
2155
|
setBusy(false);
|
|
1627
|
-
editor.disableSubmit = false;
|
|
1628
2156
|
if (!cleanedUp) tui.requestRender();
|
|
1629
2157
|
}
|
|
1630
2158
|
};
|
|
2159
|
+
editor.onSubmit = async (rawValue) => {
|
|
2160
|
+
const pending = sanitizeTerminalText(rawValue).trim();
|
|
2161
|
+
if (/^\/rewind(?:\s|$)/u.test(pending)) {
|
|
2162
|
+
if (rewindPending) return;
|
|
2163
|
+
rewindPending = true;
|
|
2164
|
+
releaseQueue = false;
|
|
2165
|
+
queuePaused = messageQueue.length > 0;
|
|
2166
|
+
let timer;
|
|
2167
|
+
try {
|
|
2168
|
+
if (busy) {
|
|
2169
|
+
if (!activeSubmission || !cancelWork) throw new Error(tr("Wait for this local operation to finish before rewinding."));
|
|
2170
|
+
cancellationRequested = true;
|
|
2171
|
+
cancelWork();
|
|
2172
|
+
setFooter(tr("Stopping the current turn before rewind..."));
|
|
2173
|
+
await Promise.race([activeSubmission, new Promise((_, reject) => {
|
|
2174
|
+
timer = setTimeout(() => reject(new Error(tr("The current turn is still stopping; no rewind was made. Try again after it finishes."))), 5_000);
|
|
2175
|
+
})]);
|
|
2176
|
+
}
|
|
2177
|
+
if (!cleanedUp) await submitListeningTurn(rawValue);
|
|
2178
|
+
} catch (error) { setFooter(sanitizeTerminalText(error.message), warning); }
|
|
2179
|
+
finally { clearTimeout(timer); rewindPending = false; }
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
if (busy || rewindPending) {
|
|
2183
|
+
if (pending === "/quit" || pending === "/exit") {
|
|
2184
|
+
shutdown();
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (previewControls && (pending === "/skip" || pending === "/stop")) {
|
|
2188
|
+
controlPreviews(pending.slice(1));
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
if (pending) {
|
|
2192
|
+
if (messageQueue.length >= 30) editor.setText(pending);
|
|
2193
|
+
else messageQueue.push(pending);
|
|
2194
|
+
if (rewindPending) queuePaused = true;
|
|
2195
|
+
}
|
|
2196
|
+
if (!cleanedUp) tui.requestRender();
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
releaseQueue = !queuePaused;
|
|
2200
|
+
const submission = submitListeningTurn(rawValue);
|
|
2201
|
+
activeSubmission = submission;
|
|
2202
|
+
try { await submission; }
|
|
2203
|
+
finally {
|
|
2204
|
+
if (activeSubmission === submission) activeSubmission = null;
|
|
2205
|
+
// This turn's messages are on screen already; the watcher draws only what others add.
|
|
2206
|
+
if (!cleanedUp) { try { markDrawn(); } catch { /* The next look tries again. */ } }
|
|
2207
|
+
if (!busy && !cleanedUp && releaseQueue) settleQueue();
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
|
|
2211
|
+
// The terminal and the App show the same conversation: the one either opened last. The terminal
|
|
2212
|
+
// follows a switch made in the App, draws what the App adds, and shows a reply the App is writing
|
|
2213
|
+
// as it grows, holding new messages in the queue until it ends.
|
|
2214
|
+
const LOOK_MS = { idle: 1_000, answering: 300 };
|
|
2215
|
+
let seenVersion = null;
|
|
2216
|
+
// The conversation on screen and the saved messages it shows.
|
|
2217
|
+
let drawn = { session: null, ids: new Set() };
|
|
2218
|
+
const savedEntries = () => application.conversationEntries?.({ limit: 500 }) ?? [];
|
|
2219
|
+
const markDrawn = () => {
|
|
2220
|
+
const reply = application.replyElsewhere?.() ?? null;
|
|
2221
|
+
drawn = { session: sessionId(), ids: new Set(savedEntries().map((entry) => entry.entry_id).filter((id) => id !== reply?.entry_id)) };
|
|
2222
|
+
};
|
|
2223
|
+
const addSavedTurns = (turns) => {
|
|
2224
|
+
for (const turn of turns) {
|
|
2225
|
+
if (turn.role === "user") addUserMessage(sanitizeTerminalText(turn.text));
|
|
2226
|
+
if (turn.role === "assistant") addMoondogMessage(turn.text);
|
|
2227
|
+
}
|
|
2228
|
+
};
|
|
2229
|
+
// Draws the saved conversation. A reply still running elsewhere is left to the watcher, which draws it live.
|
|
2230
|
+
const drawSavedConversation = () => {
|
|
2231
|
+
const reply = application.replyElsewhere?.() ?? null;
|
|
2232
|
+
const turns = application.currentConversationTurns?.({ limit: 40 }) ?? application.currentSessionTurns({ limit: 40 });
|
|
2233
|
+
const last = reply ? turns.findLastIndex((turn) => turn.role === "user") : -1;
|
|
2234
|
+
if (last >= 0 && turns[last].text === reply.user_text) turns.splice(last);
|
|
2235
|
+
profileDiscoveryDraft = null;
|
|
2236
|
+
transcript.clear();
|
|
2237
|
+
homeVisible = turns.length === 0 && !reply;
|
|
2238
|
+
homeFocused = false;
|
|
2239
|
+
editor.focused = true;
|
|
2240
|
+
addSavedTurns(turns);
|
|
2241
|
+
return turns;
|
|
2242
|
+
};
|
|
2243
|
+
const endElsewhere = () => {
|
|
2244
|
+
if (!elsewhere) return;
|
|
2245
|
+
elsewhere = null;
|
|
2246
|
+
setBusy(false);
|
|
2247
|
+
setFooter(tr("Ready."));
|
|
2248
|
+
};
|
|
2249
|
+
const followElsewhere = () => {
|
|
2250
|
+
const id = application.activeConversationId?.();
|
|
2251
|
+
if (!id || id === sessionId()) return;
|
|
2252
|
+
storeRecall();
|
|
2253
|
+
if (!application.followActiveConversation?.()) return;
|
|
2254
|
+
endElsewhere();
|
|
2255
|
+
activeRuntime.restoreSession();
|
|
2256
|
+
const now = sessionId();
|
|
2257
|
+
installRecall(now, recallBySession.get(now) ?? recallFromTurns());
|
|
2258
|
+
conversationTitle = "";
|
|
2259
|
+
drawSavedConversation();
|
|
2260
|
+
markDrawn();
|
|
2261
|
+
tui.requestRender(true);
|
|
2262
|
+
};
|
|
2263
|
+
const showElsewhere = () => {
|
|
2264
|
+
const reply = application.replyElsewhere?.() ?? null;
|
|
2265
|
+
for (const entry of savedEntries()) {
|
|
2266
|
+
if (drawn.ids.has(entry.entry_id) || entry.entry_id === elsewhere?.id) continue;
|
|
2267
|
+
if (entry.entry_id !== reply?.entry_id) {
|
|
2268
|
+
drawn.ids.add(entry.entry_id);
|
|
2269
|
+
addSavedTurns(application.conversationEntryTurns?.(entry) ?? []);
|
|
2270
|
+
continue;
|
|
2271
|
+
}
|
|
2272
|
+
enterConversation();
|
|
2273
|
+
addUserMessage(sanitizeTerminalText(reply.user_text));
|
|
2274
|
+
addLabel("Moondog");
|
|
2275
|
+
const response = new Markdown(sanitizeTerminalText(reply.assistant_text), 1, 1, markdownTheme, textStyle);
|
|
2276
|
+
transcript.addChild(response);
|
|
2277
|
+
transcriptScroll.scrollToEnd();
|
|
2278
|
+
elsewhere = { id: reply.entry_id, response, text: reply.assistant_text };
|
|
2279
|
+
setBusy(true);
|
|
2280
|
+
setFooter(tr("Answering in another Moondog window..."));
|
|
2281
|
+
}
|
|
2282
|
+
if (!elsewhere) return;
|
|
2283
|
+
if (reply?.entry_id === elsewhere.id) {
|
|
2284
|
+
if (reply.assistant_text !== elsewhere.text) {
|
|
2285
|
+
elsewhere.text = reply.assistant_text;
|
|
2286
|
+
elsewhere.response.setText(sanitizeTerminalText(reply.assistant_text));
|
|
2287
|
+
tui.requestRender();
|
|
2288
|
+
}
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
// The reply ended: draw it as saved, then send what waited in the queue.
|
|
2292
|
+
const saved = savedEntries().find((entry) => entry.entry_id === elsewhere.id);
|
|
2293
|
+
const answer = saved ? (application.conversationEntryTurns?.(saved) ?? []).find((turn) => turn.role === "assistant") : null;
|
|
2294
|
+
elsewhere.response.setText(sanitizeTerminalText(answer?.text ?? elsewhere.text));
|
|
2295
|
+
drawn.ids.add(elsewhere.id);
|
|
2296
|
+
endElsewhere();
|
|
2297
|
+
settleQueue();
|
|
2298
|
+
};
|
|
2299
|
+
const lookElsewhere = () => {
|
|
2300
|
+
if (typeof application.memoryStore?.dataVersion !== "function") return null;
|
|
2301
|
+
if ((busy && !elsewhere) || rewindPending || tui.hasOverlay() || profileView || importView) return LOOK_MS.idle;
|
|
2302
|
+
followElsewhere();
|
|
2303
|
+
if (drawn.session !== sessionId()) markDrawn();
|
|
2304
|
+
const version = application.memoryStore.dataVersion();
|
|
2305
|
+
if (version !== seenVersion || elsewhere) {
|
|
2306
|
+
seenVersion = version;
|
|
2307
|
+
showElsewhere();
|
|
2308
|
+
}
|
|
2309
|
+
return elsewhere ? LOOK_MS.answering : LOOK_MS.idle;
|
|
2310
|
+
};
|
|
2311
|
+
const watchElsewhere = () => {
|
|
2312
|
+
if (cleanedUp) return;
|
|
2313
|
+
let wait = LOOK_MS.idle;
|
|
2314
|
+
try { wait = lookElsewhere(); } catch { /* A busy or closing store; the next look tries again. */ }
|
|
2315
|
+
if (wait === null || cleanedUp) return;
|
|
2316
|
+
watchTimer = setTimeout(watchElsewhere, wait);
|
|
2317
|
+
watchTimer.unref?.();
|
|
2318
|
+
};
|
|
1631
2319
|
|
|
1632
2320
|
tui.addInputListener((data) => {
|
|
1633
2321
|
if (tui.hasOverlay()) return undefined;
|
|
1634
2322
|
if (importView) {
|
|
1635
|
-
if (busy)
|
|
2323
|
+
if (busy) {
|
|
2324
|
+
if (importPage === "signin" && (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))) importAuthController?.abort();
|
|
2325
|
+
if (importPage === "working" && (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))) lastfmController?.abort();
|
|
2326
|
+
return { consume: true };
|
|
2327
|
+
}
|
|
1636
2328
|
if (matchesKey(data, "ctrl+c")) {
|
|
1637
2329
|
closeImport();
|
|
1638
2330
|
return { consume: true };
|
|
@@ -1648,6 +2340,10 @@ export async function runMoondogTui({
|
|
|
1648
2340
|
if (matchesKey(data, "ctrl+c")) { closeProfile(); return { consume: true }; }
|
|
1649
2341
|
return undefined;
|
|
1650
2342
|
}
|
|
2343
|
+
if (matchesKey(data, "alt+r")) {
|
|
2344
|
+
void editor.onSubmit('/rewind');
|
|
2345
|
+
return { consume: true };
|
|
2346
|
+
}
|
|
1651
2347
|
if (!busy && homeVisible && !editor.getText()) {
|
|
1652
2348
|
if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
|
|
1653
2349
|
homeFocused = !homeFocused;
|
|
@@ -1680,16 +2376,21 @@ export async function runMoondogTui({
|
|
|
1680
2376
|
}
|
|
1681
2377
|
}
|
|
1682
2378
|
}
|
|
2379
|
+
if ((busy || queuePaused) && !editor.isShowingAutocomplete() && matchesKey(data, "up") && !editor.getText() && messageQueue.length) {
|
|
2380
|
+
editor.setText(messageQueue.pop());
|
|
2381
|
+
tui.requestRender();
|
|
2382
|
+
return { consume: true };
|
|
2383
|
+
}
|
|
1683
2384
|
if (!matchesKey(data, "ctrl+c")) return undefined;
|
|
1684
2385
|
if (busy) {
|
|
1685
2386
|
if (cancelWork) {
|
|
1686
2387
|
if (!cancellationRequested) {
|
|
1687
2388
|
cancellationRequested = true;
|
|
1688
|
-
setFooter("Stopping..."
|
|
2389
|
+
setFooter(tr("Stopping..."));
|
|
1689
2390
|
cancelWork();
|
|
1690
2391
|
}
|
|
1691
2392
|
} else {
|
|
1692
|
-
setFooter("This can't be stopped halfway. Almost there..."
|
|
2393
|
+
setFooter(tr("This can't be stopped halfway. Almost there..."));
|
|
1693
2394
|
}
|
|
1694
2395
|
} else {
|
|
1695
2396
|
shutdown();
|
|
@@ -1697,18 +2398,26 @@ export async function runMoondogTui({
|
|
|
1697
2398
|
return { consume: true };
|
|
1698
2399
|
});
|
|
1699
2400
|
|
|
1700
|
-
setFooter("New conversation · /resume history");
|
|
2401
|
+
setFooter(tr("New conversation · /resume history"));
|
|
1701
2402
|
void sourceStatusTask.then((error) => {
|
|
1702
|
-
if (cleanedUp || !error || footerText !== "New conversation · /resume history") return;
|
|
1703
|
-
setFooter(
|
|
2403
|
+
if (cleanedUp || !error || footerText !== tr("New conversation · /resume history")) return;
|
|
2404
|
+
setFooter(tr("Couldn't read the music library: {error}", { error: sanitizeTerminalText(error.message) }), warning);
|
|
1704
2405
|
});
|
|
1705
2406
|
try {
|
|
1706
2407
|
startAttempted = true;
|
|
1707
2408
|
tui.start();
|
|
2409
|
+
watchTimer = setTimeout(watchElsewhere, LOOK_MS.idle);
|
|
2410
|
+
watchTimer.unref?.();
|
|
2411
|
+
if (checkUpdates) void Promise.resolve().then(() => checkUpdates({ signal: updateCheckController.signal })).then(result => {
|
|
2412
|
+
if (cleanedUp || result?.state !== "available") return;
|
|
2413
|
+
updateNotice = tr("Moondog {version} available · /update", { version: result.latest });
|
|
2414
|
+
tui.requestRender();
|
|
2415
|
+
}).catch(() => {});
|
|
1708
2416
|
void updateHomeLyrics();
|
|
1709
2417
|
await stoppedPromise;
|
|
1710
2418
|
} finally {
|
|
1711
2419
|
cleanup();
|
|
1712
2420
|
await lyricTask;
|
|
1713
2421
|
}
|
|
2422
|
+
return exitRequest;
|
|
1714
2423
|
}
|