Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/connect-model-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/kosong": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Add a `/connect` command that configures a provider and model from a model catalog. By default it reads from a pruned catalog snapshot bundled with the CLI, so the command works offline and is not gated by models.dev availability. Model metadata (context window, output limit, and capabilities) is filled in automatically, so models no longer need to be written by hand in config. Pass `--refresh` to fetch the latest catalog from models.dev (falling back to the bundled snapshot on failure), or `--url` to point at a custom catalog endpoint that uses the same format. When connecting an Anthropic-compatible provider whose catalog base URL already includes a version segment, the request path no longer duplicates that segment, so connections that previously failed with a not-found error now succeed.
5 changes: 5 additions & 0 deletions .changeset/connect-picker-search-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

The `/connect` provider and model pickers now support type-to-search filtering, and long selection lists are paginated instead of rendering every entry at once. The model picker also paginates when many models are configured.
5 changes: 5 additions & 0 deletions .changeset/model-picker-empty-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show a hint pointing to /login (Kimi) and /connect (other providers) when /model is opened with no configured models, and surface the same hint on the welcome panel when no model is set.
8 changes: 8 additions & 0 deletions .github/workflows/_native-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ jobs:
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}

- name: Generate built-in catalog (release artifacts)
if: inputs.sign-macos
shell: bash
run: |
CATALOG_FILE="$RUNNER_TEMP/kimi-code-built-in-catalog.json"
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"

- name: Build native executable (release profile, macOS signed)
if: runner.os == 'macOS' && inputs.sign-macos
run: pnpm --filter @moonshot-ai/kimi-code run build:native:release
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate Kimi Code built-in catalog
shell: bash
run: |
CATALOG_FILE="$RUNNER_TEMP/kimi-code-built-in-catalog.json"
node apps/kimi-code/scripts/update-catalog.mjs --out "$CATALOG_FILE"
echo "KIMI_CODE_BUILT_IN_CATALOG_FILE=$CATALOG_FILE" >> "$GITHUB_ENV"
Comment on lines +44 to +49
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid blocking all releases on catalog fetch availability

The release workflow now unconditionally fetches models.dev before any package build/publish step, so a transient outage or network failure at that endpoint fails the entire monorepo release job (including unrelated packages). Because the catalog is only needed for embedding into Kimi Code artifacts, this new always-on external dependency can halt normal release operations even when no Kimi Code package is being shipped.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

@7Sageer 7Sageer May 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design — /connect offline-by-default is a release-time commitment. If the catalog can't be fetched, the produced binaries would silently lose offline behavior, which is a degraded release and should block. The right response to a transient models.dev outage here is to retry the release, not decouple the dependency. Coupling unrelated packages (kosong, agent-core, etc.) to catalog availability is the cost of a single release pipeline; we prefer that over silently shipping degraded kimi-code.


- name: Build packages
run: pnpm build

Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
},
"scripts": {
"build": "tsdown",
"catalog:update": "node scripts/update-catalog.mjs --out dist/built-in-catalog.json",
"smoke": "node scripts/smoke.mjs",
"build:native:js": "node scripts/native/01-bundle.mjs",
"build:native:sea": "node scripts/native/build.mjs --profile=local",
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-code/scripts/built-in-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { readFileSync } from 'node:fs';

export const BUILT_IN_CATALOG_ENV = 'KIMI_CODE_BUILT_IN_CATALOG_FILE';
export const BUILT_IN_CATALOG_DEFINE = '__KIMI_CODE_BUILT_IN_CATALOG__';

export function builtInCatalogDefine(env = process.env) {
const file = env[BUILT_IN_CATALOG_ENV];
if (file === undefined || file.length === 0) return 'undefined';
return JSON.stringify(readFileSync(file, 'utf-8'));
}
10 changes: 10 additions & 0 deletions apps/kimi-code/scripts/native/build.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';

import { runBundleStep } from './01-bundle.mjs';
import { runInjectStep } from './03-inject.mjs';
import { runSeaBlobStep } from './02-sea-blob.mjs';
import { runSignStep } from './04-sign.mjs';
import { runVerifyStep } from './05-verify.mjs';
import { run } from './exec.mjs';
import { appRoot, nativeIntermediatesDir } from './paths.mjs';
import { BUILT_IN_CATALOG_ENV } from '../built-in-catalog.mjs';

const { values } = parseArgs({
options: {
Expand All @@ -31,6 +35,12 @@ function ensureNodeVersion() {
ensureNodeVersion();
console.log(`==> Native build (profile=${profile})`);

if (profile === 'release' && process.env[BUILT_IN_CATALOG_ENV] === undefined) {
const catalogPath = resolve(nativeIntermediatesDir(), 'built-in-catalog.json');
await run(process.execPath, [resolve(appRoot, 'scripts/update-catalog.mjs'), '--out', catalogPath]);
process.env[BUILT_IN_CATALOG_ENV] = catalogPath;
}

await runBundleStep();
await runSeaBlobStep();
await runInjectStep();
Expand Down
87 changes: 87 additions & 0 deletions apps/kimi-code/scripts/update-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Fetches models.dev/api.json, strips fields not needed by kimi-code, and
* writes the result as raw JSON for release builds to inline.
*
* This script intentionally does not write into src/. The source tree keeps a
* placeholder so the generated catalog is not committed.
*/

import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";

const scriptDir = import.meta.dirname;
const outFile = resolveOutputFile(process.argv.slice(2));
const modelsUrl = process.env.MODELS_DEV_URL || "https://models.dev/api.json";

const KEEP_PROVIDER = new Set(["id", "name", "api", "env", "npm", "type", "models"]);
const KEEP_MODEL = new Set(["id", "name", "family", "limit", "tool_call", "reasoning", "modalities"]);

function resolveOutputFile(args) {
const index = args.indexOf("--out");
if (index !== -1) {
const value = args[index + 1];
if (value === undefined || value.length === 0) {
throw new Error("Missing value for --out");
}
return resolve(process.cwd(), value);
}
return resolve(scriptDir, "../dist/built-in-catalog.json");
}

function stripModel(model) {
if (typeof model !== "object" || model === null) return undefined;
const result = {};
for (const key of Object.keys(model)) {
if (KEEP_MODEL.has(key)) result[key] = model[key];
}
return result;
}

function stripProvider(provider) {
if (typeof provider !== "object" || provider === null) return undefined;
const result = {};
for (const key of Object.keys(provider)) {
if (!KEEP_PROVIDER.has(key)) continue;
const value = provider[key];
if (key === "models") {
const stripped = {};
for (const [mId, m] of Object.entries(value)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle non-object models before iterating entries

The catalog-stripper assumes every provider's models field is an object and immediately calls Object.entries(value). If upstream returns models: null or models: undefined for any provider (or a malformed mirror does), this throws and aborts catalog generation, which blocks release builds because the workflow now depends on this step. Add a type/null guard for value before iterating so invalid providers are skipped instead of crashing the pipeline.

Useful? React with 👍 / 👎.

const s = stripModel(m);
if (s !== undefined) stripped[mId] = s;
}
if (Object.keys(stripped).length > 0) result[key] = stripped;
} else {
result[key] = value;
}
}
return result;
}

async function fetchCatalog(url) {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const raw = await res.json();
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("invalid payload shape");
}
const stripped = {};
for (const [k, v] of Object.entries(raw)) {
const p = stripProvider(v);
if (p !== undefined && Object.keys(p).length > 0) stripped[k] = p;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject reserved top-level keys when building catalog JSON

The build script writes provider IDs from fetched JSON directly into a plain object. If the upstream/mirrored catalog contains a key like __proto__, this assignment mutates the accumulator's prototype instead of creating a normal entry, silently producing corrupted output JSON. Because release workflows now fetch and embed this file, the script should reject reserved keys (or use null-prototype maps) before assignment.

Useful? React with 👍 / 👎.

}
return JSON.stringify(stripped);
}

async function main() {
console.log(`Fetching ${modelsUrl} ...`);
const json = await fetchCatalog(modelsUrl);
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(outFile, json, "utf-8");
console.log(`Wrote ${outFile} (${(json.length / 1024).toFixed(0)} KB JSON)`);
}

main().catch((error) => {
console.error(error.message);
process.exit(1);
});
8 changes: 8 additions & 0 deletions apps/kimi-code/src/built-in-catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Filled by tsdown define in release builds. Source stays empty so the
// generated models.dev snapshot is not committed.
declare const __KIMI_CODE_BUILT_IN_CATALOG__: string | undefined;

export const BUILT_IN_CATALOG_JSON: string | undefined =
typeof __KIMI_CODE_BUILT_IN_CATALOG__ === 'string'
? __KIMI_CODE_BUILT_IN_CATALOG__
: undefined;
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ export const BUILTIN_SLASH_COMMANDS = [
description: 'Select a platform and authenticate',
priority: 40,
},
{
name: 'connect',
aliases: [],
description: 'Connect a provider from a model catalog',
priority: 40,
},
{
name: 'exit',
aliases: ['quit', 'q'],
Expand Down
4 changes: 2 additions & 2 deletions apps/kimi-code/src/tui/components/chrome/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class WelcomeComponent implements Component {
const dim = chalk.hex(this.colors.textDim);
const labelStyle = chalk.bold.hex(this.colors.textDim);
const rightRow1 = truncateToWidth(
dim(isLoggedOut ? 'Run /login to sign in.' : 'Send /help for help information.'),
dim(isLoggedOut ? 'Run /login or /connect to get started.' : 'Send /help for help information.'),
textWidth,
'…',
);
Expand All @@ -52,7 +52,7 @@ export class WelcomeComponent implements Component {
];

const modelValue = isLoggedOut
? chalk.hex(this.colors.warning)('not set, send /login to login')
? chalk.hex(this.colors.warning)('not set, run /login or /connect')
: this.state.model;

const infoLines = [
Expand Down
65 changes: 51 additions & 14 deletions apps/kimi-code/src/tui/components/dialogs/choice-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import chalk from 'chalk';

import type { ColorPalette } from '#/tui/theme/colors';
import { SearchableList } from '#/tui/utils/searchable-list';

export interface ChoiceOption {
/** Value passed to onSelect (e.g. the actual editor command string). */
Expand All @@ -35,6 +36,10 @@ export interface ChoicePickerOptions {
readonly options: readonly ChoiceOption[];
readonly currentValue?: string;
readonly colors: ColorPalette;
/** When true, typed characters filter the list (fuzzy) and a search line is shown. */
readonly searchable?: boolean;
/** Items per page. Lists longer than this paginate. */
readonly pageSize?: number;
readonly onSelect: (value: string) => void;
readonly onCancel: () => void;
}
Expand Down Expand Up @@ -67,48 +72,73 @@ function wrapDescription(text: string, width: number): string[] {
export class ChoicePickerComponent extends Container implements Focusable {
focused = false;
private readonly opts: ChoicePickerOptions;
private selectedIndex: number;
private readonly list: SearchableList<ChoiceOption>;

constructor(opts: ChoicePickerOptions) {
super();
this.opts = opts;
const currentIdx = opts.options.findIndex((o) => o.value === opts.currentValue);
this.selectedIndex = Math.max(currentIdx, 0);
this.list = new SearchableList({
items: opts.options,
toSearchText: (o) => `${o.label} ${o.description ?? ''}`,
pageSize: opts.pageSize,
initialIndex: Math.max(currentIdx, 0),
searchable: opts.searchable === true,
});
}

handleInput(data: string): void {
if (matchesKey(data, Key.escape)) {
if (this.list.clearQuery()) return;
this.opts.onCancel();
return;
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
// Left/Right page through the list (this picker has no horizontal control).
if (matchesKey(data, Key.left)) {
this.list.pageUp();
return;
}
if (matchesKey(data, Key.down)) {
this.selectedIndex = Math.min(this.opts.options.length - 1, this.selectedIndex + 1);
if (matchesKey(data, Key.right)) {
this.list.pageDown();
return;
}
if (matchesKey(data, Key.enter)) {
const chosen = this.opts.options[this.selectedIndex];
const chosen = this.list.selected();
if (chosen !== undefined) this.opts.onSelect(chosen.value);
return;
}
this.list.handleKey(data);
}

override render(width: number): string[] {
const { colors } = this.opts;
const hint = this.opts.hint ?? '↑↓ navigate · Enter select · Esc cancel';
const searchable = this.opts.searchable === true;
const view = this.list.view();
const options = view.items;

const navParts = ['↑↓ navigate'];
if (view.page.pageCount > 1) navParts.push('←→ page');
navParts.push('Enter select', 'Esc cancel');
const hint = this.opts.hint ?? navParts.join(' · ');

const titleSuffix =
searchable && view.query.length === 0 ? chalk.hex(colors.textMuted)(' (type to search)') : '';
const lines: string[] = [
chalk.hex(colors.primary)('─'.repeat(width)),
chalk.hex(colors.primary).bold(` ${this.opts.title}`),
chalk.hex(colors.textMuted)(` ${hint}`),
'',
chalk.hex(colors.primary).bold(` ${this.opts.title}`) + titleSuffix,
];
if (searchable && view.query.length > 0) {
lines.push(chalk.hex(colors.primary)(` Search: `) + chalk.hex(colors.text)(view.query));
}
lines.push(chalk.hex(colors.textMuted)(` ${hint}`));
lines.push('');

for (let i = 0; i < this.opts.options.length; i++) {
const opt = this.opts.options[i]!;
const isSelected = i === this.selectedIndex;
if (options.length === 0) {
lines.push(chalk.hex(colors.textMuted)(' No matches'));
}
for (let i = view.page.start; i < view.page.end; i++) {
const opt = options[i]!;
const isSelected = i === view.selectedIndex;
const isCurrent = opt.value === this.opts.currentValue;
const pointer = isSelected ? '❯' : ' ';
const labelStyle = isSelected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
Expand All @@ -127,6 +157,13 @@ export class ChoicePickerComponent extends Container implements Focusable {
}

lines.push('');
if (view.page.pageCount > 1) {
lines.push(
chalk.hex(colors.textMuted)(
` Page ${String(view.page.page + 1)}/${String(view.page.pageCount)}`,
),
);
}
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
return lines.map((line) => truncateToWidth(line, width));
}
Expand Down
Loading
Loading