mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
* refactor(desktop): dock terminal under chat and simplify file rail
Keep the right rail focused on file browsing while moving the persistent terminal into the chat column bottom slot, and make terminal colors follow the active light/dark mode instead of a fixed Solarized palette.
* fix(desktop): make the terminal a resizable, themed side pane
- Move the terminal into a resizable pane (viewport-% widths) that shares
<main>'s stacking context, so its drag handle no longer sits under the
fixed terminal overlay; works on either rail side.
- Restore +x on node-pty's spawn-helper before the first spawn to fix
"posix_spawnp failed" on macOS prebuilds (real cause; drop the redundant
shell-candidate retry loop).
- Gate terminal open/fit/start on document.fonts.ready and strip leading
blank rows (re-armed before the resize Ctrl-L redraw) so the prompt sits
flush at the top with no starship add_newline gap.
- Inherit the app editor-surface color as the terminal background.
- Bind Ctrl+` (⌃` on macOS) to toggle the terminal; add a palette entry.
* feat(desktop): show platform hotkey hints in the command palette
- Render each palette item's live binding as a <KbdGroup> hint via a new
comboTokens() helper (mac shows ⌘/⌃/⌥/⇧, every other platform shows
Ctrl/Alt/Shift — never a ⌘ on PC).
- Default the terminal toggle to ⌘` / Ctrl+` (the ~ key) on both platforms.
- Drop the hardcoded (⌘⏎) baked into the composer steer tooltip; render it
platform-aware with formatCombo instead.
* fix(desktop): drop the active check on the command-palette terminal item
* fix(desktop): remove active/check states from the command palette
* fix(desktop): allow ⌥/Shift-drag selection over mouse-mode TUIs
Full-screen apps (hermes --tui, vim) enable mouse reporting, so a plain
drag can't select text and ⌘/Ctrl+L (add-selection-to-chat) had nothing
to send. Enable macOptionClickForcesSelection so ⌥-drag on macOS (Shift
elsewhere) forces a native selection over mouse-mode apps.
* feat(desktop): tell the in-pane agent it's embedded in the GUI
Set HERMES_DESKTOP_TERMINAL=1 on the terminal pane's shell env and surface
it in build_environment_hints, so a hermes/--tui launched inside the pane
knows it's next to the GUI chat and that ⌥/Shift-drag + ⌘/Ctrl+L sends a
selection to the composer. Distinct from HERMES_DESKTOP (agent backend).
* refactor(desktop): drop the redundant Ctrl+` terminal-toggle fallback
The toggle now ships as mod+` on both platforms, so the standard combo
index handles it — the bespoke fallback (and its stale 'old default'
comment) is dead weight.
* fix(desktop): read live terminal selection for ⌘/Ctrl+L
A redraw-heavy TUI (spinners/clocks) outruns onSelectionChange, leaving the
React selection state empty so the state-gated shortcut listener never
attached and ⌘L no-op'd. Always listen and read xterm's live selection (with
a native fallback) at press time; only swallow the key when there's text to
send. Drops the now-redundant custom key handler.
* feat(desktop): make any agent aware it's in the Hermes desktop GUI
Generalize the runtime-surface hint: fire for HERMES_DESKTOP (the backend
powering the GUI chat) as well as HERMES_DESKTOP_TERMINAL (a hermes in the
embedded terminal pane), so it's about being inside the desktop GUI, not
about being a TUI. The terminal-pane selection note stays pane-specific.
* feat(desktop): give the GUI agent a read_terminal tool
The in-app terminal buffer lives in the renderer (xterm), so expose it to the
chat agent over the same blocking bridge clarify uses: read_terminal emits
terminal.read.request, the renderer serializes the buffer (visible screen by
default, or a start_line/count range against total_lines) and answers
terminal.read.respond. Gated to the GUI via HERMES_DESKTOP.
Also restores the flipped-layout titlebar inset fix (app-shell +
desktop-controller) for terminal/preview rails at the window's left edge.
* chore(desktop): trim read_terminal comments
* feat(desktop): add a terminal toggle to the statusbar
The file rail lost its terminal icon, leaving ⌘` and the command palette
as the only ways in. Add a one-click toggle to the statusbar's left
cluster, mirroring the command-center item: it reads $terminalTakeover so
it lights up while the pane is open and stays in sync with the hotkey, and
is gated to chat view (the only place the pane can show).
* fix(desktop): relabel the terminal header button to what it does
The in-pane button claimed a focus/split fullscreen toggle ("Focus
terminal view" / "Return to split view", screen-full/normal icons), but
the terminal is just a resizable side pane — there's no fullscreen. The
button only mounts while the pane is open, so the focus branch was dead
and clicking it merely closed the terminal. Relabel to "Hide terminal"
with a close icon, drop the dead conditional and the now-unused takeover
read.
* fix(desktop): move the terminal toggle next to the version item
Relocate it from the left cluster to the right of the statusbar, just
left of the client version item.
* feat(desktop): default the terminal to PowerShell on Windows
Prefer pwsh (7+) then Windows PowerShell 5.1 over cmd.exe, falling back to
comspec only when neither is present. -NoLogo drops the startup banner so
the prompt sits flush like the POSIX shells.
* feat(desktop): show a persistent divider on the terminal pane
The resize sash only painted on hover, so the terminal/chat boundary was
invisible at rest. Add an opt-in `divider` prop to Pane that paints a thin
resting hairline on the resize edge (side-aware, so it tracks the rail when
the layout flips) and enable it on the terminal pane.
* refactor(desktop): resolve the terminal shell instead of hardcoding it
Make shell selection a real resolver: an explicit override wins
(HERMES_DESKTOP_SHELL on both platforms, $SHELL on POSIX), otherwise
auto-detect the best installed shell — pwsh > Windows PowerShell 5.1 > cmd
on Windows, zsh > bash > sh on POSIX. A shared shellSpecFor() picks the
interactive flags by family, so an overridden bash/pwsh/cmd all launch
correctly.
* fix(desktop): repaint the terminal on light/dark switch
Setting term.options.theme updated colors for the DOM renderer but not the
WebGL one, which caches glyph colors in a texture atlas — so already-drawn
cells kept their old palette after a mode switch. Hold the WebglAddon in a
ref and clear its atlas when the theme changes.
* fix(desktop): match the terminal palette to VS Code Light+/Dark+
Adopt VS Code's exact default ANSI palette (the terminalColorRegistry
defaults), enable minimumContrastRatio: 4.5 so foregrounds are clamped
against the background the way the integrated terminal does, and key the
light/dark choice off renderedMode (the painted surface) instead of
resolvedMode so it can't invert. The canvas + inset paint the live skin
surface (--ui-editor-surface-background) so the terminal blends with the
app and follows light/dark, while the contrast clamp keeps colors crisp.
* fix(desktop): tighten command palette search to substring matching
cmdk's default fuzzy scorer matched anything with the query letters
scattered across an item, so e.g. "color" never narrowed to color
entries. Add a substring filter: every typed word must literally appear
in an item's value/keywords, keeping results tight and predictable.
* fix(desktop): blend the terminal header into the skin surface
The persistent-terminal overlay painted the static palette background
(#1e1e1e/#ffffff), so the transparent header strip revealed a near-black
slab above the surface-colored body. Paint the overlay with the live
--ui-editor-surface-background so header and body read as one pane.
* fix(desktop): re-resolve the terminal surface on skin switch
The canvas surface only re-resolved on light/dark change, so switching
skins at the same mode left the WebGL canvas painted with the old tint
until reload. Key the resolve off themeName too. Also trim the palette
comments.
* chore(desktop): drop redundant terminal theming header comment
199 lines
9.1 KiB
TypeScript
199 lines
9.1 KiB
TypeScript
import { useStore } from '@nanostores/react'
|
|
import type { CSSProperties, ReactNode } from 'react'
|
|
import { useSyncExternalStore } from 'react'
|
|
|
|
import { NotificationStack } from '@/components/notifications'
|
|
import { PaneShell } from '@/components/pane-shell'
|
|
import { SidebarProvider } from '@/components/ui/sidebar'
|
|
import { useMediaQuery } from '@/hooks/use-media-query'
|
|
import {
|
|
$fileBrowserOpen,
|
|
$panesFlipped,
|
|
$sidebarOpen,
|
|
FILE_BROWSER_DEFAULT_WIDTH,
|
|
FILE_BROWSER_PANE_ID,
|
|
setSidebarOpen
|
|
} from '@/store/layout'
|
|
import { $paneWidthOverride } from '@/store/panes'
|
|
import { $connection } from '@/store/session'
|
|
import { isSecondaryWindow } from '@/store/windows'
|
|
|
|
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
|
|
|
import { KeybindPanel } from './keybind-panel'
|
|
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
|
|
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
|
|
import { TitlebarControls, type TitlebarTool } from './titlebar-controls'
|
|
|
|
interface AppShellProps {
|
|
children: ReactNode
|
|
leftStatusbarItems?: readonly StatusbarItem[]
|
|
leftTitlebarTools?: readonly TitlebarTool[]
|
|
// Fixed-position overlays that must share <main>'s stacking context so pane
|
|
// resize handles (z-20) paint above them. The persistent terminal lives here:
|
|
// hoisting it to the root `overlays` layer (sibling of <main>, z above z-3)
|
|
// would cover every pane's drag handle.
|
|
mainOverlays?: ReactNode
|
|
onOpenSettings: () => void
|
|
overlays?: ReactNode
|
|
// Rails that sit at the window's left edge in the flipped layout but never
|
|
// force-collapse to hover-reveal overlays — so they cover the top-left traffic
|
|
// lights (and zero the titlebar inset) even below the collapse breakpoint.
|
|
previewPaneOpen?: boolean
|
|
statusbarItems?: readonly StatusbarItem[]
|
|
terminalPaneOpen?: boolean
|
|
titlebarTools?: readonly TitlebarTool[]
|
|
}
|
|
|
|
// Renderer-side fallback so layout snaps even when the main-process fullscreen event
|
|
// hasn't landed yet (e.g. dev reloads, before the IPC bridge is wired).
|
|
function subscribeWindowSize(cb: () => void) {
|
|
window.addEventListener('resize', cb)
|
|
window.addEventListener('fullscreenchange', cb)
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', cb)
|
|
window.removeEventListener('fullscreenchange', cb)
|
|
}
|
|
}
|
|
|
|
const viewportIsFullscreen = () =>
|
|
window.innerWidth >= window.screen.width && window.innerHeight >= window.screen.height
|
|
|
|
export function AppShell({
|
|
children,
|
|
leftStatusbarItems,
|
|
leftTitlebarTools,
|
|
mainOverlays,
|
|
onOpenSettings,
|
|
overlays,
|
|
previewPaneOpen = false,
|
|
statusbarItems,
|
|
terminalPaneOpen = false,
|
|
titlebarTools
|
|
}: AppShellProps) {
|
|
const sidebarOpen = useStore($sidebarOpen)
|
|
const fileBrowserOpen = useStore($fileBrowserOpen)
|
|
const panesFlipped = useStore($panesFlipped)
|
|
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
|
|
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
|
|
const connection = useStore($connection)
|
|
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
|
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
|
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
|
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
|
// on macOS, where window controls sit on the left and are reported via
|
|
// windowButtonPosition instead). The right tool cluster has to clear them.
|
|
const nativeOverlayWidth = connection?.nativeOverlayWidth ?? 0
|
|
const titlebarToolsRight = nativeOverlayWidth > 0 ? `${nativeOverlayWidth}px` : '0.75rem'
|
|
|
|
// The inset clears the top-left titlebar buttons when nothing covers the
|
|
// window's left edge. Default layout: the sessions sidebar sits there.
|
|
// Flipped layout: the file browser does instead. Both force-collapse to a
|
|
// hover-reveal overlay (0px track) below the collapse breakpoint, so the edge
|
|
// is uncovered there regardless of their stored open state. A standalone
|
|
// session window renders no sidebar at all, so its edge is always uncovered.
|
|
const collapsibleLeftPaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
|
|
// The terminal + preview rails never force-collapse, so when they're the
|
|
// leftmost open pane (flipped layout) they cover the edge even when narrow.
|
|
const persistentLeftPaneOpen = panesFlipped && (terminalPaneOpen || previewPaneOpen)
|
|
|
|
const leftEdgePaneOpen =
|
|
!isSecondaryWindow() && ((!narrowViewport && collapsibleLeftPaneOpen) || persistentLeftPaneOpen)
|
|
|
|
const titlebarContentInset = leftEdgePaneOpen
|
|
? 0
|
|
: titlebarControls.left + TITLEBAR_HEIGHT + Math.round(TITLEBAR_HEIGHT / 2)
|
|
|
|
// The static system cluster (haptics, profiles, settings, right-sidebar) is
|
|
// hardcoded in TitlebarControls. Pane-supplied tools (preview's group) render
|
|
// in a separate cluster anchored further left.
|
|
//
|
|
// Width math has to include the `gap-x-1` (0.25rem) between buttons:
|
|
// N buttons + (N - 1) inner gaps, plus one extra 0.25rem of breathing room
|
|
// between the pane-tool cluster and the system cluster so they don't sit
|
|
// flush against each other. Modeled as N gaps (N - 1 inner + 1 trailing)
|
|
// to keep the formula generic for any pane-tool count.
|
|
const SYSTEM_TOOL_COUNT = 4
|
|
const paneToolCount = titlebarTools?.filter(tool => !tool.hidden).length ?? 0
|
|
const systemToolsWidth = `calc(${SYSTEM_TOOL_COUNT} * (var(--titlebar-control-size) + 0.25rem))`
|
|
|
|
const fileBrowserWidth =
|
|
fileBrowserWidthOverride !== undefined ? `${fileBrowserWidthOverride}px` : FILE_BROWSER_DEFAULT_WIDTH
|
|
|
|
// Where the pane-tool cluster's right edge sits, measured from the inner
|
|
// titlebar padding (--titlebar-tools-right). Two anchors:
|
|
// - file-browser closed → flush against static cluster's left edge
|
|
// - file-browser open → flush against the file-browser pane's left edge
|
|
// (= preview pane's right edge)
|
|
const previewToolbarGap = fileBrowserOpen ? fileBrowserWidth : systemToolsWidth
|
|
|
|
// Used by the drag region to know where the rightmost interactive element
|
|
// ends. When pane tools are present, that's `gap + paneCount * controlSize
|
|
// + paneCount * 0.25rem` (the leftmost button is at `tools-right + gap +
|
|
// paneCount * (size + gap-x-1)`). Otherwise the static cluster's footprint
|
|
// is enough.
|
|
const titlebarToolsWidth =
|
|
paneToolCount > 0
|
|
? `calc(${previewToolbarGap} + ${paneToolCount} * (var(--titlebar-control-size) + 0.25rem))`
|
|
: systemToolsWidth
|
|
|
|
return (
|
|
<SidebarProvider
|
|
className="h-screen min-h-0 flex-col bg-background"
|
|
onOpenChange={setSidebarOpen}
|
|
open={sidebarOpen}
|
|
style={
|
|
{
|
|
// Alias for shadcn <Sidebar> descendants. Resolves to the chat-sidebar
|
|
// pane track via PaneShell's emitted --pane-chat-sidebar-width.
|
|
'--sidebar-width': 'var(--pane-chat-sidebar-width)',
|
|
'--titlebar-height': `${TITLEBAR_HEIGHT}px`,
|
|
'--titlebar-content-inset': `${titlebarContentInset}px`,
|
|
'--titlebar-controls-left': `${titlebarControls.left}px`,
|
|
'--titlebar-controls-top': `${titlebarControls.top}px`,
|
|
'--titlebar-tools-right': titlebarToolsRight,
|
|
'--titlebar-tools-width': titlebarToolsWidth,
|
|
// Anchor for the pane-tool cluster's right edge in TitlebarControls.
|
|
// Sourced from the layout store rather than the PaneShell-emitted
|
|
// --pane-*-width vars because the titlebar is a sibling of PaneShell
|
|
// and CSS variables resolve at the consumer's scope.
|
|
'--shell-preview-toolbar-gap': previewToolbarGap
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
|
|
|
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
|
<PaneShell className="min-h-0 flex-1">
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-0 top-0 z-1 h-(--titlebar-height) w-(--titlebar-controls-left) [-webkit-app-region:drag]"
|
|
/>
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute top-0 z-1 h-(--titlebar-height) left-[calc(var(--titlebar-controls-left)+(var(--titlebar-control-size)*2)+0.75rem)] right-[calc(var(--titlebar-tools-right)+var(--titlebar-tools-width)+0.75rem)] [-webkit-app-region:drag]"
|
|
/>
|
|
|
|
{children}
|
|
</PaneShell>
|
|
|
|
{/* Fixed overlays scoped to main's stacking context (terminal). Rendered
|
|
after PaneShell so it paints over pane content, but its z stays under
|
|
the panes' z-20 resize handles, keeping every pane resizable. */}
|
|
{mainOverlays}
|
|
|
|
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
|
</main>
|
|
|
|
{overlays}
|
|
|
|
{/* Keybind map dialog (titlebar ⌨ button / ⌘/). */}
|
|
<KeybindPanel />
|
|
|
|
{/* Mounted at the shell root (after overlays) so success/error toasts
|
|
surface above every route and overlay — not just the chat view. */}
|
|
<NotificationStack />
|
|
</SidebarProvider>
|
|
)
|
|
}
|