diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8d8c902e8..0405b4337 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -54,6 +54,9 @@ ### Fixed - Kept the welcome screen's Tips, LSP Servers, and Recent sessions visible when a long model name still leaves enough terminal width for both columns ([#8657](https://github.com/can1357/oh-my-pi/issues/8657)). +### Fixed + +- Fixed focused `ultrathink`, `orchestrate`, and `workflowz` shimmer frames repainting the full TUI every 70 ms, causing high CPU usage while composing prompts on WSL2 ([#8646](https://github.com/can1357/oh-my-pi/issues/8646)). ## [17.3.4] - 2026-08-14 diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 78a41674d..cc821787b 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -818,7 +818,7 @@ export class InteractiveMode implements InteractiveModeContext { this.editor.onAutocompleteUpdate = () => { this.ui.requestRender(); }; - this.editor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor)); + this.editor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(this.editor)); this.#syncEditorMaxHeight(); this.#resizeHandler = () => { this.#syncEditorMaxHeight(); @@ -4264,7 +4264,7 @@ export class InteractiveMode implements InteractiveModeContext { nextEditor.onAutocompleteUpdate = () => { this.ui.requestRender(); }; - nextEditor.setShimmerRepaintHandler(() => this.ui.requestComponentRender(this.editor)); + nextEditor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(nextEditor)); nextEditor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth)); nextEditor.setMaxHeight(this.#computeEditorMaxHeight()); if (this.historyStorage) { diff --git a/packages/coding-agent/test/interactive-mode-editor-component.test.ts b/packages/coding-agent/test/interactive-mode-editor-component.test.ts index 5599d7096..85bea4906 100644 --- a/packages/coding-agent/test/interactive-mode-editor-component.test.ts +++ b/packages/coding-agent/test/interactive-mode-editor-component.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, setSystemTime, vi } from "bun:test"; import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; @@ -9,10 +9,67 @@ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import { AuthStorage } from "@oh-my-pi/pi-coding-agent/session/auth-storage"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { TUI } from "@oh-my-pi/pi-tui"; import { TempDir } from "@oh-my-pi/pi-utils"; +import { StressRenderScheduler } from "../../tui/test/render-stress-scheduler"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal"; class TestModalEditor extends CustomEditor {} +class RenderCountingTUI extends TUI { + renderCount = 0; + + override render(width: number): readonly string[] { + this.renderCount++; + return super.render(width); + } +} + +async function expectTwoDirectShimmerFrames( + tui: RenderCountingTUI, + terminal: VirtualTerminal, + writes: readonly string[], + keyword: string, +): Promise { + const renderCount = tui.renderCount; + const viewport = terminal.getViewport().map(row => Bun.stripANSI(row).trimEnd()); + const bufferPosition = terminal.getBufferPosition(); + const scrollback = terminal + .getScrollBuffer() + .slice(0, bufferPosition.baseY) + .map(row => Bun.stripANSI(row).trimEnd()); + const cursor = terminal.getCursor(); + const writesBeforeFirstPhase = writes.length; + + expect(viewport.join("\n")).toContain(keyword); + expect(bufferPosition.baseY).toBeGreaterThan(0); + + vi.advanceTimersByTime(CustomEditor.SHIMMER_FRAME_MS); + await terminal.flush(); + const firstPhaseWrites = writes.slice(writesBeforeFirstPhase); + expect(firstPhaseWrites.length).toBeGreaterThan(0); + expect(firstPhaseWrites.join("")).toContain("\x1b[38"); + + const writesBeforeSecondPhase = writes.length; + vi.advanceTimersByTime(CustomEditor.SHIMMER_FRAME_MS); + await terminal.flush(); + const secondPhaseWrites = writes.slice(writesBeforeSecondPhase); + expect(secondPhaseWrites.length).toBeGreaterThan(0); + expect(secondPhaseWrites.join("")).toContain("\x1b[38"); + expect(secondPhaseWrites.join("")).not.toBe(firstPhaseWrites.join("")); + + expect(tui.renderCount).toBe(renderCount); + expect(terminal.getViewport().map(row => Bun.stripANSI(row).trimEnd())).toEqual(viewport); + expect(terminal.getBufferPosition()).toEqual(bufferPosition); + expect( + terminal + .getScrollBuffer() + .slice(0, bufferPosition.baseY) + .map(row => Bun.stripANSI(row).trimEnd()), + ).toEqual(scrollback); + expect(terminal.getCursor()).toEqual(cursor); +} + describe("InteractiveMode.setEditorComponent", () => { let tempDir: TempDir; let authStorage: AuthStorage; @@ -73,4 +130,51 @@ describe("InteractiveMode.setEditorComponent", () => { expect(mode.editor.onEscape).toBeDefined(); expect(refreshSpy).toHaveBeenCalled(); }); + + it("direct-writes focused shimmer frames without disturbing terminal state before or after replacement", async () => { + const terminal = new VirtualTerminal(80, 8, 1_000); + terminal.write(Array.from({ length: 12 }, (_unused, index) => `seed-${index}\r\n`).join("")); + const writes: string[] = []; + const write = terminal.write.bind(terminal); + vi.spyOn(terminal, "write").mockImplementation((data: string) => { + writes.push(data); + write(data); + }); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(terminal, true, { renderScheduler: scheduler }); + const initialEditor = mode.editor; + let replacementEditor: CustomEditor | undefined; + + vi.spyOn(mode, "refreshSlashCommandState").mockResolvedValue(); + vi.useFakeTimers(); + setSystemTime(0); + try { + mode.ui = tui; + initialEditor.setUseTerminalCursor(true); + initialEditor.magicKeywordsEnabledOverride = true; + initialEditor.setText("please orchestrate this draft"); + tui.addChild(mode.editorContainer); + tui.setFocus(initialEditor); + tui.start(); + await scheduler.drain(terminal); + + await expectTwoDirectShimmerFrames(tui, terminal, writes, "orchestrate"); + initialEditor.setShimmerRepaintHandler(undefined); + + mode.setEditorComponent((_tui, editorTheme) => new TestModalEditor(editorTheme)); + replacementEditor = mode.editor; + replacementEditor.magicKeywordsEnabledOverride = true; + replacementEditor.setText("please workflowz this draft"); + await scheduler.drain(terminal); + + await expectTwoDirectShimmerFrames(tui, terminal, writes, "workflowz"); + } finally { + initialEditor.setShimmerRepaintHandler(undefined); + replacementEditor?.setShimmerRepaintHandler(undefined); + tui.stop(); + await terminal.flush(); + vi.useRealTimers(); + setSystemTime(); + } + }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index cc8f78375..006c1e141 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed long CPU-bound event-loop stalls being misclassified as system sleep and omitted from `ui.loop-blocked` diagnostics ([#8442](https://github.com/can1357/oh-my-pi/pull/8442) by [@Mustaqeem66](https://github.com/Mustaqeem66)). +- Fixed marker-bearing focused components falling back from direct row writes to full TUI renders, while preserving hardware-cursor position and native scrollback across marker changes ([#8646](https://github.com/can1357/oh-my-pi/issues/8646)). ## [17.3.4] - 2026-08-14 diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index b4bcdf376..c5f8e8bf8 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1772,13 +1772,76 @@ export class TUI extends Container { markers.length = keep; } + /** + * Replace the cursor markers contributed by one fixed-size root segment. + * The ledger stays sorted by absolute frame row; entries belonging to later + * siblings move as one tail and otherwise retain their identity. + */ + #replaceFrameCursorMarkers(startRow: number, lines: readonly string[]): void { + const markers = this.#frameCursorMarkers; + const endRow = startRow + lines.length; + let intervalStart = 0; + while (intervalStart < markers.length && markers[intervalStart]!.row < startRow) intervalStart++; + let intervalEnd = intervalStart; + while (intervalEnd < markers.length && markers[intervalEnd]!.row < endRow) intervalEnd++; + + let nextCount = 0; + for (let row = 0; row < lines.length; row++) { + if (lines[row]!.includes(CURSOR_MARKER)) nextCount++; + } + + const previousCount = intervalEnd - intervalStart; + const delta = nextCount - previousCount; + const previousLength = markers.length; + if (delta > 0) { + markers.length = previousLength + delta; + for (let index = previousLength - 1; index >= intervalEnd; index--) { + markers[index + delta] = markers[index]!; + } + } else if (delta < 0) { + for (let index = intervalEnd; index < previousLength; index++) { + markers[index + delta] = markers[index]!; + } + markers.length = previousLength + delta; + } + + const reusableCount = Math.min(previousCount, nextCount); + let markerSlot = intervalStart; + for (let row = 0; row < lines.length; row++) { + const line = lines[row]!; + const markerIndex = line.indexOf(CURSOR_MARKER); + if (markerIndex === -1) continue; + const absoluteRow = startRow + row; + const col = visibleWidth(line.slice(0, markerIndex)); + if (markerSlot < intervalStart + reusableCount) { + const marker = markers[markerSlot]!; + marker.row = absoluteRow; + marker.col = col; + } else { + markers[markerSlot] = { row: absoluteRow, col }; + } + markerSlot++; + } + } + + /** Strip every internal cursor sentinel from one rendered row. */ + #stripCursorMarkers(line: string, markerIndex = line.indexOf(CURSOR_MARKER)): string { + if (markerIndex === -1) return line; + let stripped = line; + while (markerIndex !== -1) { + stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length); + markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex); + } + return stripped; + } + /** * Append one row to the composed frame, stripping CURSOR_MARKER occurrences * (internal sentinels that must never reach the terminal, the committed * prefix, or the resync audit) and recording the first marker's position. */ #ingestFrameRow(line: string): void { - let markerIndex = line.indexOf(CURSOR_MARKER); + const markerIndex = line.indexOf(CURSOR_MARKER); if (markerIndex === -1) { this.#composedFrame.push(line); return; @@ -1787,12 +1850,7 @@ export class TUI extends Container { row: this.#composedFrame.length, col: visibleWidth(line.slice(0, markerIndex)), }); - let stripped = line; - while (markerIndex !== -1) { - stripped = stripped.slice(0, markerIndex) + stripped.slice(markerIndex + CURSOR_MARKER.length); - markerIndex = stripped.indexOf(CURSOR_MARKER, markerIndex); - } - this.#composedFrame.push(stripped); + this.#composedFrame.push(this.#stripCursorMarkers(line, markerIndex)); } #syncTerminalCursorMode(component: Component | null): void { @@ -2558,12 +2616,6 @@ export class TUI extends Container { this.requestComponentRender(component); return; } - for (const line of nextLines) { - if (line.includes(CURSOR_MARKER)) { - this.requestComponentRender(component); - return; - } - } let firstChanged = -1; let lastChanged = -1; @@ -2571,8 +2623,9 @@ export class TUI extends Container { for (let i = 0; i < nextLines.length; i++) { const frameRow = segment.start + i; const raw = nextLines[i]!; - const prepared = this.#prepareLine(raw, width); - this.#composedFrame[frameRow] = raw; + const composed = this.#stripCursorMarkers(raw); + const prepared = this.#prepareLine(composed, width); + this.#composedFrame[frameRow] = composed; this.#preparedMeta[frameRow] = prepared; this.#preparedFrame[frameRow] = prepared.line; if (previousWindow[screenStart + i] === prepared.line) continue; @@ -2580,7 +2633,8 @@ export class TUI extends Container { if (firstChanged === -1) firstChanged = i; lastChanged = i; } - segments[segmentIndex] = { ...segment, lines: nextLines }; + this.#replaceFrameCursorMarkers(segment.start, nextLines); + segment.lines = nextLines; this.#preparedValidRows = Math.max(this.#preparedValidRows, segment.start + nextLines.length); this.#renderStablePrefixRows = Math.min(this.#renderStablePrefixRows, segment.start); diff --git a/packages/tui/test/component-render.test.ts b/packages/tui/test/component-render.test.ts index 15ad5cfc3..d027c27bf 100644 --- a/packages/tui/test/component-render.test.ts +++ b/packages/tui/test/component-render.test.ts @@ -86,6 +86,16 @@ function visible(term: VirtualTerminal): string[] { return strip(term.getViewport()).filter(row => row.length > 0); } +function captureWrites(term: VirtualTerminal): string[] { + const writes: string[] = []; + const write = term.write.bind(term); + term.write = (data: string): void => { + writes.push(data); + write(data); + }; + return writes; +} + class RenderCountingTUI extends TUI { renders = 0; @@ -583,6 +593,202 @@ describe("TUI.requestDirectWrite", () => { } }); + it("directly strips markers while updating ANSI and wide-grapheme cursor geometry", async () => { + const term = new VirtualTerminal(40, 5, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const head = new CountingLines(["head"]); + const editor = new CountingLines(["plain"]); + const footer = new CountingLines(["footer"]); + tui.addChild(head); + tui.addChild(editor); + tui.addChild(footer); + + try { + tui.start(); + await scheduler.drain(term); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set([`\x1b[31m好a${CURSOR_MARKER}b${CURSOR_MARKER}\x1b[0m`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["head", "好ab", "footer", "", ""]); + expect(term.getCursor()).toEqual({ row: 1, col: 3 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + + writes.length = 0; + editor.set(["done"]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["head", "done", "footer", "", ""]); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).toContain("\x1b[?25l"); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("moves a marker without repainting unchanged row bytes", async () => { + const term = new VirtualTerminal(40, 4, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const editor = new CountingLines([`ab${CURSOR_MARKER}cd`, "efgh"]); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]); + expect(term.getCursor()).toEqual({ row: 0, col: 2 }); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set(["abcd", `e${CURSOR_MARKER}fgh`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["abcd", "efgh", "", ""]); + expect(term.getCursor()).toEqual({ row: 1, col: 1 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain("abcd"); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("preserves later sibling precedence across marker interval growth, shrink, and recompose", async () => { + const term = new VirtualTerminal(40, 8, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const earlier = new CountingLines(["early"]); + const target = new CountingLines(["t0", "t1", "t2"]); + const later = new CountingLines([`later${CURSOR_MARKER}`]); + tui.addChild(earlier); + tui.addChild(target); + tui.addChild(later); + + try { + tui.start(); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 4, col: 5 }); + const tuiRenders = tui.renders; + writes.length = 0; + + target.set([`${CURSOR_MARKER}t0`, `t${CURSOR_MARKER}1`, `t2${CURSOR_MARKER}`]); + tui.requestDirectWrite(target); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 4, col: 5 }); + expect(tui.renders).toBe(tuiRenders); + + target.set(["t0", `t${CURSOR_MARKER}1`, "t2"]); + tui.requestDirectWrite(target); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 4, col: 5 }); + expect(tui.renders).toBe(tuiRenders); + + later.set(["later"]); + tui.requestDirectWrite(later); + await scheduler.drain(term); + expect(term.getCursor()).toEqual({ row: 2, col: 1 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + + const targetRenders = target.renders; + writes.length = 0; + earlier.set(["early-new"]); + tui.requestComponentRender(earlier); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["early-new", "t0", "t1", "t2", "later", "", "", ""]); + expect(term.getCursor()).toEqual({ row: 2, col: 1 }); + expect(tui.renders).toBeGreaterThan(tuiRenders); + expect(target.renders).toBe(targetRenders); + expect(writes.join("")).toContain("\x1b[?25h"); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("keeps non-empty native scrollback byte-for-byte stable during marker direct writes", async () => { + const term = new VirtualTerminal(30, 4, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const transcript = new CountingLines(Array.from({ length: 8 }, (_unused, row) => `history-${row}`)); + const editor = new CountingLines(["anim-0"]); + tui.addChild(transcript); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + const beforeBuffer = term.getScrollBuffer(); + const beforeHistory = beforeBuffer.slice(0, Math.max(0, beforeBuffer.length - term.rows)); + expect(beforeHistory.length).toBeGreaterThan(0); + expect(beforeHistory.some(row => row.trimEnd().length > 0)).toBe(true); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set([`anim-1${CURSOR_MARKER}`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + const afterBuffer = term.getScrollBuffer(); + const afterHistory = afterBuffer.slice(0, Math.max(0, afterBuffer.length - term.rows)); + expect(afterHistory).toEqual(beforeHistory); + expect(strip(term.getViewport())).toEqual(["history-5", "history-6", "history-7", "anim-1"]); + expect(term.getCursor()).toEqual({ row: 3, col: 6 }); + expect(tui.renders).toBe(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + + it("falls back safely when marker-bearing output changes row count", async () => { + const term = new VirtualTerminal(40, 5, 1_000); + const writes = captureWrites(term); + const scheduler = new StressRenderScheduler(); + const tui = new RenderCountingTUI(term, true, { renderScheduler: scheduler }); + const head = new CountingLines(["head"]); + const editor = new CountingLines([`one${CURSOR_MARKER}`]); + tui.addChild(head); + tui.addChild(editor); + + try { + tui.start(); + await scheduler.drain(term); + const tuiRenders = tui.renders; + writes.length = 0; + + editor.set([`one${CURSOR_MARKER}`, `two${CURSOR_MARKER}`]); + tui.requestDirectWrite(editor); + await scheduler.drain(term); + + expect(strip(term.getViewport())).toEqual(["head", "one", "two", "", ""]); + expect(term.getCursor()).toEqual({ row: 2, col: 3 }); + expect(tui.renders).toBeGreaterThan(tuiRenders); + expect(writes.join("")).not.toContain(CURSOR_MARKER); + } finally { + tui.stop(); + await term.flush(); + } + }); + it("directly rewrites fully live anchored status segments", async () => { const term = new VirtualTerminal(40, 8, 1_000); const scheduler = new StressRenderScheduler();