From fd256b0a7066ca652b434ae09008af1ccdcbf538 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 18 May 2026 02:20:03 -0500 Subject: [PATCH] feat(desktop): persistent terminal pane + fullscreen takeover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a VSCode-style "focus terminal" toggle to the right sidebar's Terminal tab that takes over the chat pane area without unmounting the shell. The xterm host is mounted once at the layout root and CSS-overlayed onto whichever is currently active, so the PTY session, scrollback, selection, focus, and WebGL renderer survive every toggle. Also: - WebGL renderer (matching dashboard ChatPage) so Hermes' TUI skins paint faithfully instead of muting through xterm's default DOM renderer - File drag/drop from the project tree or OS into xterm — paths are shell-quoted (zsh/bash/pwsh/cmd) and written straight into the PTY - Solarized dark canvas with brights promoted to real accent variants (Schoonover's UI-gray brights washed out every TUI accent) - Strip NO_COLOR/FORCE_COLOR/COLORFGBG/TERM=dumb leaking from non-tty parents (CI runners, Cursor's agent shell) so the embedded shell gets truecolor regardless of how Electron was launched - rAF-debounced ResizeObserver — running fit.fit() synchronously during sibling pane transitions crashed the WebGL texture-atlas rebuild --- apps/desktop/electron/main.cjs | 10 +- apps/desktop/package.json | 1 + apps/desktop/src/app/desktop-controller.tsx | 19 ++- apps/desktop/src/app/right-sidebar/index.tsx | 30 ++-- apps/desktop/src/app/right-sidebar/store.ts | 14 +- .../src/app/right-sidebar/terminal/index.tsx | 35 ++++- .../app/right-sidebar/terminal/persistent.tsx | 110 ++++++++++++++ .../app/right-sidebar/terminal/selection.ts | 81 ++++------- .../terminal/use-terminal-session.ts | 134 +++++++++++++++--- package-lock.json | 4 +- 10 files changed, 348 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/persistent.tsx diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 40a6d3ebc67..8d8c3cfad7c 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -3096,7 +3096,15 @@ function terminalShellEnv() { } } - env.COLORTERM = env.COLORTERM || 'truecolor' + // Strip color/theme-detection vars that ride along when Electron is launched + // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 + // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into + // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. + delete env.NO_COLOR + delete env.FORCE_COLOR + delete env.COLORFGBG + + env.COLORTERM = 'truecolor' env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' env.TERM = 'xterm-256color' env.TERM_PROGRAM = 'Hermes' diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fa7f5acfe23..7601baeaa24 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -63,6 +63,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 0f0a64dd5fd..5defcb85de3 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -54,6 +54,8 @@ import { useGatewayBoot } from './gateway/hooks/use-gateway-boot' import { useGatewayRequest } from './gateway/hooks/use-gateway-request' import { ModelPickerOverlay } from './model-picker-overlay' import { RightSidebarPane } from './right-sidebar' +import { $terminalTakeover } from './right-sidebar/store' +import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent' import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute } from './routes' import { useContextSuggestions } from './session/hooks/use-context-suggestions' import { useCwdActions } from './session/hooks/use-cwd-actions' @@ -99,6 +101,7 @@ export function DesktopController() { const filePreviewTarget = useStore($filePreviewTarget) const previewTarget = useStore($previewTarget) const selectedStoredSessionId = useStore($selectedStoredSessionId) + const terminalTakeover = useStore($terminalTakeover) const routedSessionId = routeSessionId(location.pathname) const routeToken = `${location.pathname}:${location.search}:${location.hash}` @@ -118,6 +121,7 @@ export function DesktopController() { settingsOpen, toggleCommandCenter } = useOverlayRouting() + const terminalTakeoverActive = chatOpen && terminalTakeover const titlebarToolGroups = useGroupRegistry() const statusbarItemGroups = useGroupRegistry() @@ -466,6 +470,9 @@ export function DesktopController() { const overlays = ( <> + {/* One PTY-backed terminal mounted forever; placeholders + decide where it shows. Toggling fullscreen never rebuilds the shell. */} + { @@ -548,6 +555,12 @@ export function DesktopController() { /> ) + const takeoverTerminalView = ( +
+ +
+ ) + return ( - - + + @@ -650,7 +664,6 @@ export function DesktopController() { diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index c0950c11db8..02c9708ed7f 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -14,13 +14,12 @@ import { SidebarPanelLabel } from '../shell/sidebar-label' import { ProjectTree } from './files/tree' import { useProjectTree } from './files/use-project-tree' -import { $rightSidebarTab, type RightSidebarTabId, setRightSidebarTab } from './store' -import { TerminalTab } from './terminal' +import { $rightSidebarTab, $terminalTakeover, type RightSidebarTabId, setRightSidebarTab } from './store' +import { TerminalSlot } from './terminal/persistent' interface RightSidebarPaneProps { onActivateFile: (path: string) => void onActivateFolder: (path: string) => void - onAddTerminalSelection: (text: string, label?: string) => void onChangeCwd: (path: string) => Promise | void } @@ -38,10 +37,10 @@ const RIGHT_SIDEBAR_TABS: readonly RightSidebarTab[] = [ export function RightSidebarPane({ onActivateFile, onActivateFolder, - onAddTerminalSelection, onChangeCwd }: RightSidebarPaneProps) { const activeTab = useStore($rightSidebarTab) + const terminalTakeover = useStore($terminalTakeover) const currentBranch = useStore($currentBranch).trim() const currentCwd = useStore($currentCwd).trim() const hasCwd = currentCwd.length > 0 @@ -54,6 +53,7 @@ export function RightSidebarPane({ : 'No folder selected' const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd) + const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab const chooseFolder = async () => { const selected = await window.hermesDesktop?.selectPaths({ @@ -82,15 +82,19 @@ export function RightSidebarPane({ } } + const tabs = terminalTakeover + ? RIGHT_SIDEBAR_TABS.filter(tab => tab.id !== 'terminal') + : RIGHT_SIDEBAR_TABS + return (