mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 08:44:38 +08:00
@@ -22,7 +22,12 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise<void
|
||||
initTheme(options.settingsManager.getTheme(), true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ui: TUI = new TuiMainScreen(new ProcessTerminal(), undefined, options.agentDir);
|
||||
const ui: TUI = new TuiMainScreen(
|
||||
new ProcessTerminal(),
|
||||
options.settingsManager.getShowHardwareCursor(),
|
||||
options.agentDir,
|
||||
);
|
||||
ui.setClearOnShrink(options.settingsManager.getClearOnShrink());
|
||||
let resolved = false;
|
||||
|
||||
const selector = new ConfigSelectorComponent(
|
||||
|
||||
@@ -288,7 +288,7 @@ When a `Focusable` component has focus, TUI:
|
||||
3. Positions the hardware terminal cursor at that location
|
||||
4. Shows the hardware cursor only when `showHardwareCursor` is enabled
|
||||
|
||||
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface.
|
||||
The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument or `setShowHardwareCursor(true)`. The `Editor` and `Input` built-in components already implement this interface.
|
||||
|
||||
**Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { deleteKittyImage, isImageLine } from "./terminal-image.ts";
|
||||
import { type TUI, TuiBase, type TuiStopOptions } from "./tui.ts";
|
||||
@@ -317,10 +318,10 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
||||
this.previousHeight = height;
|
||||
};
|
||||
|
||||
const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
|
||||
const redrawLogDirectory = process.env.PI_TUI_DEBUG_REDRAW === "1" ? this.logDirectory : undefined;
|
||||
const logRedraw = (reason: string): void => {
|
||||
if (!debugRedraw) return;
|
||||
const logPath = path.join(this.logDirectory, "pi-debug.log");
|
||||
if (redrawLogDirectory === undefined) return;
|
||||
const logPath = path.join(redrawLogDirectory, "pi-tui-debug.log");
|
||||
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.appendFileSync(logPath, msg);
|
||||
@@ -351,7 +352,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
||||
|
||||
// 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
|
||||
// Configurable via setClearOnShrink()
|
||||
if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) {
|
||||
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
|
||||
fullRender(true);
|
||||
@@ -515,7 +516,7 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
||||
output.append("\x1b[2K"); // Clear current line
|
||||
if (!isImage && visibleWidth(line) > width) {
|
||||
// Log all lines to crash file for debugging
|
||||
const crashLogPath = path.join(this.logDirectory, "pi-crash.log");
|
||||
const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-crash.log");
|
||||
const crashData = [
|
||||
`Crash at ${new Date().toISOString()}`,
|
||||
`Terminal width: ${width}`,
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
* Minimal TUI implementation with differential rendering
|
||||
*/
|
||||
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { isKeyRelease, matchesKey } from "./keys.ts";
|
||||
import type { Terminal } from "./terminal.ts";
|
||||
@@ -466,15 +464,16 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
private renderTimer: NodeJS.Timeout | undefined;
|
||||
private lastRenderAt = 0;
|
||||
private static readonly MIN_RENDER_INTERVAL_MS = 16;
|
||||
private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
||||
private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1";
|
||||
private showHardwareCursor = false;
|
||||
private clearOnShrink = false;
|
||||
protected fullRedrawCount = 0;
|
||||
protected stopped = false;
|
||||
private pendingOsc11BackgroundReplies = 0;
|
||||
private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];
|
||||
private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();
|
||||
private terminalColorSchemeNotificationsEnabled = false;
|
||||
protected readonly logDirectory: string;
|
||||
/** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */
|
||||
protected readonly logDirectory: string | undefined;
|
||||
|
||||
// Overlay stack for modal components rendered on top of base content
|
||||
private focusOrderCounter = 0;
|
||||
@@ -489,7 +488,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) {
|
||||
super();
|
||||
this.terminal = terminal;
|
||||
this.logDirectory = logDirectory ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
|
||||
this.logDirectory = logDirectory;
|
||||
if (showHardwareCursor !== undefined) {
|
||||
this.showHardwareCursor = showHardwareCursor;
|
||||
}
|
||||
@@ -530,8 +529,8 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
|
||||
/**
|
||||
* Set whether to trigger full re-render when content shrinks.
|
||||
* When true (default), empty rows are cleared when content shrinks.
|
||||
* When false, empty rows remain (reduces redraws on slower terminals).
|
||||
* When true, empty rows are cleared when content shrinks.
|
||||
* When false (default), empty rows remain (reduces redraws on slower terminals).
|
||||
*/
|
||||
setClearOnShrink(enabled: boolean): void {
|
||||
this.clearOnShrink = enabled;
|
||||
|
||||
@@ -143,7 +143,7 @@ describe("TUI debug logging", () => {
|
||||
it("writes redraw logs to the provided directory", async () => {
|
||||
const logDir = mkdtempSync(join(tmpdir(), "pi-tui-log-"));
|
||||
try {
|
||||
await withEnv({ PI_DEBUG_REDRAW: "1" }, async () => {
|
||||
await withEnv({ PI_TUI_DEBUG_REDRAW: "1" }, async () => {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal, undefined, logDir);
|
||||
const component = new TestComponent();
|
||||
@@ -152,7 +152,7 @@ describe("TUI debug logging", () => {
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
assert.match(readFileSync(join(logDir, "pi-debug.log"), "utf-8"), /fullRender: first render/);
|
||||
assert.match(readFileSync(join(logDir, "pi-tui-debug.log"), "utf-8"), /fullRender: first render/);
|
||||
tui.stop();
|
||||
});
|
||||
} finally {
|
||||
@@ -206,6 +206,56 @@ describe("TUI bounded render output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Set each environment variable to `value`, returning a function that restores the previous state. */
|
||||
function overrideEnv(names: readonly string[], value: string): () => void {
|
||||
const previousValues = names.map((name) => [name, process.env[name]] as const);
|
||||
for (const name of names) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
return () => {
|
||||
for (const [name, previousValue] of previousValues) {
|
||||
if (previousValue === undefined) delete process.env[name];
|
||||
else process.env[name] = previousValue;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("TUI crash dump without configured log directory", () => {
|
||||
it("writes the crash dump to the OS temp directory instead of a home-directory default", async () => {
|
||||
// The TUI falls back to os.tmpdir() when no log directory is configured, so
|
||||
// isolate the test by pointing the temp directory at a fresh directory rather
|
||||
// than sharing the real one with concurrent test runs. os.tmpdir() reads
|
||||
// TMPDIR on POSIX and TEMP/TMP on Windows, so override all three.
|
||||
const crashDir = mkdtempSync(join(tmpdir(), "pi-tui-crash-"));
|
||||
const crashLogPath = join(crashDir, "pi-tui-crash.log");
|
||||
const restoreTmpdirEnv = overrideEnv(["TMPDIR", "TEMP", "TMP"], crashDir);
|
||||
try {
|
||||
const terminal = new VirtualTerminal(40, 10);
|
||||
const tui: TUI = new TuiMainScreen(terminal);
|
||||
const component = new TestComponent();
|
||||
tui.addChild(component);
|
||||
component.lines = ["ok"];
|
||||
tui.start();
|
||||
await terminal.waitForRender();
|
||||
|
||||
// Width overflow is detected in the differential render path
|
||||
component.lines = ["ok", "x".repeat(60)];
|
||||
assert.throws(
|
||||
() => tui.renderNow(),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.ok(error.message.includes(crashLogPath), `error message should reference ${crashLogPath}`);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.match(readFileSync(crashLogPath, "utf-8"), /Terminal width: 40/);
|
||||
} finally {
|
||||
restoreTmpdirEnv();
|
||||
rmSync(crashDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("TUI Kitty image cleanup", () => {
|
||||
it("clears reserved Kitty image rows before drawing appended image placements", async () => {
|
||||
setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true });
|
||||
|
||||
Reference in New Issue
Block a user