From 6dc88d55d4dd36c14b09672a1bdf81175bb17a3a Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 17 Jun 2026 09:27:11 +0000 Subject: [PATCH 1/2] fix(tui): kept role model assignment selectable Gate context-window disabling to the temporary active-model picker so Alt+M role assignment can still open the role menu for smaller-window models.\n\nFixes #2861 --- packages/coding-agent/CHANGELOG.md | 4 ++++ .../coding-agent/src/modes/components/model-selector.ts | 7 ++++++- .../test/model-selector-role-badge-thinking.test.ts | 8 ++++---- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8f7751577..aeb7319dd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the Alt+M model-configuration menu so role assignment remains selectable for models whose context window is smaller than the current session; the context-size disabling still applies to the Alt+P temporary active-model switch ([#2861](https://github.com/can1357/oh-my-pi/issues/2861)). + ## [16.0.4] - 2026-06-17 ### Fixed diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 5200b6945..29cdce868 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -700,7 +700,12 @@ export class ModelSelectorComponent extends Container { #isModelOverContextLimit(model: Model): boolean { const contextWindow = model.contextWindow ?? 0; - return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow; + return ( + this.#temporaryOnly && + this.#currentContextTokens > 0 && + contextWindow > 0 && + this.#currentContextTokens > contextWindow + ); } #isItemDisabled(item: ModelItem | CanonicalModelItem): boolean { diff --git a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts index b7c4c44dd..a2121448d 100644 --- a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts +++ b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts @@ -154,7 +154,7 @@ describe("ModelSelector role badge thinking display", () => { expect(rendered).toContain("[SLOW auto]"); }); - test("dims and disables models below the current context size", async () => { + test("dims and disables models below the current context size in temporary mode", async () => { installTestTheme(); const settings = Settings.isolated({}); const small = createContextTestModel("a-small", 4096); @@ -175,7 +175,7 @@ describe("ModelSelector role badge thinking display", () => { expect(selected).toEqual(["b-large"]); }); - test("does not open the model menu when every candidate is disabled", async () => { + test("opens the role assignment menu for models below the current context size", async () => { installTestTheme(); const settings = Settings.isolated({}); const small = createContextTestModel("only-small", 4096); @@ -188,11 +188,11 @@ describe("ModelSelector role badge thinking display", () => { const rendered = normalizeRenderedText(selector.render(220).join("\n")); expect(rendered).toContain("only-small"); - expect(rendered).toContain("current context 6k > 4.1k limit"); + expect(rendered).not.toContain("current context 6k > 4.1k limit"); selector.handleInput("\n"); const afterEnter = normalizeRenderedText(selector.render(220).join("\n")); - expect(afterEnter).not.toContain("Action for"); + expect(afterEnter).toContain("Action for: only-small"); expect(onSelect).not.toHaveBeenCalled(); }); From c37ca9ea95abb7af985827ba38f94cf95f078711 Mon Sep 17 00:00:00 2001 From: roboomp Date: Wed, 17 Jun 2026 09:51:57 +0000 Subject: [PATCH 2/2] fix(tui): guarded default role over context Keep Alt+M role assignment open for non-current roles while preventing the Default action from switching the live session to an over-context model.\n\nFixes #2861 --- .../src/modes/components/model-selector.ts | 81 +++++++++++++++---- ...model-selector-role-badge-thinking.test.ts | 11 ++- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/modes/components/model-selector.ts b/packages/coding-agent/src/modes/components/model-selector.ts index 29cdce868..f6266f8ba 100644 --- a/packages/coding-agent/src/modes/components/model-selector.ts +++ b/packages/coding-agent/src/modes/components/model-selector.ts @@ -698,14 +698,21 @@ 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.#temporaryOnly && - this.#currentContextTokens > 0 && - contextWindow > 0 && - this.#currentContextTokens > contextWindow - ); + 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 { @@ -716,7 +723,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 { @@ -1051,6 +1058,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; @@ -1058,7 +1109,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. @@ -1091,7 +1142,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 : ""; @@ -1229,21 +1282,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); diff --git a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts index a2121448d..e1c13164c 100644 --- a/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts +++ b/packages/coding-agent/test/model-selector-role-badge-thinking.test.ts @@ -175,7 +175,7 @@ describe("ModelSelector role badge thinking display", () => { expect(selected).toEqual(["b-large"]); }); - test("opens the role assignment menu for models below the current context size", async () => { + test("opens the role assignment menu but guards over-context default switches", async () => { installTestTheme(); const settings = Settings.isolated({}); const small = createContextTestModel("only-small", 4096); @@ -191,8 +191,13 @@ describe("ModelSelector role badge thinking display", () => { expect(rendered).not.toContain("current context 6k > 4.1k limit"); selector.handleInput("\n"); - const afterEnter = normalizeRenderedText(selector.render(220).join("\n")); - expect(afterEnter).toContain("Action for: only-small"); + const afterOpen = normalizeRenderedText(selector.render(220).join("\n")); + expect(afterOpen).toContain("Action for: only-small"); + expect(afterOpen).toContain("Set as DEFAULT (Default) ⦸ context>4.1k"); + + selector.handleInput("\n"); + const afterRoleEnter = normalizeRenderedText(selector.render(220).join("\n")); + expect(afterRoleEnter).toContain("Thinking for: Fast (only-small)"); expect(onSelect).not.toHaveBeenCalled(); });