feat: add terminal and simple shortcut system (#16)

This commit is contained in:
Dominic Elm 2024-07-29 14:37:23 +02:00 committed by GitHub
parent d35f64eb1d
commit 8486d85f64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 696 additions and 111 deletions

View File

@ -3,7 +3,7 @@ import { useChat } from 'ai/react';
import { useAnimate } from 'framer-motion'; import { useAnimate } from 'framer-motion';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { cssTransition, toast, ToastContainer } from 'react-toastify'; import { cssTransition, toast, ToastContainer } from 'react-toastify';
import { useMessageParser, usePromptEnhancer, useSnapScroll } from '~/lib/hooks'; import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
import { useChatHistory } from '~/lib/persistence'; import { useChatHistory } from '~/lib/persistence';
import { chatStore } from '~/lib/stores/chat'; import { chatStore } from '~/lib/stores/chat';
import { workbenchStore } from '~/lib/stores/workbench'; import { workbenchStore } from '~/lib/stores/workbench';
@ -25,7 +25,7 @@ export function Chat() {
return ( return (
<> <>
{ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />} {ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
<ToastContainer position="bottom-right" stacked pauseOnFocusLoss transition={toastAnimation} />; <ToastContainer position="bottom-right" stacked pauseOnFocusLoss transition={toastAnimation} />
</> </>
); );
} }
@ -36,6 +36,8 @@ interface ChatProps {
} }
export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) { export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) {
useShortcuts();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [chatStarted, setChatStarted] = useState(initialMessages.length > 0); const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);

View File

@ -0,0 +1,15 @@
import { memo } from 'react';
import { classNames } from '~/utils/classNames';
interface PanelHeaderProps {
className?: string;
children: React.ReactNode;
}
export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
return (
<div className={classNames('flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px]', className)}>
{children}
</div>
);
});

View File

@ -1,6 +1,6 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { memo, useMemo } from 'react'; import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
import { import {
CodeMirrorEditor, CodeMirrorEditor,
type EditorDocument, type EditorDocument,
@ -9,12 +9,16 @@ import {
type OnSaveCallback as OnEditorSave, type OnSaveCallback as OnEditorSave,
type OnScrollCallback as OnEditorScroll, type OnScrollCallback as OnEditorScroll,
} from '~/components/editor/codemirror/CodeMirrorEditor'; } from '~/components/editor/codemirror/CodeMirrorEditor';
import { PanelHeader } from '~/components/ui/PanelHeader';
import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton'; import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
import { shortcutEventEmitter } from '~/lib/hooks';
import type { FileMap } from '~/lib/stores/files'; import type { FileMap } from '~/lib/stores/files';
import { themeStore } from '~/lib/stores/theme'; import { themeStore } from '~/lib/stores/theme';
import { workbenchStore } from '~/lib/stores/workbench';
import { renderLogger } from '~/utils/logger'; import { renderLogger } from '~/utils/logger';
import { isMobile } from '~/utils/mobile'; import { isMobile } from '~/utils/mobile';
import { FileTreePanel } from './FileTreePanel'; import { FileTreePanel } from './FileTreePanel';
import { Terminal, type TerminalRef } from './terminal/Terminal';
interface EditorPanelProps { interface EditorPanelProps {
files?: FileMap; files?: FileMap;
@ -29,6 +33,9 @@ interface EditorPanelProps {
onFileReset?: () => void; onFileReset?: () => void;
} }
const DEFAULT_TERMINAL_SIZE = 25;
const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
const editorSettings: EditorSettings = { tabSize: 2 }; const editorSettings: EditorSettings = { tabSize: 2 };
export const EditorPanel = memo( export const EditorPanel = memo(
@ -47,6 +54,13 @@ export const EditorPanel = memo(
renderLogger.trace('EditorPanel'); renderLogger.trace('EditorPanel');
const theme = useStore(themeStore); const theme = useStore(themeStore);
const showTerminal = useStore(workbenchStore.showTerminal);
const terminalRefs = useRef<Array<TerminalRef | null>>([]);
const terminalPanelRef = useRef<ImperativePanelHandle>(null);
const terminalToggledByShortcut = useRef(false);
const [terminalCount] = useState(1);
const activeFile = useMemo(() => { const activeFile = useMemo(() => {
if (!editorDocument) { if (!editorDocument) {
@ -60,14 +74,51 @@ export const EditorPanel = memo(
return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath); return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
}, [editorDocument, unsavedFiles]); }, [editorDocument, unsavedFiles]);
useEffect(() => {
const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
terminalToggledByShortcut.current = true;
});
const unsubscribeFromThemeStore = themeStore.subscribe(() => {
for (const ref of Object.values(terminalRefs.current)) {
ref?.reloadStyles();
}
});
return () => {
unsubscribeFromEventEmitter();
unsubscribeFromThemeStore();
};
}, []);
useEffect(() => {
const { current: terminal } = terminalPanelRef;
if (!terminal) {
return;
}
const isCollapsed = terminal.isCollapsed();
if (!showTerminal && !isCollapsed) {
terminal.collapse();
} else if (showTerminal && isCollapsed) {
terminal.resize(DEFAULT_TERMINAL_SIZE);
}
terminalToggledByShortcut.current = false;
}, [showTerminal]);
return ( return (
<PanelGroup direction="vertical">
<Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
<PanelGroup direction="horizontal"> <PanelGroup direction="horizontal">
<Panel defaultSize={25} minSize={10} collapsible> <Panel defaultSize={25} minSize={10} collapsible>
<div className="flex flex-col border-r h-full"> <div className="flex flex-col border-r h-full">
<div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px]"> <PanelHeader>
<div className="i-ph:tree-structure-duotone shrink-0" /> <div className="i-ph:tree-structure-duotone shrink-0" />
Files Files
</div> </PanelHeader>
<FileTreePanel <FileTreePanel
files={files} files={files}
unsavedFiles={unsavedFiles} unsavedFiles={unsavedFiles}
@ -78,9 +129,9 @@ export const EditorPanel = memo(
</Panel> </Panel>
<PanelResizeHandle /> <PanelResizeHandle />
<Panel className="flex flex-col" defaultSize={75} minSize={20}> <Panel className="flex flex-col" defaultSize={75} minSize={20}>
<div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px] text-sm"> <PanelHeader>
{activeFile && ( {activeFile && (
<div className="flex items-center flex-1"> <div className="flex items-center flex-1 text-sm">
{activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>} {activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
{activeFileUnsaved && ( {activeFileUnsaved && (
<div className="flex gap-1 ml-auto -mr-1.5"> <div className="flex gap-1 ml-auto -mr-1.5">
@ -96,7 +147,7 @@ export const EditorPanel = memo(
)} )}
</div> </div>
)} )}
</div> </PanelHeader>
<div className="h-full flex-1 overflow-hidden"> <div className="h-full flex-1 overflow-hidden">
<CodeMirrorEditor <CodeMirrorEditor
theme={theme} theme={theme}
@ -111,6 +162,48 @@ export const EditorPanel = memo(
</div> </div>
</Panel> </Panel>
</PanelGroup> </PanelGroup>
</Panel>
<PanelResizeHandle />
<Panel
ref={terminalPanelRef}
defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
minSize={10}
collapsible
onExpand={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(true);
}
}}
onCollapse={() => {
if (!terminalToggledByShortcut.current) {
workbenchStore.toggleTerminal(false);
}
}}
>
<div className="border-t h-full">
<PanelHeader>
<span className="i-ph:terminal-window-duotone shrink-0" /> Terminal
</PanelHeader>
<div className="p-3.5">
{Array.from({ length: terminalCount }, (_, index) => {
return (
<div key={index} className="h-full">
<Terminal
ref={(ref) => {
terminalRefs.current.push(ref);
}}
onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
className="h-full"
theme={theme}
/>
</div>
);
})}
</div>
</div>
</Panel>
</PanelGroup>
); );
}, },
); );

View File

@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import { AnimatePresence, motion, type HTMLMotionProps, type Variants } from 'framer-motion'; import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
import { computed } from 'nanostores'; import { computed } from 'nanostores';
import { memo, useCallback, useEffect, useState } from 'react'; import { memo, useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@ -98,9 +98,7 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
return ( return (
chatStarted && ( chatStarted && (
<AnimatePresence> <motion.div initial="closed" animate={showWorkbench ? 'open' : 'closed'} variants={workbenchVariants}>
{showWorkbench && (
<motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
<div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0"> <div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
<div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8"> <div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
<div className="flex items-center px-3 py-2 border-b border-gray-200"> <div className="flex items-center px-3 py-2 border-b border-gray-200">
@ -142,8 +140,6 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
</div> </div>
</div> </div>
</motion.div> </motion.div>
)}
</AnimatePresence>
) )
); );
}); });

View File

@ -0,0 +1,83 @@
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Terminal as XTerm } from '@xterm/xterm';
import { forwardRef, memo, useEffect, useImperativeHandle, useRef } from 'react';
import type { Theme } from '~/lib/stores/theme';
import { getTerminalTheme } from './theme';
import '@xterm/xterm/css/xterm.css';
export interface TerminalRef {
reloadStyles: () => void;
}
export interface TerminalProps {
className?: string;
theme: Theme;
readonly?: boolean;
onTerminalReady?: (terminal: XTerm) => void;
onTerminalResize?: (cols: number, rows: number) => void;
}
export const Terminal = memo(
forwardRef<TerminalRef, TerminalProps>(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => {
const terminalElementRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<XTerm>();
useEffect(() => {
const element = terminalElementRef.current!;
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon();
const terminal = new XTerm({
cursorBlink: true,
convertEol: true,
disableStdin: readonly,
theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
fontSize: 13,
fontFamily: 'Menlo, courier-new, courier, monospace',
});
terminalRef.current = terminal;
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
terminal.open(element);
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
onTerminalResize?.(terminal.cols, terminal.rows);
});
resizeObserver.observe(element);
onTerminalReady?.(terminal);
return () => {
resizeObserver.disconnect();
terminal.dispose();
};
}, []);
useEffect(() => {
const terminal = terminalRef.current!;
// we render a transparent cursor in case the terminal is readonly
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
terminal.options.disableStdin = readonly;
}, [theme, readonly]);
useImperativeHandle(ref, () => {
return {
reloadStyles: () => {
const terminal = terminalRef.current!;
terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
},
};
}, []);
return <div className={className} ref={terminalElementRef} />;
}),
);

View File

@ -0,0 +1,36 @@
import type { ITheme } from '@xterm/xterm';
const style = getComputedStyle(document.documentElement);
const cssVar = (token: string) => style.getPropertyValue(token) || undefined;
export function getTerminalTheme(overrides?: ITheme): ITheme {
return {
cursor: cssVar('--bolt-elements-terminal-cursorColor'),
cursorAccent: cssVar('--bolt-elements-terminal-cursorColorAccent'),
foreground: cssVar('--bolt-elements-terminal-textColor'),
background: cssVar('--bolt-elements-terminal-backgroundColor'),
selectionBackground: cssVar('--bolt-elements-terminal-selection-backgroundColor'),
selectionForeground: cssVar('--bolt-elements-terminal-selection-textColor'),
selectionInactiveBackground: cssVar('--bolt-elements-terminal-selection-backgroundColorInactive'),
// ansi escape code colors
black: cssVar('--bolt-elements-terminal-color-black'),
red: cssVar('--bolt-elements-terminal-color-red'),
green: cssVar('--bolt-elements-terminal-color-green'),
yellow: cssVar('--bolt-elements-terminal-color-yellow'),
blue: cssVar('--bolt-elements-terminal-color-blue'),
magenta: cssVar('--bolt-elements-terminal-color-magenta'),
cyan: cssVar('--bolt-elements-terminal-color-cyan'),
white: cssVar('--bolt-elements-terminal-color-white'),
brightBlack: cssVar('--bolt-elements-terminal-color-brightBlack'),
brightRed: cssVar('--bolt-elements-terminal-color-brightRed'),
brightGreen: cssVar('--bolt-elements-terminal-color-brightGreen'),
brightYellow: cssVar('--bolt-elements-terminal-color-brightYellow'),
brightBlue: cssVar('--bolt-elements-terminal-color-brightBlue'),
brightMagenta: cssVar('--bolt-elements-terminal-color-brightMagenta'),
brightCyan: cssVar('--bolt-elements-terminal-color-brightCyan'),
brightWhite: cssVar('--bolt-elements-terminal-color-brightWhite'),
...overrides,
};
}

View File

@ -1,12 +1,7 @@
import { RemixBrowser } from '@remix-run/react'; import { RemixBrowser } from '@remix-run/react';
import { startTransition, StrictMode } from 'react'; import { startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client'; import { hydrateRoot } from 'react-dom/client';
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(document, <RemixBrowser />);
document,
<StrictMode>
<RemixBrowser />
</StrictMode>,
);
}); });

View File

@ -13,6 +13,10 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
IMPORTANT: Git is NOT available. IMPORTANT: Git is NOT available.
IMPORTANT: Prefer writing Node.js scripts instead of shell scripts. The environment doesn't fully support shell scripts, so use Node.js for scripting tasks whenever possible!
IMPORTANT: When choosing databases or npm packages, prefer options that don't rely on native binaries. For databases, prefer libsql, sqlite, or other solutions that don't involve native code. WebContainer CANNOT execute arbitrary native binaries.
Available shell commands: cat, chmod, cp, echo, hostname, kill, ln, ls, mkdir, mv, ps, pwd, rm, rmdir, xxd, alias, cd, clear, curl, env, false, getconf, head, sort, tail, touch, true, uptime, which, code, jq, loadenv, node, python3, wasm, xdg-open, command, exit, export, source Available shell commands: cat, chmod, cp, echo, hostname, kill, ln, ls, mkdir, mv, ps, pwd, rm, rmdir, xxd, alias, cd, clear, curl, env, false, getconf, head, sort, tail, touch, true, uptime, which, code, jq, loadenv, node, python3, wasm, xdg-open, command, exit, export, source
</system_constraints> </system_constraints>
@ -92,7 +96,7 @@ 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.
- 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 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.
- 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.

View File

@ -1,3 +1,4 @@
export * from './useMessageParser'; export * from './useMessageParser';
export * from './usePromptEnhancer'; export * from './usePromptEnhancer';
export * from './useShortcuts';
export * from './useSnapScroll'; export * from './useSnapScroll';

View File

@ -0,0 +1,56 @@
import { useStore } from '@nanostores/react';
import { useEffect } from 'react';
import { shortcutsStore, type Shortcuts } from '~/lib/stores/settings';
class ShortcutEventEmitter {
#emitter = new EventTarget();
dispatch(type: keyof Shortcuts) {
this.#emitter.dispatchEvent(new Event(type));
}
on(type: keyof Shortcuts, cb: VoidFunction) {
this.#emitter.addEventListener(type, cb);
return () => {
this.#emitter.removeEventListener(type, cb);
};
}
}
export const shortcutEventEmitter = new ShortcutEventEmitter();
export function useShortcuts(): void {
const shortcuts = useStore(shortcutsStore);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
const { key, ctrlKey, shiftKey, altKey, metaKey } = event;
for (const name in shortcuts) {
const shortcut = shortcuts[name as keyof Shortcuts];
if (
shortcut.key.toLowerCase() === key.toLowerCase() &&
(shortcut.ctrlOrMetaKey
? ctrlKey || metaKey
: (shortcut.ctrlKey === undefined || shortcut.ctrlKey === ctrlKey) &&
(shortcut.metaKey === undefined || shortcut.metaKey === metaKey)) &&
(shortcut.shiftKey === undefined || shortcut.shiftKey === shiftKey) &&
(shortcut.altKey === undefined || shortcut.altKey === altKey)
) {
shortcutEventEmitter.dispatch(name as keyof Shortcuts);
event.preventDefault();
shortcut.action();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [shortcuts]);
}

View File

@ -0,0 +1,39 @@
import { map } from 'nanostores';
import { workbenchStore } from './workbench';
export interface Shortcut {
key: string;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
ctrlOrMetaKey?: boolean;
action: () => void;
}
export interface Shortcuts {
toggleTerminal: Shortcut;
}
export interface Settings {
shortcuts: Shortcuts;
}
export const shortcutsStore = map<Shortcuts>({
toggleTerminal: {
key: 'j',
ctrlOrMetaKey: true,
action: () => workbenchStore.toggleTerminal(),
},
});
export const settingsStore = map<Settings>({
shortcuts: shortcutsStore.get(),
});
shortcutsStore.subscribe((shortcuts) => {
settingsStore.set({
...settingsStore.get(),
shortcuts,
});
});

View File

@ -0,0 +1,40 @@
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 { coloredText } from '~/utils/terminal';
export class TerminalStore {
#webcontainer: Promise<WebContainer>;
#terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = [];
showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(false);
constructor(webcontainerPromise: Promise<WebContainer>) {
this.#webcontainer = webcontainerPromise;
if (import.meta.hot) {
import.meta.hot.data.showTerminal = this.showTerminal;
}
}
toggleTerminal(value?: boolean) {
this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get());
}
async attachTerminal(terminal: ITerminal) {
try {
const shellProcess = await newShellProcess(await this.#webcontainer, terminal);
this.#terminals.push({ terminal, process: shellProcess });
} catch (error: any) {
terminal.write(coloredText.red('Failed to spawn shell\n\n') + error.message);
return;
}
}
onTerminalResize(cols: number, rows: number) {
for (const { process } of this.#terminals) {
process.resize({ cols, rows });
}
}
}

View File

@ -8,6 +8,8 @@ export function themeIsDark() {
return themeStore.get() === 'dark'; return themeStore.get() === 'dark';
} }
export const DEFAULT_THEME = 'light';
export const themeStore = atom<Theme>(initStore()); export const themeStore = atom<Theme>(initStore());
function initStore() { function initStore() {
@ -15,10 +17,10 @@ function initStore() {
const persistedTheme = localStorage.getItem(kTheme) as Theme | undefined; const persistedTheme = localStorage.getItem(kTheme) as Theme | undefined;
const themeAttribute = document.querySelector('html')?.getAttribute('data-theme'); const themeAttribute = document.querySelector('html')?.getAttribute('data-theme');
return persistedTheme ?? (themeAttribute as Theme) ?? 'light'; return persistedTheme ?? (themeAttribute as Theme) ?? DEFAULT_THEME;
} }
return 'light'; return DEFAULT_THEME;
} }
export function toggleTheme() { export function toggleTheme() {

View File

@ -3,10 +3,12 @@ import type { EditorDocument, ScrollPosition } from '~/components/editor/codemir
import { ActionRunner } from '~/lib/runtime/action-runner'; import { ActionRunner } from '~/lib/runtime/action-runner';
import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser'; import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
import { webcontainer } from '~/lib/webcontainer'; import { webcontainer } from '~/lib/webcontainer';
import type { ITerminal } from '~/types/terminal';
import { unreachable } from '~/utils/unreachable'; import { unreachable } from '~/utils/unreachable';
import { EditorStore } from './editor'; import { EditorStore } from './editor';
import { FilesStore, type FileMap } from './files'; import { FilesStore, type FileMap } from './files';
import { PreviewsStore } from './previews'; import { PreviewsStore } from './previews';
import { TerminalStore } from './terminal';
export interface ArtifactState { export interface ArtifactState {
title: string; title: string;
@ -22,6 +24,7 @@ export class WorkbenchStore {
#previewsStore = new PreviewsStore(webcontainer); #previewsStore = new PreviewsStore(webcontainer);
#filesStore = new FilesStore(webcontainer); #filesStore = new FilesStore(webcontainer);
#editorStore = new EditorStore(this.#filesStore); #editorStore = new EditorStore(this.#filesStore);
#terminalStore = new TerminalStore(webcontainer);
artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({}); artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false); showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
@ -53,6 +56,22 @@ export class WorkbenchStore {
return this.#editorStore.selectedFile; return this.#editorStore.selectedFile;
} }
get showTerminal() {
return this.#terminalStore.showTerminal;
}
toggleTerminal(value?: boolean) {
this.#terminalStore.toggleTerminal(value);
}
attachTerminal(terminal: ITerminal) {
this.#terminalStore.attachTerminal(terminal);
}
onTerminalResize(cols: number, rows: number) {
this.#terminalStore.onTerminalResize(cols, rows);
}
setDocuments(files: FileMap) { setDocuments(files: FileMap) {
this.#editorStore.setDocuments(files); this.#editorStore.setDocuments(files);

View File

@ -0,0 +1,28 @@
[data-resize-handle] {
position: relative;
&[data-panel-group-direction='horizontal']:after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -6px;
right: -5px;
z-index: $zIndexMax;
}
&[data-panel-group-direction='vertical']:after {
content: '';
position: absolute;
left: 0;
right: 0;
top: -5px;
bottom: -6px;
z-index: $zIndexMax;
}
&[data-resize-handle-state='hover']:after,
&[data-resize-handle-state='drag']:after {
background-color: #8882;
}
}

View File

@ -0,0 +1,3 @@
.xterm {
height: 100%;
}

View File

@ -1,5 +1,8 @@
@import './variables.scss'; @import './variables.scss';
@import './z-index.scss';
@import './animations.scss'; @import './animations.scss';
@import './components/terminal.scss';
@import './components/resize-handle.scss';
body { body {
--at-apply: bg-bolt-elements-app-backgroundColor; --at-apply: bg-bolt-elements-app-backgroundColor;

View File

@ -20,12 +20,72 @@
--bolt-border-primary: theme('colors.gray.200'); --bolt-border-primary: theme('colors.gray.200');
--bolt-border-accent: theme('colors.accent.600'); --bolt-border-accent: theme('colors.accent.600');
/* Terminal Colors */
--bolt-terminal-background: var(--bolt-background-primary);
--bolt-terminal-foreground: #333333;
--bolt-terminal-selection-background: #00000040;
--bolt-terminal-black: #000000;
--bolt-terminal-red: #cd3131;
--bolt-terminal-green: #00bc00;
--bolt-terminal-yellow: #949800;
--bolt-terminal-blue: #0451a5;
--bolt-terminal-magenta: #bc05bc;
--bolt-terminal-cyan: #0598bc;
--bolt-terminal-white: #555555;
--bolt-terminal-brightBlack: #686868;
--bolt-terminal-brightRed: #cd3131;
--bolt-terminal-brightGreen: #00bc00;
--bolt-terminal-brightYellow: #949800;
--bolt-terminal-brightBlue: #0451a5;
--bolt-terminal-brightMagenta: #bc05bc;
--bolt-terminal-brightCyan: #0598bc;
--bolt-terminal-brightWhite: #a5a5a5;
} }
/* Color Tokens Dark Theme */ /* Color Tokens Dark Theme */
:root, :root,
:root[data-theme='dark'] { :root[data-theme='dark'] {
--bolt-background-primary: theme('colors.gray.50'); --bolt-background-primary: theme('colors.gray.0');
--bolt-background-secondary: theme('colors.gray.50');
--bolt-background-active: theme('colors.gray.200');
--bolt-background-accent: theme('colors.accent.600');
--bolt-background-accent-secondary: theme('colors.accent.600');
--bolt-background-accent-active: theme('colors.accent.500');
--bolt-text-primary: theme('colors.gray.800');
--bolt-text-primary-inverted: theme('colors.gray.0');
--bolt-text-secondary: theme('colors.gray.600');
--bolt-text-secondary-inverted: theme('colors.gray.200');
--bolt-text-disabled: theme('colors.gray.400');
--bolt-text-accent: theme('colors.accent.600');
--bolt-text-positive: theme('colors.positive.700');
--bolt-text-warning: theme('colors.warning.600');
--bolt-text-negative: theme('colors.negative.600');
--bolt-border-primary: theme('colors.gray.200');
--bolt-border-accent: theme('colors.accent.600');
/* Terminal Colors */
--bolt-terminal-background: #16181d;
--bolt-terminal-foreground: #eff0eb;
--bolt-terminal-selection-background: #97979b33;
--bolt-terminal-black: #000000;
--bolt-terminal-red: #ff5c57;
--bolt-terminal-green: #5af78e;
--bolt-terminal-yellow: #f3f99d;
--bolt-terminal-blue: #57c7ff;
--bolt-terminal-magenta: #ff6ac1;
--bolt-terminal-cyan: #9aedfe;
--bolt-terminal-white: #f1f1f0;
--bolt-terminal-brightBlack: #686868;
--bolt-terminal-brightRed: #ff5c57;
--bolt-terminal-brightGreen: #5af78e;
--bolt-terminal-brightYellow: #f3f99d;
--bolt-terminal-brightBlue: #57c7ff;
--bolt-terminal-brightMagenta: #ff6ac1;
--bolt-terminal-brightCyan: #9aedfe;
--bolt-terminal-brightWhite: #f1f1f0;
} }
/* /*
@ -36,9 +96,33 @@
:root { :root {
--header-height: 65px; --header-height: 65px;
--z-index-max: 999;
/* App */ /* App */
--bolt-elements-app-backgroundColor: var(--bolt-background-primary); --bolt-elements-app-backgroundColor: var(--bolt-background-primary);
--bolt-elements-app-borderColor: var(--bolt-border-primary); --bolt-elements-app-borderColor: var(--bolt-border-primary);
--bolt-elements-app-textColor: var(--bolt-text-primary); --bolt-elements-app-textColor: var(--bolt-text-primary);
--bolt-elements-app-linkColor: var(--bolt-text-accent); --bolt-elements-app-linkColor: var(--bolt-text-accent);
/* Terminal */
--bolt-elements-terminal-backgroundColor: var(--bolt-terminal-background);
--bolt-elements-terminal-textColor: var(--bolt-terminal-foreground);
--bolt-elements-terminal-cursorColor: var(--bolt-terminal-foreground);
--bolt-elements-terminal-selection-backgroundColor: var(--bolt-terminal-selection-background);
--bolt-elements-terminal-color-black: var(--bolt-terminal-black);
--bolt-elements-terminal-color-red: var(--bolt-terminal-red);
--bolt-elements-terminal-color-green: var(--bolt-terminal-green);
--bolt-elements-terminal-color-yellow: var(--bolt-terminal-yellow);
--bolt-elements-terminal-color-blue: var(--bolt-terminal-blue);
--bolt-elements-terminal-color-magenta: var(--bolt-terminal-magenta);
--bolt-elements-terminal-color-cyan: var(--bolt-terminal-cyan);
--bolt-elements-terminal-color-white: var(--bolt-terminal-white);
--bolt-elements-terminal-color-brightBlack: var(--bolt-terminal-brightBlack);
--bolt-elements-terminal-color-brightRed: var(--bolt-terminal-brightRed);
--bolt-elements-terminal-color-brightGreen: var(--bolt-terminal-brightGreen);
--bolt-elements-terminal-color-brightYellow: var(--bolt-terminal-brightYellow);
--bolt-elements-terminal-color-brightBlue: var(--bolt-terminal-brightBlue);
--bolt-elements-terminal-color-brightMagenta: var(--bolt-terminal-brightMagenta);
--bolt-elements-terminal-color-brightCyan: var(--bolt-terminal-brightCyan);
--bolt-elements-terminal-color-brightWhite: var(--bolt-terminal-brightWhite);
} }

View File

@ -0,0 +1 @@
$zIndexMax: 999;

View File

@ -0,0 +1,8 @@
export interface ITerminal {
readonly cols?: number;
readonly rows?: number;
reset: () => void;
write: (data: string) => void;
onData: (cb: (data: string) => void) => void;
}

View File

@ -0,0 +1,51 @@
import type { WebContainer } from '@webcontainer/api';
import type { ITerminal } from '~/types/terminal';
import { withResolvers } from './promises';
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<void>();
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) => {
if (isInteractive) {
input.write(data);
}
});
await jshReady.promise;
return process;
}

View File

@ -0,0 +1,11 @@
const reset = '\x1b[0m';
export const escapeCodes = {
reset,
clear: '\x1b[g',
red: '\x1b[1;31m',
};
export const coloredText = {
red: (text: string) => `${escapeCodes.red}${text}${reset}`,
};

View File

@ -39,7 +39,7 @@
"@remix-run/react": "^2.10.2", "@remix-run/react": "^2.10.2",
"@stackblitz/sdk": "^1.11.0", "@stackblitz/sdk": "^1.11.0",
"@unocss/reset": "^0.61.0", "@unocss/reset": "^0.61.0",
"@webcontainer/api": "^1.3.0-internal.1", "@webcontainer/api": "^1.3.0-internal.2",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0", "@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
@ -51,6 +51,7 @@
"nanostores": "^0.10.3", "nanostores": "^0.10.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.5.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-resizable-panels": "^2.0.20", "react-resizable-panels": "^2.0.20",
"react-toastify": "^10.0.5", "react-toastify": "^10.0.5",

View File

@ -105,8 +105,8 @@ importers:
specifier: ^0.61.0 specifier: ^0.61.0
version: 0.61.0 version: 0.61.0
'@webcontainer/api': '@webcontainer/api':
specifier: ^1.3.0-internal.1 specifier: ^1.3.0-internal.2
version: 1.3.0-internal.1 version: 1.3.0-internal.2
'@xterm/addon-fit': '@xterm/addon-fit':
specifier: ^0.10.0 specifier: ^0.10.0
version: 0.10.0(@xterm/xterm@5.5.0) version: 0.10.0(@xterm/xterm@5.5.0)
@ -140,6 +140,9 @@ importers:
react-dom: react-dom:
specifier: ^18.2.0 specifier: ^18.2.0
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-hotkeys-hook:
specifier: ^4.5.0
version: 4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-markdown: react-markdown:
specifier: ^9.0.1 specifier: ^9.0.1
version: 9.0.1(@types/react@18.3.3)(react@18.3.1) version: 9.0.1(@types/react@18.3.3)(react@18.3.1)
@ -1722,8 +1725,8 @@ packages:
'@web3-storage/multipart-parser@1.0.0': '@web3-storage/multipart-parser@1.0.0':
resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
'@webcontainer/api@1.3.0-internal.1': '@webcontainer/api@1.3.0-internal.2':
resolution: {integrity: sha512-XHveAaZgZItLWieict8xTteBbPLeAwCJLuc80Zq6Mmk0LEWTw8yYZep0dTKbet6bd9MPTQ1+vjPAsEtD0H1fOA==} resolution: {integrity: sha512-lLSlSehbuYc9E7ecK+tMRX4BbWETNX1OgRlS+NerQh3X3sHNbxLD86eScEMAiA5VBnUeSnLtLe7eC/ftM8fR3Q==}
'@xterm/addon-fit@0.10.0': '@xterm/addon-fit@0.10.0':
resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
@ -4090,6 +4093,12 @@ packages:
peerDependencies: peerDependencies:
react: ^18.3.1 react: ^18.3.1
react-hotkeys-hook@4.5.0:
resolution: {integrity: sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==}
peerDependencies:
react: '>=16.8.1'
react-dom: '>=16.8.1'
react-is@18.3.1: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@ -6814,7 +6823,7 @@ snapshots:
'@web3-storage/multipart-parser@1.0.0': {} '@web3-storage/multipart-parser@1.0.0': {}
'@webcontainer/api@1.3.0-internal.1': {} '@webcontainer/api@1.3.0-internal.2': {}
'@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)':
dependencies: dependencies:
@ -9748,6 +9757,11 @@ snapshots:
react: 18.3.1 react: 18.3.1
scheduler: 0.23.2 scheduler: 0.23.2
react-hotkeys-hook@4.5.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-is@18.3.1: {} react-is@18.3.1: {}
react-markdown@9.0.1(@types/react@18.3.3)(react@18.3.1): react-markdown@9.0.1(@types/react@18.3.3)(react@18.3.1):