jeo-code 0.6.23 → 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.
@@ -412,128 +412,90 @@ export async function animateFrames(stage: AsciiStage, opts: AnimateFramesOption
412
412
  }
413
413
  return total;
414
414
  }
415
- export const DNA_CLAW_ART: string[] = [
416
- " ╭╯ ◆ ◆ ╰╮ ",
417
- " ╭╯ ╱╲ ╱╲ ╰╮ ",
418
- " ║ ╲ ╱ ║ ",
419
- " ╰╮ ╳ ╳ ╭╯ ",
420
- " ╰╮ ╱ ╲ ╭╯ ",
421
- " ╚══○ ○══╝ ",
422
- " ║ ║ ",
423
- " [ DNA Claw ] "
415
+ /** The compact jeo forge mark: a clean, wordless pictograph of the mascot neon
416
+ * crayfish. Read top→bottom — antennae arc outward (╲ ╱) flanking the asymmetric
417
+ * sunglasses face (◆ blue lens / ◇ pink lens on a ┃ nose-bridge); the front claws
418
+ * (❮ ❯) on short arms (━┫ ┣━) hugging the rounded carapace (◉◉◉); tucked legs
419
+ * (╲ ╱); the tail fan tipped by the blinking telson (◀▮▶). The mark is purely
420
+ * symbolic NO embedded lettering (the brand wordmark lives in the welcome
421
+ * header, not under the emblem). Width-1 glyphs only (box drawing + geometrics)
422
+ * so padding/centering math stays exact. Frame 0 is the static symbol. */
423
+ export const FORGE_MARK_ART: string[] = [
424
+ " ╲ ◆ ┃ ◇ ╱ ",
425
+ " ❮━┫ ◉◉◉ ┣━❯ ",
426
+ " ╲ ┃ ╱ ",
427
+ " ◀▮▶ "
424
428
  ];
425
429
 
426
- export const DNA_CLAW_ART_ASCII: string[] = [
427
- " /{ * * }\\ ",
428
- " /{ / \\ / \\ }\\ ",
429
- " | \\ X / | ",
430
- " \\{ X X }/ ",
431
- " \\{ / X \\ }/ ",
432
- " \\==o o==/ ",
433
- " | | ",
434
- " [ DNA Claw ] "
430
+ export const FORGE_MARK_ART_ASCII: string[] = [
431
+ " \\ * | o / ",
432
+ " <=[ @@@ ]=> ",
433
+ " \\ | / ",
434
+ " <#> "
435
435
  ];
436
436
 
437
- /** Twist animation frames for the compact DNA Claw: the claw silhouette stays
438
- * fixed while the inner helix lattice rotates. Frame 0 === DNA_CLAW_ART, so a
439
- * frameless render is byte-identical to the static symbol. All lines are the
440
- * same width (18) and every glyph is display-width 1. */
441
- export const DNA_CLAW_FRAMES: string[][] = [
442
- DNA_CLAW_ART,
437
+ /** Blink animation frames for the compact crayfish forge mark: the antennae,
438
+ * carapace and legs stay fixed while the telson cursor blinks (▮ ▯) and the
439
+ * asymmetric sunglass lenses swap accent (◆ ◆), so the crayfish "winks".
440
+ * Frame 0 === FORGE_MARK_ART, so a frameless render is byte-identical to the
441
+ * static symbol. All lines share the same width (21) and width-1 glyphs. */
442
+ export const FORGE_MARK_FRAMES: string[][] = [
443
+ FORGE_MARK_ART,
443
444
  [
444
- " ╭╯ ◆ ╰╮ ",
445
- " ╭╯ ╲╱ ╲╱ ╰╮ ",
446
- " ║ ╳ ╳ ║ ",
447
- " ╰╮ ╱ ╳ ╲ ╭╯ ",
448
- " ╰╮ ╳ ╳ ╭╯ ",
449
- " ╚══○ ○══╝ ",
450
- " ║ ║ ",
451
- " [ DNA Claw ] "
452
- ],
453
- [
454
- " ╭╯ ◆ ◆ ╰╮ ",
455
- " ╭╯ ╱╲ ╱╲ ╰╮ ",
456
- " ║ ╳ ╳ ║ ",
457
- " ╰╮ ╲ ╳ ╱ ╭╯ ",
458
- " ╰╮ ╳ ╳ ╭╯ ",
459
- " ╚══○ ○══╝ ",
460
- " ║ ║ ",
461
- " [ DNA Claw ] "
445
+ " ╲ ◇ ┃ ",
446
+ " ❮━┫ ◉◉◉ ┣━❯ ",
447
+ " ",
448
+ " ◀▯▶ "
462
449
  ]
463
450
  ];
464
451
 
465
- export const DNA_CLAW_FRAMES_ASCII: string[][] = [
466
- DNA_CLAW_ART_ASCII,
467
- [
468
- " /{ * * }\\ ",
469
- " /{ \\ / \\ / }\\ ",
470
- " | X X | ",
471
- " \\{ / X \\ }/ ",
472
- " \\{ X X }/ ",
473
- " \\==o o==/ ",
474
- " | | ",
475
- " [ DNA Claw ] "
476
- ],
452
+ export const FORGE_MARK_FRAMES_ASCII: string[][] = [
453
+ FORGE_MARK_ART_ASCII,
477
454
  [
478
- " /{ * * }\\ ",
479
- " /{ / \\ / \\ }\\ ",
480
- " | X X | ",
481
- " \\{ \\ X / }/ ",
482
- " \\{ X X }/ ",
483
- " \\==o o==/ ",
484
- " | | ",
485
- " [ DNA Claw ] "
455
+ " \\ o | * / ",
456
+ " <=[ @@@ ]=> ",
457
+ " \\ | / ",
458
+ " <_> "
486
459
  ]
487
460
  ];
488
461
 
489
- /** Number of twist frames in the compact DNA Claw animation cycle. */
490
- export function dnaClawFrameCount(): number {
491
- return DNA_CLAW_FRAMES.length;
462
+ /** Number of blink frames in the compact forge-mark animation cycle. */
463
+ export function forgeMarkFrameCount(): number {
464
+ return FORGE_MARK_FRAMES.length;
492
465
  }
493
466
 
494
- /** Grand hero variant for the welcome forge box (gjc-style spacious banner):
495
- * a wide claw whose pincers frame a twisting DNA helix. Width-1 glyphs only
496
- * (box drawing + diagonals + geometrics) so padding/centering math stays exact. */
497
- export const DNA_CLAW_ART_GRAND: string[] = [
498
- " ◆◆ ◆◆ ",
499
- " ╭──╯╰──╮ ╭──╯╰──╮ ",
500
- " ╭╯ ╰╮ ╲╲ ╱╱ ╭╯ ╰╮ ",
501
- " ╭╯ ║ ╲╳╳╱ ║ ╰╮ ",
502
- " ║ ║ ╳╳ ║ ║ ",
503
- " ║ ║ ╱╳╳╲ ║ ║ ",
504
- " ╰╮ ║ ╱╱ ╲╲ ║ ╭╯ ",
505
- " ╰╮ ║ ╲╲ ╱╱ ║ ╭╯ ",
506
- " ╰──╮ ║ ╲╳╳╱ ║ ╭──╯ ",
507
- " ╰════○ ╳╳ ○════╯ ",
508
- " ║ ╱╳╳╲ ║ ",
509
- " [ D N A · C L A W ] "
467
+ /** Grand hero variant for the welcome forge box (gjc-style spacious banner): the
468
+ * same mascot crayfish rendered large and wordless antennae (╲ ╱) flanking the
469
+ * asymmetric ◆/◇ sunglasses face on a nose-bridge, the big front pincers
470
+ * (❮━━┫ ┣━━❯) hugging the segmented carapace (◉ ◉ ◉), tucked legs, and the broad
471
+ * tail fan tipped by the telson (◀──▮──▶). Purely symbolic — NO embedded
472
+ * lettering or caption. Width-1 glyphs only so padding/centering math stays
473
+ * exact. */
474
+ export const FORGE_MARK_ART_GRAND: string[] = [
475
+ " ╲ ◆ ◇ ╱ ",
476
+ " ❮━━┫ ◉ ◉ ┣━━❯ ",
477
+ " ┃ ╱ ╱ ",
478
+ " ◀──▮──▶ "
510
479
  ];
511
480
 
512
- export const DNA_CLAW_ART_GRAND_ASCII: string[] = [
513
- " ** ** ",
514
- " /--'`--\\ /--'`--\\ ",
515
- " /' `\\ \\\\ // /' `\\ ",
516
- " /' | \\XX/ | `\\ ",
517
- " | | XX | | ",
518
- " | | /XX\\ | | ",
519
- " \\, | // \\\\ | ,/ ",
520
- " \\, | \\\\ // | ,/ ",
521
- " \\--, | \\XX/ | ,--/ ",
522
- " \\====o XX o====/ ",
523
- " | /XX\\ | ",
524
- " [ D N A . C L A W ] "
481
+ export const FORGE_MARK_ART_GRAND_ASCII: string[] = [
482
+ " \\ * | o / ",
483
+ " <==[ O O O ]==> ",
484
+ " \\ \\ | / / ",
485
+ " <--#--> "
525
486
  ];
526
487
 
527
- // Bounded memo of fully-rendered DNA Claw frames keyed by every input that affects
488
+
489
+ // Bounded memo of fully-rendered forge-mark frames keyed by every input that affects
528
490
  // output (grand/unicode/cols/color/colorLevel/phase/frame). The live HUD cycles a
529
- // FIXED ~60-frame set (3 twists × 20 gradient phases) at ~120ms; without this each
530
- // recurrence recomputed per-line animatedGradientText (ANSI gradient) from scratch.
531
- // The memo makes the 2nd+ cycle O(1) lookups, cutting steady-state HUD CPU. LRU-capped.
532
- const dnaClawMemo = new Map<string, string[]>();
533
- const DNA_CLAW_MEMO_CAP = 256;
534
- const EMPTY_DNA_FRAME: string[] = [];
491
+ // FIXED frame set (blink × gradient phases) at ~120ms; without this each recurrence
492
+ // recomputed per-line animatedGradientText (ANSI gradient) from scratch. The memo
493
+ // makes the 2nd+ cycle O(1) lookups, cutting steady-state HUD CPU. LRU-capped.
494
+ const forgeMarkMemo = new Map<string, string[]>();
495
+ const FORGE_MARK_MEMO_CAP = 256;
496
+ const EMPTY_FORGE_FRAME: string[] = [];
535
497
 
536
- export function renderDnaClaw(opts: {
498
+ export function renderForgeMark(opts: {
537
499
  cols?: number;
538
500
  phase?: number;
539
501
  unicode?: boolean;
@@ -541,34 +503,35 @@ export function renderDnaClaw(opts: {
541
503
  colorLevel?: ColorLevel;
542
504
  /** Grand hero variant (welcome forge box); default is the compact in-turn symbol. */
543
505
  grand?: boolean;
544
- /** Twist-animation frame (compact symbol only; wraps). The helix lattice rotates
545
- * while the claw silhouette stays fixed — combined with the flowing gradient
546
- * `phase` this animates the forge identity without any frame-count growth. */
506
+ /** Blink-animation frame (compact symbol only; wraps). The cursor blinks and the
507
+ * status lamps swap while the window silhouette stays fixed — combined with the
508
+ * flowing gradient `phase` this animates the forge identity without any
509
+ * frame-count growth. */
547
510
  frame?: number;
548
511
  }): string[] {
549
512
  const memoKey = `${opts.grand ? "g" : "c"}|${opts.unicode !== false ? 1 : 0}|${opts.cols ?? -1}|${opts.color !== false ? 1 : 0}|${opts.colorLevel ?? ColorLevel.TrueColor}|${opts.phase ?? 0}|${opts.frame ?? 0}`;
550
- const memoHit = dnaClawMemo.get(memoKey);
513
+ const memoHit = forgeMarkMemo.get(memoKey);
551
514
  if (memoHit) return memoHit;
552
515
  const useUnicode = opts.unicode !== false;
553
516
  let source: string[];
554
517
  if (opts.grand) {
555
- source = useUnicode ? DNA_CLAW_ART_GRAND : DNA_CLAW_ART_GRAND_ASCII;
518
+ source = useUnicode ? FORGE_MARK_ART_GRAND : FORGE_MARK_ART_GRAND_ASCII;
556
519
  } else {
557
- const frames = useUnicode ? DNA_CLAW_FRAMES : DNA_CLAW_FRAMES_ASCII;
520
+ const frames = useUnicode ? FORGE_MARK_FRAMES : FORGE_MARK_FRAMES_ASCII;
558
521
  const f = Math.abs(Math.trunc(opts.frame ?? 0)) % frames.length;
559
522
  source = frames[f]!;
560
523
  }
561
524
  const width = Math.max(0, ...source.map(l => l.length));
562
525
 
563
526
  if (opts.cols !== undefined && opts.cols < width) {
564
- dnaClawMemo.set(memoKey, EMPTY_DNA_FRAME);
565
- return EMPTY_DNA_FRAME;
527
+ forgeMarkMemo.set(memoKey, EMPTY_FORGE_FRAME);
528
+ return EMPTY_FORGE_FRAME;
566
529
  }
567
530
 
568
531
  const phase = opts.phase ?? 0;
569
532
  const useColor = opts.color !== false;
570
533
  const colorLevel = opts.colorLevel ?? ColorLevel.TrueColor;
571
- const palette = DNA_FLOW_PALETTE;
534
+ const palette = FORGE_FLOW_PALETTE;
572
535
 
573
536
  const result = source.map((line, idx) => {
574
537
  const padded = line.length < width ? line + " ".repeat(width - line.length) : line;
@@ -578,19 +541,19 @@ export function renderDnaClaw(opts: {
578
541
  return animatedGradientText(padded, palette, phase + idx * 0.07, { colorLevel });
579
542
  });
580
543
 
581
- if (dnaClawMemo.size >= DNA_CLAW_MEMO_CAP) {
582
- const oldest = dnaClawMemo.keys().next().value;
583
- if (oldest !== undefined) dnaClawMemo.delete(oldest);
544
+ if (forgeMarkMemo.size >= FORGE_MARK_MEMO_CAP) {
545
+ const oldest = forgeMarkMemo.keys().next().value;
546
+ if (oldest !== undefined) forgeMarkMemo.delete(oldest);
584
547
  }
585
- dnaClawMemo.set(memoKey, result);
548
+ forgeMarkMemo.set(memoKey, result);
586
549
  return result;
587
550
  }
588
551
 
589
- /** The jeo identity palette — the mascot's synthwave neon read straight off the
590
- * character: blue lens → violet gown → pink lens. Shared by the claw art and the
591
- * forge-card border flow so the whole brand glows in the wizard's signature
592
- * blue→violet→pink (the dual neon lenses bracketing the gown). */
593
- export const DNA_FLOW_PALETTE: readonly string[] = ["#48dbfb", "#8e44ad", "#f368e0"];
552
+ /** The jeo identity palette — the mascot crayfish's synthwave neon read straight
553
+ * off the character: blue antennae glow → violet carapace → pink claw tips.
554
+ * Shared by the forge mark and the forge-card border flow so the whole brand
555
+ * glows in the crayfish-wizard's signature blue→violet→pink shell sheen. */
556
+ export const FORGE_FLOW_PALETTE: readonly string[] = ["#48dbfb", "#8e44ad", "#f368e0"];
594
557
 
595
558
  /** Width-1 forge title-mark glyph cycling the mascot's `jeo>` terminal prompt:
596
559
  * a prompt caret then a blinking block cursor (filled → hollow), echoing the
@@ -601,6 +564,6 @@ export function forgeBeat(frame: number, unicode = true): string {
601
564
  return beats[Math.abs(Math.trunc(frame)) % beats.length]!;
602
565
  }
603
566
 
604
- export function dnaClawHeight(): number {
605
- return DNA_CLAW_ART.length;
567
+ export function forgeMarkHeight(): number {
568
+ return FORGE_MARK_ART.length;
606
569
  }
@@ -7,6 +7,12 @@ import { SelectList, renderSelectList, type SelectItem, type RenderSelectOptions
7
7
  import type { ProviderStatus } from "../../ai/provider-status";
8
8
  import type { ProviderName } from "../../ai/types";
9
9
  import { companyLabel } from "../../ai/model-catalog";
10
+ import { SUBSCRIPTION_PROVIDER_NAMES } from "../../ai/providers/openai-compatible-catalog";
11
+
12
+ /** True for subscription/plan-tier providers (coding-plan, portal, token-plan, code). */
13
+ export function isSubscriptionProvider(name: ProviderName): boolean {
14
+ return (SUBSCRIPTION_PROVIDER_NAMES as readonly string[]).includes(name);
15
+ }
10
16
 
11
17
  /** Right-aligned hint for a provider row: credential kind + base URL + readiness. */
12
18
  export function providerHint(s: ProviderStatus, unicode = true): string {
@@ -43,3 +49,159 @@ export function providerPicker(statuses: ProviderStatus[], unicode = true): Sele
43
49
  export function renderProviderPicker(list: SelectList<ProviderName>, opts: RenderSelectOptions = {}): string[] {
44
50
  return renderSelectList(list, { title: "Select a provider", rows: 8, ...opts });
45
51
  }
52
+ /** Relative expiry label for a stored OAuth token, e.g. "expires in 42m" / "expired". */
53
+ export function loginExpiryLabel(expires: number | undefined, now: number = Date.now()): string | undefined {
54
+ if (!expires) return undefined;
55
+ const ms = expires - now;
56
+ if (ms <= 0) return "expired";
57
+ const mins = Math.round(ms / 60000);
58
+ if (mins < 60) return `expires in ${mins}m`;
59
+ return `expires in ${Math.round(mins / 60)}h`;
60
+ }
61
+
62
+ /** Right-aligned hint for a `/login` row: live OAuth login status (account + expiry)
63
+ * rather than generic readiness. Logged-in providers show a check + account/expiry;
64
+ * others show a muted "not logged in". gjc-parity for the login selector. */
65
+ export function loginHint(s: ProviderStatus, unicode = true): string {
66
+ if (!s.loggedIn) return unicode ? "\u00b7 not logged in" : "not logged in";
67
+ const parts: string[] = [unicode ? "\u2713 logged in" : "logged in"];
68
+ if (s.oauthEmail) parts.push(s.oauthEmail);
69
+ const expiry = loginExpiryLabel(s.oauthExpires);
70
+ if (expiry) parts.push(expiry);
71
+ return parts.join(" \u00b7 ");
72
+ }
73
+
74
+ /** Build `/login` choices: logged-in providers first, each row badged with its live
75
+ * OAuth login status (account/expiry). Pure builder mirroring gjc's OAuth selector. */
76
+ export function buildLoginChoices(statuses: ProviderStatus[], unicode = true): SelectItem<ProviderName>[] {
77
+ const sorted = [...statuses].sort((a, b) => (!!a.loggedIn === !!b.loggedIn ? 0 : a.loggedIn ? -1 : 1));
78
+ return sorted.map(s => ({
79
+ value: s.name,
80
+ label: `${s.name} (${companyLabel(s.name)})`,
81
+ group: s.loggedIn ? "logged in" : "not logged in",
82
+ hint: loginHint(s, unicode),
83
+ }));
84
+ }
85
+
86
+ /** Construct a ready-to-drive `SelectList` for the `/login` flow. */
87
+ export function loginPicker(statuses: ProviderStatus[], unicode = true): SelectList<ProviderName> {
88
+ return new SelectList(buildLoginChoices(statuses, unicode));
89
+ }
90
+
91
+ /** Right-aligned hint for a subscription-provider row: whether its key/token is stored,
92
+ * plus the env var that seeds it. Subscriptions authenticate by token, not OAuth. */
93
+ export function subscriptionHint(s: ProviderStatus, unicode = true): string {
94
+ const set = s.kind === "api_key";
95
+ const badge = set ? (unicode ? "\u2713 active" : "active") : (unicode ? "\u00b7 no token" : "no token");
96
+ return s.envVar ? `${badge} \u00b7 ${s.envVar}` : badge;
97
+ }
98
+
99
+ /** Build the combined "OAuth / subscription" login choices: OAuth providers (logged-in
100
+ * first, badged with account/expiry) followed by subscription-tier providers (active
101
+ * first, badged with token status). Pure builder mirroring gjc's onboarding selector. */
102
+ export function buildSubscriptionLoginChoices(
103
+ oauthStatuses: ProviderStatus[],
104
+ subscriptionStatuses: ProviderStatus[],
105
+ unicode = true,
106
+ ): SelectItem<ProviderName>[] {
107
+ const oauth = [...oauthStatuses]
108
+ .sort((a, b) => (!!a.loggedIn === !!b.loggedIn ? 0 : a.loggedIn ? -1 : 1))
109
+ .map(s => ({
110
+ value: s.name,
111
+ label: `${s.name} (${companyLabel(s.name)})`,
112
+ group: "OAuth login",
113
+ hint: loginHint(s, unicode),
114
+ }));
115
+ const subs = [...subscriptionStatuses]
116
+ .sort((a, b) => ((a.kind === "api_key") === (b.kind === "api_key") ? 0 : a.kind === "api_key" ? -1 : 1))
117
+ .map(s => ({
118
+ value: s.name,
119
+ label: `${s.name} (${companyLabel(s.name)})`,
120
+ group: "subscription / plan",
121
+ hint: subscriptionHint(s, unicode),
122
+ }));
123
+ return [...oauth, ...subs];
124
+ }
125
+
126
+ /** Construct a ready-to-drive `SelectList` for the combined OAuth / subscription login flow. */
127
+ export function subscriptionLoginPicker(
128
+ oauthStatuses: ProviderStatus[],
129
+ subscriptionStatuses: ProviderStatus[],
130
+ unicode = true,
131
+ ): SelectList<ProviderName> {
132
+ return new SelectList(buildSubscriptionLoginChoices(oauthStatuses, subscriptionStatuses, unicode));
133
+ }
134
+
135
+ /** Render a login picker `SelectList` with a sensible default title. */
136
+ export function renderLoginPicker(list: SelectList<ProviderName>, opts: RenderSelectOptions = {}): string[] {
137
+ return renderSelectList(list, { title: "Select provider to login", rows: 8, ...opts });
138
+ }
139
+
140
+ /** The ways to onboard a provider, mirroring gjc's `/provider` onboarding selector:
141
+ * log in to an OAuth/subscription provider, register an API-compatible endpoint, or
142
+ * store an API key for one of the bundled API-key-only providers (groq, deepseek, …). */
143
+ export type OnboardingAction = "oauth-login" | "api-key" | "api-add";
144
+
145
+ /** Build the bare-`/provider` onboarding choices (gjc-parity interactive selector).
146
+ * Pure builder: OAuth-login first (the common path), then API-key providers, then a
147
+ * custom API-compatible endpoint. */
148
+ export function buildOnboardingChoices(unicode = true): SelectItem<OnboardingAction>[] {
149
+ const arrow = unicode ? "\u2192 " : "";
150
+ return [
151
+ {
152
+ value: "oauth-login",
153
+ label: "Login with OAuth / subscription",
154
+ hint: `${arrow}OAuth providers + subscription / plan tokens`,
155
+ },
156
+ {
157
+ value: "api-key",
158
+ label: "Set an API key for a provider",
159
+ hint: `${arrow}groq, deepseek, mistral, openrouter, …`,
160
+ },
161
+ {
162
+ value: "api-add",
163
+ label: "Add an API-compatible endpoint",
164
+ hint: `${arrow}/provider add --base-url <url>`,
165
+ },
166
+ ];
167
+ }
168
+
169
+ /** Construct a ready-to-drive `SelectList` for the bare-`/provider` onboarding flow. */
170
+ export function onboardingPicker(unicode = true): SelectList<OnboardingAction> {
171
+ return new SelectList(buildOnboardingChoices(unicode));
172
+ }
173
+
174
+ /** Render the onboarding picker `SelectList` with a sensible default title. */
175
+ export function renderOnboardingPicker(list: SelectList<OnboardingAction>, opts: RenderSelectOptions = {}): string[] {
176
+ return renderSelectList(list, { title: "Provider onboarding \u2191\u2193 move \u00b7 Enter select \u00b7 Esc cancel", rows: 4, ...opts });
177
+ }
178
+
179
+ /** Right-aligned hint for an API-key provider row: whether a key is stored, plus the
180
+ * env var that seeds it. Mirrors `loginHint` but for keyed (no-OAuth) providers. */
181
+ export function apiKeyHint(s: ProviderStatus, unicode = true): string {
182
+ const set = s.kind === "api_key";
183
+ const badge = set ? (unicode ? "\u2713 key set" : "key set") : (unicode ? "\u00b7 no key" : "no key");
184
+ return s.envVar ? `${badge} \u00b7 ${s.envVar}` : badge;
185
+ }
186
+
187
+ /** Build `/provider` API-key choices: providers with a stored key first, each badged with
188
+ * its key status + env var. Pure builder mirroring the OAuth login selector. */
189
+ export function buildApiKeyChoices(statuses: ProviderStatus[], unicode = true): SelectItem<ProviderName>[] {
190
+ const sorted = [...statuses].sort((a, b) => (a.kind === "api_key") === (b.kind === "api_key") ? 0 : a.kind === "api_key" ? -1 : 1);
191
+ return sorted.map(s => ({
192
+ value: s.name,
193
+ label: `${s.name} (${companyLabel(s.name)})`,
194
+ group: s.kind === "api_key" ? "key set" : "needs key",
195
+ hint: apiKeyHint(s, unicode),
196
+ }));
197
+ }
198
+
199
+ /** Construct a ready-to-drive `SelectList` for the API-key onboarding flow. */
200
+ export function apiKeyPicker(statuses: ProviderStatus[], unicode = true): SelectList<ProviderName> {
201
+ return new SelectList(buildApiKeyChoices(statuses, unicode));
202
+ }
203
+
204
+ /** Render the API-key provider picker `SelectList` with a sensible default title. */
205
+ export function renderApiKeyPicker(list: SelectList<ProviderName>, opts: RenderSelectOptions = {}): string[] {
206
+ return renderSelectList(list, { title: "Select a provider to key", rows: 8, ...opts });
207
+ }
@@ -37,8 +37,8 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
37
37
  { command: "/goal", usage: "/goal <condition>", description: "Set a natural language stop condition for the session", group: "session" },
38
38
  { command: "/model", usage: "/model [id|#N|save|thinking <level>|subagent <role> <model|#N|thinking L>]", description: "Show/switch model; picker can apply to default or any subagent role and set thinking", group: "models" },
39
39
  { command: "/fast", usage: "/fast [on|off|status]", description: "Toggle fast thinking mode when the active model supports it", group: "models" },
40
- { command: "/provider", usage: "/provider [login [name] | add --base-url <url> [--model <m>]]", description: "Provider onboarding: `login [name]` starts OAuth; `add --base-url <url>` registers an OpenAI-compatible endpoint. Switch the active model/provider with /model", group: "models" },
41
- { command: "/login", usage: "/login [provider]", description: "OAuth login (alias of /provider login)", group: "models" },
40
+ { command: "/provider", usage: "/provider [login [name] | key [name] [key] | add --base-url <url> [--model <m>]]", description: "Provider onboarding: `login [name]` starts OAuth; `key [name]` stores an API key (groq, deepseek, …); `add --base-url <url>` registers an OpenAI-compatible endpoint. Switch the active model/provider with /model", group: "models" },
41
+ { command: "/login", usage: "/login [provider]", description: "OAuth login — opens a provider picker showing live login status (account · expiry) for each provider (alias of /provider login)", group: "models" },
42
42
  { command: "/logout", usage: "/logout <anthropic|openai|gemini|antigravity>", description: "Remove the stored OAuth token for a provider", group: "models" },
43
43
  { command: "/roles", usage: "/roles [tier model]", description: "Show or set model role tiers (smol/slow/plan)", group: "models" },
44
44
  { command: "/thinking", usage: "/thinking [level]", description: "Show or set thinking budget (minimal/low/medium/high/xhigh)", group: "models" },
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { renderDnaClaw, DNA_CLAW_ART_GRAND } from "./ascii-art";
2
+ import { renderForgeMark, FORGE_MARK_ART_GRAND } from "./ascii-art";
3
3
  import { truncate, isTTY } from "../terminal";
4
4
  import { detectColorLevel, ColorLevel } from "./color";
5
5
 
@@ -13,7 +13,7 @@ export interface WelcomeData {
13
13
  contextFiles?: string[]; // project context file paths (render basenames)
14
14
  recentSessions?: { name: string; timeAgo: string }[];
15
15
  cols?: number; // default 80
16
- /** Gradient phase [0..1) for the DNA Claw symbol — drives the launch sweep animation. */
16
+ /** Gradient phase [0..1) for the forge mark — drives the launch sweep animation. */
17
17
  phase?: number;
18
18
  /** Lit-edge painter (top border + left edge); theme accent. Default gray. */
19
19
  accent?: (s: string) => string;
@@ -42,7 +42,7 @@ function padLine(line: string, width: number, align: "left" | "center" | "right"
42
42
  /**
43
43
  * The gjc-style hero welcome box ("JEO forge"): one outer box with the version
44
44
  * embedded in the top border and a SINGLE CENTERED column inside — brand line,
45
- * tagline, the grand DNA Claw symbol (flowing gradient on capable terminals),
45
+ * tagline, the grand jeo forge mark (flowing gradient on capable terminals),
46
46
  * and the model/provider pills. Workspace details and key hints intentionally
47
47
  * live elsewhere (footer/status bar), matching the gjc forge banner.
48
48
  */
@@ -57,8 +57,8 @@ export function renderWelcome(d: WelcomeData): string[] {
57
57
 
58
58
  // The banner fills the full terminal width (gjc forge: flush with the input box and
59
59
  // status bar below it). `cols - 1` leaves the last column free so a full-width row
60
- // never wraps; the DNA-claw + pills stay centered inside the box.
61
- const grandWidth = Math.max(...DNA_CLAW_ART_GRAND.map(l => l.length));
60
+ // never wraps; the forge mark + pills stay centered inside the box.
61
+ const grandWidth = Math.max(...FORGE_MARK_ART_GRAND.map(l => l.length));
62
62
  // Title rides ON the top border: `─── jeo v{version} · JEO forge ───`. Defined
63
63
  // once here so the width calc and the border render below can't drift.
64
64
  const titleDashes = 3;
@@ -93,10 +93,10 @@ export function renderWelcome(d: WelcomeData): string[] {
93
93
  const bottomBorderPlain = g.bl + g.h.repeat(inner) + g.br;
94
94
  const bottomBorderLine = shadow(bottomBorderPlain);
95
95
 
96
- // Grand symbol when the box is wide enough; compact DNA Claw otherwise.
96
+ // Grand symbol when the box is wide enough; compact forge mark otherwise.
97
97
  const colorLevel = useColor ? detectColorLevel(process.env, isTTY()) : ColorLevel.None;
98
98
  const grand = inner >= grandWidth;
99
- const artLines = renderDnaClaw({
99
+ const artLines = renderForgeMark({
100
100
  color: useColor,
101
101
  phase: d.phase ?? 0,
102
102
  unicode,
@@ -136,7 +136,7 @@ export function renderWelcome(d: WelcomeData): string[] {
136
136
  }
137
137
 
138
138
  /**
139
- * Launch animation: sweep the DNA Claw's gradient through `cycles` FULL palette
139
+ * Launch animation: sweep the forge mark's gradient through `cycles` FULL palette
140
140
  * cycles by re-printing the welcome box in place (cursor-up rewrites, same row
141
141
  * count every frame). The loop is SEAMLESS — the phase wraps exactly at each
142
142
  * cycle boundary with a constant frame delay, so consecutive cycles join with