Merge pull request #228 from thecodacus/feature--bolt-shell

feat(bolt terminal): added dedicated bolt terminal, and attached to workbench
This commit is contained in:
Chris Mahoney 2024-11-12 09:30:21 -06:00 committed by GitHub
commit 0203cf9538
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 320 additions and 68 deletions

View File

@ -151,7 +151,13 @@ const ActionList = memo(({ actions }: ActionListProps) => {
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<div className={classNames('text-lg', getIconColor(action.status))}> <div className={classNames('text-lg', getIconColor(action.status))}>
{status === 'running' ? ( {status === 'running' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div> <>
{type !== 'start' ? (
<div className="i-svg-spinners:90-ring-with-bg"></div>
) : (
<div className="i-ph:terminal-window-duotone"></div>
)}
</>
) : status === 'pending' ? ( ) : status === 'pending' ? (
<div className="i-ph:circle-duotone"></div> <div className="i-ph:circle-duotone"></div>
) : status === 'complete' ? ( ) : status === 'complete' ? (
@ -171,9 +177,19 @@ const ActionList = memo(({ actions }: ActionListProps) => {
<div className="flex items-center w-full min-h-[28px]"> <div className="flex items-center w-full min-h-[28px]">
<span className="flex-1">Run command</span> <span className="flex-1">Run command</span>
</div> </div>
) : type === 'start' ? (
<a
onClick={(e) => {
e.preventDefault();
workbenchStore.currentView.set('preview');
}}
className="flex items-center w-full min-h-[28px]"
>
<span className="flex-1">Start Application</span>
</a>
) : null} ) : null}
</div> </div>
{type === 'shell' && ( {(type === 'shell' || type === 'start') && (
<ShellCodeBlock <ShellCodeBlock
classsName={classNames('mt-1', { classsName={classNames('mt-1', {
'mb-3.5': !isLast, 'mb-3.5': !isLast,

View File

@ -18,7 +18,7 @@ import { themeStore } from '~/lib/stores/theme';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
import { classNames } from '~/utils/classNames'; import { classNames } from '~/utils/classNames';
import { WORK_DIR } from '~/utils/constants'; import { WORK_DIR } from '~/utils/constants';
import { renderLogger } from '~/utils/logger'; import { logger, renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile'; import { isMobile } from '~/utils/mobile';
import { FileBreadcrumb } from './FileBreadcrumb'; import { FileBreadcrumb } from './FileBreadcrumb';
import { FileTree } from './FileTree'; import { FileTree } from './FileTree';
@ -199,25 +199,48 @@ export const EditorPanel = memo(
<div className="h-full"> <div className="h-full">
<div className="bg-bolt-elements-terminals-background h-full flex flex-col"> <div className="bg-bolt-elements-terminals-background h-full flex flex-col">
<div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2"> <div className="flex items-center bg-bolt-elements-background-depth-2 border-y border-bolt-elements-borderColor gap-1.5 min-h-[34px] p-2">
{Array.from({ length: terminalCount }, (_, index) => { {Array.from({ length: terminalCount + 1 }, (_, index) => {
const isActive = activeTerminal === index; const isActive = activeTerminal === index;
return ( return (
<button <>
key={index} {index == 0 ? (
className={classNames( <button
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full', key={index}
{ className={classNames(
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive, 'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground': {
!isActive, 'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary':
}, isActive,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Bolt Terminal
</button>
) : (
<>
<button
key={index}
className={classNames(
'flex items-center text-sm cursor-pointer gap-1.5 px-3 py-2 h-full whitespace-nowrap rounded-full',
{
'bg-bolt-elements-terminals-buttonBackground text-bolt-elements-textPrimary': isActive,
'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:bg-bolt-elements-terminals-buttonBackground':
!isActive,
},
)}
onClick={() => setActiveTerminal(index)}
>
<div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index}
</button>
</>
)} )}
onClick={() => setActiveTerminal(index)} </>
>
<div className="i-ph:terminal-window-duotone text-lg" />
Terminal {terminalCount > 1 && index + 1}
</button>
); );
})} })}
{terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />} {terminalCount < MAX_TERMINALS && <IconButton icon="i-ph:plus" size="md" onClick={addTerminal} />}
@ -229,9 +252,26 @@ export const EditorPanel = memo(
onClick={() => workbenchStore.toggleTerminal(false)} onClick={() => workbenchStore.toggleTerminal(false)}
/> />
</div> </div>
{Array.from({ length: terminalCount }, (_, index) => { {Array.from({ length: terminalCount + 1 }, (_, index) => {
const isActive = activeTerminal === index; const isActive = activeTerminal === index;
if (index == 0) {
logger.info('Starting bolt terminal');
return (
<Terminal
key={index}
className={classNames('h-full overflow-hidden', {
hidden: !isActive,
})}
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
theme={theme}
/>
);
}
return ( return (
<Terminal <Terminal
key={index} key={index}

View File

@ -174,10 +174,16 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
- When Using \`npx\`, ALWAYS provide the \`--yes\` flag. - When Using \`npx\`, ALWAYS provide the \`--yes\` flag.
- When running multiple shell commands, use \`&&\` to run them sequentially. - When running multiple shell commands, use \`&&\` to run them sequentially.
- ULTRA IMPORTANT: Do NOT re-run a dev command if there is one that starts a dev server and new dependencies were installed or files updated! If a dev server has started already, assume that installing dependencies will be executed in a different process and will be picked up by the dev server. - ULTRA IMPORTANT: Do NOT re-run a dev command with shell action use dev action to run dev commands
- file: For writing new files or updating existing files. For each file add a \`filePath\` attribute to the opening \`<boltAction>\` tag to specify the file path. The content of the file artifact is the file contents. All file paths MUST BE relative to the current working directory. - file: For writing new files or updating existing files. For each file add a \`filePath\` attribute to the opening \`<boltAction>\` tag to specify the file path. The content of the file artifact is the file contents. All file paths MUST BE relative to the current working directory.
- start: For starting development server.
- Use to start application if not already started or NEW dependencies added
- Only use this action when you need to run a dev server or start the application
- ULTRA IMORTANT: do NOT re-run a dev server if files updated, existing dev server can autometically detect changes and executes the file changes
9. The order of the actions is VERY IMPORTANT. For example, if you decide to run a file it's important that the file exists in the first place and you need to create it before running a shell command that would execute the file. 9. The order of the actions is VERY IMPORTANT. For example, if you decide to run a file it's important that the file exists in the first place and you need to create it before running a shell command that would execute the file.
10. ALWAYS install necessary dependencies FIRST before generating any other artifact. If that requires a \`package.json\` then you should create that first! 10. ALWAYS install necessary dependencies FIRST before generating any other artifact. If that requires a \`package.json\` then you should create that first!
@ -265,7 +271,7 @@ Here are some examples of correct usage of artifacts:
... ...
</boltAction> </boltAction>
<boltAction type="shell"> <boltAction type="start">
npm run dev npm run dev
</boltAction> </boltAction>
</boltArtifact> </boltArtifact>
@ -322,7 +328,7 @@ Here are some examples of correct usage of artifacts:
... ...
</boltAction> </boltAction>
<boltAction type="shell"> <boltAction type="start">
npm run dev npm run dev
</boltAction> </boltAction>
</boltArtifact> </boltArtifact>

View File

@ -1,10 +1,12 @@
import { WebContainer } from '@webcontainer/api'; import { WebContainer, type WebContainerProcess } from '@webcontainer/api';
import { map, type MapStore } from 'nanostores'; import { atom, map, type MapStore } from 'nanostores';
import * as nodePath from 'node:path'; import * as nodePath from 'node:path';
import type { BoltAction } from '~/types/actions'; import type { BoltAction } from '~/types/actions';
import { createScopedLogger } from '~/utils/logger'; import { createScopedLogger } from '~/utils/logger';
import { unreachable } from '~/utils/unreachable'; import { unreachable } from '~/utils/unreachable';
import type { ActionCallbackData } from './message-parser'; import type { ActionCallbackData } from './message-parser';
import type { ITerminal } from '~/types/terminal';
import type { BoltShell } from '~/utils/shell';
const logger = createScopedLogger('ActionRunner'); const logger = createScopedLogger('ActionRunner');
@ -36,11 +38,14 @@ type ActionsMap = MapStore<Record<string, ActionState>>;
export class ActionRunner { export class ActionRunner {
#webcontainer: Promise<WebContainer>; #webcontainer: Promise<WebContainer>;
#currentExecutionPromise: Promise<void> = Promise.resolve(); #currentExecutionPromise: Promise<void> = Promise.resolve();
#shellTerminal: () => BoltShell;
runnerId = atom<string>(`${Date.now()}`);
actions: ActionsMap = map({}); actions: ActionsMap = map({});
constructor(webcontainerPromise: Promise<WebContainer>) { constructor(webcontainerPromise: Promise<WebContainer>, getShellTerminal: () => BoltShell) {
this.#webcontainer = webcontainerPromise; this.#webcontainer = webcontainerPromise;
this.#shellTerminal = getShellTerminal;
} }
addAction(data: ActionCallbackData) { addAction(data: ActionCallbackData) {
@ -110,11 +115,16 @@ export class ActionRunner {
await this.#runFileAction(action); await this.#runFileAction(action);
break; break;
} }
case 'start': {
await this.#runStartAction(action)
break;
}
} }
this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' }); this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' });
} catch (error) { } catch (error) {
this.#updateAction(actionId, { status: 'failed', error: 'Action failed' }); this.#updateAction(actionId, { status: 'failed', error: 'Action failed' });
logger.error(`[${action.type}]:Action failed\n\n`, error);
// re-throw the error to be caught in the promise chain // re-throw the error to be caught in the promise chain
throw error; throw error;
@ -125,28 +135,38 @@ export class ActionRunner {
if (action.type !== 'shell') { if (action.type !== 'shell') {
unreachable('Expected shell action'); unreachable('Expected shell action');
} }
const shell = this.#shellTerminal()
await shell.ready()
if (!shell || !shell.terminal || !shell.process) {
unreachable('Shell terminal not found');
}
const resp = await shell.executeCommand(this.runnerId.get(), action.content)
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`)
if (resp?.exitCode != 0) {
throw new Error("Failed To Execute Shell Command");
const webcontainer = await this.#webcontainer; }
}
const process = await webcontainer.spawn('jsh', ['-c', action.content], { async #runStartAction(action: ActionState) {
env: { npm_config_yes: true }, if (action.type !== 'start') {
}); unreachable('Expected shell action');
}
if (!this.#shellTerminal) {
unreachable('Shell terminal not found');
}
const shell = this.#shellTerminal()
await shell.ready()
if (!shell || !shell.terminal || !shell.process) {
unreachable('Shell terminal not found');
}
const resp = await shell.executeCommand(this.runnerId.get(), action.content)
logger.debug(`${action.type} Shell Response: [exit code:${resp?.exitCode}]`)
action.abortSignal.addEventListener('abort', () => { if (resp?.exitCode != 0) {
process.kill(); throw new Error("Failed To Start Application");
}); }
return resp
process.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
},
}),
);
const exitCode = await process.exit;
logger.debug(`Process terminated with code ${exitCode}`);
} }
async #runFileAction(action: ActionState) { async #runFileAction(action: ActionState) {
@ -177,7 +197,6 @@ export class ActionRunner {
logger.error('Failed to write file\n\n', error); logger.error('Failed to write file\n\n', error);
} }
} }
#updateAction(id: string, newState: ActionStateUpdate) { #updateAction(id: string, newState: ActionStateUpdate) {
const actions = this.actions.get(); const actions = this.actions.get();

View File

@ -54,7 +54,7 @@ interface MessageState {
export class StreamingMessageParser { export class StreamingMessageParser {
#messages = new Map<string, MessageState>(); #messages = new Map<string, MessageState>();
constructor(private _options: StreamingMessageParserOptions = {}) {} constructor(private _options: StreamingMessageParserOptions = {}) { }
parse(messageId: string, input: string) { parse(messageId: string, input: string) {
let state = this.#messages.get(messageId); let state = this.#messages.get(messageId);
@ -256,7 +256,7 @@ export class StreamingMessageParser {
} }
(actionAttributes as FileAction).filePath = filePath; (actionAttributes as FileAction).filePath = filePath;
} else if (actionType !== 'shell') { } else if (!(['shell', 'start'].includes(actionType))) {
logger.warn(`Unknown action type '${actionType}'`); logger.warn(`Unknown action type '${actionType}'`);
} }

View File

@ -1,14 +1,15 @@
import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import { atom, type WritableAtom } from 'nanostores'; import { atom, type WritableAtom } from 'nanostores';
import type { ITerminal } from '~/types/terminal'; import type { ITerminal } from '~/types/terminal';
import { newShellProcess } from '~/utils/shell'; import { newBoltShellProcess, newShellProcess } from '~/utils/shell';
import { coloredText } from '~/utils/terminal'; import { coloredText } from '~/utils/terminal';
export class TerminalStore { export class TerminalStore {
#webcontainer: Promise<WebContainer>; #webcontainer: Promise<WebContainer>;
#terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = []; #terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = [];
#boltTerminal = newBoltShellProcess()
showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(false); showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(true);
constructor(webcontainerPromise: Promise<WebContainer>) { constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise; this.#webcontainer = webcontainerPromise;
@ -17,10 +18,22 @@ export class TerminalStore {
import.meta.hot.data.showTerminal = this.showTerminal; import.meta.hot.data.showTerminal = this.showTerminal;
} }
} }
get boltTerminal() {
return this.#boltTerminal;
}
toggleTerminal(value?: boolean) { toggleTerminal(value?: boolean) {
this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get()); this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get());
} }
async attachBoltTerminal(terminal: ITerminal) {
try {
let wc = await this.#webcontainer
await this.#boltTerminal.init(wc, terminal)
} catch (error: any) {
terminal.write(coloredText.red('Failed to spawn bolt shell\n\n') + error.message);
return;
}
}
async attachTerminal(terminal: ITerminal) { async attachTerminal(terminal: ITerminal) {
try { try {

View File

@ -12,6 +12,7 @@ import { TerminalStore } from './terminal';
import JSZip from 'jszip'; import JSZip from 'jszip';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { Octokit } from "@octokit/rest"; import { Octokit } from "@octokit/rest";
import type { WebContainerProcess } from '@webcontainer/api';
export interface ArtifactState { export interface ArtifactState {
id: string; id: string;
@ -39,6 +40,7 @@ export class WorkbenchStore {
unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>()); unsavedFiles: WritableAtom<Set<string>> = import.meta.hot?.data.unsavedFiles ?? atom(new Set<string>());
modifiedFiles = new Set<string>(); modifiedFiles = new Set<string>();
artifactIdList: string[] = []; artifactIdList: string[] = [];
#boltTerminal: { terminal: ITerminal; process: WebContainerProcess } | undefined;
constructor() { constructor() {
if (import.meta.hot) { if (import.meta.hot) {
@ -76,6 +78,9 @@ export class WorkbenchStore {
get showTerminal() { get showTerminal() {
return this.#terminalStore.showTerminal; return this.#terminalStore.showTerminal;
} }
get boltTerminal() {
return this.#terminalStore.boltTerminal;
}
toggleTerminal(value?: boolean) { toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value); this.#terminalStore.toggleTerminal(value);
@ -84,6 +89,10 @@ export class WorkbenchStore {
attachTerminal(terminal: ITerminal) { attachTerminal(terminal: ITerminal) {
this.#terminalStore.attachTerminal(terminal); this.#terminalStore.attachTerminal(terminal);
} }
attachBoltTerminal(terminal: ITerminal) {
this.#terminalStore.attachBoltTerminal(terminal);
}
onTerminalResize(cols: number, rows: number) { onTerminalResize(cols: number, rows: number) {
this.#terminalStore.onTerminalResize(cols, rows); this.#terminalStore.onTerminalResize(cols, rows);
@ -232,7 +241,7 @@ export class WorkbenchStore {
id, id,
title, title,
closed: false, closed: false,
runner: new ActionRunner(webcontainer), runner: new ActionRunner(webcontainer, () => this.boltTerminal),
}); });
} }
@ -336,20 +345,20 @@ export class WorkbenchStore {
} }
async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) { async pushToGitHub(repoName: string, githubUsername: string, ghToken: string) {
try { try {
// Get the GitHub auth token from environment variables // Get the GitHub auth token from environment variables
const githubToken = ghToken; const githubToken = ghToken;
const owner = githubUsername; const owner = githubUsername;
if (!githubToken) { if (!githubToken) {
throw new Error('GitHub token is not set in environment variables'); throw new Error('GitHub token is not set in environment variables');
} }
// Initialize Octokit with the auth token // Initialize Octokit with the auth token
const octokit = new Octokit({ auth: githubToken }); const octokit = new Octokit({ auth: githubToken });
// Check if the repository already exists before creating it // Check if the repository already exists before creating it
let repo let repo
try { try {
@ -368,13 +377,13 @@ export class WorkbenchStore {
throw error; // Some other error occurred throw error; // Some other error occurred
} }
} }
// Get all files // Get all files
const files = this.files.get(); const files = this.files.get();
if (!files || Object.keys(files).length === 0) { if (!files || Object.keys(files).length === 0) {
throw new Error('No files found to push'); throw new Error('No files found to push');
} }
// Create blobs for each file // Create blobs for each file
const blobs = await Promise.all( const blobs = await Promise.all(
Object.entries(files).map(async ([filePath, dirent]) => { Object.entries(files).map(async ([filePath, dirent]) => {
@ -389,13 +398,13 @@ export class WorkbenchStore {
} }
}) })
); );
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
if (validBlobs.length === 0) { if (validBlobs.length === 0) {
throw new Error('No valid files to push'); throw new Error('No valid files to push');
} }
// Get the latest commit SHA (assuming main branch, update dynamically if needed) // Get the latest commit SHA (assuming main branch, update dynamically if needed)
const { data: ref } = await octokit.git.getRef({ const { data: ref } = await octokit.git.getRef({
owner: repo.owner.login, owner: repo.owner.login,
@ -403,7 +412,7 @@ export class WorkbenchStore {
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
}); });
const latestCommitSha = ref.object.sha; const latestCommitSha = ref.object.sha;
// Create a new tree // Create a new tree
const { data: newTree } = await octokit.git.createTree({ const { data: newTree } = await octokit.git.createTree({
owner: repo.owner.login, owner: repo.owner.login,
@ -416,7 +425,7 @@ export class WorkbenchStore {
sha: blob!.sha, sha: blob!.sha,
})), })),
}); });
// Create a new commit // Create a new commit
const { data: newCommit } = await octokit.git.createCommit({ const { data: newCommit } = await octokit.git.createCommit({
owner: repo.owner.login, owner: repo.owner.login,
@ -425,7 +434,7 @@ export class WorkbenchStore {
tree: newTree.sha, tree: newTree.sha,
parents: [latestCommitSha], parents: [latestCommitSha],
}); });
// Update the reference // Update the reference
await octokit.git.updateRef({ await octokit.git.updateRef({
owner: repo.owner.login, owner: repo.owner.login,
@ -433,7 +442,7 @@ export class WorkbenchStore {
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
sha: newCommit.sha, sha: newCommit.sha,
}); });
alert(`Repository created and code pushed: ${repo.html_url}`); alert(`Repository created and code pushed: ${repo.html_url}`);
} catch (error) { } catch (error) {
console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error)); console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error));

View File

@ -13,6 +13,10 @@ export interface ShellAction extends BaseAction {
type: 'shell'; type: 'shell';
} }
export type BoltAction = FileAction | ShellAction; export interface StartAction extends BaseAction {
type: 'start';
}
export type BoltAction = FileAction | ShellAction | StartAction;
export type BoltActionData = BoltAction | BaseAction; export type BoltActionData = BoltAction | BaseAction;

View File

@ -5,4 +5,5 @@ export interface ITerminal {
reset: () => void; reset: () => void;
write: (data: string) => void; write: (data: string) => void;
onData: (cb: (data: string) => void) => void; onData: (cb: (data: string) => void) => void;
input: (data: string) => void;
} }

View File

@ -1,6 +1,7 @@
import type { WebContainer } from '@webcontainer/api'; import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
import type { ITerminal } from '~/types/terminal'; import type { ITerminal } from '~/types/terminal';
import { withResolvers } from './promises'; import { withResolvers } from './promises';
import { atom } from 'nanostores';
export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) { export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
const args: string[] = []; const args: string[] = [];
@ -19,7 +20,6 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer
const jshReady = withResolvers<void>(); const jshReady = withResolvers<void>();
let isInteractive = false; let isInteractive = false;
output.pipeTo( output.pipeTo(
new WritableStream({ new WritableStream({
write(data) { write(data) {
@ -40,6 +40,8 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer
); );
terminal.onData((data) => { terminal.onData((data) => {
// console.log('terminal onData', { data, isInteractive });
if (isInteractive) { if (isInteractive) {
input.write(data); input.write(data);
} }
@ -49,3 +51,145 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer
return process; return process;
} }
export class BoltShell {
#initialized: (() => void) | undefined
#readyPromise: Promise<void>
#webcontainer: WebContainer | undefined
#terminal: ITerminal | undefined
#process: WebContainerProcess | undefined
executionState = atom<{ sessionId: string, active: boolean, executionPrms?: Promise<any> } | undefined>()
#outputStream: ReadableStreamDefaultReader<string> | undefined
#shellInputStream: WritableStreamDefaultWriter<string> | 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
let callback = (data: string) => {
console.log(data)
}
let { 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) {
if (!this.process || !this.terminal) {
return
}
let state = this.executionState.get()
//interrupt the current execution
// this.#shellInputStream?.write('\x03');
this.terminal.input('\x03');
if (state && state.executionPrms) {
await state.executionPrms
}
//start a new execution
this.terminal.input(command.trim() + '\n');
//wait for the execution to finish
let executionPrms = this.getCurrentExecutionResult()
this.executionState.set({ sessionId, active: true, executionPrms })
let resp = await executionPrms
this.executionState.set({ sessionId, active: false })
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<void>();
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() {
let { 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 };
let 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, , pid, code] = text.match(/\x1b\]654;([^\x07=]+)=?((-?\d+):(\d+))?\x07/) || [];
if (osc === 'exit') {
exitCode = parseInt(code, 10);
}
if (osc === waitCode) {
break;
}
}
return { output: fullOutput, exitCode };
}
}
export function newBoltShellProcess() {
return new BoltShell();
}