Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createTerminalInstance,
getDefaultTerminalBg,
setupFocusListener,
setupKeyboardHandler,
setupResizeHandlers,
} from "./helpers";
import type { TerminalProps, TerminalStreamEvent } from "./types";
Expand All @@ -26,7 +27,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
const setActiveTab = useSetActiveTab();
const terminalTheme = useTerminalTheme();

// Get the workspace CWD for resolving relative file paths
// Required for resolving relative file paths in terminal commands
const { data: workspaceCwd } =
trpc.terminal.getWorkspaceCwd.useQuery(workspaceId);

Expand All @@ -48,12 +49,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {

const handleStreamData = (event: TerminalStreamEvent) => {
if (!xtermRef.current) {
// Queue events that arrive before xterm is ready or before recovery is applied
// Prevent data loss during terminal initialization
pendingEventsRef.current.push(event);
return;
}

// Queue events while subscription is not enabled (recovery in progress)
// Prevent race condition where events arrive before scrollback recovery completes
if (!subscriptionEnabled) {
pendingEventsRef.current.push(event);
return;
Expand All @@ -73,7 +74,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {

trpc.terminal.stream.useSubscription(tabId, {
onData: handleStreamData,
enabled: true, // Always listen, but queue events internally until subscriptionEnabled is true
// Always listen to prevent missing events during initialization
enabled: true,
});

useEffect(() => {
Expand All @@ -88,9 +90,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
xtermRef.current = xterm;
fitAddonRef.current = fitAddon;
isExitedRef.current = false;
// Don't enable subscription yet - wait until recovery is applied
// Delay enabling subscription to ensure scrollback is applied first, preventing duplicate output

// Flush any pending events that arrived before xterm was ready or before recovery
const flushPendingEvents = () => {
if (pendingEventsRef.current.length === 0) return;
const events = pendingEventsRef.current.splice(
Expand Down Expand Up @@ -171,6 +172,17 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
);

const inputDisposable = xterm.onData(handleTerminalInput);

// Intercept keyboard events to handle app hotkeys and provide iTerm-like line continuation UX
setupKeyboardHandler(xterm, {
onShiftEnter: () => {
if (!isExitedRef.current) {
// Use shell's native continuation syntax to avoid shell-specific parsing
writeRef.current({ tabId, data: "\\\n" });
}
},
});

const cleanupFocus = setupFocusListener(
xterm,
workspaceId,
Expand Down Expand Up @@ -199,17 +211,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
};
}, [tabId, workspaceId, setActiveTab, workspaceCwd, tabTitle, terminalTheme]);

// Update terminal theme when it changes
// Sync theme changes to xterm instance for live theme switching
useEffect(() => {
const xterm = xtermRef.current;
if (!xterm || !terminalTheme) return;

// Set theme via property setter - preserves all other options
// xterm.js v5 uses setters that trigger internal repaint
xterm.options.theme = terminalTheme;
}, [terminalTheme]);

// Get terminal background color from theme, with theme-aware default
// Match container background to terminal theme for seamless visual integration
const terminalBg = terminalTheme?.background ?? getDefaultTerminalBg();

const handleDragOver = (event: React.DragEvent) => {
Expand All @@ -223,11 +233,10 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;

// Get file paths via Electron's webUtils API (contextIsolation-safe)
// Use Electron's webUtils API to access file paths in context-isolated renderer process
const paths = files.map((file) => window.webUtils.getPathForFile(file));
const text = shellEscapePaths(paths);

// Write to terminal (same as typing)
if (!isExitedRef.current) {
writeRef.current({ tabId, data: text });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,6 @@ export function createTerminalInstance(
// Activate Unicode 11
xterm.unicode.activeVersion = "11";

// Forward app hotkeys to document so useHotkeys can catch them
setupShortcutForwarding(xterm);

// Fit after addons are loaded
fitAddon.fit();

Expand All @@ -130,26 +127,45 @@ export function createTerminalInstance(
};
}

export interface KeyboardHandlerOptions {
/** Callback for Shift+Enter to create a line continuation (like iTerm) */
onShiftEnter?: () => void;
}

/**
* Setup shortcut forwarding for xterm.
* When an app hotkey is pressed while terminal is focused, re-dispatch to document
* so react-hotkeys-hook handlers can catch it.
* Setup keyboard handling for xterm including:
* - Shortcut forwarding: App hotkeys are re-dispatched to document for react-hotkeys-hook
* - Shift+Enter: Creates a line continuation (like iTerm) instead of executing
*/
function setupShortcutForwarding(xterm: XTerm): void {
export function setupKeyboardHandler(
xterm: XTerm,
options: KeyboardHandlerOptions = {},
): void {
xterm.attachCustomKeyEventHandler((event: KeyboardEvent) => {
// Only intercept keydown events with meta/ctrl modifier
const isShiftEnter =
event.key === "Enter" &&
event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey;

if (isShiftEnter) {
// Block both keydown and keyup to prevent Enter from leaking through
if (event.type === "keydown" && options.onShiftEnter) {
options.onShiftEnter();
}
return false;
}

if (event.type !== "keydown") return true;
if (!event.metaKey && !event.ctrlKey) return true;

// Check if this is an app hotkey
if (isAppHotkey(event)) {
// Re-dispatch to document for react-hotkeys-hook to catch
document.dispatchEvent(new KeyboardEvent(event.type, event));
// Return false to tell xterm to ignore this event
return false;
}

// Let xterm handle all other keys
return true;
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function WorkspaceHeader({ worktreePath }: WorkspaceHeaderProps) {
alt=""
className="size-4 object-contain"
/>
<span className="font-medium">/{folderName}</span>
<span className="font-medium">{folderName}</span>
</Button>
)}
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
Expand Down