@f5-sales-demo/xcsh 22.2.1 → 22.3.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.
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "22.2.1",
21
- "commit": "36bd12837ef237223ef118e02b72b46534defe70",
22
- "shortCommit": "36bd128",
20
+ "version": "22.3.3",
21
+ "commit": "82917afa08ee15956c363dacfb1c0f4511c74d3e",
22
+ "shortCommit": "82917af",
23
23
  "branch": "main",
24
- "tag": "v22.2.1",
25
- "commitDate": "2026-09-28T02:56:41Z",
26
- "buildDate": "2026-09-28T03:32:41.148Z",
24
+ "tag": "v22.3.3",
25
+ "commitDate": "2026-09-28T13:54:02Z",
26
+ "buildDate": "2026-09-28T14:30:23.798Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/36bd12837ef237223ef118e02b72b46534defe70",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v22.2.1"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/82917afa08ee15956c363dacfb1c0f4511c74d3e",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v22.3.3"
33
33
  };
@@ -6,12 +6,13 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { gunzipSync } from "node:zlib";
8
8
  import { createStore } from "@tobilu/qmd";
9
- import type {
10
- DocumentationAsset,
11
- DocumentationDocument,
12
- DocumentationRepository,
13
- DocumentationSearchResult,
14
- DocumentationSource,
9
+ import {
10
+ DOCUMENTATION_SOURCES,
11
+ type DocumentationAsset,
12
+ type DocumentationDocument,
13
+ type DocumentationRepository,
14
+ type DocumentationSearchResult,
15
+ type DocumentationSource,
15
16
  } from "./documentation-resolve";
16
17
  import { extractVerifiedDocumentationAsset, type VerifiedDocumentationAsset } from "./documentation-snapshot";
17
18
 
@@ -259,6 +260,23 @@ function boundedSnippet(markdown: string, query: string): string {
259
260
  return `${prefix}${body.slice(start, start + 598 - prefix.length - suffix.length)}${suffix}`;
260
261
  }
261
262
 
263
+ function lexicalDocumentationQuery(query: string): string {
264
+ return query.replace(/^\s*(?:what|who)\s+(?:is|are)\s+/i, "").trim() || query;
265
+ }
266
+
267
+ const SUPPORT_QUERY = /\b(?:troubleshoot|support|knowledge[- ]base|error|failure|issue|K[0-9]{6,})\b/i;
268
+ const CONFIGURATION_QUERY = /\b(?:configure|configuration|set\s*up|procedure|instructions?|how\s+(?:do|can|to))\b/i;
269
+ const CONCEPTUAL_QUERY = /\b(?:what\s+(?:is|are)|overview|product|solution|capabilities|benefits)\b/i;
270
+ const MARKETING_TOPIC =
271
+ /\b(?:client[- ]side defense|distributed cloud|web (?:app|application) and api protection|multi[- ]cloud networking|dns load balancer|bot defense|api security|app connect|appstack|content delivery network|cdn|mobile app shield|synthetic monitoring|web app scanning)\b/i;
272
+
273
+ function preferredDocumentationSource(query: string): DocumentationSource | undefined {
274
+ if (SUPPORT_QUERY.test(query)) return "my-f5-com";
275
+ if (CONFIGURATION_QUERY.test(query)) return "docs-cloud-f5-com";
276
+ if (CONCEPTUAL_QUERY.test(query) || MARKETING_TOPIC.test(query)) return "www-f5-com";
277
+ return undefined;
278
+ }
279
+
262
280
  export function createEmbeddedDocumentationRepository(
263
281
  assets: EmbeddedDocumentationAssets,
264
282
  options: EmbeddedDocumentationRepositoryOptions = {},
@@ -297,7 +315,27 @@ export function createEmbeddedDocumentationRepository(
297
315
  },
298
316
  search: async (query, source, limit): Promise<readonly DocumentationSearchResult[]> => {
299
317
  const current = await state();
300
- const results = await current.store.searchLex(query, { limit, collection: source });
318
+ const lexicalQuery = lexicalDocumentationQuery(query);
319
+ const preferredSource = source ? undefined : preferredDocumentationSource(query);
320
+ const results = source
321
+ ? await current.store.searchLex(lexicalQuery, { limit, collection: source })
322
+ : (
323
+ await Promise.all(
324
+ DOCUMENTATION_SOURCES.map(collection =>
325
+ current.store.searchLex(lexicalQuery, { limit, collection }),
326
+ ),
327
+ )
328
+ ).flat();
329
+ results.sort((left, right) => {
330
+ const sourceOrder =
331
+ Number(right.collectionName === preferredSource) - Number(left.collectionName === preferredSource);
332
+ return (
333
+ sourceOrder ||
334
+ right.score - left.score ||
335
+ left.collectionName.localeCompare(right.collectionName) ||
336
+ left.displayPath.localeCompare(right.displayPath)
337
+ );
338
+ });
301
339
  const rows: DocumentationSearchResult[] = [];
302
340
  const lookup = current.database.query(
303
341
  "SELECT source, stable_path, title, original_url, markdown FROM documentation_documents WHERE source = ? AND stable_path = ?",
@@ -1,6 +1,6 @@
1
1
  import type { InternalResource, InternalUrl } from "./types";
2
2
 
3
- export const DOCUMENTATION_SOURCES = ["docs-cloud-f5-com", "my-f5-com"] as const;
3
+ export const DOCUMENTATION_SOURCES = ["docs-cloud-f5-com", "my-f5-com", "www-f5-com"] as const;
4
4
  export type DocumentationSource = (typeof DOCUMENTATION_SOURCES)[number];
5
5
 
6
6
  export interface DocumentationProvenance {
@@ -68,7 +68,13 @@ const DOCUMENT_PROVENANCE_KEYS = [
68
68
  const SOURCE_ROOT_URLS: Readonly<Record<DocumentationSource, string>> = {
69
69
  "docs-cloud-f5-com": "https://docs.cloud.f5.com/docs-v2",
70
70
  "my-f5-com": "https://my.f5.com/manage/s",
71
+ "www-f5-com": "https://www.f5.com/products/distributed-cloud-services",
71
72
  };
73
+ const MARKETING_SOLUTION_URLS = new Set([
74
+ "https://www.f5.com/solutions/use-cases/hybrid-multicloud-application-delivery",
75
+ "https://www.f5.com/solutions/use-cases/multi-cloud-networking",
76
+ "https://www.f5.com/solutions/web-app-and-api-protection",
77
+ ]);
72
78
 
73
79
  export interface DocumentationReleaseAssetPin {
74
80
  readonly sha256: string;
@@ -182,7 +188,11 @@ function validateTimestamp(value: unknown, field: string): string {
182
188
  function validateOriginalUrl(value: unknown, source: DocumentationSource, field: string): string {
183
189
  const result = string(value, field);
184
190
  const root = SOURCE_ROOT_URLS[source];
185
- if (result !== root && !result.startsWith(`${root}/`) && !result.startsWith(`${root}?`)) {
191
+ const allowed =
192
+ source === "www-f5-com"
193
+ ? result === root || result.startsWith(`${root}/`) || MARKETING_SOLUTION_URLS.has(result)
194
+ : result === root || result.startsWith(`${root}/`) || result.startsWith(`${root}?`);
195
+ if (!allowed) {
186
196
  throw new Error(`${field} is outside the declared source root`);
187
197
  }
188
198
  return result;
@@ -598,6 +608,12 @@ export async function verifyDocumentationRelease(
598
608
  const markdown = entry.data.toString("utf8");
599
609
  const parsed = splitDocument(markdown);
600
610
  const originalUrl = validateOriginalUrl(item.url, source, "manifest document URL");
611
+ if (
612
+ source === "www-f5-com" &&
613
+ archiveMemberPath !== `content/www-f5-com${new URL(originalUrl).pathname}/index.md`
614
+ ) {
615
+ throw new Error(`marketing document path does not match its reviewed URL: ${archiveMemberPath}`);
616
+ }
601
617
  if (parsed.metadata.sourceId !== source || parsed.metadata.url !== originalUrl)
602
618
  throw new Error(`manifest document frontmatter mismatch: ${archiveMemberPath}`);
603
619
  const bodySha256 = string(item.body_sha256, "manifest document body hash");
@@ -620,6 +636,9 @@ export async function verifyDocumentationRelease(
620
636
  };
621
637
  });
622
638
 
639
+ const marketingDocumentPaths = new Set(
640
+ documents.filter(document => document.source === "www-f5-com").map(document => document.stablePath),
641
+ );
623
642
  const assets = manifest.assets.map((value, position): VerifiedDocumentationAsset => {
624
643
  const item = object(value, `manifest asset ${position}`);
625
644
  exactKeys(item, MANIFEST_ASSET_KEYS, `manifest asset ${position}`);
@@ -631,6 +650,9 @@ export async function verifyDocumentationRelease(
631
650
  const source = archiveMemberPath.split("/")[1] as DocumentationSource;
632
651
  if (!DOCUMENTATION_SOURCES.includes(source)) throw new Error(`unknown manifest asset source: ${source}`);
633
652
  const parsed = stableAssetPath(archiveMemberPath, source);
653
+ if (source === "www-f5-com" && !marketingDocumentPaths.has(parsed.stablePath)) {
654
+ throw new Error(`marketing asset is not attached to a reviewed document: ${archiveMemberPath}`);
655
+ }
634
656
  const digest = string(item.sha256, "manifest asset hash");
635
657
  if (!SHA256.test(digest) || !parsed.filename.startsWith(`${digest}.`)) {
636
658
  throw new Error(`manifest asset digest filename mismatch: ${archiveMemberPath}`);
@@ -843,6 +865,11 @@ export async function buildDocumentationIndex(
843
865
  pattern: "**/*.md",
844
866
  context: { "/": "MyF5 support and knowledge-base documentation" },
845
867
  },
868
+ "www-f5-com": {
869
+ path: path.join(documentsRoot, "www-f5-com"),
870
+ pattern: "**/*.md",
871
+ context: { "/": "F5 Distributed Cloud product and solution overviews" },
872
+ },
846
873
  },
847
874
  },
848
875
  });
@@ -1,5 +1,5 @@
1
1
  export type KnowledgeRoute = "api" | "documentation" | "direct-uri" | "none";
2
- export type DocumentationKnowledgeSource = "docs-cloud-f5-com" | "my-f5-com";
2
+ export type DocumentationKnowledgeSource = "docs-cloud-f5-com" | "my-f5-com" | "www-f5-com";
3
3
 
4
4
  export interface KnowledgeClassifierResource {
5
5
  readonly name: string;
@@ -46,7 +46,11 @@ const ANAPHORIC = /\b(?:its|it|that|this|those|them|the same)\b/i;
46
46
  const API_METADATA =
47
47
  /\b(?:api|endpoint|operation\s*id|api\s+path|http\s+method|method|payload|request\s+body|required\s+fields?|enum|allowed\s+values?|constraints?|limits?|maximum|max(?:imum)?|minimum|min(?:imum)?|how\s+many|create|creates|creating|get|gets|list|lists|update|updates|replace|replaces|delete|deletes|clone|import)\b/i;
48
48
  const DOCUMENTATION_INTENT =
49
- /\b(?:documentation|docs?|guide|tutorial|how\s+(?:do|can|to)|configure|configuration|set\s*up|troubleshoot|procedure|instructions?)\b/i;
49
+ /\b(?:documentation|docs?|guide|tutorial|how\s+(?:do|can|to)|configure|configuration|set\s*up|troubleshoot|procedure|instructions?|what\s+(?:is|are)|overview|product|solution|capabilities|benefits)\b/i;
50
+ const CONFIGURATION_INTENT = /\b(?:configure|configuration|set\s*up|procedure|instructions?|how\s+(?:do|can|to))\b/i;
51
+ const SUPPORT_INTENT = /\b(?:troubleshoot|support|knowledge[- ]base|error|failure|issue|K[0-9]{6,})\b/i;
52
+ const MARKETING_TOPIC =
53
+ /\b(?:client[- ]side defense|distributed cloud|web (?:app|application) and api protection|multi[- ]cloud networking|dns load balancer|bot defense|api security|app connect|appstack|content delivery network|cdn|mobile app shield|synthetic monitoring|web app scanning)\b/i;
50
54
  const EXCLUDED_API_INTENT = /\b(?:pricing|price|quote|licen[cs]ing|sales)\b/i;
51
55
  const DIRECT_URI = /\bxcsh:\/\/(?:api-catalog|api-spec|documentation)\/[^\s)>\]}]*/i;
52
56
 
@@ -130,7 +134,9 @@ function direct(prompt: string): KnowledgeClassification | null {
130
134
  }
131
135
  if (parsed.hostname === "documentation") {
132
136
  const source = parsed.pathname.split("/").filter(Boolean)[0];
133
- if (source === "docs-cloud-f5-com" || source === "my-f5-com") Object.assign(constraints, { source });
137
+ if (source === "docs-cloud-f5-com" || source === "my-f5-com" || source === "www-f5-com") {
138
+ Object.assign(constraints, { source });
139
+ }
134
140
  }
135
141
  return { route: "direct-uri", confidence: 1, directUri, constraints, query: EMPTY_QUERY };
136
142
  } catch {
@@ -191,9 +197,17 @@ export function classifyKnowledgeRequest(prompt: string, options: KnowledgeClass
191
197
  if (
192
198
  !thirdPartyOnly &&
193
199
  (DOCUMENTATION_INTENT.test(prompt) || (ANAPHORIC.test(prompt) && previousDocumentation)) &&
194
- (F5_SCOPE.test(prompt) || /\b(?:documentation|docs?)\b/i.test(prompt) || previousDocumentation)
200
+ (F5_SCOPE.test(prompt) ||
201
+ /\b(?:documentation|docs?)\b/i.test(prompt) ||
202
+ MARKETING_TOPIC.test(prompt) ||
203
+ previousDocumentation)
195
204
  ) {
196
- const source = options.documentationSource ?? previousDocumentation?.source;
205
+ const inferredSource = SUPPORT_INTENT.test(prompt)
206
+ ? "my-f5-com"
207
+ : CONFIGURATION_INTENT.test(prompt)
208
+ ? "docs-cloud-f5-com"
209
+ : "www-f5-com";
210
+ const source = options.documentationSource ?? previousDocumentation?.source ?? inferredSource;
197
211
  const subject = documentationSubject(prompt);
198
212
  return {
199
213
  route: "documentation",
@@ -1,5 +1,11 @@
1
1
  import { ThinkingLevel } from "@f5-sales-demo/pi-agent-core";
2
- import { getSupportedReasoningEfforts, type Model, modelsAreEqual, ReasoningEffort } from "@f5-sales-demo/pi-ai";
2
+ import {
3
+ type CodexContextTier,
4
+ getSupportedReasoningEfforts,
5
+ type Model,
6
+ modelsAreEqual,
7
+ ReasoningEffort,
8
+ } from "@f5-sales-demo/pi-ai";
3
9
  import {
4
10
  Container,
5
11
  getKeybindings,
@@ -58,6 +64,10 @@ export function reasoningLabel(level: ThinkingLevel): string {
58
64
  }[level] ?? level
59
65
  );
60
66
  }
67
+
68
+ function formatContextSize(tokens: number): string {
69
+ return `${Math.round(tokens / 1_000)}K`;
70
+ }
61
71
  export interface ModelNavigationContext {
62
72
  initialSearchInput?: string;
63
73
  initialProvider?: string;
@@ -312,6 +322,8 @@ export interface ModelSelection {
312
322
  model: Model;
313
323
  selector: string;
314
324
  thinkingLevel: ThinkingLevel;
325
+ /** Provider-wide context policy, present only for supported OpenAI routes. */
326
+ contextTier?: CodexContextTier;
315
327
  role?: string;
316
328
  }
317
329
  type RoleSelectCallback = (selection: ModelSelection) => boolean | undefined | Promise<boolean | undefined>;
@@ -398,19 +410,28 @@ export class ModelSelectorComponent extends Container {
398
410
  ];
399
411
  if (this.#isMenuOpen && selected) {
400
412
  details.push(...wrapTextWithAnsi(selected.selector, inner));
401
- title = getModelDisplayName(selected.model);
413
+ title =
414
+ this.#menuStep === "thinking"
415
+ ? "Reasoning"
416
+ : this.#menuStep === "context"
417
+ ? "Context window"
418
+ : getModelDisplayName(selected.model);
402
419
  const labels =
403
- this.#menuStep === "scope"
404
- ? this.#scopeActions()
405
- : this.#menuStep === "role"
406
- ? this.#menuRoleActions.map(action => action.label)
407
- : this.#getThinkingLevelsForModel(selected.model).map(reasoningLabel);
420
+ this.#menuStep === "context"
421
+ ? this.#contextActions(selected.model).map(action => action.label)
422
+ : this.#menuStep === "scope"
423
+ ? this.#scopeActions()
424
+ : this.#menuStep === "role"
425
+ ? this.#menuRoleActions.map(action => action.label)
426
+ : this.#getThinkingLevelsForModel(selected.model).map(reasoningLabel);
408
427
  purpose =
409
428
  this.#menuStep === "thinking"
410
- ? `Reasoning · ${this.#menuScope === "conversation" ? "This conversation" : this.#menuScope === "default" ? "Saved default" : `Role: ${this.#menuSelectedRole}`}`
411
- : this.#menuStep === "scope"
412
- ? "Choose where this model applies."
413
- : "Choose a specialist role.";
429
+ ? "Choose reasoning for this model."
430
+ : this.#menuStep === "context"
431
+ ? "This provider-wide setting controls the usable context window. Scope controls only the model and reasoning assignment."
432
+ : this.#menuStep === "scope"
433
+ ? "Choose where this model applies."
434
+ : "Choose a specialist role.";
414
435
  body = labels.map((label, i) => selectorRow([label], [inner - 2], i === this.#menuSelectedIndex));
415
436
  selectedBodyIndex = this.#menuSelectedIndex;
416
437
  if (this.#menuStep === "scope")
@@ -422,10 +443,17 @@ export class ModelSelectorComponent extends Container {
422
443
  ][this.#menuSelectedIndex],
423
444
  );
424
445
  else if (this.#menuStep === "thinking") details.push(`Reasoning: ${labels[this.#menuSelectedIndex]}`);
446
+ else if (this.#menuStep === "context") {
447
+ const context = this.#contextActions(selected.model)[this.#menuSelectedIndex];
448
+ if (context) {
449
+ details.push(`Usable window: ${formatContextSize(context.effectiveContextWindow)}.`);
450
+ details.push(`Automatic compaction begins at ${formatContextSize(context.autoCompactTokenLimit)}.`);
451
+ }
452
+ }
425
453
  if (this.#errorMessage)
426
454
  details.push(`Could not apply: ${String(this.#errorMessage)}. ${selectorKeys("confirm")} to retry.`);
427
455
  footer = [
428
- selectorNavigationHint(this.#menuStep === "thinking" ? "confirm" : "continue"),
456
+ selectorNavigationHint(this.#menuStep === "scope" || this.#menuStep === "role" ? "continue" : "confirm"),
429
457
  selectorCancelHint(),
430
458
  ];
431
459
  } else {
@@ -585,13 +613,15 @@ export class ModelSelectorComponent extends Container {
585
613
  // Context menu state
586
614
  #isMenuOpen: boolean = false;
587
615
  #menuSelectedIndex: number = 0;
588
- #menuStep: "scope" | "role" | "thinking" = "scope";
616
+ #menuStep: "thinking" | "context" | "scope" | "role" = "thinking";
589
617
  #menuScope: ModelSelection["scope"] = "conversation";
590
618
  #menuItem?: ModelItem | CanonicalModelItem;
591
619
  #applying = false;
592
620
  #onLogin?: () => void;
593
621
  #currentThinkingLevel: ThinkingLevel = ThinkingLevel.Inherit;
594
622
  #menuSelectedRole: string | null = null;
623
+ #menuThinkingLevel: ThinkingLevel = ThinkingLevel.Inherit;
624
+ #menuContextTier?: CodexContextTier;
595
625
 
596
626
  constructor(
597
627
  tui: TUI,
@@ -1197,6 +1227,47 @@ export class ModelSelectorComponent extends Container {
1197
1227
  return foundIndex >= 0 ? foundIndex : 0;
1198
1228
  }
1199
1229
 
1230
+ #contextSettingFor(model: Model): "providers.openaiContextTier" | "providers.litellmContextTier" | undefined {
1231
+ if (model.provider === "openai-codex") return "providers.openaiContextTier";
1232
+ if (model.provider === "litellm") return "providers.litellmContextTier";
1233
+ return undefined;
1234
+ }
1235
+
1236
+ #contextActions(model: Model): Array<{
1237
+ tier: CodexContextTier;
1238
+ label: string;
1239
+ effectiveContextWindow: number;
1240
+ autoCompactTokenLimit: number;
1241
+ }> {
1242
+ const setting = this.#contextSettingFor(model);
1243
+ if (!setting || !model.maxContextWindow) return [];
1244
+ // The registry has already clamped this to the safe advertised provider limit.
1245
+ const providerMaximum = model.providerContextWindow ?? model.maxContextWindow;
1246
+ const standard = Math.min(272_000, providerMaximum);
1247
+ const codexMaximum = Math.min(model.maxContextWindow, providerMaximum);
1248
+ const effectivePercent = model.effectiveContextWindowPercent ?? 95;
1249
+ const compactPercent = model.autoCompactThresholdPercent ?? 90;
1250
+ const current = this.#settings.get(setting) as CodexContextTier;
1251
+ return [
1252
+ ["standard", "Standard", standard],
1253
+ ["codex-max", "Codex maximum", codexMaximum],
1254
+ ["provider-max", "Provider maximum", providerMaximum],
1255
+ ].map(([tier, label, size]) => ({
1256
+ tier: tier as CodexContextTier,
1257
+ label: `${label} — ${formatContextSize(size as number)}${tier === current ? " (current/default)" : ""}`,
1258
+ effectiveContextWindow: Math.floor(((size as number) * effectivePercent) / 100),
1259
+ autoCompactTokenLimit: Math.floor(((size as number) * compactPercent) / 100),
1260
+ }));
1261
+ }
1262
+
1263
+ #contextPreselectIndex(model: Model): number {
1264
+ const setting = this.#contextSettingFor(model);
1265
+ if (!setting) return 0;
1266
+ const current = this.#settings.get(setting) as CodexContextTier;
1267
+ const index = this.#contextActions(model).findIndex(action => action.tier === current);
1268
+ return index >= 0 ? index : 0;
1269
+ }
1270
+
1200
1271
  #getSelectedItem(): ModelItem | CanonicalModelItem | undefined {
1201
1272
  return this.#isCanonicalTab()
1202
1273
  ? this.#filteredCanonicalModels[this.#selectedIndex]
@@ -1214,29 +1285,31 @@ export class ModelSelectorComponent extends Container {
1214
1285
  if (!this.#menuItem || this.#isItemDisabled(this.#menuItem)) return;
1215
1286
  this.#isMenuOpen = true;
1216
1287
  this.#menuScope = "conversation";
1217
- this.#menuStep = "scope";
1288
+ this.#menuStep = "thinking";
1218
1289
  this.#menuSelectedRole = null;
1219
- this.#menuSelectedIndex = 0;
1290
+ this.#menuThinkingLevel = this.#getThinkingLevelsForModel(this.#menuItem.model)[0] ?? ThinkingLevel.Inherit;
1291
+ this.#menuContextTier = undefined;
1292
+ this.#menuSelectedIndex = this.#getThinkingPreselectIndex("default", this.#menuItem.model);
1220
1293
  this.#updateMenu();
1221
1294
  }
1222
1295
 
1223
- #openThinkingMenu(role: string): void {
1296
+ #openScopeMenu(): void {
1224
1297
  const selected = this.#menuItem ?? this.#getSelectedItem();
1225
1298
  if (!selected) return;
1226
1299
  this.#menuItem = selected;
1227
1300
  this.#isMenuOpen = true;
1228
- this.#menuStep = "thinking";
1229
- this.#menuSelectedRole = role;
1230
- this.#menuSelectedIndex = this.#getThinkingPreselectIndex(role, selected.model);
1301
+ this.#menuStep = "scope";
1302
+ this.#menuSelectedIndex = 0;
1231
1303
  this.#updateMenu();
1232
1304
  }
1233
1305
 
1234
1306
  #closeMenu(): void {
1235
1307
  this.#resetDetailPage();
1236
1308
  this.#isMenuOpen = false;
1237
- this.#menuStep = "role";
1309
+ this.#menuStep = "thinking";
1238
1310
  this.#menuSelectedRole = null;
1239
1311
  this.#menuItem = undefined;
1312
+ this.#menuContextTier = undefined;
1240
1313
  this.#updateList();
1241
1314
  }
1242
1315
 
@@ -1336,11 +1409,13 @@ export class ModelSelectorComponent extends Container {
1336
1409
  if (!selectedItem) return;
1337
1410
 
1338
1411
  const optionCount =
1339
- this.#menuStep === "thinking" && this.#menuSelectedRole !== null
1412
+ this.#menuStep === "thinking"
1340
1413
  ? this.#getThinkingLevelsForModel(selectedItem.model).length
1341
- : this.#menuStep === "scope"
1342
- ? this.#scopeActions().length
1343
- : this.#menuRoleActions.length;
1414
+ : this.#menuStep === "context"
1415
+ ? this.#contextActions(selectedItem.model).length
1416
+ : this.#menuStep === "scope"
1417
+ ? this.#scopeActions().length
1418
+ : this.#menuRoleActions.length;
1344
1419
  if (optionCount === 0) return;
1345
1420
 
1346
1421
  if (matchesSelectorKey(keyData, "up")) {
@@ -1356,13 +1431,31 @@ export class ModelSelectorComponent extends Container {
1356
1431
  }
1357
1432
 
1358
1433
  if (matchesSelectorKey(keyData, "confirm")) {
1434
+ if (this.#menuStep === "thinking") {
1435
+ const thinking = this.#getThinkingLevelsForModel(selectedItem.model)[this.#menuSelectedIndex];
1436
+ if (!thinking) return;
1437
+ this.#menuThinkingLevel = thinking;
1438
+ if (this.#contextActions(selectedItem.model).length > 0) {
1439
+ this.#menuStep = "context";
1440
+ this.#menuSelectedIndex = this.#contextPreselectIndex(selectedItem.model);
1441
+ this.#updateMenu();
1442
+ } else this.#openScopeMenu();
1443
+ return;
1444
+ }
1445
+ if (this.#menuStep === "context") {
1446
+ const context = this.#contextActions(selectedItem.model)[this.#menuSelectedIndex];
1447
+ if (!context) return;
1448
+ this.#menuContextTier = context.tier;
1449
+ this.#openScopeMenu();
1450
+ return;
1451
+ }
1359
1452
  if (this.#menuStep === "scope") {
1360
1453
  this.#menuScope = (["conversation", "default", "role"] as const)[this.#menuSelectedIndex];
1361
1454
  if (this.#menuScope === "role") {
1362
1455
  this.#menuStep = "role";
1363
1456
  this.#menuSelectedIndex = 0;
1364
1457
  this.#updateMenu();
1365
- } else this.#openThinkingMenu("default");
1458
+ } else void this.#handleSelect(selectedItem, this.#menuThinkingLevel);
1366
1459
  return;
1367
1460
  }
1368
1461
  if (this.#menuStep === "role") {
@@ -1370,27 +1463,41 @@ export class ModelSelectorComponent extends Container {
1370
1463
  const action = this.#menuRoleActions[this.#menuSelectedIndex];
1371
1464
  if (!action) return;
1372
1465
  this.#menuSelectedRole = action.role;
1373
- this.#menuStep = "thinking";
1374
- this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
1375
- this.#updateMenu();
1466
+ void this.#handleSelect(selectedItem, this.#menuThinkingLevel);
1376
1467
  return;
1377
1468
  }
1378
-
1379
- if (!this.#menuSelectedRole) return;
1380
- const thinkingOptions = this.#getThinkingLevelsForModel(selectedItem.model);
1381
- const thinkingLevel = thinkingOptions[this.#menuSelectedIndex];
1382
- if (!thinkingLevel) return;
1383
- void this.#handleSelect(selectedItem, thinkingLevel);
1384
- return;
1385
1469
  }
1386
1470
 
1387
1471
  if (getKeybindings().matches(keyData, "tui.select.cancel")) {
1388
- if (this.#menuStep === "thinking" && this.#menuSelectedRole !== null) {
1389
- this.#menuStep = this.#menuScope === "role" ? "role" : "scope";
1390
- const roleIndex = this.#menuRoleActions.findIndex(action => action.role === this.#menuSelectedRole);
1472
+ if (this.#menuStep === "role") {
1473
+ this.#menuStep = "scope";
1391
1474
  this.#menuSelectedRole = null;
1392
- this.#menuSelectedIndex =
1393
- this.#menuScope === "role" ? Math.max(0, roleIndex) : this.#menuScope === "default" ? 1 : 0;
1475
+ this.#menuSelectedIndex = 2;
1476
+ this.#updateMenu();
1477
+ return;
1478
+ }
1479
+ if (this.#menuStep === "scope") {
1480
+ if (this.#contextActions(selectedItem.model).length > 0) {
1481
+ this.#menuStep = "context";
1482
+ this.#menuSelectedIndex = this.#contextActions(selectedItem.model).findIndex(
1483
+ action => action.tier === this.#menuContextTier,
1484
+ );
1485
+ if (this.#menuSelectedIndex < 0)
1486
+ this.#menuSelectedIndex = this.#contextPreselectIndex(selectedItem.model);
1487
+ } else {
1488
+ this.#menuStep = "thinking";
1489
+ this.#menuSelectedIndex = this.#getThinkingLevelsForModel(selectedItem.model).indexOf(
1490
+ this.#menuThinkingLevel,
1491
+ );
1492
+ }
1493
+ this.#updateMenu();
1494
+ return;
1495
+ }
1496
+ if (this.#menuStep === "context") {
1497
+ this.#menuStep = "thinking";
1498
+ this.#menuSelectedIndex = this.#getThinkingLevelsForModel(selectedItem.model).indexOf(
1499
+ this.#menuThinkingLevel,
1500
+ );
1394
1501
  this.#updateMenu();
1395
1502
  return;
1396
1503
  }
@@ -1411,6 +1518,7 @@ export class ModelSelectorComponent extends Container {
1411
1518
  model: item.model,
1412
1519
  selector: item.selector,
1413
1520
  thinkingLevel,
1521
+ ...(this.#menuContextTier ? { contextTier: this.#menuContextTier } : {}),
1414
1522
  ...(this.#menuScope === "role" ? { role: this.#menuSelectedRole! } : {}),
1415
1523
  };
1416
1524
  this.#applying = true;
@@ -281,6 +281,19 @@ export class InputController {
281
281
  return;
282
282
  }
283
283
 
284
+ // Handle built-in slash commands
285
+ const slashResult = await executeBuiltinSlashCommand(text, {
286
+ ctx: this.ctx,
287
+ handleBackgroundCommand: () => this.handleBackgroundCommand(),
288
+ });
289
+ if (slashResult === true) {
290
+ return;
291
+ }
292
+ if (typeof slashResult === "string") {
293
+ // Command handled but returned remaining text to use as prompt
294
+ text = slashResult;
295
+ }
296
+
284
297
  const runner = this.ctx.session.extensionRunner;
285
298
  let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
286
299
 
@@ -301,19 +314,6 @@ export class InputController {
301
314
 
302
315
  if (!text) return;
303
316
 
304
- // Handle built-in slash commands
305
- const slashResult = await executeBuiltinSlashCommand(text, {
306
- ctx: this.ctx,
307
- handleBackgroundCommand: () => this.handleBackgroundCommand(),
308
- });
309
- if (slashResult === true) {
310
- return;
311
- }
312
- if (typeof slashResult === "string") {
313
- // Command handled but returned remaining text to use as prompt
314
- text = slashResult;
315
- }
316
-
317
317
  // Handle skill commands (/skill:name [args])
318
318
  if (text.startsWith("/skill:")) {
319
319
  const spaceIndex = text.indexOf(" ");
@@ -17,8 +17,21 @@ export function prepareModelSelection(session: AgentSession, selection: ModelSel
17
17
  : "None";
18
18
  const selectedModel = formatModelSelectorValue(selection.selector, selection.thinkingLevel);
19
19
  const role = selection.scope === "default" ? "default" : selection.role;
20
+ const contextSetting =
21
+ selection.contextTier && model.provider === "openai-codex"
22
+ ? "providers.openaiContextTier"
23
+ : selection.contextTier && model.provider === "litellm"
24
+ ? "providers.litellmContextTier"
25
+ : undefined;
20
26
  const routingState = session.getRoutingState();
21
27
  const changes: Array<{ field: string; before: string; after: string }> = [];
28
+ if (contextSetting) {
29
+ changes.push({
30
+ field: "Provider-wide context tier",
31
+ before: String(session.settings.get(contextSetting)),
32
+ after: selection.contextTier!,
33
+ });
34
+ }
22
35
  if (selection.scope !== "conversation") {
23
36
  if (!role) return undefined;
24
37
  changes.push({
@@ -56,7 +69,7 @@ export function prepareModelSelection(session: AgentSession, selection: ModelSel
56
69
  changes: changed,
57
70
  consequence:
58
71
  selection.scope === "conversation"
59
- ? "Switches this conversation and records a manual routing pin; user model defaults are unchanged."
72
+ ? "Switches this conversation and records a manual routing pin; the selected provider-wide context tier is saved."
60
73
  : selection.scope === "default"
61
74
  ? "Saves the user default role, then switches this conversation and records a manual routing pin."
62
75
  : `Saves the ${role} role for future routed work; the active conversation model is unchanged.`,
@@ -71,6 +84,13 @@ export async function applyModelSelection(session: AgentSession, selection: Mode
71
84
  const previousModel = session.model;
72
85
  const previousThinking = session.thinkingLevel;
73
86
  const previousRoles = { ...session.settings.get("modelRoles") };
87
+ const contextSetting =
88
+ selection.contextTier && model.provider === "openai-codex"
89
+ ? "providers.openaiContextTier"
90
+ : selection.contextTier && model.provider === "litellm"
91
+ ? "providers.litellmContextTier"
92
+ : undefined;
93
+ const previousContextTier = contextSetting ? session.settings.get(contextSetting) : undefined;
74
94
  const previousRouting = session.getRoutingState();
75
95
  let switched = false;
76
96
  const role = scope === "default" ? "default" : selection.role;
@@ -80,10 +100,16 @@ export async function applyModelSelection(session: AgentSession, selection: Mode
80
100
  throw new Error(`No API key for ${selector}`);
81
101
  }
82
102
  try {
83
- if (scope !== "conversation") {
84
- session.settings.setModelRole(role!, formatModelSelectorValue(selector, thinkingLevel));
103
+ if (scope !== "conversation" || contextSetting) {
104
+ if (scope !== "conversation")
105
+ session.settings.setModelRole(role!, formatModelSelectorValue(selector, thinkingLevel));
106
+ if (contextSetting) session.settings.set(contextSetting, selection.contextTier!);
85
107
  await session.settings.flush({ throwOnError: true });
86
108
  }
109
+ if (contextSetting) {
110
+ if (model.provider === "openai-codex") session.modelRegistry.setOpenAIContextTier(selection.contextTier!);
111
+ else session.modelRegistry.setLiteLLMContextTier(selection.contextTier!);
112
+ }
87
113
  if (scope !== "role") {
88
114
  switched = true;
89
115
  await session.setModelTemporary(model, thinkingLevel);
@@ -101,10 +127,15 @@ export async function applyModelSelection(session: AgentSession, selection: Mode
101
127
  await session.sessionManager.flush();
102
128
  }
103
129
  } catch (error) {
104
- if (scope !== "conversation") {
130
+ if (scope !== "conversation" || contextSetting) {
105
131
  session.settings.set("modelRoles", previousRoles);
132
+ if (contextSetting) session.settings.set(contextSetting, previousContextTier!);
106
133
  await session.settings.flush({ throwOnError: true }).catch(() => {});
107
134
  }
135
+ if (contextSetting) {
136
+ if (model.provider === "openai-codex") session.modelRegistry.setOpenAIContextTier(previousContextTier!);
137
+ else session.modelRegistry.setLiteLLMContextTier(previousContextTier!);
138
+ }
108
139
  if (switched && previousModel) await session.setModelTemporary(previousModel, previousThinking);
109
140
  session.restoreRoutingState(previousRouting);
110
141
  if (switched) {