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} )}