@mars-sea/dsh-commandcode-provider 0.10.3 → 0.10.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/lib/index.js CHANGED
@@ -220,16 +220,38 @@ var CommandCodeAccountPool = class {
220
220
  until: 0
221
221
  });
222
222
  }
223
- /** One account's key: literal → credential seam → auth file (default slot). */
223
+ /**
224
+ * One account's key: literal → credential seam → auth file (default slot).
225
+ *
226
+ * Every source is normalized here, at the single point where a slot's key
227
+ * enters the pool. The adapter sends the key through the harness's
228
+ * `assertUsableApiKey()`, which trims it — a stored key from the credentials
229
+ * seam, a `.env` line, or a shell export all pick up surrounding whitespace
230
+ * — and reports that trimmed form back to `markRejected()`. Returning the
231
+ * raw value would file every 429/401 mark under a key no later lookup can
232
+ * find: rotation would re-offer the same account, the account card would show
233
+ * no mark, and the usage endpoints would 401 while chat kept working.
234
+ * Normalizing once makes resolution, probing, marking, and the request path
235
+ * agree on one string.
236
+ */
224
237
  async resolveSlotKey(slot) {
225
- if (slot.literal !== void 0 && slot.literal !== "") return slot.literal;
238
+ if (slot.literal !== void 0) {
239
+ const literal = normalizeResolvedKey(slot.literal);
240
+ if (literal !== void 0) return literal;
241
+ }
226
242
  if (slot.ref !== void 0) {
227
243
  const hit = await this.deps.resolveRef(slot.ref);
228
- if (hit !== void 0 && hit !== "") return hit;
244
+ if (hit !== void 0) {
245
+ const resolved = normalizeResolvedKey(hit);
246
+ if (resolved !== void 0) return resolved;
247
+ }
229
248
  }
230
249
  if (slot.allowAuthFile) {
231
250
  const fromFile = this.deps.authFileKey();
232
- if (fromFile !== void 0 && fromFile !== "") return fromFile;
251
+ if (fromFile !== void 0) {
252
+ const fileKey = normalizeResolvedKey(fromFile);
253
+ if (fileKey !== void 0) return fileKey;
254
+ }
233
255
  }
234
256
  }
235
257
  /** Hand out the chosen account's key. */
@@ -240,6 +262,16 @@ var CommandCodeAccountPool = class {
240
262
  };
241
263
  }
242
264
  };
265
+ /**
266
+ * Normalize one resolved credential to the form every consumer sees. Trim
267
+ * only: a blank-after-trim value means "no key" (the slot is omitted and the
268
+ * caller reports `MISSING_CREDENTIAL`), while characters an HTTP header cannot
269
+ * carry are left for `assertUsableApiKey()` to reject with its own message.
270
+ */
271
+ function normalizeResolvedKey(value) {
272
+ const trimmed = value.trim();
273
+ return trimmed === "" ? void 0 : trimmed;
274
+ }
243
275
  //#endregion
244
276
  //#region src/capabilities.ts
245
277
  /**
@@ -1207,8 +1239,65 @@ function wireToolCallIds(paired) {
1207
1239
  function blockText(block) {
1208
1240
  return block.type === "text" || block.type === "reasoning" ? block.text : "";
1209
1241
  }
1210
- function toolResultText(block) {
1211
- return block.content.map(blockText).filter(Boolean).join("\n");
1242
+ /**
1243
+ * Collect text and nested images of one tool result. The harness `read_image`
1244
+ * tool returns both, so dropping the image half is what made a tool-read image
1245
+ * invisible to a Vision model (issue #30). Deduplication keeps a result that
1246
+ * repeats one attachment from paying for the same pixels twice.
1247
+ */
1248
+ function toolResultMedia(block) {
1249
+ const chunks = [];
1250
+ const images = [];
1251
+ const seen = /* @__PURE__ */ new Set();
1252
+ const walk = (blocks) => {
1253
+ for (const nested of blocks) {
1254
+ if (nested.type === "text" || nested.type === "reasoning") {
1255
+ if (nested.text) chunks.push(nested.text);
1256
+ continue;
1257
+ }
1258
+ if (nested.type === "image") {
1259
+ if (!seen.has(nested.attachment.attachmentId)) {
1260
+ seen.add(nested.attachment.attachmentId);
1261
+ images.push(nested.attachment);
1262
+ }
1263
+ continue;
1264
+ }
1265
+ if (nested.type === "tool-result") walk(nested.content);
1266
+ }
1267
+ };
1268
+ walk(block.content);
1269
+ return {
1270
+ text: chunks.join("\n"),
1271
+ images
1272
+ };
1273
+ }
1274
+ /**
1275
+ * Result text for the wire's `role: 'tool'` message. A tool that returned only
1276
+ * an image (and possibly a nested image-only tool result) has no text at all,
1277
+ * and neither transport accepts an empty tool content string, so it gets a
1278
+ * descriptor pointing at the user message that follows with the pixels.
1279
+ */
1280
+ function toolResultTextForWire(media) {
1281
+ return media.text || (media.images.length > 0 ? "(image returned; see the attached image)" : "");
1282
+ }
1283
+ /** Leading line of the user message that carries a tool result's images. */
1284
+ const TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result:";
1285
+ /**
1286
+ * The model-visible note introducing the images carried out of one tool
1287
+ * result. Neither transport merges them back into the tool result, so this
1288
+ * line is what tells the model the image it is about to see belongs to the
1289
+ * tool it just ran rather than to the user; it also leads the part list,
1290
+ * because an image-first content array is what some gateways reject. Count and
1291
+ * pixel dimensions are appended when the tool's own text does not already
1292
+ * state them (a result that returns the image and nothing else).
1293
+ */
1294
+ function toolResultImageNote(media) {
1295
+ const first = media.images[0];
1296
+ if (first === void 0) return TOOL_RESULT_IMAGE_TEXT;
1297
+ const dimensions = `${first.width}x${first.height} px`;
1298
+ if (first.width <= 0 || first.height <= 0 || media.text.includes(dimensions)) return TOOL_RESULT_IMAGE_TEXT;
1299
+ const count = media.images.length > 1 ? `${media.images.length} images, ` : "";
1300
+ return `${TOOL_RESULT_IMAGE_TEXT} ${count}${dimensions}`;
1212
1301
  }
1213
1302
  function hasImageContent(message) {
1214
1303
  const check = (blocks) => blocks.some((b) => b.type === "image" || b.type === "tool-result" && check(b.content));
@@ -1276,6 +1365,7 @@ async function messagesToCC(messages, readImage) {
1276
1365
  if (message.role === "user" && message.source.kind === "tool") {
1277
1366
  const block = message.content[0];
1278
1367
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1368
+ const media = toolResultMedia(block);
1279
1369
  out.push({
1280
1370
  role: "tool",
1281
1371
  content: [{
@@ -1284,13 +1374,25 @@ async function messagesToCC(messages, readImage) {
1284
1374
  toolName: toolNames.get(block.toolCallId) || "unknown",
1285
1375
  output: block.isError ? {
1286
1376
  type: "error-text",
1287
- value: toolResultText(block)
1377
+ value: toolResultTextForWire(media)
1288
1378
  } : {
1289
1379
  type: "text",
1290
- value: toolResultText(block)
1380
+ value: toolResultTextForWire(media)
1291
1381
  }
1292
1382
  }]
1293
1383
  });
1384
+ if (media.images.length > 0) {
1385
+ if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1386
+ const carried = [{
1387
+ type: "text",
1388
+ text: toolResultImageNote(media)
1389
+ }];
1390
+ for (const attachment of media.images) carried.push(await imageToCommandCode(attachment, readImage));
1391
+ out.push({
1392
+ role: "user",
1393
+ content: carried
1394
+ });
1395
+ }
1294
1396
  }
1295
1397
  }
1296
1398
  return out;
@@ -1369,11 +1471,24 @@ async function messagesToOpenAI(messages, readImage) {
1369
1471
  if (message.role === "user" && message.source.kind === "tool") {
1370
1472
  const block = message.content[0];
1371
1473
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1474
+ const media = toolResultMedia(block);
1372
1475
  out.push({
1373
1476
  role: "tool",
1374
1477
  tool_call_id: wireIds.get(block.toolCallId) ?? block.toolCallId,
1375
- content: toolResultText(block)
1478
+ content: toolResultTextForWire(media)
1376
1479
  });
1480
+ if (media.images.length > 0) {
1481
+ if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1482
+ const carried = [{
1483
+ type: "text",
1484
+ text: toolResultImageNote(media)
1485
+ }];
1486
+ for (const attachment of media.images) carried.push(await imageToOpenAI(attachment, readImage));
1487
+ out.push({
1488
+ role: "user",
1489
+ content: carried
1490
+ });
1491
+ }
1377
1492
  }
1378
1493
  }
1379
1494
  return out;
@@ -1760,7 +1875,12 @@ var CommandCodeAdapter = class extends LlmAdapter {
1760
1875
  const access = this.deps.options().filterModelsByPlan === false ? void 0 : await this.loadBillingAccess();
1761
1876
  const visible = this.deps.options().visibleModels;
1762
1877
  const allow = Array.isArray(visible) && visible.length > 0 ? new Set(visible.filter((id) => typeof id === "string" && id !== "")) : void 0;
1763
- return catalog.filter((model) => modelVisibleInPlan(model.id, access)).filter((model) => allow === void 0 || allow.has(model.id)).map(toInfo).sort(compareByPlan);
1878
+ const overrides = this.deps.options().modelVisibility;
1879
+ return catalog.filter((model) => modelVisibleInPlan(model.id, access)).filter((model) => {
1880
+ const override = overrides?.[model.id];
1881
+ if (typeof override === "boolean") return override;
1882
+ return allow === void 0 || allow.has(model.id);
1883
+ }).map(toInfo).sort(compareByPlan);
1764
1884
  }
1765
1885
  async resolveModel(provider, model, signal) {
1766
1886
  const entry = this.catalog.find((m) => m.id === model) ?? (await this.loadCatalog(signal)).find((m) => m.id === model);
@@ -3122,6 +3242,14 @@ var CommandCodeLoginFlow = class {
3122
3242
  timer;
3123
3243
  /** Settle hooks of the live attempt's callback promise. */
3124
3244
  settle;
3245
+ /**
3246
+ * Attempt generation. A delivered callback keeps validating the key
3247
+ * asynchronously (`complete()`), and that window is open to a cancel or a
3248
+ * fresh `begin()`; the generation lets a late completion recognize that it
3249
+ * no longer owns the status face and stop instead of storing a credential
3250
+ * the user cancelled and flipping the page back to success.
3251
+ */
3252
+ attemptSeq = 0;
3125
3253
  disposed = false;
3126
3254
  constructor(deps) {
3127
3255
  this.deps = deps;
@@ -3142,8 +3270,9 @@ var CommandCodeLoginFlow = class {
3142
3270
  */
3143
3271
  async begin() {
3144
3272
  if (this.disposed) throw new Error("login flow has been disposed");
3145
- if (this.statusValue.state === "waiting") return this.statusValue;
3273
+ if (this.statusValue.state === "waiting" && this.server !== void 0) return this.statusValue;
3146
3274
  this.teardown();
3275
+ const attempt = ++this.attemptSeq;
3147
3276
  const port = await this.findPort();
3148
3277
  const expectedState = this.deps.randomToken?.(32) ?? randomBytes(32).toString("base64url");
3149
3278
  const settled = new Promise((resolve, reject) => {
@@ -3171,7 +3300,7 @@ var CommandCodeLoginFlow = class {
3171
3300
  });
3172
3301
  }, this.deps.timeoutMs ?? 12e4);
3173
3302
  this.timer.unref?.();
3174
- settled.then((credentials) => this.complete(credentials), (failure) => this.failFrom(failure));
3303
+ settled.then((credentials) => this.complete(attempt, credentials), (failure) => this.failFrom(attempt, failure));
3175
3304
  return this.statusValue;
3176
3305
  }
3177
3306
  /** Cancel a waiting attempt; terminal states are untouched. */
@@ -3288,6 +3417,13 @@ var CommandCodeLoginFlow = class {
3288
3417
  }
3289
3418
  if (typeof payload === "object" && payload !== null && "error" in payload) {
3290
3419
  const denial = payload;
3420
+ if (denial.state !== expectedState) {
3421
+ json(403, {
3422
+ success: false,
3423
+ error: "Invalid state token"
3424
+ });
3425
+ return;
3426
+ }
3291
3427
  const description = denial.error_description ?? denial.error;
3292
3428
  this.settleAttempt(json, 200, { success: true }, new LoginSettleError(denial.error === "access_denied" ? "denied" : "error", typeof description === "string" && description !== "" ? description : "Authorization failed"));
3293
3429
  return;
@@ -3319,10 +3455,20 @@ var CommandCodeLoginFlow = class {
3319
3455
  if (failure !== void 0) settle.reject(failure);
3320
3456
  else if (credentials !== void 0) settle.resolve(credentials);
3321
3457
  }
3322
- /** Post-validation completion: whoami check, then hand-off to storage. */
3323
- async complete(credentials) {
3324
- if (this.disposed || this.statusValue.state !== "waiting") return;
3458
+ /**
3459
+ * Post-validation completion: whoami check, then hand-off to storage.
3460
+ *
3461
+ * Every step re-checks {@link ownsAttempt} first: the whoami round-trip and
3462
+ * the credential write are awaits, and the user may cancel (or start another
3463
+ * attempt) while one is in flight. A completion that no longer owns the
3464
+ * attempt must not write the key or publish a status — otherwise cancel
3465
+ * would report "cancelled" while the credential landed anyway, and the page
3466
+ * would silently flip to success.
3467
+ */
3468
+ async complete(attempt, credentials) {
3469
+ if (!this.ownsAttempt(attempt)) return;
3325
3470
  const validation = await validateCommandApiKey(this.deps.fetchImpl ?? fetch, this.readApiBase(), credentials.apiKey);
3471
+ if (!this.ownsAttempt(attempt)) return;
3326
3472
  if (!validation.valid) {
3327
3473
  const reason = validation.error === "invalid_key" ? "invalid-key" : validation.error === "network_error" ? "network" : "error";
3328
3474
  this.setStatus({
@@ -3332,9 +3478,11 @@ var CommandCodeLoginFlow = class {
3332
3478
  });
3333
3479
  return;
3334
3480
  }
3481
+ if (!this.ownsAttempt(attempt)) return;
3335
3482
  try {
3336
3483
  await this.deps.storeKey(credentials);
3337
3484
  } catch (error) {
3485
+ if (!this.ownsAttempt(attempt)) return;
3338
3486
  this.setStatus({
3339
3487
  state: "failed",
3340
3488
  reason: "unavailable",
@@ -3342,7 +3490,7 @@ var CommandCodeLoginFlow = class {
3342
3490
  });
3343
3491
  return;
3344
3492
  }
3345
- if (this.disposed) return;
3493
+ if (!this.ownsAttempt(attempt)) return;
3346
3494
  this.clearTimer();
3347
3495
  this.setStatus({
3348
3496
  state: "success",
@@ -3350,10 +3498,14 @@ var CommandCodeLoginFlow = class {
3350
3498
  keyName: credentials.keyName
3351
3499
  });
3352
3500
  }
3501
+ /** Whether one attempt still owns the status face (not cancelled, replaced, or disposed). */
3502
+ ownsAttempt(attempt) {
3503
+ return !this.disposed && this.attemptSeq === attempt && this.statusValue.state === "waiting";
3504
+ }
3353
3505
  /** Map a tagged settle rejection onto the status face. */
3354
- failFrom(failure) {
3506
+ failFrom(attempt, failure) {
3355
3507
  if (!(failure instanceof LoginSettleError)) return;
3356
- if (this.disposed || this.statusValue.state !== "waiting") return;
3508
+ if (!this.ownsAttempt(attempt)) return;
3357
3509
  this.setStatus({
3358
3510
  state: "failed",
3359
3511
  reason: failure.reason,
@@ -3634,6 +3786,338 @@ var CommandCodeSearchProvider = class {
3634
3786
  }
3635
3787
  };
3636
3788
  //#endregion
3789
+ //#region src/tui-settings.ts
3790
+ /** The selector value meaning "no pinned account — follow rotation order". */
3791
+ const ACTIVE_ACCOUNT_AUTO = "auto";
3792
+ /** The selector value meaning "no language override — follow the shell locale". */
3793
+ const LANG_AUTO = "auto";
3794
+ /** Tier display names, mirroring the web dropdown's headings (`model-select.ts`). */
3795
+ const TIER_TITLES = {
3796
+ go: "Go",
3797
+ goat: "GOAT",
3798
+ pro: "Pro",
3799
+ provider: "Provider",
3800
+ max: "Max"
3801
+ };
3802
+ /** Tiers in picker order; a model outside this set joins the "Other" group. */
3803
+ const TIER_ORDER = [
3804
+ "go",
3805
+ "goat",
3806
+ "pro",
3807
+ "provider",
3808
+ "max"
3809
+ ];
3810
+ /** The group holding models this build's catalog does not know. */
3811
+ const OTHER_GROUP_ID = "models-other";
3812
+ /**
3813
+ * Every model this build knows, in checkbox order: plan tier (Go first), then
3814
+ * free before paid inside a tier, then by id.
3815
+ *
3816
+ * The list is the static capability snapshot rather than a live catalog read
3817
+ * on purpose — a settings page must draw synchronously, and the snapshot is
3818
+ * synced from the same upstream table the picker's tier headings come from.
3819
+ * A model added upstream after this build still reaches the user: an empty
3820
+ * allowlist shows everything, and a model named in the allowlist but absent
3821
+ * here is rendered by the "Other" group instead of disappearing.
3822
+ */
3823
+ function commandCodeTuiModelChoices() {
3824
+ return Object.keys(KNOWN_PLANS).map((id) => ({
3825
+ id,
3826
+ tier: KNOWN_PLANS[id] ?? "",
3827
+ free: isFreeModel(id),
3828
+ hint: capabilityDescription(id)
3829
+ })).sort((a, b) => {
3830
+ const tierDelta = tierRank(a.tier) - tierRank(b.tier);
3831
+ if (tierDelta !== 0) return tierDelta;
3832
+ if (a.free !== b.free) return a.free ? -1 : 1;
3833
+ return a.id.localeCompare(b.id);
3834
+ });
3835
+ }
3836
+ /** Sort rank of a tier key; unknown tiers trail every known one. */
3837
+ function tierRank(tier) {
3838
+ const rank = TIER_ORDER.indexOf(tier);
3839
+ return rank === -1 ? TIER_ORDER.length : rank;
3840
+ }
3841
+ /** The stored allowlist as a clean id list (non-strings and blanks dropped). */
3842
+ function storedIds(value) {
3843
+ return Array.isArray(value) ? value.filter((id) => typeof id === "string" && id !== "") : [];
3844
+ }
3845
+ /**
3846
+ * Build the section descriptor. Pure, so tests can pin the exact fields
3847
+ * without a dsh-TUI host.
3848
+ *
3849
+ * Field choices worth keeping: the two option-bearing fields (`activeAccount`,
3850
+ * `lang`) are `text` + `options` rather than `select`, because a `select`
3851
+ * cannot express "unset" — cycling only ever lands on a declared option, so a
3852
+ * `select` would strand the user on a pinned value with no way back to
3853
+ * automatic. The `auto` sentinel plus a `parse` that clears the path keeps the
3854
+ * unset state reachable. `filterModelsByPlan` formats its EFFECTIVE default
3855
+ * (unset means true at the adapter), so a fresh install reads true instead of
3856
+ * the screen's "(empty)".
3857
+ */
3858
+ function buildCommandCodeTuiSection(deps) {
3859
+ const ref = deps.apiKeyRef();
3860
+ const slots = deps.accountSlots();
3861
+ const choices = (deps.modelChoices ?? commandCodeTuiModelChoices)();
3862
+ const catalogIds = choices.map((choice) => choice.id);
3863
+ const known = new Set(catalogIds);
3864
+ const stored = storedIds(deps.visibleModels());
3865
+ const overrides = deps.modelVisibility?.() ?? {};
3866
+ const extras = [.../* @__PURE__ */ new Set([...stored, ...Object.keys(overrides)])].filter((id) => !known.has(id));
3867
+ const tierGroups = TIER_ORDER.filter((tier) => choices.some((choice) => choice.tier === tier)).map((tier) => ({
3868
+ id: `models-${tier}`,
3869
+ title: `${TIER_TITLES[tier] ?? tier} models`,
3870
+ descriptions: { zh: `${TIER_TITLES[tier] ?? tier} 模型` }
3871
+ }));
3872
+ const unranked = choices.filter((choice) => tierRank(choice.tier) === TIER_ORDER.length);
3873
+ const otherGroup = unranked.length > 0 || extras.length > 0 ? [{
3874
+ id: OTHER_GROUP_ID,
3875
+ title: "Other models",
3876
+ descriptions: { zh: "其他模型" }
3877
+ }] : [];
3878
+ /**
3879
+ * One checkbox: a boolean at a path of its OWN (`modelVisibility.<id>`).
3880
+ *
3881
+ * The path must be unique per model. dsh-TUI keys a staged draft by the
3882
+ * field's path (`fieldKey`), so N checkboxes sharing `visibleModels` share
3883
+ * ONE draft: every one of them then parses that same draft on save, all N
3884
+ * write ops address the same path, and only the LAST field's op survives —
3885
+ * which silently rewrote the allowlist from the last catalog model instead
3886
+ * of the one that was toggled. A per-model key is what makes a checkbox
3887
+ * express one model's state.
3888
+ *
3889
+ * An override equal to what the array already says is written as a CLEAR, so
3890
+ * toggling a model back to its inherited state leaves no residue and the
3891
+ * document stays minimal.
3892
+ */
3893
+ const modelField = (id, hint, group) => ({
3894
+ path: ["modelVisibility", id],
3895
+ group,
3896
+ kind: "boolean",
3897
+ label: id,
3898
+ ...hint === "" ? {} : { hint },
3899
+ format: (value) => {
3900
+ if (typeof value === "boolean") return String(value);
3901
+ const listed = storedIds(deps.visibleModels());
3902
+ return String(listed.length === 0 || listed.includes(id));
3903
+ },
3904
+ parse: (text) => {
3905
+ const on = text.trim() === "true";
3906
+ const listed = storedIds(deps.visibleModels());
3907
+ return on === (listed.length === 0 || listed.includes(id)) ? { kind: "clear" } : {
3908
+ kind: "set",
3909
+ value: on
3910
+ };
3911
+ }
3912
+ });
3913
+ return {
3914
+ ns: deps.ns,
3915
+ title: deps.title ?? "Command Code",
3916
+ descriptions: { zh: "Command Code(非官方)" },
3917
+ groups: [
3918
+ {
3919
+ id: "connection",
3920
+ title: "Connection",
3921
+ descriptions: { zh: "连接" }
3922
+ },
3923
+ {
3924
+ id: "models",
3925
+ title: "Models",
3926
+ descriptions: { zh: "模型" }
3927
+ },
3928
+ ...tierGroups,
3929
+ ...otherGroup,
3930
+ {
3931
+ id: "advanced",
3932
+ title: "Advanced",
3933
+ descriptions: { zh: "高级" }
3934
+ }
3935
+ ],
3936
+ fields: [
3937
+ {
3938
+ path: ["apiKey"],
3939
+ group: "connection",
3940
+ kind: "text",
3941
+ label: "API key",
3942
+ descriptions: { zh: "API 密钥" },
3943
+ secret: { ref },
3944
+ hint: `Stored in the credential store as ${ref}, never in settings.yaml.`,
3945
+ hintDescriptions: { zh: `保存在凭据库(${ref}),不会写入 settings.yaml。` }
3946
+ },
3947
+ {
3948
+ path: ["apiBase"],
3949
+ group: "connection",
3950
+ kind: "text",
3951
+ label: "API base",
3952
+ descriptions: { zh: "API 地址" },
3953
+ placeholder: "https://api.commandcode.ai",
3954
+ hint: "Leave empty for the public Command Code Provider API.",
3955
+ hintDescriptions: { zh: "留空即使用官方 Command Code Provider API。" },
3956
+ parse: (text) => {
3957
+ const trimmed = text.trim();
3958
+ return trimmed === "" ? { kind: "clear" } : {
3959
+ kind: "set",
3960
+ value: trimmed
3961
+ };
3962
+ }
3963
+ },
3964
+ {
3965
+ path: ["filterModelsByPlan"],
3966
+ group: "models",
3967
+ kind: "boolean",
3968
+ label: "Hide out-of-plan models",
3969
+ descriptions: { zh: "隐藏套餐外的模型" },
3970
+ hint: "Keeps models above your subscription tier out of the picker. Fails open.",
3971
+ hintDescriptions: { zh: "在选择器里隐藏超出当前订阅档位的模型;判断不出来时全部显示。" },
3972
+ format: (value) => value === false ? "false" : "true",
3973
+ parse: (text) => ({
3974
+ kind: "set",
3975
+ value: text.trim() === "true"
3976
+ })
3977
+ },
3978
+ ...choices.filter((choice) => tierRank(choice.tier) !== TIER_ORDER.length).map((choice) => modelField(choice.id, choice.hint, `models-${choice.tier}`)),
3979
+ ...unranked.map((choice) => modelField(choice.id, choice.hint, OTHER_GROUP_ID)),
3980
+ ...extras.map((id) => modelField(id, capabilityDescription(id), OTHER_GROUP_ID)),
3981
+ {
3982
+ path: ["activeAccount"],
3983
+ group: "advanced",
3984
+ kind: "text",
3985
+ label: "Active account",
3986
+ descriptions: { zh: "当前账号" },
3987
+ hint: "A pinned account id, or auto to follow the rotation order.",
3988
+ hintDescriptions: { zh: "固定使用某个账号的 id;auto 表示按轮换顺序自动选择。" },
3989
+ options: [{
3990
+ value: ACTIVE_ACCOUNT_AUTO,
3991
+ label: "Automatic (rotation order)",
3992
+ descriptions: { zh: "自动(按轮换顺序)" }
3993
+ }, ...slots.map((slot) => ({
3994
+ value: slot.id,
3995
+ label: slot.label,
3996
+ descriptions: { zh: slot.label }
3997
+ }))],
3998
+ format: (value) => typeof value === "string" && value.trim() !== "" ? value : ACTIVE_ACCOUNT_AUTO,
3999
+ parse: (text) => {
4000
+ const trimmed = text.trim();
4001
+ return trimmed === "" || trimmed === "auto" ? { kind: "clear" } : {
4002
+ kind: "set",
4003
+ value: trimmed
4004
+ };
4005
+ }
4006
+ },
4007
+ {
4008
+ path: ["lang"],
4009
+ group: "advanced",
4010
+ kind: "text",
4011
+ label: "Command language",
4012
+ descriptions: { zh: "命令语言" },
4013
+ hint: "Language of the /commandcode dashboard; auto follows the shell locale.",
4014
+ hintDescriptions: { zh: "/commandcode 用量面板的语言;auto 跟随终端 locale。" },
4015
+ options: [
4016
+ {
4017
+ value: LANG_AUTO,
4018
+ label: "Automatic (shell locale)",
4019
+ descriptions: { zh: "自动(跟随终端 locale)" }
4020
+ },
4021
+ {
4022
+ value: "zh",
4023
+ label: "中文"
4024
+ },
4025
+ {
4026
+ value: "en",
4027
+ label: "English"
4028
+ }
4029
+ ],
4030
+ format: (value) => value === "zh" || value === "en" ? value : LANG_AUTO,
4031
+ parse: (text) => {
4032
+ const trimmed = text.trim();
4033
+ return trimmed === "" || trimmed === "auto" ? { kind: "clear" } : {
4034
+ kind: "set",
4035
+ value: trimmed
4036
+ };
4037
+ }
4038
+ }
4039
+ ]
4040
+ };
4041
+ }
4042
+ /**
4043
+ * The `tuiSettingsSections` service, read defensively.
4044
+ *
4045
+ * The service name is declared by dsh-TUI's own module augmentation, which
4046
+ * this package deliberately does not import, so the typed `Context` has no
4047
+ * such property. The read goes through the REFLECTIVE `ctx.get` rather than a
4048
+ * bare property access: cordis refuses a property read for a service the fiber
4049
+ * never declared in `inject` (`cannot get property … without inject`), and an
4050
+ * unmeet seam must degrade to "no section" instead of throwing out of the
4051
+ * plugin's boot.
4052
+ */
4053
+ function tuiSettingsService(ctx) {
4054
+ let candidate;
4055
+ try {
4056
+ candidate = ctx.get("tuiSettingsSections");
4057
+ } catch {
4058
+ return;
4059
+ }
4060
+ if (typeof candidate !== "object" || candidate === null) return void 0;
4061
+ const register = candidate.register;
4062
+ if (typeof register !== "function") return void 0;
4063
+ return { register: register.bind(candidate) };
4064
+ }
4065
+ /**
4066
+ * Identity of everything in the section that can change at runtime: the
4067
+ * credential reference behind the API-key field, the account slots behind the
4068
+ * active-account selector, and the stored-but-unknown allowlist entries that
4069
+ * get a checkbox of their own. Re-registration is skipped while this matches,
4070
+ * so ordinary settings writes never churn the screen's section list — the
4071
+ * checkboxes themselves read their state live and need no re-declaration.
4072
+ */
4073
+ function sectionSignature(section) {
4074
+ const active = section.fields.find((field) => field.path.join(".") === "activeAccount");
4075
+ return JSON.stringify({
4076
+ secret: section.fields.find((field) => field.secret !== void 0)?.secret?.ref ?? "",
4077
+ options: active?.options?.map((option) => option.value) ?? [],
4078
+ other: section.fields.filter((field) => field.group === OTHER_GROUP_ID).map((field) => field.label)
4079
+ });
4080
+ }
4081
+ /**
4082
+ * Register the Command Code section on a dsh-TUI host.
4083
+ *
4084
+ * @param ctx - the context of an activated `tuiSettingsSections` injection.
4085
+ * @param deps - plugin-owned facts the section reads.
4086
+ * @returns a refresh function that re-registers the section when a fact it
4087
+ * renders changed (the plugin entry calls it from its settings `onChange`
4088
+ * hook), or `undefined` when the seam is unusable. The returned function is
4089
+ * inert after the fiber is torn down.
4090
+ */
4091
+ function applyCommandCodeTuiSettings(ctx, deps) {
4092
+ const service = tuiSettingsService(ctx);
4093
+ if (service === void 0) return void 0;
4094
+ let disposed = false;
4095
+ let current;
4096
+ const refresh = () => {
4097
+ if (disposed) return;
4098
+ const section = buildCommandCodeTuiSection(deps);
4099
+ const signature = sectionSignature(section);
4100
+ if (current?.signature === signature) return;
4101
+ current?.dispose();
4102
+ current = void 0;
4103
+ try {
4104
+ current = {
4105
+ signature,
4106
+ dispose: service.register(section)
4107
+ };
4108
+ } catch (error) {
4109
+ ctx.logger?.warn(`llm-commandcode: could not register the dsh-TUI settings section: ${error instanceof Error ? error.message : String(error)}`);
4110
+ }
4111
+ };
4112
+ refresh();
4113
+ ctx.effect(() => () => {
4114
+ disposed = true;
4115
+ current?.dispose();
4116
+ current = void 0;
4117
+ }, "dsh-commandcode-provider: tui settings section");
4118
+ return refresh;
4119
+ }
4120
+ //#endregion
3637
4121
  //#region src/index.ts
3638
4122
  /**
3639
4123
  * dsh-commandcode-provider — DeepSeek Harness LLM provider plugin for Command
@@ -3672,7 +4156,7 @@ const PROVIDER = "commandcode";
3672
4156
  const DEFAULT_MODELS_CACHE_PATH = join(homedir(), ".commandcode", "models-cache.json");
3673
4157
  const Config = z.object({
3674
4158
  apiKeyEnv: z.string().role("credential-ref").default(DEFAULT_API_KEY_ENV),
3675
- apiKey: z.string(),
4159
+ apiKey: z.string().role("secret"),
3676
4160
  apiBase: z.string(),
3677
4161
  workingDir: z.string(),
3678
4162
  modelsCachePath: z.string(),
@@ -3680,11 +4164,21 @@ const Config = z.object({
3680
4164
  streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
3681
4165
  filterModelsByPlan: z.boolean(),
3682
4166
  visibleModels: z.array(z.string()),
4167
+ /**
4168
+ * Per-model visibility overrides for the terminal settings page's checkbox
4169
+ * list, keyed by catalog id. dsh-TUI addresses a staged edit by its field
4170
+ * PATH, so two checkboxes sharing one path would overwrite each other's
4171
+ * draft and the section's last model would decide every write; a map gives
4172
+ * each checkbox a path of its own. An id listed here wins over
4173
+ * {@link visibleModels}; ids absent here keep following it.
4174
+ */
4175
+ modelVisibility: z.dict(z.boolean()),
3683
4176
  webSearch: z.boolean().default(true),
3684
4177
  accounts: z.array(z.object({
3685
4178
  label: z.string(),
3686
4179
  apiKeyEnv: z.string().role("credential-ref"),
3687
- apiKey: z.string()
4180
+ /** Literal key for one extra slot; see the top-level `apiKey` secret note. */
4181
+ apiKey: z.string().role("secret")
3688
4182
  })),
3689
4183
  activeAccount: z.string(),
3690
4184
  modelAccountRules: z.array(z.object({
@@ -3708,9 +4202,22 @@ function resolveAdapterOptions(config) {
3708
4202
  requestTimeoutMs: config.requestTimeoutMs ?? 6e4,
3709
4203
  streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? 3e5,
3710
4204
  filterModelsByPlan: config.filterModelsByPlan ?? true,
3711
- visibleModels: Array.isArray(config.visibleModels) ? config.visibleModels.filter((id) => typeof id === "string" && id !== "") : void 0
4205
+ visibleModels: Array.isArray(config.visibleModels) ? config.visibleModels.filter((id) => typeof id === "string" && id !== "") : void 0,
4206
+ modelVisibility: readModelVisibility(config.modelVisibility)
3712
4207
  };
3713
4208
  }
4209
+ /**
4210
+ * Per-model visibility overrides, cleaned for the adapter. Programmatic
4211
+ * construction may bypass Schemastery normalization, so a non-object or a
4212
+ * non-boolean entry is dropped here instead of reaching the picker filter.
4213
+ * @param raw - The `modelVisibility` value from any config source.
4214
+ * @returns A frozen id → boolean map, or undefined when nothing is set.
4215
+ */
4216
+ function readModelVisibility(raw) {
4217
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
4218
+ const entries = Object.entries(raw).filter((entry) => entry[0] !== "" && typeof entry[1] === "boolean");
4219
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
4220
+ }
3714
4221
  function apply(ctx, config) {
3715
4222
  let current = () => config;
3716
4223
  let lastRaw;
@@ -3874,6 +4381,22 @@ function apply(ctx, config) {
3874
4381
  applyCommandCodeSearchSelection(webCtx.web, searchSelection, false);
3875
4382
  }, "dsh-commandcode-provider: web search selection");
3876
4383
  });
4384
+ let refreshTuiSettings;
4385
+ ctx.inject(["tuiSettingsSections"], (tuiCtx) => {
4386
+ refreshTuiSettings = applyCommandCodeTuiSettings(tuiCtx, {
4387
+ ns: NS,
4388
+ apiKeyRef: () => current().apiKeyEnv ?? DEFAULT_API_KEY_ENV,
4389
+ accountSlots: () => slots().map((slot) => ({
4390
+ id: slot.id,
4391
+ label: slot.label
4392
+ })),
4393
+ visibleModels: () => options().visibleModels ?? [],
4394
+ modelVisibility: () => options().modelVisibility
4395
+ });
4396
+ tuiCtx.effect(() => () => {
4397
+ refreshTuiSettings = void 0;
4398
+ }, "dsh-commandcode-provider: tui settings handle");
4399
+ });
3877
4400
  ctx.inject(["settings"], (settingsCtx) => {
3878
4401
  settingsCtx.settings.installSection(ctx, NS, Config, config, {
3879
4402
  setSource: (source) => {
@@ -3881,11 +4404,12 @@ function apply(ctx, config) {
3881
4404
  },
3882
4405
  onChange: () => {
3883
4406
  applySearchSelection(current().webSearch ?? true);
4407
+ refreshTuiSettings?.();
3884
4408
  }
3885
4409
  });
3886
4410
  });
3887
4411
  }
3888
4412
  //#endregion
3889
- export { BILLING_ACCESS_TTL_MS, COMMANDCODE_SEARCH_PROVIDER_ID, COMMAND_CODE_CLI_VERSION, CommandCodeAccountPool, CommandCodeAdapter, CommandCodeLoginFlow, CommandCodeSearchProvider, CommandCodeUsageService, Config, DEFAULT_API_BASE, DEFAULT_GENERATE_MAX_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MODELS_CACHE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_WEB_SEARCH_PROVIDER_ID, KNOWN_DEALS, KNOWN_EFFORTS, KNOWN_IMAGE_MODELS, KNOWN_PEAK_PRICING, KNOWN_PLANS, KNOWN_SUBSCRIPTION_PLANS, KNOWN_THINKING_MODELS, LOGIN_ALLOWED_ORIGINS, LOGIN_BEGIN_ENDPOINT, LOGIN_BODY_LIMIT_BYTES, LOGIN_CANCEL_ENDPOINT, LOGIN_MAX_PORT_ATTEMPTS, LOGIN_START_PORT, LOGIN_STATUS_ENDPOINT, LOGIN_TIMEOUT_MS, PLAN_LABELS, PLAN_ORDER, PROVIDER, USAGE_REPORT_ENDPOINT, accountUsable, apply, applyCommandCodeSearchSelection, applyCommands, applyUsageRemote, buildCommandAuthUrl, capabilityDescription, commandCodeSearchSelection, commandDefinition, compareByPlan, dealLabel, formatContext, inject, loginStatusSchema, matchModelRule, modelVisibleInPlan, name, parseLoginStatus, peakPricingLabel, peakPricingState, planLabel, projectSlugFromPath, resolveAdapterOptions, resolveAuthFileApiKey, selectAccountForModel, selectActiveAccount, selectCommandCodeSearchProvider, studioBaseForApiBase, subscriptionPlanInfo, usageReportSchema, validateCommandApiKey };
4413
+ export { ACTIVE_ACCOUNT_AUTO, BILLING_ACCESS_TTL_MS, COMMANDCODE_SEARCH_PROVIDER_ID, COMMAND_CODE_CLI_VERSION, CommandCodeAccountPool, CommandCodeAdapter, CommandCodeLoginFlow, CommandCodeSearchProvider, CommandCodeUsageService, Config, DEFAULT_API_BASE, DEFAULT_GENERATE_MAX_TOKENS, DEFAULT_MAX_OUTPUT_TOKENS, DEFAULT_MODELS_CACHE_PATH, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_STREAM_IDLE_TIMEOUT_MS, DEFAULT_WEB_SEARCH_PROVIDER_ID, KNOWN_DEALS, KNOWN_EFFORTS, KNOWN_IMAGE_MODELS, KNOWN_PEAK_PRICING, KNOWN_PLANS, KNOWN_SUBSCRIPTION_PLANS, KNOWN_THINKING_MODELS, LANG_AUTO, LOGIN_ALLOWED_ORIGINS, LOGIN_BEGIN_ENDPOINT, LOGIN_BODY_LIMIT_BYTES, LOGIN_CANCEL_ENDPOINT, LOGIN_MAX_PORT_ATTEMPTS, LOGIN_START_PORT, LOGIN_STATUS_ENDPOINT, LOGIN_TIMEOUT_MS, PLAN_LABELS, PLAN_ORDER, PROVIDER, USAGE_REPORT_ENDPOINT, accountUsable, apply, applyCommandCodeSearchSelection, applyCommandCodeTuiSettings, applyCommands, applyUsageRemote, buildCommandAuthUrl, buildCommandCodeTuiSection, capabilityDescription, commandCodeSearchSelection, commandDefinition, compareByPlan, dealLabel, formatContext, inject, loginStatusSchema, matchModelRule, modelVisibleInPlan, name, parseLoginStatus, peakPricingLabel, peakPricingState, planLabel, projectSlugFromPath, resolveAdapterOptions, resolveAuthFileApiKey, selectAccountForModel, selectActiveAccount, selectCommandCodeSearchProvider, studioBaseForApiBase, subscriptionPlanInfo, usageReportSchema, validateCommandApiKey };
3890
4414
 
3891
4415
  //# sourceMappingURL=index.js.map