From d1f3e8cbecc4e3704f382e93a4b2c6195678cfba Mon Sep 17 00:00:00 2001 From: Anirban Kar Date: Fri, 8 Nov 2024 21:47:31 +0530 Subject: [PATCH] feat: added bolt dedicated shell --- app/components/chat/Artifact.tsx | 4 + app/components/workbench/EditorPanel.tsx | 72 ++++++++++--- app/lib/.server/llm/prompts.ts | 12 ++- app/lib/runtime/action-runner.ts | 79 ++++++++++---- app/lib/runtime/message-parser.ts | 4 +- app/lib/stores/terminal.ts | 17 ++- app/lib/stores/workbench.ts | 39 ++++--- app/types/actions.ts | 6 +- app/types/terminal.ts | 1 + app/utils/shell.ts | 132 ++++++++++++++++++++++- 10 files changed, 302 insertions(+), 64 deletions(-) diff --git a/app/components/chat/Artifact.tsx b/app/components/chat/Artifact.tsx index 9de52dd..f8cd72b 100644 --- a/app/components/chat/Artifact.tsx +++ b/app/components/chat/Artifact.tsx @@ -171,6 +171,10 @@ const ActionList = memo(({ actions }: ActionListProps) => {
Run command
+ ) : type === 'start' ? ( +
+ Start Application +
) : null} {type === 'shell' && ( diff --git a/app/components/workbench/EditorPanel.tsx b/app/components/workbench/EditorPanel.tsx index d1a265a..3a834c1 100644 --- a/app/components/workbench/EditorPanel.tsx +++ b/app/components/workbench/EditorPanel.tsx @@ -199,25 +199,48 @@ export const EditorPanel = memo(
- {Array.from({ length: terminalCount }, (_, index) => { + {Array.from({ length: terminalCount + 1 }, (_, index) => { const isActive = activeTerminal === index; return ( - + ) : ( + <> + + )} - onClick={() => setActiveTerminal(index)} - > -
- Terminal {terminalCount > 1 && index + 1} - + ); })} {terminalCount < MAX_TERMINALS && } @@ -229,9 +252,26 @@ export const EditorPanel = memo( onClick={() => workbenchStore.toggleTerminal(false)} />
- {Array.from({ length: terminalCount }, (_, index) => { + {Array.from({ length: terminalCount + 1 }, (_, index) => { const isActive = activeTerminal === index; + if (index == 0) { + console.log('starting bolt terminal'); + return ( + { + terminalRefs.current.push(ref); + }} + onTerminalReady={(terminal) => workbenchStore.attachBoltTerminal(terminal)} + onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)} + theme={theme} + /> + ); + } return ( \` 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. 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: ... - + npm run dev @@ -322,7 +328,7 @@ Here are some examples of correct usage of artifacts: ... - + npm run dev diff --git a/app/lib/runtime/action-runner.ts b/app/lib/runtime/action-runner.ts index e2ea6a2..3f03bb1 100644 --- a/app/lib/runtime/action-runner.ts +++ b/app/lib/runtime/action-runner.ts @@ -1,10 +1,12 @@ -import { WebContainer } from '@webcontainer/api'; -import { map, type MapStore } from 'nanostores'; +import { WebContainer, type WebContainerProcess } from '@webcontainer/api'; +import { atom, map, type MapStore } from 'nanostores'; import * as nodePath from 'node:path'; import type { BoltAction } from '~/types/actions'; import { createScopedLogger } from '~/utils/logger'; import { unreachable } from '~/utils/unreachable'; import type { ActionCallbackData } from './message-parser'; +import type { ITerminal } from '~/types/terminal'; +import type { BoltShell } from '~/utils/shell'; const logger = createScopedLogger('ActionRunner'); @@ -36,11 +38,14 @@ type ActionsMap = MapStore>; export class ActionRunner { #webcontainer: Promise; #currentExecutionPromise: Promise = Promise.resolve(); - + #shellTerminal: () => BoltShell; + runnerId = atom(`${Date.now()}`); actions: ActionsMap = map({}); - constructor(webcontainerPromise: Promise) { + constructor(webcontainerPromise: Promise, getShellTerminal: () => BoltShell) { this.#webcontainer = webcontainerPromise; + this.#shellTerminal = getShellTerminal; + } addAction(data: ActionCallbackData) { @@ -110,6 +115,10 @@ export class ActionRunner { await this.#runFileAction(action); break; } + case 'start': { + await this.#runStartAction(action); + break; + } } this.#updateAction(actionId, { status: action.abortSignal.aborted ? 'aborted' : 'complete' }); @@ -125,28 +134,35 @@ export class ActionRunner { if (action.type !== 'shell') { 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) + if (resp?.exitCode != 0) { + throw new Error("Failed To Start Application"); - const webcontainer = await this.#webcontainer; + } + } - const process = await webcontainer.spawn('jsh', ['-c', action.content], { - env: { npm_config_yes: true }, - }); + async #runStartAction(action: ActionState) { + 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) + if (resp?.exitCode != 0) { + throw new Error("Failed To Start Application"); - action.abortSignal.addEventListener('abort', () => { - process.kill(); - }); - - 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) { @@ -177,6 +193,23 @@ export class ActionRunner { logger.error('Failed to write file\n\n', error); } } + async getCurrentExecutionResult(output: ReadableStreamDefaultReader) { + let fullOutput = ''; + let exitCode: number = 0; + while (true) { + const { value, done } = await output.read(); + if (done) break; + const text = value || ''; + fullOutput += text; + // Check if command completion signal with exit code + const exitMatch = fullOutput.match(/\]654;exit=-?\d+:(\d+)/); + if (exitMatch) { + exitCode = parseInt(exitMatch[1], 10); + break; + } + } + return { output: fullOutput, exitCode }; + } #updateAction(id: string, newState: ActionStateUpdate) { const actions = this.actions.get(); diff --git a/app/lib/runtime/message-parser.ts b/app/lib/runtime/message-parser.ts index 317f81d..070841c 100644 --- a/app/lib/runtime/message-parser.ts +++ b/app/lib/runtime/message-parser.ts @@ -54,7 +54,7 @@ interface MessageState { export class StreamingMessageParser { #messages = new Map(); - constructor(private _options: StreamingMessageParserOptions = {}) {} + constructor(private _options: StreamingMessageParserOptions = {}) { } parse(messageId: string, input: string) { let state = this.#messages.get(messageId); @@ -256,7 +256,7 @@ export class StreamingMessageParser { } (actionAttributes as FileAction).filePath = filePath; - } else if (actionType !== 'shell') { + } else if (!(['shell', 'start'].includes(actionType))) { logger.warn(`Unknown action type '${actionType}'`); } diff --git a/app/lib/stores/terminal.ts b/app/lib/stores/terminal.ts index 419320e..b2537cc 100644 --- a/app/lib/stores/terminal.ts +++ b/app/lib/stores/terminal.ts @@ -1,14 +1,15 @@ import type { WebContainer, WebContainerProcess } from '@webcontainer/api'; import { atom, type WritableAtom } from 'nanostores'; import type { ITerminal } from '~/types/terminal'; -import { newShellProcess } from '~/utils/shell'; +import { newBoltShellProcess, newShellProcess } from '~/utils/shell'; import { coloredText } from '~/utils/terminal'; export class TerminalStore { #webcontainer: Promise; #terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = []; + #boltTerminal = newBoltShellProcess() - showTerminal: WritableAtom = import.meta.hot?.data.showTerminal ?? atom(false); + showTerminal: WritableAtom = import.meta.hot?.data.showTerminal ?? atom(true); constructor(webcontainerPromise: Promise) { this.#webcontainer = webcontainerPromise; @@ -17,10 +18,22 @@ export class TerminalStore { import.meta.hot.data.showTerminal = this.showTerminal; } } + get boltTerminal() { + return this.#boltTerminal; + } toggleTerminal(value?: boolean) { 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) { try { diff --git a/app/lib/stores/workbench.ts b/app/lib/stores/workbench.ts index c42cc62..b71f35f 100644 --- a/app/lib/stores/workbench.ts +++ b/app/lib/stores/workbench.ts @@ -12,6 +12,7 @@ import { TerminalStore } from './terminal'; import JSZip from 'jszip'; import { saveAs } from 'file-saver'; import { Octokit } from "@octokit/rest"; +import type { WebContainerProcess } from '@webcontainer/api'; export interface ArtifactState { id: string; @@ -39,6 +40,7 @@ export class WorkbenchStore { unsavedFiles: WritableAtom> = import.meta.hot?.data.unsavedFiles ?? atom(new Set()); modifiedFiles = new Set(); artifactIdList: string[] = []; + #boltTerminal: { terminal: ITerminal; process: WebContainerProcess } | undefined; constructor() { if (import.meta.hot) { @@ -76,6 +78,9 @@ export class WorkbenchStore { get showTerminal() { return this.#terminalStore.showTerminal; } + get boltTerminal() { + return this.#terminalStore.boltTerminal; + } toggleTerminal(value?: boolean) { this.#terminalStore.toggleTerminal(value); @@ -84,6 +89,10 @@ export class WorkbenchStore { attachTerminal(terminal: ITerminal) { this.#terminalStore.attachTerminal(terminal); } + attachBoltTerminal(terminal: ITerminal) { + + this.#terminalStore.attachBoltTerminal(terminal); + } onTerminalResize(cols: number, rows: number) { this.#terminalStore.onTerminalResize(cols, rows); @@ -232,7 +241,7 @@ export class WorkbenchStore { id, title, 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) { - + try { // Get the GitHub auth token from environment variables const githubToken = ghToken; - + const owner = githubUsername; - + if (!githubToken) { throw new Error('GitHub token is not set in environment variables'); } - + // Initialize Octokit with the auth token const octokit = new Octokit({ auth: githubToken }); - + // Check if the repository already exists before creating it let repo try { @@ -368,13 +377,13 @@ export class WorkbenchStore { throw error; // Some other error occurred } } - + // Get all files const files = this.files.get(); if (!files || Object.keys(files).length === 0) { throw new Error('No files found to push'); } - + // Create blobs for each file const blobs = await Promise.all( Object.entries(files).map(async ([filePath, dirent]) => { @@ -389,13 +398,13 @@ export class WorkbenchStore { } }) ); - + const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs - + if (validBlobs.length === 0) { throw new Error('No valid files to push'); } - + // Get the latest commit SHA (assuming main branch, update dynamically if needed) const { data: ref } = await octokit.git.getRef({ owner: repo.owner.login, @@ -403,7 +412,7 @@ export class WorkbenchStore { ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch }); const latestCommitSha = ref.object.sha; - + // Create a new tree const { data: newTree } = await octokit.git.createTree({ owner: repo.owner.login, @@ -416,7 +425,7 @@ export class WorkbenchStore { sha: blob!.sha, })), }); - + // Create a new commit const { data: newCommit } = await octokit.git.createCommit({ owner: repo.owner.login, @@ -425,7 +434,7 @@ export class WorkbenchStore { tree: newTree.sha, parents: [latestCommitSha], }); - + // Update the reference await octokit.git.updateRef({ owner: repo.owner.login, @@ -433,7 +442,7 @@ export class WorkbenchStore { ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch sha: newCommit.sha, }); - + alert(`Repository created and code pushed: ${repo.html_url}`); } catch (error) { console.error('Error pushing to GitHub:', error instanceof Error ? error.message : String(error)); diff --git a/app/types/actions.ts b/app/types/actions.ts index b81127a..08c1f39 100644 --- a/app/types/actions.ts +++ b/app/types/actions.ts @@ -13,6 +13,10 @@ export interface ShellAction extends BaseAction { 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; diff --git a/app/types/terminal.ts b/app/types/terminal.ts index 75ae3a3..48e50b4 100644 --- a/app/types/terminal.ts +++ b/app/types/terminal.ts @@ -5,4 +5,5 @@ export interface ITerminal { reset: () => void; write: (data: string) => void; onData: (cb: (data: string) => void) => void; + input: (data: string) => void; } diff --git a/app/utils/shell.ts b/app/utils/shell.ts index 1c5c834..3d4e887 100644 --- a/app/utils/shell.ts +++ b/app/utils/shell.ts @@ -1,6 +1,7 @@ -import type { WebContainer } from '@webcontainer/api'; +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[] = []; @@ -19,7 +20,6 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer const jshReady = withResolvers(); let isInteractive = false; - output.pipeTo( new WritableStream({ write(data) { @@ -40,6 +40,8 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer ); terminal.onData((data) => { + // console.log('terminal onData', { data, isInteractive }); + if (isInteractive) { input.write(data); } @@ -49,3 +51,129 @@ export async function newShellProcess(webcontainer: WebContainer, terminal: ITer return process; } + + + +export class BoltShell { + #initialized: (() => void) | undefined + #readyPromise: Promise + #webcontainer: WebContainer | undefined + #terminal: ITerminal | undefined + #process: WebContainerProcess | undefined + executionState = atom<{ sessionId: string, active: boolean } | undefined>() + #outputStream: ReadableStream | 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 + 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() + if (state && state.sessionId !== sessionId && state.active) { + this.terminal.input('\x03'); + } + this.executionState.set({ sessionId, active: true }) + this.terminal.input(command.trim() + '\n'); + let resp = await this.getCurrentExecutionResult() + 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(); + 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() { + let fullOutput = ''; + let exitCode: number = 0; + if (!this.#outputStream) return; + let tappedStream = this.#outputStream.getReader() + + 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 exitMatch = fullOutput.match(/\]654;exit=-?\d+:(\d+)/); + if (exitMatch) { + console.log(exitMatch); + exitCode = parseInt(exitMatch[1], 10); + tappedStream.releaseLock() + break; + } + } + return { output: fullOutput, exitCode }; + } +} +export function newBoltShellProcess() { + return new BoltShell(); +}