jeo-code 0.6.2 → 0.6.4

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.
@@ -19,8 +19,9 @@ import { formatForgeBox } from "../tui/components/forge";
19
19
  import { interactiveOAuthLogin } from "./auth";
20
20
  import { logoutOAuth } from "../auth";
21
21
  import type { AuthProvider } from "../auth";
22
- import { matchSlash, isSlashAttempt, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
22
+ import { matchSlash, isSlashAttempt, suggestSlashCommands, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
23
23
  import { staticCompletionContext, readlineCompleter, formatCompletionPreview, tokenize, type CompletionContext } from "../tui/components/autocomplete";
24
+ import { normalizeBaseUrl } from "./setup-helpers";
24
25
  import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
25
26
  import { getEvolutionTip } from "../tui/components/evolution";
26
27
  import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
@@ -37,6 +38,7 @@ import { readGlobalConfig, saveConfigPatch } from "../agent/state";
37
38
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
38
39
  import { describeModel, describeAllProviders, thinkingMaxTokens, discoverModels, flattenModels, resolveSelection, catalogMetadata, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
39
40
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
41
+ import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
40
42
 
41
43
  import { listAliases } from "../ai/model-registry";
42
44
 
@@ -45,7 +47,6 @@ import { SelectList, renderSelectList, type SelectItem } from "../tui/components
45
47
  import {
46
48
  formatModelLine,
47
49
  formatProviderPanel,
48
- emitLoginCleanup,
49
50
  formatAgentsPanel,
50
51
  formatAgentDetail,
51
52
  formatConfigPanel,
@@ -56,7 +57,7 @@ import {
56
57
  } from "../tui/components/config-panel";
57
58
  import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
58
59
 
59
- import { providerPicker, renderProviderPicker, buildProviderChoices } from "../tui/components/provider-picker";
60
+ import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
60
61
  import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
61
62
  import { categoryBadge } from "../tui/components/category-index";
62
63
  import { renderInputFrame } from "../tui/components/input-box";
@@ -1549,14 +1550,48 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1549
1550
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
1550
1551
  // because nothing ever forced the model to update item statuses).
1551
1552
  let turnTodos: { title: string; status: string }[] = [];
1552
- const onBeforeDone = (): string | null => {
1553
+ const onBeforeDone = async (reason: string): Promise<string | null> => {
1553
1554
  const unfinished = turnTodos.filter(t => t.status !== "done");
1554
- if (turnTodos.length === 0 || unfinished.length === 0) return null;
1555
- return (
1556
- `Your todo list still shows ${unfinished.length} unfinished item(s): ${unfinished.map(t => `"${t.title}"`).join(", ")}. ` +
1557
- `Reconcile the plan first — call the todo tool resending the FULL list with every actually-completed item marked "done" ` +
1558
- `(drop items that no longer apply), then call done again.`
1559
- );
1555
+ if (turnTodos.length > 0 && unfinished.length > 0) {
1556
+ return (
1557
+ `Your todo list still shows ${unfinished.length} unfinished item(s): ${unfinished.map(t => `"${t.title}"`).join(", ")}. ` +
1558
+ `Reconcile the plan first — call the todo tool resending the FULL list with every actually-completed item marked "done" ` +
1559
+ `(drop items that no longer apply), then call done again.`
1560
+ );
1561
+ }
1562
+
1563
+ const goalState = await readGoalState(cwd);
1564
+ if (goalState && goalState.condition) {
1565
+ const reBlockCount = goalState.verdicts.filter(v => v.verdict === "NOT_MET" || v.verdict === "IMPOSSIBLE").length;
1566
+ const MAX_RE_BLOCKS = 2;
1567
+
1568
+ if (reBlockCount >= MAX_RE_BLOCKS) {
1569
+ if (tui) tui.events().onNotice?.(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
1570
+ else console.log(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
1571
+ return null;
1572
+ }
1573
+
1574
+ if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
1575
+ const verdict = await verifyGoal(goalState.condition, history, sessionModel);
1576
+
1577
+ goalState.verdicts.push({
1578
+ at: Date.now(),
1579
+ verdict: verdict.verdict,
1580
+ gap: verdict.reason,
1581
+ });
1582
+ await writeGoalState(goalState, cwd);
1583
+
1584
+ if (verdict.verdict === "NOT_MET" || verdict.verdict === "IMPOSSIBLE") {
1585
+ const prefix = verdict.verdict === "IMPOSSIBLE" ? "[IMPOSSIBLE] " : "";
1586
+ return (
1587
+ `Goal verifier check failed: The goal "${goalState.condition}" is not yet met.\n` +
1588
+ `Reason: ${prefix}${verdict.reason}\n` +
1589
+ `Please address the remaining gaps and call done again.`
1590
+ );
1591
+ }
1592
+ }
1593
+
1594
+ return null;
1560
1595
  };
1561
1596
  const fullTools = {
1562
1597
  ...DEFAULT_TOOLS,
@@ -2473,7 +2508,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2473
2508
  const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
2474
2509
  const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
2475
2510
  const frame = renderInputFrame(expandSentinel(line), {
2476
- cols,
2511
+ // Cap the input box at 120 cols to match the live-turn box (renderLiveInputBox)
2512
+ // and the user-card width, so the box doesn't visibly jump width on the
2513
+ // idle→live transition on wide terminals. The status bar below stays full-width.
2514
+ cols: Math.min(120, cols),
2477
2515
  color: true,
2478
2516
  unicode: true,
2479
2517
  accent: boxAccent,
@@ -3121,16 +3159,48 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3121
3159
  // Idle-prompt resize: re-reserve the footer at the new terminal height so the
3122
3160
  // fixed reservation stays accurate (otherwise the next paint would target the
3123
3161
  // old row count and either over-shoot or under-paint the reserved region).
3124
- const idleResizeHandler = () => {
3162
+ // gjc-style responsiveness: re-reserve the footer IMMEDIATELY on the first resize
3163
+ // event (leading edge → no lag), then cap re-reservations to ~30fps during a
3164
+ // drag-resize, with a trailing settle so the FINAL height is always reserved exactly.
3165
+ // Skip same-geometry events; each re-reserve disarms→arms so it must not run per-event.
3166
+ const RESIZE_THROTTLE_MS = 33;
3167
+ let idleResizeTimer: ReturnType<typeof setTimeout> | undefined;
3168
+ let lastIdleAt = 0;
3169
+ let lastIdleCols = process.stdout.columns ?? 80;
3170
+ let lastIdleRows = process.stdout.rows ?? 24;
3171
+ const reReserveFooter = () => {
3125
3172
  if (!previewArmed) return;
3173
+ const cols = process.stdout.columns ?? 80;
3174
+ const rows = process.stdout.rows ?? 24;
3175
+ if (cols === lastIdleCols && rows === lastIdleRows) return;
3176
+ lastIdleCols = cols;
3177
+ lastIdleRows = rows;
3126
3178
  try {
3127
3179
  disarmPreview();
3128
3180
  armPreview();
3129
3181
  drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
3130
3182
  } catch { /* ignore resize render races */ }
3131
3183
  };
3184
+ const idleResizeHandler = () => {
3185
+ if (!previewArmed) return;
3186
+ const now = Date.now();
3187
+ if (now - lastIdleAt >= RESIZE_THROTTLE_MS) {
3188
+ lastIdleAt = now;
3189
+ reReserveFooter();
3190
+ return;
3191
+ }
3192
+ if (idleResizeTimer) clearTimeout(idleResizeTimer);
3193
+ idleResizeTimer = setTimeout(() => {
3194
+ idleResizeTimer = undefined;
3195
+ lastIdleAt = Date.now();
3196
+ reReserveFooter();
3197
+ }, RESIZE_THROTTLE_MS);
3198
+ };
3132
3199
  process.stdout.on("resize", idleResizeHandler);
3133
- promptListenerCleanups.push(() => process.stdout.off("resize", idleResizeHandler));
3200
+ promptListenerCleanups.push(() => {
3201
+ process.stdout.off("resize", idleResizeHandler);
3202
+ if (idleResizeTimer) clearTimeout(idleResizeTimer);
3203
+ });
3134
3204
  }
3135
3205
 
3136
3206
  while (true) {
@@ -3548,6 +3618,32 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3548
3618
  }
3549
3619
  continue;
3550
3620
  }
3621
+ if (input === "/goal" || input.startsWith("/goal ")) {
3622
+ const arg = input.substring(5).trim();
3623
+ if (!arg) {
3624
+ const current = await readGoalState(cwd);
3625
+ if (current) {
3626
+ console.log(`Current goal: "${current.condition}" (set at ${new Date(current.setAt).toLocaleTimeString()})`);
3627
+ } else {
3628
+ console.log("Usage: /goal <condition> (set a natural language stop condition for the session)");
3629
+ console.log(" /goal clear (clear the current goal)");
3630
+ }
3631
+ continue;
3632
+ }
3633
+ if (arg === "clear" || arg === "none") {
3634
+ await clearGoalState(cwd);
3635
+ console.log("Goal cleared.");
3636
+ continue;
3637
+ }
3638
+ const state = {
3639
+ condition: arg,
3640
+ setAt: Date.now(),
3641
+ verdicts: [],
3642
+ };
3643
+ await writeGoalState(state, cwd);
3644
+ console.log(`Goal set to: "${arg}"`);
3645
+ continue;
3646
+ }
3551
3647
  // ---- gjc-parity inspection commands ------------------------------------
3552
3648
  if (input === "/usage") {
3553
3649
  const total = sessionUsage.inputTokens + sessionUsage.outputTokens;
@@ -3632,8 +3728,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3632
3728
  }
3633
3729
  if (input.startsWith("/provider") && (input === "/provider" || input[9] === " ")) {
3634
3730
  const tokens = input.substring(9).trim().split(/\s+/).filter(Boolean);
3635
- let name = (tokens[0] ?? "").toLowerCase();
3636
- const explicitModel = tokens[1];
3731
+ const name = (tokens[0] ?? "").toLowerCase();
3732
+ // gjc-parity (semantic): /provider is ONBOARDING ONLY — set up OAuth credentials
3733
+ // or an API-compatible endpoint. Switching the active provider/model lives in /model.
3734
+ const providerOnboardingUsage = (): string[] => [
3735
+ "Provider onboarding — set up credentials or an API-compatible endpoint:",
3736
+ " OAuth / subscription : /provider login [anthropic|openai|gemini|antigravity] (alias: /login)",
3737
+ " API-compatible : /provider add --base-url <url> [--model <model>] [--compat openai] (reads OPENAI_API_KEY)",
3738
+ " show current / clear: /provider add · /provider add clear",
3739
+ " Logout : /logout <provider>",
3740
+ "Switch the active model or provider with /model.",
3741
+ ];
3637
3742
  // `/provider login|auth [name]` → run OAuth login from the REPL.
3638
3743
  if (name === "login" || name === "auth") {
3639
3744
  const cloud = ["anthropic", "openai", "gemini", "antigravity"] as const;
@@ -3643,14 +3748,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3643
3748
  if (process.stdin.isTTY && process.stdout.isTTY) {
3644
3749
  target = await pickCloudProvider(statuses);
3645
3750
  } else {
3646
- // No provider given → show current status and let the user pick.
3647
3751
  console.log("Log in to which provider?");
3648
3752
  cloud.forEach((p, i) => {
3649
3753
  const st = statuses.find(s => s.name === p);
3650
3754
  console.log(` ${i + 1}) ${p.padEnd(10)} ${st?.ready ? `✓ ${st.label}` : "· not ready"}`);
3651
3755
  });
3652
- const ans = (await promptInput("Choose [1-3] or name (blank to cancel): ")).trim().toLowerCase();
3653
- const byNum: Record<string, string> = { "1": "anthropic", "2": "openai", "3": "gemini" };
3756
+ const ans = (await promptInput(`Choose [1-${cloud.length}] or name (blank to cancel): `)).trim().toLowerCase();
3757
+ const byNum: Record<string, string> = Object.fromEntries(cloud.map((p, i) => [String(i + 1), p]));
3654
3758
  target = byNum[ans] ?? ((cloud as readonly string[]).includes(ans) ? ans : undefined);
3655
3759
  }
3656
3760
  if (!target) {
@@ -3661,131 +3765,107 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3661
3765
  console.log(`Starting OAuth login for ${target}…`);
3662
3766
  try {
3663
3767
  const { email } = await interactiveOAuthLogin(target as AuthProvider, rl);
3664
- // Tidy back to the initial query-input screen: the OAuth flow printed
3665
- // browser prompts / "waiting…" lines that clutter scrollback. Clear the
3666
- // screen + scrollback and re-render the welcome (same path as /clear),
3667
- // then a single concise confirmation. lastPickIndex is still seeded so
3668
- // `/model #N` works; the verbose live-model dump is dropped (it cluttered).
3768
+ console.log(`[SUCCESS] OAuth login complete for ${target}${email ? ` (${email})` : ""}. Tokens saved to ~/.jeo/config.json.`);
3669
3769
  const live = await refreshLiveModelsCache();
3670
3770
  const after = (await describeAllProviders()).find(s => s.name === target);
3771
+ if (after) console.log(` status → ${after.name}: ${after.ready ? `✓ ${after.label}` : after.label}`);
3671
3772
  const forProvider = live.filter(r => r.provider === target);
3672
- if (forProvider.some(r => r.ok && r.models.length > 0)) lastPickIndex = flattenModels(forProvider);
3673
- // Tidy back to the initial query-input screen: the OAuth flow printed
3674
- // browser prompts / "waiting…" lines that clutter scrollback. emitLoginCleanup
3675
- // clears + re-renders the welcome (same path as /clear) then prints one
3676
- // confirmation; the verbose live-model dump is dropped (lastPickIndex is
3677
- // still seeded so /model #N works). Orchestration is unit-tested.
3678
- emitLoginCleanup(
3679
- {
3680
- clear: () => { disarmPreview(); process.stdout.write("\x1b[2J\x1b[3J\x1b[H"); },
3681
- write: line => console.log(line),
3682
- },
3683
- {
3684
- isTty: process.stdout.isTTY === true,
3685
- provider: target,
3686
- email,
3687
- ready: after?.ready ?? false,
3688
- label: after?.label,
3689
- welcomeLines: renderWelcome(welcomeData),
3690
- },
3691
- );
3773
+ if (forProvider.some(r => r.ok && r.models.length > 0)) {
3774
+ lastPickIndex = flattenModels(forProvider);
3775
+ const viaCatalog = forProvider.some(r => r.fallback);
3776
+ console.log(` ${viaCatalog ? "catalog" : "live"} ${target} models → /model #N${viaCatalog ? " (live list endpoint rejected this token; showing known models)" : ""}`);
3777
+ logLines(formatPickListWithCapabilities(lastPickIndex, { cap: 12 }));
3778
+ } else {
3779
+ const failed = forProvider.find(r => !r.ok);
3780
+ if (failed?.error) console.log(` live ${target} models unavailable: ${failed.error}`);
3781
+ }
3692
3782
  } catch (err) {
3693
3783
  console.log(`[FAILED] ${(err as Error).message} — or set ${target.toUpperCase()}_API_KEY.`);
3784
+ } finally {
3785
+ // The OAuth manual-code prompt opens an rl.question that is aborted the
3786
+ // instant the browser callback (or a failure) settles the flow. Bun leaves
3787
+ // that abandoned question's buffer behind, so the next prompt would adopt
3788
+ // it as residual prefill and resurrect the submitted "/provider login" in
3789
+ // the input box. Reset the readline line buffer so the prompt starts empty.
3790
+ const rli = rl as unknown as { line?: string; cursor?: number };
3791
+ rli.line = "";
3792
+ rli.cursor = 0;
3694
3793
  }
3695
3794
  continue;
3696
3795
  }
3697
- const cfgNow = await readGlobalConfig();
3698
- const statuses = await describeAllProviders(cfgNow);
3699
- if (!name) {
3700
- if (process.stdin.isTTY && process.stdout.isTTY) {
3701
- // gjc-style interactive picker (grouped ready / needs-setup, current marked)
3702
- // instead of a static text dump — arrows + Enter switch, Esc cancels.
3703
- const current = (await describeModel(sessionModel || cfgNow.defaultModel, cfgNow)).provider;
3704
- const items = buildProviderChoices(statuses, true, current).map(c => ({
3705
- value: c.value as string,
3706
- label: c.label,
3707
- hint: c.hint,
3708
- group: c.group,
3709
- }));
3710
- const picked = await pickFromOptions("Select a provider ↑↓ move · Enter switch · Esc cancel", items);
3711
- if (!picked) { console.log("(switch cancelled)"); continue; }
3712
- name = picked.toLowerCase(); // fall through to the switch logic below
3713
- } else {
3714
- console.log("Providers (credential · base URL):");
3715
- logLines(formatProviderPanel(statuses));
3716
- console.log("Switch with: /provider <name> [model] · choose models: /model");
3796
+ // `/provider add --base-url <url> [--model <m>] [--compat openai]` → register an
3797
+ // OpenAI-compatible endpoint (sets openaiBaseUrl). gjc flag style; bare positional
3798
+ // URL/model still accepted for leniency. `/provider add clear` removes it.
3799
+ if (name === "add") {
3800
+ const rest = tokens.slice(1);
3801
+ if ((rest[0] ?? "").toLowerCase() === "clear") {
3802
+ await saveConfigPatch(() => ({ openaiBaseUrl: undefined }));
3803
+ await refreshLiveModelsCache();
3804
+ console.log("OpenAI-compatible base URL cleared — saved to ~/.jeo/config.json.");
3717
3805
  continue;
3718
3806
  }
3719
- }
3720
- if (!isProviderName(name)) {
3721
- console.log(`Unknown provider '${name}'. Known: ${statuses.map(s => s.name).join(", ")}.`);
3722
- continue;
3723
- }
3724
- const st = statuses.find(s => s.name === name);
3725
- if (st && !st.ready) {
3726
- console.log(`! ${name} is not ready (${st.label}) set ${st.envVar ?? "the provider key"} or configure a compatible base URL. Switching anyway.`);
3727
- }
3728
- const live = await getLiveModels();
3729
- const forProvider = live.filter(r => r.provider === name);
3730
- const providerPick = flattenModels(forProvider);
3731
- const currentResolved = (await describeModel(sessionModel || cfgNow.defaultModel)).resolved;
3732
- let pickedFromPicker = false;
3733
- let target = explicitModel ?? PROVIDER_DEFAULT[name];
3734
- if (!explicitModel && providerPick.length && process.stdin.isTTY && process.stdout.isTTY) {
3735
- const picked = await pickLiveProviderModel(name, providerPick, currentResolved, st && !st.ready && !selectableThoughNotReady(st) ? [name] : []);
3736
- if (!picked) {
3737
- console.log("(cancelled)");
3807
+ let baseUrl: string | undefined;
3808
+ let modelArg: string | undefined;
3809
+ let compat: string | undefined;
3810
+ for (let i = 0; i < rest.length; i++) {
3811
+ const t = rest[i]!;
3812
+ if (t === "--base-url" || t === "--url") baseUrl = rest[++i];
3813
+ else if (t === "--model") modelArg = rest[++i];
3814
+ else if (t === "--compat") compat = (rest[++i] ?? "").toLowerCase();
3815
+ else if (t === "--api-key-env" || t === "--provider") { i++; /* gjc-only: jeo has no named-provider registry — ignored */ }
3816
+ else if (!t.startsWith("--") && baseUrl === undefined) baseUrl = t; // positional url
3817
+ else if (!t.startsWith("--") && modelArg === undefined) modelArg = t; // positional model
3818
+ }
3819
+ if (compat && compat !== "openai") {
3820
+ console.log(`Only --compat openai is supported (got '${compat}') — jeo registers a single OpenAI-compatible endpoint.`);
3738
3821
  continue;
3739
3822
  }
3740
- pickedFromPicker = true;
3741
- target = qualifyModelId(picked.model, picked.provider);
3742
- } else if (explicitModel && providerPick.length) {
3743
- const sel = resolveSelection(providerPick, explicitModel);
3744
- if (sel.kind === "index" || sel.kind === "match") {
3745
- target = qualifyModelId(sel.entry.model, sel.entry.provider);
3746
- if (st && !st.ready) {
3747
- if (selectableThoughNotReady(st)) {
3748
- console.log(notReadyWarning(st));
3749
- } else {
3750
- console.log(`Cannot select ${sel.entry.model}: ${name} is not ready (${st.label}). Set ${st.envVar ?? "the provider key"} first.`);
3751
- continue;
3752
- }
3753
- }
3754
- } else if (sel.kind === "ambiguous") {
3755
- console.log(`'${explicitModel}' matches ${sel.matches.length} ${name} models — be more specific:`);
3756
- for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3823
+ if (!baseUrl) {
3824
+ const cur = (await readGlobalConfig()).openaiBaseUrl;
3825
+ console.log(cur ? `OpenAI-compatible base URL: ${cur}` : "No OpenAI-compatible base URL set.");
3826
+ console.log("Set one with: /provider add --base-url <url> [--model <model>] · clear with: /provider add clear");
3757
3827
  continue;
3758
- } else if (sel.kind === "out-of-range") {
3759
- console.log(`#${explicitModel.slice(1)} is out of range for ${name} (1-${sel.max}).`);
3828
+ }
3829
+ const url = normalizeBaseUrl(baseUrl, "");
3830
+ if (!url) {
3831
+ console.log("Provide a base URL, e.g. /provider add --base-url http://localhost:1234/v1");
3760
3832
  continue;
3761
3833
  }
3762
- } else if (explicitModel?.startsWith("#")) {
3763
- console.log(`No numbered ${name} model list is available yet.`);
3764
- continue;
3765
- }
3766
- const { resolved, provider } = await describeModel(target);
3767
- if (explicitModel && provider !== name) {
3768
- console.log(`! '${target}' resolves to ${provider}, not ${name}. Pick a ${name} model from the live list below.`);
3769
- if (providerPick.length) logLines(formatPickListWithCapabilities(providerPick, { cap: 20 }));
3834
+ await saveConfigPatch(() => ({ openaiBaseUrl: url }));
3835
+ if (modelArg) {
3836
+ const qualified = qualifyModelId(modelArg, "openai");
3837
+ sessionModel = qualified;
3838
+ await saveConfigPatch(raw => rememberModelPatch(raw, qualified));
3839
+ console.log(`OpenAI-compatible endpoint set: ${url} · default model ${qualified} — saved to ~/.jeo/config.json.`);
3840
+ } else {
3841
+ console.log(`OpenAI-compatible endpoint set: ${url} — saved to ~/.jeo/config.json.`);
3842
+ }
3843
+ const live = await refreshLiveModelsCache();
3844
+ const forProvider = live.filter(r => r.provider === "openai");
3845
+ const pick = flattenModels(forProvider);
3846
+ if (pick.length) {
3847
+ lastPickIndex = pick;
3848
+ console.log("Discovered models at this endpoint — select with /model #N:");
3849
+ logLines(formatPickListWithCapabilities(pick, { cap: 12 }));
3850
+ } else {
3851
+ const failed = forProvider.find(r => !r.ok);
3852
+ console.log(failed?.error ? ` could not list models: ${failed.error}` : " no models discovered yet — the endpoint may need a key (set OPENAI_API_KEY).");
3853
+ }
3770
3854
  continue;
3771
3855
  }
3772
- if (pickedFromPicker && await applyPickedModelWithTarget(target)) {
3773
- if (providerPick.length) lastPickIndex = providerPick;
3856
+ // Bare `/provider` (or `help`): show readiness + the onboarding usage. No switching.
3857
+ if (!name || name === "help") {
3858
+ const cfgNow = await readGlobalConfig();
3859
+ const statuses = await describeAllProviders(cfgNow);
3860
+ console.log("Providers (credential · base URL):");
3861
+ logLines(formatProviderPanel(statuses));
3862
+ for (const line of providerOnboardingUsage()) console.log(line);
3774
3863
  continue;
3775
3864
  }
3776
- sessionModel = target;
3777
- // MRU persistence: a provider/model pick becomes the default for EVERY
3778
- // future session and the head of the recents rotation.
3779
- await saveConfigPatch(raw => rememberModelPatch(raw, target));
3780
- console.log(`Model set to ${formatModelLine({ label: target, resolved, provider, ready: st?.ready })} — saved as default`);
3781
- // Show the provider's live, credentialed catalog so the user can pick a concrete id.
3782
- if (providerPick.length) {
3783
- lastPickIndex = providerPick;
3784
- if (!pickedFromPicker) {
3785
- console.log(`Live ${name} models — select with /model #N, /provider ${name} #N, or rerun /provider ${name} and use arrows+Enter:`);
3786
- logLines(formatPickListWithCapabilities(lastPickIndex, { current: resolved }));
3787
- }
3788
- }
3865
+ // Anything else (a provider name) used to switch the active provider — that moved
3866
+ // to /model (gjc semantics: /provider onboards, /model selects).
3867
+ console.log(`'/provider ${name}' no longer switches providers provider/model selection moved to /model.`);
3868
+ console.log("Run /model to pick any provider's model (or /model <id|#N>). /provider now only onboards — see /provider help.");
3789
3869
  continue;
3790
3870
  }
3791
3871
  if (input.startsWith("/logout") && (input === "/logout" || input[7] === " ")) {
@@ -3964,11 +4044,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3964
4044
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
3965
4045
  continue;
3966
4046
  } else if (sel.kind === "out-of-range") {
3967
- console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
4047
+ console.log(`#${modelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
3968
4048
  continue;
3969
4049
  }
3970
4050
  } else if (modelArg.startsWith("#")) {
3971
- console.log("Use /model or /provider <name> first to build the numbered live model list.");
4051
+ console.log("Use /model first to build the numbered live model list.");
3972
4052
  continue;
3973
4053
  }
3974
4054
  // Persist a per-role model override to ~/.jeo/config.json (consumed by 'jeo team').
@@ -4038,11 +4118,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4038
4118
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4039
4119
  continue;
4040
4120
  } else if (sel.kind === "out-of-range") {
4041
- console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
4121
+ console.log(`#${chosenModel.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4042
4122
  continue;
4043
4123
  }
4044
4124
  } else if (chosenModel.startsWith("#")) {
4045
- console.log("Use /model or /provider <name> first to build the numbered live model list.");
4125
+ console.log("Use /model first to build the numbered live model list.");
4046
4126
  continue;
4047
4127
  }
4048
4128
  await saveConfigPatch(raw => ({ roles: { ...(raw.roles ?? {}), [tier]: chosenModel } }));
@@ -4123,12 +4203,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4123
4203
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4124
4204
  continue;
4125
4205
  } else if (sel.kind === "out-of-range") {
4126
- console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
4206
+ console.log(`#${toSave.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4127
4207
  continue;
4128
4208
  }
4129
4209
  // kind "none" → treat `toSave` as a literal model id/alias.
4130
4210
  } else if (toSave.startsWith("#")) {
4131
- console.log("Use /model or /provider <name> first to build the numbered list.");
4211
+ console.log("Use /model first to build the numbered list.");
4132
4212
  continue;
4133
4213
  }
4134
4214
  // Fall back to the FRESH on-disk default (not the stale session-start snapshot) so a
@@ -4196,11 +4276,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4196
4276
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4197
4277
  continue;
4198
4278
  } else if (sel.kind === "out-of-range") {
4199
- console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
4279
+ console.log(`#${roleModelArg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4200
4280
  continue;
4201
4281
  }
4202
4282
  } else if (roleModelArg.startsWith("#")) {
4203
- console.log("Use /model or /provider <name> first to build the numbered list.");
4283
+ console.log("Use /model first to build the numbered list.");
4204
4284
  continue;
4205
4285
  }
4206
4286
  if (roleModelArg) {
@@ -4251,12 +4331,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4251
4331
  for (const e of sel.matches.slice(0, 12)) console.log(` #${e.index} ${e.model} (${e.provider})`);
4252
4332
  continue;
4253
4333
  } else if (sel.kind === "out-of-range") {
4254
- console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model or /provider <name> first.`);
4334
+ console.log(`#${arg.slice(1)} is out of range (1-${sel.max}). Use /model first.`);
4255
4335
  continue;
4256
4336
  }
4257
4337
  // kind "none" → fall through and treat `arg` as a literal model id/alias.
4258
4338
  } else if (arg.startsWith("#")) {
4259
- console.log("Use /model or /provider <name> first to build the numbered list.");
4339
+ console.log("Use /model first to build the numbered list.");
4260
4340
  continue;
4261
4341
  }
4262
4342
  const label = arg || (sessionModel || defaultModel);
@@ -4282,7 +4362,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4282
4362
  }
4283
4363
  }
4284
4364
  if (arg && liveModelsCache && resolved === label && !liveModelKnown(liveModelsCache, resolved)) {
4285
- console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model or /provider <name> to pick a valid id)`);
4365
+ console.log(` (note: '${resolved}' is not in the live ${provider} catalog — use /model to pick a valid id)`);
4286
4366
  }
4287
4367
  const meta = catalogMetadata(resolved);
4288
4368
  if (meta) console.log(` ${formatCapabilityLine(meta)}`);
@@ -4450,7 +4530,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4450
4530
  if (m.length) {
4451
4531
  for (const line of formatSlashCommandList(input, skillSlashDetails)) console.log(line);
4452
4532
  } else {
4453
- console.log(`Unknown command '${input}'. Try /help.`);
4533
+ const cmds = [...completionContext().slashCommands];
4534
+ const near = suggestSlashCommands(input, cmds);
4535
+ console.log(near.length ? `Unknown command '${input}'. Did you mean ${near.join(", ")}?` : `Unknown command '${input}'. Try /help.`);
4454
4536
  }
4455
4537
  continue;
4456
4538
  }