Dominic Elm
commited on
feat: add terminal and simple shortcut system (#16)
Browse files- packages/bolt/app/components/chat/Chat.client.tsx +4 -2
- packages/bolt/app/components/ui/PanelHeader.tsx +15 -0
- packages/bolt/app/components/workbench/EditorPanel.tsx +139 -46
- packages/bolt/app/components/workbench/Workbench.client.tsx +42 -46
- packages/bolt/app/components/workbench/terminal/Terminal.tsx +83 -0
- packages/bolt/app/components/workbench/terminal/theme.ts +36 -0
- packages/bolt/app/entry.client.tsx +2 -7
- packages/bolt/app/lib/.server/llm/prompts.ts +5 -1
- packages/bolt/app/lib/hooks/index.ts +1 -0
- packages/bolt/app/lib/hooks/useShortcuts.ts +56 -0
- packages/bolt/app/lib/stores/settings.ts +39 -0
- packages/bolt/app/lib/stores/terminal.ts +40 -0
- packages/bolt/app/lib/stores/theme.ts +4 -2
- packages/bolt/app/lib/stores/workbench.ts +19 -0
- packages/bolt/app/styles/components/resize-handle.scss +28 -0
- packages/bolt/app/styles/components/terminal.scss +3 -0
- packages/bolt/app/styles/index.scss +3 -0
- packages/bolt/app/styles/variables.scss +85 -1
- packages/bolt/app/styles/z-index.scss +1 -0
- packages/bolt/app/types/terminal.ts +8 -0
- packages/bolt/app/utils/shell.ts +51 -0
- packages/bolt/app/utils/terminal.ts +11 -0
- packages/bolt/package.json +2 -1
- pnpm-lock.yaml +19 -5
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="
|
65 |
-
<Panel defaultSize={
|
66 |
-
<
|
67 |
-
<
|
68 |
-
<div className="
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
{
|
83 |
-
<
|
84 |
-
{activeFile
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
<div className="
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
|
|
|
|
|
|
|
|
95 |
</div>
|
96 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
97 |
</div>
|
98 |
-
|
99 |
-
</
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
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 {
|
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 |
-
<
|
102 |
-
|
103 |
-
<
|
104 |
-
<div className="
|
105 |
-
<
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
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 |
-
|
145 |
-
|
146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
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) ??
|
19 |
}
|
20 |
|
21 |
-
return
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
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.
|
109 |
-
version: 1.3.0-internal.
|
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].
|
1726 |
-
resolution: {integrity: sha512-
|
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].
|
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 |
+
version: 4.5.0([email protected]([email protected]))([email protected])
|
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 |