From d67a438fecf23bff6edff1bc63c5ad2b56de60e9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 16 May 2026 19:21:33 -0500 Subject: [PATCH] feat: glass ui pass --- apps/desktop/electron/main.cjs | 161 +++- apps/desktop/electron/preload.cjs | 18 + apps/desktop/package.json | 10 +- apps/desktop/src/app/artifacts/index.tsx | 280 +++--- .../src/app/chat/composer/attachments.tsx | 13 +- .../app/chat/composer/completion-drawer.tsx | 37 +- .../src/app/chat/composer/context-menu.tsx | 9 +- .../src/app/chat/composer/controls.tsx | 16 +- .../src/app/chat/composer/drop-affordance.ts | 2 + apps/desktop/src/app/chat/composer/focus.ts | 103 +++ apps/desktop/src/app/chat/composer/index.tsx | 316 +++---- .../src/app/chat/composer/inline-refs.ts | 91 ++ .../src/app/chat/composer/queue-panel.tsx | 5 +- .../src/app/chat/composer/rich-editor.ts | 2 +- .../src/app/chat/composer/trigger-popover.tsx | 56 +- .../app/chat/hooks/use-composer-actions.ts | 58 +- apps/desktop/src/app/chat/index.tsx | 50 +- .../app/chat/right-rail/preview-console.tsx | 6 +- .../src/app/chat/right-rail/preview-file.tsx | 8 +- .../src/app/chat/right-rail/preview-pane.tsx | 15 +- .../src/app/chat/right-rail/preview.tsx | 31 +- apps/desktop/src/app/chat/sidebar/index.tsx | 667 +++++++++++--- .../app/chat/sidebar/session-actions-menu.tsx | 25 +- .../src/app/chat/sidebar/session-row.tsx | 112 ++- apps/desktop/src/app/command-center/index.tsx | 2 +- apps/desktop/src/app/cron/index.tsx | 158 ++-- apps/desktop/src/app/desktop-controller.tsx | 89 +- apps/desktop/src/app/file-browser/index.tsx | 173 ---- apps/desktop/src/app/messaging/index.tsx | 108 ++- .../src/app/overlays/overlay-search-input.tsx | 55 +- .../src/app/overlays/overlay-split-layout.tsx | 16 +- .../desktop/src/app/overlays/overlay-view.tsx | 10 +- apps/desktop/src/app/page-search-shell.tsx | 43 + apps/desktop/src/app/profiles/index.tsx | 7 +- .../files}/ipc.ts | 0 .../files}/tree.tsx | 64 +- .../files}/use-project-tree.test.ts | 0 .../files}/use-project-tree.ts | 2 +- apps/desktop/src/app/right-sidebar/index.tsx | 290 +++++++ apps/desktop/src/app/right-sidebar/store.ts | 9 + .../src/app/right-sidebar/terminal/index.tsx | 58 ++ .../app/right-sidebar/terminal/selection.ts | 57 ++ .../terminal/use-terminal-session.ts | 241 ++++++ .../src/app/session/hooks/use-cwd-actions.ts | 68 +- .../app/session/hooks/use-message-stream.ts | 21 +- .../app/session/hooks/use-prompt-actions.ts | 5 +- .../app/session/hooks/use-session-actions.ts | 53 +- .../src/app/settings/appearance-settings.tsx | 34 +- apps/desktop/src/app/settings/constants.ts | 4 +- .../src/app/settings/gateway-settings.tsx | 18 +- .../src/app/settings/keys-settings.tsx | 5 +- apps/desktop/src/app/settings/primitives.tsx | 24 +- apps/desktop/src/app/settings/types.ts | 4 +- apps/desktop/src/app/shell/app-shell.tsx | 24 +- .../app/shell/hooks/use-statusbar-items.tsx | 35 +- apps/desktop/src/app/shell/sidebar-label.tsx | 9 +- .../src/app/shell/statusbar-controls.tsx | 10 +- .../src/app/shell/titlebar-controls.tsx | 57 +- apps/desktop/src/app/shell/titlebar.ts | 6 +- apps/desktop/src/app/skills/index.tsx | 122 +-- apps/desktop/src/app/types.ts | 7 +- apps/desktop/src/components/ThemeControls.tsx | 200 +++-- .../assistant-ui/directive-text.tsx | 11 +- .../components/assistant-ui/markdown-text.tsx | 77 +- .../src/components/assistant-ui/thread.tsx | 811 +++++++++++++++--- .../assistant-ui/tool-fallback-model.ts | 50 +- .../components/assistant-ui/tool-fallback.tsx | 276 +++--- .../desktop/src/components/chat/code-card.tsx | 82 ++ .../src/components/chat/diff-lines.tsx | 51 ++ .../src/components/chat/disclosure-row.tsx | 28 +- apps/desktop/src/components/chat/intro.tsx | 16 +- .../src/components/chat/shiki-highlighter.tsx | 103 ++- .../components/desktop-onboarding-overlay.tsx | 12 +- apps/desktop/src/components/notifications.tsx | 7 +- .../src/components/pane-shell/pane-shell.tsx | 8 +- apps/desktop/src/components/ui/checkbox.tsx | 4 +- apps/desktop/src/components/ui/codicon.tsx | 20 + apps/desktop/src/components/ui/dialog.tsx | 16 +- .../src/components/ui/disclosure-caret.tsx | 20 + .../src/components/ui/dropdown-menu.tsx | 24 +- apps/desktop/src/components/ui/kbd.tsx | 37 + apps/desktop/src/components/ui/pagination.tsx | 8 +- apps/desktop/src/components/ui/select.tsx | 6 +- apps/desktop/src/components/ui/sheet.tsx | 18 +- apps/desktop/src/global.d.ts | 19 + apps/desktop/src/hooks/use-resize-observer.ts | 15 +- apps/desktop/src/lib/chat-messages.ts | 3 +- apps/desktop/src/lib/chat-runtime.ts | 6 + apps/desktop/src/lib/icons.ts | 2 +- apps/desktop/src/lib/markdown-code.ts | 59 ++ apps/desktop/src/store/composer.ts | 126 ++- apps/desktop/src/store/layout.ts | 41 +- apps/desktop/src/store/session.ts | 2 + apps/desktop/src/styles.css | 556 ++++++++++-- apps/desktop/src/themes/context.tsx | 77 +- apps/desktop/src/themes/presets.ts | 40 +- apps/desktop/src/types/hermes.ts | 1 + apps/desktop/vite.config.ts | 9 +- hermes_cli/web_server.py | 4 + hermes_state.py | 19 +- package-lock.json | 165 ++-- tests/hermes_cli/test_web_server.py | 23 + tests/test_hermes_state.py | 18 + tui_gateway/server.py | 43 +- 104 files changed, 5173 insertions(+), 1919 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/drop-affordance.ts create mode 100644 apps/desktop/src/app/chat/composer/focus.ts create mode 100644 apps/desktop/src/app/chat/composer/inline-refs.ts delete mode 100644 apps/desktop/src/app/file-browser/index.tsx create mode 100644 apps/desktop/src/app/page-search-shell.tsx rename apps/desktop/src/app/{file-browser => right-sidebar/files}/ipc.ts (100%) rename apps/desktop/src/app/{file-browser => right-sidebar/files}/tree.tsx (69%) rename apps/desktop/src/app/{file-browser => right-sidebar/files}/use-project-tree.test.ts (100%) rename apps/desktop/src/app/{file-browser => right-sidebar/files}/use-project-tree.ts (98%) create mode 100644 apps/desktop/src/app/right-sidebar/index.tsx create mode 100644 apps/desktop/src/app/right-sidebar/store.ts create mode 100644 apps/desktop/src/app/right-sidebar/terminal/index.tsx create mode 100644 apps/desktop/src/app/right-sidebar/terminal/selection.ts create mode 100644 apps/desktop/src/app/right-sidebar/terminal/use-terminal-session.ts create mode 100644 apps/desktop/src/components/chat/code-card.tsx create mode 100644 apps/desktop/src/components/chat/diff-lines.tsx create mode 100644 apps/desktop/src/components/ui/codicon.tsx create mode 100644 apps/desktop/src/components/ui/disclosure-caret.tsx create mode 100644 apps/desktop/src/components/ui/kbd.tsx 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 (