pi-provider-kimi-code 0.6.2 → 0.6.5

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.
package/index.ts CHANGED
@@ -15,66 +15,59 @@
15
15
  * login/refresh handlers, stream-level auth refresh
16
16
  * src/models.ts — /v1/models discovery + extras-merging helpers
17
17
  * src/payload.ts — payload pipeline + file upload + transforms
18
+ * src/project-trust.ts — project config approval compatibility helpers
18
19
  * src/stream.ts — empty-response filter + streamSimpleKimi orchestrator
19
20
  */
20
21
 
21
22
  import {
22
- AuthStorage,
23
23
  type ExtensionAPI,
24
24
  type ExtensionCommandContext,
25
25
  type ExtensionFactory,
26
26
  } from "@earendil-works/pi-coding-agent";
27
+ import { Input, SettingsList, type SettingItem, truncateToWidth } from "@earendil-works/pi-tui";
27
28
  import os from "node:os";
28
- import { relative } from "node:path";
29
29
 
30
30
  import {
31
31
  type KimiCodeConfig,
32
32
  type KimiCodeConfigPatch,
33
33
  KIMI_TOOL_NAMES,
34
- getProjectKimiCodeConfigPath,
35
- getGlobalKimiCodeConfigPath,
36
34
  loadHomeKimiCodeConfig,
37
35
  loadKimiCodeConfig,
38
- loadKimiCodeConfigSources,
39
36
  loadProjectKimiCodeConfig,
40
37
  saveHomeKimiCodeConfig,
41
38
  saveProjectKimiCodeConfig,
42
39
  type KimiToolName,
43
40
  } from "./src/config.ts";
44
41
  import { PROVIDER_ID, PROVIDER_VERSION, getBaseUrl, getKimiApiType } from "./src/constants.ts";
45
- import { getCommonHeaders } from "./src/device.ts";
46
42
  import {
47
43
  type KimiOAuthCredentials,
48
44
  type KimiOAuthExtras,
49
45
  buildKimiModelFromConfig,
50
46
  applyKimiOAuthExtrasToModel,
47
+ KIMI_CODING_HIGHSPEED_MODEL_ID,
48
+ KIMI_CODING_MODEL_ID,
51
49
  discoverKimiModelMetadata,
50
+ getKimiModelMetadata,
52
51
  resolveKimiModelConfig,
53
52
  } from "./src/models.ts";
54
- import { loginKimiCode, refreshKimiAuthToken, refreshKimiCodeToken } from "./src/oauth.ts";
53
+ import { getKimiApiKey, loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
54
+ import { isKimiProjectConfigApproved } from "./src/project-trust.ts";
55
+ import {
56
+ type KimiConfigScope,
57
+ buildSettingsTheme,
58
+ formatByteSize,
59
+ formatScopeDescription,
60
+ parseByteSizeInput,
61
+ } from "./src/settings-ui.ts";
55
62
  import { setStoreResolvedKimiConfig, streamSimpleKimi } from "./src/stream.ts";
63
+ import { fetchKimiUsageSummary, getKimiUsageToken } from "./src/usage.ts";
56
64
  import { buildMoonshotFetchTool, buildMoonshotSearchTool } from "./src/tools/moonshot.ts";
57
65
  import { buildKimiDatasourceTool } from "./src/tools/datasource.ts";
58
- type KimiConfigScope = "project" | "home";
59
- const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
60
- LEVEL_FREE: "Free",
61
- LEVEL_BASIC: "Adagio",
62
- LEVEL_STANDARD: "Moderato",
63
- LEVEL_INTERMEDIATE: "Allegretto",
64
- LEVEL_ADVANCED: "Allegro",
65
- LEVEL_PREMIUM: "Vivace",
66
- };
67
-
68
- interface UsageRow {
69
- label: string;
70
- used: number;
71
- limit: number;
72
- }
73
-
74
66
  interface KimiRuntimeState {
75
67
  cwd: string;
76
68
  config: KimiCodeConfig;
77
69
  modelExtras: KimiOAuthExtras;
70
+ projectTrusted: boolean;
78
71
  overrides?: KimiCodeConfigPatch;
79
72
  }
80
73
 
@@ -111,12 +104,23 @@ function registerConfiguredMoonshotTools(
111
104
  pi.setActiveTools([...activeTools]);
112
105
  }
113
106
 
114
- function reloadEffectiveKimiRuntimeConfig(state: KimiRuntimeState, cwd: string): KimiCodeConfig {
115
- const config = loadKimiCodeConfig({ cwd, home: os.homedir() }, state.overrides);
107
+ function reloadEffectiveKimiRuntimeConfig(
108
+ state: KimiRuntimeState,
109
+ cwd: string,
110
+ projectTrusted: boolean,
111
+ ): KimiCodeConfig {
112
+ const config = loadKimiCodeConfig(
113
+ { cwd, home: os.homedir(), includeProject: projectTrusted },
114
+ state.overrides,
115
+ );
116
116
  state.cwd = cwd;
117
117
  state.config = config;
118
+ state.projectTrusted = projectTrusted;
118
119
  setStoreResolvedKimiConfig({
119
- model: resolveKimiModelConfig(config.model, state.modelExtras),
120
+ model: resolveKimiModelConfig(
121
+ config.model,
122
+ getKimiModelMetadata(state.modelExtras, KIMI_CODING_MODEL_ID),
123
+ ),
120
124
  protocol: config.protocol,
121
125
  uploads: config.uploads,
122
126
  });
@@ -127,324 +131,225 @@ function applyEffectiveKimiRuntimeConfig(
127
131
  pi: ExtensionAPI,
128
132
  state: KimiRuntimeState,
129
133
  cwd: string,
130
- options: { updateActiveTools: boolean },
134
+ options: { updateActiveTools: boolean; projectTrusted: boolean },
131
135
  ): KimiCodeConfig {
132
- const config = reloadEffectiveKimiRuntimeConfig(state, cwd);
136
+ const config = reloadEffectiveKimiRuntimeConfig(state, cwd, options.projectTrusted);
133
137
  registerConfiguredMoonshotTools(pi, config, options);
134
138
  return config;
135
139
  }
136
140
 
137
- function getKimiUsageToken(): string | null {
138
- const credential = AuthStorage.create().get(PROVIDER_ID);
139
- if (credential?.type === "oauth" && credential.access) return credential.access;
140
- const apiKey = process.env.KIMI_API_KEY?.trim();
141
- return apiKey || null;
142
- }
143
-
144
- function toNumber(value: unknown): number | null {
145
- const number = Number(value);
146
- return Number.isFinite(number) ? number : null;
147
- }
148
-
149
- function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
150
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
151
- const record = value as Record<string, unknown>;
152
- const limit = toNumber(record.limit);
153
- const usedValue = toNumber(record.used);
154
- const remaining = toNumber(record.remaining);
155
- const used = usedValue ?? (limit !== null && remaining !== null ? limit - remaining : null);
156
- if (limit === null && used === null) return null;
157
- return {
158
- label: String(record.name || record.title || fallbackLabel),
159
- used: used ?? 0,
160
- limit: limit ?? 0,
161
- };
162
- }
163
-
164
- function parseUsageSummary(payload: unknown): string {
165
- if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
166
- return "Usage: unavailable";
167
- }
168
-
169
- const record = payload as Record<string, unknown>;
170
- const lines: string[] = [];
171
- const membership = parseMembership(record);
172
- if (membership) lines.push(membership);
173
-
174
- const rows: UsageRow[] = [];
175
- const summary = parseUsageRow(record.usage, "Weekly limit");
176
- if (summary) rows.push(summary);
177
-
178
- if (Array.isArray(record.limits)) {
179
- for (const [index, item] of record.limits.entries()) {
180
- const detail =
181
- typeof item === "object" && item !== null && !Array.isArray(item)
182
- ? ((item as Record<string, unknown>).detail ?? item)
183
- : item;
184
- const row = parseUsageRow(detail, index === 0 ? "5h rate limit" : `Limit #${index + 1}`);
185
- if (row) rows.push(row);
186
- }
187
- }
188
-
189
- lines.push(...rows.map(formatUsageRow));
190
- return lines.length === 0 ? "Usage: no usage data" : lines.join("\n");
191
- }
192
-
193
- function parseMembership(record: Record<string, unknown>): string | null {
194
- const user = record.user;
195
- if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
196
- const membership = (user as Record<string, unknown>).membership;
197
- if (typeof membership !== "object" || membership === null || Array.isArray(membership)) {
198
- return null;
199
- }
200
- const level = (membership as Record<string, unknown>).level;
201
- if (typeof level !== "string" || !level) return null;
202
- const name = MEMBERSHIP_LEVEL_NAMES[level];
203
- return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
204
- }
205
-
206
- function formatUsageRow(row: UsageRow): string {
207
- if (row.limit <= 0) return `${row.label}: ${row.used} used`;
208
- const remaining = Math.max(0, Math.min(row.limit - row.used, row.limit));
209
- const percent = Math.round((remaining / row.limit) * 100);
210
- return `${row.label}: ${quotaBar(remaining, row.limit)} ${percent}% left (${remaining}/${row.limit})`;
211
- }
212
-
213
- function quotaBar(remaining: number, limit: number): string {
214
- const width = 20;
215
- const filled = Math.max(0, Math.min(width, Math.round((remaining / limit) * width)));
216
- return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
217
- }
218
-
219
- function fetchKimiUsage(token: string, signal: AbortSignal): Promise<Response> {
220
- return fetch("https://api.kimi.com/coding/v1/usages", {
221
- method: "GET",
222
- headers: {
223
- ...getCommonHeaders(),
224
- Authorization: `Bearer ${token}`,
225
- },
226
- signal,
227
- });
228
- }
229
-
230
- async function fetchKimiUsageSummary(): Promise<string> {
231
- const token = getKimiUsageToken();
232
- if (!token) return "Usage: missing credentials. Run /login kimi-coding.";
233
-
234
- const controller = new AbortController();
235
- const timeout = setTimeout(() => controller.abort(), 15_000);
236
- try {
237
- let response = await fetchKimiUsage(token, controller.signal);
238
- if (response.status === 401) {
239
- const refreshed = await refreshKimiAuthToken(token);
240
- if (refreshed) response = await fetchKimiUsage(refreshed, controller.signal);
241
- }
242
- if (!response.ok) return `Usage: fetch failed (${response.status})`;
243
- return parseUsageSummary(await response.json());
244
- } catch (error) {
245
- const message = error instanceof Error ? error.message : String(error);
246
- return `Usage: fetch failed (${message})`;
247
- } finally {
248
- clearTimeout(timeout);
249
- }
250
- }
251
-
252
- function moonshotStatus(config: KimiCodeConfig): string {
253
- return KIMI_TOOL_NAMES.map((toolName) => {
254
- const tool = config.tools[toolName];
255
- const enabled = tool.enabled ? "enabled" : "disabled";
256
- const collapsed = tool.default_collapsed ? "collapsed" : "expanded";
257
- return `${toolName}: ${enabled}, default ${collapsed}`;
258
- }).join("\n");
259
- }
260
-
261
- function toggleEnabled(config: KimiCodeConfig, toolName: KimiToolName): KimiCodeConfig {
262
- return {
263
- ...config,
264
- tools: {
265
- ...config.tools,
266
- [toolName]: {
267
- ...config.tools[toolName],
268
- enabled: !config.tools[toolName].enabled,
269
- },
270
- },
271
- };
272
- }
273
-
274
- function toggleCollapsed(config: KimiCodeConfig, toolName: KimiToolName): KimiCodeConfig {
275
- return {
276
- ...config,
277
- tools: {
278
- ...config.tools,
279
- [toolName]: {
280
- ...config.tools[toolName],
281
- default_collapsed: !config.tools[toolName].default_collapsed,
282
- },
283
- },
284
- };
285
- }
286
-
287
- function setProtocol(config: KimiCodeConfig, protocol: KimiCodeConfig["protocol"]): KimiCodeConfig {
288
- return { ...config, protocol };
289
- }
290
-
291
- function setUploadThreshold(config: KimiCodeConfig, thresholdBytes: number): KimiCodeConfig {
292
- return { ...config, uploads: { ...config.uploads, thresholdBytes } };
293
- }
294
-
295
- async function refreshModelExtras(state: KimiRuntimeState): Promise<void> {
141
+ async function refreshModelExtras(state: KimiRuntimeState): Promise<boolean> {
296
142
  const token = getKimiUsageToken();
297
- if (!token) return;
143
+ if (!token) return false;
298
144
  const extras = await discoverKimiModelMetadata(token, state.config.protocol);
299
- if (Object.keys(extras).length > 0) {
300
- Object.assign(state.modelExtras, extras);
301
- }
145
+ if (Object.keys(extras).length === 0) return false;
146
+ Object.assign(state.modelExtras, extras);
147
+ return true;
302
148
  }
303
149
 
304
- async function runKimiCommand(
150
+ async function openSettingsMenu(
305
151
  pi: ExtensionAPI,
306
152
  ctx: ExtensionCommandContext,
307
153
  state: KimiRuntimeState,
308
154
  ): Promise<void> {
309
- let config = applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, { updateActiveTools: true });
310
- let [usage] = await Promise.all([fetchKimiUsageSummary(), refreshModelExtras(state)]);
311
- ctx.ui.notify(usage);
312
-
313
- while (true) {
314
- const choice = await ctx.ui.select(buildKimiMainTitle(config, ctx.cwd, state.modelExtras), [
315
- `Edit project config (${relative(ctx.cwd, getProjectKimiCodeConfigPath(ctx.cwd))})`,
316
- `Edit home config (${homeRelative(getGlobalKimiCodeConfigPath(os.homedir()))})`,
317
- "Refresh usage",
318
- "Done",
319
- ]);
320
-
321
- if (!choice || choice === "Done") return;
322
- if (choice === "Refresh usage") {
323
- usage = await fetchKimiUsageSummary();
324
- ctx.ui.notify(usage);
325
- continue;
326
- }
327
- if (choice.startsWith("Edit project config")) {
328
- config = await editConfigScope(pi, ctx, state, "project");
329
- } else if (choice.startsWith("Edit home config")) {
330
- config = await editConfigScope(pi, ctx, state, "home");
331
- }
332
- }
333
- }
334
-
335
- async function editConfigScope(
336
- pi: ExtensionAPI,
337
- ctx: ExtensionCommandContext,
338
- state: KimiRuntimeState,
339
- scope: KimiConfigScope,
340
- ): Promise<KimiCodeConfig> {
341
- let current = loadScopeKimiCodeConfig(scope, ctx.cwd);
342
- while (true) {
343
- const items = KIMI_TOOL_NAMES.map((name) => toolMenuItem(current, name));
344
- items.push(protocolMenuItem(current), uploadThresholdMenuItem(current), "Back");
345
- const choice = await ctx.ui.select(buildConfigScopeTitle(scope, current, ctx.cwd), items);
346
- if (!choice || choice === "Back") {
347
- return loadKimiCodeConfig({ cwd: ctx.cwd, home: os.homedir() });
348
- }
349
- const toolName = KIMI_TOOL_NAMES.find((name) => choice.startsWith(name));
350
- if (toolName) {
351
- current = await editMoonshotTool(pi, ctx, state, scope, current, toolName);
352
- } else if (choice.startsWith("Protocol")) {
353
- current = await editProtocol(pi, ctx, state, scope, current);
354
- } else if (choice.startsWith("Upload threshold")) {
355
- current = await editUploadThreshold(pi, ctx, state, scope, current);
356
- }
357
- }
358
- }
359
-
360
- async function editProtocol(
361
- pi: ExtensionAPI,
362
- ctx: ExtensionCommandContext,
363
- state: KimiRuntimeState,
364
- scope: KimiConfigScope,
365
- config: KimiCodeConfig,
366
- ): Promise<KimiCodeConfig> {
367
- const choice = await ctx.ui.select(`Edit protocol\n\n${formatProtocolStatus(config)}`, [
368
- "Use openai protocol",
369
- "Use anthropic protocol",
370
- "Back",
155
+ const [usage, modelsRefreshed] = await Promise.all([
156
+ fetchKimiUsageSummary(),
157
+ refreshModelExtras(state),
371
158
  ]);
372
- if (!choice || choice === "Back") return config;
373
- const protocol = choice.includes("anthropic") ? "anthropic" : "openai";
374
- const current = setProtocol(config, protocol);
375
- saveAndApplyKimiCodeConfig(pi, ctx, state, scope, current);
376
- ctx.ui.notify("Saved protocol config", "info");
377
- return current;
378
- }
379
-
380
- async function editUploadThreshold(
381
- pi: ExtensionAPI,
382
- ctx: ExtensionCommandContext,
383
- state: KimiRuntimeState,
384
- scope: KimiConfigScope,
385
- config: KimiCodeConfig,
386
- ): Promise<KimiCodeConfig> {
387
- const input = await ctx.ui.input(
388
- "Edit upload threshold\n\nExamples: 512 KiB, 2 MiB, 1.5 MB. Plain numbers are MiB.",
389
- formatByteSize(config.uploads.thresholdBytes),
390
- );
391
- if (input === undefined) return config;
392
- const thresholdBytes = parseByteSizeInput(input);
393
- if (thresholdBytes === undefined || thresholdBytes <= 0) {
394
- ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
395
- return config;
396
- }
397
- const current = setUploadThreshold(config, thresholdBytes);
398
- saveAndApplyKimiCodeConfig(pi, ctx, state, scope, current);
399
- ctx.ui.notify("Saved upload threshold config", "info");
400
- return current;
401
- }
159
+ if (modelsRefreshed) registerKimiProvider(pi, state);
402
160
 
403
- async function editMoonshotTool(
404
- pi: ExtensionAPI,
405
- ctx: ExtensionCommandContext,
406
- state: KimiRuntimeState,
407
- scope: KimiConfigScope,
408
- config: KimiCodeConfig,
409
- toolName: KimiToolName,
410
- ): Promise<KimiCodeConfig> {
411
- let current = config;
412
- while (true) {
413
- const tool = current.tools[toolName];
414
- const choice = await ctx.ui.select(
415
- `Edit ${toolName}\n\n${formatToolStatus(current, toolName)}`,
416
- [
417
- tool.enabled ? `Disable ${toolName}` : `Enable ${toolName}`,
418
- tool.default_collapsed ? "Expand previews by default" : "Collapse previews by default",
419
- "Back",
420
- ],
421
- );
422
- if (!choice || choice === "Back") return current;
423
- if (choice.startsWith("Enable") || choice.startsWith("Disable")) {
424
- current = toggleEnabled(current, toolName);
425
- } else if (choice.endsWith("previews by default")) {
426
- current = toggleCollapsed(current, toolName);
161
+ const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
162
+ const homeDraft = loadHomeKimiCodeConfig(os.homedir());
163
+ const drafts: Record<KimiConfigScope, KimiCodeConfig> = {
164
+ project: projectTrusted ? loadProjectKimiCodeConfig(ctx.cwd) : homeDraft,
165
+ home: homeDraft,
166
+ };
167
+ let scope: KimiConfigScope = projectTrusted ? "project" : "home";
168
+ let dirty = false;
169
+
170
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
171
+ const settingsTheme = buildSettingsTheme(theme);
172
+
173
+ let list: SettingsList;
174
+
175
+ const toolLabel = (toolName: KimiToolName) => {
176
+ if (toolName === "moonshot_search") return "Search tool";
177
+ if (toolName === "moonshot_fetch") return "Fetch tool";
178
+ return "Real-world data API";
179
+ };
180
+
181
+ const formatToolMenuValue = (toolName: KimiToolName) => {
182
+ const tool = drafts[scope].tools[toolName];
183
+ if (!tool.enabled) return "disabled";
184
+ return tool.default_collapsed ? "enabled without preview" : "enabled with preview";
185
+ };
186
+
187
+ const refreshDisplays = () => {
188
+ scopeItem.description = formatScopeDescription(scope, ctx.cwd);
189
+ for (const toolName of KIMI_TOOL_NAMES) {
190
+ list.updateValue(toolName, formatToolMenuValue(toolName));
191
+ }
192
+ list.updateValue("protocol", drafts[scope].protocol);
193
+ list.updateValue("uploadThreshold", formatByteSize(drafts[scope].uploads.thresholdBytes));
194
+ };
195
+
196
+ const save = () => {
197
+ if (scope === "project" && !projectTrusted) {
198
+ ctx.ui.notify("Project config cannot be saved until the project is trusted.", "warning");
199
+ return;
200
+ }
201
+ try {
202
+ saveScopeKimiCodeConfig(scope, ctx.cwd, drafts[scope]);
203
+ applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, {
204
+ updateActiveTools: true,
205
+ projectTrusted: scope === "project" ? true : projectTrusted,
206
+ });
207
+ dirty = true;
208
+ } catch (error: unknown) {
209
+ ctx.ui.notify((error as Error).message, "error");
210
+ }
211
+ };
212
+
213
+ const onChange = (id: string, newValue: string) => {
214
+ if (id === "scope") {
215
+ scope = newValue as KimiConfigScope;
216
+ refreshDisplays();
217
+ return;
218
+ }
219
+ if (id === "protocol") {
220
+ drafts[scope].protocol = newValue as KimiCodeConfig["protocol"];
221
+ list.updateValue(id, newValue);
222
+ save();
223
+ return;
224
+ }
225
+ if (id === "uploadThreshold") {
226
+ const bytes = parseByteSizeInput(newValue);
227
+ if (bytes === undefined || bytes <= 0) {
228
+ ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
229
+ return;
230
+ }
231
+ drafts[scope].uploads.thresholdBytes = bytes;
232
+ list.updateValue(id, formatByteSize(bytes));
233
+ save();
234
+ return;
235
+ }
236
+ const toolMatch = /^(?<tool>.+):(?<field>enabled|collapsed)$/.exec(id);
237
+ if (toolMatch?.groups) {
238
+ const toolName = toolMatch.groups.tool as KimiToolName;
239
+ const field = toolMatch.groups.field as "enabled" | "collapsed";
240
+ if (field === "enabled") {
241
+ drafts[scope].tools[toolName].enabled = newValue === "true";
242
+ } else {
243
+ drafts[scope].tools[toolName].default_collapsed = newValue !== "true";
244
+ }
245
+ list.updateValue(toolName, formatToolMenuValue(toolName));
246
+ save();
247
+ }
248
+ };
249
+
250
+ const scopeItem: SettingItem = {
251
+ id: "scope",
252
+ label: "Config scope",
253
+ description: projectTrusted
254
+ ? formatScopeDescription(scope, ctx.cwd)
255
+ : "Project config disabled until the project is trusted; editing home config only",
256
+ currentValue: scope,
257
+ values: projectTrusted ? ["project", "home"] : ["home"],
258
+ };
259
+
260
+ const items: SettingItem[] = [scopeItem];
261
+ for (const toolName of KIMI_TOOL_NAMES) {
262
+ items.push({
263
+ id: toolName,
264
+ label: toolLabel(toolName),
265
+ description: `Configure ${toolName} registration and preview defaults`,
266
+ currentValue: formatToolMenuValue(toolName),
267
+ submenu: (_current, submenuDone) =>
268
+ new SettingsList(
269
+ [
270
+ {
271
+ id: `${toolName}:enabled`,
272
+ label: "Enabled",
273
+ description: `Register ${toolName} at session start`,
274
+ currentValue: String(drafts[scope].tools[toolName].enabled),
275
+ values: ["true", "false"],
276
+ },
277
+ {
278
+ id: `${toolName}:collapsed`,
279
+ label: "Show preview",
280
+ description: `Show ${toolName} result previews by default`,
281
+ currentValue: String(!drafts[scope].tools[toolName].default_collapsed),
282
+ values: ["true", "false"],
283
+ },
284
+ ],
285
+ 2,
286
+ settingsTheme,
287
+ onChange,
288
+ () => submenuDone(),
289
+ ),
290
+ });
427
291
  }
428
- saveAndApplyKimiCodeConfig(pi, ctx, state, scope, current);
429
- ctx.ui.notify(`Saved ${toolName} config`, "info");
430
- return current;
431
- }
432
- }
292
+ items.push({
293
+ id: "protocol",
294
+ label: "Protocol",
295
+ description: "API protocol for Kimi requests",
296
+ currentValue: drafts[scope].protocol,
297
+ values: ["openai", "anthropic"],
298
+ });
299
+ items.push({
300
+ id: "uploadThreshold",
301
+ label: "Upload threshold",
302
+ description: "Max size for inline file uploads",
303
+ currentValue: formatByteSize(drafts[scope].uploads.thresholdBytes),
304
+ submenu: (_current, submenuDone) => {
305
+ const input = new Input();
306
+ input.setValue(formatByteSize(drafts[scope].uploads.thresholdBytes));
307
+ input.onSubmit = (value) => {
308
+ const bytes = parseByteSizeInput(value);
309
+ if (bytes === undefined || bytes <= 0) {
310
+ ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
311
+ submenuDone();
312
+ return;
313
+ }
314
+ submenuDone(formatByteSize(bytes));
315
+ };
316
+ input.onEscape = () => submenuDone();
317
+ return input;
318
+ },
319
+ });
433
320
 
434
- function saveAndApplyKimiCodeConfig(
435
- pi: ExtensionAPI,
436
- ctx: ExtensionCommandContext,
437
- state: KimiRuntimeState,
438
- scope: KimiConfigScope,
439
- config: KimiCodeConfig,
440
- ): void {
441
- saveScopeKimiCodeConfig(scope, ctx.cwd, config);
442
- applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, { updateActiveTools: true });
443
- }
321
+ list = new SettingsList(items, items.length, settingsTheme, onChange, () => done(), {
322
+ enableSearch: true,
323
+ });
324
+
325
+ return {
326
+ items,
327
+ onChange,
328
+ render(width: number) {
329
+ const usageLines = usage.split("\n").map((line) => truncateToWidth(` ${line}`, width));
330
+ return [
331
+ truncateToWidth(
332
+ theme.fg("accent", theme.bold(`Kimi settings (provider v${PROVIDER_VERSION})`)),
333
+ width,
334
+ ),
335
+ "",
336
+ truncateToWidth(theme.fg("accent", theme.bold("Kimi usage")), width),
337
+ ...usageLines,
338
+ "",
339
+ ...list.render(width),
340
+ ];
341
+ },
342
+ handleInput(data: string) {
343
+ list.handleInput?.(data);
344
+ tui.requestRender();
345
+ },
346
+ invalidate() {
347
+ list.invalidate();
348
+ },
349
+ };
350
+ });
444
351
 
445
- function loadScopeKimiCodeConfig(scope: KimiConfigScope, cwd: string): KimiCodeConfig {
446
- if (scope === "project") return loadProjectKimiCodeConfig(cwd);
447
- return loadHomeKimiCodeConfig(os.homedir());
352
+ if (dirty) await ctx.reload();
448
353
  }
449
354
 
450
355
  function saveScopeKimiCodeConfig(
@@ -459,167 +364,101 @@ function saveScopeKimiCodeConfig(
459
364
  }
460
365
  }
461
366
 
462
- function buildKimiMainTitle(config: KimiCodeConfig, cwd: string, extras: KimiOAuthExtras): string {
463
- const sources = loadKimiCodeConfigSources({ cwd, home: os.homedir() });
464
- const modelName = extras.modelDisplay || "kimi-for-coding";
465
- return [
466
- `Kimi settings (provider v${PROVIDER_VERSION})`,
467
- "",
468
- `Model: ${modelName}`,
469
- `Protocol: ${config.protocol} (${sources.protocol})`,
470
- `Upload threshold: ${formatByteSize(config.uploads.thresholdBytes)} (${sources.uploads.thresholdBytes})`,
471
- "",
472
- "Effective tools:",
473
- ...KIMI_TOOL_NAMES.map((toolName) => {
474
- const enabled = config.tools[toolName].enabled ? "enabled" : "disabled";
475
- return `- ${toolName}: ${enabled} (${sources.tools[toolName].enabled})`;
476
- }),
477
- ].join("\n");
478
- }
479
-
480
- function buildConfigScopeTitle(
481
- scope: KimiConfigScope,
482
- config: KimiCodeConfig,
483
- cwd: string,
484
- ): string {
485
- const path =
486
- scope === "project"
487
- ? relative(cwd, getProjectKimiCodeConfigPath(cwd))
488
- : homeRelative(getGlobalKimiCodeConfigPath(os.homedir()));
489
- return [
490
- `Edit ${scope} config`,
491
- `File: ${path}`,
492
- "",
493
- formatProtocolStatus(config),
494
- formatUploadThresholdStatus(config),
495
- "",
496
- moonshotStatus(config),
497
- ].join("\n");
498
- }
499
-
500
- function homeRelative(filePath: string): string {
501
- const home = os.homedir();
502
- return filePath.startsWith(`${home}/`) ? `~/${filePath.slice(home.length + 1)}` : filePath;
503
- }
504
-
505
- function toolMenuItem(config: KimiCodeConfig, toolName: KimiToolName): string {
506
- return `${toolName} -> ${formatToolStatus(config, toolName)}`;
507
- }
508
-
509
- function protocolMenuItem(config: KimiCodeConfig): string {
510
- return `Protocol -> ${config.protocol}`;
511
- }
512
-
513
- function uploadThresholdMenuItem(config: KimiCodeConfig): string {
514
- return `Upload threshold -> ${formatByteSize(config.uploads.thresholdBytes)}`;
515
- }
516
-
517
- function formatByteSize(bytes: number): string {
518
- const units = [
519
- { suffix: "GiB", size: 1024 ** 3 },
520
- { suffix: "MiB", size: 1024 ** 2 },
521
- { suffix: "KiB", size: 1024 },
522
- ];
523
- for (const unit of units) {
524
- if (bytes >= unit.size && bytes % unit.size === 0) {
525
- return `${bytes / unit.size} ${unit.suffix}`;
526
- }
527
- }
528
- for (const unit of units) {
529
- if (bytes >= unit.size) {
530
- return `${(bytes / unit.size).toFixed(2).replace(/\.00$/, "")} ${unit.suffix}`;
531
- }
532
- }
533
- return `${bytes} B`;
534
- }
535
-
536
- function parseByteSizeInput(input: string): number | undefined {
537
- const match = input.trim().match(/^(\d+(?:\.\d+)?)\s*(b|bytes?|kib|kb|mib|mb|gib|gb)?$/i);
538
- if (!match) return undefined;
539
- const value = Number(match[1]);
540
- if (!Number.isFinite(value) || value <= 0) return undefined;
541
- const unit = (match[2] ?? "mib").toLowerCase();
542
- const multipliers: Record<string, number> = {
543
- b: 1,
544
- byte: 1,
545
- bytes: 1,
546
- kib: 1024,
547
- kb: 1000,
548
- mib: 1024 ** 2,
549
- mb: 1000 ** 2,
550
- gib: 1024 ** 3,
551
- gb: 1000 ** 3,
552
- };
553
- const multiplier = multipliers[unit];
554
- if (!multiplier) return undefined;
555
- const bytes = Math.round(value * multiplier);
556
- return Number.isSafeInteger(bytes) && bytes > 0 ? bytes : undefined;
367
+ function filterAvailableKimiModels<T extends { id: string }>(
368
+ models: T[],
369
+ extras: KimiOAuthExtras,
370
+ ): T[] {
371
+ const available = extras.modelCatalog ? new Set(Object.keys(extras.modelCatalog)) : null;
372
+ return available ? models.filter((model) => available.has(model.id)) : models;
557
373
  }
558
374
 
559
- function formatProtocolStatus(config: KimiCodeConfig): string {
560
- return `protocol: ${config.protocol}`;
561
- }
562
-
563
- function formatUploadThresholdStatus(config: KimiCodeConfig): string {
564
- return `upload threshold: ${formatByteSize(config.uploads.thresholdBytes)}`;
565
- }
375
+ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
376
+ const standardModel = applyKimiOAuthExtrasToModel(
377
+ buildKimiModelFromConfig(state.config.model),
378
+ getKimiModelMetadata(state.modelExtras, KIMI_CODING_MODEL_ID),
379
+ );
380
+ const highSpeedModel = applyKimiOAuthExtrasToModel(
381
+ buildKimiModelFromConfig(state.config.model, KIMI_CODING_HIGHSPEED_MODEL_ID),
382
+ getKimiModelMetadata(state.modelExtras, KIMI_CODING_HIGHSPEED_MODEL_ID),
383
+ );
566
384
 
567
- function formatToolStatus(config: KimiCodeConfig, toolName: KimiToolName): string {
568
- const tool = config.tools[toolName];
569
- const enabled = tool.enabled ? "enabled" : "disabled";
570
- const collapsed = tool.default_collapsed ? "default collapsed" : "default expanded";
571
- return `${enabled}, ${collapsed}`;
385
+ pi.registerProvider(PROVIDER_ID, {
386
+ baseUrl: getBaseUrl(state.config.protocol),
387
+ apiKey: "$KIMI_API_KEY",
388
+ api: getKimiApiType(state.config.protocol),
389
+ streamSimple: streamSimpleKimi,
390
+
391
+ models: filterAvailableKimiModels([standardModel, highSpeedModel], state.modelExtras),
392
+
393
+ oauth: {
394
+ name: "Kimi Code (OAuth)",
395
+ login: loginKimiCode,
396
+ refreshToken: refreshKimiCodeToken,
397
+ getApiKey: getKimiApiKey,
398
+ // Reflect server-side model identity on the registered model after login
399
+ // / refresh. We never rewrite the model id (pi-side `/model` selections
400
+ // and persisted sessions reference it); only the human-facing name, the
401
+ // context window, and an out-of-band `wireModelId` carried into the
402
+ // request payload by streamSimpleKimi.
403
+ modifyModels: (models, cred) => {
404
+ const extras = cred as KimiOAuthCredentials;
405
+ state.modelExtras = extras;
406
+ reloadEffectiveKimiRuntimeConfig(state, state.cwd, state.projectTrusted);
407
+ const available = extras.modelCatalog ? new Set(Object.keys(extras.modelCatalog)) : null;
408
+ return models
409
+ .filter(
410
+ (model) => model.provider !== PROVIDER_ID || !available || available.has(model.id),
411
+ )
412
+ .map((model) =>
413
+ model.provider === PROVIDER_ID
414
+ ? applyKimiOAuthExtrasToModel(model, getKimiModelMetadata(extras, model.id))
415
+ : model,
416
+ );
417
+ },
418
+ },
419
+ });
572
420
  }
573
421
 
574
422
  export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
575
423
  return async (pi: ExtensionAPI) => {
576
424
  const cwd = process.cwd();
577
- const config = loadKimiCodeConfig({ cwd, home: os.homedir() }, overrides);
578
- const baseModel = buildKimiModelFromConfig(config.model);
425
+ const config = loadKimiCodeConfig(
426
+ { cwd, home: os.homedir(), includeProject: false },
427
+ overrides,
428
+ );
579
429
  const discoveryToken = getKimiUsageToken();
580
430
  const discovered = discoveryToken
581
431
  ? await discoverKimiModelMetadata(discoveryToken, config.protocol)
582
432
  : {};
583
- const state: KimiRuntimeState = { cwd, config, modelExtras: discovered, overrides };
584
- reloadEffectiveKimiRuntimeConfig(state, cwd);
585
- const model = applyKimiOAuthExtrasToModel(baseModel, discovered);
586
-
587
- pi.registerProvider(PROVIDER_ID, {
588
- baseUrl: getBaseUrl(config.protocol),
589
- apiKey: "$KIMI_API_KEY",
590
- api: getKimiApiType(config.protocol),
591
- streamSimple: streamSimpleKimi,
592
-
593
- models: [model],
594
-
595
- oauth: {
596
- name: "Kimi Code (OAuth)",
597
- login: loginKimiCode,
598
- refreshToken: refreshKimiCodeToken,
599
- getApiKey: (cred) => cred.access,
600
- // Reflect server-side model identity on the registered model after login
601
- // / refresh. We never rewrite the model id (pi-side `/model` selections
602
- // and persisted sessions reference it); only the human-facing name, the
603
- // context window, and an out-of-band `wireModelId` carried into the
604
- // request payload by streamSimpleKimi.
605
- modifyModels: (models, cred) => {
606
- const extras = cred as KimiOAuthCredentials;
607
- state.modelExtras = extras;
608
- reloadEffectiveKimiRuntimeConfig(state, state.cwd);
609
- return models.map((model) => {
610
- if (model.id !== "kimi-for-coding") return model;
611
- return applyKimiOAuthExtrasToModel(model, extras);
612
- });
613
- },
614
- },
615
- });
433
+ const state: KimiRuntimeState = {
434
+ cwd,
435
+ config,
436
+ modelExtras: discovered,
437
+ projectTrusted: false,
438
+ overrides,
439
+ };
440
+ reloadEffectiveKimiRuntimeConfig(state, cwd, false);
441
+ registerKimiProvider(pi, state);
616
442
 
617
443
  registerConfiguredMoonshotTools(pi, state.config, { updateActiveTools: false });
618
444
 
445
+ pi.on("session_start", async (_event, ctx) => {
446
+ const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
447
+ applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, {
448
+ updateActiveTools: true,
449
+ projectTrusted,
450
+ });
451
+ registerKimiProvider(pi, state);
452
+ });
453
+
619
454
  pi.registerCommand("kimi-settings", {
620
455
  description: "Show Kimi usage and configure optional Kimi tools",
621
456
  handler: async (_args, ctx) => {
622
- await runKimiCommand(pi, ctx, state);
457
+ if (ctx.mode !== "tui") {
458
+ ctx.ui.notify("/kimi-settings requires TUI mode", "error");
459
+ return;
460
+ }
461
+ await openSettingsMenu(pi, ctx, state);
623
462
  },
624
463
  });
625
464
  };