diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index d0f747e50..4ad794266 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -561,20 +561,38 @@ export class CommandController { this.ctx.showError("Usage: /memory "); } - async handleClearCommand(): Promise { - if (this.ctx.loadingAnimation) { - this.ctx.loadingAnimation.stop(); - this.ctx.loadingAnimation = undefined; - } - this.ctx.statusContainer.clear(); - + async handleClearCommand(options?: { beforeSwitch?: () => Promise | void }): Promise { if (this.ctx.session.isCompacting) { this.ctx.session.abortCompaction(); while (this.ctx.session.isCompacting) { await Bun.sleep(10); } } - await this.ctx.session.newSession(); + + const switchApproved = await this.ctx.session.canStartNewSession(); + if (!switchApproved) { + this.ctx.chatContainer.addChild(new Spacer(1)); + this.ctx.chatContainer.addChild(new Text(theme.fg("error", "Error: New session cancelled"), 1, 0)); + this.ctx.ui.requestRender(); + return false; + } + + await options?.beforeSwitch?.(); + + if (this.ctx.loadingAnimation) { + this.ctx.loadingAnimation.stop(); + this.ctx.loadingAnimation = undefined; + } + this.ctx.statusContainer.clear(); + + const success = await this.ctx.session.newSession(undefined, { skipBeforeSwitchCheck: true }); + if (!success) { + this.ctx.chatContainer.addChild(new Spacer(1)); + this.ctx.chatContainer.addChild(new Text(theme.fg("error", "Error: New session cancelled"), 1, 0)); + this.ctx.ui.requestRender(); + return false; + } + setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); this.ctx.statusLine.invalidate(); @@ -595,6 +613,7 @@ export class CommandController { ); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(); + return true; } async handleForkCommand(): Promise { diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 841a2654a..cec0703f5 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -696,7 +696,7 @@ export class InteractiveMode implements InteractiveModeContext { } const previousTools = this.#planModePreviousTools; - if (previousTools && previousTools.length > 0) { + if (previousTools) { await this.session.setActiveToolsByName(previousTools); } if (this.#planModePreviousModel) { @@ -747,15 +747,22 @@ export class InteractiveMode implements InteractiveModeContext { planContent: string, options: { planFilePath: string; finalPlanFilePath: string }, ): Promise { - await renameApprovedPlanFile({ - planFilePath: options.planFilePath, - finalPlanFilePath: options.finalPlanFilePath, - getArtifactsDir: () => this.sessionManager.getArtifactsDir(), - getSessionId: () => this.sessionManager.getSessionId(), - }); const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames(); - await this.#exitPlanMode({ silent: true, paused: false }); - await this.handleClearCommand(); + const didCreateFreshSession = await this.handleClearCommand({ + beforeSwitch: async () => { + await renameApprovedPlanFile({ + planFilePath: options.planFilePath, + finalPlanFilePath: options.finalPlanFilePath, + getArtifactsDir: () => this.sessionManager.getArtifactsDir(), + getSessionId: () => this.sessionManager.getSessionId(), + }); + await this.#exitPlanMode({ silent: true, paused: false }); + }, + }); + if (!didCreateFreshSession) { + return; + } + await this.session.setActiveToolsByName(previousTools); // The new session has a fresh local:// root — persist the approved plan there // so `local://.md` resolves correctly in the execution session. const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, { @@ -763,9 +770,6 @@ export class InteractiveMode implements InteractiveModeContext { getSessionId: () => this.sessionManager.getSessionId(), }); await Bun.write(newLocalPath, planContent); - if (previousTools.length > 0) { - await this.session.setActiveToolsByName(previousTools); - } this.session.setPlanReferencePath(options.finalPlanFilePath); this.session.markPlanReferenceSent(); const prompt = renderPromptTemplate(planModeApprovedPrompt, { @@ -1084,10 +1088,14 @@ export class InteractiveMode implements InteractiveModeContext { this.#commandController.handleHotkeysCommand(); } - handleClearCommand(): Promise<void> { - this.#btwController.dispose(); - this.#extensionUiController.clearExtensionTerminalInputListeners(); - return this.#commandController.handleClearCommand(); + async handleClearCommand(options?: { beforeSwitch?: () => Promise<void> | void }): Promise<boolean> { + return this.#commandController.handleClearCommand({ + beforeSwitch: async () => { + await options?.beforeSwitch?.(); + this.#btwController.dispose(); + this.#extensionUiController.clearExtensionTerminalInputListeners(); + }, + }); } handleForkCommand(): Promise<void> { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index bd7fd1766..df2ed915f 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -177,7 +177,7 @@ export interface InteractiveModeContext { handleHotkeysCommand(): void; handleDumpCommand(): void; handleDebugTranscriptCommand(): Promise<void>; - handleClearCommand(): Promise<void>; + handleClearCommand(options?: { beforeSwitch?: () => Promise<void> | void }): Promise<boolean>; handleForkCommand(): Promise<void>; handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>; handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>; diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f6c2c980e..f8e0b43a3 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2931,6 +2931,26 @@ export class AgentSession { this.#promptInFlightCount = 0; } + async #canSwitchSession(options: { + reason: "new" | "fork" | "resume"; + targetSessionFile?: string; + }): Promise<boolean> { + if (!this.#extensionRunner?.hasHandlers("session_before_switch")) { + return true; + } + const result = (await this.#extensionRunner.emit({ + type: "session_before_switch", + reason: options.reason, + targetSessionFile: options.targetSessionFile, + })) as SessionBeforeSwitchResult | undefined; + + return !result?.cancel; + } + + async canStartNewSession(): Promise<boolean> { + return this.#canSwitchSession({ reason: "new" }); + } + /** * Start a new session, optionally with initial messages and parent tracking. * Clears all messages and starts a new session. @@ -2938,7 +2958,7 @@ export class AgentSession { * @param options - Optional initial messages and parent session path * @returns true if completed, false if cancelled by hook */ - async newSession(options?: NewSessionOptions): Promise<boolean> { + async newSession(options?: NewSessionOptions, internal?: { skipBeforeSwitchCheck?: boolean }): Promise<boolean> { const previousSessionFile = this.sessionFile; const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled ? [ @@ -2947,16 +2967,8 @@ export class AgentSession { ] : undefined; - // Emit session_before_switch event with reason "new" (can be cancelled) - if (this.#extensionRunner?.hasHandlers("session_before_switch")) { - const result = (await this.#extensionRunner.emit({ - type: "session_before_switch", - reason: "new", - })) as SessionBeforeSwitchResult | undefined; - - if (result?.cancel) { - return false; - } + if (!internal?.skipBeforeSwitchCheck && !(await this.#canSwitchSession({ reason: "new" }))) { + return false; } this.#disconnectFromAgent(); @@ -3018,16 +3030,8 @@ export class AgentSession { async fork(): Promise<boolean> { const previousSessionFile = this.sessionFile; - // Emit session_before_switch event with reason "fork" (can be cancelled) - if (this.#extensionRunner?.hasHandlers("session_before_switch")) { - const result = (await this.#extensionRunner.emit({ - type: "session_before_switch", - reason: "fork", - })) as SessionBeforeSwitchResult | undefined; - - if (result?.cancel) { - return false; - } + if (!(await this.#canSwitchSession({ reason: "fork" }))) { + return false; } // Flush current session to ensure all entries are written @@ -5036,17 +5040,8 @@ export class AgentSession { async switchSession(sessionPath: string): Promise<boolean> { const previousSessionFile = this.sessionManager.getSessionFile(); - // Emit session_before_switch event (can be cancelled) - if (this.#extensionRunner?.hasHandlers("session_before_switch")) { - const result = (await this.#extensionRunner.emit({ - type: "session_before_switch", - reason: "resume", - targetSessionFile: sessionPath, - })) as SessionBeforeSwitchResult | undefined; - - if (result?.cancel) { - return false; - } + if (!(await this.#canSwitchSession({ reason: "resume", targetSessionFile: sessionPath }))) { + return false; } this.#disconnectFromAgent(); diff --git a/packages/coding-agent/test/modes/controllers/command-controller-new-session.test.ts b/packages/coding-agent/test/modes/controllers/command-controller-new-session.test.ts index e4600b485..afe513066 100644 --- a/packages/coding-agent/test/modes/controllers/command-controller-new-session.test.ts +++ b/packages/coding-agent/test/modes/controllers/command-controller-new-session.test.ts @@ -19,9 +19,7 @@ type TestContainer = { type TestContext = InteractiveModeContext & { chatContainer: TestContainer; pendingMessagesContainer: TestContainer; - statusContainer: { - clear: () => void; - }; + statusContainer: TestContainer; pendingTools: { clear: () => void; }; @@ -39,7 +37,9 @@ type TestContext = InteractiveModeContext & { terminal: { columns: number; rows: number }; }; session: { - newSession: () => Promise<boolean>; + canStartNewSession: () => Promise<boolean>; + newSession: (...args: unknown[]) => Promise<boolean>; + isStreaming: boolean; isCompacting: boolean; abortCompaction: () => void; }; @@ -67,12 +67,18 @@ function createTrackedContainer(name: string, calls: string[], initialChildren: return container; } -function createContext(options?: { withLoadingAnimation?: boolean }): { ctx: TestContext; calls: string[] } { +function createContext(options?: { + withLoadingAnimation?: boolean; + canStartNewSessionResult?: boolean; + newSessionResult?: boolean; + isStreaming?: boolean; +}): { ctx: TestContext; calls: string[] } { const calls: string[] = []; const chatContainer = createTrackedContainer("chatContainer", calls, [new Text("stale chat", 0, 0)]); const pendingMessagesContainer = createTrackedContainer("pendingMessagesContainer", calls, [ new Text("pending", 0, 0), ]); + const statusContainer = createTrackedContainer("statusContainer", calls, [new Text("streaming status", 0, 0)]); const loadingAnimation = options?.withLoadingAnimation === false ? undefined @@ -85,11 +91,7 @@ function createContext(options?: { withLoadingAnimation?: boolean }): { ctx: Tes const ctx = { chatContainer, pendingMessagesContainer, - statusContainer: { - clear: vi.fn(() => { - calls.push("statusContainer.clear"); - }), - }, + statusContainer, pendingTools: { clear: vi.fn(() => { calls.push("pendingTools.clear"); @@ -114,10 +116,15 @@ function createContext(options?: { withLoadingAnimation?: boolean }): { ctx: Tes terminal: { columns: 120, rows: 40 }, }, session: { + canStartNewSession: vi.fn(async () => { + calls.push("session.canStartNewSession"); + return options?.canStartNewSessionResult ?? true; + }), newSession: vi.fn(async () => { calls.push("session.newSession"); - return true; + return options?.newSessionResult ?? true; }), + isStreaming: options?.isStreaming ?? false, isCompacting: false, abortCompaction: vi.fn(), }, @@ -150,12 +157,15 @@ describe("CommandController /new command", () => { }); const controller = new CommandController(ctx); - await controller.handleClearCommand(); + const result = await controller.handleClearCommand(); + expect(result).toBe(true); + expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1); expect(ctx.session.newSession).toHaveBeenCalledTimes(1); expect(ctx.session.abortCompaction).not.toHaveBeenCalled(); expect(ctx.chatContainer.clear).toHaveBeenCalledTimes(1); expect(ctx.pendingMessagesContainer.clear).toHaveBeenCalledTimes(1); + expect(ctx.statusContainer.clear).toHaveBeenCalledTimes(1); expect(ctx.pendingTools.clear).toHaveBeenCalledTimes(1); expect(loadingAnimation?.stop).toHaveBeenCalledTimes(1); expect(ctx.loadingAnimation).toBeUndefined(); @@ -167,23 +177,22 @@ describe("CommandController /new command", () => { expect(ctx.reloadTodos).toHaveBeenCalledTimes(1); expect(ctx.ui.requestRender).toHaveBeenCalledTimes(2); expect(setSessionTerminalTitleMock).toHaveBeenCalledWith("Fresh session", "/tmp/project"); - expect(calls).toEqual([ - "loadingAnimation.stop", - "statusContainer.clear", - "session.newSession", - "setSessionTerminalTitle", - "statusLine.invalidate", - "statusLine.setSessionStartTime", - "updateEditorTopBorder", - "ui.requestRender", - "chatContainer.clear", - "pendingMessagesContainer.clear", - "pendingTools.clear", - "chatContainer.addChild", - "chatContainer.addChild", - "reloadTodos", - "ui.requestRender", - ]); + expect(calls).toEqual( + expect.arrayContaining([ + "session.canStartNewSession", + "session.newSession", + "loadingAnimation.stop", + "statusContainer.clear", + "setSessionTerminalTitle", + "statusLine.invalidate", + "statusLine.setSessionStartTime", + "updateEditorTopBorder", + "chatContainer.clear", + "pendingMessagesContainer.clear", + "pendingTools.clear", + "reloadTodos", + ]), + ); }); it("clearCommand without loadingAnimation does not throw", async () => { @@ -191,13 +200,69 @@ describe("CommandController /new command", () => { const { ctx, calls } = createContext({ withLoadingAnimation: false }); const controller = new CommandController(ctx); - await expect(controller.handleClearCommand()).resolves.toBeUndefined(); + await expect(controller.handleClearCommand()).resolves.toBe(true); expect(ctx.loadingAnimation).toBeUndefined(); expect(calls).not.toContain("loadingAnimation.stop"); expect(ctx.ui.requestRender).toHaveBeenCalledTimes(2); }); + it("clearCommand keeps the current streaming session UI when newSession is cancelled", async () => { + setSessionTerminalTitleMock.mockReset(); + const { ctx, calls } = createContext({ canStartNewSessionResult: false, isStreaming: true }); + expect(ctx.session.isStreaming).toBe(true); + const loadingAnimation = ctx.loadingAnimation; + const controller = new CommandController(ctx); + + const result = await controller.handleClearCommand(); + expect(result).toBe(false); + expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1); + expect(ctx.session.newSession).not.toHaveBeenCalled(); + expect(setSessionTerminalTitleMock).not.toHaveBeenCalled(); + expect(ctx.statusLine.invalidate).not.toHaveBeenCalled(); + expect(ctx.statusLine.setSessionStartTime).not.toHaveBeenCalled(); + expect(ctx.updateEditorTopBorder).not.toHaveBeenCalled(); + expect(ctx.ui.requestRender).toHaveBeenCalledTimes(1); + expect(ctx.chatContainer.clear).not.toHaveBeenCalled(); + expect(ctx.pendingMessagesContainer.clear).not.toHaveBeenCalled(); + expect(ctx.statusContainer.clear).not.toHaveBeenCalled(); + expect(ctx.pendingTools.clear).not.toHaveBeenCalled(); + expect(ctx.reloadTodos).not.toHaveBeenCalled(); + expect(loadingAnimation?.stop).not.toHaveBeenCalled(); + expect(ctx.loadingAnimation).toBe(loadingAnimation); + expect(ctx.chatContainer.children).toHaveLength(3); + const existingMessage = ctx.chatContainer.children[0]; + const spacer = ctx.chatContainer.children[1]; + const errorMessage = ctx.chatContainer.children[2]; + if (!(existingMessage instanceof Text)) { + throw new Error("Expected stale chat to remain visible"); + } + if (!(spacer instanceof Spacer)) { + throw new Error("Expected spacer before cancellation message"); + } + if (!(errorMessage instanceof Text)) { + throw new Error("Expected cancellation error message"); + } + expect(existingMessage.render(120).join("\n")).toContain("stale chat"); + expect(errorMessage.render(120).join("\n")).toContain("Error: New session cancelled"); + expect(ctx.pendingMessagesContainer.children).toHaveLength(1); + expect(ctx.statusContainer.children).toHaveLength(1); + const existingStatus = ctx.statusContainer.children[0]; + if (!(existingStatus instanceof Text)) { + throw new Error("Expected current status to remain visible"); + } + expect(existingStatus.render(120).join("\n")).toContain("streaming status"); + expect(ctx.compactionQueuedMessages as unknown).toEqual(["queued"]); + expect(ctx.streamingComponent as unknown).toEqual({ active: true }); + expect(ctx.streamingMessage as unknown).toEqual({ active: true }); + expect(calls).toEqual([ + "session.canStartNewSession", + "chatContainer.addChild", + "chatContainer.addChild", + "ui.requestRender", + ]); + }); + it("clearCommand adds a new session started message after clearing chat", async () => { setSessionTerminalTitleMock.mockReset(); const { ctx } = createContext(); diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-tree.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-tree.test.ts index f973c037a..a8cb17f8c 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-tree.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-tree.test.ts @@ -5,6 +5,7 @@ import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/ import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import type { SessionMessageEntry, SessionTreeNode } from "@oh-my-pi/pi-coding-agent/session/session-manager"; +import { type Component, Spacer, Text } from "@oh-my-pi/pi-tui"; interface TestEditorContainer { children: unknown[]; @@ -19,6 +20,12 @@ interface TestEditor { } type TestContext = InteractiveModeContext & { + chatContainer: { + children: Component[]; + addChild(child: Component): void; + }; + lastStatusSpacer: Spacer | undefined; + lastStatusText: Text | undefined; editor: TestEditor; editorContainer: TestEditorContainer; }; @@ -53,6 +60,13 @@ function createContext(tree: SessionTreeNode[], leafId: string | null) { return []; }, }; + const chatContainer = { + children: [] as Component[], + addChild(child: Component) { + this.children.push(child); + calls.push("chatContainer.addChild"); + }, + }; const editorContainer: TestEditorContainer = { children: [], clear() { @@ -72,9 +86,19 @@ function createContext(tree: SessionTreeNode[], leafId: string | null) { }); const showStatus = vi.fn((message: string) => { calls.push(`showStatus:${message}`); + const spacer = new Spacer(1); + const text = new Text(message, 1, 0); + chatContainer.addChild(spacer); + chatContainer.addChild(text); + ctx.lastStatusSpacer = spacer; + ctx.lastStatusText = text; + requestRender(); }); const ctx = { + chatContainer, + lastStatusSpacer: undefined, + lastStatusText: undefined, editor, editorContainer, ui: { @@ -128,12 +152,23 @@ describe("SelectorController tree selector", () => { expect(showStatus).toHaveBeenCalledWith("No entries in session"); expect(ctx.editorContainer.children).toEqual([]); + expect(ctx.chatContainer.children).toHaveLength(2); + const statusMessage = ctx.chatContainer.children[1]; + if (!(statusMessage instanceof Text)) { + throw new Error("Expected empty-tree status message"); + } + expect(statusMessage.render(120).join("\n")).toContain("No entries in session"); expect(setFocus).not.toHaveBeenCalled(); - expect(requestRender).not.toHaveBeenCalled(); - expect(calls).toEqual(["showStatus:No entries in session"]); + expect(requestRender).toHaveBeenCalledTimes(1); + expect(calls).toEqual([ + "showStatus:No entries in session", + "chatContainer.addChild", + "chatContainer.addChild", + "ui.requestRender", + ]); }); - it("selecting the current leaf runs done() and restores the editor without an extra render", () => { + it("selecting the current leaf runs done() and restores the editor before the status render", () => { const tree = [createTreeNode("entry-1", null, "hello")]; const { ctx, calls, editor, setFocus, requestRender, showStatus } = createContext(tree, "entry-1"); const controller = new SelectorController(ctx); @@ -149,7 +184,13 @@ describe("SelectorController tree selector", () => { expect(ctx.editorContainer.children).toEqual([editor]); expect(setFocus).toHaveBeenLastCalledWith(editor); expect(showStatus).toHaveBeenCalledWith("Already at this point"); - expect(requestRender).toHaveBeenCalledTimes(1); + expect(ctx.chatContainer.children).toHaveLength(2); + const statusMessage = ctx.chatContainer.children[1]; + if (!(statusMessage instanceof Text)) { + throw new Error("Expected current-leaf status message"); + } + expect(statusMessage.render(120).join("\n")).toContain("Already at this point"); + expect(requestRender).toHaveBeenCalledTimes(2); expect(calls).toEqual([ "editorContainer.clear", "editorContainer.addChild", @@ -159,6 +200,9 @@ describe("SelectorController tree selector", () => { "editorContainer.addChild", "ui.setFocus.editor", "showStatus:Already at this point", + "chatContainer.addChild", + "chatContainer.addChild", + "ui.requestRender", ]); }); }); diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index d7f2cc8f9..e508499f5 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -549,14 +549,17 @@ export class TUI extends Container { // appears right below the TUI output without a blank gap. if (this.#previousLines.length > 0) { const height = this.terminal.rows; - // How many content rows are visible in the current viewport - const visibleContentRows = Math.min(this.#previousLines.length, height); + // How many content rows are actually visible in the current viewport. + // After shrink paths, viewportTopRow can still point below historical content, + // so total previous line count overstates what is on screen. + const visibleContentRows = Math.max(0, Math.min(this.#previousLines.length - this.#viewportTopRow, height)); // Screen row of the last visible content line const lastContentScreenRow = visibleContentRows - 1; // Screen row where the hardware cursor currently sits const cursorScreenRow = this.#hardwareCursorRow - this.#viewportTopRow; - // Move to the row just after the last visible content - const targetScreenRow = lastContentScreenRow + 1; + // Move to the last visible content row, then print a newline so the shell prompt lands + // immediately below the rendered viewport content without an extra blank row. + const targetScreenRow = Math.max(0, lastContentScreenRow); const screenDelta = targetScreenRow - cursorScreenRow; if (screenDelta > 0) { this.terminal.write(`\x1b[${screenDelta}B`); @@ -1217,7 +1220,7 @@ export class TUI extends Container { // No line-level changes detected if (firstChanged === -1) { - if (height > this.#previousHeight && this.#previousHeight > 0) { + if (height > this.#previousHeight && this.#previousHeight > 0 && !isTermuxSession()) { repaintAfterHeightIncrease(); return; } diff --git a/packages/tui/test/render-regressions.test.ts b/packages/tui/test/render-regressions.test.ts index fd24d9858..42d2bb57b 100644 --- a/packages/tui/test/render-regressions.test.ts +++ b/packages/tui/test/render-regressions.test.ts @@ -190,6 +190,36 @@ describe("TUI terminal-state regressions", () => { } }); + it("Termux no-op height increase does not replay overflowing viewport rows into scrollback", async () => { + const previousTermuxVersion = process.env.TERMUX_VERSION; + process.env.TERMUX_VERSION = "1"; + const term = new VirtualTerminal(40, 4); + const tui = new TUI(term); + const component = new MutableLinesComponent(rows("ui-", 12)); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + expect(countMatches(term.getScrollBuffer(), /\bui-6\b/)).toBe(1); + expect(countMatches(term.getScrollBuffer(), /\bui-11\b/)).toBe(1); + + term.resize(40, 6); + await settle(term); + + expect(countMatches(term.getScrollBuffer(), /\bui-6\b/)).toBe(1); + expect(countMatches(term.getScrollBuffer(), /\bui-7\b/)).toBe(1); + expect(countMatches(term.getScrollBuffer(), /\bui-8\b/)).toBe(1); + expect(countMatches(term.getScrollBuffer(), /\bui-9\b/)).toBe(1); + expect(countMatches(term.getScrollBuffer(), /\bui-10\b/)).toBe(1); + expect(countMatches(term.getScrollBuffer(), /\bui-11\b/)).toBe(1); + } finally { + if (previousTermuxVersion === undefined) delete process.env.TERMUX_VERSION; + else process.env.TERMUX_VERSION = previousTermuxVersion; + tui.stop(); + } + }); + it("resizing width truncates visible lines without ghost wrap rows", async () => { const term = new VirtualTerminal(30, 6); const tui = new TUI(term); @@ -1089,6 +1119,35 @@ describe("TUI terminal-state regressions", () => { } }); + it("stop after content shrink moves the prompt to the visible content boundary", async () => { + const term = new VirtualTerminal(40, 10); + const tui = new TUI(term); + const component = new MutableLinesComponent(rows("base-", 30)); + tui.addChild(component); + + try { + tui.start(); + await settle(term); + + component.setLines(rows("base-", 4)); + tui.requestRender(); + await settle(term); + + tui.stop(); + await settle(term); + + const viewport = visible(term); + expect(viewport[0]?.trim()).toBe("base-0"); + expect(viewport[3]?.trim()).toBe("base-3"); + const active = (term as unknown as { xterm: { buffer: { active: { cursorY: number; baseY: number } } } }) + .xterm.buffer.active; + expect(active.baseY).toBe(30); + expect(active.cursorY).toBe(4); + } finally { + // stop() already ran in the main flow; keep finally for symmetry if the test fails early + } + }); + it("stop after overlay dismissal does not create scrollback gap", async () => { const term = new VirtualTerminal(40, 10); const tui = new TUI(term);