@mars-sea/dsh-commandcode-provider 0.10.4 → 0.10.6

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
@@ -1024,7 +1024,7 @@ function capabilityDescription(modelId, contextWindow, now = Date.now()) {
1024
1024
  * and API key or subscription, and Command Code's terms apply.
1025
1025
  *
1026
1026
  * Wire protocol (reverse-engineered by the pi plugin, command-code@1.28.4;
1027
- * re-verified against command-code@1.53.0 — endpoints, request shape, and
1027
+ * re-verified against command-code@1.53.1 — endpoints, request shape, and
1028
1028
  * stream events unchanged):
1029
1029
  * POST {apiBase}/alpha/generate
1030
1030
  * body: { config, memory, taste, skills, params: { model, messages, tools,
@@ -1037,7 +1037,7 @@ function capabilityDescription(modelId, contextWindow, now = Date.now()) {
1037
1037
  * per-request options thunk and an API-key resolver from the plugin entry
1038
1038
  * (src/index.ts), so a settings change reaches the very next request.
1039
1039
  */
1040
- const COMMAND_CODE_CLI_VERSION = "1.53.0";
1040
+ const COMMAND_CODE_CLI_VERSION = "1.53.1";
1041
1041
  const DEFAULT_API_BASE = "https://api.commandcode.ai";
1042
1042
  const DEFAULT_GENERATE_MAX_TOKENS = 64e3;
1043
1043
  const DEFAULT_MAX_OUTPUT_TOKENS = 65536;
@@ -1105,6 +1105,124 @@ function recordOrEmpty(value) {
1105
1105
  } catch {}
1106
1106
  return {};
1107
1107
  }
1108
+ /** How deep a root `$ref` / combinator chain is followed while normalizing. */
1109
+ const SCHEMA_NORMALIZE_MAX_DEPTH = 4;
1110
+ /**
1111
+ * Whether a type-less node is object-shaped enough to be one with the type
1112
+ * declared: `properties`/`required`/`additionalProperties` only make sense on
1113
+ * an object, and a schema carrying them was meant to be one.
1114
+ */
1115
+ function isObjectShaped(node) {
1116
+ return isRecord(node.properties) || isRecord(node.patternProperties) || Array.isArray(node.required) || node.additionalProperties !== void 0;
1117
+ }
1118
+ /** The `required` names of one schema node, ignoring malformed entries. */
1119
+ function requiredNames(node) {
1120
+ return Array.isArray(node.required) ? node.required.filter((name) => typeof name === "string") : [];
1121
+ }
1122
+ /** Resolve a local `#/...` pointer inside the schema that carried it. */
1123
+ function resolveLocalRef(root, ref) {
1124
+ if (!ref.startsWith("#/")) return void 0;
1125
+ let node = root;
1126
+ for (const rawSegment of ref.slice(2).split("/")) {
1127
+ const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
1128
+ if (!isRecord(node)) return void 0;
1129
+ node = node[segment];
1130
+ }
1131
+ return isRecord(node) ? node : void 0;
1132
+ }
1133
+ /**
1134
+ * Flatten a type-less combinator node into one object schema, so the model
1135
+ * still sees the branch fields instead of an argument-free tool. `allOf`
1136
+ * branches must all hold, so their `required` entries are all kept; `anyOf` /
1137
+ * `oneOf` branches are alternatives, so only a name required by every branch
1138
+ * survives. Returns undefined when no branch describes an object.
1139
+ */
1140
+ function mergeCombinatorBranches(node, depth) {
1141
+ if (depth >= SCHEMA_NORMALIZE_MAX_DEPTH) return void 0;
1142
+ const properties = {};
1143
+ const required = /* @__PURE__ */ new Set();
1144
+ const alternatives = [];
1145
+ let sawBranch = false;
1146
+ for (const key of [
1147
+ "allOf",
1148
+ "anyOf",
1149
+ "oneOf"
1150
+ ]) {
1151
+ const branches = node[key];
1152
+ if (!Array.isArray(branches)) continue;
1153
+ const objectBranches = [];
1154
+ for (const branch of branches) {
1155
+ if (!isRecord(branch)) continue;
1156
+ objectBranches.push(toolParametersSchema(branch, depth + 1));
1157
+ }
1158
+ if (objectBranches.length === 0) continue;
1159
+ sawBranch = true;
1160
+ const names = objectBranches.map(requiredNames);
1161
+ if (key === "allOf") for (const name of names.flat()) required.add(name);
1162
+ else if (names.length > 0) alternatives.push(names[0].filter((name) => names.every((list) => list.includes(name))));
1163
+ for (const branch of objectBranches) {
1164
+ const branchProperties = isRecord(branch.properties) ? branch.properties : {};
1165
+ for (const [name, schema] of Object.entries(branchProperties)) if (!(name in properties)) properties[name] = schema;
1166
+ }
1167
+ }
1168
+ if (!sawBranch) return void 0;
1169
+ for (const group of alternatives) for (const name of group) required.add(name);
1170
+ const merged = {
1171
+ type: "object",
1172
+ properties
1173
+ };
1174
+ if (required.size > 0) merged.required = [...required];
1175
+ if (node.additionalProperties !== void 0) merged.additionalProperties = node.additionalProperties;
1176
+ for (const key of ["description", "title"]) if (node[key] !== void 0) merged[key] = node[key];
1177
+ return merged;
1178
+ }
1179
+ /**
1180
+ * Normalize one tool's parameters schema to an object-rooted JSON Schema.
1181
+ * A schema that already declares an object root is passed through untouched;
1182
+ * a type-less object-shaped one gains the type; a root `$ref` or combinator is
1183
+ * resolved/merged; anything else (an array/scalar root, or no schema at all)
1184
+ * degrades to a permissive free-form object, because a request the provider
1185
+ * refuses helps no one and the tool's own description is still in the prompt.
1186
+ */
1187
+ function toolParametersSchema(parameters, depth = 0) {
1188
+ if (!isRecord(parameters)) return {
1189
+ type: "object",
1190
+ properties: {},
1191
+ additionalProperties: true
1192
+ };
1193
+ if (parameters.type === "object") return parameters;
1194
+ if (Array.isArray(parameters.type) && parameters.type.includes("object")) return {
1195
+ ...parameters,
1196
+ type: "object"
1197
+ };
1198
+ if (parameters.type === void 0 || parameters.type === null) {
1199
+ if (typeof parameters.$ref === "string" && depth < SCHEMA_NORMALIZE_MAX_DEPTH) {
1200
+ const target = resolveLocalRef(parameters, parameters.$ref);
1201
+ if (target !== void 0) {
1202
+ const { $ref: _ref, ...rest } = parameters;
1203
+ return toolParametersSchema({
1204
+ ...target,
1205
+ ...rest
1206
+ }, depth + 1);
1207
+ }
1208
+ }
1209
+ if (isObjectShaped(parameters)) return {
1210
+ ...parameters,
1211
+ type: "object"
1212
+ };
1213
+ const merged = mergeCombinatorBranches(parameters, depth);
1214
+ if (merged !== void 0) return merged;
1215
+ if (typeof parameters.$ref === "string") return {
1216
+ ...parameters,
1217
+ type: "object"
1218
+ };
1219
+ }
1220
+ return {
1221
+ type: "object",
1222
+ properties: {},
1223
+ additionalProperties: true
1224
+ };
1225
+ }
1108
1226
  function projectSlugFromPath(pathName) {
1109
1227
  return pathName.toLowerCase().replace(/^[a-z]:/i, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|(?<!-)-+$/g, "") || "project";
1110
1228
  }
@@ -1281,23 +1399,33 @@ function toolResultTextForWire(media) {
1281
1399
  return media.text || (media.images.length > 0 ? "(image returned; see the attached image)" : "");
1282
1400
  }
1283
1401
  /** 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:";
1402
+ const TOOL_RESULT_IMAGE_TEXT = "Attached image(s) from tool result";
1285
1403
  /**
1286
1404
  * The model-visible note introducing the images carried out of one tool
1287
1405
  * result. Neither transport merges them back into the tool result, so this
1288
1406
  * line is what tells the model the image it is about to see belongs to the
1289
1407
  * 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).
1408
+ * because an image-first content array is what some gateways reject.
1409
+ *
1410
+ * The note names the tool call the image came out of, because a turn's
1411
+ * carriers are emitted as a group after the whole tool group (issue #33):
1412
+ * position alone would have to carry the association, and every `read_image`
1413
+ * result renders the same envelope text, so two parallel calls would produce
1414
+ * two identical notes. The id is the WIRE id — the one the model saw on the
1415
+ * `tool-call` it issued and on the tool message just above — so an overlong
1416
+ * cross-provider id is named by the alias that replaced it, not by the
1417
+ * harness-side id the model never saw.
1418
+ *
1419
+ * Count and pixel dimensions are appended when the tool's own text does not
1420
+ * already state them (a result that returns the image and nothing else).
1293
1421
  */
1294
- function toolResultImageNote(media) {
1422
+ function toolResultImageNote(media, toolCallId) {
1423
+ const lead = `${TOOL_RESULT_IMAGE_TEXT} (${toolCallId}):`;
1295
1424
  const first = media.images[0];
1296
- if (first === void 0) return TOOL_RESULT_IMAGE_TEXT;
1425
+ if (first === void 0) return lead;
1297
1426
  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}`;
1427
+ if (first.width <= 0 || first.height <= 0 || media.text.includes(dimensions)) return lead;
1428
+ return `${lead} ${media.images.length > 1 ? `${media.images.length} images, ` : ""}${dimensions}`;
1301
1429
  }
1302
1430
  function hasImageContent(message) {
1303
1431
  const check = (blocks) => blocks.some((b) => b.type === "image" || b.type === "tool-result" && check(b.content));
@@ -1324,9 +1452,15 @@ async function messagesToCC(messages, readImage) {
1324
1452
  const out = [];
1325
1453
  const { ids: paired, names: toolNames } = pairedToolCalls(messages);
1326
1454
  const wireIds = wireToolCallIds(paired);
1455
+ const pendingImages = [];
1456
+ const flushPendingImages = () => {
1457
+ for (const message of pendingImages) out.push(message);
1458
+ pendingImages.length = 0;
1459
+ };
1327
1460
  for (const message of messages) {
1328
1461
  if (message.role === "system") continue;
1329
1462
  if (message.role === "user" && message.source.kind !== "tool") {
1463
+ flushPendingImages();
1330
1464
  const parts = [];
1331
1465
  for (const block of message.content) {
1332
1466
  if (block.type === "text") parts.push({
@@ -1338,6 +1472,7 @@ async function messagesToCC(messages, readImage) {
1338
1472
  parts.push(await imageToCommandCode(block.attachment, readImage));
1339
1473
  }
1340
1474
  }
1475
+ if (parts.length === 0) continue;
1341
1476
  out.push({
1342
1477
  role: "user",
1343
1478
  content: parts
@@ -1345,11 +1480,16 @@ async function messagesToCC(messages, readImage) {
1345
1480
  continue;
1346
1481
  }
1347
1482
  if (message.role === "assistant") {
1483
+ flushPendingImages();
1348
1484
  const parts = [];
1349
1485
  for (const block of message.content) if (block.type === "text") parts.push({
1350
1486
  type: "text",
1351
1487
  text: block.text
1352
1488
  });
1489
+ else if (block.type === "reasoning") parts.push({
1490
+ type: "reasoning",
1491
+ text: block.text
1492
+ });
1353
1493
  else if (block.type === "tool-call" && paired.has(block.id)) parts.push({
1354
1494
  type: "tool-call",
1355
1495
  toolCallId: wireIds.get(block.id) ?? block.id,
@@ -1366,11 +1506,12 @@ async function messagesToCC(messages, readImage) {
1366
1506
  const block = message.content[0];
1367
1507
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1368
1508
  const media = toolResultMedia(block);
1509
+ const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
1369
1510
  out.push({
1370
1511
  role: "tool",
1371
1512
  content: [{
1372
1513
  type: "tool-result",
1373
- toolCallId: wireIds.get(block.toolCallId) ?? block.toolCallId,
1514
+ toolCallId: wireToolCallId,
1374
1515
  toolName: toolNames.get(block.toolCallId) || "unknown",
1375
1516
  output: block.isError ? {
1376
1517
  type: "error-text",
@@ -1385,16 +1526,17 @@ async function messagesToCC(messages, readImage) {
1385
1526
  if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1386
1527
  const carried = [{
1387
1528
  type: "text",
1388
- text: toolResultImageNote(media)
1529
+ text: toolResultImageNote(media, wireToolCallId)
1389
1530
  }];
1390
1531
  for (const attachment of media.images) carried.push(await imageToCommandCode(attachment, readImage));
1391
- out.push({
1532
+ pendingImages.push({
1392
1533
  role: "user",
1393
1534
  content: carried
1394
1535
  });
1395
1536
  }
1396
1537
  }
1397
1538
  }
1539
+ flushPendingImages();
1398
1540
  return out;
1399
1541
  }
1400
1542
  /**
@@ -1422,9 +1564,15 @@ async function messagesToOpenAI(messages, readImage) {
1422
1564
  const out = [];
1423
1565
  const { ids: paired } = pairedToolCalls(messages);
1424
1566
  const wireIds = wireToolCallIds(paired);
1567
+ const pendingImages = [];
1568
+ const flushPendingImages = () => {
1569
+ for (const message of pendingImages) out.push(message);
1570
+ pendingImages.length = 0;
1571
+ };
1425
1572
  for (const message of messages) {
1426
1573
  if (message.role === "system") continue;
1427
1574
  if (message.role === "user" && message.source.kind !== "tool") {
1575
+ flushPendingImages();
1428
1576
  const parts = [];
1429
1577
  for (const block of message.content) {
1430
1578
  if (block.type === "text") parts.push({
@@ -1448,6 +1596,7 @@ async function messagesToOpenAI(messages, readImage) {
1448
1596
  continue;
1449
1597
  }
1450
1598
  if (message.role === "assistant") {
1599
+ flushPendingImages();
1451
1600
  const text = message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1452
1601
  const reasoning = message.content.filter((block) => block.type === "reasoning").map((block) => block.text).join("");
1453
1602
  const toolCalls = message.content.filter((block) => block.type === "tool-call" && paired.has(block.id)).map((block) => ({
@@ -1472,25 +1621,27 @@ async function messagesToOpenAI(messages, readImage) {
1472
1621
  const block = message.content[0];
1473
1622
  if (!block || block.type !== "tool-result" || !paired.has(block.toolCallId)) continue;
1474
1623
  const media = toolResultMedia(block);
1624
+ const wireToolCallId = wireIds.get(block.toolCallId) ?? block.toolCallId;
1475
1625
  out.push({
1476
1626
  role: "tool",
1477
- tool_call_id: wireIds.get(block.toolCallId) ?? block.toolCallId,
1627
+ tool_call_id: wireToolCallId,
1478
1628
  content: toolResultTextForWire(media)
1479
1629
  });
1480
1630
  if (media.images.length > 0) {
1481
1631
  if (!readImage) throw new LlmError("Image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1482
1632
  const carried = [{
1483
1633
  type: "text",
1484
- text: toolResultImageNote(media)
1634
+ text: toolResultImageNote(media, wireToolCallId)
1485
1635
  }];
1486
1636
  for (const attachment of media.images) carried.push(await imageToOpenAI(attachment, readImage));
1487
- out.push({
1637
+ pendingImages.push({
1488
1638
  role: "user",
1489
1639
  content: carried
1490
1640
  });
1491
1641
  }
1492
1642
  }
1493
1643
  }
1644
+ flushPendingImages();
1494
1645
  return out;
1495
1646
  }
1496
1647
  /** Account endpoints fetched by one `getUsage()` run (see the classification there). */
@@ -1595,7 +1746,7 @@ async function buildCliBody(options, connection, facts) {
1595
1746
  type: "function",
1596
1747
  name: tool.name,
1597
1748
  description: tool.description,
1598
- input_schema: tool.parameters
1749
+ input_schema: toolParametersSchema(tool.parameters)
1599
1750
  })),
1600
1751
  system: facts.systemText,
1601
1752
  max_tokens: facts.maxTokens,
@@ -1617,7 +1768,7 @@ async function buildOpenAIBody(options, facts) {
1617
1768
  function: {
1618
1769
  name: tool.name,
1619
1770
  description: tool.description,
1620
- parameters: tool.parameters
1771
+ parameters: toolParametersSchema(tool.parameters)
1621
1772
  }
1622
1773
  }));
1623
1774
  return {
@@ -1875,7 +2026,12 @@ var CommandCodeAdapter = class extends LlmAdapter {
1875
2026
  const access = this.deps.options().filterModelsByPlan === false ? void 0 : await this.loadBillingAccess();
1876
2027
  const visible = this.deps.options().visibleModels;
1877
2028
  const allow = Array.isArray(visible) && visible.length > 0 ? new Set(visible.filter((id) => typeof id === "string" && id !== "")) : void 0;
1878
- return catalog.filter((model) => modelVisibleInPlan(model.id, access)).filter((model) => allow === void 0 || allow.has(model.id)).map(toInfo).sort(compareByPlan);
2029
+ const overrides = this.deps.options().modelVisibility;
2030
+ return catalog.filter((model) => modelVisibleInPlan(model.id, access)).filter((model) => {
2031
+ const override = overrides?.[model.id];
2032
+ if (typeof override === "boolean") return override;
2033
+ return allow === void 0 || allow.has(model.id);
2034
+ }).map(toInfo).sort(compareByPlan);
1879
2035
  }
1880
2036
  async resolveModel(provider, model, signal) {
1881
2037
  const entry = this.catalog.find((m) => m.id === model) ?? (await this.loadCatalog(signal)).find((m) => m.id === model);
@@ -3786,6 +3942,57 @@ var CommandCodeSearchProvider = class {
3786
3942
  const ACTIVE_ACCOUNT_AUTO = "auto";
3787
3943
  /** The selector value meaning "no language override — follow the shell locale". */
3788
3944
  const LANG_AUTO = "auto";
3945
+ /** Tier display names, mirroring the web dropdown's headings (`model-select.ts`). */
3946
+ const TIER_TITLES = {
3947
+ go: "Go",
3948
+ goat: "GOAT",
3949
+ pro: "Pro",
3950
+ provider: "Provider",
3951
+ max: "Max"
3952
+ };
3953
+ /** Tiers in picker order; a model outside this set joins the "Other" group. */
3954
+ const TIER_ORDER = [
3955
+ "go",
3956
+ "goat",
3957
+ "pro",
3958
+ "provider",
3959
+ "max"
3960
+ ];
3961
+ /** The group holding models this build's catalog does not know. */
3962
+ const OTHER_GROUP_ID = "models-other";
3963
+ /**
3964
+ * Every model this build knows, in checkbox order: plan tier (Go first), then
3965
+ * free before paid inside a tier, then by id.
3966
+ *
3967
+ * The list is the static capability snapshot rather than a live catalog read
3968
+ * on purpose — a settings page must draw synchronously, and the snapshot is
3969
+ * synced from the same upstream table the picker's tier headings come from.
3970
+ * A model added upstream after this build still reaches the user: an empty
3971
+ * allowlist shows everything, and a model named in the allowlist but absent
3972
+ * here is rendered by the "Other" group instead of disappearing.
3973
+ */
3974
+ function commandCodeTuiModelChoices() {
3975
+ return Object.keys(KNOWN_PLANS).map((id) => ({
3976
+ id,
3977
+ tier: KNOWN_PLANS[id] ?? "",
3978
+ free: isFreeModel(id),
3979
+ hint: capabilityDescription(id)
3980
+ })).sort((a, b) => {
3981
+ const tierDelta = tierRank(a.tier) - tierRank(b.tier);
3982
+ if (tierDelta !== 0) return tierDelta;
3983
+ if (a.free !== b.free) return a.free ? -1 : 1;
3984
+ return a.id.localeCompare(b.id);
3985
+ });
3986
+ }
3987
+ /** Sort rank of a tier key; unknown tiers trail every known one. */
3988
+ function tierRank(tier) {
3989
+ const rank = TIER_ORDER.indexOf(tier);
3990
+ return rank === -1 ? TIER_ORDER.length : rank;
3991
+ }
3992
+ /** The stored allowlist as a clean id list (non-strings and blanks dropped). */
3993
+ function storedIds(value) {
3994
+ return Array.isArray(value) ? value.filter((id) => typeof id === "string" && id !== "") : [];
3995
+ }
3789
3996
  /**
3790
3997
  * Build the section descriptor. Pure, so tests can pin the exact fields
3791
3998
  * without a dsh-TUI host.
@@ -3795,13 +4002,65 @@ const LANG_AUTO = "auto";
3795
4002
  * cannot express "unset" — cycling only ever lands on a declared option, so a
3796
4003
  * `select` would strand the user on a pinned value with no way back to
3797
4004
  * automatic. The `auto` sentinel plus a `parse` that clears the path keeps the
3798
- * unset state reachable. The two booleans format their EFFECTIVE default
3799
- * (`filterModelsByPlan` unset means true at the adapter), so a fresh install
3800
- * reads true instead of the screen's "(empty)".
4005
+ * unset state reachable. `filterModelsByPlan` formats its EFFECTIVE default
4006
+ * (unset means true at the adapter), so a fresh install reads true instead of
4007
+ * the screen's "(empty)".
3801
4008
  */
3802
4009
  function buildCommandCodeTuiSection(deps) {
3803
4010
  const ref = deps.apiKeyRef();
3804
4011
  const slots = deps.accountSlots();
4012
+ const choices = (deps.modelChoices ?? commandCodeTuiModelChoices)();
4013
+ const catalogIds = choices.map((choice) => choice.id);
4014
+ const known = new Set(catalogIds);
4015
+ const stored = storedIds(deps.visibleModels());
4016
+ const overrides = deps.modelVisibility?.() ?? {};
4017
+ const extras = [.../* @__PURE__ */ new Set([...stored, ...Object.keys(overrides)])].filter((id) => !known.has(id));
4018
+ const tierGroups = TIER_ORDER.filter((tier) => choices.some((choice) => choice.tier === tier)).map((tier) => ({
4019
+ id: `models-${tier}`,
4020
+ title: `${TIER_TITLES[tier] ?? tier} models`,
4021
+ descriptions: { zh: `${TIER_TITLES[tier] ?? tier} 模型` }
4022
+ }));
4023
+ const unranked = choices.filter((choice) => tierRank(choice.tier) === TIER_ORDER.length);
4024
+ const otherGroup = unranked.length > 0 || extras.length > 0 ? [{
4025
+ id: OTHER_GROUP_ID,
4026
+ title: "Other models",
4027
+ descriptions: { zh: "其他模型" }
4028
+ }] : [];
4029
+ /**
4030
+ * One checkbox: a boolean at a path of its OWN (`modelVisibility.<id>`).
4031
+ *
4032
+ * The path must be unique per model. dsh-TUI keys a staged draft by the
4033
+ * field's path (`fieldKey`), so N checkboxes sharing `visibleModels` share
4034
+ * ONE draft: every one of them then parses that same draft on save, all N
4035
+ * write ops address the same path, and only the LAST field's op survives —
4036
+ * which silently rewrote the allowlist from the last catalog model instead
4037
+ * of the one that was toggled. A per-model key is what makes a checkbox
4038
+ * express one model's state.
4039
+ *
4040
+ * An override equal to what the array already says is written as a CLEAR, so
4041
+ * toggling a model back to its inherited state leaves no residue and the
4042
+ * document stays minimal.
4043
+ */
4044
+ const modelField = (id, hint, group) => ({
4045
+ path: ["modelVisibility", id],
4046
+ group,
4047
+ kind: "boolean",
4048
+ label: id,
4049
+ ...hint === "" ? {} : { hint },
4050
+ format: (value) => {
4051
+ if (typeof value === "boolean") return String(value);
4052
+ const listed = storedIds(deps.visibleModels());
4053
+ return String(listed.length === 0 || listed.includes(id));
4054
+ },
4055
+ parse: (text) => {
4056
+ const on = text.trim() === "true";
4057
+ const listed = storedIds(deps.visibleModels());
4058
+ return on === (listed.length === 0 || listed.includes(id)) ? { kind: "clear" } : {
4059
+ kind: "set",
4060
+ value: on
4061
+ };
4062
+ }
4063
+ });
3805
4064
  return {
3806
4065
  ns: deps.ns,
3807
4066
  title: deps.title ?? "Command Code",
@@ -3817,6 +4076,8 @@ function buildCommandCodeTuiSection(deps) {
3817
4076
  title: "Models",
3818
4077
  descriptions: { zh: "模型" }
3819
4078
  },
4079
+ ...tierGroups,
4080
+ ...otherGroup,
3820
4081
  {
3821
4082
  id: "advanced",
3822
4083
  title: "Advanced",
@@ -3865,23 +4126,9 @@ function buildCommandCodeTuiSection(deps) {
3865
4126
  value: text.trim() === "true"
3866
4127
  })
3867
4128
  },
3868
- {
3869
- path: ["visibleModels"],
3870
- group: "models",
3871
- kind: "text",
3872
- label: "Visible models",
3873
- descriptions: { zh: "显示的模型" },
3874
- hint: "Comma-separated catalog ids; empty shows every model.",
3875
- hintDescriptions: { zh: "以逗号分隔的模型 id;留空表示显示全部模型。" },
3876
- format: (value) => (Array.isArray(value) ? value.filter((id) => typeof id === "string" && id !== "") : []).join(", "),
3877
- parse: (text) => {
3878
- const ids = text.split(",").map((id) => id.trim()).filter((id) => id !== "");
3879
- return ids.length === 0 ? { kind: "clear" } : {
3880
- kind: "set",
3881
- value: ids
3882
- };
3883
- }
3884
- },
4129
+ ...choices.filter((choice) => tierRank(choice.tier) !== TIER_ORDER.length).map((choice) => modelField(choice.id, choice.hint, `models-${choice.tier}`)),
4130
+ ...unranked.map((choice) => modelField(choice.id, choice.hint, OTHER_GROUP_ID)),
4131
+ ...extras.map((id) => modelField(id, capabilityDescription(id), OTHER_GROUP_ID)),
3885
4132
  {
3886
4133
  path: ["activeAccount"],
3887
4134
  group: "advanced",
@@ -3968,15 +4215,18 @@ function tuiSettingsService(ctx) {
3968
4215
  }
3969
4216
  /**
3970
4217
  * Identity of everything in the section that can change at runtime: the
3971
- * credential reference behind the API-key field and the account slots behind
3972
- * the active-account selector. Re-registration is skipped while this matches,
3973
- * so ordinary settings writes never churn the screen's section list.
4218
+ * credential reference behind the API-key field, the account slots behind the
4219
+ * active-account selector, and the stored-but-unknown allowlist entries that
4220
+ * get a checkbox of their own. Re-registration is skipped while this matches,
4221
+ * so ordinary settings writes never churn the screen's section list — the
4222
+ * checkboxes themselves read their state live and need no re-declaration.
3974
4223
  */
3975
4224
  function sectionSignature(section) {
3976
4225
  const active = section.fields.find((field) => field.path.join(".") === "activeAccount");
3977
4226
  return JSON.stringify({
3978
4227
  secret: section.fields.find((field) => field.secret !== void 0)?.secret?.ref ?? "",
3979
- options: active?.options?.map((option) => option.value) ?? []
4228
+ options: active?.options?.map((option) => option.value) ?? [],
4229
+ other: section.fields.filter((field) => field.group === OTHER_GROUP_ID).map((field) => field.label)
3980
4230
  });
3981
4231
  }
3982
4232
  /**
@@ -4065,6 +4315,15 @@ const Config = z.object({
4065
4315
  streamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS),
4066
4316
  filterModelsByPlan: z.boolean(),
4067
4317
  visibleModels: z.array(z.string()),
4318
+ /**
4319
+ * Per-model visibility overrides for the terminal settings page's checkbox
4320
+ * list, keyed by catalog id. dsh-TUI addresses a staged edit by its field
4321
+ * PATH, so two checkboxes sharing one path would overwrite each other's
4322
+ * draft and the section's last model would decide every write; a map gives
4323
+ * each checkbox a path of its own. An id listed here wins over
4324
+ * {@link visibleModels}; ids absent here keep following it.
4325
+ */
4326
+ modelVisibility: z.dict(z.boolean()),
4068
4327
  webSearch: z.boolean().default(true),
4069
4328
  accounts: z.array(z.object({
4070
4329
  label: z.string(),
@@ -4094,9 +4353,22 @@ function resolveAdapterOptions(config) {
4094
4353
  requestTimeoutMs: config.requestTimeoutMs ?? 6e4,
4095
4354
  streamIdleTimeoutMs: config.streamIdleTimeoutMs ?? 3e5,
4096
4355
  filterModelsByPlan: config.filterModelsByPlan ?? true,
4097
- visibleModels: Array.isArray(config.visibleModels) ? config.visibleModels.filter((id) => typeof id === "string" && id !== "") : void 0
4356
+ visibleModels: Array.isArray(config.visibleModels) ? config.visibleModels.filter((id) => typeof id === "string" && id !== "") : void 0,
4357
+ modelVisibility: readModelVisibility(config.modelVisibility)
4098
4358
  };
4099
4359
  }
4360
+ /**
4361
+ * Per-model visibility overrides, cleaned for the adapter. Programmatic
4362
+ * construction may bypass Schemastery normalization, so a non-object or a
4363
+ * non-boolean entry is dropped here instead of reaching the picker filter.
4364
+ * @param raw - The `modelVisibility` value from any config source.
4365
+ * @returns A frozen id → boolean map, or undefined when nothing is set.
4366
+ */
4367
+ function readModelVisibility(raw) {
4368
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return void 0;
4369
+ const entries = Object.entries(raw).filter((entry) => entry[0] !== "" && typeof entry[1] === "boolean");
4370
+ return entries.length === 0 ? void 0 : Object.fromEntries(entries);
4371
+ }
4100
4372
  function apply(ctx, config) {
4101
4373
  let current = () => config;
4102
4374
  let lastRaw;
@@ -4268,7 +4540,9 @@ function apply(ctx, config) {
4268
4540
  accountSlots: () => slots().map((slot) => ({
4269
4541
  id: slot.id,
4270
4542
  label: slot.label
4271
- }))
4543
+ })),
4544
+ visibleModels: () => options().visibleModels ?? [],
4545
+ modelVisibility: () => options().modelVisibility
4272
4546
  });
4273
4547
  tuiCtx.effect(() => () => {
4274
4548
  refreshTuiSettings = void 0;