Merge PR #2862: fix(tui): keep Alt+M role models selectable over context limit (@roboomp)

# Conflicts:
#	packages/coding-agent/test/model-selector-role-badge-thinking.test.ts
This commit is contained in:
can1357
2026-06-20 22:14:28 +02:00
3 changed files with 79 additions and 14 deletions
@@ -705,11 +705,23 @@ export class ModelSelectorComponent extends Container {
return this.#getActiveTabId() === CANONICAL_TAB;
}
#isModelOverContextLimit(model: Model): boolean {
#isModelOverCurrentContext(model: Model): boolean {
const contextWindow = model.contextWindow ?? 0;
return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow;
}
#isModelOverContextLimit(model: Model): boolean {
return this.#temporaryOnly && this.#isModelOverCurrentContext(model);
}
#isDefaultRoleActionOverContextLimit(action: MenuRoleAction, model: Model): boolean {
return action.role === "default" && this.#isModelOverCurrentContext(model);
}
#formatCurrentContextLimitSuffix(model: Model): string {
return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
}
#isItemDisabled(item: ModelItem | CanonicalModelItem): boolean {
return this.#isModelOverContextLimit(item.model);
}
@@ -718,7 +730,7 @@ export class ModelSelectorComponent extends Container {
if (!this.#isModelOverContextLimit(model)) {
return "";
}
return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
return this.#formatCurrentContextLimitSuffix(model);
}
#getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
@@ -1053,6 +1065,50 @@ export class ModelSelectorComponent extends Container {
: this.#filteredModels[this.#selectedIndex];
}
#isMenuRoleIndexDisabled(index: number, selectedItem: ModelItem | CanonicalModelItem): boolean {
const action = this.#menuRoleActions[index];
return action ? this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model) : false;
}
#coerceMenuSelectedIndex(index: number, selectedItem: ModelItem | CanonicalModelItem): number {
const maxIndex = this.#menuRoleActions.length - 1;
if (maxIndex < 0) {
return 0;
}
const clamped = Math.max(0, Math.min(index, maxIndex));
if (!this.#isMenuRoleIndexDisabled(clamped, selectedItem)) {
return clamped;
}
for (let i = clamped + 1; i <= maxIndex; i++) {
if (!this.#isMenuRoleIndexDisabled(i, selectedItem)) {
return i;
}
}
for (let i = clamped - 1; i >= 0; i--) {
if (!this.#isMenuRoleIndexDisabled(i, selectedItem)) {
return i;
}
}
return clamped;
}
#moveMenuSelection(delta: number, selectedItem: ModelItem | CanonicalModelItem, optionCount: number): void {
let index = this.#menuSelectedIndex;
for (let step = 0; step < optionCount; step++) {
index = (index + delta + optionCount) % optionCount;
if (this.#menuStep !== "role" || !this.#isMenuRoleIndexDisabled(index, selectedItem)) {
this.#menuSelectedIndex = index;
this.#updateMenu();
return;
}
}
this.#menuSelectedIndex =
this.#menuStep === "role"
? this.#coerceMenuSelectedIndex(this.#menuSelectedIndex, selectedItem)
: this.#menuSelectedIndex;
this.#updateMenu();
}
#openMenu(): void {
const selectedItem = this.#getSelectedItem();
if (!selectedItem || this.#isItemDisabled(selectedItem)) return;
@@ -1060,7 +1116,7 @@ export class ModelSelectorComponent extends Container {
this.#isMenuOpen = true;
this.#menuStep = "role";
this.#menuSelectedRole = null;
this.#menuSelectedIndex = 0;
this.#menuSelectedIndex = this.#coerceMenuSelectedIndex(0, selectedItem);
// Collapse the model list while the action/thinking menu is open so the
// menu owns the full viewport instead of stacking below a now-irrelevant
// (and often off-screen) list.
@@ -1093,7 +1149,9 @@ export class ModelSelectorComponent extends Container {
})
: this.#menuRoleActions.map((action, index) => {
const prefix = index === this.#menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
return `${prefix}${action.label}`;
const disabled = this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model);
const suffix = disabled ? this.#formatCurrentContextLimitSuffix(selectedItem.model) : "";
return `${prefix}${action.label}${suffix}`;
});
const selectedRoleName = this.#menuSelectedRole ? getRoleInfo(this.#menuSelectedRole, this.#settings).name : "";
@@ -1231,21 +1289,19 @@ export class ModelSelectorComponent extends Container {
if (optionCount === 0) return;
if (matchesSelectUp(keyData)) {
this.#menuSelectedIndex = (this.#menuSelectedIndex - 1 + optionCount) % optionCount;
this.#updateMenu();
this.#moveMenuSelection(-1, selectedItem, optionCount);
return;
}
if (matchesSelectDown(keyData)) {
this.#menuSelectedIndex = (this.#menuSelectedIndex + 1) % optionCount;
this.#updateMenu();
this.#moveMenuSelection(1, selectedItem, optionCount);
return;
}
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
if (this.#menuStep === "role") {
const action = this.#menuRoleActions[this.#menuSelectedIndex];
if (!action) return;
if (!action || this.#isDefaultRoleActionOverContextLimit(action, selectedItem.model)) return;
this.#menuSelectedRole = action.role;
this.#menuStep = "thinking";
this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);