import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { type PtyRunResult, PtySession } from "@oh-my-pi/pi-natives"; import { type Component, extractPrintableText, matchesKey, padding, parseKey, parseKittySequence, truncateToWidth, visibleWidth, } from "@oh-my-pi/pi-tui"; import { sanitizeText } from "@oh-my-pi/pi-utils"; 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 { if (!this.#writing && this.#writeOffset >= this.#writeQueue.length) { return Promise.resolve(); } const { promise, resolve } = Promise.withResolvers(); 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, options: { command: string; cwd: string; timeoutMs: number; signal?: AbortSignal; env?: Record; artifactPath?: string; artifactId?: string; }, ): Promise { 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( (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; }