diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c98881efa..5ac708707 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Ctrl+T staying locked off for OpenAI-compatible providers that stream reasoning content without advertising reasoning support in model metadata. ([#3669](https://github.com/can1357/oh-my-pi/issues/3669)) + ## [16.2.2] - 2026-06-27 ### Added diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index b30ac3b30..094c7b118 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -554,6 +554,7 @@ export class EventController { this.#ensureWorkingLoaderWhileStreaming(); this.#vocalizeDelta(event); if (this.ctx.streamingComponent && event.message.role === "assistant") { + this.ctx.noteDisplayableThinkingContent(event.message); this.ctx.streamingMessage = event.message; this.#streamingReveal.setTarget(this.ctx.streamingMessage); @@ -681,6 +682,9 @@ export class EventController { async #handleMessageEnd(event: Extract): Promise { if (event.message.role === "user") return; + if (event.message.role === "assistant") { + this.ctx.noteDisplayableThinkingContent(event.message); + } if (event.message.role === "assistant" && settings.get("speech.enabled")) { if (event.message.stopReason === "aborted") { // Esc / Ctrl+C / interrupt: stop speaking now and drop the trailing partial. diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 55c70adaa..68eb4a53c 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1739,14 +1739,14 @@ export class InputController { } toggleThinkingBlockVisibility(): void { - // When thinking is "off", thinking blocks are always hidden (some - // providers return them regardless). The toggle is meaningless in - // that state — inform the user instead of silently flipping the - // persisted value. When thinking is on, the toggle works normally - // even if blocks are already hidden (user may want to show them). + // When thinking is "off" and the session has not produced reasoning + // content, thinking blocks stay auto-hidden; the toggle would only corrupt + // the persisted preference. OpenAI-compatible servers can stream reasoning + // without advertising model support, so observed thinking content unlocks + // the display toggle. const thinkingOff = ((this.ctx.viewSession ?? this.ctx.session)?.thinkingLevel ?? ThinkingLevel.Off) === ThinkingLevel.Off; - if (thinkingOff) { + if (thinkingOff && !this.ctx.hasDisplayableThinkingContent) { this.ctx.showStatus("Thinking is off — enable thinking to show blocks"); return; } diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index e47ff23f7..d31f04d5c 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -112,6 +112,7 @@ import { renderTreeList } from "../tui/tree-list"; import type { EventBus } from "../utils/event-bus"; import { getEditorCommand, openInEditor } from "../utils/external-editor"; import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color"; +import { messageHasDisplayableThinking } from "../utils/thinking-display"; import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator"; import { isSearchProviderId, @@ -413,14 +414,27 @@ export class InteractiveMode implements InteractiveModeContext { #modelCycleClearTimer: NodeJS.Timeout | undefined; todoPhases: TodoPhase[] = []; hideThinkingBlock = false; + #sessionsWithDisplayableThinkingContent = new WeakSet(); + /** Whether the visible session has produced thinking content the user can reveal. */ + get hasDisplayableThinkingContent(): boolean { + return this.#sessionsWithDisplayableThinkingContent.has(this.viewSession); + } + /** Record received reasoning content so Ctrl+T can reveal it even when model metadata says thinking is off. */ + noteDisplayableThinkingContent(message: AgentMessage): void { + if (messageHasDisplayableThinking(message, this.proseOnlyThinking)) { + this.#sessionsWithDisplayableThinkingContent.add(this.viewSession); + } + } /** - * Effective thinking-block visibility: hidden when the user's setting is on - * OR the session thinking level is "off". Some providers (MiniMax, GLM, - * DeepSeek) return thinking blocks even with reasoning disabled; this - * respects the user's intent when they set thinking to "off" (#626). + * Effective thinking-block visibility: hidden when the user's setting is on, + * or while thinking is "off" before the session has actually produced + * displayable thinking content. Some providers return thinking blocks without + * advertising reasoning support, so observed content unlocks the visibility + * toggle. */ get effectiveHideThinkingBlock(): boolean { - return this.hideThinkingBlock || (this.viewSession?.thinkingLevel ?? ThinkingLevel.Off) === ThinkingLevel.Off; + const thinkingOff = (this.viewSession?.thinkingLevel ?? ThinkingLevel.Off) === ThinkingLevel.Off; + return this.hideThinkingBlock || (thinkingOff && !this.hasDisplayableThinkingContent); } proseOnlyThinking = true; compactionQueuedMessages: CompactionQueuedMessage[] = []; @@ -3583,6 +3597,9 @@ export class InteractiveMode implements InteractiveModeContext { sessionContext: SessionContext, options?: { updateFooter?: boolean; populateHistory?: boolean }, ): void { + for (const message of sessionContext.messages) { + this.noteDisplayableThinkingContent(message); + } this.#uiHelpers.renderSessionContext(sessionContext, options); } diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 9ccb11971..a10740bab 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -161,10 +161,14 @@ export interface InteractiveModeContext { hideThinkingBlock: boolean; /** * Effective thinking-block visibility: true when hidden by user setting OR - * thinking level is "off". Read this in render paths instead of - * {@link hideThinkingBlock} so blocks are auto-hidden when thinking is off. + * thinking level is "off" before the session has produced displayable + * thinking content. */ readonly effectiveHideThinkingBlock: boolean; + /** Whether this visible session has produced thinking content the user can reveal. */ + readonly hasDisplayableThinkingContent: boolean; + /** Record a message whose thinking content makes Ctrl+T meaningful even at thinking level "off". */ + noteDisplayableThinkingContent(message: AgentMessage): void; proseOnlyThinking: boolean; compactionQueuedMessages: CompactionQueuedMessage[]; pendingTools: Map; diff --git a/packages/coding-agent/src/utils/thinking-display.ts b/packages/coding-agent/src/utils/thinking-display.ts index 5a52c5e13..e438bbaa2 100644 --- a/packages/coding-agent/src/utils/thinking-display.ts +++ b/packages/coding-agent/src/utils/thinking-display.ts @@ -1,3 +1,5 @@ +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; + export function canonicalizeMessage(text: string | null | undefined): string { if (!text) return ""; const trimmed = text.trim(); @@ -79,6 +81,7 @@ export function formatThinkingForDisplay(text: string, proseOnly: boolean): stri return formatted; } +/** Whether a formatted thinking block has non-placeholder content worth rendering. */ export function hasDisplayableThinking( text: string | null | undefined, formattedText: string | null | undefined, @@ -87,3 +90,15 @@ export function hasDisplayableThinking( if (!formattedText) return false; return formattedText.length > 0 && canonicalizeMessage(text).length > 0; } + +/** Whether an assistant message contains thinking content the TUI can reveal. */ +export function messageHasDisplayableThinking(message: AgentMessage, proseOnly: boolean): boolean { + if (message.role !== "assistant") return false; + for (const content of message.content) { + if (content.type !== "thinking") continue; + if (hasDisplayableThinking(content.thinking, formatThinkingForDisplay(content.thinking, proseOnly))) { + return true; + } + } + return false; +} diff --git a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts index b059e002f..2ed3f9fa3 100644 --- a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts +++ b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts @@ -75,6 +75,34 @@ describe("InputController thinking visibility", () => { expect(showStatus).toHaveBeenCalledWith("Thinking is off — enable thinking to show blocks"); }); + it("allows toggling when thinking is off after reasoning content was received", () => { + const assistant = new AssistantMessageComponent(); + const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); + const set = vi.fn(); + const showStatus = vi.fn(); + const resetDisplay = vi.fn(); + const ctx = { + hideThinkingBlock: false, + effectiveHideThinkingBlock: false, + hasDisplayableThinkingContent: true, + settings: { set }, + session: { agent: { hideThinkingSummary: false }, thinkingLevel: "off" }, + chatContainer: { children: [assistant], clear: vi.fn(), addChild: vi.fn() }, + streamingComponent: undefined, + streamingMessage: undefined, + showStatus, + ui: { resetDisplay }, + } as unknown as InteractiveModeContext; + + new InputController(ctx).toggleThinkingBlockVisibility(); + + expect(ctx.hideThinkingBlock).toBe(true); + expect(set).toHaveBeenCalledWith("hideThinkingBlock", true); + expect(setHideThinkingBlock).toHaveBeenCalledWith(true); + expect(resetDisplay).toHaveBeenCalledTimes(1); + expect(showStatus).toHaveBeenCalledWith("Thinking blocks: hidden"); + }); + it("refuses to toggle when the focused view session has thinking off", () => { const assistant = new AssistantMessageComponent(); const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock");