revert: 535 due to TUI regressions
This commit is contained in:
@@ -59,7 +59,6 @@ describe("AuthStorage api-key login replacement", () => {
|
||||
await authStorage.login("kagi", controller);
|
||||
await authStorage.login("kagi", controller);
|
||||
|
||||
expect(loginKagiSpy).toHaveBeenCalledTimes(2);
|
||||
expect(countCredentialRows(dbPath, "kagi")).toBe(1);
|
||||
const credentials = store.listAuthCredentials("kagi");
|
||||
expect(credentials).toHaveLength(1);
|
||||
|
||||
@@ -23,7 +23,7 @@ import { BorderedLoader } from "../../modes/components/bordered-loader";
|
||||
import { DynamicBorder } from "../../modes/components/dynamic-border";
|
||||
import { PythonExecutionComponent } from "../../modes/components/python-execution";
|
||||
import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
|
||||
import type { ClearCommandOptions, ClearCommandRollback, InteractiveModeContext } from "../../modes/types";
|
||||
import type { InteractiveModeContext } from "../../modes/types";
|
||||
import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
|
||||
import type { AsyncJobSnapshotItem } from "../../session/agent-session";
|
||||
import type { AuthStorage } from "../../session/auth-storage";
|
||||
@@ -561,68 +561,40 @@ export class CommandController {
|
||||
this.ctx.showError("Usage: /memory <view|clear|reset|enqueue|rebuild>");
|
||||
}
|
||||
|
||||
async handleClearCommand(options?: ClearCommandOptions): Promise<boolean> {
|
||||
let rollbackBeforeSwitchCheck: ClearCommandRollback | undefined;
|
||||
try {
|
||||
rollbackBeforeSwitchCheck = await options?.beforeSwitchCheck?.();
|
||||
|
||||
const switchApproved = await this.ctx.session.canStartNewSession();
|
||||
if (!switchApproved) {
|
||||
await rollbackBeforeSwitchCheck?.();
|
||||
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;
|
||||
}
|
||||
|
||||
rollbackBeforeSwitchCheck = undefined;
|
||||
if (this.ctx.session.isCompacting) {
|
||||
this.ctx.session.abortCompaction();
|
||||
while (this.ctx.session.isCompacting) {
|
||||
await Bun.sleep(10);
|
||||
}
|
||||
}
|
||||
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();
|
||||
this.ctx.statusLine.setSessionStartTime(Date.now());
|
||||
this.ctx.updateEditorTopBorder();
|
||||
this.ctx.ui.requestRender();
|
||||
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.compactionQueuedMessages = [];
|
||||
this.ctx.streamingComponent = undefined;
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
|
||||
);
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender();
|
||||
return true;
|
||||
} catch (error) {
|
||||
await rollbackBeforeSwitchCheck?.();
|
||||
throw error;
|
||||
async handleClearCommand(): Promise<void> {
|
||||
if (this.ctx.loadingAnimation) {
|
||||
this.ctx.loadingAnimation.stop();
|
||||
this.ctx.loadingAnimation = undefined;
|
||||
}
|
||||
this.ctx.statusContainer.clear();
|
||||
|
||||
if (this.ctx.session.isCompacting) {
|
||||
this.ctx.session.abortCompaction();
|
||||
while (this.ctx.session.isCompacting) {
|
||||
await Bun.sleep(10);
|
||||
}
|
||||
}
|
||||
await this.ctx.session.newSession();
|
||||
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
|
||||
|
||||
this.ctx.statusLine.invalidate();
|
||||
this.ctx.statusLine.setSessionStartTime(Date.now());
|
||||
this.ctx.updateEditorTopBorder();
|
||||
this.ctx.ui.requestRender();
|
||||
|
||||
this.ctx.chatContainer.clear();
|
||||
this.ctx.pendingMessagesContainer.clear();
|
||||
this.ctx.compactionQueuedMessages = [];
|
||||
this.ctx.streamingComponent = undefined;
|
||||
this.ctx.streamingMessage = undefined;
|
||||
this.ctx.pendingTools.clear();
|
||||
|
||||
this.ctx.chatContainer.addChild(new Spacer(1));
|
||||
this.ctx.chatContainer.addChild(
|
||||
new Text(`${theme.fg("accent", `${theme.status.success} New session started`)}`, 1, 1),
|
||||
);
|
||||
await this.ctx.reloadTodos();
|
||||
this.ctx.ui.requestRender();
|
||||
}
|
||||
|
||||
async handleForkCommand(): Promise<void> {
|
||||
|
||||
@@ -60,14 +60,7 @@ import {
|
||||
onThemeChange,
|
||||
theme,
|
||||
} from "./theme/theme";
|
||||
import type {
|
||||
ClearCommandOptions,
|
||||
CompactionQueuedMessage,
|
||||
InteractiveModeContext,
|
||||
SubmittedUserInput,
|
||||
TodoItem,
|
||||
TodoPhase,
|
||||
} from "./types";
|
||||
import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
|
||||
import { UiHelpers } from "./utils/ui-helpers";
|
||||
|
||||
const EDITOR_MAX_HEIGHT_MIN = 6;
|
||||
@@ -703,7 +696,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
}
|
||||
|
||||
const previousTools = this.#planModePreviousTools;
|
||||
if (previousTools) {
|
||||
if (previousTools && previousTools.length > 0) {
|
||||
await this.session.setActiveToolsByName(previousTools);
|
||||
}
|
||||
if (this.#planModePreviousModel) {
|
||||
@@ -754,29 +747,15 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
planContent: string,
|
||||
options: { planFilePath: string; finalPlanFilePath: string },
|
||||
): Promise<void> {
|
||||
const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
|
||||
const didCreateFreshSession = await this.handleClearCommand({
|
||||
beforeSwitchCheck: () => {
|
||||
this.sessionManager.setTransientModeOverride("none");
|
||||
return () => {
|
||||
this.sessionManager.clearTransientModeOverride();
|
||||
};
|
||||
},
|
||||
beforeSwitch: async () => {
|
||||
this.sessionManager.clearTransientModeOverride();
|
||||
await renameApprovedPlanFile({
|
||||
planFilePath: options.planFilePath,
|
||||
finalPlanFilePath: options.finalPlanFilePath,
|
||||
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
||||
getSessionId: () => this.sessionManager.getSessionId(),
|
||||
});
|
||||
await this.#exitPlanMode({ silent: true, paused: false });
|
||||
},
|
||||
await renameApprovedPlanFile({
|
||||
planFilePath: options.planFilePath,
|
||||
finalPlanFilePath: options.finalPlanFilePath,
|
||||
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
|
||||
getSessionId: () => this.sessionManager.getSessionId(),
|
||||
});
|
||||
if (!didCreateFreshSession) {
|
||||
return;
|
||||
}
|
||||
await this.session.setActiveToolsByName(previousTools);
|
||||
const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
|
||||
await this.#exitPlanMode({ silent: true, paused: false });
|
||||
await this.handleClearCommand();
|
||||
// The new session has a fresh local:// root — persist the approved plan there
|
||||
// so `local://<title>.md` resolves correctly in the execution session.
|
||||
const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
|
||||
@@ -784,6 +763,9 @@ 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, {
|
||||
@@ -1102,15 +1084,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#commandController.handleHotkeysCommand();
|
||||
}
|
||||
|
||||
async handleClearCommand(options?: ClearCommandOptions): Promise<boolean> {
|
||||
return this.#commandController.handleClearCommand({
|
||||
beforeSwitchCheck: options?.beforeSwitchCheck,
|
||||
beforeSwitch: async () => {
|
||||
await options?.beforeSwitch?.();
|
||||
this.#btwController.dispose();
|
||||
this.#extensionUiController.clearExtensionTerminalInputListeners();
|
||||
},
|
||||
});
|
||||
handleClearCommand(): Promise<void> {
|
||||
this.#btwController.dispose();
|
||||
this.#extensionUiController.clearExtensionTerminalInputListeners();
|
||||
return this.#commandController.handleClearCommand();
|
||||
}
|
||||
|
||||
handleForkCommand(): Promise<void> {
|
||||
|
||||
@@ -54,14 +54,6 @@ export type TodoPhase = {
|
||||
tasks: TodoItem[];
|
||||
};
|
||||
|
||||
export type ClearCommandRollback = () => Promise<void> | void;
|
||||
export type ClearCommandCheck = () => Promise<ClearCommandRollback | undefined> | ClearCommandRollback | undefined;
|
||||
|
||||
export interface ClearCommandOptions {
|
||||
beforeSwitchCheck?: ClearCommandCheck;
|
||||
beforeSwitch?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface InteractiveModeContext {
|
||||
// UI access
|
||||
ui: TUI;
|
||||
@@ -185,7 +177,7 @@ export interface InteractiveModeContext {
|
||||
handleHotkeysCommand(): void;
|
||||
handleDumpCommand(): void;
|
||||
handleDebugTranscriptCommand(): Promise<void>;
|
||||
handleClearCommand(options?: ClearCommandOptions): Promise<boolean>;
|
||||
handleClearCommand(): Promise<void>;
|
||||
handleForkCommand(): Promise<void>;
|
||||
handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void>;
|
||||
handlePythonCommand(code: string, excludeFromContext?: boolean): Promise<void>;
|
||||
|
||||
@@ -2944,26 +2944,6 @@ 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.
|
||||
@@ -2971,7 +2951,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, internal?: { skipBeforeSwitchCheck?: boolean }): Promise<boolean> {
|
||||
async newSession(options?: NewSessionOptions): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
|
||||
? [
|
||||
@@ -2980,8 +2960,16 @@ export class AgentSession {
|
||||
]
|
||||
: undefined;
|
||||
|
||||
if (!internal?.skipBeforeSwitchCheck && !(await this.#canSwitchSession({ reason: "new" }))) {
|
||||
return false;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
this.#disconnectFromAgent();
|
||||
@@ -3044,8 +3032,16 @@ export class AgentSession {
|
||||
async fork(): Promise<boolean> {
|
||||
const previousSessionFile = this.sessionFile;
|
||||
|
||||
if (!(await this.#canSwitchSession({ reason: "fork" }))) {
|
||||
return false;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush current session to ensure all entries are written
|
||||
@@ -5209,8 +5205,17 @@ export class AgentSession {
|
||||
const switchingToDifferentSession = previousSessionFile
|
||||
? path.resolve(previousSessionFile) !== path.resolve(sessionPath)
|
||||
: true;
|
||||
if (!(await this.#canSwitchSession({ reason: "resume", targetSessionFile: sessionPath }))) {
|
||||
return false;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
this.#disconnectFromAgent();
|
||||
|
||||
@@ -1394,7 +1394,6 @@ export class SessionManager {
|
||||
#byId: Map<string, SessionEntry> = new Map();
|
||||
#labelsById: Map<string, string> = new Map();
|
||||
#leafId: string | null = null;
|
||||
#transientModeOverride: { mode: string; data?: Record<string, unknown> } | undefined;
|
||||
#usageStatistics = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
@@ -2077,14 +2076,6 @@ export class SessionManager {
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
setTransientModeOverride(mode: string, data?: Record<string, unknown>): void {
|
||||
this.#transientModeOverride = { mode, data };
|
||||
}
|
||||
|
||||
clearTransientModeOverride(): void {
|
||||
this.#transientModeOverride = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a model change as child of current leaf, then advance leaf. Returns entry id.
|
||||
* @param model Model in "provider/modelId" format
|
||||
@@ -2350,16 +2341,7 @@ export class SessionManager {
|
||||
* Uses tree traversal from current leaf.
|
||||
*/
|
||||
buildSessionContext(): SessionContext {
|
||||
const context = buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
|
||||
if (!this.#transientModeOverride) {
|
||||
return context;
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
mode: this.#transientModeOverride.mode,
|
||||
modeData: this.#transientModeOverride.data,
|
||||
};
|
||||
return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
|
||||
}
|
||||
|
||||
/** Strip stale OpenAI Responses assistant replay metadata from loaded in-memory entries. */
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { CommandController } from "@oh-my-pi/pi-coding-agent/modes/controllers/command-controller";
|
||||
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 { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { type Component, Spacer, Text } from "@oh-my-pi/pi-tui";
|
||||
|
||||
const setSessionTerminalTitleMock = vi.fn();
|
||||
|
||||
vi.mock("@oh-my-pi/pi-coding-agent/utils/title-generator", () => ({
|
||||
setSessionTerminalTitle: setSessionTerminalTitleMock,
|
||||
}));
|
||||
|
||||
type TestContainer = {
|
||||
children: Component[];
|
||||
clear: () => void;
|
||||
addChild: (child: Component) => void;
|
||||
};
|
||||
|
||||
type TestContext = InteractiveModeContext & {
|
||||
chatContainer: TestContainer;
|
||||
pendingMessagesContainer: TestContainer;
|
||||
statusContainer: TestContainer;
|
||||
pendingTools: {
|
||||
clear: () => void;
|
||||
};
|
||||
loadingAnimation:
|
||||
| {
|
||||
stop: () => void;
|
||||
}
|
||||
| undefined;
|
||||
statusLine: {
|
||||
invalidate: () => void;
|
||||
setSessionStartTime: (time: number) => void;
|
||||
};
|
||||
ui: {
|
||||
requestRender: () => void;
|
||||
terminal: { columns: number; rows: number };
|
||||
};
|
||||
session: {
|
||||
canStartNewSession: () => Promise<boolean>;
|
||||
newSession: (...args: unknown[]) => Promise<boolean>;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
abortCompaction: () => void;
|
||||
};
|
||||
sessionManager: {
|
||||
getSessionName: () => string;
|
||||
getCwd: () => string;
|
||||
buildSessionContext: () => SessionContext;
|
||||
};
|
||||
reloadTodos: () => Promise<void>;
|
||||
updateEditorTopBorder: () => void;
|
||||
};
|
||||
|
||||
function createTrackedContainer(name: string, calls: string[], initialChildren: Component[] = []): TestContainer {
|
||||
const container: TestContainer = {
|
||||
children: [...initialChildren],
|
||||
clear: vi.fn(() => {
|
||||
container.children = [];
|
||||
calls.push(`${name}.clear`);
|
||||
}),
|
||||
addChild: vi.fn((child: Component) => {
|
||||
container.children.push(child);
|
||||
calls.push(`${name}.addChild`);
|
||||
}),
|
||||
};
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function createContext(options?: {
|
||||
withLoadingAnimation?: boolean;
|
||||
canStartNewSessionResult?: boolean;
|
||||
newSessionResult?: boolean;
|
||||
isStreaming?: boolean;
|
||||
isCompacting?: boolean;
|
||||
initialSessionMode?: string;
|
||||
}): { 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
|
||||
: {
|
||||
stop: vi.fn(() => {
|
||||
calls.push("loadingAnimation.stop");
|
||||
}),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
chatContainer,
|
||||
pendingMessagesContainer,
|
||||
statusContainer,
|
||||
pendingTools: {
|
||||
clear: vi.fn(() => {
|
||||
calls.push("pendingTools.clear");
|
||||
}),
|
||||
},
|
||||
loadingAnimation,
|
||||
compactionQueuedMessages: ["queued"],
|
||||
streamingComponent: { active: true },
|
||||
streamingMessage: { active: true },
|
||||
statusLine: {
|
||||
invalidate: vi.fn(() => {
|
||||
calls.push("statusLine.invalidate");
|
||||
}),
|
||||
setSessionStartTime: vi.fn(() => {
|
||||
calls.push("statusLine.setSessionStartTime");
|
||||
}),
|
||||
},
|
||||
ui: {
|
||||
requestRender: vi.fn(() => {
|
||||
calls.push("ui.requestRender");
|
||||
}),
|
||||
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 options?.newSessionResult ?? true;
|
||||
}),
|
||||
isStreaming: options?.isStreaming ?? false,
|
||||
isCompacting: options?.isCompacting ?? false,
|
||||
abortCompaction: vi.fn(() => {
|
||||
calls.push("session.abortCompaction");
|
||||
}),
|
||||
},
|
||||
sessionManager: {
|
||||
getSessionName: vi.fn(() => "Fresh session"),
|
||||
getCwd: vi.fn(() => "/tmp/project"),
|
||||
buildSessionContext: vi.fn(() => ({ mode: options?.initialSessionMode ?? "none" }) as SessionContext),
|
||||
},
|
||||
reloadTodos: vi.fn(async () => {
|
||||
calls.push("reloadTodos");
|
||||
}),
|
||||
updateEditorTopBorder: vi.fn(() => {
|
||||
calls.push("updateEditorTopBorder");
|
||||
}),
|
||||
} as unknown as TestContext;
|
||||
|
||||
return { ctx, calls };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
initTheme();
|
||||
});
|
||||
|
||||
describe("CommandController /new command", () => {
|
||||
it("clearCommand clears session state and starts a fresh session without extra renders", async () => {
|
||||
setSessionTerminalTitleMock.mockReset();
|
||||
const { ctx, calls } = createContext();
|
||||
const loadingAnimation = ctx.loadingAnimation;
|
||||
setSessionTerminalTitleMock.mockImplementation(() => {
|
||||
calls.push("setSessionTerminalTitle");
|
||||
});
|
||||
const controller = new CommandController(ctx);
|
||||
|
||||
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();
|
||||
expect(ctx.streamingComponent).toBeUndefined();
|
||||
expect(ctx.streamingMessage).toBeUndefined();
|
||||
expect(ctx.compactionQueuedMessages).toEqual([]);
|
||||
expect(ctx.statusLine.invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.statusLine.setSessionStartTime).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.reloadTodos).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.ui.requestRender).toHaveBeenCalledTimes(2);
|
||||
expect(setSessionTerminalTitleMock).toHaveBeenCalledWith("Fresh session", "/tmp/project");
|
||||
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 () => {
|
||||
setSessionTerminalTitleMock.mockReset();
|
||||
const { ctx, calls } = createContext({ withLoadingAnimation: false });
|
||||
const controller = new CommandController(ctx);
|
||||
|
||||
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 rolls back temporary pre-switch teardown when new session approval is denied", async () => {
|
||||
const { ctx, calls } = createContext({ canStartNewSessionResult: false, initialSessionMode: "plan" });
|
||||
const hookState = { mode: "plan" as "plan" | "none" };
|
||||
const controller = new CommandController(ctx);
|
||||
|
||||
ctx.sessionManager.buildSessionContext = vi.fn(() => ({ mode: hookState.mode }) as SessionContext);
|
||||
ctx.session.canStartNewSession = vi.fn(async () => {
|
||||
calls.push(`session.canStartNewSession:${ctx.sessionManager.buildSessionContext().mode}`);
|
||||
return false;
|
||||
});
|
||||
|
||||
const rollbackBeforeSwitchCheck = vi.fn(() => {
|
||||
hookState.mode = "plan";
|
||||
calls.push(`rollbackBeforeSwitchCheck:${hookState.mode}`);
|
||||
});
|
||||
const beforeSwitchCheck = vi.fn(() => {
|
||||
hookState.mode = "none";
|
||||
calls.push(`prepareBeforeSwitchCheck:${hookState.mode}`);
|
||||
return rollbackBeforeSwitchCheck;
|
||||
});
|
||||
const beforeSwitch = vi.fn(() => {
|
||||
calls.push("beforeSwitch");
|
||||
});
|
||||
|
||||
const result = await controller.handleClearCommand({
|
||||
beforeSwitchCheck,
|
||||
beforeSwitch,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(beforeSwitchCheck).toHaveBeenCalledTimes(1);
|
||||
expect(rollbackBeforeSwitchCheck).toHaveBeenCalledTimes(1);
|
||||
expect(beforeSwitch).not.toHaveBeenCalled();
|
||||
expect(ctx.session.newSession).not.toHaveBeenCalled();
|
||||
expect(hookState.mode).toBe("plan");
|
||||
expect(calls).toEqual([
|
||||
"prepareBeforeSwitchCheck:none",
|
||||
"session.canStartNewSession:none",
|
||||
"rollbackBeforeSwitchCheck:plan",
|
||||
"chatContainer.addChild",
|
||||
"chatContainer.addChild",
|
||||
"ui.requestRender",
|
||||
]);
|
||||
});
|
||||
|
||||
it("clearCommand does not abort compaction when new session approval is denied", async () => {
|
||||
const { ctx } = createContext({ canStartNewSessionResult: false, isCompacting: true });
|
||||
const controller = new CommandController(ctx);
|
||||
|
||||
const result = await controller.handleClearCommand();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(ctx.session.canStartNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.session.abortCompaction).not.toHaveBeenCalled();
|
||||
expect(ctx.session.newSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearCommand adds a new session started message after clearing chat", async () => {
|
||||
setSessionTerminalTitleMock.mockReset();
|
||||
const { ctx } = createContext();
|
||||
const controller = new CommandController(ctx);
|
||||
|
||||
await controller.handleClearCommand();
|
||||
|
||||
expect(ctx.chatContainer.children).toHaveLength(2);
|
||||
const spacer = ctx.chatContainer.children[0];
|
||||
const message = ctx.chatContainer.children[1];
|
||||
if (!(spacer instanceof Spacer)) {
|
||||
throw new Error("Expected spacer after clearing chat");
|
||||
}
|
||||
if (!(message instanceof Text)) {
|
||||
throw new Error("Expected new session message");
|
||||
}
|
||||
expect(message.render(120).join("\n")).toContain("New session started");
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { TreeSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tree-selector";
|
||||
import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller";
|
||||
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[];
|
||||
clear(): void;
|
||||
addChild(child: unknown): void;
|
||||
}
|
||||
|
||||
interface TestEditor {
|
||||
readonly kind: "editor";
|
||||
invalidate(): void;
|
||||
render(width: number): string[];
|
||||
}
|
||||
|
||||
type TestContext = InteractiveModeContext & {
|
||||
chatContainer: {
|
||||
children: Component[];
|
||||
addChild(child: Component): void;
|
||||
};
|
||||
lastStatusSpacer: Spacer | undefined;
|
||||
lastStatusText: Text | undefined;
|
||||
editor: TestEditor;
|
||||
editorContainer: TestEditorContainer;
|
||||
};
|
||||
|
||||
function createTreeNode(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
text: string,
|
||||
children: SessionTreeNode[] = [],
|
||||
): SessionTreeNode {
|
||||
const entry: SessionMessageEntry = {
|
||||
type: "message",
|
||||
id,
|
||||
parentId,
|
||||
timestamp: "2025-01-01T00:00:00Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: 1,
|
||||
},
|
||||
};
|
||||
|
||||
return { entry, children };
|
||||
}
|
||||
|
||||
function createContext(tree: SessionTreeNode[], leafId: string | null) {
|
||||
const calls: string[] = [];
|
||||
const editor: TestEditor = {
|
||||
kind: "editor",
|
||||
invalidate() {},
|
||||
render() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const chatContainer = {
|
||||
children: [] as Component[],
|
||||
addChild(child: Component) {
|
||||
this.children.push(child);
|
||||
calls.push("chatContainer.addChild");
|
||||
},
|
||||
};
|
||||
const editorContainer: TestEditorContainer = {
|
||||
children: [],
|
||||
clear() {
|
||||
this.children = [];
|
||||
calls.push("editorContainer.clear");
|
||||
},
|
||||
addChild(child: unknown) {
|
||||
this.children.push(child);
|
||||
calls.push("editorContainer.addChild");
|
||||
},
|
||||
};
|
||||
const setFocus = vi.fn((component: unknown) => {
|
||||
calls.push(component === editor ? "ui.setFocus.editor" : "ui.setFocus.selector");
|
||||
});
|
||||
const requestRender = vi.fn(() => {
|
||||
calls.push("ui.requestRender");
|
||||
});
|
||||
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: {
|
||||
setFocus,
|
||||
requestRender,
|
||||
terminal: { rows: 40 },
|
||||
},
|
||||
sessionManager: {
|
||||
getTree: vi.fn(() => tree),
|
||||
getLeafId: vi.fn(() => leafId),
|
||||
appendLabelChange: vi.fn(),
|
||||
},
|
||||
showStatus,
|
||||
} as unknown as TestContext;
|
||||
|
||||
return { ctx, calls, editor, setFocus, requestRender, showStatus };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
_resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true });
|
||||
initTheme();
|
||||
});
|
||||
|
||||
describe("SelectorController tree selector", () => {
|
||||
it("showTreeSelector opens the tree selector in the editor container", () => {
|
||||
const tree = [createTreeNode("entry-1", null, "hello")];
|
||||
const { ctx, calls, setFocus, requestRender } = createContext(tree, "entry-1");
|
||||
const controller = new SelectorController(ctx);
|
||||
|
||||
controller.showTreeSelector();
|
||||
|
||||
expect(ctx.editorContainer.children).toHaveLength(1);
|
||||
const selector = ctx.editorContainer.children[0];
|
||||
expect(selector).toBeInstanceOf(TreeSelectorComponent);
|
||||
expect(setFocus).toHaveBeenCalledWith(selector);
|
||||
expect(requestRender).toHaveBeenCalledTimes(1);
|
||||
expect(calls).toEqual([
|
||||
"editorContainer.clear",
|
||||
"editorContainer.addChild",
|
||||
"ui.setFocus.selector",
|
||||
"ui.requestRender",
|
||||
]);
|
||||
});
|
||||
|
||||
it("showTreeSelector with an empty tree shows a status message", () => {
|
||||
const { ctx, calls, showStatus, setFocus, requestRender } = createContext([], null);
|
||||
const controller = new SelectorController(ctx);
|
||||
|
||||
controller.showTreeSelector();
|
||||
|
||||
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).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 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);
|
||||
|
||||
controller.showTreeSelector();
|
||||
const selector = ctx.editorContainer.children[0];
|
||||
if (!(selector instanceof TreeSelectorComponent)) {
|
||||
throw new Error("Expected tree selector component");
|
||||
}
|
||||
|
||||
selector.handleInput("\n");
|
||||
|
||||
expect(ctx.editorContainer.children).toEqual([editor]);
|
||||
expect(setFocus).toHaveBeenLastCalledWith(editor);
|
||||
expect(showStatus).toHaveBeenCalledWith("Already at this point");
|
||||
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",
|
||||
"ui.setFocus.selector",
|
||||
"ui.requestRender",
|
||||
"editorContainer.clear",
|
||||
"editorContainer.addChild",
|
||||
"ui.setFocus.editor",
|
||||
"showStatus:Already at this point",
|
||||
"chatContainer.addChild",
|
||||
"chatContainer.addChild",
|
||||
"ui.requestRender",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+51
-194
@@ -230,8 +230,6 @@ export class TUI extends Container {
|
||||
#maxLinesRendered = 0; // High-water line count used for clear-on-shrink policy
|
||||
#fullRedrawCount = 0;
|
||||
#stopped = false;
|
||||
#forceFullRepaint = false; // One-shot flag: skip diff rendering and use the correct full-repaint mode
|
||||
#terminalStateTrusted = false; // stop() and shell output can desync the real terminal from our cached frame
|
||||
|
||||
// Overlay stack for modal components rendered on top of base content
|
||||
overlayStack: {
|
||||
@@ -546,37 +544,31 @@ export class TUI extends Container {
|
||||
stop(): void {
|
||||
this.#clearSixelProbeState();
|
||||
this.#stopped = true;
|
||||
// Move cursor just past the visible content so the shell prompt
|
||||
// appears right below the TUI output without a blank gap.
|
||||
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
||||
if (this.#previousLines.length > 0) {
|
||||
const height = this.terminal.rows;
|
||||
// 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 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`);
|
||||
} else if (screenDelta < 0) {
|
||||
this.terminal.write(`\x1b[${-screenDelta}A`);
|
||||
const targetRow = this.#previousLines.length; // Line after the last content
|
||||
const lineDiff = targetRow - this.#hardwareCursorRow;
|
||||
if (lineDiff > 0) {
|
||||
this.terminal.write(`\x1b[${lineDiff}B`);
|
||||
} else if (lineDiff < 0) {
|
||||
this.terminal.write(`\x1b[${-lineDiff}A`);
|
||||
}
|
||||
this.terminal.write("\r\n");
|
||||
}
|
||||
this.#terminalStateTrusted = false;
|
||||
|
||||
this.terminal.showCursor();
|
||||
this.terminal.stop();
|
||||
}
|
||||
|
||||
requestRender(force = false): void {
|
||||
if (force) {
|
||||
this.#forceFullRepaint = true;
|
||||
this.#previousLines = [];
|
||||
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
||||
this.#previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
|
||||
this.#cursorRow = 0;
|
||||
this.#hardwareCursorRow = 0;
|
||||
this.#viewportTopRow = 0;
|
||||
this.#maxLinesRendered = 0;
|
||||
}
|
||||
if (this.#renderRequested) return;
|
||||
this.#renderRequested = true;
|
||||
@@ -1011,182 +1003,71 @@ export class TUI extends Container {
|
||||
const widthChanged = this.#previousWidth !== 0 && this.#previousWidth !== width;
|
||||
const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
|
||||
|
||||
// Consume force flag
|
||||
const forceRepaint = this.#forceFullRepaint;
|
||||
this.#forceFullRepaint = false;
|
||||
const hasPriorFrame = this.#terminalStateTrusted && this.#previousLines.length > 0;
|
||||
|
||||
// Common bookkeeping after any full-repaint path
|
||||
const finishFullRepaint = (): void => {
|
||||
// Helper to clear scrollback and viewport and render all new lines
|
||||
const fullRender = (clear: boolean): void => {
|
||||
this.#fullRedrawCount += 1;
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
if (clear) buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
|
||||
const reset = SEGMENT_RESET;
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
const line = newLines[i];
|
||||
buffer += TERMINAL.isImageLine(line) ? line : line + reset;
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
this.#cursorRow = Math.max(0, newLines.length - 1);
|
||||
this.#hardwareCursorRow = this.#cursorRow;
|
||||
this.#maxLinesRendered = newLines.length;
|
||||
// Reset max lines when clearing, otherwise track growth
|
||||
if (clear) {
|
||||
this.#maxLinesRendered = newLines.length;
|
||||
} else {
|
||||
this.#maxLinesRendered = Math.max(this.#maxLinesRendered, newLines.length);
|
||||
}
|
||||
this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
|
||||
this.#positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.#previousLines = newLines;
|
||||
this.#previousWidth = width;
|
||||
this.#previousHeight = height;
|
||||
this.#terminalStateTrusted = true;
|
||||
};
|
||||
|
||||
const previousVisibleRows = Math.max(
|
||||
0,
|
||||
Math.min(this.#previousHeight, this.#previousLines.length - prevViewportTop),
|
||||
);
|
||||
const seedScrollRows = previousVisibleRows > 0 ? previousVisibleRows : height;
|
||||
|
||||
// First paint: no prior trusted TUI frame exists. Preserve whatever is currently
|
||||
// visible by scrolling only the rows we know are occupied; otherwise fall back to
|
||||
// the full viewport for the initial shell->TUI takeover path.
|
||||
const seedTranscript = (): void => {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
if (seedScrollRows > 0) {
|
||||
// Push existing viewport content into scrollback by scrolling it off.
|
||||
// Move to the last occupied screen row, then emit newlines for exactly those rows.
|
||||
buffer += `\x1b[${seedScrollRows};1H`;
|
||||
buffer += "\n".repeat(seedScrollRows);
|
||||
}
|
||||
buffer += "\x1b[H"; // Home cursor
|
||||
const reset = SEGMENT_RESET;
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
if (i > 0) buffer += "\r\n";
|
||||
buffer += "\x1b[2K"; // Clear this display row before writing
|
||||
const line = newLines[i];
|
||||
buffer += TERMINAL.isImageLine(line) ? line : line + reset;
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
finishFullRepaint();
|
||||
};
|
||||
|
||||
// Viewport repaint: a prior TUI frame exists. Before overwriting the visible
|
||||
// display, scroll off the rows that are transitioning from viewport to
|
||||
// scrollback (the viewport-shift delta). Then overwrite in-place.
|
||||
const repaintViewport = (): void => {
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
// Compute how many rows the viewport shifted since the last render.
|
||||
// These rows were at the top of the old viewport and now belong in scrollback.
|
||||
const oldVpTop = Math.max(0, this.#previousLines.length - this.#previousHeight);
|
||||
const newVpTop = Math.max(0, newLines.length - height);
|
||||
const scrollDelta = Math.max(0, newVpTop - oldVpTop);
|
||||
if (scrollDelta > 0) {
|
||||
// Move cursor to the last row that is actually occupied on screen before scrolling.
|
||||
const curScreenRow = hardwareCursorRow - prevViewportTop;
|
||||
const usedRows = previousVisibleRows;
|
||||
const toBottom = usedRows - 1 - curScreenRow;
|
||||
if (toBottom > 0) buffer += `\x1b[${toBottom}B`;
|
||||
buffer += "\r\n".repeat(scrollDelta);
|
||||
}
|
||||
buffer += "\x1b[H"; // Home cursor
|
||||
const vpTop = newVpTop;
|
||||
const vpLines = newLines.length - vpTop;
|
||||
const reset = SEGMENT_RESET;
|
||||
for (let i = vpTop; i < newLines.length; i++) {
|
||||
if (i > vpTop) buffer += "\r\n";
|
||||
buffer += "\x1b[2K"; // Clear this display row before writing
|
||||
const line = newLines[i];
|
||||
buffer += TERMINAL.isImageLine(line) ? line : line + reset;
|
||||
}
|
||||
// Clear any remaining display rows below the viewport content.
|
||||
// Use erase-to-end instead of \r\n loops to avoid moving the cursor
|
||||
// past the content area — cursor drift here would desync stop().
|
||||
if (vpLines < height) {
|
||||
if (vpLines > 0) {
|
||||
buffer += "\r\n\x1b[J"; // Move to col 0 on next line, erase to end of display
|
||||
buffer += "\x1b[1A"; // Move cursor back to last content row
|
||||
} else {
|
||||
buffer += "\x1b[J"; // No content lines; erase entire viewport from home
|
||||
}
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
finishFullRepaint();
|
||||
};
|
||||
|
||||
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
|
||||
const logRedraw = (reason: string): void => {
|
||||
if (!debugRedraw) return;
|
||||
const logPath = getDebugLogPath();
|
||||
const msg = `[${new Date().toISOString()}] repaint: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
||||
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.#previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
||||
fs.appendFileSync(logPath, msg);
|
||||
};
|
||||
|
||||
const repaintAfterHeightIncrease = (): void => {
|
||||
logRedraw(`height increase (${this.#previousHeight} -> ${height})`);
|
||||
let buffer = "\x1b[?2026h"; // Begin synchronized output
|
||||
// Scroll only the rows that are actually visible now.
|
||||
const curScreenRow = hardwareCursorRow - prevViewportTop;
|
||||
const screenRows = previousVisibleRows;
|
||||
if (screenRows > 0) {
|
||||
const toBottom = screenRows - 1 - curScreenRow;
|
||||
if (toBottom > 0) buffer += `\x1b[${toBottom}B`;
|
||||
buffer += "\r\n".repeat(screenRows);
|
||||
}
|
||||
buffer += "\x1b[H"; // Home cursor
|
||||
const vpTop = Math.max(0, newLines.length - height);
|
||||
const vpLines = newLines.length - vpTop;
|
||||
const reset = SEGMENT_RESET;
|
||||
for (let i = vpTop; i < newLines.length; i++) {
|
||||
if (i > vpTop) buffer += "\r\n";
|
||||
buffer += "\x1b[2K";
|
||||
const line = newLines[i];
|
||||
buffer += TERMINAL.isImageLine(line) ? line : line + reset;
|
||||
}
|
||||
// Clear any remaining display rows below viewport content.
|
||||
// Use erase-to-end to avoid moving the cursor past content area.
|
||||
if (vpLines < height) {
|
||||
if (vpLines > 0) {
|
||||
buffer += "\r\n\x1b[J";
|
||||
buffer += "\x1b[1A";
|
||||
} else {
|
||||
buffer += "\x1b[J";
|
||||
}
|
||||
}
|
||||
buffer += "\x1b[?2026l"; // End synchronized output
|
||||
this.terminal.write(buffer);
|
||||
finishFullRepaint();
|
||||
};
|
||||
|
||||
// First render — no prior TUI frame, seed the full transcript
|
||||
if (!hasPriorFrame && !widthChanged && !heightChanged) {
|
||||
// First render - just output everything without clearing (assumes clean screen)
|
||||
if (this.#previousLines.length === 0 && !widthChanged && !heightChanged) {
|
||||
logRedraw("first render");
|
||||
seedTranscript();
|
||||
fullRender(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forced full repaint (e.g. requestRender(true)) — use viewport repaint if we
|
||||
// have a prior frame, otherwise seed from scratch
|
||||
if (forceRepaint) {
|
||||
logRedraw("forced repaint");
|
||||
if (hasPriorFrame) repaintViewport();
|
||||
else seedTranscript();
|
||||
return;
|
||||
}
|
||||
|
||||
// Width changed — viewport repaint (line wrapping invalidates all content)
|
||||
// Width changes always need a full re-render because wrapping changes.
|
||||
if (widthChanged) {
|
||||
logRedraw(`width changed (${this.#previousWidth} -> ${width})`);
|
||||
repaintViewport();
|
||||
logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Height decreased — viewport repaint to realign content.
|
||||
// (Height increases use dedicated repaint paths elsewhere in this method.)
|
||||
// Termux changes height when the software keyboard shows or hides;
|
||||
// in that environment, a full redraw causes the entire history to replay on every toggle.
|
||||
if (heightChanged && height < this.#previousHeight && !isTermuxSession()) {
|
||||
logRedraw(`terminal height decreased (${this.#previousHeight} -> ${height})`);
|
||||
repaintViewport();
|
||||
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
||||
// but Termux changes height when the software keyboard shows or hides.
|
||||
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
||||
if (heightChanged && !isTermuxSession()) {
|
||||
logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Content shrunk below the working area and no overlays — viewport repaint to clear empty rows
|
||||
// Content shrunk below the working area and no overlays - re-render to clear empty rows
|
||||
// (overlays need the padding, so only do this when no overlays are active)
|
||||
// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
|
||||
if (this.#clearOnShrink && newLines.length < this.#maxLinesRendered && this.overlayStack.length === 0) {
|
||||
logRedraw(`clearOnShrink (maxLinesRendered=${this.#maxLinesRendered})`);
|
||||
repaintViewport();
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1214,32 +1095,10 @@ export class TUI extends Container {
|
||||
}
|
||||
const appendStart = appendedLines && firstChanged === this.#previousLines.length && firstChanged > 0;
|
||||
|
||||
// When the terminal grows and the UI still does not fill the new viewport,
|
||||
// newly revealed rows can contain shell history. If content also changed in the
|
||||
// same tick, diff rendering would only touch the changed range and leave those
|
||||
// revealed rows visible. Repaint the viewport from scratch before diffing.
|
||||
if (
|
||||
heightChanged &&
|
||||
height > this.#previousHeight &&
|
||||
this.#previousHeight > 0 &&
|
||||
newLines.length < height &&
|
||||
firstChanged !== -1 &&
|
||||
!isTermuxSession()
|
||||
) {
|
||||
repaintAfterHeightIncrease();
|
||||
return;
|
||||
}
|
||||
|
||||
// No line-level changes detected
|
||||
// No changes - but still need to update hardware cursor position if it moved
|
||||
if (firstChanged === -1) {
|
||||
if (height > this.#previousHeight && this.#previousHeight > 0 && !isTermuxSession()) {
|
||||
repaintAfterHeightIncrease();
|
||||
return;
|
||||
}
|
||||
this.#previousHeight = height;
|
||||
this.#positionHardwareCursor(cursorPos, newLines.length);
|
||||
this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
|
||||
this.#terminalStateTrusted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1257,7 +1116,7 @@ export class TUI extends Container {
|
||||
const extraLines = this.#previousLines.length - newLines.length;
|
||||
if (extraLines > height) {
|
||||
logRedraw(`extraLines > height (${extraLines} > ${height})`);
|
||||
repaintViewport();
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
const clearStartOffset = newLines.length > 0 && extraLines > 0 ? 1 : 0;
|
||||
@@ -1282,7 +1141,6 @@ export class TUI extends Container {
|
||||
this.#previousWidth = width;
|
||||
this.#previousHeight = height;
|
||||
this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
|
||||
this.#terminalStateTrusted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1292,7 +1150,7 @@ export class TUI extends Container {
|
||||
if (firstChanged < previousContentViewportTop) {
|
||||
// First change is above previous viewport - need full re-render
|
||||
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
|
||||
repaintViewport();
|
||||
fullRender(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1431,7 +1289,6 @@ export class TUI extends Container {
|
||||
this.#previousLines = newLines;
|
||||
this.#previousWidth = width;
|
||||
this.#previousHeight = height;
|
||||
this.#terminalStateTrusted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("TUI overlays", () => {
|
||||
expect(term.getScrollBuffer().length).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("preserves preexisting terminal scrollback across startup full redraw", async () => {
|
||||
it("clears preexisting terminal scrollback on startup full redraw", async () => {
|
||||
const term = new VirtualTerminal(40, 4);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await term.flush();
|
||||
@@ -116,15 +116,13 @@ describe("TUI overlays", () => {
|
||||
tui.start();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
term.resize(39, 4);
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
const viewport = term.getViewport().join("\n");
|
||||
expect(viewport.includes("shell-")).toBeFalsy();
|
||||
const scrollback = term.getScrollBuffer().join("\n");
|
||||
expect(scrollback.includes("shell-0")).toBeTruthy();
|
||||
expect(scrollback.includes("shell-4")).toBeTruthy();
|
||||
expect(scrollback.includes("shell-0")).toBeFalsy();
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
@@ -151,38 +149,7 @@ describe("TUI overlays", () => {
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
it("preserves shell output written while stopped across restart forced redraw", async () => {
|
||||
const term = new VirtualTerminal(40, 4);
|
||||
const tui = new TUI(term);
|
||||
tui.addChild(new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3", "ui-4", "ui-5"]));
|
||||
|
||||
tui.start();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
term.write("shell-a\r\nshell-b\r\n");
|
||||
await term.flush();
|
||||
expect(term.getViewport().join("\n").includes("shell-")).toBeTruthy();
|
||||
|
||||
tui.start();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
const viewport = term.getViewport().join("\n");
|
||||
expect(viewport.includes("ui-2")).toBeTruthy();
|
||||
expect(viewport.includes("ui-5")).toBeTruthy();
|
||||
expect(viewport.includes("shell-")).toBeFalsy();
|
||||
|
||||
const scrollback = term.getScrollBuffer().join("\n");
|
||||
expect(scrollback.includes("shell-a")).toBeTruthy();
|
||||
expect(scrollback.includes("shell-b")).toBeTruthy();
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("fully redraws on height increase without wiping shell scrollback", async () => {
|
||||
it("fully redraws on height increase to avoid stale viewport rows", async () => {
|
||||
const term = new VirtualTerminal(40, 4);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await term.flush();
|
||||
@@ -201,40 +168,9 @@ describe("TUI overlays", () => {
|
||||
|
||||
const viewport = term.getViewport().join("\n");
|
||||
expect(viewport.includes("shell-")).toBeFalsy();
|
||||
const scrollback = term.getScrollBuffer().join("\n");
|
||||
expect(scrollback.includes("shell-0")).toBeTruthy();
|
||||
expect(scrollback.includes("shell-4")).toBeTruthy();
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
it("fully redraws on height increase when content changes in the same tick", async () => {
|
||||
const term = new VirtualTerminal(40, 4);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\nshell-5\r\n");
|
||||
await term.flush();
|
||||
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableContentComponent(["ui-0", "ui-1", "ui-2", "ui-3"]);
|
||||
tui.addChild(component);
|
||||
|
||||
tui.start();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
component.setLines(["ui-0", "ui-1", "ui-2", "ui-3*"]);
|
||||
term.resize(40, 8);
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
const viewport = term.getViewport().join("\n");
|
||||
expect(viewport.includes("shell-")).toBeFalsy();
|
||||
expect(viewport.includes("ui-3*")).toBeTruthy();
|
||||
const scrollback = term.getScrollBuffer().join("\n");
|
||||
expect(scrollback.includes("shell-0")).toBeTruthy();
|
||||
expect(scrollback.includes("shell-5")).toBeTruthy();
|
||||
|
||||
tui.stop();
|
||||
});
|
||||
|
||||
it("renders viewport-only on resize when content size is stable", async () => {
|
||||
const term = new VirtualTerminal(60, 8);
|
||||
const tui = new TUI(term);
|
||||
@@ -279,7 +215,7 @@ describe("TUI overlays", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps shell scrollback on viewport-only resize redraw", async () => {
|
||||
it("keeps scrollback on viewport-only resize redraw", async () => {
|
||||
const term = new VirtualTerminal(40, 4);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\n");
|
||||
await term.flush();
|
||||
@@ -292,11 +228,8 @@ describe("TUI overlays", () => {
|
||||
term.resize(39, 4);
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
const viewport = term.getViewport().join("\n");
|
||||
expect(viewport.includes("shell-")).toBeFalsy();
|
||||
const scrollback = term.getScrollBuffer().join("\n");
|
||||
expect(scrollback.includes("shell-0")).toBeTruthy();
|
||||
expect(scrollback.includes("shell-3")).toBeTruthy();
|
||||
expect(scrollback.includes("shell-0")).toBeFalsy();
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -528,106 +461,4 @@ describe("TUI overlays", () => {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("exit after startup scrollback seeding does not leave long blank run", async () => {
|
||||
const term = new VirtualTerminal(40, 6);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await term.flush();
|
||||
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableContentComponent(buildRows(20));
|
||||
tui.addChild(component);
|
||||
|
||||
tui.start();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
|
||||
const scrollback = term.getScrollBuffer();
|
||||
// Shell history should survive
|
||||
expect(scrollback.join("\n").includes("shell-0")).toBeTruthy();
|
||||
// No large blank gap from exit — viewport should still have content
|
||||
const viewport = term.getViewport().map(l => l.trimEnd());
|
||||
const contentLines = viewport.filter(l => l.trim().length > 0);
|
||||
expect(contentLines.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("shrink after preexisting shell history does not flood viewport with blanks", async () => {
|
||||
const term = new VirtualTerminal(40, 8);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\n");
|
||||
await term.flush();
|
||||
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableContentComponent(buildRows(40));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
// Shrink to tiny content (like /new)
|
||||
component.setLines(["New session"]);
|
||||
tui.requestRender(true);
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
const viewport = term.getViewport().map(l => l.trimEnd());
|
||||
// Content should be at the top, not at the bottom
|
||||
expect(viewport[0]?.trim()).toBe("New session");
|
||||
for (let i = 1; i < 8; i++) {
|
||||
expect(viewport[i]?.trim()).toBe("");
|
||||
}
|
||||
|
||||
// Scrollback should not have a big blank gap
|
||||
const scrollback = term.getScrollBuffer();
|
||||
expect(longestBlankRun(scrollback)).toBeLessThan(10);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("overlay dismissal after historical scrollback does not create gap", async () => {
|
||||
const term = new VirtualTerminal(40, 8);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\n");
|
||||
await term.flush();
|
||||
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableContentComponent(buildRows(20));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
// Show overlay
|
||||
const handle = tui.showOverlay(new LineComponent("over-", 4), { anchor: "center" });
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
// Dismiss
|
||||
handle.hide();
|
||||
await Bun.sleep(0);
|
||||
await term.flush();
|
||||
|
||||
// Viewport should show the tail of base content
|
||||
const viewport = term.getViewport().map(l => l.trimEnd());
|
||||
expect(viewport.at(-1)?.trim()).toBe("row-19");
|
||||
// No large blank run
|
||||
expect(longestBlankRun(viewport)).toBeLessThan(2);
|
||||
|
||||
tui.stop();
|
||||
await term.flush();
|
||||
|
||||
// Stop after overlay dismissal should not create gap
|
||||
const afterStopViewport = term.getViewport().map(l => l.trimEnd());
|
||||
const contentLines = afterStopViewport.filter(l => l.trim().length > 0);
|
||||
expect(contentLines.length).toBeGreaterThanOrEqual(5);
|
||||
} finally {
|
||||
if (!tui.fullRedraws) tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,35 +41,6 @@ function countMatches(lines: string[], pattern: RegExp): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
function longestBlankRun(lines: string[]): number {
|
||||
let longest = 0;
|
||||
let current = 0;
|
||||
for (const line of lines) {
|
||||
if (line.trim().length === 0) {
|
||||
current += 1;
|
||||
longest = Math.max(longest, current);
|
||||
} else {
|
||||
current = 0;
|
||||
}
|
||||
}
|
||||
return longest;
|
||||
}
|
||||
|
||||
/** Count blank lines at the end of the buffer (after the last content line). */
|
||||
function trailingBlanks(lines: string[]): number {
|
||||
let count = 0;
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
if (lines[i].trim().length === 0) count++;
|
||||
else break;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function activeBuffer(term: VirtualTerminal): { baseY: number; cursorY: number } {
|
||||
return (term as unknown as { xterm: { buffer: { active: { baseY: number; cursorY: number } } } }).xterm.buffer
|
||||
.active;
|
||||
}
|
||||
|
||||
describe("TUI terminal-state regressions", () => {
|
||||
describe("cursor + differential stability", () => {
|
||||
it("keeps stable output across repeated no-op renders", async () => {
|
||||
@@ -170,7 +141,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
});
|
||||
|
||||
describe("resize + viewport behavior", () => {
|
||||
it("preserves preexisting shell rows across startup and resize redraws", async () => {
|
||||
it("clears preexisting shell rows on startup and resize redraw", async () => {
|
||||
const term = new VirtualTerminal(50, 5);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await settle(term);
|
||||
@@ -187,64 +158,7 @@ describe("TUI terminal-state regressions", () => {
|
||||
await settle(term);
|
||||
|
||||
const buffer = term.getScrollBuffer().join("\n");
|
||||
expect(buffer.includes("shell-0")).toBeTruthy();
|
||||
expect(buffer.includes("shell-4")).toBeTruthy();
|
||||
expect(visible(term).join("\n").includes("shell-")).toBeFalsy();
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
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("height increase after content shrink scrolls only the visible rows", async () => {
|
||||
const term = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("ui-", 30));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
component.setLines(rows("ui-", 3));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
const beforeBaseY = activeBuffer(term).baseY;
|
||||
term.resize(40, 12);
|
||||
await settle(term);
|
||||
|
||||
expect(activeBuffer(term).baseY).toBe(beforeBaseY);
|
||||
expect(visible(term).slice(0, 3)).toEqual(["ui-0", "ui-1", "ui-2"]);
|
||||
expect(buffer.includes("shell-")).toBeFalsy();
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -693,10 +607,8 @@ describe("TUI terminal-state regressions", () => {
|
||||
});
|
||||
|
||||
describe("scrollback integrity", () => {
|
||||
it("overflowing startup preserves shell scrollback while keeping each row unique across the full buffer", async () => {
|
||||
it("overflow content appears once across buffer without duplicate row IDs", async () => {
|
||||
const term = new VirtualTerminal(32, 5);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await settle(term);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 10));
|
||||
tui.addChild(component);
|
||||
@@ -706,10 +618,6 @@ describe("TUI terminal-state regressions", () => {
|
||||
await settle(term);
|
||||
|
||||
const all = term.getScrollBuffer();
|
||||
const allText = all.join("\n");
|
||||
expect(allText.includes("shell-0")).toBeTruthy();
|
||||
expect(allText.includes("shell-4")).toBeTruthy();
|
||||
expect(visible(term).join("\n").includes("shell-")).toBeFalsy();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const pattern = new RegExp(`\\bline-${i}\\b`);
|
||||
expect(countMatches(all, pattern), `line-${i} should appear exactly once`).toBe(1);
|
||||
@@ -754,10 +662,8 @@ describe("TUI terminal-state regressions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("offscreen header changes preserve shell history during overflow growth", async () => {
|
||||
it("retains append history when offscreen header changes during overflow growth", async () => {
|
||||
const term = new VirtualTerminal(32, 6);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await settle(term);
|
||||
const tui = new TUI(term);
|
||||
const logLines = rows("line-", 6);
|
||||
let tick = 0;
|
||||
@@ -777,9 +683,6 @@ describe("TUI terminal-state regressions", () => {
|
||||
}
|
||||
|
||||
const scrollback = term.getScrollBuffer();
|
||||
const scrollbackText = scrollback.join("\n");
|
||||
expect(scrollbackText.includes("shell-0")).toBeTruthy();
|
||||
expect(scrollbackText.includes("shell-4")).toBeTruthy();
|
||||
for (let i = 0; i < 70; i++) {
|
||||
expect(countMatches(scrollback, new RegExp(`\\bline-${i}\\b`))).toBe(1);
|
||||
}
|
||||
@@ -788,7 +691,6 @@ describe("TUI terminal-state regressions", () => {
|
||||
}
|
||||
|
||||
const viewport = visible(term).map(line => line.trim());
|
||||
expect(viewport.join("\n").includes("shell-")).toBeFalsy();
|
||||
expect(viewport.at(-1)).toBe("line-69");
|
||||
for (let i = 1; i < viewport.length; i++) {
|
||||
const prev = Number.parseInt(viewport[i - 1]!.slice(5), 10);
|
||||
@@ -799,41 +701,6 @@ describe("TUI terminal-state regressions", () => {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
it("large delete fallback preserves shell scrollback without stale rows", async () => {
|
||||
const term = new VirtualTerminal(32, 5);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await settle(term);
|
||||
const tui = new TUI(term);
|
||||
tui.setClearOnShrink(false);
|
||||
const component = new MutableLinesComponent(rows("row-", 18));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
component.setLines(rows("row-", 4));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
const buffer = term.getScrollBuffer();
|
||||
const bufferText = buffer.join("\n");
|
||||
expect(bufferText.includes("shell-0")).toBeTruthy();
|
||||
expect(bufferText.includes("shell-4")).toBeTruthy();
|
||||
expect(visible(term).join("\n").includes("shell-")).toBeFalsy();
|
||||
expect(visible(term).filter(line => line.trim().length > 0)).toEqual(["row-0", "row-1", "row-2", "row-3"]);
|
||||
const viewportRows = visible(term).filter(line => line.trim().length > 0);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect(
|
||||
viewportRows.filter(r => r === `row-${i}`).length,
|
||||
`viewport row-${i} should appear exactly once`,
|
||||
).toBe(1);
|
||||
}
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates visible tail line when appending during overflow", async () => {
|
||||
const term = new VirtualTerminal(32, 5);
|
||||
const tui = new TUI(term);
|
||||
@@ -860,12 +727,10 @@ describe("TUI terminal-state regressions", () => {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
it("forced full redraws preserve shell history without duplicating overflowing content", async () => {
|
||||
it("forced full redraws do not duplicate persistent content", async () => {
|
||||
const term = new VirtualTerminal(40, 5);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\nshell-4\r\n");
|
||||
await settle(term);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 14));
|
||||
const component = new MutableLinesComponent(["alpha", "beta", "gamma"]);
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
@@ -877,15 +742,10 @@ describe("TUI terminal-state regressions", () => {
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
const all = term.getScrollBuffer();
|
||||
const allText = all.join("\n");
|
||||
expect(allText.includes("shell-0")).toBeTruthy();
|
||||
expect(allText.includes("shell-4")).toBeTruthy();
|
||||
expect(visible(term).join("\n").includes("shell-")).toBeFalsy();
|
||||
for (let i = 0; i < 14; i++) {
|
||||
expect(countMatches(all, new RegExp(`\\bline-${i}\\b`)), `line-${i} should appear exactly once`).toBe(1);
|
||||
}
|
||||
expect(visible(term).at(-1)?.trim()).toBe("line-13");
|
||||
const allText = term.getScrollBuffer().join("\n");
|
||||
expect((allText.match(/alpha/g) ?? []).length).toBe(1);
|
||||
expect((allText.match(/beta/g) ?? []).length).toBe(1);
|
||||
expect((allText.match(/gamma/g) ?? []).length).toBe(1);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
@@ -954,282 +814,4 @@ describe("TUI terminal-state regressions", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("exit gap regression", () => {
|
||||
it("stop after tall content does not leave a large blank gap below content", async () => {
|
||||
const term = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 60));
|
||||
tui.addChild(component);
|
||||
|
||||
tui.start();
|
||||
await settle(term);
|
||||
expect(visible(term).at(-1)?.trim()).toBe("line-59");
|
||||
|
||||
tui.stop();
|
||||
await settle(term);
|
||||
|
||||
// After exit, the viewport should still show content with at most
|
||||
// 1-2 trailing blank rows (for the shell prompt boundary).
|
||||
const viewport = visible(term);
|
||||
const contentLines = viewport.filter(l => l.trim().length > 0);
|
||||
expect(contentLines.length).toBeGreaterThanOrEqual(8);
|
||||
expect(trailingBlanks(viewport)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("stop after overflowing content with shell history does not add blank rows", async () => {
|
||||
const term = new VirtualTerminal(40, 8);
|
||||
term.write("shell-0\r\nshell-1\r\nshell-2\r\nshell-3\r\n");
|
||||
await settle(term);
|
||||
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 30));
|
||||
tui.addChild(component);
|
||||
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
tui.stop();
|
||||
await settle(term);
|
||||
|
||||
const scrollback = term.getScrollBuffer();
|
||||
// Shell history should survive
|
||||
expect(scrollback.join("\n").includes("shell-0")).toBeTruthy();
|
||||
// After stop, the viewport should have content, not a big blank gap
|
||||
const viewport = visible(term);
|
||||
expect(trailingBlanks(viewport)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("stop after shrink does not push content off screen", async () => {
|
||||
const term = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 40));
|
||||
tui.addChild(component);
|
||||
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
// Shrink content dramatically
|
||||
component.setLines(["New session started"]);
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
tui.stop();
|
||||
await settle(term);
|
||||
|
||||
// After exit, the viewport should still show the shrunken content
|
||||
const viewport = visible(term);
|
||||
expect(viewport[0]?.trim()).toBe("New session started");
|
||||
// Content should not be scrolled off by exit
|
||||
expect(viewport.filter(l => l.trim().length > 0).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("content shrink regression", () => {
|
||||
it("shrink from tall to tiny anchors prompt near content, not at terminal bottom", async () => {
|
||||
const term = new VirtualTerminal(40, 20);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 80));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
// Simulate /new: content collapses to just a few lines
|
||||
component.setLines(["New session started", "prompt>"]);
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
const viewport = visible(term);
|
||||
// Content should be at the top of the viewport, not at the bottom
|
||||
expect(viewport[0]?.trim()).toBe("New session started");
|
||||
expect(viewport[1]?.trim()).toBe("prompt>");
|
||||
// The rest should be blank - no long blank run ABOVE the content
|
||||
for (let i = 2; i < 20; i++) {
|
||||
expect(viewport[i]?.trim()).toBe("");
|
||||
}
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("repeated shrink cycles do not accumulate blank lines", async () => {
|
||||
const term = new VirtualTerminal(40, 12);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("line-", 50));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
for (let cycle = 0; cycle < 5; cycle++) {
|
||||
// Shrink to tiny
|
||||
component.setLines([`session-${cycle}`]);
|
||||
tui.requestRender(true);
|
||||
await settle(term);
|
||||
|
||||
// Grow back to overflowing
|
||||
component.setLines(rows("line-", 50));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
// After cycles, viewport should show the tail of content
|
||||
const viewport = visible(term);
|
||||
expect(viewport.at(-1)?.trim()).toBe("line-49");
|
||||
|
||||
const scrollback = term.getScrollBuffer();
|
||||
// No giant blank run from accumulated drift
|
||||
expect(longestBlankRun(scrollback)).toBeLessThan(15);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("overlay dismiss cursor recovery", () => {
|
||||
it("overlay dismiss restores viewport without gap below content", async () => {
|
||||
const term = new VirtualTerminal(40, 12);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("base-", 8));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
// Show overlay taller than remaining viewport space
|
||||
const overlay = new MutableLinesComponent(rows("over-", 6));
|
||||
const handle = tui.showOverlay(overlay, { anchor: "center" });
|
||||
await settle(term);
|
||||
|
||||
// Dismiss overlay
|
||||
handle.hide();
|
||||
await settle(term);
|
||||
|
||||
// After dismiss, viewport should show base content without gaps
|
||||
const viewport = visible(term);
|
||||
expect(viewport[0]?.trim()).toBe("base-0");
|
||||
expect(viewport[7]?.trim()).toBe("base-7");
|
||||
// No content rows should be pushed below the viewport
|
||||
for (let i = 8; i < 12; i++) {
|
||||
expect(viewport[i]?.trim()).toBe("");
|
||||
}
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("repeated overlay show/hide does not drift the cursor", async () => {
|
||||
const term = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("base-", 10));
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
const baseViewport = visible(term);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const handle = tui.showOverlay(new MutableLinesComponent([`overlay-${i}`]), {
|
||||
anchor: "center",
|
||||
});
|
||||
await settle(term);
|
||||
handle.hide();
|
||||
await settle(term);
|
||||
}
|
||||
|
||||
// After 10 show/hide cycles, viewport must match the original base
|
||||
expect(visible(term)).toEqual(baseViewport);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
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("restart after content shrink preserves history without seeding blank scrollback", 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-", 3));
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
tui.stop();
|
||||
await settle(term);
|
||||
|
||||
const beforeBaseY = activeBuffer(term).baseY;
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
expect(activeBuffer(term).baseY).toBe(beforeBaseY);
|
||||
expect(visible(term).slice(0, 3)).toEqual(["base-0", "base-1", "base-2"]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("stop after overlay dismissal does not create scrollback gap", async () => {
|
||||
const term = new VirtualTerminal(40, 10);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent(rows("base-", 30));
|
||||
tui.addChild(component);
|
||||
|
||||
tui.start();
|
||||
await settle(term);
|
||||
|
||||
const handle = tui.showOverlay(new MutableLinesComponent(rows("over-", 5)), {
|
||||
anchor: "center",
|
||||
});
|
||||
await settle(term);
|
||||
|
||||
handle.hide();
|
||||
await settle(term);
|
||||
|
||||
tui.stop();
|
||||
await settle(term);
|
||||
|
||||
// After stop, viewport should still have content
|
||||
const viewport = visible(term);
|
||||
expect(trailingBlanks(viewport)).toBeLessThanOrEqual(2);
|
||||
expect(viewport.filter(l => l.trim().length > 0).length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user