jeo-code 0.6.22 → 0.6.24

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 (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.ja.md +6 -2
  3. package/README.ko.md +6 -2
  4. package/README.md +6 -2
  5. package/README.zh.md +6 -2
  6. package/package.json +1 -1
  7. package/src/agent/config-schema.ts +12 -0
  8. package/src/agent/session.ts +10 -3
  9. package/src/agent/state.ts +19 -14
  10. package/src/ai/index.ts +1 -0
  11. package/src/ai/model-catalog.ts +121 -1
  12. package/src/ai/model-discovery.ts +55 -3
  13. package/src/ai/model-manager.ts +43 -11
  14. package/src/ai/model-registry.ts +2 -0
  15. package/src/ai/provider-status.ts +45 -7
  16. package/src/ai/providers/anthropic-compatible.ts +27 -0
  17. package/src/ai/providers/anthropic.ts +3 -1
  18. package/src/ai/providers/antigravity.ts +31 -6
  19. package/src/ai/providers/gemini.ts +45 -4
  20. package/src/ai/providers/kimi.ts +18 -0
  21. package/src/ai/providers/lmstudio.ts +8 -0
  22. package/src/ai/providers/ollama.ts +17 -5
  23. package/src/ai/providers/openai-compatible-catalog.ts +83 -0
  24. package/src/ai/providers/openai-compatible.ts +34 -0
  25. package/src/ai/providers/openai-responses.ts +11 -0
  26. package/src/ai/providers/openai.ts +115 -7
  27. package/src/ai/providers/xai.ts +18 -0
  28. package/src/ai/register-providers.ts +18 -0
  29. package/src/ai/think-tags.ts +84 -0
  30. package/src/ai/types.ts +11 -1
  31. package/src/auth/flows/index.ts +3 -3
  32. package/src/auth/index.ts +4 -1
  33. package/src/auth/oauth.ts +3 -3
  34. package/src/auth/refresh.ts +5 -0
  35. package/src/auth/storage.ts +12 -1
  36. package/src/commands/auth.ts +21 -2
  37. package/src/commands/launch/flags.ts +5 -1
  38. package/src/commands/launch/input.ts +13 -0
  39. package/src/commands/launch.ts +307 -26
  40. package/src/commands/setup.ts +3 -2
  41. package/src/tui/app.ts +61 -41
  42. package/src/tui/components/ascii-art.ts +91 -124
  43. package/src/tui/components/autocomplete.ts +16 -0
  44. package/src/tui/components/forge.ts +1 -1
  45. package/src/tui/components/provider-picker.ts +162 -0
  46. package/src/tui/components/slash.ts +2 -2
  47. package/src/tui/components/transcript.ts +7 -0
  48. package/src/tui/components/welcome.ts +8 -8
  49. package/src/tui/components/width.ts +21 -0
@@ -17,10 +17,10 @@ import { runUltragoalEngine, type UltragoalEngineOptions } from "./ultragoal";
17
17
  import { skillsPromptSection, loadSkills, buildSkillTask, workflowSkillsForPrompt, parseSkillInvocation, parseSkillChain, looksLikeSkillEcho, skillInvocationCard, type SkillDoc, type SkillInvocation } from "../skills/catalog";
18
18
  import { formatForgeBox } from "../tui/components/forge";
19
19
  import { interactiveOAuthLogin } from "./auth";
20
- import { logoutOAuth } from "../auth";
20
+ import { logoutOAuth, OAUTH_PROVIDERS, API_KEY_ONLY_PROVIDERS, setApiKey } from "../auth";
21
21
  import type { AuthProvider } from "../auth";
22
22
  import { matchSlash, isSlashAttempt, suggestSlashCommands, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
23
- import { staticCompletionContext, readlineCompleter, formatCompletionPreview, tokenize, type CompletionContext } from "../tui/components/autocomplete";
23
+ import { staticCompletionContext, readlineCompleter, formatCompletionPreview, formatMidTurnHint, tokenize, type CompletionContext } from "../tui/components/autocomplete";
24
24
  import { normalizeBaseUrl } from "./setup-helpers";
25
25
  import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
26
26
  import { getEvolutionTip } from "../tui/components/evolution";
@@ -41,6 +41,7 @@ import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLev
41
41
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
42
42
 
43
43
  import { listAliases } from "../ai/model-registry";
44
+ import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
44
45
 
45
46
  import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting } from "../agent/subagents";
46
47
  import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
@@ -57,7 +58,7 @@ import {
57
58
  } from "../tui/components/config-panel";
58
59
  import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
59
60
 
60
- import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
61
+ import { loginPicker, renderLoginPicker, onboardingPicker, renderOnboardingPicker, apiKeyPicker, renderApiKeyPicker, subscriptionLoginPicker, type OnboardingAction } from "../tui/components/provider-picker";
61
62
  import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
62
63
  import { categoryBadge } from "../tui/components/category-index";
63
64
  import { renderInputFrame, verticalCursorOffset } from "../tui/components/input-box";
@@ -134,6 +135,7 @@ import {
134
135
  captureLivePromptInputChunk,
135
136
  restoreQueuedLinesToPrefill,
136
137
  createInFlightAbortHarness,
138
+ classifyMidTurnLine,
137
139
  } from "./launch/input";
138
140
  import {
139
141
  gatedStdout,
@@ -198,6 +200,7 @@ export {
198
200
  captureLivePromptInputChunk,
199
201
  restoreQueuedLinesToPrefill,
200
202
  createInFlightAbortHarness,
203
+ classifyMidTurnLine,
201
204
 
202
205
  gatedStdout,
203
206
  formatTaskSubEvent,
@@ -211,7 +214,10 @@ export {
211
214
  currentAtLabelFn as currentAtLabel,
212
215
  };
213
216
  export function normalizeSlashAlias(input: string): string {
214
- if (input === "/login" || input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
217
+ // gjc-parity: bare `/login` opens the provider onboarding selector (same as bare
218
+ // `/provider`); `/login <provider|args>` is the direct OAuth-login alias.
219
+ if (input === "/login") return "/provider";
220
+ if (input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
215
221
  if (input === "/settings") return "/config";
216
222
  if (input === "/subagent" || input.startsWith("/subagent ")) return `/agents${input.slice("/subagent".length)}`;
217
223
  if (input === "/subagents" || input.startsWith("/subagents ")) return `/agents${input.slice("/subagents".length)}`;
@@ -219,7 +225,12 @@ export function normalizeSlashAlias(input: string): string {
219
225
  return input;
220
226
  }
221
227
 
222
- const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
228
+ // Per-provider starting model for `--provider <name>` / role pinning. Catalog
229
+ // OpenAI-compatible providers supply their own default; built-ins use this map.
230
+ const STATIC_PROVIDER_DEFAULT: Partial<Record<ProviderName, string>> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast", lmstudio: "lmstudio/local-model", xai: "grok-4.3", kimi: "kimi-k2-0711-preview" };
231
+ function providerDefaultModel(p: ProviderName): string {
232
+ return openaiCompatDef(p)?.defaultModel ?? STATIC_PROVIDER_DEFAULT[p] ?? "";
233
+ }
223
234
 
224
235
 
225
236
  export function formatResumeHint(sessionId: string): string {
@@ -263,7 +274,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
263
274
  const defaultModel = cfg.defaultModel;
264
275
  const initialSessionModel =
265
276
  flags.model ??
266
- (flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? PROVIDER_DEFAULT[flags.provider] : undefined);
277
+ (flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? providerDefaultModel(flags.provider) : undefined);
267
278
  if (flags.provider && initialSessionModel) {
268
279
  const { provider } = await describeModel(initialSessionModel);
269
280
  if (provider !== flags.provider) {
@@ -472,6 +483,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
472
483
  // Full untruncated output of the most recent tool call — the clipped forge
473
484
  // card's `⟦Ctrl+O for more⟧` hint resolves here.
474
485
  let lastToolDetail: { tool: string; output: string } | null = null;
486
+ // Accumulated reasoning/thinking for the in-flight turn (the model's thought before its
487
+ // answer). Captured from the reasoning stream and persisted on the assistant message so
488
+ // it survives /resume + export (gjc "think → answer" record). Reset at each turn start.
489
+ let lastTurnReasoning = "";
475
490
  /** Wrap turn events so EVERY sink (TUI or plain stream) records the last full
476
491
  * tool output for the Ctrl+O detail view. */
477
492
  const withToolDetailCapture = (base: ReturnType<LaunchTui["events"]>): ReturnType<LaunchTui["events"]> => ({
@@ -480,6 +495,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
480
495
  lastToolDetail = { tool, output };
481
496
  base.onToolResult?.(tool, success, output);
482
497
  },
498
+ onReasoningStream: (textSoFar: string) => {
499
+ // textSoFar is the cumulative thought for the current step; keep the latest
500
+ // non-empty value (the thought immediately preceding the turn's answer).
501
+ if (textSoFar.trim()) lastTurnReasoning = textSoFar;
502
+ base.onReasoningStream?.(textSoFar);
503
+ },
483
504
  });
484
505
  /** Compose a session-persistence flush into onStep so each completed step is
485
506
  * written as it lands (durability across mid-turn interruption) without
@@ -547,6 +568,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
547
568
  // Clears the live next-prompt draft — used after a mid-turn Enter is lifted into
548
569
  // the steering inbox so the consumed line does not also become the next prompt.
549
570
  let queueBusyClear: (() => void) | undefined;
571
+ // Routes a command-shaped (/… or $…) mid-turn draft into the idle loop's
572
+ // pending-line queue so it runs as a real COMMAND at the turn boundary,
573
+ // instead of being steered into the model as literal text.
574
+ let queueBusyCommand: ((line: string) => void) | undefined;
550
575
  let interactiveTurnActive = false;
551
576
 
552
577
 
@@ -577,6 +602,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
577
602
  // AFTER compaction (which mutates history) and consumed by the post-turn
578
603
  // persistence block below.
579
604
  let beforeLen = history.length;
605
+ lastTurnReasoning = ""; // fresh turn: capture this turn's thinking from scratch
580
606
  // Incremental session persistence (durability across mid-turn interruption):
581
607
  // persistTurnTail() flushes history messages added since the last flush — called
582
608
  // right after the user prompt, on every onStep boundary, and once post-turn — so
@@ -675,12 +701,34 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
675
701
  if (typedEnter) {
676
702
  const line = (queueBusySnapshot?.().text ?? "").trim();
677
703
  if (line) {
678
- steerInbox.push(line);
704
+ // A mid-turn /command or $skill is NOT a query for the model — steering it
705
+ // would send the literal "/model" / "$skill" text to the LLM. Recognize it
706
+ // and run it as a real COMMAND: queue it for the idle dispatcher and stop the
707
+ // turn so it runs at once (below). Plain queries still steer into the running
708
+ // turn. JEO_NO_STEER=1 disables both (legacy draft-only).
679
709
  queueBusyClear?.();
680
710
  tui.setLivePromptInput("");
681
- // Surface the steered query as a `user` card in scrollback so it reads
682
- // as an accepted input that started work — not just a transient notice.
683
- tui.flushSteerCard(line);
711
+ tui.setLivePromptHint([]);
712
+ if (classifyMidTurnLine(line) === "command") {
713
+ // Run it as a real COMMAND: queue it for immediate dispatch by the prompt
714
+ // loop and abort the turn (the same controller Esc uses). The abort ends a
715
+ // streaming turn at once and cancels any further steps; a running tool still
716
+ // finishes first (jeo's abort is step-level, like Esc). The queued command is
717
+ // then auto-dispatched — no second Enter. JEO_NO_MIDTURN_DISPATCH=1 keeps the
718
+ // legacy behavior (queue to prefill, no interrupt, press Enter to run).
719
+ queueBusyCommand?.(line);
720
+ if (jeoEnv("NO_MIDTURN_DISPATCH") === "1") {
721
+ tui.events().onNotice?.(`⌘ queued ${line} — press Enter after this turn to run`);
722
+ } else {
723
+ tui.events().onNotice?.(`⌘ ${line} — interrupting the turn to run it`);
724
+ harness.controller.abort();
725
+ }
726
+ } else {
727
+ steerInbox.push(line);
728
+ // Surface the steered query as a `user` card in scrollback so it reads
729
+ // as an accepted input that started work — not just a transient notice.
730
+ tui.flushSteerCard(line);
731
+ }
684
732
  return;
685
733
  }
686
734
  }
@@ -691,7 +739,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
691
739
  // suppressed for the whole turn). On Enter the draft is lifted into the steering
692
740
  // inbox and surfaces as a `user` card (above). JEO_NO_LIVE_DRAFT=1 opts out.
693
741
  if (captured && jeoEnv("NO_LIVE_DRAFT") !== "1") {
694
- tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
742
+ const draft = queueBusySnapshot?.().text ?? "";
743
+ tui.setLivePromptInput(draft);
744
+ // Mid-turn command preview: as you type a /command or $skill DURING a turn,
745
+ // show its matches above the input box so command input visibly reacts
746
+ // (idle-prompt parity). Cleared the moment the draft stops being command-shaped.
747
+ tui.setLivePromptHint(
748
+ /^\s*[/$]/.test(draft) ? formatMidTurnHint(draft.trimStart(), completionContext(), 5) : [],
749
+ );
695
750
  }
696
751
  },
697
752
  onAbortNotice: msg => {
@@ -851,8 +906,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
851
906
  // this only covers the tail — net content is the full turn either way.
852
907
  try {
853
908
  await persistTurnTail();
854
- history.push({ role: "assistant", content: reply });
855
- if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
909
+ const assistantMsg: Message = lastTurnReasoning.trim()
910
+ ? { role: "assistant", content: reply, reasoning: lastTurnReasoning }
911
+ : { role: "assistant", content: reply };
912
+ history.push(assistantMsg);
913
+ if (sessionId) await appendMessage(sessionId, assistantMsg, cwd);
856
914
  if (tui) tui.finish(reply);
857
915
  } finally {
858
916
  if (tui) interactiveTurnActive = false;
@@ -1038,7 +1096,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1038
1096
  // gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
1039
1097
  // never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
1040
1098
  if (process.stdout.isTTY) process.stdout.write(clearScreen());
1041
- // Launch sweep: the DNA Claw's gradient loops seamlessly (default 2 full
1099
+ // Launch sweep: the forge mark's gradient loops seamlessly (default 2 full
1042
1100
  // cycles, JEO_WELCOME_ANIM_CYCLES overrides), ending on the static banner.
1043
1101
  // Truecolor TTYs only; JEO_NO_WELCOME_ANIM=1 opts out.
1044
1102
  const sweepable =
@@ -1356,6 +1414,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1356
1414
  // only captures the line submitted while it is registered; orphan lines emit
1357
1415
  // 'line' instead), so queue those and serve them before prompting again.
1358
1416
  const pendingStdinLines: string[] = [];
1417
+ // Commands submitted mid-turn (/… or $…) land here; the prompt loop dispatches them
1418
+ // IMMEDIATELY on its next iteration, bypassing the "new input first" prefill contract
1419
+ // (the user explicitly invoked them — no second Enter).
1420
+ const pendingMidTurnCommands: string[] = [];
1359
1421
  const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: "", pastedLines: [], inPaste: false };
1360
1422
  queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk);
1361
1423
  queueBusyPasteActive = () => queuedPromptInput.inPaste;
@@ -1363,6 +1425,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1363
1425
  text: queuedPromptInput.partial,
1364
1426
  });
1365
1427
  queueBusyClear = () => { queuedPromptInput.partial = ""; };
1428
+ queueBusyCommand = (line: string) => {
1429
+ // NO_MIDTURN_DISPATCH=1 keeps the legacy prefill path (tee up, press Enter); the
1430
+ // default routes to the immediate-dispatch queue served at the top of the loop.
1431
+ (jeoEnv("NO_MIDTURN_DISPATCH") === "1" ? pendingStdinLines : pendingMidTurnCommands).push(line);
1432
+ };
1366
1433
  // Bracketed-paste line routing at the PROMPT: readline strips the 2004 markers
1367
1434
  // and replays pasted lines as synthetic keypresses, emitting paste-start /
1368
1435
  // paste-end around them. Lines submitted INSIDE that window are intentional
@@ -2142,13 +2209,112 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2142
2209
 
2143
2210
 
2144
2211
  const pickCloudProvider = async (statuses: Awaited<ReturnType<typeof describeAllProviders>>): Promise<AuthProvider | undefined> => {
2145
- const cloud = new Set(["anthropic", "openai", "gemini", "antigravity"]);
2146
- const list = providerPicker(statuses.filter(s => cloud.has(s.name)), true);
2212
+ const cloud = new Set<string>(OAUTH_PROVIDERS); // OAuth-login providers (anthropic/openai/gemini/antigravity)
2213
+ const subs = new Set<string>(SUBSCRIPTION_PROVIDER_NAMES); // subscription/plan products (token-keyed)
2214
+ const list = subscriptionLoginPicker(
2215
+ statuses.filter(s => cloud.has(s.name)),
2216
+ statuses.filter(s => subs.has(s.name)),
2217
+ true,
2218
+ );
2219
+ let chosen: ProviderName | undefined;
2220
+ await runSelectPicker(
2221
+ (cols, rows) =>
2222
+ renderLoginPicker(list, {
2223
+ title: "Login with OAuth / subscription ↑↓ move · Enter select · Esc cancel",
2224
+ cols,
2225
+ rows: Math.max(4, Math.min(rows, 8)),
2226
+ unicode: true,
2227
+ color: true,
2228
+ }),
2229
+ (ch, key) => {
2230
+ if (key?.name === "up") {
2231
+ list.up();
2232
+ return false;
2233
+ }
2234
+ if (key?.name === "down") {
2235
+ list.down();
2236
+ return false;
2237
+ }
2238
+ if (key?.name === "pageup") {
2239
+ list.page(-1, 4);
2240
+ return false;
2241
+ }
2242
+ if (key?.name === "pagedown") {
2243
+ list.page(1, 4);
2244
+ return false;
2245
+ }
2246
+ if (key?.name === "backspace") {
2247
+ list.backspace();
2248
+ return false;
2249
+ }
2250
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
2251
+ return true;
2252
+ }
2253
+ if (key?.name === "return" || key?.name === "enter") {
2254
+ chosen = list.selected()?.value;
2255
+ return true;
2256
+ }
2257
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
2258
+ list.typeChar(ch);
2259
+ }
2260
+ return false;
2261
+ },
2262
+ );
2263
+ return chosen && (cloud.has(chosen) || subs.has(chosen)) ? chosen as AuthProvider : undefined;
2264
+ };
2265
+
2266
+ // Bare `/provider` opens gjc's interactive onboarding selector: choose between
2267
+ // OAuth/subscription login and registering an API-compatible endpoint. Returns the
2268
+ // picked action, or undefined when cancelled (Esc/Ctrl+C). TTY only — callers fall
2269
+ // back to the printed usage in non-interactive mode.
2270
+ const pickOnboardingAction = async (): Promise<OnboardingAction | undefined> => {
2271
+ const list = onboardingPicker(true);
2272
+ let chosen: OnboardingAction | undefined;
2273
+ await runSelectPicker(
2274
+ (cols, rows) =>
2275
+ renderOnboardingPicker(list, {
2276
+ cols,
2277
+ rows: Math.max(4, Math.min(rows, 6)),
2278
+ unicode: true,
2279
+ color: true,
2280
+ }),
2281
+ (ch, key) => {
2282
+ if (key?.name === "up") {
2283
+ list.up();
2284
+ return false;
2285
+ }
2286
+ if (key?.name === "down") {
2287
+ list.down();
2288
+ return false;
2289
+ }
2290
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) {
2291
+ return true;
2292
+ }
2293
+ if (key?.name === "return" || key?.name === "enter") {
2294
+ chosen = list.selected()?.value;
2295
+ return true;
2296
+ }
2297
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) {
2298
+ list.typeChar(ch);
2299
+ }
2300
+ return false;
2301
+ },
2302
+ );
2303
+ return chosen;
2304
+ };
2305
+
2306
+ // API-key onboarding: pick one of the bundled API-key-only providers (groq, deepseek,
2307
+ // mistral, …) to store a key for. Returns the picked provider, or undefined on cancel.
2308
+ // TTY only — the caller prints scriptable guidance otherwise.
2309
+ const pickApiKeyProvider = async (statuses: Awaited<ReturnType<typeof describeAllProviders>>): Promise<AuthProvider | undefined> => {
2310
+ const subs = new Set<string>(SUBSCRIPTION_PROVIDER_NAMES); // surfaced under OAuth/subscription login instead
2311
+ const keyed = new Set<string>(API_KEY_ONLY_PROVIDERS);
2312
+ const list = apiKeyPicker(statuses.filter(s => keyed.has(s.name) && !subs.has(s.name)), true);
2147
2313
  let chosen: ProviderName | undefined;
2148
2314
  await runSelectPicker(
2149
2315
  (cols, rows) =>
2150
- renderProviderPicker(list, {
2151
- title: "Select OAuth provider",
2316
+ renderApiKeyPicker(list, {
2317
+ title: "Select a provider to key \u2191\u2193 move \u00b7 Enter select \u00b7 Esc cancel",
2152
2318
  cols,
2153
2319
  rows: Math.max(4, Math.min(rows, 8)),
2154
2320
  unicode: true,
@@ -2188,9 +2354,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2188
2354
  return false;
2189
2355
  },
2190
2356
  );
2191
- return chosen && cloud.has(chosen) ? chosen as AuthProvider : undefined;
2357
+ return chosen && keyed.has(chosen) ? chosen as AuthProvider : undefined;
2192
2358
  };
2193
2359
 
2360
+
2194
2361
  if (previewEnabled) {
2195
2362
  process.once("exit", () => out.write("\x1b[?25h")); // safety net: never leave the cursor hidden
2196
2363
  const footerKeypressHandler = (_ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
@@ -2482,7 +2649,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2482
2649
  // Box mode: NO raw `jeo>` prompt at all — the boxed footer IS the input UI
2483
2650
  // (gating already suppresses readline echo, the empty prompt guarantees no
2484
2651
  // raw CLI input line can ever flash). Legacy prompt only without the box.
2485
- const rawText = await promptInput(previewEnabled ? "" : "\njeo> ");
2652
+ const rawText = pendingMidTurnCommands.length
2653
+ ? (disarmPreview(), pendingMidTurnCommands.shift()!)
2654
+ : await promptInput(previewEnabled ? "" : "\njeo> ");
2486
2655
  if (rawText.includes("\u0003")) forceExitFromCtrlC();
2487
2656
  const raw = rawText.trim();
2488
2657
  disarmPreview();
@@ -2936,40 +3105,152 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2936
3105
  }
2937
3106
  if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
2938
3107
  const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
2939
- const name = (tokens[0] ?? "").toLowerCase();
3108
+ let name = (tokens[0] ?? "").toLowerCase();
2940
3109
  // gjc-parity (semantic): /provider is ONBOARDING ONLY — set up OAuth credentials
2941
3110
  // or an API-compatible endpoint. Switching the active provider/model lives in /model.
2942
3111
  const providerOnboardingUsage = (): string[] => [
2943
3112
  "Provider onboarding — set up credentials or an API-compatible endpoint:",
2944
- " OAuth / subscription : /provider login [anthropic|openai|gemini|antigravity] (alias: /login)",
3113
+ " OAuth / subscription : /provider login [anthropic|openai|gemini|antigravity|<subscription>] (alias: /login)",
3114
+ " subscriptions (token): alibaba-coding-plan, qwen-portal, xiaomi-token-plan-*, minimax-code*",
3115
+ " API key (cloud) : /provider key [provider] [key] (groq, deepseek, mistral, openrouter, …)",
2945
3116
  " API-compatible : /provider add --base-url <url> [--model <model>] [--compat openai] (reads OPENAI_API_KEY)",
2946
3117
  " show current / clear: /provider add · /provider add clear",
2947
3118
  " Logout : /logout <provider>",
3119
+ " Headless OAuth : paste the redirect URL or code when the login prompt asks.",
2948
3120
  "Switch the active model or provider with /model.",
2949
3121
  ];
3122
+ // Bare `/provider` in an interactive TTY → gjc's interactive onboarding selector
3123
+ // (OAuth login vs API-compatible endpoint). The choice routes into the same
3124
+ // `login`/`add` branches below; cancel falls through to the printed readiness +
3125
+ // usage. Non-TTY / `help` keep the static panel (scriptable, unchanged).
3126
+ if (!name && process.stdin.isTTY && process.stdout.isTTY) {
3127
+ const action = await pickOnboardingAction();
3128
+ if (action === "oauth-login") name = "login";
3129
+ else if (action === "api-key") name = "key";
3130
+ else if (action === "api-add") {
3131
+ console.log("Add an API-compatible endpoint:");
3132
+ console.log(" /provider add --base-url <url> [--model <model>] [--compat openai]");
3133
+ console.log(" reads OPENAI_API_KEY · show current / clear: /provider add · /provider add clear");
3134
+ continue;
3135
+ }
3136
+ // action === undefined (cancelled) → fall through to the readiness panel.
3137
+ }
3138
+ // `/provider key [name] [key]` → store an API key for an API-key-only provider
3139
+ // (groq/deepseek/mistral/…). Interactive: pick the provider, then paste the key.
3140
+ if (name === "key") {
3141
+ const keyed = new Set<string>(API_KEY_ONLY_PROVIDERS);
3142
+ let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => keyed.has(t)) as AuthProvider | undefined;
3143
+ // A trailing token after the provider name is treated as the key itself.
3144
+ const inlineKey = target ? tokens.slice(1).filter(t => t.toLowerCase() !== target).pop() : undefined;
3145
+ if (!target) {
3146
+ const statuses = await describeAllProviders();
3147
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3148
+ target = await pickApiKeyProvider(statuses);
3149
+ } else {
3150
+ console.log("Set an API key for which provider?");
3151
+ console.log(` ${API_KEY_ONLY_PROVIDERS.filter(p => !(SUBSCRIPTION_PROVIDER_NAMES as readonly string[]).includes(p)).join(", ")}`);
3152
+ console.log(" Subscription / plan products use /provider login (token).");
3153
+ console.log(" Usage: /provider key <provider> <api-key> (or set <PROVIDER>_API_KEY)");
3154
+ }
3155
+ if (!target) {
3156
+ console.log("(cancelled)");
3157
+ continue;
3158
+ }
3159
+ }
3160
+ const envVar = `${target.toUpperCase().replace(/-/g, "_")}_API_KEY`;
3161
+ let apiKey = inlineKey;
3162
+ if (!apiKey) {
3163
+ apiKey = (await promptInput(`Paste ${target} API key (blank to cancel): `)).trim();
3164
+ }
3165
+ if (!apiKey) {
3166
+ console.log("(cancelled — no key entered)");
3167
+ continue;
3168
+ }
3169
+ await setApiKey(target, apiKey);
3170
+ console.log(`[SUCCESS] Stored ${target} API key in ~/.jeo/config.json (also reads ${envVar}).`);
3171
+ const live = await refreshLiveModelsCache();
3172
+ const after = (await describeAllProviders()).find(s => s.name === target);
3173
+ if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
3174
+ const forProvider = live.filter(r => r.provider === target);
3175
+ if (forProvider.some(r => r.ok && r.models.length > 0)) {
3176
+ lastPickIndex = flattenModels(forProvider);
3177
+ const viaCatalog = forProvider.some(r => r.fallback);
3178
+ console.log(` ${viaCatalog ? "catalog" : "live"} ${target} models → /model #N${viaCatalog ? " (live list endpoint unavailable; showing known models)" : ""}`);
3179
+ logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
3180
+ } else {
3181
+ const failed = forProvider.find(r => !r.ok);
3182
+ if (failed?.error) console.log(` live ${target} models unavailable: ${failed.error}`);
3183
+ }
3184
+ continue;
3185
+ }
2950
3186
  // `/provider login|auth [name]` → run OAuth login from the REPL.
2951
3187
  if (name === "login" || name === "auth") {
2952
3188
  const cloud = ["anthropic", "openai", "gemini", "antigravity"] as const;
2953
- let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => (cloud as readonly string[]).includes(t));
3189
+ const subs = new Set<string>(SUBSCRIPTION_PROVIDER_NAMES); // token-keyed subscription/plan products
3190
+ let target = tokens.slice(1).map(t => t.toLowerCase()).find(t => (cloud as readonly string[]).includes(t) || subs.has(t));
2954
3191
  if (!target) {
2955
3192
  const statuses = await describeAllProviders();
2956
3193
  if (process.stdin.isTTY && process.stdout.isTTY) {
2957
3194
  target = await pickCloudProvider(statuses);
2958
3195
  } else {
2959
3196
  console.log("Log in to which provider?");
3197
+ console.log(" OAuth:");
2960
3198
  cloud.forEach((p, i) => {
2961
3199
  const st = statuses.find(s => s.name === p);
2962
- console.log(` ${i + 1}) ${p.padEnd(10)} ${st?.ready ? `✓ ${st.label}` : "· not ready"}`);
3200
+ const badge = st?.loggedIn
3201
+ ? `\u2713 logged in${st.oauthEmail ? ` (${st.oauthEmail})` : ""}`
3202
+ : "\u00b7 not logged in";
3203
+ console.log(` ${i + 1}) ${p.padEnd(10)} ${badge}`);
2963
3204
  });
3205
+ console.log(" Subscription / plan (token):");
3206
+ for (const p of SUBSCRIPTION_PROVIDER_NAMES) {
3207
+ const st = statuses.find(s => s.name === p);
3208
+ const badge = st?.kind === "api_key" ? "\u2713 active" : "\u00b7 no token";
3209
+ console.log(` ${p.padEnd(22)} ${badge} (set ${st?.envVar ?? `${p.toUpperCase().replace(/-/g, "_")}_API_KEY`})`);
3210
+ }
2964
3211
  const ans = (await promptInput(`Choose [1-${cloud.length}] or name (blank to cancel): `)).trim().toLowerCase();
2965
3212
  const byNum: Record<string, string> = Object.fromEntries(cloud.map((p, i) => [String(i + 1), p]));
2966
- target = byNum[ans] ?? ((cloud as readonly string[]).includes(ans) ? ans : undefined);
3213
+ target = byNum[ans] ?? ((cloud as readonly string[]).includes(ans) || subs.has(ans) ? ans : undefined);
2967
3214
  }
2968
3215
  if (!target) {
2969
3216
  console.log("(cancelled)");
2970
3217
  continue;
2971
3218
  }
2972
3219
  }
3220
+ // Subscription/plan providers authenticate by token (not OAuth): prompt for the key
3221
+ // and store it like `/provider key`, then refresh + list models.
3222
+ if (subs.has(target)) {
3223
+ const sub = target as AuthProvider;
3224
+ const envVar = `${sub.toUpperCase().replace(/-/g, "_")}_API_KEY`;
3225
+ let token = tokens.slice(1).filter(t => t.toLowerCase() !== sub).pop();
3226
+ if (!token) {
3227
+ if (process.stdin.isTTY && process.stdout.isTTY) {
3228
+ token = (await promptInput(`Paste ${sub} subscription token (blank to cancel): `)).trim();
3229
+ } else {
3230
+ console.log(`Set the ${sub} subscription token with: /provider login ${sub} <token> (or set ${envVar}).`);
3231
+ }
3232
+ }
3233
+ if (!token) {
3234
+ console.log("(cancelled — no token entered)");
3235
+ continue;
3236
+ }
3237
+ await setApiKey(sub, token);
3238
+ console.log(`[SUCCESS] Stored ${sub} subscription token in ~/.jeo/config.json (also reads ${envVar}).`);
3239
+ const live = await refreshLiveModelsCache();
3240
+ const after = (await describeAllProviders()).find(s => s.name === sub);
3241
+ if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
3242
+ const forProvider = live.filter(r => r.provider === sub);
3243
+ if (forProvider.some(r => r.ok && r.models.length > 0)) {
3244
+ lastPickIndex = flattenModels(forProvider);
3245
+ const viaCatalog = forProvider.some(r => r.fallback);
3246
+ console.log(` ${viaCatalog ? "catalog" : "live"} ${sub} models → /model #N${viaCatalog ? " (live list endpoint unavailable; showing known models)" : ""}`);
3247
+ logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
3248
+ } else {
3249
+ const failed = forProvider.find(r => !r.ok);
3250
+ if (failed?.error) console.log(` live ${sub} models unavailable: ${failed.error}`);
3251
+ }
3252
+ continue;
3253
+ }
2973
3254
  console.log(`Starting OAuth login for ${target}…`);
2974
3255
  try {
2975
3256
  const { email } = await interactiveOAuthLogin(target as AuthProvider, rl);
@@ -3216,7 +3497,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3216
3497
  // No model given → the provider's first live model, provider-qualified.
3217
3498
  chosenModel = qualifyModelId(forProvider[0]!.model, want);
3218
3499
  } else {
3219
- chosenModel = PROVIDER_DEFAULT[want];
3500
+ chosenModel = providerDefaultModel(want);
3220
3501
  }
3221
3502
  await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
3222
3503
  console.log(`${role.title} pinned to ${want} via model ${chosenModel} — saved to ~/.jeo/config.json`);
@@ -8,6 +8,7 @@ import {
8
8
  OAUTH_FLOW_REGISTRY,
9
9
  openInBrowser,
10
10
  type AuthProvider,
11
+ type OAuthProvider,
11
12
  type OAuthController,
12
13
  } from "../auth";
13
14
  import {
@@ -118,7 +119,7 @@ export async function runSetupCommand(): Promise<void> {
118
119
  const key = await rl.question(`${choice} API key [${current.providers[choice] ? "********" : "None"}]: `);
119
120
  if (key.trim()) next.providers[choice] = key.trim();
120
121
  } else {
121
- const flow = OAUTH_FLOW_REGISTRY[choice as AuthProvider];
122
+ const flow = OAUTH_FLOW_REGISTRY[choice as OAuthProvider];
122
123
  if (!flow.verifiedEndToEnd && flow.note) console.log(`Note: ${flow.note}`);
123
124
  // Abort the pending "Paste redirect URL…" question once the flow settles —
124
125
  // otherwise it survives the SUCCESS/FAILED result, reprints its prompt, and
@@ -138,7 +139,7 @@ export async function runSetupCommand(): Promise<void> {
138
139
  try {
139
140
  let email: string | undefined;
140
141
  try {
141
- ({ email } = await interactiveLogin(choice as AuthProvider, ctrl));
142
+ ({ email } = await interactiveLogin(choice as OAuthProvider, ctrl));
142
143
  } finally {
143
144
  // Must fire BEFORE the catch's API-key question below, or that
144
145
  // question queues behind the stale paste prompt.