diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 9cd1b07068e..24dabd72940 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -31,6 +31,14 @@ const { resolveTimeoutMs } = require('./hardening.cjs') +let nodePty = null + +try { + nodePty = require('@homebridge/node-pty-prebuilt-multiarch') +} catch { + nodePty = null +} + const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR if (USER_DATA_OVERRIDE) { const resolvedUserData = path.resolve(USER_DATA_OVERRIDE) @@ -133,6 +141,7 @@ const APP_ICON_PATHS = [ ] let rendererTitleBarTheme = null +const terminalSessions = new Map() function isHexColor(value) { return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value) @@ -2792,7 +2801,21 @@ ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url)) // Always-hidden noise (covers non-git projects too — gitignore would catch // these anyway when present, but we want the same hygiene without one). -const FS_READDIR_HIDDEN = new Set(['.git', '.hg', '.svn', 'node_modules', '__pycache__', '.next', '.venv', 'venv']) +const FS_READDIR_HIDDEN = new Set([ + '.git', + '.hg', + '.svn', + '.cache', + '.next', + '.turbo', + '.venv', + '__pycache__', + 'build', + 'dist', + 'node_modules', + 'target', + 'venv' +]) function findGitRoot(start) { let dir = start @@ -2818,6 +2841,76 @@ function findGitRoot(start) { return null } +function terminalShellCommand() { + if (IS_WINDOWS) { + return { args: [], command: process.env.COMSPEC || 'cmd.exe' } + } + + const configuredShell = process.env.SHELL || '' + const shellPath = + (path.isAbsolute(configuredShell) && fs.existsSync(configuredShell) && configuredShell) || + ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => fs.existsSync(candidate)) || + '/bin/sh' + const shellName = path.basename(shellPath) + const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + + return { args: interactiveArgs, command: shellPath, name: shellName } +} + +function safeTerminalCwd(cwd) { + const candidate = path.resolve(String(cwd || app.getPath('home'))) + + try { + const stat = fs.statSync(candidate) + + return stat.isDirectory() ? candidate : path.dirname(candidate) + } catch { + return app.getPath('home') + } +} + +function terminalShellEnv() { + const env = { ...process.env } + + // Electron is commonly launched through `npm run dev`; do not leak npm's + // managed prefix into a user's interactive shell (nvm/proto warn loudly). + for (const key of Object.keys(env)) { + if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { + delete env[key] + } + } + + env.COLORTERM = env.COLORTERM || 'truecolor' + env.LC_CTYPE = env.LC_CTYPE || 'UTF-8' + env.TERM = 'xterm-256color' + env.TERM_PROGRAM = 'Hermes' + env.TERM_PROGRAM_VERSION = app.getVersion() + + return env +} + +function terminalChannel(id, suffix) { + return `hermes:terminal:${id}:${suffix}` +} + +function disposeTerminalSession(id) { + const sessionInfo = terminalSessions.get(id) + + if (!sessionInfo) { + return false + } + + terminalSessions.delete(id) + + try { + sessionInfo.pty.kill() + } catch { + // Process may already be gone. + } + + return true +} + ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => { const resolved = path.resolve(String(dirPath || '')) @@ -2859,6 +2952,72 @@ ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => { } }) +ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { + if (!nodePty) { + throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.') + } + + const id = crypto.randomUUID() + const { args, command, name } = terminalShellCommand() + const cwd = safeTerminalCwd(payload?.cwd) + const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + const ptyProcess = nodePty.spawn(command, args, { + cols, + cwd, + env: terminalShellEnv(), + name: 'xterm-256color', + rows + }) + + terminalSessions.set(id, { pty: ptyProcess, webContentsId: event.sender.id }) + + const send = (suffix, payload) => { + if (event.sender.isDestroyed()) { + return + } + + event.sender.send(terminalChannel(id, suffix), payload) + } + + ptyProcess.onData(data => send('data', data)) + ptyProcess.onExit(({ exitCode, signal }) => { + terminalSessions.delete(id) + send('exit', { code: exitCode, signal: signal || null }) + }) + event.sender.once('destroyed', () => disposeTerminalSession(id)) + + return { cwd, id, shell: name } +}) + +ipcMain.handle('hermes:terminal:write', (_event, id, data) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + sessionInfo.pty.write(String(data || '')) + + return true +}) + +ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) + + sessionInfo.pty.resize(cols, rows) + + return true +}) +ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) + ipcMain.handle('hermes:updates:check', async () => checkUpdates().catch(error => ({ supported: true, diff --git a/apps/desktop/electron/preload.cjs b/apps/desktop/electron/preload.cjs index de3dbeb8f93..2cc04f5a62e 100644 --- a/apps/desktop/electron/preload.cjs +++ b/apps/desktop/electron/preload.cjs @@ -33,6 +33,24 @@ contextBridge.exposeInMainWorld('hermesDesktop', { fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), + terminal: { + dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), + resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), + start: options => ipcRenderer.invoke('hermes:terminal:start', options), + write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data), + onData: (id, callback) => { + const channel = `hermes:terminal:${id}:data` + const listener = (_event, payload) => callback(payload) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) + }, + onExit: (id, callback) => { + const channel = `hermes:terminal:${id}:exit` + const listener = (_event, payload) => callback(payload) + ipcRenderer.on(channel, listener) + return () => ipcRenderer.removeListener(channel, listener) + } + }, onClosePreviewRequested: callback => { const listener = () => callback() ipcRenderer.on('hermes:close-preview-requested', listener) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a70afbc7979..6f6660744ab 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -45,7 +45,11 @@ "@assistant-ui/react-streamdown": "^0.1.11", "@audiowave/react": "^0.6.2", "@chenglou/pretext": "^0.0.6", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hermes/shared": "file:../shared", + "@homebridge/node-pty-prebuilt-multiarch": "^0.13.1", "@nanostores/react": "^1.1.0", "@nous-research/ui": "^0.13.0", "@radix-ui/react-slot": "^1.2.4", @@ -54,6 +58,11 @@ "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.2.4", "@tanstack/react-query": "^5.100.6", + "@vscode/codicons": "^0.0.45", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -62,7 +71,6 @@ "ignore": "^7.0.5", "katex": "^0.16.45", "leva": "^0.10.1", - "lucide-react": "^0.577.0", "motion": "^12.38.0", "nanostores": "^1.3.0", "radix-ui": "^1.4.3", diff --git a/apps/desktop/src/app/artifacts/index.tsx b/apps/desktop/src/app/artifacts/index.tsx index e00666286ee..6350c35f3b8 100644 --- a/apps/desktop/src/app/artifacts/index.tsx +++ b/apps/desktop/src/app/artifacts/index.tsx @@ -6,7 +6,6 @@ import { ZoomableImage } from '@/components/chat/zoomable-image' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { CopyButton } from '@/components/ui/copy-button' -import { Input } from '@/components/ui/input' import { Pagination, PaginationButton, @@ -19,16 +18,16 @@ import { import { getSessionMessages, listSessions } from '@/hermes' import { sessionTitle } from '@/lib/chat-runtime' import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link' -import { FileImage, FileText, FolderOpen, Layers3, Link2, RefreshCw, Search, X } from '@/lib/icons' +import { Codicon } from '@/components/ui/codicon' +import { FileImage, FileText, FolderOpen, Layers3, Link2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { notifyError } from '@/store/notifications' import type { SessionInfo, SessionMessage } from '@/types/hermes' import { useRouteEnumParam } from '../hooks/use-route-enum-param' +import { PageSearchShell } from '../page-search-shell' import { sessionRoute } from '../routes' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' -import { titlebarHeaderBaseClass } from '../shell/titlebar' -import type { SetTitlebarToolGroup } from '../shell/titlebar-controls' type ArtifactKind = 'image' | 'file' | 'link' type ArtifactFilter = 'all' | ArtifactKind @@ -363,12 +362,10 @@ const itemsLabel = (f: ArtifactFilter) => (f === 'link' ? 'links' : f === 'file' interface ArtifactsViewProps extends React.ComponentProps<'section'> { setStatusbarItemGroup?: SetStatusbarItemGroup - setTitlebarToolGroup?: SetTitlebarToolGroup } export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, - setTitlebarToolGroup, ...props }: ArtifactsViewProps) { const navigate = useNavigate() @@ -412,24 +409,6 @@ export function ArtifactsView({ void refreshArtifacts() }, [refreshArtifacts]) - useEffect(() => { - if (!setTitlebarToolGroup) { - return - } - - setTitlebarToolGroup('artifacts', [ - { - disabled: refreshing, - icon: , - id: 'refresh-artifacts', - label: refreshing ? 'Refreshing artifacts' : 'Refresh artifacts', - onSelect: () => void refreshArtifacts() - } - ]) - - return () => setTitlebarToolGroup('artifacts', []) - }, [refreshArtifacts, refreshing, setTitlebarToolGroup]) - useEffect(() => { setImagePage(1) setFilePage(1) @@ -523,127 +502,115 @@ export function ArtifactsView({ } return ( -
-
-

Artifacts

- {counts.all} found -
- -
-
-
- setKindFilter('all')} - /> - setKindFilter('image')} - /> - setKindFilter('file')} - /> - setKindFilter('link')} - /> -
-
- - setQuery(event.target.value)} - placeholder="Search artifacts..." - value={query} - /> - {query && ( - - )} -
+ + setKindFilter('all')} + /> + setKindFilter('image')} + /> + setKindFilter('file')} + /> + setKindFilter('link')} + /> + + } + onSearchChange={setQuery} + searchPlaceholder="Search artifacts..." + searchValue={query} + searchTrailingAction={ + + } + > + {!artifacts ? ( + + ) : visibleArtifacts.length === 0 ? ( +
+
+
No artifacts found
+
+ Generated images and file outputs will appear here as sessions produce them.
- - {!artifacts ? ( - - ) : visibleArtifacts.length === 0 ? ( -
-
-
No artifacts found
-
- Generated images and file outputs will appear here as sessions produce them. -
-
-
- ) : ( -
-
- {visibleImageArtifacts.length > 0 && ( -
-
- +
+ {visibleImageArtifacts.length > 0 && ( +
+
+ +
+
+ {pagedImageArtifacts.map(artifact => ( + navigate(sessionRoute(sessionId))} /> -
-
- {pagedImageArtifacts.map(artifact => ( - navigate(sessionRoute(sessionId))} - /> - ))} -
-
- )} + ))} +
+
+ )} - {visibleFileArtifacts.length > 0 && ( -
-
- -
-
- -
-
- )} -
+ {visibleFileArtifacts.length > 0 && ( +
+
+ +
+
+ +
+
+ )}
- )} -
-
+ + )} + ) } @@ -712,8 +679,8 @@ function FilterButton({ return (