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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user