@audiofool/moondog 0.1.0 → 0.2.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +153 -68
  3. package/contracts/v1/definitions.schema.json +1 -169
  4. package/package.json +7 -23
  5. package/scripts/contract-lib.mjs +0 -7
  6. package/scripts/contract-semantics.mjs +0 -1904
  7. package/scripts/moondog.mjs +285 -74
  8. package/src/core/apple-library-source-status.mjs +0 -16
  9. package/src/core/apple-projection-domain-services.mjs +168 -36
  10. package/src/core/application-update.mjs +320 -0
  11. package/src/core/capability-catalog.mjs +278 -55
  12. package/src/core/listening-now.mjs +108 -0
  13. package/src/core/listening-profile-domain-services.mjs +153 -41
  14. package/src/core/listening-upkeep.mjs +31 -0
  15. package/src/core/moondog-application.mjs +1908 -104
  16. package/src/core/playback-preferences.mjs +97 -0
  17. package/src/core/spotify-listening-intent.mjs +121 -0
  18. package/src/core/synthetic-domain-services.mjs +24 -3
  19. package/src/demo/moondog-demo.mjs +4 -22
  20. package/src/i18n/index.mjs +285 -0
  21. package/src/i18n/messages/de.mjs +128 -0
  22. package/src/i18n/messages/en.mjs +129 -0
  23. package/src/i18n/messages/es.mjs +128 -0
  24. package/src/i18n/messages/fr.mjs +128 -0
  25. package/src/i18n/messages/it.mjs +128 -0
  26. package/src/i18n/messages/ja.mjs +128 -0
  27. package/src/i18n/messages/ko.mjs +128 -0
  28. package/src/i18n/messages/pt.mjs +128 -0
  29. package/src/i18n/messages/zh-Hant.mjs +128 -0
  30. package/src/i18n/messages/zh.mjs +128 -0
  31. package/src/i18n/preferences.mjs +34 -0
  32. package/src/i18n/screen/es.mjs +966 -0
  33. package/src/i18n/screen/ja.mjs +966 -0
  34. package/src/i18n/screen/pt.mjs +966 -0
  35. package/src/i18n/screen/zh.mjs +966 -0
  36. package/src/importers/apple-music-library/index.mjs +0 -16
  37. package/src/importers/apple-music-library/sqlite-projection.mjs +51 -9
  38. package/src/integrations/apple-music/catalog.mjs +123 -14
  39. package/src/integrations/apple-music/music-app.mjs +232 -0
  40. package/src/integrations/apple-music/storefront.mjs +28 -0
  41. package/src/integrations/apple-music/terminal-preview-player.mjs +221 -0
  42. package/src/integrations/lastfm/history.mjs +221 -0
  43. package/src/integrations/lastfm/settings.mjs +75 -0
  44. package/src/integrations/netease/ncm-cli-player.mjs +321 -0
  45. package/src/integrations/open-music-similarity/artist-radio.mjs +26 -18
  46. package/src/integrations/public-playlists.mjs +2 -1
  47. package/src/integrations/spotify/account-data-profile.mjs +14 -6
  48. package/src/integrations/spotify/authentication.mjs +149 -15
  49. package/src/integrations/spotify/catalog-resolver.mjs +8 -4
  50. package/src/integrations/spotify/connection.mjs +2 -1
  51. package/src/integrations/spotify/credential-store.mjs +4 -0
  52. package/src/integrations/spotify/extended-streaming-history.mjs +11 -11
  53. package/src/integrations/spotify/history-archive.mjs +1 -1
  54. package/src/integrations/spotify/library-snapshot.mjs +227 -0
  55. package/src/integrations/spotify/recent-activity.mjs +2 -0
  56. package/src/integrations/spotify/service.mjs +873 -69
  57. package/src/integrations/spotify/web-api-client.mjs +407 -127
  58. package/src/integrations/web/brave-web.mjs +45 -0
  59. package/src/integrations/web/codex-web.mjs +15 -82
  60. package/src/integrations/web/deepseek-web.mjs +81 -0
  61. package/src/integrations/web/web-research.mjs +119 -0
  62. package/src/integrations/web/web-result.mjs +215 -0
  63. package/src/integrations/youtube-music/takeout.mjs +3 -1
  64. package/src/memory/local-memory-store.mjs +469 -41
  65. package/src/profile/history-import.mjs +42 -6
  66. package/src/profile/listener-profile-build.mjs +378 -0
  67. package/src/profile/listener-profile-store.mjs +130 -0
  68. package/src/profile/listener-profile-verification.mjs +68 -0
  69. package/src/profile/listener-profile-worker.mjs +172 -0
  70. package/src/profile/listening-history-store.mjs +424 -8
  71. package/src/profile/listening-profile-projection.mjs +242 -76
  72. package/src/profile/local-music-data.mjs +1 -1
  73. package/src/profile/music-providers.mjs +11 -7
  74. package/src/profile/profile-exploration.mjs +84 -0
  75. package/src/profile/recorded-measures.mjs +24 -0
  76. package/src/profile/spotify-archive-taste.mjs +0 -4
  77. package/src/runtime/pi/agent-runtime.mjs +2204 -605
  78. package/src/runtime/pi/authentication.mjs +34 -1
  79. package/src/runtime/pi/configured-runtime.mjs +22 -6
  80. package/src/runtime/pi/conversation-name.mjs +53 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +2 -2
  82. package/src/runtime/pi/model-catalog.mjs +28 -5
  83. package/src/runtime/pi/model-settings.mjs +202 -0
  84. package/src/runtime/pi/model-transport.mjs +10 -0
  85. package/src/runtime/pi/persistent-credential-store.mjs +67 -31
  86. package/src/runtime/pi/profile-build-runtime.mjs +160 -0
  87. package/src/runtime/pi/public-model-catalog.mjs +288 -0
  88. package/src/runtime/pi/runtime-settings.mjs +10 -2
  89. package/src/runtime/pi/skills/listener-profile/SKILL.md +46 -0
  90. package/src/runtime/pi/tool-trace.mjs +37 -0
  91. package/src/surfaces/cli/auth-command.mjs +12 -4
  92. package/src/surfaces/cli/brand-components.mjs +310 -132
  93. package/src/surfaces/cli/brand-theme.mjs +51 -28
  94. package/src/surfaces/cli/command-completions.mjs +58 -42
  95. package/src/surfaces/cli/format-output.mjs +199 -388
  96. package/src/surfaces/cli/history-import-view.mjs +292 -139
  97. package/src/surfaces/cli/import-guides.mjs +2 -0
  98. package/src/surfaces/cli/listener-profile-command.mjs +92 -0
  99. package/src/surfaces/cli/listening-menu.mjs +7 -4
  100. package/src/surfaces/cli/profile-command.mjs +30 -21
  101. package/src/surfaces/cli/seasons-chart.mjs +87 -0
  102. package/src/surfaces/cli/spotify-command.mjs +54 -6
  103. package/src/surfaces/cli/taste-profile-model.mjs +78 -79
  104. package/src/surfaces/cli/taste-profile-view.mjs +33 -26
  105. package/src/surfaces/cli/terminal-art.mjs +140 -35
  106. package/src/surfaces/cli/tui.mjs +1066 -357
  107. package/src/surfaces/cli/update-command.mjs +32 -0
  108. package/src/surfaces/cli/web-command.mjs +53 -14
  109. package/src/surfaces/html/tasteprint.mjs +43 -70
  110. package/contracts/v1/capability-policy.schema.json +0 -572
  111. package/contracts/v1/profile-evidence.schema.json +0 -237
  112. package/contracts/v1/tool-invocation.schema.json +0 -318
  113. package/contracts/v1/tool-receipt.schema.json +0 -1066
  114. package/scripts/demo-studio.mjs +0 -58
  115. package/src/core/apple-data-migration.mjs +0 -50
  116. package/src/demo/moondog-interactive-tasteprint-demo.mjs +0 -293
  117. package/src/demo/moondog-tasteprint-demo.mjs +0 -631
  118. package/src/integrations/spotify/index.mjs +0 -80
  119. package/src/surfaces/web/showcase.mjs +0 -272
  120. package/src/surfaces/web/studio-page.mjs +0 -4148
  121. package/src/surfaces/web/studio.mjs +0 -2288
@@ -1,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 { supportedAuthProviderIds } from "../../runtime/pi/authentication.mjs";
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: "home", description: "Back to the record sleeve" },
87
- { name: "lyrics", description: "Your lyric library, or sync more songs" },
88
- { name: "import", description: "Bring in your listening history or library" },
89
- { name: "theme", description: "Paper, charcoal, or your terminal colors" },
90
- { name: "art", description: "Braille, ASCII, or a quiet opening" },
91
- { name: "motion", description: "Turn the animation on or off" },
92
- { name: "commands", description: "Search every command" },
93
- { name: "status", description: "See what's connected and ready" },
94
- { name: "sources", description: "See where your music data comes from" },
95
- { name: "profile", description: "Review, change, or undo your choices" },
96
- { name: "taste", description: "Your listening profile and the evidence behind it" },
97
- { name: "memory", description: "See what Moondog remembers" },
98
- { name: "tools", description: "See which music tools are available" },
99
- { name: "doctor", description: "Check your setup" },
100
- { name: "model", description: "Choose the model Moondog talks through" },
101
- { name: "auth", description: "Sign in to a model provider" },
102
- { name: "web", description: "Look things up on public music sites" },
103
- { name: "spotify", description: "Connect and control Spotify" },
104
- { name: "reload", description: "Reload model settings and sign-ins" },
105
- { name: "remember", description: "Ask Moondog to remember something" },
106
- { name: "forget", description: "Forget one memory by ID" },
107
- { name: "resume", description: "Pick up a saved conversation" },
108
- { name: "new", description: "Start fresh; this conversation stays saved" },
109
- { name: "help", description: "Commands and shortcuts" },
110
- { name: "quit", description: "Leave Moondog" },
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
- ? [`you're not signed in to ${provider}`, `Sign in with \`/auth ${provider}\`.`]
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
- : [`I can't reach the model (\`${runtimeStatus.reason}\`)`, "Type `/model` to pick another, or `/reload` to try again."];
158
- return `Obscured by clouds. I can't talk yet because ${why}.
159
-
160
- ${next}
161
-
162
- Your profile doesn't need a model. \`/taste\`, \`/import\`, and \`/help\` all work right now.`;
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
- const green = color("success");
205
- const yellow = color("warning");
206
- const red = color("error");
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: (width) => profileView || importView ? [] : composer.render(width),
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 marker = theme.plain ? "." : busy && !tui.hasOverlay() ? ["◴", "◷", "◶", "◵"][phase % 4] : "◎";
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.startsWith("Reading the file") ? "Reading; nothing added yet."
379
- : footerText.startsWith("History saved") ? "Saved; refreshing profile..." : "Saving history locally...";
380
- else if (importView.state.error) statusText = "See the message above.";
381
- else if (footerText.startsWith("Guide opened")) statusText = "Guide opened in browser.";
382
- else statusText = importPage === "preview" ? "Ready; nothing added."
383
- : importPage === "file" ? "Choose one history file." : "Choose a step; Esc goes back.";
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.replace(/\. Profile refreshed\.$/u, "");
387
- if (tui.hasOverlay()) statusText = "Enter chooses · Esc back";
388
- else if (footerText.startsWith("Pick a song or artist.")) statusText = "Enter actions · Tab views · Esc back";
389
- else if (footerText.startsWith("Here's the evidence.")) statusText = "Evidence · PgUp/PgDn scroll · Enter";
390
- else if (footerText.startsWith("Imported.")) statusText = "Imported · Enter to review";
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
- const lines = [paintBrandLine(` ${theme.faint(marker)} ${footerColor(statusText)}`, width, theme)];
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 ? "cancelling · draft stays here"
411
- : cancelWork ? "draft stays here · ctrl+c cancel"
412
- : "draft stays here · waiting for command"
413
- : editor.isShowingAutocomplete() ? "↑↓ choose · tab/enter complete · esc dismiss" : homeFocused
414
- ? "↑ ↓ choose · enter open · esc type"
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
- : width >= 72 ? "pgup history · ctrl+p · /home enter send · shift+enter newline" : "pgup history · /home";
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(slashCommands, {
425
- providers, models, authProviderIds: supportedAuthProviderIds,
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
- addLabel("You");
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
- const addMoondogMessage = (markdown) => {
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
- transcript.addChild(new Markdown(sanitizeTerminalText(markdown), 1, 1, markdownTheme, textStyle));
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(`That didn't work: ${error.message}`, red);
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.", green);
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 " + subject.kind },
637
- { value: "avoid", label: "Keep it out", description: "Leave this " + subject.kind + " out of suggestions" },
638
- ...(subject.correctionId ? [{ value: "retract", label: "Undo my choice", description: "Go back to what your listening says" }] : []),
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.", yellow);
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" ? green : yellow);
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 detailLines = [
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) ? ["", `How sure: ${Math.round(explanation.confidence * 100)}%`] : []),
680
- "", ...subject.detailLines,
681
- ].filter((line) => typeof line === "string").map(sanitizeTerminalText);
682
- const model = buildTasteProfileModel(profileSnapshot);
683
- model.subjects = model.subjects.map((item) => item.key === subject.key ? { ...item, detailLines } : item);
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...", yellow);
892
+ setFooter(tr("Saving your choice..."));
694
893
  await runProfileAction(args);
695
894
  if (cleanedUp || !profileView) return;
696
- const receipt = selected.value === "retract" ? `Undone: ${subject.label}`
697
- : `${selected.value === "like" ? "Liked" : "Kept out"}: ${subject.label}`;
698
- const named = `${subject.label}${subject.kind === "track" ? ` by ${target.artistCredit}` : ""}`;
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
- ? `Done. I'll read ${named} from your listening again, without your earlier choice.`
899
+ ? tr("Done. I'll read {name} from your listening again, without your earlier choice.", { name: named })
701
900
  : selected.value === "like"
702
- ? `Noted. You like ${named}.\n\nYour listening history stays as it was.`
703
- : `Noted. I'll leave ${named} out of suggestions.\n\nYour listening history stays as it was.`);
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(`${receipt}.`, green);
907
+ setFooter(tr("{receipt}.", { receipt }), success);
709
908
  } catch (error) {
710
- setFooter(`${receipt}. The profile didn't refresh; Ctrl+R tries again.`, yellow);
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
- addMoondogMessage(formatLocalResult("taste", await application.runLocalCommand("taste", runtimeStatus)));
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.", green);
936
+ setFooter(tr("Profile updated."), success);
738
937
  }); };
739
938
  profileView.onReport = () => {
740
939
  const snapshot = profileSnapshot;
741
940
  closeProfile();
742
- addMoondogMessage(formatLocalResult("taste", snapshot));
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.", green);
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
- throw new Error("Usage: /model [provider] [model].");
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(`Unknown Pi provider: ${providerId}`);
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.", yellow);
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(`Unknown ${providerId} model: ${modelId}`);
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
- `Choose a model for ${providerId}`,
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.", yellow);
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
- setFooter(`Loading ${providerId}/${modelId}...`, yellow);
836
- const status = await replaceRuntime({ provider: providerId, model: modelId });
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
- ? `Now talking through \`${providerId}/${modelId}\`. This conversation and what I remember carry over.`
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
- ? `Saved \`${providerId}/${modelId}\`, but you're not signed in yet. Run \`/auth ${providerId}\` to connect it.`
842
- : `Saved \`${providerId}/${modelId}\`, but I can't reach it yet (\`${status.reason}\`).`,
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" ? green : yellow,
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 provider",
1145
+ tr("Sign in to a model or web search service"),
872
1146
  );
873
1147
  if (!selectedProvider) {
874
- setFooter("Sign-in cancelled.", yellow);
1148
+ setFooter(tr("Sign-in cancelled."));
875
1149
  return;
876
1150
  }
877
1151
  providerId = selectedProvider.value;
878
1152
  }
879
1153
 
880
- setFooter(`Opening ${providerId} sign-in...`, yellow);
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
- ? { provider: runtimeStatus.provider, model: runtimeStatus.model }
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
- status.state === "configured"
900
- ? `Signed in. Talking through \`${status.provider}/${status.model}\`.`
901
- : `Signed in. Run \`/model ${providerId}\` to pick a model.`,
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.", green);
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
- addMoondogMessage([
914
- "Here's everything I can read so far, including anything you imported before.",
915
- formatLocalResult("taste", profile),
916
- "Type `/taste` to open any song or artist and see why it's here. `/taste report` brings this report back.",
917
- ].join("\n\n"));
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.", green);
1197
+ else setFooter(tr("Imported. /taste opens your profile."), success);
920
1198
  } catch (error) {
921
1199
  addMoondogMessage(
922
- `Your history was imported, but I couldn't show the profile: ${error.message}\n\nNothing was lost. Type \`/taste\` to try again.`,
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.", yellow);
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.", yellow);
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.", green);
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.", yellow);
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.", yellow);
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 your recent listening. Nothing is added until you import it.", green);
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 to allow recent listening.",
1004
- spotify_authentication_required: "Your Spotify sign-in has expired. Choose Reconnect Spotify.",
1005
- spotify_quota_exceeded: "Your Spotify app has used up its quota for now. A downloaded history ZIP still works.",
1006
- spotify_rate_limited: "Spotify wants us to slow down. Wait a minute and try again, or use a history ZIP.",
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.", yellow);
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
- setImportPage("working", { message: configuring ? "Saving your Spotify app..." : "Finish signing in to Spotify in your browser. Ctrl+C cancels." });
1021
- importAuthController = new AbortController();
1022
- if (!configuring) tui.stop({ preserveScreen: true });
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
- await runSpotify(configuring ? ["configure", clientId] : ["login", "--history-only"], { signal: importAuthController.signal });
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
- setImportPage("quick", { message: configuring ? "App saved. Now connect Spotify." : "Spotify is connected. Next, preview your recent listening." });
1030
- setFooter("Connected. Nothing imported yet.", green);
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
- setImportPage(configuring ? "client" : "quick", { error: sanitizeTerminalText(error.message) });
1034
- setFooter("Nothing imported. Try again, or use a history ZIP.", yellow);
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...", yellow);
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
- : `Saved ${receipt.library_tracks ?? preview.tracks} songs from your library. A library shows what you keep, not when you played it, so no plays were added.`
1062
- : receipt.already_imported || (receipt.inserted_events === 0 && !receipt.inserted_profile_evidence && !receipt.superseded_events)
1063
- ? "Already up to date. There was nothing new in this one."
1064
- : `${receipt.inserted_events ?? 0} new ${receipt.inserted_events === 1 ? "play" : "plays"}${receipt.duplicate_events ? `, ${receipt.duplicate_events} I already had` : ""}.${receipt.inserted_profile_evidence ? ` Also ${receipt.inserted_profile_evidence} saved songs, follows, or playlist entries.` : ""}`;
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" : "## Your history is in",
1068
- [preview.sourceLabel, preview.fileName].filter(Boolean).join(" · "),
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
- ...(receipt.superseded_events ? [`${receipt.superseded_events} plays you already had were replaced with more detailed versions.`] : []),
1071
- "Everything from before is still here, including your choices. `/import` adds another source any time.",
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...", yellow);
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(`Your history is saved, but I couldn't update the profile: ${sanitizeTerminalText(error.message)}\n\nNothing was lost. Try \`/reload\`, then \`/taste\`.`);
1091
- setFooter("History saved. The profile needs another try.", yellow);
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.", yellow);
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: `I couldn't open your browser. Here's the link: ${url}` });
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(importOrigin === "quick" ? "quick" : "file");
1134
- } else setImportPage({ waiting: "spotifyHistory", file: importFileReturn, setup: "quick", client: "setup", empty: "quick", quick: "spotify", spotifyHistory: "spotify", appleQuick: "apple", appleHistory: "apple", youtubeQuick: "youtube_music", youtubeHistory: "youtube_music" }[importPage] ?? "start");
1135
- } else if (["start", "file", "spotify", "waiting", "other", "quick", "setup", "client", "spotifyHistory", "apple", "appleQuick", "appleHistory", "youtube_music", "youtubeQuick", "youtubeHistory", "qq_music", "netease"].includes(type)) {
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(importPage === "file"
1142
- ? "Paste or drag in a file. Tab completes the path, Enter reads it."
1143
- : "Pick a step.");
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(`That didn't work: ${error.message}`, red);
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}...`, yellow);
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 (action === "import-history") {
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.`, green);
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: `${formatTime.format(new Date(session.updated_at))} · ${session.turn_count} ${session.turn_count === 1 ? "message" : "messages"}${session.session_id === currentId ? " · open now" : ""}`,
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
- activeRuntime.restoreSession();
1241
- const turns = application.currentSessionTurns({ limit: 40 });
1242
- transcript.clear();
1243
- homeVisible = turns.length === 0;
1244
- homeFocused = false;
1245
- editor.focused = true;
1246
- for (const turn of turns) {
1247
- if (turn.role === "user") addUserMessage(sanitizeTerminalText(turn.text));
1248
- if (turn.role === "assistant") addMoondogMessage(turn.text);
1691
+ if (!recallBySession.has(selected.value)) {
1692
+ recallBySession.set(selected.value, recallFromTurns());
1249
1693
  }
1250
- const title = selected.label ?? turns.find((turn) => turn.role === "user")?.text ?? "Saved conversation";
1251
- setFooter(`Back in: ${title.replace(/\s+/gu, " ")}`, green);
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
- `${status.ready} ${status.ready === 1 ? "song" : "songs"} with lyrics · ${status.instrumental} ${status.instrumental === 1 ? "instrumental" : "instrumentals"} · ${status.entries} looked up`,
1265
- `Stored at ${status.path}`,
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 ? [`Last problem: ${lyricError}`] : []),
1268
- ...(homeLyric ? ["", `On the sleeve now: ${homeLyric.title} · ${homeLyric.artist}`, `Lyrics from ${homeLyric.sourceUrl}`] : []),
1269
- "", "`/lyrics sync` looks up more songs from your profile. `/home` takes you back to the sleeve.",
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
- addMoondogMessage(formatLocalResult("taste", await application.runLocalCommand("taste", runtimeStatus)));
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
- setFooter("Looking it up with Codex... Ctrl+C cancels.", yellow);
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.", green);
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: "Warm ivory and charcoal ink" },
1309
- { value: "charcoal", label: "Charcoal", description: "A quiet room, warm light" },
1310
- { value: "terminal", label: "Terminal", description: "Keep your terminal's colors" },
1311
- { value: "auto", label: "Auto", description: "Follow MOONDOG_THEME, or guess from your terminal" },
1312
- ], theme.mode, "Pick a look");
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(`${theme.mode === "paper" ? "Paper" : theme.mode === "charcoal" ? "Charcoal" : "Terminal"} theme, for this session.`);
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." : `${sleeve.artMode === "ascii" || sleeve.artMode === "text" ? "ASCII" : "Braille"} 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(`Your choice is saved, but I couldn't update the profile: ${error.message}\n\nType \`/taste\` to try again.`);
1371
- setFooter("Choice saved. The profile needs another try.", yellow);
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.", green);
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
- ? `Reloaded \`${status.provider}/${status.model}\`. This conversation and what I remember carry over.`
1385
- : `Reloaded, but no model is connected (\`${status.reason}\`). Your profile still works.`,
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" ? green : yellow);
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
- `${memory.created ? "I'll remember that" : "I already knew that"}: ${memory.text}\n\nTo forget it later: \`/forget ${memory.memory_id}\``,
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.", green);
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
- ? `Forgotten: \`${result.memory_id}\`.`
1420
- : `I don't have a memory called \`${result.memory_id}\`.`,
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.", yellow);
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.", green);
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(slashCommands.map(({ name, description }) => ({
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(`Your message is still here. Send or clear it, then use /${selected.value}.`);
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
- editor.onSubmit = async (rawValue) => {
1467
- if (busy) return;
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 (!slashCommands.some((entry) => entry.name === command)) {
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(`I don't know \`/${command}\`. Type \`/help\` to see what I do know.`);
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}...`, yellow);
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
- addMoondogMessage(cancelled ? command === "web" ? "Stopped the lookup." : "Stopped the lyric sync." : `That didn't work: ${error.message}`);
1507
- setFooter(cancelled ? "Cancelled." : `/${command} didn't work.`, cancelled ? yellow : red);
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.", yellow);
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
- editor.disableSubmit = true;
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
- if (cancellationRequested) return;
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 ? `${pending.length} running · ${pending[0].label}` : `${pending[0].label}...`, yellow);
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" ? `${label} didn't work · carrying on...` : "Putting it together...", state === "failed" ? yellow : dim);
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(`Can't reach the model. Trying again (${attempt}/${maxRetries})...`, yellow);
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.", yellow);
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("Ready.", green);
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
- : `Something went wrong: ${sanitizeTerminalText(error.message)}`;
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.", red);
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 (toolCalls.size && !cleanedUp) {
1612
- const calls = [...toolCalls.values()];
1613
- const counts = ["completed", "failed", "running"].flatMap((state) => {
1614
- const count = calls.filter((call) => call.state === state).length;
1615
- return count ? [`${count} ${{ completed: "done", failed: "failed", running: "unfinished" }[state]}`] : [];
1616
- });
1617
- const summary = `Tools · ${counts.join(", ")} · ${elapsedTime(workStartedAt)}`;
1618
- const labels = [...new Set(calls.map((call) => call.label))].join(" · ");
1619
- transcript.addChild({
1620
- invalidate() {},
1621
- render(width) {
1622
- return new Text(theme.faint(truncateToWidth(`${summary} · ${labels}`, Math.max(1, width - 2))), 1, 0).render(width);
1623
- },
1624
- });
1625
- }
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) return { consume: true };
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...", yellow);
2389
+ setFooter(tr("Stopping..."));
1689
2390
  cancelWork();
1690
2391
  }
1691
2392
  } else {
1692
- setFooter("This can't be stopped halfway. Almost there...", yellow);
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(`Couldn't read the music library: ${sanitizeTerminalText(error.message)}`, yellow);
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
  }