Files
oh-my-pi/packages/coding-agent/src/tools/bash-interactive.ts
T
can1357 2e33e436b4 feat: switched to native text sanitization, removed Bun.stripANSI
- Added `sanitizeText` function to pi-natives that strips ANSI escape sequences, removes control characters and lone surrogates, and normalizes line endings.
- Moved `sanitizeText` function from `@oh-my-pi/pi-utils` to `@oh-my-pi/pi-natives` for better code organization and native performance.
- Added line length clamping (4000 characters) to bash and Python execution output to prevent excessively long lines.
- Replaced internal `#normalizeOutput` methods with `sanitizeText` utility function in bash and Python execution components.
- Fixed bash interactive tool to gracefully handle malformed output chunks by normalizing them with `sanitizeText`.
- Simplified documentation by removing WASM terminology from package descriptions and comments.
2026-02-14 03:25:13 +01:00

376 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,
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 type { Theme } from "../modes/theme/theme";
import { OutputSink, type OutputSummary } from "../session/streaming-output";
import { getStateIcon } from "../tui";
import { replaceTabs, wrapBrackets } 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").replace(/\r/gu, "\n");
return sanitizeText(normalized);
}
const XtermTerminal = xterm.Terminal;
function normalizeInputForPty(data: string, applicationCursorKeysMode: boolean): string {
const kitty = parseKittySequence(data);
if (!kitty) {
return data;
}
if (kitty.eventType === 3) {
return "";
}
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;
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.#terminal.write(chunk);
}
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"
? getStateIcon("running", this.uiTheme)
: this.#state === "complete" && this.#exitCode === 0
? getStateIcon("success", this.uiTheme)
: getStateIcon("warning", this.uiTheme);
const title = this.uiTheme.fg("accent", "Console");
const statusBadge = this.uiTheme.fg("dim", wrapBrackets(this.#stateText(), this.uiTheme));
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 sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
let pendingChunks = Promise.resolve();
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 pendingChunks;
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: {
...options.env,
// Disable pagers so commands don't block on interactive views.
PAGER: "cat",
GIT_PAGER: "cat",
MANPAGER: "cat",
SYSTEMD_PAGER: "cat",
BAT_PAGER: "cat",
DELTA_PAGER: "cat",
GH_PAGER: "cat",
GLAB_PAGER: "cat",
PSQL_PAGER: "cat",
MYSQL_PAGER: "cat",
AWS_PAGER: "",
HOMEBREW_PAGER: "cat",
LESS: "FRX",
// Disable editor and terminal credential prompts.
GIT_EDITOR: "true",
VISUAL: "true",
EDITOR: "true",
GIT_TERMINAL_PROMPT: "0",
SSH_ASKPASS: "/usr/bin/false",
CI: "1",
// Package manager defaults for unattended execution.
npm_config_yes: "true",
npm_config_update_notifier: "false",
npm_config_fund: "false",
npm_config_audit: "false",
npm_config_progress: "false",
PNPM_DISABLE_SELF_UPDATE_CHECK: "true",
PNPM_UPDATE_NOTIFIER: "false",
YARN_ENABLE_TELEMETRY: "0",
YARN_ENABLE_PROGRESS_BARS: "0",
// Cross-language/tooling non-interactive defaults.
CARGO_TERM_PROGRESS_WHEN: "never",
DEBIAN_FRONTEND: "noninteractive",
PIP_NO_INPUT: "1",
PIP_DISABLE_PIP_VERSION_CHECK: "1",
TF_INPUT: "0",
TF_IN_AUTOMATION: "1",
GH_PROMPT_DISABLED: "1",
COMPOSER_NO_INTERACTION: "1",
CLOUDSDK_CORE_DISABLE_PROMPTS: "1",
},
signal: options.signal,
cols,
rows,
},
(err, chunk) => {
if (err || !chunk) return;
try {
component.appendOutput(chunk);
} catch {
const normalizedChunk = normalizeCaptureChunk(chunk);
component.appendOutput(normalizedChunk);
}
const normalizedChunk = normalizeCaptureChunk(chunk);
pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {});
tui.requestRender();
},
)
.then(finalize)
.catch(error => {
pendingChunks = pendingChunks
.then(() => sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`))
.catch(() => {});
finalize({ exitCode: undefined, cancelled: false, timedOut: false });
});
return component;
},
{ overlay: true },
);
return result;
}