pi-provider-kimi-code 0.6.2 → 0.6.3

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,34 +15,30 @@
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,
@@ -51,30 +47,24 @@ import {
51
47
  discoverKimiModelMetadata,
52
48
  resolveKimiModelConfig,
53
49
  } from "./src/models.ts";
54
- import { loginKimiCode, refreshKimiAuthToken, refreshKimiCodeToken } from "./src/oauth.ts";
50
+ import { loginKimiCode, refreshKimiCodeToken } from "./src/oauth.ts";
51
+ import { isKimiProjectConfigApproved } from "./src/project-trust.ts";
52
+ import {
53
+ type KimiConfigScope,
54
+ buildSettingsTheme,
55
+ formatByteSize,
56
+ formatScopeDescription,
57
+ parseByteSizeInput,
58
+ } from "./src/settings-ui.ts";
55
59
  import { setStoreResolvedKimiConfig, streamSimpleKimi } from "./src/stream.ts";
60
+ import { fetchKimiUsageSummary, getKimiUsageToken } from "./src/usage.ts";
56
61
  import { buildMoonshotFetchTool, buildMoonshotSearchTool } from "./src/tools/moonshot.ts";
57
62
  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
63
  interface KimiRuntimeState {
75
64
  cwd: string;
76
65
  config: KimiCodeConfig;
77
66
  modelExtras: KimiOAuthExtras;
67
+ projectTrusted: boolean;
78
68
  overrides?: KimiCodeConfigPatch;
79
69
  }
80
70
 
@@ -111,10 +101,18 @@ function registerConfiguredMoonshotTools(
111
101
  pi.setActiveTools([...activeTools]);
112
102
  }
113
103
 
114
- function reloadEffectiveKimiRuntimeConfig(state: KimiRuntimeState, cwd: string): KimiCodeConfig {
115
- const config = loadKimiCodeConfig({ cwd, home: os.homedir() }, state.overrides);
104
+ function reloadEffectiveKimiRuntimeConfig(
105
+ state: KimiRuntimeState,
106
+ cwd: string,
107
+ projectTrusted: boolean,
108
+ ): KimiCodeConfig {
109
+ const config = loadKimiCodeConfig(
110
+ { cwd, home: os.homedir(), includeProject: projectTrusted },
111
+ state.overrides,
112
+ );
116
113
  state.cwd = cwd;
117
114
  state.config = config;
115
+ state.projectTrusted = projectTrusted;
118
116
  setStoreResolvedKimiConfig({
119
117
  model: resolveKimiModelConfig(config.model, state.modelExtras),
120
118
  protocol: config.protocol,
@@ -127,171 +125,13 @@ function applyEffectiveKimiRuntimeConfig(
127
125
  pi: ExtensionAPI,
128
126
  state: KimiRuntimeState,
129
127
  cwd: string,
130
- options: { updateActiveTools: boolean },
128
+ options: { updateActiveTools: boolean; projectTrusted: boolean },
131
129
  ): KimiCodeConfig {
132
- const config = reloadEffectiveKimiRuntimeConfig(state, cwd);
130
+ const config = reloadEffectiveKimiRuntimeConfig(state, cwd, options.projectTrusted);
133
131
  registerConfiguredMoonshotTools(pi, config, options);
134
132
  return config;
135
133
  }
136
134
 
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
135
  async function refreshModelExtras(state: KimiRuntimeState): Promise<void> {
296
136
  const token = getKimiUsageToken();
297
137
  if (!token) return;
@@ -301,150 +141,205 @@ async function refreshModelExtras(state: KimiRuntimeState): Promise<void> {
301
141
  }
302
142
  }
303
143
 
304
- async function runKimiCommand(
144
+ async function openSettingsMenu(
305
145
  pi: ExtensionAPI,
306
146
  ctx: ExtensionCommandContext,
307
147
  state: KimiRuntimeState,
308
148
  ): 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
- }
149
+ const [usage] = await Promise.all([fetchKimiUsageSummary(), refreshModelExtras(state)]);
334
150
 
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);
151
+ const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
152
+ const homeDraft = loadHomeKimiCodeConfig(os.homedir());
153
+ const drafts: Record<KimiConfigScope, KimiCodeConfig> = {
154
+ project: projectTrusted ? loadProjectKimiCodeConfig(ctx.cwd) : homeDraft,
155
+ home: homeDraft,
156
+ };
157
+ let scope: KimiConfigScope = projectTrusted ? "project" : "home";
158
+ let dirty = false;
159
+
160
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
161
+ const settingsTheme = buildSettingsTheme(theme);
162
+
163
+ let list: SettingsList;
164
+
165
+ const toolLabel = (toolName: KimiToolName) => {
166
+ if (toolName === "moonshot_search") return "Search tool";
167
+ if (toolName === "moonshot_fetch") return "Fetch tool";
168
+ return "Real-world data API";
169
+ };
170
+
171
+ const formatToolMenuValue = (toolName: KimiToolName) => {
172
+ const tool = drafts[scope].tools[toolName];
173
+ if (!tool.enabled) return "disabled";
174
+ return tool.default_collapsed ? "enabled without preview" : "enabled with preview";
175
+ };
176
+
177
+ const refreshDisplays = () => {
178
+ scopeItem.description = formatScopeDescription(scope, ctx.cwd);
179
+ for (const toolName of KIMI_TOOL_NAMES) {
180
+ list.updateValue(toolName, formatToolMenuValue(toolName));
181
+ }
182
+ list.updateValue("protocol", drafts[scope].protocol);
183
+ list.updateValue("uploadThreshold", formatByteSize(drafts[scope].uploads.thresholdBytes));
184
+ };
185
+
186
+ const save = () => {
187
+ if (scope === "project" && !projectTrusted) {
188
+ ctx.ui.notify("Project config cannot be saved until the project is trusted.", "warning");
189
+ return;
190
+ }
191
+ try {
192
+ saveScopeKimiCodeConfig(scope, ctx.cwd, drafts[scope]);
193
+ applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, {
194
+ updateActiveTools: true,
195
+ projectTrusted: scope === "project" ? true : projectTrusted,
196
+ });
197
+ dirty = true;
198
+ } catch (error: unknown) {
199
+ ctx.ui.notify((error as Error).message, "error");
200
+ }
201
+ };
202
+
203
+ const onChange = (id: string, newValue: string) => {
204
+ if (id === "scope") {
205
+ scope = newValue as KimiConfigScope;
206
+ refreshDisplays();
207
+ return;
208
+ }
209
+ if (id === "protocol") {
210
+ drafts[scope].protocol = newValue as KimiCodeConfig["protocol"];
211
+ list.updateValue(id, newValue);
212
+ save();
213
+ return;
214
+ }
215
+ if (id === "uploadThreshold") {
216
+ const bytes = parseByteSizeInput(newValue);
217
+ if (bytes === undefined || bytes <= 0) {
218
+ ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
219
+ return;
220
+ }
221
+ drafts[scope].uploads.thresholdBytes = bytes;
222
+ list.updateValue(id, formatByteSize(bytes));
223
+ save();
224
+ return;
225
+ }
226
+ const toolMatch = /^(?<tool>.+):(?<field>enabled|collapsed)$/.exec(id);
227
+ if (toolMatch?.groups) {
228
+ const toolName = toolMatch.groups.tool as KimiToolName;
229
+ const field = toolMatch.groups.field as "enabled" | "collapsed";
230
+ if (field === "enabled") {
231
+ drafts[scope].tools[toolName].enabled = newValue === "true";
232
+ } else {
233
+ drafts[scope].tools[toolName].default_collapsed = newValue !== "true";
234
+ }
235
+ list.updateValue(toolName, formatToolMenuValue(toolName));
236
+ save();
237
+ }
238
+ };
239
+
240
+ const scopeItem: SettingItem = {
241
+ id: "scope",
242
+ label: "Config scope",
243
+ description: projectTrusted
244
+ ? formatScopeDescription(scope, ctx.cwd)
245
+ : "Project config disabled until the project is trusted; editing home config only",
246
+ currentValue: scope,
247
+ values: projectTrusted ? ["project", "home"] : ["home"],
248
+ };
249
+
250
+ const items: SettingItem[] = [scopeItem];
251
+ for (const toolName of KIMI_TOOL_NAMES) {
252
+ items.push({
253
+ id: toolName,
254
+ label: toolLabel(toolName),
255
+ description: `Configure ${toolName} registration and preview defaults`,
256
+ currentValue: formatToolMenuValue(toolName),
257
+ submenu: (_current, submenuDone) =>
258
+ new SettingsList(
259
+ [
260
+ {
261
+ id: `${toolName}:enabled`,
262
+ label: "Enabled",
263
+ description: `Register ${toolName} at session start`,
264
+ currentValue: String(drafts[scope].tools[toolName].enabled),
265
+ values: ["true", "false"],
266
+ },
267
+ {
268
+ id: `${toolName}:collapsed`,
269
+ label: "Show preview",
270
+ description: `Show ${toolName} result previews by default`,
271
+ currentValue: String(!drafts[scope].tools[toolName].default_collapsed),
272
+ values: ["true", "false"],
273
+ },
274
+ ],
275
+ 2,
276
+ settingsTheme,
277
+ onChange,
278
+ () => submenuDone(),
279
+ ),
280
+ });
356
281
  }
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",
371
- ]);
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
- }
282
+ items.push({
283
+ id: "protocol",
284
+ label: "Protocol",
285
+ description: "API protocol for Kimi requests",
286
+ currentValue: drafts[scope].protocol,
287
+ values: ["openai", "anthropic"],
288
+ });
289
+ items.push({
290
+ id: "uploadThreshold",
291
+ label: "Upload threshold",
292
+ description: "Max size for inline file uploads",
293
+ currentValue: formatByteSize(drafts[scope].uploads.thresholdBytes),
294
+ submenu: (_current, submenuDone) => {
295
+ const input = new Input();
296
+ input.setValue(formatByteSize(drafts[scope].uploads.thresholdBytes));
297
+ input.onSubmit = (value) => {
298
+ const bytes = parseByteSizeInput(value);
299
+ if (bytes === undefined || bytes <= 0) {
300
+ ctx.ui.notify("Upload threshold must be a positive size, for example 2 MiB", "error");
301
+ submenuDone();
302
+ return;
303
+ }
304
+ submenuDone(formatByteSize(bytes));
305
+ };
306
+ input.onEscape = () => submenuDone();
307
+ return input;
308
+ },
309
+ });
402
310
 
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);
427
- }
428
- saveAndApplyKimiCodeConfig(pi, ctx, state, scope, current);
429
- ctx.ui.notify(`Saved ${toolName} config`, "info");
430
- return current;
431
- }
432
- }
311
+ list = new SettingsList(items, items.length, settingsTheme, onChange, () => done(), {
312
+ enableSearch: true,
313
+ });
433
314
 
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
- }
315
+ return {
316
+ items,
317
+ onChange,
318
+ render(width: number) {
319
+ const usageLines = usage.split("\n").map((line) => truncateToWidth(` ${line}`, width));
320
+ return [
321
+ truncateToWidth(
322
+ theme.fg("accent", theme.bold(`Kimi settings (provider v${PROVIDER_VERSION})`)),
323
+ width,
324
+ ),
325
+ "",
326
+ truncateToWidth(theme.fg("accent", theme.bold("Kimi usage")), width),
327
+ ...usageLines,
328
+ "",
329
+ ...list.render(width),
330
+ ];
331
+ },
332
+ handleInput(data: string) {
333
+ list.handleInput?.(data);
334
+ tui.requestRender();
335
+ },
336
+ invalidate() {
337
+ list.invalidate();
338
+ },
339
+ };
340
+ });
444
341
 
445
- function loadScopeKimiCodeConfig(scope: KimiConfigScope, cwd: string): KimiCodeConfig {
446
- if (scope === "project") return loadProjectKimiCodeConfig(cwd);
447
- return loadHomeKimiCodeConfig(os.homedir());
342
+ if (dirty) await ctx.reload();
448
343
  }
449
344
 
450
345
  function saveScopeKimiCodeConfig(
@@ -459,167 +354,83 @@ function saveScopeKimiCodeConfig(
459
354
  }
460
355
  }
461
356
 
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;
557
- }
558
-
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
- }
357
+ function registerKimiProvider(pi: ExtensionAPI, state: KimiRuntimeState): void {
358
+ const model = applyKimiOAuthExtrasToModel(
359
+ buildKimiModelFromConfig(state.config.model),
360
+ state.modelExtras,
361
+ );
566
362
 
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}`;
363
+ pi.registerProvider(PROVIDER_ID, {
364
+ baseUrl: getBaseUrl(state.config.protocol),
365
+ apiKey: "$KIMI_API_KEY",
366
+ api: getKimiApiType(state.config.protocol),
367
+ streamSimple: streamSimpleKimi,
368
+
369
+ models: [model],
370
+
371
+ oauth: {
372
+ name: "Kimi Code (OAuth)",
373
+ login: loginKimiCode,
374
+ refreshToken: refreshKimiCodeToken,
375
+ getApiKey: (cred) => cred.access,
376
+ // Reflect server-side model identity on the registered model after login
377
+ // / refresh. We never rewrite the model id (pi-side `/model` selections
378
+ // and persisted sessions reference it); only the human-facing name, the
379
+ // context window, and an out-of-band `wireModelId` carried into the
380
+ // request payload by streamSimpleKimi.
381
+ modifyModels: (models, cred) => {
382
+ const extras = cred as KimiOAuthCredentials;
383
+ state.modelExtras = extras;
384
+ reloadEffectiveKimiRuntimeConfig(state, state.cwd, state.projectTrusted);
385
+ return models.map((model) => {
386
+ if (model.id !== "kimi-for-coding") return model;
387
+ return applyKimiOAuthExtrasToModel(model, extras);
388
+ });
389
+ },
390
+ },
391
+ });
572
392
  }
573
393
 
574
394
  export function KimiCode(overrides?: KimiCodeConfigPatch): ExtensionFactory {
575
395
  return async (pi: ExtensionAPI) => {
576
396
  const cwd = process.cwd();
577
- const config = loadKimiCodeConfig({ cwd, home: os.homedir() }, overrides);
578
- const baseModel = buildKimiModelFromConfig(config.model);
397
+ const config = loadKimiCodeConfig(
398
+ { cwd, home: os.homedir(), includeProject: false },
399
+ overrides,
400
+ );
579
401
  const discoveryToken = getKimiUsageToken();
580
402
  const discovered = discoveryToken
581
403
  ? await discoverKimiModelMetadata(discoveryToken, config.protocol)
582
404
  : {};
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
- });
405
+ const state: KimiRuntimeState = {
406
+ cwd,
407
+ config,
408
+ modelExtras: discovered,
409
+ projectTrusted: false,
410
+ overrides,
411
+ };
412
+ reloadEffectiveKimiRuntimeConfig(state, cwd, false);
413
+ registerKimiProvider(pi, state);
616
414
 
617
415
  registerConfiguredMoonshotTools(pi, state.config, { updateActiveTools: false });
618
416
 
417
+ pi.on("session_start", async (_event, ctx) => {
418
+ const projectTrusted = await isKimiProjectConfigApproved(ctx, ctx.cwd);
419
+ applyEffectiveKimiRuntimeConfig(pi, state, ctx.cwd, {
420
+ updateActiveTools: true,
421
+ projectTrusted,
422
+ });
423
+ registerKimiProvider(pi, state);
424
+ });
425
+
619
426
  pi.registerCommand("kimi-settings", {
620
427
  description: "Show Kimi usage and configure optional Kimi tools",
621
428
  handler: async (_args, ctx) => {
622
- await runKimiCommand(pi, ctx, state);
429
+ if (ctx.mode !== "tui") {
430
+ ctx.ui.notify("/kimi-settings requires TUI mode", "error");
431
+ return;
432
+ }
433
+ await openSettingsMenu(pi, ctx, state);
623
434
  },
624
435
  });
625
436
  };