@audiofool/moondog 0.2.0-beta.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +5 -4
  2. package/assets/brand/moondog-logo.svg +1 -0
  3. package/package.json +2 -2
  4. package/scripts/moondog.mjs +30 -6
  5. package/src/core/application-update.mjs +5 -3
  6. package/src/core/first-run.mjs +53 -0
  7. package/src/i18n/screen/es.mjs +62 -4
  8. package/src/i18n/screen/ja.mjs +62 -4
  9. package/src/i18n/screen/pt.mjs +62 -4
  10. package/src/i18n/screen/zh.mjs +62 -4
  11. package/src/integrations/netease/ncm-cli-player.mjs +29 -13
  12. package/src/memory/reflection-worker.mjs +1 -1
  13. package/src/runtime/pi/authentication.mjs +31 -10
  14. package/src/runtime/pi/configured-runtime.mjs +3 -3
  15. package/src/runtime/pi/custom-endpoints.mjs +229 -0
  16. package/src/runtime/pi/model-catalog.mjs +7 -3
  17. package/src/runtime/pi/model-list.mjs +122 -0
  18. package/src/runtime/pi/model-settings.mjs +131 -20
  19. package/src/runtime/pi/model-transport.mjs +2 -2
  20. package/src/runtime/pi/persistent-credential-store.mjs +31 -0
  21. package/src/runtime/pi/provider-registry.mjs +3 -9
  22. package/src/runtime/pi/public-model-catalog.mjs +12 -4
  23. package/src/runtime/pi/runtime-selection.mjs +9 -0
  24. package/src/runtime/pi/runtime-settings.mjs +37 -0
  25. package/src/surfaces/brand/logo.mjs +237 -0
  26. package/src/surfaces/cli/auth-command.mjs +25 -56
  27. package/src/surfaces/cli/brand-components.mjs +1 -1
  28. package/src/surfaces/cli/command-completions.mjs +14 -0
  29. package/src/surfaces/cli/endpoint-command.mjs +106 -0
  30. package/src/surfaces/cli/first-run-view.mjs +138 -0
  31. package/src/surfaces/cli/format-output.mjs +1 -1
  32. package/src/surfaces/cli/terminal-art.mjs +24 -66
  33. package/src/surfaces/cli/tui.mjs +320 -33
  34. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
@@ -21,6 +21,8 @@ import {
21
21
  } from "../../runtime/pi/model-catalog.mjs";
22
22
  import { getPublicPiModelCatalog } from "../../runtime/pi/public-model-catalog.mjs";
23
23
  import { serviceKeyProviders, supportedAuthProviderIds } from "../../runtime/pi/authentication.mjs";
24
+ import { EFFORT_LEVELS, EFFORT_SCALE } from "../../runtime/pi/runtime-settings.mjs";
25
+ import { runtimeBehindSelection } from "../../runtime/pi/runtime-selection.mjs";
24
26
  import {
25
27
  formatLocalResult,
26
28
  helpText,
@@ -35,6 +37,7 @@ import { buildTasteProfileModel } from "./taste-profile-model.mjs";
35
37
  import { TasteProfileView } from "./taste-profile-view.mjs";
36
38
  import { lyricTrackKey } from "../../core/lyric-profile.mjs";
37
39
  import { HistoryImportView } from "./history-import-view.mjs";
40
+ import { FirstRunView, neteaseSetupCommand, playbackServiceChoices, playbackServiceName } from "./first-run-view.mjs";
38
41
  import { IMPORT_GUIDE_URLS } from "./import-guides.mjs";
39
42
  import { withCommandCompletions } from "./command-completions.mjs";
40
43
  import {
@@ -50,6 +53,10 @@ import {
50
53
  setBrandText,
51
54
  } from "./brand-components.mjs";
52
55
 
56
+ // The lines Moondog shows, as a radio host, while the model thinks; the window has the same ones.
57
+ export const hostLines = (tr) => [tr("Reading the room"), tr("Checking the request line"), tr("Finding the groove"), tr("Working the decks"),
58
+ tr("Planning the segue"), tr("Mic check"), tr("Riding the faders"), tr("Finding the cue")];
59
+
53
60
  const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
54
61
 
55
62
  /** Full-screen listening room. The conversation scrolls; the header and draft stay put. */
@@ -110,7 +117,9 @@ const slashCommands = [
110
117
  { name: "model", description: N_("Choose the model Moondog talks through") },
111
118
  { name: "effort", description: N_("Choose how hard the model thinks") },
112
119
  { name: "auth", description: N_("Sign in to a model or web search service") },
120
+ { name: "endpoint", description: N_("Add a model service by its address") },
113
121
  { name: "web", description: N_("Look things up on public music sites") },
122
+ { name: "service", description: N_("Choose where Moondog plays music") },
114
123
  { name: "spotify", description: N_("Connect and control Spotify") },
115
124
  { name: "skip", description: N_("Skip to the next song preview") },
116
125
  { name: "stop", description: N_("Stop the song previews") },
@@ -164,7 +173,7 @@ export function parseTuiArguments(text) {
164
173
  }
165
174
 
166
175
  function offlineReply(runtimeStatus, tr) {
167
- const provider = runtimeStatus.provider ?? "openai-codex";
176
+ const provider = runtimeStatus.provider ?? "openai";
168
177
  const [why, next] = runtimeStatus.reason === "provider_authentication_required"
169
178
  ? [tr("you're not signed in to {provider}", { provider }), tr("Sign in with `/auth {provider}`.", { provider })]
170
179
  : runtimeStatus.reason === "model_not_configured" || !runtimeStatus.reason
@@ -187,6 +196,7 @@ export async function runMoondogTui({
187
196
  signalTarget = process,
188
197
  rebuildRuntime,
189
198
  runAuth,
199
+ runEndpoint,
190
200
  runSpotify,
191
201
  runProfile,
192
202
  runProfileAction,
@@ -202,7 +212,14 @@ export async function runMoondogTui({
202
212
  providers = listPiProviders,
203
213
  models = listPiModels,
204
214
  refreshModels,
215
+ refreshEndpoints,
205
216
  saveLanguage,
217
+ // Hears a model chosen or a sign-in made in the window; see watchRuntimeSettings.
218
+ watchModelSettings,
219
+ // The one-time music service and model setup (src/core/first-run.mjs), shared with the window.
220
+ firstRun,
221
+ // Saves the music service that plays songs; the picker and the agent share it.
222
+ savePlaybackService,
206
223
  // The terminal's preview player. The room shows what it plays (subscribe) and
207
224
  // gives the listener /skip and /stop.
208
225
  previewPlayer,
@@ -213,6 +230,7 @@ export async function runMoondogTui({
213
230
  providers = () => catalog.providers();
214
231
  models = id => catalog.models(id);
215
232
  refreshModels ??= options => catalog.refresh(options);
233
+ refreshEndpoints ??= () => catalog.readEndpoints();
216
234
  }
217
235
  // Screen text follows the listener's language and switches with /language.
218
236
  const tr = liveScreenTranslator(() => application.locale);
@@ -226,6 +244,8 @@ export async function runMoondogTui({
226
244
  application.startNewSession?.("tui_start");
227
245
  activeRuntime.reset();
228
246
  let runtimeStatus = activeRuntime.publicStatus();
247
+ // Read before the room opens, so a first launch opens on the first run rather than the sleeve.
248
+ const firstRunAtLaunch = firstRun ? await firstRun.state({ modelReady: runtimeStatus.state === "configured" }).catch(() => null) : null;
229
249
  const sourceStatusTask = Promise.resolve().then(() => application.sourceStatus?.()).then(
230
250
  () => null,
231
251
  (error) => error,
@@ -255,6 +275,14 @@ export async function runMoondogTui({
255
275
  let conversationTitle = "";
256
276
  let profileView = null;
257
277
  let importView = null;
278
+ // The first run asks once for a music service, then a model; see openFirstRun. Its note says what
279
+ // the last choice did, and its setup shows how to install NetEase's player when it was chosen early.
280
+ let firstRunView = null;
281
+ let firstRunSteps = 0;
282
+ let firstRunNote = null;
283
+ let firstRunSetup = null;
284
+ let firstRunTicket = 0;
285
+ let stopWatchingFirstRun = null;
258
286
  let importPage = "start";
259
287
  let importReturnHome = false;
260
288
  let preparedImport = null;
@@ -273,6 +301,8 @@ export async function runMoondogTui({
273
301
  let homeFocused = false;
274
302
  let homeSelected = 0;
275
303
  let busy = false;
304
+ // A model chosen or a sign-in made in the window while a turn ran here; it applies once the turn ends.
305
+ let modelChangedElsewhere = false;
276
306
  // A reply another Moondog window or terminal is writing in this conversation: { id, response, text }.
277
307
  let elsewhere = null;
278
308
  let watchTimer = null;
@@ -285,7 +315,7 @@ export async function runMoondogTui({
285
315
  : Math.min(messageQueue.length, 4) + (messageQueue.length > 4 ? 1 : 0);
286
316
  // The preview now playing sits on its own line above the draft.
287
317
  let nowPlaying = null;
288
- const nowPlayingRows = () => (nowPlaying && !profileView && !importView ? 1 : 0);
318
+ const nowPlayingRows = () => (nowPlaying && !profileView && !importView && !firstRunView ? 1 : 0);
289
319
  let cancelWork = null;
290
320
  let cancellationRequested = false;
291
321
  let workStartedAt = 0;
@@ -385,7 +415,7 @@ export async function runMoondogTui({
385
415
  const renderHeader = () => { header.invalidate(); tui.requestRender(); };
386
416
  const synchronizeHomeMotion = () => {
387
417
  const shouldRun = startAttempted && !cleanedUp && motionEnabled && !theme.plain &&
388
- !busy && !profileView && !importView && homeVisible && !homeFocused && !tui.hasOverlay() && !editor.getText() && sleeve.canAnimate;
418
+ !busy && !profileView && !importView && !firstRunView && homeVisible && !homeFocused && !tui.hasOverlay() && !editor.getText() && sleeve.canAnimate;
389
419
  if (!shouldRun) {
390
420
  clearInterval(homeMotion);
391
421
  homeMotion = null;
@@ -417,11 +447,12 @@ export async function runMoondogTui({
417
447
  transcript.invalidate();
418
448
  profileView?.invalidate();
419
449
  importView?.invalidate();
450
+ firstRunView?.invalidate();
420
451
  },
421
452
  render(width) {
422
453
  // Measure the draft before the sleeve asks, so one frame uses the real height.
423
- if (!profileView && !importView) editor.rowCount = editor.render(width).length;
424
- const lines = importView ? importView.render(width)
454
+ if (!profileView && !importView && !firstRunView) editor.rowCount = editor.render(width).length;
455
+ const lines = firstRunView ? firstRunView.render(width) : importView ? importView.render(width)
425
456
  : profileView ? profileView.render(width)
426
457
  : homeVisible ? sleeve.render(width)
427
458
  : transcriptPane.render(width);
@@ -429,7 +460,7 @@ export async function runMoondogTui({
429
460
  return lines;
430
461
  },
431
462
  [LAYOUT_NODE]() {
432
- if (!homeVisible && !profileView && !importView) return transcriptScroll[LAYOUT_NODE]();
463
+ if (!homeVisible && !profileView && !importView && !firstRunView) return transcriptScroll[LAYOUT_NODE]();
433
464
  return undefined;
434
465
  },
435
466
  };
@@ -437,7 +468,7 @@ export async function runMoondogTui({
437
468
  const composerSlot = {
438
469
  invalidate() { composer.invalidate(); },
439
470
  render(width) {
440
- if (profileView || importView) return [];
471
+ if (profileView || importView || firstRunView) return [];
441
472
  const lines = [];
442
473
  if (nowPlaying) {
443
474
  const { track, position, count, type } = nowPlaying;
@@ -476,7 +507,7 @@ export async function runMoondogTui({
476
507
  shell.addChild(header, { shrink: 0 });
477
508
  shell.addChild(stage, { grow: 1, shrink: 1, minSize: 1 });
478
509
  shell.addChild(composerSlot, { shrink: 0 });
479
- tui.passViewportKeys = () => Boolean(profileView || importView);
510
+ tui.passViewportKeys = () => Boolean(profileView || importView || firstRunView);
480
511
  tui.setLayoutRoot(shell);
481
512
  shell.addChild({
482
513
  invalidate() {},
@@ -486,7 +517,7 @@ export async function runMoondogTui({
486
517
  // The clock glyphs sat on different baselines, so the mark jumped up and down.
487
518
  const marker = theme.plain ? "." : "◎";
488
519
  const markerInk = spinning ? theme.prism[phase % theme.prism.length] : theme.faint;
489
- const compactHint = terminal.rows < 20 && homeVisible && !busy && !profileView && !importView;
520
+ const compactHint = terminal.rows < 20 && homeVisible && !busy && !profileView && !importView && !firstRunView;
490
521
  let statusText = compactHint && homeFocused ? tr("↑ ↓ choose · enter open · esc type")
491
522
  : compactHint && editor.getText() ? tr("enter send · ctrl+p commands")
492
523
  : compactHint && footerText === tr("Ready.") ? tr("tab explore · type to talk") : footerText;
@@ -505,17 +536,21 @@ export async function runMoondogTui({
505
536
  else if (footerText === tr("Here's the evidence. PgUp/PgDn to scroll, Enter for actions.")) statusText = tr("Evidence · PgUp/PgDn scroll · Enter");
506
537
  else if (footerText === tr("Imported. Here's your profile with everything so far.")) statusText = tr("Imported · Enter to review");
507
538
  }
508
- if (busy && !profileView && !importView && width >= 20) {
539
+ // Without room for the keys line, the first run's status says how to choose and skip.
540
+ if (firstRunView && !busy && terminal.rows < 20) statusText = tr("↑↓ ↵ choose · esc skip setup");
541
+ if (busy && !profileView && !importView && !(firstRunView && tui.hasOverlay()) && width >= 20) {
509
542
  const elapsed = ` · ${elapsedTime(workStartedAt)}`;
510
543
  statusText = truncateToWidth(statusText, width - 3 - visibleWidth(elapsed)) + elapsed;
511
544
  }
512
- if (terminal.rows < 20 && !busy && !profileView && !importView && editor.isShowingAutocomplete()) {
545
+ if (terminal.rows < 20 && !busy && !profileView && !importView && !firstRunView && editor.isShowingAutocomplete()) {
513
546
  statusText = tr("↑↓ choose · Tab/Enter complete");
514
547
  }
515
- if (updateNotice && homeVisible && [tr("Ready."), tr("New conversation · /resume history")].includes(footerText) && !editor.getText() && !busy && !profileView && !importView && !tui.hasOverlay() && !editor.isShowingAutocomplete()) statusText = updateNotice;
548
+ if (updateNotice && homeVisible && [tr("Ready."), tr("New conversation · /resume history")].includes(footerText) && !editor.getText() && !busy && !profileView && !importView && !firstRunView && !tui.hasOverlay() && !editor.isShowingAutocomplete()) statusText = updateNotice;
516
549
  const lines = [paintBrandLine(` ${markerInk(marker)} ${footerColor(statusText)}`, width, theme)];
517
550
  if (terminal.rows >= 20) {
518
- const keys = importView
551
+ const keys = firstRunView
552
+ ? tui.hasOverlay() ? "" : busy ? tr("One moment...") : tr("↑↓ choose · enter select · esc skip setup")
553
+ : importView
519
554
  ? width < 64 ? busy ? tr("One moment...") : importPage === "file"
520
555
  ? tr("Tab path · ↵ inspect · Esc back") : tr("↑↓ ↵ · PgUp/Dn read · Esc back")
521
556
  : busy ? tr("One moment...") : importPage === "file"
@@ -566,6 +601,7 @@ export async function runMoondogTui({
566
601
  indicator.unref?.();
567
602
  }
568
603
  if (!cleanedUp) tui.requestRender();
604
+ if (!value && modelChangedElsewhere) void followModel();
569
605
  };
570
606
  const setFooter = (text, color = dim) => {
571
607
  if (cleanedUp) return;
@@ -594,7 +630,7 @@ export async function runMoondogTui({
594
630
  };
595
631
  // A finished local message is read from the top. Several messages added before
596
632
  // the next frame share the first anchor, once that frame has measured them.
597
- tui.conversationVisible = () => !homeVisible && !profileView && !importView;
633
+ tui.conversationVisible = () => !homeVisible && !profileView && !importView && !firstRunView;
598
634
  tui.revealScroll = (added) => {
599
635
  transcriptScroll.scrollToEnd();
600
636
  const hiddenAbove = added - transcriptScroll.viewportHeight;
@@ -693,6 +729,8 @@ export async function runMoondogTui({
693
729
  try { application.conversationPending?.({ draft: editor.getText(), queued: [...messageQueue] }); } catch { /* Terminal cleanup must still run when storage is unavailable. */ }
694
730
  cleanedUp = true;
695
731
  clearTimeout(watchTimer);
732
+ stopWatchingModel?.();
733
+ stopWatchingFirstRun?.();
696
734
  stopWatchingPreviews?.();
697
735
  clearInterval(indicator);
698
736
  indicator = null;
@@ -766,8 +804,10 @@ export async function runMoondogTui({
766
804
  let overlay;
767
805
  const finish = (value) => {
768
806
  overlay.hide();
769
- if (profileView) tui.setFocus(profileView);
770
- else editor.focused = !homeFocused;
807
+ // The first run may have closed while the menu was open, so focus goes where the room is now.
808
+ if (firstRunView) tui.setFocus(firstRunView);
809
+ else if (profileView) tui.setFocus(profileView);
810
+ else { tui.setFocus(editor); editor.focused = !homeFocused; }
771
811
  tui.requestRender();
772
812
  resolve(value);
773
813
  };
@@ -964,8 +1004,27 @@ export async function runMoondogTui({
964
1004
  } catch {}
965
1005
  renderHeader();
966
1006
  tui.requestRender(true);
1007
+ // A model that can answer now, such as one signed in from the window, may end the first run.
1008
+ void refreshFirstRun();
967
1009
  return nextStatus;
968
1010
  };
1011
+ // The window saved another model, or a sign-in that lets this side answer. The terminal's own
1012
+ // choices rebuild at once, so their saves find this agent already on them.
1013
+ const followModel = async () => {
1014
+ if (cleanedUp || busy || typeof rebuildRuntime !== "function") return;
1015
+ modelChangedElsewhere = false;
1016
+ await replaceRuntime().catch(() => {});
1017
+ };
1018
+ // The window's endpoints reach /model here; an agent on an endpoint that changed is rebuilt, since its
1019
+ // address or key may have. The window's menu list and its per-conversation choices don't move this one.
1020
+ const stopWatchingModel = watchModelSettings?.((selection, changed) => {
1021
+ if (changed === "models") return;
1022
+ if (changed === "endpoints") void refreshEndpoints?.();
1023
+ const onEndpoint = changed === "endpoints" && String(runtimeStatus.provider ?? "").startsWith("endpoint-");
1024
+ if (!onEndpoint && !runtimeBehindSelection(runtimeStatus, selection)) return;
1025
+ modelChangedElsewhere = true;
1026
+ void followModel();
1027
+ });
969
1028
 
970
1029
  const refreshCatalog = async (options) => {
971
1030
  try { return await refreshModels(options); }
@@ -975,7 +1034,9 @@ export async function runMoondogTui({
975
1034
  }
976
1035
  };
977
1036
 
978
- const selectModel = async (args) => {
1037
+ // The first run passes its own `say`, so its page tells what happened instead of the conversation.
1038
+ // A switch returns the new agent's status.
1039
+ const selectModel = async (args, { say = addMoondogMessage } = {}) => {
979
1040
  if (args.length > 2) throw new Error(tr("Usage: /model [provider] [model], or /model refresh [provider]."));
980
1041
  const describeRefresh = results => results.map(result => [`${result.provider}: ${result.state}`,
981
1042
  result.count !== undefined ? ` (${tr.n(result.count, "{count} current model", "{count} current models")})` : "",
@@ -995,10 +1056,12 @@ export async function runMoondogTui({
995
1056
  const availableProviders = providers()
996
1057
  .filter((provider) => provider.modelCount > 0)
997
1058
  .sort((left, right) => {
1059
+ // The one in use, then OpenAI, Anthropic, Google and xAI, as the window lists them.
1060
+ const featured = ["openai", "anthropic", "google", "xai"];
998
1061
  const priority = (provider) => {
999
1062
  if (provider.id === runtimeStatus.provider) return 0;
1000
- if (provider.id === "openai-codex") return 1;
1001
- return 2;
1063
+ const at = featured.indexOf(provider.id);
1064
+ return at === -1 ? featured.length + 1 : at + 1;
1002
1065
  };
1003
1066
  return (
1004
1067
  priority(left) - priority(right) ||
@@ -1069,7 +1132,7 @@ export async function runMoondogTui({
1069
1132
  setBusy(true);
1070
1133
  setFooter(tr("Loading {model}...", { model: `${providerId}/${modelId}` }));
1071
1134
  const status = await replaceRuntime(runtimeSelection(providerId, modelId, runtimeStatus.effort));
1072
- addMoondogMessage(
1135
+ say(
1073
1136
  status.state === "configured"
1074
1137
  ? tr("Now talking through `{model}`. This conversation and what I remember carry over.", { model: `${providerId}/${modelId}` })
1075
1138
  : status.reason === "provider_authentication_required"
@@ -1080,16 +1143,18 @@ export async function runMoondogTui({
1080
1143
  status.state === "configured" ? tr("Model ready.") : tr("Model saved, not connected yet."),
1081
1144
  status.state === "configured" ? success : warning,
1082
1145
  );
1146
+ return status;
1083
1147
  };
1084
1148
 
1085
1149
  const selectEffort = async (args) => {
1086
1150
  if (args.length > 1) throw new Error(tr("Use /effort, or /effort <level>."));
1087
1151
  if (runtimeStatus.state !== "configured") throw new Error(tr("Choose a model with /model first."));
1088
1152
  const model = `${runtimeStatus.provider}/${runtimeStatus.model}`;
1089
- const levels = runtimeStatus.effort_levels ?? [];
1090
- if (levels.length < 2) throw new Error(tr("`{model}` has no effort levels to choose from.", { model }));
1153
+ if ((runtimeStatus.effort_levels ?? []).length < 2) throw new Error(tr("`{model}` has no effort levels to choose from.", { model }));
1154
+ // Every model that thinks is offered the same scale; a level it lacks reaches it as the nearest lighter one.
1155
+ const levels = EFFORT_SCALE;
1091
1156
  let effort = args[0]?.toLowerCase();
1092
- if (effort && effort !== "default" && !levels.includes(effort)) {
1157
+ if (effort && effort !== "default" && !EFFORT_LEVELS.includes(effort)) {
1093
1158
  throw new Error(tr("`{model}` supports: {levels}, or default.", { model, levels: levels.join(", ") }));
1094
1159
  }
1095
1160
  if (!effort) {
@@ -1121,7 +1186,7 @@ export async function runMoondogTui({
1121
1186
  setFooter(tr("Effort saved."), success);
1122
1187
  };
1123
1188
 
1124
- const authenticate = async (args) => {
1189
+ const authenticate = async (args, { say = addMoondogMessage } = {}) => {
1125
1190
  if (args.length > 1) {
1126
1191
  throw new Error(tr("Use /auth or /auth <provider>. Type API keys only into the hidden prompt that follows."));
1127
1192
  }
@@ -1140,9 +1205,9 @@ export async function runMoondogTui({
1140
1205
  availableProviders.map((provider) => ({
1141
1206
  value: provider.id,
1142
1207
  label: provider.name,
1143
- description: `${provider.id} - ${provider.id === "openai-codex" ? tr("ChatGPT sign-in") : serviceKey(provider.id) ? tr("Web search key") : tr("API key")}`,
1208
+ description: `${provider.id} - ${provider.id === "openai" ? tr("ChatGPT sign-in or API key") : serviceKey(provider.id) ? tr("Web search key") : tr("API key")}`,
1144
1209
  })),
1145
- runtimeStatus.provider ?? "openai-codex",
1210
+ runtimeStatus.provider ?? "openai",
1146
1211
  tr("Sign in to a model or web search service"),
1147
1212
  );
1148
1213
  if (!selectedProvider) {
@@ -1152,10 +1217,24 @@ export async function runMoondogTui({
1152
1217
  providerId = selectedProvider.value;
1153
1218
  }
1154
1219
 
1220
+ // OpenAI takes a ChatGPT subscription or an API key.
1221
+ let chatgpt = false;
1222
+ if (providerId === "openai") {
1223
+ const way = await choose([
1224
+ { value: "chatgpt", label: tr("ChatGPT subscription"), description: tr("Sign in with ChatGPT in your browser") },
1225
+ { value: "key", label: tr("API key"), description: tr("Paste an OpenAI API key") },
1226
+ ], "chatgpt", tr("How do you want to use OpenAI?"));
1227
+ if (!way) {
1228
+ setFooter(tr("Sign-in cancelled."));
1229
+ return;
1230
+ }
1231
+ chatgpt = way.value === "chatgpt";
1232
+ }
1233
+
1155
1234
  setFooter(tr("Opening {provider} sign-in...", { provider: providerId }));
1156
1235
  tui.stop({ preserveScreen: true });
1157
1236
  try {
1158
- await runAuth(providerId);
1237
+ await runAuth(providerId, { chatgpt });
1159
1238
  } finally {
1160
1239
  if (!cleanedUp) {
1161
1240
  tui.start();
@@ -1171,7 +1250,7 @@ export async function runMoondogTui({
1171
1250
  : undefined;
1172
1251
  const status = await replaceRuntime(currentSelection);
1173
1252
  const search = application.webLookup?.("search");
1174
- addMoondogMessage(
1253
+ say(
1175
1254
  serviceKey(providerId)
1176
1255
  ? search
1177
1256
  ? tr("Saved the {service} key. Web search goes through {provider}.", { service: serviceKey(providerId).name, provider: search.name })
@@ -1181,11 +1260,52 @@ export async function runMoondogTui({
1181
1260
  : tr("Signed in. Run `/model {provider}` to pick a model.", { provider: providerId }),
1182
1261
  );
1183
1262
  } else {
1184
- addMoondogMessage(tr("Signed in. Restart Moondog to use it."));
1263
+ say(tr("Signed in. Restart Moondog to use it."));
1185
1264
  }
1186
1265
  setFooter(tr("Signed in."), success);
1187
1266
  };
1188
1267
 
1268
+ // Endpoints: the list, one added on the paused terminal with its key out of sight, or one removed.
1269
+ // /model then offers their models.
1270
+ const manageEndpoints = async (args) => {
1271
+ if (typeof runEndpoint !== "function") throw new Error(tr("Endpoints aren't available when Moondog is started this way."));
1272
+ const action = args[0]?.toLowerCase() ?? "list";
1273
+ if (!["list", "add", "remove"].includes(action) || (action === "add" && args.length > 1) || (action === "list" && args.length > 1)) {
1274
+ throw new Error(tr("Use /endpoint, /endpoint add, or /endpoint remove <id>."));
1275
+ }
1276
+ if (action === "list") {
1277
+ const endpoints = await runEndpoint(["list"], { quiet: true });
1278
+ addMoondogMessage(endpoints.length
1279
+ ? endpoints.map((endpoint) => `- \`${endpoint.id}\` ${endpoint.name}`).join("\n") + `\n\n${tr("Type `/model <id>` to talk through one, or `/endpoint add` to add another.")}`
1280
+ : tr("No endpoints yet. `/endpoint add` adds any service that speaks OpenAI's or Anthropic's API, by its address."));
1281
+ setFooter(tr("Endpoints listed."));
1282
+ return;
1283
+ }
1284
+ let result;
1285
+ if (action === "add") {
1286
+ tui.stop({ preserveScreen: true });
1287
+ try {
1288
+ result = await runEndpoint(["add"]);
1289
+ } finally {
1290
+ if (!cleanedUp) {
1291
+ tui.start();
1292
+ tui.requestRender(true);
1293
+ }
1294
+ }
1295
+ if (cleanedUp) return;
1296
+ } else {
1297
+ result = await runEndpoint(["remove", ...args.slice(1)], { quiet: true });
1298
+ }
1299
+ await refreshEndpoints?.();
1300
+ if (action === "add") {
1301
+ addMoondogMessage(tr("Saved {name}. Type `/model {id}` to pick one of its models.", { name: result.name, id: result.id }));
1302
+ setFooter(tr("Endpoint saved."), success);
1303
+ } else {
1304
+ addMoondogMessage(tr("Removed {name} and its key.", { name: result.name }));
1305
+ setFooter(tr("Endpoint removed."), success);
1306
+ }
1307
+ };
1308
+
1189
1309
  const showImportedListeningProfile = async () => {
1190
1310
  void updateHomeLyrics();
1191
1311
  try {
@@ -1602,6 +1722,145 @@ export async function runMoondogTui({
1602
1722
  setFooter(`Spotify ${action}: done.`, success);
1603
1723
  };
1604
1724
 
1725
+ // The music service that plays songs, chosen in the first run or with /service, as in the window.
1726
+ const neteaseState = () => application.neteasePlayer?.status?.().state ?? "unavailable";
1727
+ const serviceChoices = () => playbackServiceChoices(tr, { netease: neteaseState(), current: application.playbackService });
1728
+ const serviceSaved = (service) => (service === "spotify" && application.spotifyReady?.() !== true
1729
+ ? tr("Spotify it is. /spotify help shows how to connect your account.")
1730
+ : tr("{service} it is.", { service: playbackServiceName(service, tr) }));
1731
+ // NetEase can be saved only once ncm-cli is signed in; until then this says how to set it up.
1732
+ const saveService = async (service) => {
1733
+ if (typeof savePlaybackService !== "function") throw new Error(tr("Choosing a music service isn't available when Moondog is started this way."));
1734
+ try {
1735
+ return { saved: await savePlaybackService(service) };
1736
+ } catch (error) {
1737
+ if (service !== "netease" || !["missing", "signed_out"].includes(neteaseState())) throw error;
1738
+ return { setup: neteaseSetupCommand(neteaseState()) };
1739
+ }
1740
+ };
1741
+ const pickService = async (args) => {
1742
+ const services = playbackServiceChoices(tr, { netease: "ready" }).map((choice) => choice.value);
1743
+ let service = args[0]?.toLowerCase();
1744
+ if (args.length > 1 || (service && !services.includes(service))) throw new Error(tr("Use /service, or /service spotify, apple_music, or netease."));
1745
+ if (!service) {
1746
+ // Whether NetEase can be offered: one check, unless the core already made it.
1747
+ if (neteaseState() === "unknown") await application.neteasePlayer.check().catch(() => {});
1748
+ if (cleanedUp) return;
1749
+ service = (await choose(serviceChoices(), application.playbackService, tr("Where should I play music?")))?.value;
1750
+ if (cleanedUp) return;
1751
+ }
1752
+ if (!service) { setFooter(tr("Music service unchanged.")); return; }
1753
+ const result = await saveService(service);
1754
+ if (cleanedUp) return;
1755
+ if (result.setup) {
1756
+ addMoondogMessage(tr("NetEase Music plays through its own command line tool. Run this in another terminal, then choose it again with `/service`:\n\n```\n{command}\n```", { command: result.setup }));
1757
+ setFooter(tr("NetEase needs ncm-cli first."), warning);
1758
+ return;
1759
+ }
1760
+ setFooter(serviceSaved(result.saved), success);
1761
+ };
1762
+
1763
+ // The first run, at a first launch: the music service, then the model, each skipped when already
1764
+ // done. Esc skips the rest for good; once both are done the core ends it. The window shares
1765
+ // first-run.json and playback.json, so a choice made there shows here, and the first run closes
1766
+ // when the window ends it.
1767
+ const drawFirstRun = (state) => {
1768
+ const step = state.service ? "model" : "service";
1769
+ if (step !== "service") firstRunSetup = null;
1770
+ firstRunView.setState({
1771
+ step, steps: firstRunSteps, note: firstRunNote, setup: firstRunSetup, services: serviceChoices(),
1772
+ model: { provider: runtimeStatus.provider, model: runtimeStatus.model,
1773
+ signIn: runtimeStatus.reason === "provider_authentication_required" && Boolean(runtimeStatus.provider && runtimeStatus.model) },
1774
+ });
1775
+ setFooter(step === "service" ? tr("Setup · music service") : tr("Setup · model"));
1776
+ tui.requestRender(true);
1777
+ };
1778
+ const closeFirstRun = (footer = tr("New conversation · /resume history"), color = dim) => {
1779
+ if (!firstRunView) return;
1780
+ stopWatchingFirstRun?.();
1781
+ stopWatchingFirstRun = null;
1782
+ firstRunTicket += 1;
1783
+ firstRunView = null;
1784
+ firstRunNote = null;
1785
+ firstRunSetup = null;
1786
+ homeFocused = false;
1787
+ tui.setFocus(editor);
1788
+ editor.focused = true;
1789
+ sleeve.invalidate();
1790
+ setFooter(footer, color);
1791
+ tui.requestRender(true);
1792
+ };
1793
+ // Reads where the first run stands and draws it, or closes it once it is over. A step that is
1794
+ // running reads it again when it ends.
1795
+ const refreshFirstRun = async () => {
1796
+ if (!firstRunView || cleanedUp || busy) return;
1797
+ const ticket = ++firstRunTicket;
1798
+ const state = await firstRun.state({ modelReady: runtimeStatus.state === "configured" }).catch(() => null);
1799
+ if (ticket !== firstRunTicket || !firstRunView || cleanedUp || busy || !state) return;
1800
+ if (state.needed) drawFirstRun(state);
1801
+ // Ended in the window, done or skipped there.
1802
+ else if (!state.service || !state.model) closeFirstRun();
1803
+ else closeFirstRun(state.service === "spotify" && application.spotifyReady?.() !== true
1804
+ ? tr("All set. /spotify help shows how to connect Spotify.")
1805
+ : tr("All set. Say anything, or press Tab to look around."), success);
1806
+ };
1807
+ // The view closes at once; the saved skip then reaches the window too.
1808
+ const skipFirstRun = () => {
1809
+ closeFirstRun(tr("Skipped. /service and /model are here when you want them."));
1810
+ void firstRun.finish({ skipped: true }).catch(() => {});
1811
+ };
1812
+ const handleFirstRunAction = async ({ type, value } = {}) => {
1813
+ if (busy || cleanedUp || !firstRunView) return;
1814
+ if (type === "skip") { skipFirstRun(); return; }
1815
+ // The model step reuses /model and /auth; what they say goes on this page, not in the conversation.
1816
+ const say = (text) => { firstRunNote = { text: String(text).replaceAll("`", "") }; };
1817
+ localCommandController = type === "model" ? new AbortController() : null;
1818
+ const controller = localCommandController;
1819
+ setBusy(true, controller ? () => controller.abort() : null);
1820
+ try {
1821
+ firstRunNote = null;
1822
+ if (type === "service") {
1823
+ setFooter(tr("Saving your music service..."));
1824
+ const result = await saveService(value);
1825
+ firstRunSetup = result.setup ?? null;
1826
+ if (result.saved) firstRunNote = { text: serviceSaved(result.saved) };
1827
+ } else if (type === "auth") {
1828
+ await authenticate([], { say });
1829
+ } else if (type === "model") {
1830
+ const status = await selectModel([], { say });
1831
+ // A model that needs a sign-in goes straight to it.
1832
+ if (status?.reason === "provider_authentication_required" && status.provider && !cleanedUp) await authenticate([status.provider], { say });
1833
+ }
1834
+ } catch (error) {
1835
+ if (cleanedUp) return;
1836
+ firstRunNote = error?.code === "auth_login_cancelled" ? { text: tr("Sign-in cancelled.") }
1837
+ : controller?.signal.aborted ? { text: tr("Stopped the catalog check. Model unchanged.") }
1838
+ : { text: tr("That didn't work: {error}", { error: sanitizeTerminalText(error.message) }), tone: "error" };
1839
+ } finally {
1840
+ if (localCommandController === controller) localCommandController = null;
1841
+ setBusy(false);
1842
+ }
1843
+ await refreshFirstRun();
1844
+ };
1845
+ const openFirstRun = (state) => {
1846
+ firstRunSteps = (state.service ? 0 : 1) + (state.model ? 0 : 1);
1847
+ firstRunView = new FirstRunView({
1848
+ getTheme, getText: () => tr,
1849
+ getRows: () => Math.max(1, terminal.rows - 2 - (terminal.rows >= 20 ? 2 : 1)),
1850
+ onAction: (action) => {
1851
+ void handleFirstRunAction(action).catch((error) => {
1852
+ if (!cleanedUp) setFooter(tr("That didn't work: {error}", { error: error.message }), failure);
1853
+ });
1854
+ },
1855
+ });
1856
+ homeFocused = false;
1857
+ tui.setFocus(firstRunView);
1858
+ stopWatchingFirstRun = firstRun.watch(() => { void refreshFirstRun(); });
1859
+ drawFirstRun(state);
1860
+ // Whether NetEase can be offered: one check, unless the core already made it.
1861
+ if (!state.service && neteaseState() === "unknown") void application.neteasePlayer.check().then(() => refreshFirstRun(), () => {});
1862
+ };
1863
+
1605
1864
  const savePendingConversation = () => application.conversationPending?.({ draft: editor.getText(), queued: [...messageQueue] });
1606
1865
  const restorePendingConversation = () => {
1607
1866
  const pending = application.conversationPending?.();
@@ -1877,6 +2136,14 @@ export async function runMoondogTui({
1877
2136
  await authenticate(args);
1878
2137
  return;
1879
2138
  }
2139
+ if (command === "endpoint") {
2140
+ await manageEndpoints(args);
2141
+ return;
2142
+ }
2143
+ if (command === "service") {
2144
+ await pickService(args);
2145
+ return;
2146
+ }
1880
2147
  if (command === "spotify") {
1881
2148
  await runSpotifyCommand(args);
1882
2149
  return;
@@ -2037,7 +2304,7 @@ export async function runMoondogTui({
2037
2304
  return;
2038
2305
  }
2039
2306
  try {
2040
- if (!["home", "theme", "art", "motion", "model", "resume", "rewind", "branch", "new", "taste", "profile", "import"].includes(command)) enterConversation();
2307
+ if (!["home", "theme", "art", "motion", "model", "service", "resume", "rewind", "branch", "new", "taste", "profile", "import"].includes(command)) enterConversation();
2041
2308
  if (command !== "auth") editor.addToHistory(value);
2042
2309
  localCommandController = ["web", "lyrics", "model", "update", "profile"].includes(command) ? new AbortController() : null;
2043
2310
  const controller = localCommandController;
@@ -2084,7 +2351,11 @@ export async function runMoondogTui({
2084
2351
  }
2085
2352
 
2086
2353
  setBusy(true, () => activeRuntime.abort());
2087
- setFooter(tr("Thinking..."));
2354
+ // While the model thinks, the host's own line from behind the decks, a new one each time it goes back to thinking.
2355
+ const lines = hostLines(tr);
2356
+ let thoughts = Math.floor(Math.random() * lines.length);
2357
+ const thinking = () => `${lines[thoughts++ % lines.length]}...`;
2358
+ setFooter(thinking());
2088
2359
  transcriptScroll.scrollToEnd();
2089
2360
  addLabel("Moondog");
2090
2361
  const work = new AgentWork(getTheme, () => phase);
@@ -2112,7 +2383,7 @@ export async function runMoondogTui({
2112
2383
  if (pending.length) {
2113
2384
  setFooter(pending.length > 1 ? tr("{count} running · {label}", { count: pending.length, label: pending[0].label }) : `${pending[0].label}...`);
2114
2385
  } else {
2115
- setFooter(state === "failed" ? tr("{label} didn't work · carrying on...", { label }) : tr("Thinking..."), state === "failed" ? warning : dim);
2386
+ setFooter(state === "failed" ? tr("{label} didn't work · carrying on...", { label }) : thinking(), state === "failed" ? warning : dim);
2116
2387
  }
2117
2388
  };
2118
2389
 
@@ -2332,7 +2603,7 @@ export async function runMoondogTui({
2332
2603
  };
2333
2604
  const lookElsewhere = () => {
2334
2605
  if (typeof application.memoryStore?.dataVersion !== "function") return null;
2335
- if ((busy && !elsewhere) || rewindPending || tui.hasOverlay() || profileView || importView) return LOOK_MS.idle;
2606
+ if ((busy && !elsewhere) || rewindPending || tui.hasOverlay() || profileView || importView || firstRunView) return LOOK_MS.idle;
2336
2607
  followElsewhere();
2337
2608
  if (drawn.session !== sessionId()) markDrawn();
2338
2609
  const version = application.memoryStore.dataVersion();
@@ -2353,6 +2624,20 @@ export async function runMoondogTui({
2353
2624
 
2354
2625
  tui.addInputListener((data) => {
2355
2626
  if (tui.hasOverlay()) return undefined;
2627
+ // The first run takes every key. Ctrl+C stops a step that can stop, or leaves Moondog as
2628
+ // anywhere else; an unfinished first run asks again next time.
2629
+ if (firstRunView) {
2630
+ if (busy) {
2631
+ if (matchesKey(data, "ctrl+c") && cancelWork && !cancellationRequested) {
2632
+ cancellationRequested = true;
2633
+ setFooter(tr("Stopping..."));
2634
+ cancelWork();
2635
+ }
2636
+ return { consume: true };
2637
+ }
2638
+ if (matchesKey(data, "ctrl+c")) { shutdown(); return { consume: true }; }
2639
+ return undefined;
2640
+ }
2356
2641
  if (importView) {
2357
2642
  if (busy) {
2358
2643
  if (importPage === "signin" && (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))) importAuthController?.abort();
@@ -2440,6 +2725,8 @@ export async function runMoondogTui({
2440
2725
  try {
2441
2726
  startAttempted = true;
2442
2727
  tui.start();
2728
+ // The first frame draws after this, so a first launch never shows the sleeve before the first run.
2729
+ if (firstRunAtLaunch?.needed) openFirstRun(firstRunAtLaunch);
2443
2730
  watchTimer = setTimeout(watchElsewhere, LOOK_MS.idle);
2444
2731
  watchTimer.unref?.();
2445
2732
  if (checkUpdates) void Promise.resolve().then(() => checkUpdates({ signal: updateCheckController.signal })).then(result => {