jeo-code 0.6.21 → 0.6.23

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 (45) hide show
  1. package/CHANGELOG.md +25 -1
  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 +26 -7
  16. package/src/ai/providers/anthropic-compatible.ts +27 -0
  17. package/src/ai/providers/anthropic.ts +7 -3
  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 +72 -0
  24. package/src/ai/providers/openai-compatible.ts +31 -0
  25. package/src/ai/providers/openai.ts +23 -7
  26. package/src/ai/providers/xai.ts +18 -0
  27. package/src/ai/register-providers.ts +18 -0
  28. package/src/ai/think-tags.ts +84 -0
  29. package/src/ai/types.ts +6 -1
  30. package/src/auth/flows/index.ts +3 -3
  31. package/src/auth/index.ts +4 -1
  32. package/src/auth/oauth.ts +3 -3
  33. package/src/auth/refresh.ts +5 -0
  34. package/src/auth/storage.ts +12 -1
  35. package/src/commands/auth.ts +19 -2
  36. package/src/commands/launch/flags.ts +5 -1
  37. package/src/commands/launch/input.ts +13 -0
  38. package/src/commands/launch.ts +78 -12
  39. package/src/commands/setup.ts +3 -2
  40. package/src/tui/app.ts +51 -31
  41. package/src/tui/components/ascii-art.ts +11 -7
  42. package/src/tui/components/autocomplete.ts +16 -0
  43. package/src/tui/components/forge.ts +1 -1
  44. package/src/tui/components/transcript.ts +7 -0
  45. package/src/tui/components/width.ts +21 -0
@@ -4,6 +4,7 @@ import { readSse } from "../sse";
4
4
  import { providerHttpError } from "./errors";
5
5
  import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
+ import { createThinkSplitter } from "../think-tags";
7
8
 
8
9
  export function openaiRequest(messages: Message[], options: CallOptions, credential: Credential, stream: boolean): { url: string; headers: Record<string, string>; body: string } {
9
10
  const model = options.model.startsWith("openai/") ? options.model.slice(7) : options.model;
@@ -22,9 +23,11 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
22
23
  : msg.content;
23
24
  openaiMessages.push({ role: msg.role, content });
24
25
  }
25
- // Reasoning models (o-series, gpt-5 family) take max_completion_tokens + reasoning_effort
26
+ // Reasoning models (o-series, gpt-5+ family) take max_completion_tokens + reasoning_effort
26
27
  // and reject temperature; classic chat models (gpt-4o, …) take max_tokens + temperature.
27
- const isReasoning = /^o\d/.test(model) || /^gpt-5/.test(model);
28
+ // Digit-count agnostic (gpt-6/o10 stay reasoning) mirrors inferCatalogMetadata.
29
+ const gptMajorMatch = model.match(/^gpt-(\d+)/);
30
+ const isReasoning = /^o\d/.test(model) || (gptMajorMatch ? Number(gptMajorMatch[1]) >= 5 : false);
28
31
  const payload: Record<string, unknown> = {
29
32
  model,
30
33
  messages: openaiMessages,
@@ -102,19 +105,30 @@ export const openaiAdapter: ProviderAdapter = {
102
105
  if (!response.body) return;
103
106
  let yieldedAny = false;
104
107
  let finishReason: string | undefined;
108
+ // Split inline <think>…</think> (DeepSeek-R1/Qwen-style local models) out of the
109
+ // visible answer and onto the reasoning channel. No-op for models that never emit it.
110
+ const think = createThinkSplitter(options.onReasoning);
105
111
  const toolAcc = new Map<number, { name: string; args: string }>();
106
112
  for await (const data of readSse(response.body)) {
107
- let chunk: { choices?: { delta?: { content?: string; tool_calls?: { index?: number; function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
113
+ let chunk: { choices?: { delta?: { content?: string; reasoning_content?: string; reasoning?: string; reasoning_text?: string; tool_calls?: { index?: number; function?: { name?: string; arguments?: string } }[] }; finish_reason?: string }[]; usage?: { prompt_tokens?: number; completion_tokens?: number } };
108
114
  try {
109
115
  chunk = JSON.parse(data);
110
116
  } catch {
111
117
  continue;
112
118
  }
113
- const delta = chunk.choices?.[0]?.delta?.content;
114
- if (delta) {
115
- yieldedAny = true;
116
- yield delta;
119
+ const raw = chunk.choices?.[0]?.delta?.content;
120
+ if (raw) {
121
+ const visible = think.push(raw);
122
+ if (visible) {
123
+ yieldedAny = true;
124
+ yield visible;
125
+ }
117
126
  }
127
+ // Structured reasoning channel (DeepSeek `reasoning_content`, OpenRouter/xAI
128
+ // `reasoning`): a SEPARATE field from content, so it bypasses the <think> splitter.
129
+ const d = chunk.choices?.[0]?.delta;
130
+ const reason = d?.reasoning_content ?? d?.reasoning ?? d?.reasoning_text;
131
+ if (reason) options.onReasoning?.(reason);
118
132
  const tcs = chunk.choices?.[0]?.delta?.tool_calls;
119
133
  if (tcs) {
120
134
  for (const tc of tcs) {
@@ -128,6 +142,8 @@ export const openaiAdapter: ProviderAdapter = {
128
142
  if (chunk.choices?.[0]?.finish_reason) finishReason = chunk.choices[0].finish_reason;
129
143
  if (chunk.usage) options.onUsage?.({ inputTokens: chunk.usage.prompt_tokens, outputTokens: chunk.usage.completion_tokens });
130
144
  }
145
+ const trailing = think.flush();
146
+ if (trailing) { yieldedAny = true; yield trailing; }
131
147
  // Native tool calls stream as tool_calls argument fragments — re-serialize once at end.
132
148
  const envelope = serializeAccumulatedToolCalls(toolAcc);
133
149
  if (envelope) { yieldedAny = true; yield envelope; }
@@ -0,0 +1,18 @@
1
+ import type { Credential } from "../../auth";
2
+ import { makeOpenAICompatibleAdapter } from "./openai-compatible";
3
+
4
+ /**
5
+ * xAI (Grok) — OpenAI-compatible cloud API at https://api.x.ai/v1, keyed by
6
+ * XAI_API_KEY (or `providers.xai`). The credential (an api_key bearer) is passed
7
+ * through; grok reasoning models (grok-4.3, grok-4-fast-*, grok-code-fast-1) stream
8
+ * reasoning via `reasoning_content`, which the openai adapter routes to onReasoning.
9
+ */
10
+ export const XAI_BASE_URL = "https://api.x.ai/v1";
11
+
12
+ export const xaiAdapter = makeOpenAICompatibleAdapter({ name: "xai", baseUrl: XAI_BASE_URL });
13
+
14
+ /** Credential carrier for xAI calls — an api_key bearer (the adapter only reads the
15
+ * token); a keyless `none` when no key is set. */
16
+ export function xaiCredential(key: string | undefined): Credential {
17
+ return key ? { kind: "api_key", provider: "openai", token: key } : { kind: "none", provider: "openai" };
18
+ }
@@ -12,10 +12,28 @@ import { anthropicAdapter } from "./providers/anthropic";
12
12
  import { openaiAdapter } from "./providers/openai";
13
13
  import { geminiAdapter } from "./providers/gemini";
14
14
  import { ollamaAdapter } from "./providers/ollama";
15
+ import { lmstudioAdapter } from "./providers/lmstudio";
16
+ import { xaiAdapter } from "./providers/xai";
15
17
  import { antigravityAdapter } from "./providers/antigravity";
18
+ import { kimiAdapter } from "./providers/kimi";
19
+ import { makeOpenAICompatibleAdapter } from "./providers/openai-compatible";
20
+ import { makeAnthropicCompatibleAdapter } from "./providers/anthropic-compatible";
21
+ import { OPENAI_COMPAT_PROVIDERS } from "./providers/openai-compatible-catalog";
16
22
 
17
23
  providerRegistry.register("anthropic", anthropicAdapter);
18
24
  providerRegistry.register("openai", openaiAdapter);
19
25
  providerRegistry.register("gemini", geminiAdapter);
20
26
  providerRegistry.register("antigravity", antigravityAdapter);
21
27
  providerRegistry.register("ollama", ollamaAdapter);
28
+ providerRegistry.register("lmstudio", lmstudioAdapter);
29
+ providerRegistry.register("xai", xaiAdapter);
30
+ providerRegistry.register("kimi", kimiAdapter);
31
+
32
+ // gjc-style data-driven providers: every catalog entry gets a thin factory adapter,
33
+ // selected by wire protocol. Add a provider by adding ONE catalog row.
34
+ for (const def of OPENAI_COMPAT_PROVIDERS) {
35
+ const adapter = def.protocol === "anthropic"
36
+ ? makeAnthropicCompatibleAdapter({ name: def.name, baseUrl: def.baseUrl })
37
+ : makeOpenAICompatibleAdapter({ name: def.name, baseUrl: def.baseUrl });
38
+ providerRegistry.register(def.name, adapter);
39
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Streaming `<think>…</think>` splitter for OpenAI-compatible / Ollama models.
3
+ *
4
+ * Many open/local reasoning models (DeepSeek-R1, Qwen "thinking", QwQ, …) do NOT
5
+ * expose a separate reasoning channel; they inline their chain-of-thought as
6
+ * `<think>…</think>` inside the normal content stream. Without splitting, that
7
+ * reasoning is dumped into the answer as literal text. This stateful splitter
8
+ * routes think-tag content to `onReasoning` (the dimmed live trace) and returns
9
+ * only the user-visible answer text — handling tags that straddle chunk
10
+ * boundaries, so it is safe to feed raw streamed deltas one at a time.
11
+ *
12
+ * Passthrough is near-free: text with no `<think>` tag flows through unchanged
13
+ * (only a trailing partial-tag fragment is briefly buffered).
14
+ */
15
+ const OPEN = "<think>";
16
+ const CLOSE = "</think>";
17
+
18
+ /** Longest suffix of `s` that is a non-empty proper prefix of `tag` (0 if none). */
19
+ function partialTail(s: string, tag: string): number {
20
+ const max = Math.min(s.length, tag.length - 1);
21
+ for (let k = max; k > 0; k--) {
22
+ if (s.endsWith(tag.slice(0, k))) return k;
23
+ }
24
+ return 0;
25
+ }
26
+
27
+ export interface ThinkSplitter {
28
+ /** Feed one streamed delta; returns the visible (answer) text to yield. */
29
+ push(delta: string): string;
30
+ /** Flush any buffered partial tag at stream end; returns trailing visible text. */
31
+ flush(): string;
32
+ }
33
+
34
+ export function createThinkSplitter(onReasoning?: (delta: string) => void): ThinkSplitter {
35
+ let inThink = false;
36
+ let pending = ""; // a tail that might be the start of an OPEN/CLOSE tag
37
+
38
+ const push = (delta: string): string => {
39
+ let s = pending + delta;
40
+ pending = "";
41
+ let visible = "";
42
+ for (;;) {
43
+ if (!inThink) {
44
+ const idx = s.indexOf(OPEN);
45
+ if (idx === -1) {
46
+ const tail = partialTail(s, OPEN);
47
+ visible += s.slice(0, s.length - tail);
48
+ pending = s.slice(s.length - tail);
49
+ break;
50
+ }
51
+ visible += s.slice(0, idx);
52
+ s = s.slice(idx + OPEN.length);
53
+ inThink = true;
54
+ } else {
55
+ const idx = s.indexOf(CLOSE);
56
+ if (idx === -1) {
57
+ const tail = partialTail(s, CLOSE);
58
+ const think = s.slice(0, s.length - tail);
59
+ if (think) onReasoning?.(think);
60
+ pending = s.slice(s.length - tail);
61
+ break;
62
+ }
63
+ const think = s.slice(0, idx);
64
+ if (think) onReasoning?.(think);
65
+ s = s.slice(idx + CLOSE.length);
66
+ inThink = false;
67
+ }
68
+ }
69
+ return visible;
70
+ };
71
+
72
+ const flush = (): string => {
73
+ const out = pending;
74
+ pending = "";
75
+ // An unterminated tail is literal content: emit it on whichever channel was open.
76
+ if (inThink) {
77
+ if (out) onReasoning?.(out);
78
+ return "";
79
+ }
80
+ return out;
81
+ };
82
+
83
+ return { push, flush };
84
+ }
package/src/ai/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Credential } from "../auth";
2
2
 
3
- export type ProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama";
3
+ export type ProviderName = "anthropic" | "openai" | "gemini" | "antigravity" | "ollama" | "lmstudio" | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax";
4
4
 
5
5
  /** An image attached to a (user) message — base64 payload + IANA media type. */
6
6
  export interface ImageAttachment {
@@ -17,6 +17,11 @@ export interface Message {
17
17
  * these alongside `content`; history bookkeeping (compaction, transcripts)
18
18
  * keeps treating `content` as the message body. */
19
19
  images?: ImageAttachment[];
20
+ /** Persisted reasoning/thinking text for an assistant turn (the thought before the
21
+ * answer). Survives /resume + export so the durable record shows "think → answer".
22
+ * Display-only: NOT replayed to providers (anthropic/gemini thinking replay needs
23
+ * the original signed block, which the streaming path does not capture). */
24
+ reasoning?: string;
20
25
  }
21
26
 
22
27
  export interface Usage {
@@ -1,5 +1,5 @@
1
1
  /** Per-provider OAuth login + refresh dispatch. */
2
- import type { AuthProvider } from "../storage";
2
+ import type { AuthProvider, OAuthProvider } from "../storage";
3
3
  import type { OAuthController, OAuthCredentials } from "../types";
4
4
  import { loginAnthropic, refreshAnthropicToken } from "./anthropic";
5
5
  import { loginOpenAI, refreshOpenAIToken } from "./openai";
@@ -7,7 +7,7 @@ import { loginGoogle, refreshGoogleToken } from "./google";
7
7
  import { loginAntigravity, refreshAntigravityToken } from "./antigravity";
8
8
 
9
9
  export interface OAuthFlow {
10
- readonly provider: AuthProvider;
10
+ readonly provider: OAuthProvider;
11
11
  readonly label: string;
12
12
  /** Run the interactive browser/PKCE login. */
13
13
  login(ctrl: OAuthController): Promise<OAuthCredentials>;
@@ -19,7 +19,7 @@ export interface OAuthFlow {
19
19
  readonly note?: string;
20
20
  }
21
21
 
22
- export const OAUTH_FLOW_REGISTRY: Record<AuthProvider, OAuthFlow> = {
22
+ export const OAUTH_FLOW_REGISTRY: Record<OAuthProvider, OAuthFlow> = {
23
23
  anthropic: {
24
24
  provider: "anthropic",
25
25
  label: "Anthropic (Claude Pro/Max)",
package/src/auth/index.ts CHANGED
@@ -6,8 +6,11 @@ export {
6
6
  setOauthCredential,
7
7
  clearOauthToken,
8
8
  setApiKey,
9
+ isOAuthProvider,
10
+ OAUTH_PROVIDERS,
11
+ API_KEY_ONLY_PROVIDERS,
9
12
  } from "./storage";
10
- export type { AuthProvider, Credential, AuthSnapshot } from "./storage";
13
+ export type { AuthProvider, OAuthProvider, Credential, AuthSnapshot } from "./storage";
11
14
  export {
12
15
  OAUTH_FLOWS,
13
16
  openInBrowser,
package/src/auth/oauth.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AuthProvider } from "./storage";
1
+ import type { AuthProvider, OAuthProvider } from "./storage";
2
2
  import { setOauthToken, clearOauthToken, setOauthCredential } from "./storage";
3
3
  import { OAUTH_FLOW_REGISTRY } from "./flows";
4
4
  import type { OAuthController } from "./types";
@@ -10,7 +10,7 @@ export interface OauthFlowDef {
10
10
  }
11
11
 
12
12
  /** Metadata kept for help text / manual-paste fallback. */
13
- export const OAUTH_FLOWS: Record<AuthProvider, OauthFlowDef> = {
13
+ export const OAUTH_FLOWS: Record<OAuthProvider, OauthFlowDef> = {
14
14
  anthropic: {
15
15
  label: "Anthropic Console (Claude)",
16
16
  authorizeUrl: "https://claude.ai/oauth/authorize",
@@ -64,7 +64,7 @@ export async function openInBrowser(url: string): Promise<void> {
64
64
  * the local callback server, wait for the code (or manual paste), exchange it,
65
65
  * and persist the full credential set (access + refresh + expiry).
66
66
  */
67
- export async function interactiveLogin(provider: AuthProvider, ctrl: OAuthController): Promise<{ email?: string }> {
67
+ export async function interactiveLogin(provider: OAuthProvider, ctrl: OAuthController): Promise<{ email?: string }> {
68
68
  const flow = OAUTH_FLOW_REGISTRY[provider];
69
69
  const creds = await flow.login(ctrl);
70
70
  await setOauthCredential(provider, {
@@ -7,6 +7,7 @@ import {
7
7
  acquireLock,
8
8
  releaseLock,
9
9
  type AuthProvider,
10
+ isOAuthProvider,
10
11
  type Credential,
11
12
  } from "./storage";
12
13
  import { OAUTH_FLOW_REGISTRY } from "./flows";
@@ -24,6 +25,10 @@ export interface RefreshResult {
24
25
  * Mirrors gjc's auth-broker refresher semantics (single source of truth).
25
26
  */
26
27
  export async function refreshOAuthToken(provider: AuthProvider): Promise<RefreshResult> {
28
+ // API-key-only providers (xai/kimi) have no OAuth flow — nothing to refresh.
29
+ if (!isOAuthProvider(provider)) {
30
+ return { refreshed: false, reason: "no_oauth_token", credential: await resolveCredential(provider) };
31
+ }
27
32
  await acquireLock(provider);
28
33
  try {
29
34
  const stored = await getStoredOAuth(provider);
@@ -5,7 +5,18 @@ import { readGlobalConfig, readRawGlobalConfig, saveConfigPatch, type StoredOAut
5
5
  import { jeoEnv } from "../util/env";
6
6
 
7
7
 
8
- export type AuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
8
+ /** Providers with an interactive OAuth login + refresh flow. */
9
+ export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
10
+ /** Every provider jeo resolves a credential for: OAuth-capable ∪ API-key-only. */
11
+ export type AuthProvider = OAuthProvider | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax";
12
+
13
+ export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"];
14
+ /** API-key-only providers (no OAuth flow) — resolved from config.providers / `<NAME>_API_KEY`. */
15
+ export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "kimi", "groq", "deepseek", "mistral", "openrouter", "together", "cerebras", "fireworks", "nvidia", "alibaba-coding-plan", "huggingface", "nanogpt", "qwen-portal", "synthetic", "venice", "zenmux", "qianfan", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "minimax-code", "minimax-code-cn", "zai", "minimax"];
16
+ /** Narrow an AuthProvider to the OAuth-capable subset (xai/kimi have no OAuth flow). */
17
+ export function isOAuthProvider(p: AuthProvider): p is OAuthProvider {
18
+ return (OAUTH_PROVIDERS as readonly string[]).includes(p);
19
+ }
9
20
 
10
21
  export type Credential =
11
22
  | { kind: "oauth"; provider: AuthProvider; token: string; projectId?: string }
@@ -14,6 +14,9 @@ import {
14
14
  logoutOAuth,
15
15
  refreshOAuthToken,
16
16
  snapshotProvider,
17
+ setApiKey,
18
+ isOAuthProvider,
19
+ API_KEY_ONLY_PROVIDERS,
17
20
  type AuthProvider,
18
21
  type OAuthController,
19
22
  } from "../auth";
@@ -29,7 +32,7 @@ export async function runAuthCommand(args: string[]): Promise<void> {
29
32
  process.exitCode = 1;
30
33
  }
31
34
 
32
- const CLOUD_PROVIDERS: readonly AuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"]
35
+ const CLOUD_PROVIDERS: readonly AuthProvider[] = ["anthropic", "openai", "gemini", "antigravity", "xai", "kimi"];
33
36
  /** True (and prints an error + sets exit code) when `p` is given but not a known provider. */
34
37
  function rejectInvalidProvider(p: string | undefined): boolean {
35
38
  if (p !== undefined && !(CLOUD_PROVIDERS as readonly string[]).includes(p)) {
@@ -53,7 +56,7 @@ async function runAuthStatus(): Promise<void> {
53
56
  const cfg = await readGlobalConfig();
54
57
  console.log("\n=== jeo auth status ===");
55
58
  console.log("Provider API key OAuth");
56
- for (const p of ["anthropic", "openai", "gemini", "antigravity"] as AuthProvider[]) {
59
+ for (const p of ["anthropic", "openai", "gemini", "antigravity", "xai", "kimi"] as AuthProvider[]) {
57
60
  const snap = await snapshotProvider(p);
58
61
  const key = p === "antigravity" ? "—" : (snap.apiKey ? "set" : "—");
59
62
  let oauth = "—";
@@ -99,6 +102,17 @@ async function runAuthLogin(rest: string[]): Promise<void> {
99
102
  rl.close();
100
103
  return;
101
104
  }
105
+ // API-key-only providers (xai/kimi): no OAuth flow — store/guide an API key.
106
+ if ((API_KEY_ONLY_PROVIDERS as readonly string[]).includes(chosen)) {
107
+ rl.close();
108
+ if (manualToken) {
109
+ await setApiKey(chosen, manualToken.trim());
110
+ console.log(`[SUCCESS] Stored ${chosen.toUpperCase()}_API_KEY in ~/.jeo/config.json.`);
111
+ } else {
112
+ console.log(`Provider '${chosen}' is API-key only (no OAuth flow). Set ${chosen.toUpperCase()}_API_KEY, or run 'jeo auth login ${chosen} --token <key>'.`);
113
+ }
114
+ return;
115
+ }
102
116
 
103
117
  // Non-interactive paste path (`--token`): store as a manual bearer.
104
118
  if (manualToken) {
@@ -213,6 +227,9 @@ export async function interactiveOAuthLogin(
213
227
  prompt: OAuthPrompt,
214
228
  log: (s: string) => void = console.log,
215
229
  ): Promise<{ email?: string }> {
230
+ if (!isOAuthProvider(provider)) {
231
+ throw new Error(`Provider '${provider}' is API-key only (no OAuth flow) — set ${provider.toUpperCase()}_API_KEY or run 'jeo auth login ${provider} --token <key>'.`);
232
+ }
216
233
  const flow = OAUTH_FLOW_REGISTRY[provider];
217
234
  log(`\n=== OAuth login — ${flow.label} ===`);
218
235
  if (!flow.verifiedEndToEnd && flow.note) log(`Note: ${flow.note}`);
@@ -51,7 +51,11 @@ export function fastThinkingLevelForModel(modelId: string): ThinkLevel | undefin
51
51
  const supported = catalogMetadata(modelId)?.thinking ?? [];
52
52
  if (supported.includes("minimal")) return "minimal";
53
53
  if (supported.includes("low")) return "low";
54
- if (/gemini-(2\.5|[3-9])/.test(modelId.toLowerCase())) return "minimal";
54
+ // Digit-count agnostic (gemini-10+ / 2.6+ stay reasoning) mirrors the gates in
55
+ // gemini.ts and inferCatalogMetadata. Last resort for prefixed ids (models/gemini-…)
56
+ // the catalog lookup above misses; catalogued ids already returned via thinking caps.
57
+ const g = modelId.toLowerCase().match(/gemini-(\d+)(?:\.(\d+))?/);
58
+ if (g && (Number(g[1]) >= 3 || (Number(g[1]) === 2 && Number(g[2] ?? 0) >= 5))) return "minimal";
55
59
  return undefined;
56
60
  }
57
61
 
@@ -395,3 +395,16 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
395
395
  },
396
396
  };
397
397
  }
398
+
399
+ /** Classify a mid-turn Enter draft. `/` (slash command) and `$` (skill) are jeo's
400
+ * command sigils: such a line must run as a COMMAND, never be steered as literal text
401
+ * into the running model. Anything else is a STEER query fed to the live turn; blank
402
+ * is EMPTY (ignored). Pure + exported so the live-turn handler and tests can't drift. */
403
+ export function classifyMidTurnLine(line: string): "command" | "steer" | "empty" {
404
+ const t = line.trim();
405
+ if (!t) return "empty";
406
+ // A lone sigil ("/" or "$") has no command name — ignore it instead of aborting the
407
+ // running turn to dispatch an empty command (a stray slash should not interrupt work).
408
+ if (t === "/" || t === "$") return "empty";
409
+ return /^[/$]/.test(t) ? "command" : "steer";
410
+ }
@@ -20,7 +20,7 @@ import { interactiveOAuthLogin } from "./auth";
20
20
  import { logoutOAuth } 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 } 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";
@@ -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,
@@ -219,7 +222,12 @@ export function normalizeSlashAlias(input: string): string {
219
222
  return input;
220
223
  }
221
224
 
222
- const PROVIDER_DEFAULT: Record<ProviderName, string> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast" };
225
+ // Per-provider starting model for `--provider <name>` / role pinning. Catalog
226
+ // OpenAI-compatible providers supply their own default; built-ins use this map.
227
+ 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" };
228
+ function providerDefaultModel(p: ProviderName): string {
229
+ return openaiCompatDef(p)?.defaultModel ?? STATIC_PROVIDER_DEFAULT[p] ?? "";
230
+ }
223
231
 
224
232
 
225
233
  export function formatResumeHint(sessionId: string): string {
@@ -263,7 +271,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
263
271
  const defaultModel = cfg.defaultModel;
264
272
  const initialSessionModel =
265
273
  flags.model ??
266
- (flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? PROVIDER_DEFAULT[flags.provider] : undefined);
274
+ (flags.modelRole ? resolveRoleModel(flags.modelRole, cfg) : flags.provider ? providerDefaultModel(flags.provider) : undefined);
267
275
  if (flags.provider && initialSessionModel) {
268
276
  const { provider } = await describeModel(initialSessionModel);
269
277
  if (provider !== flags.provider) {
@@ -472,6 +480,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
472
480
  // Full untruncated output of the most recent tool call — the clipped forge
473
481
  // card's `⟦Ctrl+O for more⟧` hint resolves here.
474
482
  let lastToolDetail: { tool: string; output: string } | null = null;
483
+ // Accumulated reasoning/thinking for the in-flight turn (the model's thought before its
484
+ // answer). Captured from the reasoning stream and persisted on the assistant message so
485
+ // it survives /resume + export (gjc "think → answer" record). Reset at each turn start.
486
+ let lastTurnReasoning = "";
475
487
  /** Wrap turn events so EVERY sink (TUI or plain stream) records the last full
476
488
  * tool output for the Ctrl+O detail view. */
477
489
  const withToolDetailCapture = (base: ReturnType<LaunchTui["events"]>): ReturnType<LaunchTui["events"]> => ({
@@ -480,6 +492,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
480
492
  lastToolDetail = { tool, output };
481
493
  base.onToolResult?.(tool, success, output);
482
494
  },
495
+ onReasoningStream: (textSoFar: string) => {
496
+ // textSoFar is the cumulative thought for the current step; keep the latest
497
+ // non-empty value (the thought immediately preceding the turn's answer).
498
+ if (textSoFar.trim()) lastTurnReasoning = textSoFar;
499
+ base.onReasoningStream?.(textSoFar);
500
+ },
483
501
  });
484
502
  /** Compose a session-persistence flush into onStep so each completed step is
485
503
  * written as it lands (durability across mid-turn interruption) without
@@ -547,6 +565,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
547
565
  // Clears the live next-prompt draft — used after a mid-turn Enter is lifted into
548
566
  // the steering inbox so the consumed line does not also become the next prompt.
549
567
  let queueBusyClear: (() => void) | undefined;
568
+ // Routes a command-shaped (/… or $…) mid-turn draft into the idle loop's
569
+ // pending-line queue so it runs as a real COMMAND at the turn boundary,
570
+ // instead of being steered into the model as literal text.
571
+ let queueBusyCommand: ((line: string) => void) | undefined;
550
572
  let interactiveTurnActive = false;
551
573
 
552
574
 
@@ -577,6 +599,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
577
599
  // AFTER compaction (which mutates history) and consumed by the post-turn
578
600
  // persistence block below.
579
601
  let beforeLen = history.length;
602
+ lastTurnReasoning = ""; // fresh turn: capture this turn's thinking from scratch
580
603
  // Incremental session persistence (durability across mid-turn interruption):
581
604
  // persistTurnTail() flushes history messages added since the last flush — called
582
605
  // right after the user prompt, on every onStep boundary, and once post-turn — so
@@ -675,12 +698,34 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
675
698
  if (typedEnter) {
676
699
  const line = (queueBusySnapshot?.().text ?? "").trim();
677
700
  if (line) {
678
- steerInbox.push(line);
701
+ // A mid-turn /command or $skill is NOT a query for the model — steering it
702
+ // would send the literal "/model" / "$skill" text to the LLM. Recognize it
703
+ // and run it as a real COMMAND: queue it for the idle dispatcher and stop the
704
+ // turn so it runs at once (below). Plain queries still steer into the running
705
+ // turn. JEO_NO_STEER=1 disables both (legacy draft-only).
679
706
  queueBusyClear?.();
680
707
  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);
708
+ tui.setLivePromptHint([]);
709
+ if (classifyMidTurnLine(line) === "command") {
710
+ // Run it as a real COMMAND: queue it for immediate dispatch by the prompt
711
+ // loop and abort the turn (the same controller Esc uses). The abort ends a
712
+ // streaming turn at once and cancels any further steps; a running tool still
713
+ // finishes first (jeo's abort is step-level, like Esc). The queued command is
714
+ // then auto-dispatched — no second Enter. JEO_NO_MIDTURN_DISPATCH=1 keeps the
715
+ // legacy behavior (queue to prefill, no interrupt, press Enter to run).
716
+ queueBusyCommand?.(line);
717
+ if (jeoEnv("NO_MIDTURN_DISPATCH") === "1") {
718
+ tui.events().onNotice?.(`⌘ queued ${line} — press Enter after this turn to run`);
719
+ } else {
720
+ tui.events().onNotice?.(`⌘ ${line} — interrupting the turn to run it`);
721
+ harness.controller.abort();
722
+ }
723
+ } else {
724
+ steerInbox.push(line);
725
+ // Surface the steered query as a `user` card in scrollback so it reads
726
+ // as an accepted input that started work — not just a transient notice.
727
+ tui.flushSteerCard(line);
728
+ }
684
729
  return;
685
730
  }
686
731
  }
@@ -691,7 +736,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
691
736
  // suppressed for the whole turn). On Enter the draft is lifted into the steering
692
737
  // inbox and surfaces as a `user` card (above). JEO_NO_LIVE_DRAFT=1 opts out.
693
738
  if (captured && jeoEnv("NO_LIVE_DRAFT") !== "1") {
694
- tui.setLivePromptInput(queueBusySnapshot?.().text ?? "");
739
+ const draft = queueBusySnapshot?.().text ?? "";
740
+ tui.setLivePromptInput(draft);
741
+ // Mid-turn command preview: as you type a /command or $skill DURING a turn,
742
+ // show its matches above the input box so command input visibly reacts
743
+ // (idle-prompt parity). Cleared the moment the draft stops being command-shaped.
744
+ tui.setLivePromptHint(
745
+ /^\s*[/$]/.test(draft) ? formatMidTurnHint(draft.trimStart(), completionContext(), 5) : [],
746
+ );
695
747
  }
696
748
  },
697
749
  onAbortNotice: msg => {
@@ -851,8 +903,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
851
903
  // this only covers the tail — net content is the full turn either way.
852
904
  try {
853
905
  await persistTurnTail();
854
- history.push({ role: "assistant", content: reply });
855
- if (sessionId) await appendMessage(sessionId, { role: "assistant", content: reply }, cwd);
906
+ const assistantMsg: Message = lastTurnReasoning.trim()
907
+ ? { role: "assistant", content: reply, reasoning: lastTurnReasoning }
908
+ : { role: "assistant", content: reply };
909
+ history.push(assistantMsg);
910
+ if (sessionId) await appendMessage(sessionId, assistantMsg, cwd);
856
911
  if (tui) tui.finish(reply);
857
912
  } finally {
858
913
  if (tui) interactiveTurnActive = false;
@@ -1356,6 +1411,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1356
1411
  // only captures the line submitted while it is registered; orphan lines emit
1357
1412
  // 'line' instead), so queue those and serve them before prompting again.
1358
1413
  const pendingStdinLines: string[] = [];
1414
+ // Commands submitted mid-turn (/… or $…) land here; the prompt loop dispatches them
1415
+ // IMMEDIATELY on its next iteration, bypassing the "new input first" prefill contract
1416
+ // (the user explicitly invoked them — no second Enter).
1417
+ const pendingMidTurnCommands: string[] = [];
1359
1418
  const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: "", pastedLines: [], inPaste: false };
1360
1419
  queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk);
1361
1420
  queueBusyPasteActive = () => queuedPromptInput.inPaste;
@@ -1363,6 +1422,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1363
1422
  text: queuedPromptInput.partial,
1364
1423
  });
1365
1424
  queueBusyClear = () => { queuedPromptInput.partial = ""; };
1425
+ queueBusyCommand = (line: string) => {
1426
+ // NO_MIDTURN_DISPATCH=1 keeps the legacy prefill path (tee up, press Enter); the
1427
+ // default routes to the immediate-dispatch queue served at the top of the loop.
1428
+ (jeoEnv("NO_MIDTURN_DISPATCH") === "1" ? pendingStdinLines : pendingMidTurnCommands).push(line);
1429
+ };
1366
1430
  // Bracketed-paste line routing at the PROMPT: readline strips the 2004 markers
1367
1431
  // and replays pasted lines as synthetic keypresses, emitting paste-start /
1368
1432
  // paste-end around them. Lines submitted INSIDE that window are intentional
@@ -2482,7 +2546,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2482
2546
  // Box mode: NO raw `jeo>` prompt at all — the boxed footer IS the input UI
2483
2547
  // (gating already suppresses readline echo, the empty prompt guarantees no
2484
2548
  // raw CLI input line can ever flash). Legacy prompt only without the box.
2485
- const rawText = await promptInput(previewEnabled ? "" : "\njeo> ");
2549
+ const rawText = pendingMidTurnCommands.length
2550
+ ? (disarmPreview(), pendingMidTurnCommands.shift()!)
2551
+ : await promptInput(previewEnabled ? "" : "\njeo> ");
2486
2552
  if (rawText.includes("\u0003")) forceExitFromCtrlC();
2487
2553
  const raw = rawText.trim();
2488
2554
  disarmPreview();
@@ -3216,7 +3282,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3216
3282
  // No model given → the provider's first live model, provider-qualified.
3217
3283
  chosenModel = qualifyModelId(forProvider[0]!.model, want);
3218
3284
  } else {
3219
- chosenModel = PROVIDER_DEFAULT[want];
3285
+ chosenModel = providerDefaultModel(want);
3220
3286
  }
3221
3287
  await saveConfigPatch(raw => ({ subagents: withSubagentSetting(raw, role.id, { model: chosenModel }) }));
3222
3288
  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.