import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; import type { ITerminal } from '~/types/terminal'; import { withResolvers } from './promises'; import { atom } from 'nanostores'; export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) { const args: string[] = []; // we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], { terminal: { cols: terminal.cols ?? 80, rows: terminal.rows ?? 15, }, }); const input = process.input.getWriter(); const output = process.output; const jshReady = withResolvers(); let isInteractive = false; output.pipeTo( new WritableStream({ write(data) { if (!isInteractive) { const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || []; if (osc === 'interactive') { // wait until we see the interactive OSC isInteractive = true; jshReady.resolve(); } } terminal.write(data); }, }), ); terminal.onData((data) => { // console.log('terminal onData', { data, isInteractive }); if (isInteractive) { input.write(data); } }); await jshReady.promise; return process; } export type ExecutionResult = { output: string; exitCode: number } | undefined; export class BoltShell { #initialized: (() => void) | undefined; #readyPromise: Promise; #webcontainer: WebContainer | undefined; #terminal: ITerminal | undefined; #process: WebContainerProcess | undefined; executionState = atom< { sessionId: string; active: boolean; executionPrms?: Promise; abort?: () => void } | undefined >(); #outputStream: ReadableStreamDefaultReader | undefined; #shellInputStream: WritableStreamDefaultWriter | undefined; constructor() { this.#readyPromise = new Promise((resolve) => { this.#initialized = resolve; }); } ready() { return this.#readyPromise; } async init(webcontainer: WebContainer, terminal: ITerminal) { this.#webcontainer = webcontainer; this.#terminal = terminal; const { process, output } = await this.newBoltShellProcess(webcontainer, terminal); this.#process = process; this.#outputStream = output.getReader(); await this.waitTillOscCode('interactive'); this.#initialized?.(); } get terminal() { return this.#terminal; } get process() { return this.#process; } async executeCommand(sessionId: string, command: string, abort?: () => void): Promise { if (!this.process || !this.terminal) { return undefined; } const state = this.executionState.get(); if (state?.active && state.abort) { state.abort(); } /* * interrupt the current execution * this.#shellInputStream?.write('\x03'); */ this.terminal.input('\x03'); await this.waitTillOscCode('prompt'); if (state && state.executionPrms) { await state.executionPrms; } //start a new execution this.terminal.input(command.trim() + '\n'); //wait for the execution to finish const executionPromise = this.getCurrentExecutionResult(); this.executionState.set({ sessionId, active: true, executionPrms: executionPromise, abort }); const resp = await executionPromise; this.executionState.set({ sessionId, active: false }); if (resp) { try { resp.output = cleanTerminalOutput(resp.output); } catch (error) { console.log('failed to format terminal output', error); } } return resp; } async newBoltShellProcess(webcontainer: WebContainer, terminal: ITerminal) { const args: string[] = []; // we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], { terminal: { cols: terminal.cols ?? 80, rows: terminal.rows ?? 15, }, }); const input = process.input.getWriter(); this.#shellInputStream = input; const [internalOutput, terminalOutput] = process.output.tee(); const jshReady = withResolvers(); let isInteractive = false; terminalOutput.pipeTo( new WritableStream({ write(data) { if (!isInteractive) { const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || []; if (osc === 'interactive') { // wait until we see the interactive OSC isInteractive = true; jshReady.resolve(); } } terminal.write(data); }, }), ); terminal.onData((data) => { // console.log('terminal onData', { data, isInteractive }); if (isInteractive) { input.write(data); } }); await jshReady.promise; return { process, output: internalOutput }; } async getCurrentExecutionResult(): Promise { const { output, exitCode } = await this.waitTillOscCode('exit'); return { output, exitCode }; } async waitTillOscCode(waitCode: string) { let fullOutput = ''; let exitCode: number = 0; if (!this.#outputStream) { return { output: fullOutput, exitCode }; } const tappedStream = this.#outputStream; while (true) { const { value, done } = await tappedStream.read(); if (done) { break; } const text = value || ''; fullOutput += text; // Check if command completion signal with exit code const [, osc, , , code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || []; if (osc === 'exit') { exitCode = parseInt(code, 10); } if (osc === waitCode) { break; } } return { output: fullOutput, exitCode }; } } /** * Cleans and formats terminal output while preserving structure and paths * Handles ANSI, OSC, and various terminal control sequences */ export function cleanTerminalOutput(input: string): string { // Step 1: Remove OSC sequences (including those with parameters) const removeOsc = input .replace(/\x1b\](\d+;[^\x07\x1b]*|\d+[^\x07\x1b]*)\x07/g, '') .replace(/\](\d+;[^\n]*|\d+[^\n]*)/g, ''); // Step 2: Remove ANSI escape sequences and color codes more thoroughly const removeAnsi = removeOsc // Remove all escape sequences with parameters .replace(/\u001b\[[\?]?[0-9;]*[a-zA-Z]/g, '') .replace(/\x1b\[[\?]?[0-9;]*[a-zA-Z]/g, '') // Remove color codes .replace(/\u001b\[[0-9;]*m/g, '') .replace(/\x1b\[[0-9;]*m/g, '') // Clean up any remaining escape characters .replace(/\u001b/g, '') .replace(/\x1b/g, ''); // Step 3: Clean up carriage returns and newlines const cleanNewlines = removeAnsi .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\n{3,}/g, '\n\n'); // Step 4: Add newlines at key breakpoints while preserving paths const formatOutput = cleanNewlines // Preserve prompt line .replace(/^([~\/][^\n❯]+)❯/m, '$1\n❯') // Add newline before command output indicators .replace(/(?/g, '\n>') // Add newline before error keywords without breaking paths .replace(/(? line.trim()) .filter((line) => line.length > 0) .join('\n'); // Step 6: Final cleanup return cleanSpaces .replace(/\n{3,}/g, '\n\n') // Replace multiple newlines with double newlines .replace(/:\s+/g, ': ') // Normalize spacing after colons .replace(/\s{2,}/g, ' ') // Remove multiple spaces .replace(/^\s+|\s+$/g, '') // Trim start and end .replace(/\u0000/g, ''); // Remove null characters } export function newBoltShellProcess() { return new BoltShell(); }