fix(tui): allowed thinking toggle after streamed reasoning

Tracked received thinking content per interactive session so OpenAI-compatible providers that omit reasoning metadata can still reveal streamed reasoning blocks. Added a Ctrl+T regression covering the unlocked visibility path.

Fixes #3669
This commit is contained in:
roboomp
2026-06-27 17:18:04 +00:00
parent 2708c1edb8
commit 4d0ef260b6
7 changed files with 85 additions and 13 deletions
+4
View File
@@ -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
@@ -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<AgentSessionEvent, { type: "message_end" }>): Promise<void> {
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.
@@ -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;
}
@@ -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<AgentSession>();
/** 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);
}
+6 -2
View File
@@ -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<string, ToolExecutionHandle>;
@@ -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;
}
@@ -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");