The PTY runner hardcoded CommandBuilder::new("sh"), but on Windows the
user's shell might be a Git Bash absolute path that isn't on PATH. The
non-PTY path already uses the resolved shell from getShellConfig(). Now
the PTY path does the same, passing it through PtyStartOptions.shell.
388 lines
12 KiB
TypeScript
388 lines
12 KiB
TypeScript
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
|
|
import { type PtyRunResult, PtySession, sanitizeText } from "@oh-my-pi/pi-natives";
|
|
import {
|
|
type Component,
|
|
extractPrintableText,
|
|
matchesKey,
|
|
padding,
|
|
parseKey,
|
|
parseKittySequence,
|
|
truncateToWidth,
|
|
visibleWidth,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import type { Terminal as XtermTerminalType } from "@xterm/headless";
|
|
import xterm from "@xterm/headless";
|
|
import { Settings } from "../config/settings";
|
|
import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
|
|
import type { Theme } from "../modes/theme/theme";
|
|
import { OutputSink, type OutputSummary } from "../session/streaming-output";
|
|
import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
|
|
import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "./output-meta";
|
|
import { formatStatusIcon, replaceTabs } from "./render-utils";
|
|
|
|
export interface BashInteractiveResult extends OutputSummary {
|
|
exitCode: number | undefined;
|
|
cancelled: boolean;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
function normalizeCaptureChunk(chunk: string): string {
|
|
const normalized = chunk.replace(/\r\n?/gu, "\n");
|
|
return sanitizeWithOptionalSixelPassthrough(normalized, sanitizeText);
|
|
}
|
|
|
|
const XtermTerminal = xterm.Terminal;
|
|
|
|
function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
|
|
const kitty = parseKittySequence(data);
|
|
if (kitty?.eventType === 3) {
|
|
return "";
|
|
}
|
|
const printableText = extractPrintableText(data);
|
|
if (printableText) {
|
|
return printableText;
|
|
}
|
|
if (!kitty) {
|
|
return data;
|
|
}
|
|
const keyId = parseKey(data);
|
|
if (!keyId) {
|
|
return data;
|
|
}
|
|
const normalizedKey = keyId.toLowerCase();
|
|
if (normalizedKey === "up") return applicationCursorKeysMode ? "\x1bOA" : "\x1b[A";
|
|
if (normalizedKey === "down") return applicationCursorKeysMode ? "\x1bOB" : "\x1b[B";
|
|
if (normalizedKey === "right") return applicationCursorKeysMode ? "\x1bOC" : "\x1b[C";
|
|
if (normalizedKey === "left") return applicationCursorKeysMode ? "\x1bOD" : "\x1b[D";
|
|
if (normalizedKey === "home") return applicationCursorKeysMode ? "\x1bOH" : "\x1b[H";
|
|
if (normalizedKey === "end") return applicationCursorKeysMode ? "\x1bOF" : "\x1b[F";
|
|
if (normalizedKey === "pageup") return "\x1b[5~";
|
|
if (normalizedKey === "pagedown") return "\x1b[6~";
|
|
if (normalizedKey === "insert") return "\x1b[2~";
|
|
if (normalizedKey === "delete") return "\x1b[3~";
|
|
if (normalizedKey === "shift+tab") return "\x1b[Z";
|
|
if (normalizedKey === "enter") return "\r";
|
|
if (normalizedKey === "tab") return "\t";
|
|
if (normalizedKey === "space") return " ";
|
|
if (normalizedKey === "backspace") return "\x7f";
|
|
if (normalizedKey === "escape") return "\x1b";
|
|
const ctrlMatch = /^ctrl\+([a-z])$/u.exec(normalizedKey);
|
|
if (ctrlMatch) {
|
|
const letter = ctrlMatch[1]!;
|
|
return String.fromCharCode(letter.charCodeAt(0) - 96);
|
|
}
|
|
const altMatch = /^alt\+([a-z])$/u.exec(normalizedKey);
|
|
if (altMatch) {
|
|
return `\x1b${altMatch[1]!}`;
|
|
}
|
|
// For any other Kitty sequence with a printable codepoint, emit the character directly
|
|
if (kitty.codepoint >= 32 && kitty.codepoint < 127) {
|
|
let ch = String.fromCharCode(kitty.codepoint);
|
|
// Apply ctrl modifier if present (modifier bit 4 = ctrl)
|
|
if (kitty.modifier & 4) {
|
|
const code = kitty.codepoint;
|
|
if (code >= 97 && code <= 122) {
|
|
ch = String.fromCharCode(code - 96);
|
|
}
|
|
}
|
|
// Apply alt modifier if present (modifier bit 2 = alt)
|
|
if (kitty.modifier & 2) {
|
|
ch = `\x1b${ch}`;
|
|
}
|
|
return ch;
|
|
}
|
|
return data;
|
|
}
|
|
class BashInteractiveOverlayComponent implements Component {
|
|
#terminal: XtermTerminalType;
|
|
#state: "running" | "complete" | "timed_out" | "killed" = "running";
|
|
#exitCode: number | undefined;
|
|
#onInput: (data: string) => void = () => {};
|
|
#onDismiss: () => void = () => {};
|
|
#onDispose: () => void = () => {};
|
|
#session: PtySession | null = null;
|
|
#lastCols = 0;
|
|
#lastRows = 0;
|
|
#writeQueue: string[] = [];
|
|
#writeOffset = 0;
|
|
#flushResolvers: Array<() => void> = [];
|
|
#writing = false;
|
|
|
|
constructor(
|
|
private readonly command: string,
|
|
private readonly uiTheme: Theme,
|
|
private readonly getTerminalRows: () => number,
|
|
) {
|
|
this.#terminal = new XtermTerminal({
|
|
cols: 120,
|
|
rows: 40,
|
|
disableStdin: true,
|
|
allowProposedApi: true,
|
|
scrollback: 10_000,
|
|
});
|
|
}
|
|
|
|
setHandlers(onInput: (data: string) => void, onDismiss: () => void, onDispose: () => void): void {
|
|
this.#onInput = onInput;
|
|
this.#onDismiss = onDismiss;
|
|
this.#onDispose = onDispose;
|
|
}
|
|
|
|
appendOutput(chunk: string): void {
|
|
this.#writeQueue.push(chunk);
|
|
this.#drainQueue();
|
|
}
|
|
|
|
#drainQueue(): void {
|
|
if (this.#writing) return;
|
|
if (this.#writeOffset >= this.#writeQueue.length) {
|
|
this.#resolveFlushWaiters();
|
|
return;
|
|
}
|
|
this.#writing = true;
|
|
const data = this.#writeQueue[this.#writeOffset]!;
|
|
this.#terminal.write(data, () => {
|
|
this.#writing = false;
|
|
this.#writeOffset += 1;
|
|
if (this.#writeOffset >= this.#writeQueue.length) {
|
|
this.#writeQueue = [];
|
|
this.#writeOffset = 0;
|
|
this.#resolveFlushWaiters();
|
|
}
|
|
this.#drainQueue();
|
|
});
|
|
}
|
|
|
|
#resolveFlushWaiters(): void {
|
|
if (this.#writing || this.#writeOffset < this.#writeQueue.length) return;
|
|
if (this.#flushResolvers.length === 0) return;
|
|
const resolvers = this.#flushResolvers;
|
|
this.#flushResolvers = [];
|
|
for (const resolve of resolvers) {
|
|
resolve();
|
|
}
|
|
}
|
|
|
|
flushOutput(): Promise<void> {
|
|
if (!this.#writing && this.#writeOffset >= this.#writeQueue.length) {
|
|
return Promise.resolve();
|
|
}
|
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
this.#flushResolvers.push(resolve);
|
|
return promise;
|
|
}
|
|
|
|
setSession(session: PtySession): void {
|
|
this.#session = session;
|
|
}
|
|
|
|
setComplete(result: { exitCode: number | undefined; cancelled: boolean; timedOut: boolean }): void {
|
|
this.#exitCode = result.exitCode;
|
|
if (result.timedOut) {
|
|
this.#state = "timed_out";
|
|
return;
|
|
}
|
|
if (result.cancelled) {
|
|
this.#state = "killed";
|
|
return;
|
|
}
|
|
this.#state = "complete";
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
if (this.#state === "running" && (matchesKey(data, "escape") || matchesKey(data, "esc"))) {
|
|
this.#onDismiss();
|
|
return;
|
|
}
|
|
if (this.#state !== "running") {
|
|
return;
|
|
}
|
|
const normalizedInput = normalizeInputForPty(data, this.#terminal.modes.applicationCursorKeysMode);
|
|
if (!normalizedInput) {
|
|
return;
|
|
}
|
|
this.#onInput(normalizedInput);
|
|
}
|
|
#stateText(): string {
|
|
if (this.#state === "running") return this.uiTheme.fg("warning", "running");
|
|
if (this.#state === "timed_out") return this.uiTheme.fg("warning", "timed out");
|
|
if (this.#state === "killed") return this.uiTheme.fg("warning", "killed");
|
|
if (this.#exitCode === 0) return this.uiTheme.fg("success", "exit 0");
|
|
if (this.#exitCode === undefined) return this.uiTheme.fg("warning", "exited");
|
|
return this.uiTheme.fg("error", `exit ${this.#exitCode}`);
|
|
}
|
|
|
|
#readViewport(innerWidth: number, maxContentRows: number): string[] {
|
|
this.#terminal.resize(innerWidth, maxContentRows);
|
|
const buffer = this.#terminal.buffer.active;
|
|
const viewportY = buffer.viewportY;
|
|
const visibleLines: string[] = [];
|
|
for (let i = 0; i < maxContentRows; i++) {
|
|
const line = buffer.getLine(viewportY + i)?.translateToString(true) ?? "";
|
|
visibleLines.push(truncateToWidth(replaceTabs(sanitizeText(line)), innerWidth));
|
|
}
|
|
return visibleLines;
|
|
}
|
|
render(width: number): string[] {
|
|
const safeWidth = Math.max(20, width);
|
|
const innerWidth = Math.max(1, safeWidth - 2);
|
|
const maxOverlayRows = Math.max(5, Math.floor(this.getTerminalRows() * 0.8));
|
|
const chromeRows = 4;
|
|
const maxContentRows = Math.max(1, maxOverlayRows - chromeRows);
|
|
// Propagate terminal resize to PTY session
|
|
const currentCols = innerWidth;
|
|
const currentRows = maxContentRows;
|
|
if (this.#session && (currentCols !== this.#lastCols || currentRows !== this.#lastRows)) {
|
|
this.#lastCols = currentCols;
|
|
this.#lastRows = currentRows;
|
|
try {
|
|
this.#session.resize(currentCols, currentRows);
|
|
} catch {
|
|
// Session may have ended
|
|
}
|
|
}
|
|
const statusIcon =
|
|
this.#state === "running"
|
|
? formatStatusIcon("running", this.uiTheme)
|
|
: this.#state === "complete" && this.#exitCode === 0
|
|
? formatStatusIcon("success", this.uiTheme)
|
|
: formatStatusIcon("warning", this.uiTheme);
|
|
const title = this.uiTheme.fg("accent", "Console");
|
|
const statusBadge = `${this.uiTheme.fg("dim", this.uiTheme.format.bracketLeft)}${this.#stateText()}${this.uiTheme.fg("dim", this.uiTheme.format.bracketRight)}`;
|
|
const prefix = `${statusIcon} ${title} `;
|
|
const suffix = ` ${statusBadge}`;
|
|
const available = Math.max(1, innerWidth - visibleWidth(prefix) - visibleWidth(suffix));
|
|
const cmd = truncateToWidth(this.uiTheme.fg("muted", replaceTabs(this.command)), available);
|
|
const header = truncateToWidth(`${prefix}${cmd}${suffix}`, innerWidth);
|
|
const footer =
|
|
this.#state === "running"
|
|
? truncateToWidth(
|
|
`${this.uiTheme.fg("warning", "esc")} ${this.uiTheme.fg("dim", "force-kill")} ${this.uiTheme.fg("dim", "· input forwarded to PTY")}`,
|
|
innerWidth,
|
|
)
|
|
: truncateToWidth(this.uiTheme.fg("dim", "session finished"), innerWidth);
|
|
const visibleLines = this.#readViewport(innerWidth, maxContentRows);
|
|
const content = visibleLines.length > 0 ? visibleLines : [padding(innerWidth)];
|
|
const borderHorizontal = this.uiTheme.fg("border", this.uiTheme.boxSharp.horizontal.repeat(innerWidth));
|
|
const borderVertical = this.uiTheme.fg("border", this.uiTheme.boxSharp.vertical);
|
|
const boxLine = (line: string) =>
|
|
`${borderVertical}${line}${padding(Math.max(0, innerWidth - visibleWidth(line)))}${borderVertical}`;
|
|
return [
|
|
`${this.uiTheme.fg("border", this.uiTheme.boxSharp.topLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.topRight)}`,
|
|
boxLine(header),
|
|
...content.map(boxLine),
|
|
boxLine(footer),
|
|
`${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomLeft)}${borderHorizontal}${this.uiTheme.fg("border", this.uiTheme.boxSharp.bottomRight)}`,
|
|
];
|
|
}
|
|
|
|
invalidate(): void {}
|
|
|
|
dispose(): void {
|
|
this.#terminal.dispose();
|
|
this.#onDispose();
|
|
}
|
|
}
|
|
|
|
export async function runInteractiveBashPty(
|
|
ui: NonNullable<AgentToolContext["ui"]>,
|
|
options: {
|
|
command: string;
|
|
cwd: string;
|
|
timeoutMs: number;
|
|
signal?: AbortSignal;
|
|
env?: Record<string, string>;
|
|
artifactPath?: string;
|
|
artifactId?: string;
|
|
},
|
|
): Promise<BashInteractiveResult> {
|
|
const settings = await Settings.init();
|
|
const { shell: resolvedShell } = settings.getShellConfig();
|
|
const sink = new OutputSink({
|
|
artifactPath: options.artifactPath,
|
|
artifactId: options.artifactId,
|
|
headBytes: resolveOutputSinkHeadBytes(settings),
|
|
maxColumns: resolveOutputMaxColumns(settings),
|
|
});
|
|
const result = await ui.custom<BashInteractiveResult>(
|
|
(tui, uiTheme, _keybindings, done) => {
|
|
const session = new PtySession();
|
|
const component = new BashInteractiveOverlayComponent(options.command, uiTheme, () => tui.terminal.rows);
|
|
component.setSession(session);
|
|
let finished = false;
|
|
const finalize = (run: PtyRunResult) => {
|
|
if (finished) return;
|
|
finished = true;
|
|
component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
|
|
tui.requestRender();
|
|
void (async () => {
|
|
await component.flushOutput();
|
|
const summary = await sink.dump();
|
|
done({
|
|
exitCode: run.exitCode,
|
|
cancelled: run.cancelled,
|
|
timedOut: run.timedOut,
|
|
...summary,
|
|
});
|
|
})();
|
|
};
|
|
const cols = Math.max(20, tui.terminal.columns - 2);
|
|
const rows = Math.max(5, tui.terminal.rows - 4);
|
|
component.setHandlers(
|
|
data => {
|
|
try {
|
|
session.write(data);
|
|
} catch {
|
|
// ignore writes after command exits
|
|
}
|
|
},
|
|
() => {
|
|
try {
|
|
session.kill();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
() => {
|
|
try {
|
|
session.kill();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
);
|
|
void session
|
|
.start(
|
|
{
|
|
command: options.command,
|
|
cwd: options.cwd,
|
|
timeoutMs: options.timeoutMs,
|
|
env: {
|
|
...NON_INTERACTIVE_ENV,
|
|
...options.env,
|
|
},
|
|
signal: options.signal,
|
|
cols,
|
|
rows,
|
|
shell: resolvedShell,
|
|
},
|
|
(err, chunk) => {
|
|
if (finished || err || !chunk) return;
|
|
component.appendOutput(chunk);
|
|
const normalizedChunk = normalizeCaptureChunk(chunk);
|
|
sink.push(normalizedChunk);
|
|
tui.requestRender();
|
|
},
|
|
)
|
|
.then(finalize)
|
|
.catch(error => {
|
|
sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
finalize({ exitCode: undefined, cancelled: false, timedOut: false });
|
|
});
|
|
return component;
|
|
},
|
|
{ overlay: true },
|
|
);
|
|
return result;
|
|
}
|