From 9bec08a9523d0d3a50ed5ebc14183e96519758d3 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 12 Jun 2026 07:30:54 +0000 Subject: [PATCH] fix(tui): preserved pending message on thinking toggle Updated thinking visibility toggles to refresh assistant blocks in place instead of rebuilding the transcript, preserving pending user submissions and loaders before streaming starts. Added a regression test for the Ctrl+T pre-stream gap.\n\nFixes #2370 --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/controllers/input-controller.ts | 14 +++--- .../modes/controllers/selector-controller.ts | 3 +- ...put-controller-thinking-visibility.test.ts | 44 +++++++++++++++++++ 4 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/test/input-controller-thinking-visibility.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c4e820d64..2ab039d80 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,7 @@ ### Fixed +- Fixed Ctrl+T thinking-block toggles clearing the pending user message and loader before the assistant stream starts ([#2370](https://github.com/can1357/oh-my-pi/issues/2370)). - Fixed snapcompact archives going partially unreadable on OpenRouter, which hard-caps requests at 8 images and silently drops the excess: compaction now passes a provider-aware frame budget (`providerFrameBudget`) so the archive never exceeds the cap (unknown providers get a safe floor of 5), with overflow kept as a text tail on the summary instead of rendering frames the gateway would drop; inline system-prompt/tool-result imaging shares the same per-provider budget (`providerImageBudget`), so once existing images exhaust it (e.g. 8 archive frames on OpenRouter) tool results ship verbatim as text - Fixed Hindsight retains to send offset-aware local timestamps instead of UTC `Z` strings so extraction prompts keep the user's local time-of-day context ([#2363](https://github.com/can1357/oh-my-pi/issues/2363)). - Fixed tool calls taller than the viewport reading as cut off while streaming (the head reappeared only once the result landed): the 15.11.6 stranded-preview fix marked every collapsed pending tool preview commit-unstable, which also blocked durable top-anchored streams — e.g. a task call's context/assignment markdown — from reaching native scrollback mid-run. Commit stability is now classified per renderer (`ToolRenderer.provisionalPendingPreview`): only the tail-window previews the result render re-anchors (edit/apply_patch streamed-diff tails, bash/ssh command caps, eval cells with interleaved outputs) stay provisional; every other pending preview commits its settled head mid-stream again diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index d55986fa2..732eab1dc 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -4,6 +4,7 @@ import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui"; import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils"; import { getRoleInfo } from "../../config/model-roles"; import { isSettingsInitialized, settings } from "../../config/settings"; +import { AssistantMessageComponent } from "../../modes/components/assistant-message"; import { renderSegmentTrack } from "../../modes/components/segment-track"; import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress"; import { expandEmoticons } from "../../modes/emoji-autocomplete"; @@ -1039,18 +1040,19 @@ export class InputController { toggleThinkingBlockVisibility(): void { this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock; - settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock); + this.ctx.settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock); this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock; - // Rebuild chat from session messages - this.ctx.chatContainer.clear(); - this.ctx.rebuildChatFromMessages(); + for (const child of this.ctx.chatContainer.children) { + if (child instanceof AssistantMessageComponent) { + child.setHideThinkingBlock(this.ctx.hideThinkingBlock); + child.invalidate(); + } + } - // If streaming, re-add the streaming component with updated visibility and re-render if (this.ctx.streamingComponent && this.ctx.streamingMessage) { this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock); this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage); - this.ctx.chatContainer.addChild(this.ctx.streamingComponent); } this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index ac0ff3a2d..9c6c1822b 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -316,10 +316,9 @@ export class SelectorController { for (const child of this.ctx.chatContainer.children) { if (child instanceof AssistantMessageComponent) { child.setHideThinkingBlock(value as boolean); + child.invalidate(); } } - this.ctx.chatContainer.clear(); - this.ctx.rebuildChatFromMessages(); break; case "theme": { setTheme(value as string, true).then(result => { diff --git a/packages/coding-agent/test/input-controller-thinking-visibility.test.ts b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts new file mode 100644 index 000000000..f5bf6c6a5 --- /dev/null +++ b/packages/coding-agent/test/input-controller-thinking-visibility.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "bun:test"; +import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; +import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; + +describe("InputController thinking visibility", () => { + it("keeps pre-stream pending transcript content mounted when Ctrl+T toggles thinking blocks", () => { + const pendingUserMessage = { kind: "pending-user" }; + const loadingIndicator = { kind: "loading" }; + const assistant = new AssistantMessageComponent(); + const setHideThinkingBlock = vi.spyOn(assistant, "setHideThinkingBlock"); + const invalidate = vi.spyOn(assistant, "invalidate"); + const clear = vi.fn(); + const addChild = vi.fn(); + const rebuildChatFromMessages = vi.fn(); + const set = vi.fn(); + const showStatus = vi.fn(); + const children = [pendingUserMessage, assistant, loadingIndicator]; + const chatContainer = { children, clear, addChild }; + const ctx = { + hideThinkingBlock: false, + settings: { set }, + session: { agent: { hideThinkingSummary: false } }, + chatContainer, + streamingComponent: undefined, + streamingMessage: undefined, + rebuildChatFromMessages, + showStatus, + } as unknown as InteractiveModeContext; + + new InputController(ctx).toggleThinkingBlockVisibility(); + + expect(ctx.hideThinkingBlock).toBe(true); + expect(set).toHaveBeenCalledWith("hideThinkingBlock", true); + expect(ctx.session.agent.hideThinkingSummary).toBe(true); + expect(chatContainer.children).toEqual([pendingUserMessage, assistant, loadingIndicator]); + expect(clear).not.toHaveBeenCalled(); + expect(addChild).not.toHaveBeenCalled(); + expect(rebuildChatFromMessages).not.toHaveBeenCalled(); + expect(setHideThinkingBlock).toHaveBeenCalledWith(true); + expect(invalidate).toHaveBeenCalledTimes(1); + expect(showStatus).toHaveBeenCalledWith("Thinking blocks: hidden"); + }); +});