Dominic Elm commited on
Commit
8486d85
·
unverified ·
1 Parent(s): d35f64e

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

Browse files
packages/bolt/app/components/chat/Chat.client.tsx CHANGED
@@ -3,7 +3,7 @@ import { useChat } from 'ai/react';
3
  import { useAnimate } from 'framer-motion';
4
  import { useEffect, useRef, useState } from 'react';
5
  import { cssTransition, toast, ToastContainer } from 'react-toastify';
6
- import { useMessageParser, usePromptEnhancer, useSnapScroll } from '~/lib/hooks';
7
  import { useChatHistory } from '~/lib/persistence';
8
  import { chatStore } from '~/lib/stores/chat';
9
  import { workbenchStore } from '~/lib/stores/workbench';
@@ -25,7 +25,7 @@ export function Chat() {
25
  return (
26
  <>
27
  {ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
28
- <ToastContainer position="bottom-right" stacked pauseOnFocusLoss transition={toastAnimation} />;
29
  </>
30
  );
31
  }
@@ -36,6 +36,8 @@ interface ChatProps {
36
  }
37
 
38
  export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) {
 
 
39
  const textareaRef = useRef<HTMLTextAreaElement>(null);
40
 
41
  const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
 
3
  import { useAnimate } from 'framer-motion';
4
  import { useEffect, useRef, useState } from 'react';
5
  import { cssTransition, toast, ToastContainer } from 'react-toastify';
6
+ import { useMessageParser, usePromptEnhancer, useShortcuts, useSnapScroll } from '~/lib/hooks';
7
  import { useChatHistory } from '~/lib/persistence';
8
  import { chatStore } from '~/lib/stores/chat';
9
  import { workbenchStore } from '~/lib/stores/workbench';
 
25
  return (
26
  <>
27
  {ready && <ChatImpl initialMessages={initialMessages} storeMessageHistory={storeMessageHistory} />}
28
+ <ToastContainer position="bottom-right" stacked pauseOnFocusLoss transition={toastAnimation} />
29
  </>
30
  );
31
  }
 
36
  }
37
 
38
  export function ChatImpl({ initialMessages, storeMessageHistory }: ChatProps) {
39
+ useShortcuts();
40
+
41
  const textareaRef = useRef<HTMLTextAreaElement>(null);
42
 
43
  const [chatStarted, setChatStarted] = useState(initialMessages.length > 0);
packages/bolt/app/components/ui/PanelHeader.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { memo } from 'react';
2
+ import { classNames } from '~/utils/classNames';
3
+
4
+ interface PanelHeaderProps {
5
+ className?: string;
6
+ children: React.ReactNode;
7
+ }
8
+
9
+ export const PanelHeader = memo(({ className, children }: PanelHeaderProps) => {
10
+ return (
11
+ <div className={classNames('flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px]', className)}>
12
+ {children}
13
+ </div>
14
+ );
15
+ });
packages/bolt/app/components/workbench/EditorPanel.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useStore } from '@nanostores/react';
2
- import { memo, useMemo } from 'react';
3
- import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
4
  import {
5
  CodeMirrorEditor,
6
  type EditorDocument,
@@ -9,12 +9,16 @@ import {
9
  type OnSaveCallback as OnEditorSave,
10
  type OnScrollCallback as OnEditorScroll,
11
  } from '~/components/editor/codemirror/CodeMirrorEditor';
 
12
  import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
 
13
  import type { FileMap } from '~/lib/stores/files';
14
  import { themeStore } from '~/lib/stores/theme';
 
15
  import { renderLogger } from '~/utils/logger';
16
  import { isMobile } from '~/utils/mobile';
17
  import { FileTreePanel } from './FileTreePanel';
 
18
 
19
  interface EditorPanelProps {
20
  files?: FileMap;
@@ -29,6 +33,9 @@ interface EditorPanelProps {
29
  onFileReset?: () => void;
30
  }
31
 
 
 
 
32
  const editorSettings: EditorSettings = { tabSize: 2 };
33
 
34
  export const EditorPanel = memo(
@@ -47,6 +54,13 @@ export const EditorPanel = memo(
47
  renderLogger.trace('EditorPanel');
48
 
49
  const theme = useStore(themeStore);
 
 
 
 
 
 
 
50
 
51
  const activeFile = useMemo(() => {
52
  if (!editorDocument) {
@@ -60,54 +74,133 @@ export const EditorPanel = memo(
60
  return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
61
  }, [editorDocument, unsavedFiles]);
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  return (
64
- <PanelGroup direction="horizontal">
65
- <Panel defaultSize={25} minSize={10} collapsible>
66
- <div className="flex flex-col border-r h-full">
67
- <div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px]">
68
- <div className="i-ph:tree-structure-duotone shrink-0" />
69
- Files
70
- </div>
71
- <FileTreePanel
72
- files={files}
73
- unsavedFiles={unsavedFiles}
74
- selectedFile={selectedFile}
75
- onFileSelect={onFileSelect}
76
- />
77
- </div>
78
- </Panel>
79
- <PanelResizeHandle />
80
- <Panel className="flex flex-col" defaultSize={75} minSize={20}>
81
- <div className="flex items-center gap-2 bg-gray-50 border-b px-4 py-1 min-h-[34px] text-sm">
82
- {activeFile && (
83
- <div className="flex items-center flex-1">
84
- {activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
85
- {activeFileUnsaved && (
86
- <div className="flex gap-1 ml-auto -mr-1.5">
87
- <PanelHeaderButton onClick={onFileSave}>
88
- <div className="i-ph:floppy-disk-duotone" />
89
- Save
90
- </PanelHeaderButton>
91
- <PanelHeaderButton onClick={onFileReset}>
92
- <div className="i-ph:clock-counter-clockwise-duotone" />
93
- Reset
94
- </PanelHeaderButton>
 
 
 
 
95
  </div>
96
  )}
 
 
 
 
 
 
 
 
 
 
 
 
97
  </div>
98
- )}
99
- </div>
100
- <div className="h-full flex-1 overflow-hidden">
101
- <CodeMirrorEditor
102
- theme={theme}
103
- editable={!isStreaming && editorDocument !== undefined}
104
- settings={editorSettings}
105
- doc={editorDocument}
106
- autoFocusOnDocumentChange={!isMobile()}
107
- onScroll={onEditorScroll}
108
- onChange={onEditorChange}
109
- onSave={onFileSave}
110
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  </div>
112
  </Panel>
113
  </PanelGroup>
 
1
  import { useStore } from '@nanostores/react';
2
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Panel, PanelGroup, PanelResizeHandle, type ImperativePanelHandle } from 'react-resizable-panels';
4
  import {
5
  CodeMirrorEditor,
6
  type EditorDocument,
 
9
  type OnSaveCallback as OnEditorSave,
10
  type OnScrollCallback as OnEditorScroll,
11
  } from '~/components/editor/codemirror/CodeMirrorEditor';
12
+ import { PanelHeader } from '~/components/ui/PanelHeader';
13
  import { PanelHeaderButton } from '~/components/ui/PanelHeaderButton';
14
+ import { shortcutEventEmitter } from '~/lib/hooks';
15
  import type { FileMap } from '~/lib/stores/files';
16
  import { themeStore } from '~/lib/stores/theme';
17
+ import { workbenchStore } from '~/lib/stores/workbench';
18
  import { renderLogger } from '~/utils/logger';
19
  import { isMobile } from '~/utils/mobile';
20
  import { FileTreePanel } from './FileTreePanel';
21
+ import { Terminal, type TerminalRef } from './terminal/Terminal';
22
 
23
  interface EditorPanelProps {
24
  files?: FileMap;
 
33
  onFileReset?: () => void;
34
  }
35
 
36
+ const DEFAULT_TERMINAL_SIZE = 25;
37
+ const DEFAULT_EDITOR_SIZE = 100 - DEFAULT_TERMINAL_SIZE;
38
+
39
  const editorSettings: EditorSettings = { tabSize: 2 };
40
 
41
  export const EditorPanel = memo(
 
54
  renderLogger.trace('EditorPanel');
55
 
56
  const theme = useStore(themeStore);
57
+ const showTerminal = useStore(workbenchStore.showTerminal);
58
+
59
+ const terminalRefs = useRef<Array<TerminalRef | null>>([]);
60
+ const terminalPanelRef = useRef<ImperativePanelHandle>(null);
61
+ const terminalToggledByShortcut = useRef(false);
62
+
63
+ const [terminalCount] = useState(1);
64
 
65
  const activeFile = useMemo(() => {
66
  if (!editorDocument) {
 
74
  return editorDocument !== undefined && unsavedFiles?.has(editorDocument.filePath);
75
  }, [editorDocument, unsavedFiles]);
76
 
77
+ useEffect(() => {
78
+ const unsubscribeFromEventEmitter = shortcutEventEmitter.on('toggleTerminal', () => {
79
+ terminalToggledByShortcut.current = true;
80
+ });
81
+
82
+ const unsubscribeFromThemeStore = themeStore.subscribe(() => {
83
+ for (const ref of Object.values(terminalRefs.current)) {
84
+ ref?.reloadStyles();
85
+ }
86
+ });
87
+
88
+ return () => {
89
+ unsubscribeFromEventEmitter();
90
+ unsubscribeFromThemeStore();
91
+ };
92
+ }, []);
93
+
94
+ useEffect(() => {
95
+ const { current: terminal } = terminalPanelRef;
96
+
97
+ if (!terminal) {
98
+ return;
99
+ }
100
+
101
+ const isCollapsed = terminal.isCollapsed();
102
+
103
+ if (!showTerminal && !isCollapsed) {
104
+ terminal.collapse();
105
+ } else if (showTerminal && isCollapsed) {
106
+ terminal.resize(DEFAULT_TERMINAL_SIZE);
107
+ }
108
+
109
+ terminalToggledByShortcut.current = false;
110
+ }, [showTerminal]);
111
+
112
  return (
113
+ <PanelGroup direction="vertical">
114
+ <Panel defaultSize={showTerminal ? DEFAULT_EDITOR_SIZE : 100} minSize={20}>
115
+ <PanelGroup direction="horizontal">
116
+ <Panel defaultSize={25} minSize={10} collapsible>
117
+ <div className="flex flex-col border-r h-full">
118
+ <PanelHeader>
119
+ <div className="i-ph:tree-structure-duotone shrink-0" />
120
+ Files
121
+ </PanelHeader>
122
+ <FileTreePanel
123
+ files={files}
124
+ unsavedFiles={unsavedFiles}
125
+ selectedFile={selectedFile}
126
+ onFileSelect={onFileSelect}
127
+ />
128
+ </div>
129
+ </Panel>
130
+ <PanelResizeHandle />
131
+ <Panel className="flex flex-col" defaultSize={75} minSize={20}>
132
+ <PanelHeader>
133
+ {activeFile && (
134
+ <div className="flex items-center flex-1 text-sm">
135
+ {activeFile} {isStreaming && <span className="text-xs ml-1 font-semibold">(read-only)</span>}
136
+ {activeFileUnsaved && (
137
+ <div className="flex gap-1 ml-auto -mr-1.5">
138
+ <PanelHeaderButton onClick={onFileSave}>
139
+ <div className="i-ph:floppy-disk-duotone" />
140
+ Save
141
+ </PanelHeaderButton>
142
+ <PanelHeaderButton onClick={onFileReset}>
143
+ <div className="i-ph:clock-counter-clockwise-duotone" />
144
+ Reset
145
+ </PanelHeaderButton>
146
+ </div>
147
+ )}
148
  </div>
149
  )}
150
+ </PanelHeader>
151
+ <div className="h-full flex-1 overflow-hidden">
152
+ <CodeMirrorEditor
153
+ theme={theme}
154
+ editable={!isStreaming && editorDocument !== undefined}
155
+ settings={editorSettings}
156
+ doc={editorDocument}
157
+ autoFocusOnDocumentChange={!isMobile()}
158
+ onScroll={onEditorScroll}
159
+ onChange={onEditorChange}
160
+ onSave={onFileSave}
161
+ />
162
  </div>
163
+ </Panel>
164
+ </PanelGroup>
165
+ </Panel>
166
+ <PanelResizeHandle />
167
+ <Panel
168
+ ref={terminalPanelRef}
169
+ defaultSize={showTerminal ? DEFAULT_TERMINAL_SIZE : 0}
170
+ minSize={10}
171
+ collapsible
172
+ onExpand={() => {
173
+ if (!terminalToggledByShortcut.current) {
174
+ workbenchStore.toggleTerminal(true);
175
+ }
176
+ }}
177
+ onCollapse={() => {
178
+ if (!terminalToggledByShortcut.current) {
179
+ workbenchStore.toggleTerminal(false);
180
+ }
181
+ }}
182
+ >
183
+ <div className="border-t h-full">
184
+ <PanelHeader>
185
+ <span className="i-ph:terminal-window-duotone shrink-0" /> Terminal
186
+ </PanelHeader>
187
+ <div className="p-3.5">
188
+ {Array.from({ length: terminalCount }, (_, index) => {
189
+ return (
190
+ <div key={index} className="h-full">
191
+ <Terminal
192
+ ref={(ref) => {
193
+ terminalRefs.current.push(ref);
194
+ }}
195
+ onTerminalReady={(terminal) => workbenchStore.attachTerminal(terminal)}
196
+ onTerminalResize={(cols, rows) => workbenchStore.onTerminalResize(cols, rows)}
197
+ className="h-full"
198
+ theme={theme}
199
+ />
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
  </div>
205
  </Panel>
206
  </PanelGroup>
packages/bolt/app/components/workbench/Workbench.client.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useStore } from '@nanostores/react';
2
- import { AnimatePresence, motion, type HTMLMotionProps, type Variants } from 'framer-motion';
3
  import { computed } from 'nanostores';
4
  import { memo, useCallback, useEffect, useState } from 'react';
5
  import { toast } from 'react-toastify';
@@ -98,52 +98,48 @@ export const Workbench = memo(({ chatStarted, isStreaming }: WorkspaceProps) =>
98
 
99
  return (
100
  chatStarted && (
101
- <AnimatePresence>
102
- {showWorkbench && (
103
- <motion.div initial="closed" animate="open" exit="closed" variants={workbenchVariants}>
104
- <div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
105
- <div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
106
- <div className="flex items-center px-3 py-2 border-b border-gray-200">
107
- <Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
108
- <IconButton
109
- icon="i-ph:x-circle"
110
- className="ml-auto -mr-1"
111
- size="xxl"
112
- onClick={() => {
113
- workbenchStore.showWorkbench.set(false);
114
- }}
115
- />
116
- </div>
117
- <div className="relative flex-1 overflow-hidden">
118
- <View
119
- initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
120
- animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
121
- >
122
- <EditorPanel
123
- editorDocument={currentDocument}
124
- isStreaming={isStreaming}
125
- selectedFile={selectedFile}
126
- files={files}
127
- unsavedFiles={unsavedFiles}
128
- onFileSelect={onFileSelect}
129
- onEditorScroll={onEditorScroll}
130
- onEditorChange={onEditorChange}
131
- onFileSave={onFileSave}
132
- onFileReset={onFileReset}
133
- />
134
- </View>
135
- <View
136
- initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
137
- animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
138
- >
139
- <Preview />
140
- </View>
141
- </div>
142
- </div>
143
  </div>
144
- </motion.div>
145
- )}
146
- </AnimatePresence>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  )
148
  );
149
  });
 
1
  import { useStore } from '@nanostores/react';
2
+ import { motion, type HTMLMotionProps, type Variants } from 'framer-motion';
3
  import { computed } from 'nanostores';
4
  import { memo, useCallback, useEffect, useState } from 'react';
5
  import { toast } from 'react-toastify';
 
98
 
99
  return (
100
  chatStarted && (
101
+ <motion.div initial="closed" animate={showWorkbench ? 'open' : 'closed'} variants={workbenchVariants}>
102
+ <div className="fixed top-[calc(var(--header-height)+1.5rem)] bottom-[calc(1.5rem-1px)] w-[50vw] mr-4 z-0">
103
+ <div className="flex flex-col bg-white border border-gray-200 shadow-sm rounded-lg overflow-hidden absolute inset-0 right-8">
104
+ <div className="flex items-center px-3 py-2 border-b border-gray-200">
105
+ <Slider selected={selectedView} options={sliderOptions} setSelected={setSelectedView} />
106
+ <IconButton
107
+ icon="i-ph:x-circle"
108
+ className="ml-auto -mr-1"
109
+ size="xxl"
110
+ onClick={() => {
111
+ workbenchStore.showWorkbench.set(false);
112
+ }}
113
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  </div>
115
+ <div className="relative flex-1 overflow-hidden">
116
+ <View
117
+ initial={{ x: selectedView === 'code' ? 0 : '-100%' }}
118
+ animate={{ x: selectedView === 'code' ? 0 : '-100%' }}
119
+ >
120
+ <EditorPanel
121
+ editorDocument={currentDocument}
122
+ isStreaming={isStreaming}
123
+ selectedFile={selectedFile}
124
+ files={files}
125
+ unsavedFiles={unsavedFiles}
126
+ onFileSelect={onFileSelect}
127
+ onEditorScroll={onEditorScroll}
128
+ onEditorChange={onEditorChange}
129
+ onFileSave={onFileSave}
130
+ onFileReset={onFileReset}
131
+ />
132
+ </View>
133
+ <View
134
+ initial={{ x: selectedView === 'preview' ? 0 : '100%' }}
135
+ animate={{ x: selectedView === 'preview' ? 0 : '100%' }}
136
+ >
137
+ <Preview />
138
+ </View>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </motion.div>
143
  )
144
  );
145
  });
packages/bolt/app/components/workbench/terminal/Terminal.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FitAddon } from '@xterm/addon-fit';
2
+ import { WebLinksAddon } from '@xterm/addon-web-links';
3
+ import { Terminal as XTerm } from '@xterm/xterm';
4
+ import { forwardRef, memo, useEffect, useImperativeHandle, useRef } from 'react';
5
+ import type { Theme } from '~/lib/stores/theme';
6
+ import { getTerminalTheme } from './theme';
7
+
8
+ import '@xterm/xterm/css/xterm.css';
9
+
10
+ export interface TerminalRef {
11
+ reloadStyles: () => void;
12
+ }
13
+
14
+ export interface TerminalProps {
15
+ className?: string;
16
+ theme: Theme;
17
+ readonly?: boolean;
18
+ onTerminalReady?: (terminal: XTerm) => void;
19
+ onTerminalResize?: (cols: number, rows: number) => void;
20
+ }
21
+
22
+ export const Terminal = memo(
23
+ forwardRef<TerminalRef, TerminalProps>(({ className, theme, readonly, onTerminalReady, onTerminalResize }, ref) => {
24
+ const terminalElementRef = useRef<HTMLDivElement>(null);
25
+ const terminalRef = useRef<XTerm>();
26
+
27
+ useEffect(() => {
28
+ const element = terminalElementRef.current!;
29
+
30
+ const fitAddon = new FitAddon();
31
+ const webLinksAddon = new WebLinksAddon();
32
+
33
+ const terminal = new XTerm({
34
+ cursorBlink: true,
35
+ convertEol: true,
36
+ disableStdin: readonly,
37
+ theme: getTerminalTheme(readonly ? { cursor: '#00000000' } : {}),
38
+ fontSize: 13,
39
+ fontFamily: 'Menlo, courier-new, courier, monospace',
40
+ });
41
+
42
+ terminalRef.current = terminal;
43
+
44
+ terminal.loadAddon(fitAddon);
45
+ terminal.loadAddon(webLinksAddon);
46
+ terminal.open(element);
47
+
48
+ const resizeObserver = new ResizeObserver(() => {
49
+ fitAddon.fit();
50
+ onTerminalResize?.(terminal.cols, terminal.rows);
51
+ });
52
+
53
+ resizeObserver.observe(element);
54
+
55
+ onTerminalReady?.(terminal);
56
+
57
+ return () => {
58
+ resizeObserver.disconnect();
59
+ terminal.dispose();
60
+ };
61
+ }, []);
62
+
63
+ useEffect(() => {
64
+ const terminal = terminalRef.current!;
65
+
66
+ // we render a transparent cursor in case the terminal is readonly
67
+ terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
68
+
69
+ terminal.options.disableStdin = readonly;
70
+ }, [theme, readonly]);
71
+
72
+ useImperativeHandle(ref, () => {
73
+ return {
74
+ reloadStyles: () => {
75
+ const terminal = terminalRef.current!;
76
+ terminal.options.theme = getTerminalTheme(readonly ? { cursor: '#00000000' } : {});
77
+ },
78
+ };
79
+ }, []);
80
+
81
+ return <div className={className} ref={terminalElementRef} />;
82
+ }),
83
+ );
packages/bolt/app/components/workbench/terminal/theme.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ITheme } from '@xterm/xterm';
2
+
3
+ const style = getComputedStyle(document.documentElement);
4
+ const cssVar = (token: string) => style.getPropertyValue(token) || undefined;
5
+
6
+ export function getTerminalTheme(overrides?: ITheme): ITheme {
7
+ return {
8
+ cursor: cssVar('--bolt-elements-terminal-cursorColor'),
9
+ cursorAccent: cssVar('--bolt-elements-terminal-cursorColorAccent'),
10
+ foreground: cssVar('--bolt-elements-terminal-textColor'),
11
+ background: cssVar('--bolt-elements-terminal-backgroundColor'),
12
+ selectionBackground: cssVar('--bolt-elements-terminal-selection-backgroundColor'),
13
+ selectionForeground: cssVar('--bolt-elements-terminal-selection-textColor'),
14
+ selectionInactiveBackground: cssVar('--bolt-elements-terminal-selection-backgroundColorInactive'),
15
+
16
+ // ansi escape code colors
17
+ black: cssVar('--bolt-elements-terminal-color-black'),
18
+ red: cssVar('--bolt-elements-terminal-color-red'),
19
+ green: cssVar('--bolt-elements-terminal-color-green'),
20
+ yellow: cssVar('--bolt-elements-terminal-color-yellow'),
21
+ blue: cssVar('--bolt-elements-terminal-color-blue'),
22
+ magenta: cssVar('--bolt-elements-terminal-color-magenta'),
23
+ cyan: cssVar('--bolt-elements-terminal-color-cyan'),
24
+ white: cssVar('--bolt-elements-terminal-color-white'),
25
+ brightBlack: cssVar('--bolt-elements-terminal-color-brightBlack'),
26
+ brightRed: cssVar('--bolt-elements-terminal-color-brightRed'),
27
+ brightGreen: cssVar('--bolt-elements-terminal-color-brightGreen'),
28
+ brightYellow: cssVar('--bolt-elements-terminal-color-brightYellow'),
29
+ brightBlue: cssVar('--bolt-elements-terminal-color-brightBlue'),
30
+ brightMagenta: cssVar('--bolt-elements-terminal-color-brightMagenta'),
31
+ brightCyan: cssVar('--bolt-elements-terminal-color-brightCyan'),
32
+ brightWhite: cssVar('--bolt-elements-terminal-color-brightWhite'),
33
+
34
+ ...overrides,
35
+ };
36
+ }
packages/bolt/app/entry.client.tsx CHANGED
@@ -1,12 +1,7 @@
1
  import { RemixBrowser } from '@remix-run/react';
2
- import { startTransition, StrictMode } from 'react';
3
  import { hydrateRoot } from 'react-dom/client';
4
 
5
  startTransition(() => {
6
- hydrateRoot(
7
- document,
8
- <StrictMode>
9
- <RemixBrowser />
10
- </StrictMode>,
11
- );
12
  });
 
1
  import { RemixBrowser } from '@remix-run/react';
2
+ import { startTransition } from 'react';
3
  import { hydrateRoot } from 'react-dom/client';
4
 
5
  startTransition(() => {
6
+ hydrateRoot(document, <RemixBrowser />);
 
 
 
 
 
7
  });
packages/bolt/app/lib/.server/llm/prompts.ts CHANGED
@@ -13,6 +13,10 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
13
 
14
  IMPORTANT: Git is NOT available.
15
 
 
 
 
 
16
  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
17
  </system_constraints>
18
 
@@ -92,7 +96,7 @@ You are Bolt, an expert AI assistant and exceptional senior software developer w
92
 
93
  - When Using \`npx\`, ALWAYS provide the \`--yes\` flag.
94
  - When running multiple shell commands, use \`&&\` to run them sequentially.
95
- - 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.
96
 
97
  - 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.
98
 
 
13
 
14
  IMPORTANT: Git is NOT available.
15
 
16
+ 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!
17
+
18
+ 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.
19
+
20
  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
21
  </system_constraints>
22
 
 
96
 
97
  - When Using \`npx\`, ALWAYS provide the \`--yes\` flag.
98
  - When running multiple shell commands, use \`&&\` to run them sequentially.
99
+ - 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.
100
 
101
  - 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.
102
 
packages/bolt/app/lib/hooks/index.ts CHANGED
@@ -1,3 +1,4 @@
1
  export * from './useMessageParser';
2
  export * from './usePromptEnhancer';
 
3
  export * from './useSnapScroll';
 
1
  export * from './useMessageParser';
2
  export * from './usePromptEnhancer';
3
+ export * from './useShortcuts';
4
  export * from './useSnapScroll';
packages/bolt/app/lib/hooks/useShortcuts.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useStore } from '@nanostores/react';
2
+ import { useEffect } from 'react';
3
+ import { shortcutsStore, type Shortcuts } from '~/lib/stores/settings';
4
+
5
+ class ShortcutEventEmitter {
6
+ #emitter = new EventTarget();
7
+
8
+ dispatch(type: keyof Shortcuts) {
9
+ this.#emitter.dispatchEvent(new Event(type));
10
+ }
11
+
12
+ on(type: keyof Shortcuts, cb: VoidFunction) {
13
+ this.#emitter.addEventListener(type, cb);
14
+
15
+ return () => {
16
+ this.#emitter.removeEventListener(type, cb);
17
+ };
18
+ }
19
+ }
20
+
21
+ export const shortcutEventEmitter = new ShortcutEventEmitter();
22
+
23
+ export function useShortcuts(): void {
24
+ const shortcuts = useStore(shortcutsStore);
25
+
26
+ useEffect(() => {
27
+ const handleKeyDown = (event: KeyboardEvent): void => {
28
+ const { key, ctrlKey, shiftKey, altKey, metaKey } = event;
29
+
30
+ for (const name in shortcuts) {
31
+ const shortcut = shortcuts[name as keyof Shortcuts];
32
+
33
+ if (
34
+ shortcut.key.toLowerCase() === key.toLowerCase() &&
35
+ (shortcut.ctrlOrMetaKey
36
+ ? ctrlKey || metaKey
37
+ : (shortcut.ctrlKey === undefined || shortcut.ctrlKey === ctrlKey) &&
38
+ (shortcut.metaKey === undefined || shortcut.metaKey === metaKey)) &&
39
+ (shortcut.shiftKey === undefined || shortcut.shiftKey === shiftKey) &&
40
+ (shortcut.altKey === undefined || shortcut.altKey === altKey)
41
+ ) {
42
+ shortcutEventEmitter.dispatch(name as keyof Shortcuts);
43
+ event.preventDefault();
44
+ shortcut.action();
45
+ break;
46
+ }
47
+ }
48
+ };
49
+
50
+ window.addEventListener('keydown', handleKeyDown);
51
+
52
+ return () => {
53
+ window.removeEventListener('keydown', handleKeyDown);
54
+ };
55
+ }, [shortcuts]);
56
+ }
packages/bolt/app/lib/stores/settings.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { map } from 'nanostores';
2
+ import { workbenchStore } from './workbench';
3
+
4
+ export interface Shortcut {
5
+ key: string;
6
+ ctrlKey?: boolean;
7
+ shiftKey?: boolean;
8
+ altKey?: boolean;
9
+ metaKey?: boolean;
10
+ ctrlOrMetaKey?: boolean;
11
+ action: () => void;
12
+ }
13
+
14
+ export interface Shortcuts {
15
+ toggleTerminal: Shortcut;
16
+ }
17
+
18
+ export interface Settings {
19
+ shortcuts: Shortcuts;
20
+ }
21
+
22
+ export const shortcutsStore = map<Shortcuts>({
23
+ toggleTerminal: {
24
+ key: 'j',
25
+ ctrlOrMetaKey: true,
26
+ action: () => workbenchStore.toggleTerminal(),
27
+ },
28
+ });
29
+
30
+ export const settingsStore = map<Settings>({
31
+ shortcuts: shortcutsStore.get(),
32
+ });
33
+
34
+ shortcutsStore.subscribe((shortcuts) => {
35
+ settingsStore.set({
36
+ ...settingsStore.get(),
37
+ shortcuts,
38
+ });
39
+ });
packages/bolt/app/lib/stores/terminal.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { WebContainer, WebContainerProcess } from '@webcontainer/api';
2
+ import { atom, type WritableAtom } from 'nanostores';
3
+ import type { ITerminal } from '~/types/terminal';
4
+ import { newShellProcess } from '~/utils/shell';
5
+ import { coloredText } from '~/utils/terminal';
6
+
7
+ export class TerminalStore {
8
+ #webcontainer: Promise<WebContainer>;
9
+ #terminals: Array<{ terminal: ITerminal; process: WebContainerProcess }> = [];
10
+
11
+ showTerminal: WritableAtom<boolean> = import.meta.hot?.data.showTerminal ?? atom(false);
12
+
13
+ constructor(webcontainerPromise: Promise<WebContainer>) {
14
+ this.#webcontainer = webcontainerPromise;
15
+
16
+ if (import.meta.hot) {
17
+ import.meta.hot.data.showTerminal = this.showTerminal;
18
+ }
19
+ }
20
+
21
+ toggleTerminal(value?: boolean) {
22
+ this.showTerminal.set(value !== undefined ? value : !this.showTerminal.get());
23
+ }
24
+
25
+ async attachTerminal(terminal: ITerminal) {
26
+ try {
27
+ const shellProcess = await newShellProcess(await this.#webcontainer, terminal);
28
+ this.#terminals.push({ terminal, process: shellProcess });
29
+ } catch (error: any) {
30
+ terminal.write(coloredText.red('Failed to spawn shell\n\n') + error.message);
31
+ return;
32
+ }
33
+ }
34
+
35
+ onTerminalResize(cols: number, rows: number) {
36
+ for (const { process } of this.#terminals) {
37
+ process.resize({ cols, rows });
38
+ }
39
+ }
40
+ }
packages/bolt/app/lib/stores/theme.ts CHANGED
@@ -8,6 +8,8 @@ export function themeIsDark() {
8
  return themeStore.get() === 'dark';
9
  }
10
 
 
 
11
  export const themeStore = atom<Theme>(initStore());
12
 
13
  function initStore() {
@@ -15,10 +17,10 @@ function initStore() {
15
  const persistedTheme = localStorage.getItem(kTheme) as Theme | undefined;
16
  const themeAttribute = document.querySelector('html')?.getAttribute('data-theme');
17
 
18
- return persistedTheme ?? (themeAttribute as Theme) ?? 'light';
19
  }
20
 
21
- return 'light';
22
  }
23
 
24
  export function toggleTheme() {
 
8
  return themeStore.get() === 'dark';
9
  }
10
 
11
+ export const DEFAULT_THEME = 'light';
12
+
13
  export const themeStore = atom<Theme>(initStore());
14
 
15
  function initStore() {
 
17
  const persistedTheme = localStorage.getItem(kTheme) as Theme | undefined;
18
  const themeAttribute = document.querySelector('html')?.getAttribute('data-theme');
19
 
20
+ return persistedTheme ?? (themeAttribute as Theme) ?? DEFAULT_THEME;
21
  }
22
 
23
+ return DEFAULT_THEME;
24
  }
25
 
26
  export function toggleTheme() {
packages/bolt/app/lib/stores/workbench.ts CHANGED
@@ -3,10 +3,12 @@ import type { EditorDocument, ScrollPosition } from '~/components/editor/codemir
3
  import { ActionRunner } from '~/lib/runtime/action-runner';
4
  import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
5
  import { webcontainer } from '~/lib/webcontainer';
 
6
  import { unreachable } from '~/utils/unreachable';
7
  import { EditorStore } from './editor';
8
  import { FilesStore, type FileMap } from './files';
9
  import { PreviewsStore } from './previews';
 
10
 
11
  export interface ArtifactState {
12
  title: string;
@@ -22,6 +24,7 @@ export class WorkbenchStore {
22
  #previewsStore = new PreviewsStore(webcontainer);
23
  #filesStore = new FilesStore(webcontainer);
24
  #editorStore = new EditorStore(this.#filesStore);
 
25
 
26
  artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
27
  showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
@@ -53,6 +56,22 @@ export class WorkbenchStore {
53
  return this.#editorStore.selectedFile;
54
  }
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  setDocuments(files: FileMap) {
57
  this.#editorStore.setDocuments(files);
58
 
 
3
  import { ActionRunner } from '~/lib/runtime/action-runner';
4
  import type { ActionCallbackData, ArtifactCallbackData } from '~/lib/runtime/message-parser';
5
  import { webcontainer } from '~/lib/webcontainer';
6
+ import type { ITerminal } from '~/types/terminal';
7
  import { unreachable } from '~/utils/unreachable';
8
  import { EditorStore } from './editor';
9
  import { FilesStore, type FileMap } from './files';
10
  import { PreviewsStore } from './previews';
11
+ import { TerminalStore } from './terminal';
12
 
13
  export interface ArtifactState {
14
  title: string;
 
24
  #previewsStore = new PreviewsStore(webcontainer);
25
  #filesStore = new FilesStore(webcontainer);
26
  #editorStore = new EditorStore(this.#filesStore);
27
+ #terminalStore = new TerminalStore(webcontainer);
28
 
29
  artifacts: Artifacts = import.meta.hot?.data.artifacts ?? map({});
30
  showWorkbench: WritableAtom<boolean> = import.meta.hot?.data.showWorkbench ?? atom(false);
 
56
  return this.#editorStore.selectedFile;
57
  }
58
 
59
+ get showTerminal() {
60
+ return this.#terminalStore.showTerminal;
61
+ }
62
+
63
+ toggleTerminal(value?: boolean) {
64
+ this.#terminalStore.toggleTerminal(value);
65
+ }
66
+
67
+ attachTerminal(terminal: ITerminal) {
68
+ this.#terminalStore.attachTerminal(terminal);
69
+ }
70
+
71
+ onTerminalResize(cols: number, rows: number) {
72
+ this.#terminalStore.onTerminalResize(cols, rows);
73
+ }
74
+
75
  setDocuments(files: FileMap) {
76
  this.#editorStore.setDocuments(files);
77
 
packages/bolt/app/styles/components/resize-handle.scss ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [data-resize-handle] {
2
+ position: relative;
3
+
4
+ &[data-panel-group-direction='horizontal']:after {
5
+ content: '';
6
+ position: absolute;
7
+ top: 0;
8
+ bottom: 0;
9
+ left: -6px;
10
+ right: -5px;
11
+ z-index: $zIndexMax;
12
+ }
13
+
14
+ &[data-panel-group-direction='vertical']:after {
15
+ content: '';
16
+ position: absolute;
17
+ left: 0;
18
+ right: 0;
19
+ top: -5px;
20
+ bottom: -6px;
21
+ z-index: $zIndexMax;
22
+ }
23
+
24
+ &[data-resize-handle-state='hover']:after,
25
+ &[data-resize-handle-state='drag']:after {
26
+ background-color: #8882;
27
+ }
28
+ }
packages/bolt/app/styles/components/terminal.scss ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .xterm {
2
+ height: 100%;
3
+ }
packages/bolt/app/styles/index.scss CHANGED
@@ -1,5 +1,8 @@
1
  @import './variables.scss';
 
2
  @import './animations.scss';
 
 
3
 
4
  body {
5
  --at-apply: bg-bolt-elements-app-backgroundColor;
 
1
  @import './variables.scss';
2
+ @import './z-index.scss';
3
  @import './animations.scss';
4
+ @import './components/terminal.scss';
5
+ @import './components/resize-handle.scss';
6
 
7
  body {
8
  --at-apply: bg-bolt-elements-app-backgroundColor;
packages/bolt/app/styles/variables.scss CHANGED
@@ -20,12 +20,72 @@
20
 
21
  --bolt-border-primary: theme('colors.gray.200');
22
  --bolt-border-accent: theme('colors.accent.600');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  }
24
 
25
  /* Color Tokens Dark Theme */
26
  :root,
27
  :root[data-theme='dark'] {
28
- --bolt-background-primary: theme('colors.gray.50');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  }
30
 
31
  /*
@@ -36,9 +96,33 @@
36
  :root {
37
  --header-height: 65px;
38
 
 
 
39
  /* App */
40
  --bolt-elements-app-backgroundColor: var(--bolt-background-primary);
41
  --bolt-elements-app-borderColor: var(--bolt-border-primary);
42
  --bolt-elements-app-textColor: var(--bolt-text-primary);
43
  --bolt-elements-app-linkColor: var(--bolt-text-accent);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  }
 
20
 
21
  --bolt-border-primary: theme('colors.gray.200');
22
  --bolt-border-accent: theme('colors.accent.600');
23
+
24
+ /* Terminal Colors */
25
+ --bolt-terminal-background: var(--bolt-background-primary);
26
+ --bolt-terminal-foreground: #333333;
27
+ --bolt-terminal-selection-background: #00000040;
28
+ --bolt-terminal-black: #000000;
29
+ --bolt-terminal-red: #cd3131;
30
+ --bolt-terminal-green: #00bc00;
31
+ --bolt-terminal-yellow: #949800;
32
+ --bolt-terminal-blue: #0451a5;
33
+ --bolt-terminal-magenta: #bc05bc;
34
+ --bolt-terminal-cyan: #0598bc;
35
+ --bolt-terminal-white: #555555;
36
+ --bolt-terminal-brightBlack: #686868;
37
+ --bolt-terminal-brightRed: #cd3131;
38
+ --bolt-terminal-brightGreen: #00bc00;
39
+ --bolt-terminal-brightYellow: #949800;
40
+ --bolt-terminal-brightBlue: #0451a5;
41
+ --bolt-terminal-brightMagenta: #bc05bc;
42
+ --bolt-terminal-brightCyan: #0598bc;
43
+ --bolt-terminal-brightWhite: #a5a5a5;
44
  }
45
 
46
  /* Color Tokens Dark Theme */
47
  :root,
48
  :root[data-theme='dark'] {
49
+ --bolt-background-primary: theme('colors.gray.0');
50
+ --bolt-background-secondary: theme('colors.gray.50');
51
+ --bolt-background-active: theme('colors.gray.200');
52
+ --bolt-background-accent: theme('colors.accent.600');
53
+ --bolt-background-accent-secondary: theme('colors.accent.600');
54
+ --bolt-background-accent-active: theme('colors.accent.500');
55
+
56
+ --bolt-text-primary: theme('colors.gray.800');
57
+ --bolt-text-primary-inverted: theme('colors.gray.0');
58
+ --bolt-text-secondary: theme('colors.gray.600');
59
+ --bolt-text-secondary-inverted: theme('colors.gray.200');
60
+ --bolt-text-disabled: theme('colors.gray.400');
61
+ --bolt-text-accent: theme('colors.accent.600');
62
+ --bolt-text-positive: theme('colors.positive.700');
63
+ --bolt-text-warning: theme('colors.warning.600');
64
+ --bolt-text-negative: theme('colors.negative.600');
65
+
66
+ --bolt-border-primary: theme('colors.gray.200');
67
+ --bolt-border-accent: theme('colors.accent.600');
68
+
69
+ /* Terminal Colors */
70
+ --bolt-terminal-background: #16181d;
71
+ --bolt-terminal-foreground: #eff0eb;
72
+ --bolt-terminal-selection-background: #97979b33;
73
+ --bolt-terminal-black: #000000;
74
+ --bolt-terminal-red: #ff5c57;
75
+ --bolt-terminal-green: #5af78e;
76
+ --bolt-terminal-yellow: #f3f99d;
77
+ --bolt-terminal-blue: #57c7ff;
78
+ --bolt-terminal-magenta: #ff6ac1;
79
+ --bolt-terminal-cyan: #9aedfe;
80
+ --bolt-terminal-white: #f1f1f0;
81
+ --bolt-terminal-brightBlack: #686868;
82
+ --bolt-terminal-brightRed: #ff5c57;
83
+ --bolt-terminal-brightGreen: #5af78e;
84
+ --bolt-terminal-brightYellow: #f3f99d;
85
+ --bolt-terminal-brightBlue: #57c7ff;
86
+ --bolt-terminal-brightMagenta: #ff6ac1;
87
+ --bolt-terminal-brightCyan: #9aedfe;
88
+ --bolt-terminal-brightWhite: #f1f1f0;
89
  }
90
 
91
  /*
 
96
  :root {
97
  --header-height: 65px;
98
 
99
+ --z-index-max: 999;
100
+
101
  /* App */
102
  --bolt-elements-app-backgroundColor: var(--bolt-background-primary);
103
  --bolt-elements-app-borderColor: var(--bolt-border-primary);
104
  --bolt-elements-app-textColor: var(--bolt-text-primary);
105
  --bolt-elements-app-linkColor: var(--bolt-text-accent);
106
+
107
+ /* Terminal */
108
+ --bolt-elements-terminal-backgroundColor: var(--bolt-terminal-background);
109
+ --bolt-elements-terminal-textColor: var(--bolt-terminal-foreground);
110
+ --bolt-elements-terminal-cursorColor: var(--bolt-terminal-foreground);
111
+ --bolt-elements-terminal-selection-backgroundColor: var(--bolt-terminal-selection-background);
112
+ --bolt-elements-terminal-color-black: var(--bolt-terminal-black);
113
+ --bolt-elements-terminal-color-red: var(--bolt-terminal-red);
114
+ --bolt-elements-terminal-color-green: var(--bolt-terminal-green);
115
+ --bolt-elements-terminal-color-yellow: var(--bolt-terminal-yellow);
116
+ --bolt-elements-terminal-color-blue: var(--bolt-terminal-blue);
117
+ --bolt-elements-terminal-color-magenta: var(--bolt-terminal-magenta);
118
+ --bolt-elements-terminal-color-cyan: var(--bolt-terminal-cyan);
119
+ --bolt-elements-terminal-color-white: var(--bolt-terminal-white);
120
+ --bolt-elements-terminal-color-brightBlack: var(--bolt-terminal-brightBlack);
121
+ --bolt-elements-terminal-color-brightRed: var(--bolt-terminal-brightRed);
122
+ --bolt-elements-terminal-color-brightGreen: var(--bolt-terminal-brightGreen);
123
+ --bolt-elements-terminal-color-brightYellow: var(--bolt-terminal-brightYellow);
124
+ --bolt-elements-terminal-color-brightBlue: var(--bolt-terminal-brightBlue);
125
+ --bolt-elements-terminal-color-brightMagenta: var(--bolt-terminal-brightMagenta);
126
+ --bolt-elements-terminal-color-brightCyan: var(--bolt-terminal-brightCyan);
127
+ --bolt-elements-terminal-color-brightWhite: var(--bolt-terminal-brightWhite);
128
  }
packages/bolt/app/styles/z-index.scss ADDED
@@ -0,0 +1 @@
 
 
1
+ $zIndexMax: 999;
packages/bolt/app/types/terminal.ts ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ export interface ITerminal {
2
+ readonly cols?: number;
3
+ readonly rows?: number;
4
+
5
+ reset: () => void;
6
+ write: (data: string) => void;
7
+ onData: (cb: (data: string) => void) => void;
8
+ }
packages/bolt/app/utils/shell.ts ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { WebContainer } from '@webcontainer/api';
2
+ import type { ITerminal } from '~/types/terminal';
3
+ import { withResolvers } from './promises';
4
+
5
+ export async function newShellProcess(webcontainer: WebContainer, terminal: ITerminal) {
6
+ const args: string[] = [];
7
+
8
+ // we spawn a JSH process with a fallback cols and rows in case the process is not attached yet to a visible terminal
9
+ const process = await webcontainer.spawn('/bin/jsh', ['--osc', ...args], {
10
+ terminal: {
11
+ cols: terminal.cols ?? 80,
12
+ rows: terminal.rows ?? 15,
13
+ },
14
+ });
15
+
16
+ const input = process.input.getWriter();
17
+ const output = process.output;
18
+
19
+ const jshReady = withResolvers<void>();
20
+
21
+ let isInteractive = false;
22
+
23
+ output.pipeTo(
24
+ new WritableStream({
25
+ write(data) {
26
+ if (!isInteractive) {
27
+ const [, osc] = data.match(/\x1b\]654;([^\x07]+)\x07/) || [];
28
+
29
+ if (osc === 'interactive') {
30
+ // wait until we see the interactive OSC
31
+ isInteractive = true;
32
+
33
+ jshReady.resolve();
34
+ }
35
+ }
36
+
37
+ terminal.write(data);
38
+ },
39
+ }),
40
+ );
41
+
42
+ terminal.onData((data) => {
43
+ if (isInteractive) {
44
+ input.write(data);
45
+ }
46
+ });
47
+
48
+ await jshReady.promise;
49
+
50
+ return process;
51
+ }
packages/bolt/app/utils/terminal.ts ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const reset = '\x1b[0m';
2
+
3
+ export const escapeCodes = {
4
+ reset,
5
+ clear: '\x1b[g',
6
+ red: '\x1b[1;31m',
7
+ };
8
+
9
+ export const coloredText = {
10
+ red: (text: string) => `${escapeCodes.red}${text}${reset}`,
11
+ };
packages/bolt/package.json CHANGED
@@ -39,7 +39,7 @@
39
  "@remix-run/react": "^2.10.2",
40
  "@stackblitz/sdk": "^1.11.0",
41
  "@unocss/reset": "^0.61.0",
42
- "@webcontainer/api": "^1.3.0-internal.1",
43
  "@xterm/addon-fit": "^0.10.0",
44
  "@xterm/addon-web-links": "^0.11.0",
45
  "@xterm/xterm": "^5.5.0",
@@ -51,6 +51,7 @@
51
  "nanostores": "^0.10.3",
52
  "react": "^18.2.0",
53
  "react-dom": "^18.2.0",
 
54
  "react-markdown": "^9.0.1",
55
  "react-resizable-panels": "^2.0.20",
56
  "react-toastify": "^10.0.5",
 
39
  "@remix-run/react": "^2.10.2",
40
  "@stackblitz/sdk": "^1.11.0",
41
  "@unocss/reset": "^0.61.0",
42
+ "@webcontainer/api": "^1.3.0-internal.2",
43
  "@xterm/addon-fit": "^0.10.0",
44
  "@xterm/addon-web-links": "^0.11.0",
45
  "@xterm/xterm": "^5.5.0",
 
51
  "nanostores": "^0.10.3",
52
  "react": "^18.2.0",
53
  "react-dom": "^18.2.0",
54
+ "react-hotkeys-hook": "^4.5.0",
55
  "react-markdown": "^9.0.1",
56
  "react-resizable-panels": "^2.0.20",
57
  "react-toastify": "^10.0.5",
pnpm-lock.yaml CHANGED
@@ -105,8 +105,8 @@ importers:
105
  specifier: ^0.61.0
106
  version: 0.61.0
107
  '@webcontainer/api':
108
- specifier: ^1.3.0-internal.1
109
- version: 1.3.0-internal.1
110
  '@xterm/addon-fit':
111
  specifier: ^0.10.0
112
  version: 0.10.0(@xterm/[email protected])
@@ -140,6 +140,9 @@ importers:
140
  react-dom:
141
  specifier: ^18.2.0
142
  version: 18.3.1([email protected])
 
 
 
143
  react-markdown:
144
  specifier: ^9.0.1
145
  version: 9.0.1(@types/[email protected])([email protected])
@@ -1722,8 +1725,8 @@ packages:
1722
  '@web3-storage/[email protected]':
1723
  resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
1724
 
1725
- '@webcontainer/[email protected].1':
1726
- resolution: {integrity: sha512-XHveAaZgZItLWieict8xTteBbPLeAwCJLuc80Zq6Mmk0LEWTw8yYZep0dTKbet6bd9MPTQ1+vjPAsEtD0H1fOA==}
1727
 
1728
  '@xterm/[email protected]':
1729
  resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
@@ -4090,6 +4093,12 @@ packages:
4090
  peerDependencies:
4091
  react: ^18.3.1
4092
 
 
 
 
 
 
 
4093
4094
  resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
4095
 
@@ -6814,7 +6823,7 @@ snapshots:
6814
 
6815
  '@web3-storage/[email protected]': {}
6816
 
6817
- '@webcontainer/[email protected].1': {}
6818
 
6819
  '@xterm/[email protected](@xterm/[email protected])':
6820
  dependencies:
@@ -9748,6 +9757,11 @@ snapshots:
9748
  react: 18.3.1
9749
  scheduler: 0.23.2
9750
 
 
 
 
 
 
9751
9752
 
9753
 
105
  specifier: ^0.61.0
106
  version: 0.61.0
107
  '@webcontainer/api':
108
+ specifier: ^1.3.0-internal.2
109
+ version: 1.3.0-internal.2
110
  '@xterm/addon-fit':
111
  specifier: ^0.10.0
112
  version: 0.10.0(@xterm/[email protected])
 
140
  react-dom:
141
  specifier: ^18.2.0
142
  version: 18.3.1([email protected])
143
+ react-hotkeys-hook:
144
+ specifier: ^4.5.0
145
146
  react-markdown:
147
  specifier: ^9.0.1
148
  version: 9.0.1(@types/[email protected])([email protected])
 
1725
  '@web3-storage/[email protected]':
1726
  resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==}
1727
 
1728
+ '@webcontainer/[email protected].2':
1729
+ resolution: {integrity: sha512-lLSlSehbuYc9E7ecK+tMRX4BbWETNX1OgRlS+NerQh3X3sHNbxLD86eScEMAiA5VBnUeSnLtLe7eC/ftM8fR3Q==}
1730
 
1731
  '@xterm/[email protected]':
1732
  resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==}
 
4093
  peerDependencies:
4094
  react: ^18.3.1
4095
 
4096
4097
+ resolution: {integrity: sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==}
4098
+ peerDependencies:
4099
+ react: '>=16.8.1'
4100
+ react-dom: '>=16.8.1'
4101
+
4102
4103
  resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
4104
 
 
6823
 
6824
  '@web3-storage/[email protected]': {}
6825
 
6826
+ '@webcontainer/[email protected].2': {}
6827
 
6828
  '@xterm/[email protected](@xterm/[email protected])':
6829
  dependencies:
 
9757
  react: 18.3.1
9758
  scheduler: 0.23.2
9759
 
9760
9761
+ dependencies:
9762
+ react: 18.3.1
9763
+ react-dom: 18.3.1([email protected])
9764
+
9765
9766
 
9767