diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
index 12f29eb99d5..46efd0dbb40 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx
@@ -8,6 +8,7 @@ import {
createTerminalInstance,
getDefaultTerminalBg,
setupFocusListener,
+ setupKeyboardHandler,
setupResizeHandlers,
} from "./helpers";
import type { TerminalProps, TerminalStreamEvent } from "./types";
@@ -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);
@@ -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;
@@ -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(() => {
@@ -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(
@@ -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,
@@ -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) => {
@@ -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 });
}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts
index 14727ebd57b..3e86c158b74 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts
@@ -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();
@@ -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;
});
}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx
index acf0816647f..627a255f637 100644
--- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx
+++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceHeader/WorkspaceHeader.tsx
@@ -94,7 +94,7 @@ export function WorkspaceHeader({ worktreePath }: WorkspaceHeaderProps) {
alt=""
className="size-4 object-contain"
/>
- /{folderName}
+ {folderName}
)}