Skip to content

Commit b291885

Browse files
authored
feat: add terminal support (#558)
1 parent f84dfd5 commit b291885

27 files changed

+1616
-189
lines changed

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
# Vim swap files
2+
*.swp
3+
*.swo
4+
*~
15
# Font files (copied from node_modules during build)
26
public/fonts/
37

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ dev: node_modules/.installed build-main ## Start development server (Vite + node
112112
"bun x nodemon --watch src --watch tsconfig.main.json --watch tsconfig.json --ext ts,tsx,json --ignore dist --ignore node_modules --exec node scripts/build-main-watch.js" \
113113
"vite"
114114
else
115-
dev: node_modules/.installed build-main ## Start development server (Vite + tsgo watcher for 10x faster type checking)
115+
dev: node_modules/.installed build-main build-preload## Start development server (Vite + tsgo watcher for 10x faster type checking)
116116
@bun x concurrently -k \
117117
"bun x concurrently \"$(TSGO) -w -p tsconfig.main.json\" \"bun x tsc-alias -w -p tsconfig.main.json\"" \
118118
"vite"

bun.lock

Lines changed: 346 additions & 175 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@ai-sdk/anthropic": "^2.0.44",
4848
"@ai-sdk/openai": "^2.0.66",
4949
"@openrouter/ai-sdk-provider": "^1.2.2",
50+
"ghostty-web": "^0.1.1",
5051
"@radix-ui/react-dialog": "^1.1.15",
5152
"@radix-ui/react-dropdown-menu": "^2.1.16",
5253
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -71,6 +72,7 @@
7172
"minimist": "^1.2.8",
7273
"motion": "^12.23.24",
7374
"ollama-ai-provider-v2": "^1.5.4",
75+
"node-pty": "1.1.0-beta39",
7476
"rehype-harden": "^1.1.5",
7577
"shescape": "^2.1.6",
7678
"source-map-support": "^0.5.21",
@@ -120,6 +122,7 @@
120122
"electron-builder": "^24.6.0",
121123
"electron-devtools-installer": "^4.0.0",
122124
"electron-mock-ipc": "^0.3.12",
125+
"electron-rebuild": "^3.2.9",
123126
"esbuild": "^0.25.11",
124127
"escape-html": "^1.0.3",
125128
"eslint": "^9.36.0",
@@ -211,6 +214,7 @@
211214
"target": "nsis",
212215
"icon": "build/icon.png",
213216
"artifactName": "${productName}-${version}-${arch}.${ext}"
214-
}
217+
},
218+
"npmRebuild": false
215219
}
216220
}

src/App.stories.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,22 @@ function setupMockAPI(options: {
9797
window: {
9898
setTitle: () => Promise.resolve(undefined),
9999
},
100+
terminal: {
101+
create: () =>
102+
Promise.resolve({
103+
sessionId: "mock-session",
104+
workspaceId: "mock-workspace",
105+
cols: 80,
106+
rows: 24,
107+
}),
108+
close: () => Promise.resolve(undefined),
109+
resize: () => Promise.resolve(undefined),
110+
sendInput: () => undefined,
111+
onOutput: () => () => undefined,
112+
onExit: () => () => undefined,
113+
openWindow: () => Promise.resolve(undefined),
114+
closeWindow: () => Promise.resolve(undefined),
115+
},
100116
update: {
101117
check: () => Promise.resolve(undefined),
102118
download: () => Promise.resolve(undefined),

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ function AppInner() {
169169
}, [selectedWorkspace, workspaceMetadata, setSelectedWorkspace]);
170170

171171
const openWorkspaceInTerminal = useCallback((workspaceId: string) => {
172-
void window.api.workspace.openTerminal(workspaceId);
172+
void window.api.terminal.openWindow(workspaceId);
173173
}, []);
174174

175175
const handleRemoveProject = useCallback(

src/browser/api.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,33 @@ const webApi: IPCApi = {
249249
return Promise.resolve();
250250
},
251251
},
252+
terminal: {
253+
create: (params) => invokeIPC(IPC_CHANNELS.TERMINAL_CREATE, params),
254+
close: (sessionId) => invokeIPC(IPC_CHANNELS.TERMINAL_CLOSE, sessionId),
255+
resize: (params) => invokeIPC(IPC_CHANNELS.TERMINAL_RESIZE, params),
256+
sendInput: (sessionId: string, data: string) => {
257+
// Send via IPC - in browser mode this becomes an HTTP POST
258+
void invokeIPC(IPC_CHANNELS.TERMINAL_INPUT, sessionId, data);
259+
},
260+
onOutput: (sessionId: string, callback: (data: string) => void) => {
261+
// Subscribe to terminal output events via WebSocket
262+
const channel = `terminal:output:${sessionId}`;
263+
return wsManager.on(channel, callback as (data: unknown) => void);
264+
},
265+
onExit: (sessionId: string, callback: (exitCode: number) => void) => {
266+
// Subscribe to terminal exit events via WebSocket
267+
const channel = `terminal:exit:${sessionId}`;
268+
return wsManager.on(channel, callback as (data: unknown) => void);
269+
},
270+
openWindow: (workspaceId) => {
271+
// In browser mode, open a new window/tab with the terminal page
272+
// Use a unique name with timestamp to create a new window each time
273+
const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`;
274+
window.open(url, `terminal-${workspaceId}-${Date.now()}`, "width=1000,height=600");
275+
return invokeIPC(IPC_CHANNELS.TERMINAL_WINDOW_OPEN, workspaceId);
276+
},
277+
closeWindow: (workspaceId) => invokeIPC(IPC_CHANNELS.TERMINAL_WINDOW_CLOSE, workspaceId),
278+
},
252279
update: {
253280
check: () => invokeIPC(IPC_CHANNELS.UPDATE_CHECK),
254281
download: () => invokeIPC(IPC_CHANNELS.UPDATE_DOWNLOAD),
@@ -263,6 +290,9 @@ const webApi: IPCApi = {
263290
server: {
264291
getLaunchProject: () => invokeIPC("server:getLaunchProject"),
265292
},
293+
// In browser mode, set platform to "browser" to differentiate from Electron
294+
platform: "browser" as const,
295+
versions: {},
266296
};
267297

268298
if (typeof window.api === "undefined") {

src/components/AIView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
5050
// Track active tab to conditionally enable resize functionality
5151
// RightSidebar notifies us of tab changes via onTabChange callback
5252
const [activeTab, setActiveTab] = useState<TabType>("costs");
53+
5354
const isReviewTabActive = activeTab === "review";
5455

5556
// Resizable sidebar for Review tab only
@@ -195,7 +196,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
195196
);
196197

197198
const handleOpenTerminal = useCallback(() => {
198-
void window.api.workspace.openTerminal(workspaceId);
199+
void window.api.terminal.openWindow(workspaceId);
199200
}, [workspaceId]);
200201

201202
// Auto-scroll when messages or todos update (during streaming)

src/components/TerminalView.tsx

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { useRef, useEffect, useState } from "react";
2+
import { Terminal, FitAddon } from "ghostty-web";
3+
import { useTerminalSession } from "@/hooks/useTerminalSession";
4+
5+
interface TerminalViewProps {
6+
workspaceId: string;
7+
sessionId?: string;
8+
visible: boolean;
9+
}
10+
11+
export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewProps) {
12+
const containerRef = useRef<HTMLDivElement>(null);
13+
const termRef = useRef<Terminal | null>(null);
14+
const fitAddonRef = useRef<FitAddon | null>(null);
15+
const [terminalError, setTerminalError] = useState<string | null>(null);
16+
const [terminalReady, setTerminalReady] = useState(false);
17+
const [terminalSize, setTerminalSize] = useState<{ cols: number; rows: number } | null>(null);
18+
19+
// Handler for terminal output
20+
const handleOutput = (data: string) => {
21+
const term = termRef.current;
22+
if (term) {
23+
term.write(data);
24+
}
25+
};
26+
27+
// Handler for terminal exit
28+
const handleExit = (exitCode: number) => {
29+
const term = termRef.current;
30+
if (term) {
31+
term.write(`\r\n[Process exited with code ${exitCode}]\r\n`);
32+
}
33+
};
34+
35+
const {
36+
sendInput,
37+
resize,
38+
error: sessionError,
39+
} = useTerminalSession(workspaceId, sessionId, visible, terminalSize, handleOutput, handleExit);
40+
41+
// Keep refs to latest functions so callbacks always use current version
42+
const sendInputRef = useRef(sendInput);
43+
const resizeRef = useRef(resize);
44+
45+
useEffect(() => {
46+
sendInputRef.current = sendInput;
47+
resizeRef.current = resize;
48+
}, [sendInput, resize]);
49+
50+
// Initialize terminal when visible
51+
useEffect(() => {
52+
if (!containerRef.current || !visible) {
53+
return;
54+
}
55+
56+
let terminal: Terminal | null = null;
57+
58+
const initTerminal = async () => {
59+
try {
60+
terminal = new Terminal({
61+
fontSize: 13,
62+
fontFamily: "Monaco, Menlo, 'Courier New', monospace",
63+
cursorBlink: true,
64+
theme: {
65+
background: "#1e1e1e",
66+
foreground: "#d4d4d4",
67+
cursor: "#d4d4d4",
68+
cursorAccent: "#1e1e1e",
69+
selectionBackground: "#264f78",
70+
black: "#000000",
71+
red: "#cd3131",
72+
green: "#0dbc79",
73+
yellow: "#e5e510",
74+
blue: "#2472c8",
75+
magenta: "#bc3fbc",
76+
cyan: "#11a8cd",
77+
white: "#e5e5e5",
78+
brightBlack: "#666666",
79+
brightRed: "#f14c4c",
80+
brightGreen: "#23d18b",
81+
brightYellow: "#f5f543",
82+
brightBlue: "#3b8eea",
83+
brightMagenta: "#d670d6",
84+
brightCyan: "#29b8db",
85+
brightWhite: "#ffffff",
86+
},
87+
});
88+
89+
const fitAddon = new FitAddon();
90+
terminal.loadAddon(fitAddon);
91+
92+
await terminal.open(containerRef.current!);
93+
fitAddon.fit();
94+
95+
const { cols, rows } = terminal;
96+
97+
// Set terminal size so PTY session can be created with matching dimensions
98+
// Use stable object reference to prevent unnecessary effect re-runs
99+
setTerminalSize((prev) => {
100+
if (prev?.cols === cols && prev?.rows === rows) {
101+
return prev;
102+
}
103+
return { cols, rows };
104+
});
105+
106+
// User input → IPC (use ref to always get latest sendInput)
107+
terminal.onData((data: string) => {
108+
sendInputRef.current(data);
109+
});
110+
111+
termRef.current = terminal;
112+
fitAddonRef.current = fitAddon;
113+
setTerminalReady(true);
114+
} catch (err) {
115+
console.error("Failed to initialize terminal:", err);
116+
setTerminalError(err instanceof Error ? err.message : "Failed to initialize terminal");
117+
}
118+
};
119+
120+
void initTerminal();
121+
122+
return () => {
123+
if (terminal) {
124+
terminal.dispose();
125+
}
126+
termRef.current = null;
127+
fitAddonRef.current = null;
128+
setTerminalReady(false);
129+
setTerminalSize(null);
130+
};
131+
// Note: sendInput and resize are intentionally not in deps
132+
// They're used in callbacks, not during effect execution
133+
}, [visible, workspaceId]);
134+
135+
// Resize on container size change
136+
useEffect(() => {
137+
if (!visible || !fitAddonRef.current || !containerRef.current || !termRef.current) {
138+
return;
139+
}
140+
141+
let lastCols = 0;
142+
let lastRows = 0;
143+
let resizeTimeoutId: ReturnType<typeof setTimeout> | null = null;
144+
let pendingResize: { cols: number; rows: number } | null = null;
145+
146+
// Use both ResizeObserver (for container changes) and window resize (as backup)
147+
const handleResize = () => {
148+
if (fitAddonRef.current && termRef.current) {
149+
try {
150+
// Resize terminal UI to fit container immediately for responsive UX
151+
fitAddonRef.current.fit();
152+
153+
// Get new dimensions
154+
const { cols, rows } = termRef.current;
155+
156+
// Only process if dimensions actually changed
157+
if (cols === lastCols && rows === lastRows) {
158+
return;
159+
}
160+
161+
lastCols = cols;
162+
lastRows = rows;
163+
164+
// Update state (with stable reference to prevent unnecessary re-renders)
165+
setTerminalSize((prev) => {
166+
if (prev?.cols === cols && prev?.rows === rows) {
167+
return prev;
168+
}
169+
return { cols, rows };
170+
});
171+
172+
// Store pending resize
173+
pendingResize = { cols, rows };
174+
175+
// Always debounce PTY resize to prevent vim corruption
176+
// Clear any pending timeout and set a new one
177+
if (resizeTimeoutId !== null) {
178+
clearTimeout(resizeTimeoutId);
179+
}
180+
181+
resizeTimeoutId = setTimeout(() => {
182+
if (pendingResize) {
183+
console.log(
184+
`[TerminalView] Sending resize to PTY: ${pendingResize.cols}x${pendingResize.rows}`
185+
);
186+
// Double requestAnimationFrame to ensure vim is ready
187+
requestAnimationFrame(() => {
188+
requestAnimationFrame(() => {
189+
if (pendingResize) {
190+
resizeRef.current(pendingResize.cols, pendingResize.rows);
191+
pendingResize = null;
192+
}
193+
});
194+
});
195+
}
196+
resizeTimeoutId = null;
197+
}, 300); // 300ms debounce - enough time for vim to stabilize
198+
} catch (err) {
199+
console.error("[TerminalView] Error fitting terminal:", err);
200+
}
201+
}
202+
};
203+
204+
const resizeObserver = new ResizeObserver(handleResize);
205+
resizeObserver.observe(containerRef.current);
206+
207+
// Also listen to window resize as backup
208+
window.addEventListener("resize", handleResize);
209+
210+
return () => {
211+
if (resizeTimeoutId !== null) {
212+
clearTimeout(resizeTimeoutId);
213+
}
214+
resizeObserver.disconnect();
215+
window.removeEventListener("resize", handleResize);
216+
};
217+
}, [visible, terminalReady]); // terminalReady ensures ResizeObserver is set up after terminal is initialized
218+
219+
if (!visible) return null;
220+
221+
const errorMessage = terminalError ?? sessionError;
222+
223+
return (
224+
<div
225+
className="terminal-view"
226+
style={{
227+
width: "100%",
228+
height: "100%",
229+
backgroundColor: "#1e1e1e",
230+
}}
231+
>
232+
{errorMessage && (
233+
<div className="border-b border-red-900/30 bg-red-900/20 p-2 text-sm text-red-400">
234+
Terminal Error: {errorMessage}
235+
</div>
236+
)}
237+
<div
238+
ref={containerRef}
239+
className="terminal-container"
240+
style={{
241+
width: "100%",
242+
height: "100%",
243+
overflow: "hidden",
244+
}}
245+
/>
246+
</div>
247+
);
248+
}

0 commit comments

Comments
 (0)