Merge pull request #81552 from NousResearch/bb/hud-mode

HUD mode: a chrome-free floating chat for the desktop app
This commit is contained in:
brooklyn! 2026-08-08 11:43:44 -05:00 committed by GitHub
commit 31cedb4830
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2068 additions and 53 deletions

View File

@ -9034,6 +9034,289 @@ function closePetOverlay() {
petOverlayWindow = null
}
// ── HUD mode ────────────────────────────────────────────────────────────────
//
// The chrome-free floating chat: a transparent, frameless, always-on-top
// window showing only the composer and its scrollback, so Hermes can be driven
// while the user works in another app.
//
// Unlike the pet overlay / quick entry, this is a FULL app renderer with its
// own gateway — the same thing createInstanceWindow() spawns, reshaped. That
// is deliberate: the HUD renders the real chat surface, so its composer is the
// app's composer (slash commands, attachments, queue, voice) instead of a
// lookalike that drifts. Entering HUD mode hides the main window; leaving
// restores it.
let hudWindow = null
// Whether the main window was visible when HUD mode was entered, so exiting
// puts the desktop back as it was rather than raising a window the user had
// already minimized.
let hudRestoreMainWindow = false
// The session the HUD is currently on, reported by its renderer whenever the
// selection changes. Leaving HUD mode is a HANDOFF, not just a window close:
// the gateway binds a session's event stream to exactly one socket, so the
// turn the HUD started is streaming to the HUD's socket and the app window
// hears nothing. The app has to re-resume that session to take the stream
// back, and it can only do that if it knows which session to ask for — the
// HUD may have switched sessions, or started a new one the app has never
// seen. Main is the only party that outlives the HUD's renderer, so it holds
// the id and hands it over in the close broadcast.
let hudSessionId = null
// A wide, short bar parked near the bottom of the active display — the shape
// of a game chat frame, and where one belongs. Defaults only: once the user
// moves or resizes the HUD, hud-state.json wins (same pattern as the main
// window's window-state.json).
const HUD_WIDTH = 620
const HUD_HEIGHT = 320
const HUD_BOTTOM_MARGIN = 72
const HUD_STATE_PATH = path.join(app.getPath('userData'), 'hud-state.json')
function readHudState() {
try {
const raw = JSON.parse(fs.readFileSync(HUD_STATE_PATH, 'utf8'))
if (
[raw?.x, raw?.y, raw?.width, raw?.height].every(v => Number.isFinite(v)) &&
raw.width >= 380 &&
raw.height >= 160
) {
return raw
}
} catch {
// First run / unreadable — fall through to defaults.
}
return null
}
function persistHudState() {
if (!hudWindow || hudWindow.isDestroyed()) {
return
}
try {
const { x, y, width, height } = hudWindow.getNormalBounds()
fs.mkdirSync(path.dirname(HUD_STATE_PATH), { recursive: true })
writeFileAtomic(HUD_STATE_PATH, JSON.stringify({ x, y, width, height }, null, 2))
} catch (err) {
rememberLog(`[hud-state] persist failed: ${err?.message || err}`)
}
}
const schedulePersistHudState = debounce(persistHudState, 250)
function hudBounds() {
// Remembered spot first — validated against the LIVE displays so a HUD
// parked on an unplugged monitor comes back on-screen instead of lost.
const saved = readHudState()
if (saved) {
const onScreen = screen.getAllDisplays().some(d => {
const a = d.workArea
return (
saved.x < a.x + a.width - 40 &&
saved.x + saved.width > a.x + 40 &&
saved.y < a.y + a.height - 40 &&
saved.y + saved.height > a.y + 40
)
})
if (onScreen) {
return saved
}
}
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint())
const area = display?.workArea
if (!area) {
return { width: HUD_WIDTH, height: HUD_HEIGHT, x: undefined, y: undefined }
}
const width = Math.min(HUD_WIDTH, area.width)
const height = Math.min(HUD_HEIGHT, area.height)
return {
width,
height,
x: Math.round(area.x + (area.width - width) / 2),
y: Math.round(Math.max(area.y, area.y + area.height - height - HUD_BOTTOM_MARGIN))
}
}
function hudUrl(sessionId) {
const query = '?win=hud'
const route = sessionId ? `#/${encodeURIComponent(sessionId)}` : '#/'
if (DEV_SERVER) {
return `${DEV_SERVER.endsWith('/') ? DEV_SERVER.slice(0, -1) : DEV_SERVER}/${query}${route}`
}
return `${pathToFileURL(resolveRendererIndex()).toString()}${query}${route}`
}
// Tell every window whether the HUD is up, so a toggle in any of them reads
// the truth even when the HUD is closed from its own side (⌘W / its exit row).
// Carries the HUD's session so the app window can re-home onto it on the way
// out (see hudSessionId).
function broadcastHudState(open) {
const payload = { open, sessionId: hudSessionId }
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('hermes:hud:changed', payload)
}
}
}
function spawnHudWindow(sessionId) {
const win = new BrowserWindow({
...hudBounds(),
minWidth: 380,
minHeight: 160,
frame: false,
transparent: true,
resizable: true,
movable: true,
minimizable: false,
maximizable: false,
fullscreenable: false,
// Same rationale as the pet overlay: on Windows/Linux keep the helper out
// of the taskbar/alt-tab list; on macOS use an NSPanel so the frameless
// window never becomes the app's cmd-tab anchor.
skipTaskbar: !IS_MAC,
hasShadow: false,
alwaysOnTop: true,
type: IS_MAC ? 'panel' : undefined,
// Clips the vibrancy layer to the HUD's silhouette rather than a hard
// rectangle — the frost stops where the window's corners do.
roundedCorners: true,
// Vibrancy must keep rendering while the window is BLURRED: streaming under
// another app is the whole feature, and the default 'followWindow' kills
// the frost the moment something else takes focus.
visualEffectState: 'active',
hiddenInMissionControl: IS_MAC,
show: false,
backgroundColor: '#00000000',
// The full chat webPreferences — this window streams a real transcript, so
// it needs everything a chat window needs (preload bridge, autoplay for
// voice, the shared throttling contract).
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
})
win.setAlwaysOnTop(true, IS_MAC ? 'floating' : 'screen-saver')
win.setHiddenInMissionControl?.(true)
try {
win.setVisibleOnAllWorkspaces(
true,
IS_MAC ? { visibleOnFullScreen: true, skipTransformProcessType: true } : undefined
)
} catch {
// Not supported everywhere — best effort.
}
// Streaming into a window that is ALWAYS blurred (the user is in another
// app) is the entire feature, so it gets the same stream-aware unthrottling
// every chat window does.
streamThrottle.register(win)
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
// Remember where the user parks and sizes it (debounced — these fire many
// times mid-drag).
win.on('moved', schedulePersistHudState)
win.on('resized', schedulePersistHudState)
win.once('ready-to-show', () => {
if (win.isDestroyed()) {
return
}
win.show()
win.focus()
// Step the app aside: the HUD IS the surface now.
if (hudRestoreMainWindow && mainWindow && !mainWindow.isDestroyed()) {
mainWindow.hide()
}
})
win.on('closed', () => {
if (hudWindow === win) {
hudWindow = null
}
// Closed from its own side (⌘W) — put the app back so the user is never
// left with no surface, and correct every window's toggle.
restoreMainWindowFromHud()
broadcastHudState(false)
})
loadWindowUrl(win, hudUrl(sessionId), 'HUD')
return win
}
// Put the app window back the way HUD mode found it.
function restoreMainWindowFromHud() {
if (!hudRestoreMainWindow) {
return
}
hudRestoreMainWindow = false
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show()
}
}
function openHudWindow(sessionId) {
if (hudWindow && !hudWindow.isDestroyed()) {
// Already up, but pointed somewhere else — switch it rather than just
// raising it. Asking for HUD mode from another tab means "put THIS
// conversation in the HUD", and a plain focus leaves the wrong one there.
if (sessionId && sessionId !== hudSessionId) {
hudSessionId = sessionId
hudWindow.webContents.send('hermes:hud:goto', sessionId)
// Keep every window's idea of where the HUD is pointed in step, so the
// toggle keeps reading "switch" vs "dismiss" correctly.
broadcastHudState(true)
}
focusWindow(hudWindow)
return hudWindow
}
hudRestoreMainWindow = Boolean(mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible())
hudSessionId = sessionId || null
hudWindow = spawnHudWindow(sessionId)
broadcastHudState(true)
return hudWindow
}
function closeHudWindow() {
const win = hudWindow
hudWindow = null
if (win && !win.isDestroyed()) {
// Null'd first so the 'closed' handler doesn't broadcast a second time.
win.removeAllListeners('closed')
win.close()
}
restoreMainWindowFromHud()
broadcastHudState(false)
if (mainWindow && !mainWindow.isDestroyed()) {
focusWindow(mainWindow)
}
}
// ── Quick Entry ─────────────────────────────────────────────────────────────
//
// A global shortcut summons a small frameless always-on-top composer from
@ -9702,6 +9985,51 @@ ipcMain.on('hermes:pet-overlay:control', (_event, payload) => {
mainWindow.webContents.send('hermes:pet-overlay:control', payload)
})
// --- HUD mode (chrome-free floating chat) -----------------------------------
ipcMain.handle('hermes:hud:open', async (_event, request) => {
openHudWindow(typeof request?.sessionId === 'string' ? request.sessionId : null)
return { ok: true }
})
// Real frosted glass behind the band — the thing CSS backdrop-filter cannot do,
// because Chromium composites a transparent window's page against nothing and
// the desktop is not in its backdrop root. Vibrancy IS the window's content
// view, so it frosts the whole rectangle; the HUD's layout leaves no dead
// margins for that reason, and the renderer only turns it on while the band is
// showing (idle HUD mode must be the bar and nothing else).
ipcMain.handle('hermes:hud:vibrancy', (_event, on) => {
if (hudWindow && !hudWindow.isDestroyed() && IS_MAC) {
hudWindow.setVibrancy(on ? 'hud' : null)
}
return { ok: true }
})
// Let clicks fall through the HUD wherever it isn't really there. An
// always-on-top window eats every click inside its rectangle, and most of that
// rectangle is a faded-out band over whatever the user is actually working in.
// `forward` keeps mousemove flowing so the renderer can re-arm when the cursor
// reaches the bar.
ipcMain.on('hermes:hud:ignore-mouse', (_event, ignore) => {
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true })
}
})
// The HUD renderer reporting which session it is on, so the close broadcast
// can hand it back to the app window (see hudSessionId).
ipcMain.on('hermes:hud:session', (event, sessionId) => {
if (hudWindow && !hudWindow.isDestroyed() && event.sender === hudWindow.webContents) {
hudSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null
}
})
ipcMain.handle('hermes:hud:close', async () => {
closeHudWindow()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:reset', async () => {
// Renderer's "Reload and retry" path. Clear the latched failure and
// reset connection state so the next startHermes() call restarts the
@ -12001,6 +12329,17 @@ app.on('before-quit', event => {
closePetOverlay()
wakeIndicatorController.close()
// Same for the HUD — an always-on-top panel outliving the app would leave a
// floating composer with nothing behind it. Close it directly rather than via
// closeHudWindow(): that also re-shows the main window, which is wrong on the
// way out (and `hudRestoreMainWindow` may still be armed from entering HUD).
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.removeAllListeners('closed')
hudWindow.destroy()
}
hudWindow = null
// Same for the Quick Entry composer — and release its global accelerator so a
// quitting Hermes never keeps another app's chord hostage.
closeQuickEntryWindow()

View File

@ -46,6 +46,30 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
}
},
// HUD mode: the chrome-free floating chat. A full app renderer (own gateway)
// sized as a floating bar, so it mounts the real composer. Main owns the
// window; `onChanged` keeps every window's toggle truthful.
hud: {
open: request => ipcRenderer.invoke('hermes:hud:open', request),
close: () => ipcRenderer.invoke('hermes:hud:close'),
setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore),
setVibrancy: on => ipcRenderer.invoke('hermes:hud:vibrancy', on),
// The HUD tells main which session it is on; main hands that back to the
// app window when the HUD closes, so the app can re-home onto it.
setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId),
onGoto: callback => {
const listener = (_event, sessionId) => callback(sessionId)
ipcRenderer.on('hermes:hud:goto', listener)
return () => ipcRenderer.removeListener('hermes:hud:goto', listener)
},
onChanged: callback => {
const listener = (_event, state) => callback(state)
ipcRenderer.on('hermes:hud:changed', listener)
return () => ipcRenderer.removeListener('hermes:hud:changed', listener)
}
},
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
// shortcut + the persisted preference; the quick window only captures text
// and hands it back, and the primary renderer submits it through the normal

View File

@ -98,9 +98,10 @@ export function installZoomReassertOnWindowEvents(win, reassert, platform = proc
}
/**
* Zoom-wiring decision per window kind. Chat windows (main + session) keep
* global UI zoom; the pet overlay and the Quick Entry composer opt out because
* they size their own OS window and inheriting zoom would crop/overflow them.
* Zoom-wiring decision per window kind. Chat windows (main + session + the HUD)
* keep global UI zoom; the pet overlay and the Quick Entry composer opt out
* because they size their own OS window and inheriting zoom would crop or
* overflow them.
*
* Extracted so the "helper windows opt out, everything else opts in" contract is
* unit-testable without booting a BrowserWindow or reading source.

View File

@ -9,10 +9,18 @@ import type { TriggerState } from './text-utils'
export const COMPOSER_STACK_BREAKPOINT_PX = 320
// Above the stack breakpoint but still cramped: the model pill sheds its label
// for its chevron icon (freeing ~120px) so the controls stop crowding the input
// before the whole row has to stack. Progressive collapse: full pill → icon
// pill → stacked.
export const COMPOSER_COMPACT_PILL_PX = 440
// for its chevron icon so the controls stop crowding the input before the whole
// row has to stack. Progressive collapse: full pill → icon pill → stacked.
//
// Sized off what the controls actually cost, because guessing put the two
// stages on top of each other. With the full pill the controls take ~284px
// (pill 111 + the icon cluster), so at the old 440 the inline input was ~156px
// — barely over its 128px minimum. A few words wrapped, wrapping is what
// stacks the row, and the pill's chevron arrived at the same moment the row
// gave up, which is the one thing progressive collapse is supposed to avoid.
// At 560 the label goes while the input still has ~276px, and the ~110px the
// chevron frees is spent keeping the row single for another stretch.
export const COMPOSER_COMPACT_PILL_PX = 560
// A single editor line is ~28px (--composer-input-min-height 1.625rem + 0.5rem
// vertical padding). Anything taller means the text wrapped to a second line,

View File

@ -3,7 +3,14 @@ import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useSta
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import {
type ComposerAttachment,
type ComposerDraftSyncMode,
onComposerDraftSyncRequest,
reloadPersistedDrafts,
stashSessionDraft,
takeSessionDraft
} from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
import {
@ -391,6 +398,38 @@ export function useComposerDraft({
}
}, [activeQueueSessionKey]) // eslint-disable-line react-hooks/exhaustive-deps
// The HUD handoff's two verbs. Entering HUD mode flushes this editor's text
// into the shared stash so the HUD's composer boots with it; leaving repaints
// from the stash so whatever the HUD typed (or sent, clearing it) is what the
// app window shows. The per-session swap effect above can't cover either one:
// the session scope doesn't change, so it never re-consults the stash.
const syncDraft = (mode: ComposerDraftSyncMode) => {
if (mode === 'flush') {
window.clearTimeout(draftPersistTimerRef.current)
pendingDraftPersistRef.current = null
stashAt(draftScopeRef.current, syncDraftFromEditor())
return
}
reloadPersistedDrafts()
const stashed = takeSessionDraft(draftScopeRef.current)
loadIntoComposer(stashed.text, stashed.attachments)
}
const syncDraftRef = useRef(syncDraft)
syncDraftRef.current = syncDraft
useEffect(
() =>
onComposerDraftSyncRequest(({ mode, target: requested }) => {
if (requested === target) {
syncDraftRef.current(mode)
}
}),
[target]
)
// pagehide is load-bearing: React skips effect cleanups on reload, so Cmd+R
// inside the debounce/rAF window would drop trailing keystrokes without this.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)

View File

@ -8,7 +8,6 @@ import {
COMPOSER_SURFACE_HEIGHT_VAR,
setSurfaceVar
} from '@/app/chat/surface-vars'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { COMPOSER_COMPACT_PILL_PX, COMPOSER_SINGLE_LINE_MAX_PX, COMPOSER_STACK_BREAKPOINT_PX } from '../composer-utils'
@ -43,7 +42,6 @@ export function useComposerMetrics({
const [tight, setTight] = useState(false)
// Wider than `tight`: the pill goes icon-only before the row has to stack.
const [compactPill, setCompactPill] = useState(false)
const narrow = useMediaQuery('(max-width: 30rem)')
// Edge signals, not the live text: these only re-render when emptiness / the
// presence of a non-trailing newline actually flips, so typing within a line
@ -191,7 +189,17 @@ export function useComposerMetrics({
}
}, [composerRef])
// Pill compacts on real width (tile/pane), OR when stacked for any reason
// (viewport-narrow / wrapped) so the controls row never over-runs.
return { compactPill: compactPill || narrow || tight, stacked: expanded || narrow || tight }
// Both decisions come from the composer's OWN measured width, never the
// viewport's. There used to be a `(max-width: 30rem)` media query in here as
// well, and it quietly outranked everything: any window under 480px stacked
// the row AND compacted the pill in the same instant, regardless of how much
// room the composer actually had. That collapsed the whole progressive ladder
// into one step for small windows — HUD mode is ~470px, so it never saw the
// ladder at all — and it disagreed with the measured breakpoints (320 to
// stack) by 160px. The ResizeObserver knows the real width; the viewport is
// not a proxy for it.
//
// The pill still compacts whenever the row stacks, so the controls row can't
// over-run once it has the width to itself.
return { compactPill: compactPill || tight, stacked: expanded || tight }
}

View File

@ -43,7 +43,7 @@ import {
sessionPinId,
shouldMigrateComposerScope
} from '@/store/session'
import { isSecondaryWindow, isWatchWindow } from '@/store/windows'
import { isAuxiliaryWindow, isWatchWindow } from '@/store/windows'
import type { ModelOptionsResponse } from '@/types/hermes'
import { primaryRouteSelectedSessionId, routeSessionId } from '../routes'
@ -135,7 +135,7 @@ function ChatHeader({
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
// are compact side panels — they drop the session-actions header + border
// entirely. A brand-new draft has nothing to pin/delete/rename either.
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
if (isAuxiliaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
return null
}
@ -397,7 +397,7 @@ export const ChatView = memo(function ChatView({
// scratch window, not the full-height empty state.
const showIntro =
isPrimary &&
!isSecondaryWindow() &&
!isAuxiliaryWindow() &&
freshDraftReady &&
!isRoutedSessionView &&
!selectedSessionId &&

View File

@ -58,6 +58,7 @@ import { $reviewOpen, closeReview, openReview, REVIEW_PANE_ID } from '@/store/re
import { $currentCwd, $selectedStoredSessionId, $sessions, $yoloActive, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
import { $statusbarVisible } from '@/store/statusbar-prefs'
import { isHudWindow } from '@/store/windows'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchPreviewTiles } from '../chat/preview-tile'
@ -69,6 +70,7 @@ import {
watchSessionTiles,
WorkspaceTabMenu
} from '../chat/session-tile'
import { HudShell } from '../hud/hud-shell'
import { $terminalTakeover, setTerminalTakeover } from '../right-sidebar/store'
import { $workspaceIsPage } from '../routes'
@ -688,6 +690,18 @@ export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
const statusbarVisible = useStore($statusbarVisible)
// HUD mode is the SAME app with its frame removed: the wiring (gateway,
// sessions, streams, submit) mounts identically, and only the shell around
// the chat surface differs. Branching here rather than at the window entry
// is what keeps the HUD's composer the real composer.
if (isHudWindow()) {
return (
<ContribWiring>
<HudShell />
</ContribWiring>
)
}
return (
<SidebarProvider
className="h-screen min-h-0 flex-col bg-background"

View File

@ -5,7 +5,7 @@ import { setPetScale } from '@/store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
import { $sessions } from '@/store/session'
import { $attentionSessionIds } from '@/store/session-states'
import { isSecondaryWindow } from '@/store/windows'
import { isAuxiliaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'
@ -31,7 +31,7 @@ export function usePetBridge({ requestGateway, resumeSession, submitText }: PetB
requestGatewayRef.current = requestGateway
useEffect(() => {
if (isSecondaryWindow()) {
if (isAuxiliaryWindow()) {
return
}

View File

@ -9,7 +9,7 @@ import {
} from '@/store/quick-entry'
import { $gatewayState, $sessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { isSecondaryWindow } from '@/store/windows'
import { isAuxiliaryWindow } from '@/store/windows'
interface QuickEntryBridgeParams {
startFreshSessionDraft: () => void
@ -57,7 +57,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
startFreshRef.current = startFreshSessionDraft
useEffect(() => {
if (isSecondaryWindow()) {
if (isAuxiliaryWindow()) {
return
}
@ -101,7 +101,7 @@ export function useQuickEntryBridge({ startFreshSessionDraft, submitText }: Quic
// Push gateway truth into the quick window whenever it changes: connection
// state gates its input; the recent-session list feeds its target picker.
useEffect(() => {
if (isSecondaryWindow()) {
if (isAuxiliaryWindow()) {
return
}

View File

@ -67,7 +67,7 @@ import {
} from '@/store/session'
import { clearSessionTodos, setSessionTodos, todosForHydration } from '@/store/todos'
import { armWakeWord, stopClientCapture } from '@/store/wake-word'
import { isSecondaryWindow } from '@/store/windows'
import { isAuxiliaryWindow, isHudWindow } from '@/store/windows'
import { useSkinCommand } from '@/themes/use-skin-command'
import { closeWorkspaceTab } from '../chat/close-tab'
@ -77,6 +77,7 @@ import { CommandPalette } from '../command-palette'
import { useGatewayBoot } from '../gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from '../gateway/hooks/use-gateway-request'
import { useKeybinds } from '../hooks/use-keybinds'
import { useHudHandoff } from '../hud/handoff'
import { ModelPickerOverlay } from '../model-picker-overlay'
import { ModelVisibilityOverlay } from '../model-visibility-overlay'
import { mainChatOccupied, openSession } from '../open-session'
@ -624,6 +625,9 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// session / new session), and it hears gateway truth from this window.
useQuickEntryBridge({ startFreshSessionDraft, submitText })
// Leaving HUD mode hands this window the session back (see hud/handoff).
useHudHandoff({ navigate, resumeSession })
// Clear a failed turn's red error banner. Errors are renderer-local (never
// persisted): a bare error placeholder is dropped entirely; a partial-output
// failure keeps its content and sheds the error. Both the runtime cache AND
@ -1006,18 +1010,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
} as CSSProperties
}
>
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
{/* HUD mode has no titlebar to hang these off the clusters are
`fixed`, so without this they'd float over the chat as orphaned
buttons. Exits are the H toggle and W. */}
{!isHudWindow() && (
<TitlebarControls
leftTools={leftTitlebarTools}
onOpenSettings={() => navigate(SETTINGS_ROUTE)}
tools={rightTitlebarTools}
/>
)}
{children}
</div>
{/* The full real overlay set (mirrors DesktopController's `overlays`). */}
<RemoteDisplayBanner />
{!isSecondaryWindow() && <DesktopInstallOverlay />}
{!isSecondaryWindow() && (
{!isAuxiliaryWindow() && <DesktopInstallOverlay />}
{!isAuxiliaryWindow() && (
<DesktopOnboardingOverlay
enabled={gatewayState === 'open'}
onCompleted={() => {
@ -1113,11 +1122,13 @@ export function ContribWiring({ children }: { children: ReactNode }) {
{/* Toasts above everything. */}
<NotificationStack />
{/* Petdex floating mascot — renders nothing unless installed + enabled. */}
<FloatingPet />
{/* Petdex floating mascot renders nothing unless installed + enabled.
Never in the HUD: that window is the chat bar and nothing else. */}
{!isHudWindow() && <FloatingPet />}
{/* Single persistent xterm host chasing the terminal pane's slot rect. */}
<PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />
{/* Single persistent xterm host chasing the terminal pane's slot rect.
The HUD has no terminal pane, so it has nothing to chase. */}
{!isHudWindow() && <PersistentTerminal onAddSelectionToChat={composer.addTerminalSelectionAttachment} />}
</ContribWiringContext.Provider>
)
}

View File

@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router'
import { closeActiveTab } from '@/app/chat/close-tab'
import { hudTargetSessionId } from '@/app/hud/handoff'
import { setTerminalTakeover } from '@/app/right-sidebar/store'
import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-sidebar/terminal/terminals'
import {
@ -24,6 +25,7 @@ import {
findPrevious as findPreviousMatch,
openFindBar
} from '@/store/find-in-page'
import { toggleHud } from '@/store/hud'
import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds'
import {
requestSessionSearchFocus,
@ -227,6 +229,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.toggleReview': toggleReview,
'view.toggleStatusbar': toggleStatusbarVisible,
'view.showFiles': showFiles,
'view.toggleHud': () => toggleHud(hudTargetSessionId()),
'view.showTerminal': () => togglePaneVisible('terminal'),
// Create first so the pane's open-effect ensure sees a non-empty set and
// doesn't also spawn one — net effect is exactly one fresh terminal.

View File

@ -0,0 +1,88 @@
import { type RefObject, useEffect } from 'react'
/**
* Let clicks fall through the HUD everywhere it isn't really there.
*
* The one thing about HUD mode that CSS cannot express, because it is a
* property of the OS WINDOW rather than of the page. It reads the engaged state
* off the DOM (`:focus-within`) rather than keeping a second copy, so there is
* one answer to "is the HUD in use" and the stylesheet owns it.
*
* An always-on-top window eats every click inside its rectangle, visible or
* not and most of the HUD's rectangle is a faded-out band over whatever the
* user is actually working in. `pointer-events: none` doesn't help: that is a
* page-level property, and the click never reaches the page.
*
* So the window itself is made mouse-transparent except where it is genuinely
* interactive: wherever the cursor is over something, and whenever anything in
* the window holds focus. `forward: true` keeps mousemove flowing while
* ignoring, which is what lets it re-arm when the cursor comes back to the bar.
*/
export function useHudClickThrough(rootRef: RefObject<HTMLElement | null>): void {
useEffect(() => {
const root = rootRef.current
const setIgnoreMouse = window.hermesDesktop?.hud?.setIgnoreMouse
if (!root || !setIgnoreMouse) {
return
}
let ignoring: boolean | null = null
// Where the cursor was last seen, so a focus change can re-decide without
// waiting for the next move (blurring with the cursor parked on the bar
// must not make the bar untouchable until you jiggle the mouse).
let point: { x: number; y: number } | null = null
// Hit-test rather than enumerate. Everything the HUD doesn't want to catch
// — the shell's dead space, the sheet, the faded band — is already
// `pointer-events: none`, so anything the document hands back at this point
// is something real: the bar, a control, the exit chip, a popover, a dialog.
// Listing those instead is how links and dialogs ended up unclickable, since
// portalled overlays live outside the shell and moving focus into one takes
// `:focus-within` with it.
const overSomething = () => {
if (!point) {
return false
}
const hit = document.elementFromPoint(point.x, point.y)
return Boolean(hit) && hit !== root && hit !== document.body && hit !== document.documentElement
}
// Focus is asked of the document, not of the shell. A dialog or popover is
// portalled to `document.body`, so focus entering one leaves the shell's
// `:focus-within` — and a HUD that decides it is unused the moment it opens
// a dialog goes mouse-transparent underneath it. Nothing but the HUD lives
// in this window, so any focus at all is the HUD in use. `hasFocus` gates it
// so a stale `activeElement` — the composer keeps it after you click away to
// another app — can't pin the HUD solid forever.
const focused = () => document.hasFocus() && document.activeElement !== document.body
const apply = () => {
const next = !focused() && !overSomething()
if (ignoring !== next) {
ignoring = next
setIgnoreMouse(next)
}
}
const onMove = (event: MouseEvent) => {
point = { x: event.clientX, y: event.clientY }
apply()
}
apply()
window.addEventListener('mousemove', onMove)
document.addEventListener('focusin', apply)
document.addEventListener('focusout', apply)
return () => {
setIgnoreMouse(false)
window.removeEventListener('mousemove', onMove)
document.removeEventListener('focusin', apply)
document.removeEventListener('focusout', apply)
}
}, [rootRef])
}

View File

@ -0,0 +1,64 @@
import { type RefObject, useEffect } from 'react'
/** The caret is in the composer — see the `:has()` rules in styles.css. */
const TYPING_SELECTOR = '[data-slot="composer-rich-input"]:focus'
/**
* Native frost behind the band.
*
* macOS vibrancy, not CSS `backdrop-filter` reaches nothing here, because a
* transparent window's backdrop root is the document and the desktop was never
* in it. Vibrancy is composited by WindowServer BELOW the web contents, which
* is what lets it see the desktop and also what makes it untouchable from the
* page: no mask, clip or stacking order can shape it.
*
* That is survivable because the band is a flat panel. It was NOT survivable
* while the band carried a vertical gradient the frost stayed a slab under a
* fading tint and the top went pale exactly where it should have been
* disappearing, which is what sent the whole thing round in circles. Uniform
* panel, uniform frost.
*
* It still cannot animate, so it is switched while the tint is at full strength
* and can hide the change: on the moment the band is engaged, off the moment the
* hold ends and the opacity fade begins. Letting it outlive the fade leaves bare
* untinted frost on screen a grey blurred rectangle that pops out at the end
* instead of a band fading away.
*
* Engaged means the caret is in the composer, matching the stylesheet. Merely
* holding window focus does not count: activating a window restores focus to
* whatever had it last, so grabbing the bar to drag the HUD would otherwise
* read as sitting down to use it. Queried live rather than tracked from
* document.activeElement, which stays put when the window is blurred and would
* latch the frost on forever once the user had ever typed here.
*/
export function useHudGlass(rootRef: RefObject<HTMLElement | null>, engaged: boolean): void {
useEffect(() => {
const root = rootRef.current
const setVibrancy = window.hermesDesktop?.hud?.setVibrancy
if (!root || !setVibrancy) {
return
}
let on: boolean | null = null
const apply = () => {
const next = engaged || root.querySelector(TYPING_SELECTOR) !== null
if (on !== next) {
on = next
void setVibrancy(next)
}
}
apply()
root.addEventListener('focusin', apply)
root.addEventListener('focusout', apply)
return () => {
void setVibrancy(false)
root.removeEventListener('focusin', apply)
root.removeEventListener('focusout', apply)
}
}, [engaged, rootRef])
}

View File

@ -0,0 +1,126 @@
/**
* HUD app-window handoff.
*
* The gateway binds a session's event stream to exactly ONE socket the last
* one to submit or resume it (`session["transport"]`). The HUD is a full
* renderer with its own socket, so entering HUD mode moves that binding to the
* HUD and the app window stops hearing the session entirely: no deltas, no
* turn-complete, no draft clear. Nothing to poll for either, since mid-turn
* there is nothing persisted to re-pull.
*
* So leaving HUD mode is a re-home, not a window close. The app window resumes
* the session the HUD ended on the existing hydration path, which rebinds the
* transport, reconciles the transcript, and picks up an in-flight turn and
* repaints its composer from the shared draft stash the HUD has been writing.
*/
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
import { reloadPersistedDrafts, requestComposerDraftSync } from '@/store/composer'
import { reportHudSession, watchHudState } from '@/store/hud'
import { $selectedStoredSessionId } from '@/store/session'
import { focusOpenSession, sessionTileDelegate } from '@/store/session-states'
import { isHudWindow } from '@/store/windows'
import { getActiveComposer } from '../chat/composer/focus'
import { openSession, type OpenSessionNavigate } from '../open-session'
import { sessionRoute } from '../routes'
/** Session tiles route on `tile:<storedSessionId>` (see session-tile.tsx). */
const TILE_TARGET_PREFIX = 'tile:'
/**
* The conversation HUD mode should open on: whichever chat surface the user is
* actually looking at.
*
* `$selectedStoredSessionId` is the WORKSPACE pane's session, so reading it
* alone sent the main tab into the HUD no matter which tile was fronted the
* tabs exist precisely so that isn't the same question. `getActiveComposer()`
* already answers it for the focus bus, healing to the visible surface when its
* cached claim is buried or gone, and a tile's routing key IS its stored
* session id.
*/
export function hudTargetSessionId(): null | string {
const target = getActiveComposer()
const tile = target.startsWith(TILE_TARGET_PREFIX) ? target.slice(TILE_TARGET_PREFIX.length) : null
return tile || $selectedStoredSessionId.get()
}
interface HudHandoffParams {
navigate: OpenSessionNavigate
resumeSession: (storedSessionId: string) => unknown
}
/** App-window side: take the session back when the HUD goes away. Also keeps
* the titlebar toggle honest when the HUD is closed from its own side. */
export function useHudHandoff({ navigate, resumeSession }: HudHandoffParams): void {
const paramsRef = useRef<HudHandoffParams>({ navigate, resumeSession })
paramsRef.current = { navigate, resumeSession }
useEffect(() => {
// The HUD's own renderer mounts the same wiring; it is the window going
// away, so it has nothing to re-home.
if (isHudWindow()) {
return
}
return watchHudState(hudSessionId => {
// The HUD may have typed or sent since this window last read the stash.
reloadPersistedDrafts()
const selected = $selectedStoredSessionId.get()
const target = hudSessionId ?? selected
// Somewhere other than the workspace pane. If it is an open tile, front
// it and re-resume THROUGH the tile delegate: the ordinary resume path
// enforces "a session is either main or a tile, never both" and would
// close the tile to take it into main, quietly rearranging tabs the user
// opened on purpose. Otherwise it's a session this window has never seen
// — route to it and let the route resume do the rest, including loading
// its draft as the composer's scope swaps.
if (target && target !== selected) {
const delegate = focusOpenSession(target) === 'tile' ? sessionTileDelegate() : null
if (delegate) {
void delegate.resumeTile(target).catch(() => undefined)
return
}
openSession(target, paramsRef.current.navigate)
return
}
// Same session, so the composer's scope never changes and its
// per-session swap effect will never re-consult the stash. Repaint it.
requestComposerDraftSync('reload')
if (target) {
void paramsRef.current.resumeSession(target)
}
})
}, [])
}
/** HUD side: follow a retarget. Asking for HUD mode from another tab while the
* HUD is already up switches the conversation showing in it. */
export function useHudGoto(navigate: OpenSessionNavigate): void {
const navigateRef = useRef(navigate)
navigateRef.current = navigate
useEffect(() => window.hermesDesktop?.hud?.onGoto?.(id => navigateRef.current(sessionRoute(id))), [])
}
/** HUD side: keep main told which session this window is on. */
export function useReportHudSession(): void {
const selectedStoredSessionId = useStore($selectedStoredSessionId)
useEffect(() => {
if (isHudWindow()) {
reportHudSession(selectedStoredSessionId)
}
}, [selectedStoredSessionId])
}

View File

@ -0,0 +1,379 @@
import { useStore } from '@nanostores/react'
import { type CSSProperties, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { closeHud } from '@/store/hud'
import { $activeSessionAwaitingInput } from '@/store/prompts'
import { $busy, $messages } from '@/store/session'
import { WiredPane } from '../contrib/wiring'
import { titlebarButtonClass } from '../shell/titlebar'
import { useHudClickThrough } from './click-through'
import { useHudGlass } from './glass'
import { useHudGoto, useReportHudSession } from './handoff'
/** How long the transcript lingers at its glanceable opacity after a turn
* lands, or after you let go of the composer before it goes. This is the ONLY
* hold: the CSS carries no transition-delay, because two stacked holds read as
* a third fade state that nobody asked for. Focus keeps it open past this. */
const HUD_RECENT_HOLD_MS = 700
/** Band visibility timings, published to CSS as custom properties so this
* module and the stylesheet cannot drift apart. Reveal is quick it is an
* answer to the user; the fade lingers, then goes slowly. */
const HUD_REVEAL_MS = 110
const HUD_FADE_MS = 180
/** The step DOWN to the glanceable opacity when you let go. Deliberately slower
* than the fade that follows it easing off is a softer gesture than leaving,
* and matching them made the two read as one long dissolve. */
const HUD_DIM_MS = Math.round(HUD_FADE_MS * 1.5)
/** The sheet rolling shut. Shorter than the fade so the panel is already gone
* while the last of the text is still going it reads as the transcript being
* drawn down into the bar rather than the two dissolving in lockstep. */
const HUD_COLLAPSE_MS = Math.round(HUD_FADE_MS * 0.66)
/** Breathing room the sheet keeps above the first row, so the fade has
* somewhere to land. Published to CSS, and used here to work out how much of
* the window the HUD actually occupies. */
const HUD_SHEET_OVERHANG_PX = 12
/** Composer on top, transcript always hanging below it Spotlight's shape,
* rather than flipping to follow the screen edge the HUD is parked against. */
const HUD_THREAD_ALWAYS_BELOW = true
/**
* True for a hold window after any conversation activity (a message landing,
* a stream flushing, a turn starting or ending). The CSS uses it alongside
* :focus-within to decide whether the thread is visible; idle HUD mode is
* just the Spotlight bar.
*
* $messages replaces ~30×/s mid-stream, so activity RESTARTS the timer on
* every flush the thread stays up while a reply is writing and for the hold
* window after it finishes, without a per-flush re-render (state only changes
* on the falsetrue edges).
*/
function useRecentActivity(): [boolean, () => void] {
const [recent, setRecent] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const bumpRef = useRef(() => {})
// eslint-disable-next-line no-restricted-syntax -- timer handle, not an atom mirror
useEffect(() => {
let signature = ''
const bump = () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
setRecent(true)
timerRef.current = setTimeout(() => setRecent(false), HUD_RECENT_HOLD_MS)
}
// Gated on the transcript actually CHANGING, not on the atom being written.
// $messages is republished for plenty of reasons that aren't new content
// (session sync, re-renders, relative timestamps), and re-arming the hold on
// every one of those latched the band open permanently — the fade simply
// never got to start.
const onMessages = () => {
const messages = $messages.get()
const last = messages[messages.length - 1]
const next = `${messages.length}:${last?.id ?? ''}:${last ? chatMessageText(last).length : 0}`
if (next === signature) {
return
}
signature = next
bump()
}
bumpRef.current = bump
// subscribe() fires immediately, so a HUD opened onto an existing
// conversation starts with the thread showing, then fades.
const offMessages = $messages.subscribe(onMessages)
const offBusy = $busy.subscribe(busy => busy && bump())
return () => {
offMessages()
offBusy()
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [])
return [recent, () => bumpRef.current()]
}
/**
* True while the HUD must stay up regardless of the hold timer.
*
* The fade is built for an idle transcript, and there are states where leaving
* is the wrong answer: a clarify/approval/sudo/secret prompt is a question you
* have to answer, and a running turn is progress you asked to watch. Letting
* either fade hands you a surface you cannot use the band goes to zero opacity
* and the window goes mouse-transparent under it, so the prompt is neither
* readable nor clickable.
*
* `recent` alone doesn't cover it: it re-arms on transcript changes, so a long
* tool call with no visible output would time out mid-turn.
*/
function useHudHeld(): boolean {
const awaitingInput = useStore($activeSessionAwaitingInput)
const busy = useStore($busy)
return awaitingInput || busy
}
/**
* HUD mode's shell the chrome-free floating chat.
*
* Deliberately almost nothing: it mounts the SAME wired chat surface the
* workspace pane does, so the composer here IS the app's composer (slash
* commands, `@` refs, attachments, queue, voice, model pill) and the transcript
* is the app's transcript, rendered by the app's renderer. Only the frame
* changes no titlebar, no statusbar, no pane tree, no sidebars.
*
* The shape is macOS Spotlight: at rest, the centered composer bar is the
* whole interface. The thread renders as bare text above it and is
* visibility-gated like a game chat frame shown while a turn is recent or the
* composer has focus, faded out otherwise (see the `[data-hud-shell]` CSS and
* `useRecentActivity`).
*/
export function HudShell() {
const { t } = useI18n()
const [recent, holdBand] = useRecentActivity()
const held = useHudHeld()
// Main holds the session id on this window's behalf, so leaving HUD mode can
// hand the app window back whatever conversation ended up here.
useReportHudSession()
useHudGoto(useNavigate())
// Which screen EDGE the window is parked against. Parked tight to the top,
// the composer flips to the window's top edge and the thread grows DOWN
// (data-hud-edge). Computed here from window.screenY — no IPC: the renderer
// always knows where its window is. Polled because the DOM has no
// window-move event; 300ms is imperceptible for a layout flip.
//
// EDGE-tight, not a midpoint rule: the first cut compared topGap<bottomGap,
// which flips the layout the moment the window crosses the vertical center
// of the screen — reported (correctly) as "flips way too early". Now it
// flips to 'top' only when the HUD is actually parked against the top, and
// back once it clearly leaves — the gap between the two thresholds is
// hysteresis so the layout can't flutter while it's dragged along the line.
const [edge, setEdge] = useState<'bottom' | 'top'>('top')
useEffect(() => {
// Measured on the WINDOW, and flush-only. Flipping is what lets the bar
// reach the top of the screen at all: the window's top edge can sit against
// the menu bar, and the flip moves the composer to that edge. Keying it off
// the visible panel instead meant it could never fire — the panel hugs the
// bar at the bottom of the window — and took the top of the screen away
// with it. FLIP_OFF is just enough hysteresis that the 300ms poll can't
// flutter on sub-pixel jitter while parked.
const FLIP_ON = 0
const FLIP_OFF = 4
const measure = () => {
// TRYING IT: the bar stays on top and the transcript always hangs below,
// wherever the HUD is parked. Flip the constant to re-enable the
// edge-aware layout (the CSS for both orientations is still here).
if (HUD_THREAD_ALWAYS_BELOW) {
setEdge('top')
return
}
// availTop ≈ menu bar / notch inset on macOS; screenY is in full-screen
// coordinates, so "parked at the top" means screenY ≈ availTop, not 0.
const availTop = (window.screen as { availTop?: number }).availTop ?? 0
const topGap = window.screenY - availTop
setEdge(prev => (topGap <= FLIP_ON ? 'top' : topGap >= FLIP_OFF ? 'bottom' : prev))
}
measure()
const timer = setInterval(measure, 300)
window.addEventListener('resize', measure)
return () => {
clearInterval(timer)
window.removeEventListener('resize', measure)
}
}, [])
// Whether the thread actually overflows its band. Gates the band's no-drag
// carve-out (styles.css): a band with nothing to scroll stays part of the
// window's drag region, so a short conversation never blocks moving the HUD.
const [scrollable, setScrollable] = useState(false)
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const root = rootRef.current
if (!root) {
return
}
let viewport: HTMLElement | null = null
const ro = new ResizeObserver(() => measure())
const measure = () => {
const el = viewport ?? root.querySelector<HTMLElement>('[data-slot="aui_thread-viewport"]')
if (el !== viewport) {
viewport = el
if (el) {
ro.observe(el)
if (el.firstElementChild) {
ro.observe(el.firstElementChild)
}
}
}
setScrollable(Boolean(el && el.scrollHeight > el.clientHeight + 4))
// How tall the band actually needs to be. The transcript is packed to the
// bottom, so this is the distance from the topmost visible row down to the
// bar — which is 0 on a fresh session, and the glass then collapses behind
// the bar instead of painting an empty slab over the whole window.
//
// Written straight to the element rather than through state: it changes on
// every stream flush, and the sheet resizing must not re-render the tree.
const rows = el?.querySelectorAll<HTMLElement>('[data-slot="aui_thread-content"] > *:not([data-slot])')
const box = el?.getBoundingClientRect()
// Measured from the bar outward, so it flips with the layout: parked at
// the bottom the transcript grows up from the bar, parked at the top it
// hangs down from it.
const span =
!rows?.length || !box
? 0
: root.dataset.hudEdge === 'top'
? rows[rows.length - 1].getBoundingClientRect().bottom - box.top
: box.bottom - rows[0].getBoundingClientRect().top
root.style.setProperty('--hud-band-height', `${Math.max(0, Math.round(span))}px`)
// …and the bar's real height, which is what the thread has to clear.
// --composer-measured-height would be the obvious source, but it is a
// surface var that never lands here, so the clearance silently fell back
// to the root estimate and reserved ~20px more than the bar occupies —
// a visible hole under the last message.
const bar = root.querySelector<HTMLElement>('[data-slot="composer-dock"]')
const barHeight = bar?.getBoundingClientRect().height ?? 0
if (bar) {
ro.observe(bar)
root.style.setProperty('--hud-bar-height', `${Math.round(barHeight)}px`)
}
void barHeight
}
// The viewport mounts async (lazy chat surface); poll briefly until it
// exists, then let the ResizeObserver own it.
measure()
const probe = setInterval(measure, 500)
return () => {
clearInterval(probe)
ro.disconnect()
}
}, [])
useHudGlass(rootRef, recent || held)
useHudClickThrough(rootRef)
// Force the HOST layers transparent. index.html's pre-paint script writes an
// opaque themed background onto <html> as an INLINE style (the anti-white-
// flash trick), and an inline style beats any stylesheet rule — so without
// this the window is a solid slab and every translucent panel below is just
// glass over a white wall. A style tag with `!important` is what the pet
// overlay and quick entry already do; they get it at mount because they are
// bespoke roots, and the HUD needs the same because it is not.
useEffect(() => {
const style = document.createElement('style')
style.textContent = 'html,body,#root{background:transparent !important;}'
document.head.appendChild(style)
return () => style.remove()
}, [])
return (
<div
className="relative flex h-screen w-screen flex-col overflow-hidden"
data-hud-edge={edge}
data-hud-recent={recent || held ? '' : undefined}
data-hud-scrollable={scrollable ? '' : undefined}
data-hud-shell
// Letting go of the composer re-arms the hold, so the transcript steps
// down to its glanceable opacity and lingers there instead of jumping
// straight from full to gone.
onBlur={holdBand}
ref={rootRef}
style={
{
'--hud-fade': `${HUD_FADE_MS}ms`,
'--hud-collapse': `${HUD_COLLAPSE_MS}ms`,
'--hud-dim': `${HUD_DIM_MS}ms`,
'--hud-reveal': `${HUD_REVEAL_MS}ms`,
'--hud-sheet-overhang': `${HUD_SHEET_OVERHANG_PX}px`
} as CSSProperties
}
>
{/* The band's sheet, on a layer of its own so it can carry the fade
without the app's chat surface having to know about it. FIRST child so
it paints behind the transcript. */}
<div aria-hidden data-hud-glass />
<WiredPane part="chatRoutes" />
{/* The top fade band, as a drag handle. Its text is masked to nothing up
there, so handing the band's mouse input to the window manager costs
no readable content and it gives the HUD a grab area that isn't the
composer.
LAST child on purpose. Electron collects draggable regions by walking
the layout tree in order, uniting `drag` rects and subtracting
`no-drag` ones, so later elements win. Above `WiredPane` this strip
was silently subtracted away by the scrollback's full-height `no-drag`
rect (z-index does not enter into it the region math is rect-based,
not paint-order-based). */}
<div aria-hidden data-hud-drag-strip />
{/* The way back. HUD mode has no titlebar, so without this the only
exits are H and W both invisible. Floats over the scrollback
(which is short and top-fades, so it rarely collides with text) and
carves itself out of the drag region so the click lands. */}
<Tip label={t.titlebar.exitHud}>
<Button
aria-label={t.titlebar.exitHud}
className={`${titlebarButtonClass} absolute right-1.5 top-1.5 z-20 bg-transparent [-webkit-app-region:no-drag]`}
data-hud-exit=""
onClick={closeHud}
size="icon-titlebar"
type="button"
variant="ghost"
>
<Codicon name="screen-normal" />
</Button>
</Tip>
</div>
)
}

View File

@ -656,12 +656,21 @@ export function useMessageStream({
const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden)
const lastVisible = [...nextMessages].reverse().find(m => !m.hidden)
const unresolvedUserTail = lastVisible?.role === 'user'
// Having streamed the reply normally means this window owns the whole
// turn and re-reading stored history would be wasted work. That only
// holds for a turn it STARTED: an adopted one (resumed onto a session
// already running elsewhere) arrives reply-first, with no prompt row,
// so it has to hydrate or the user's own message never shows up.
shouldHydrate =
!completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText)
!completionError &&
!hasInlineError &&
!unresolvedUserTail &&
(state.adoptedRunningTurn || !state.sawAssistantPayload || !finalText)
return {
...state,
messages: nextMessages,
adoptedRunningTurn: false,
streamId: null,
pendingBranchGroup: null,
awaitingResponse: false,

View File

@ -963,6 +963,7 @@ describe('resumeSession failure recovery', () => {
interimBoundaryPending: false,
interrupted: false,
messages: [],
adoptedRunningTurn: false,
model: '',
needsInput: false,
pendingBranchGroup: null,

View File

@ -767,8 +767,17 @@ export function useSessionActions({
} else {
const runtimeInfo = applyRuntimeInfo(activated.info)
let activatedMessages =
activated.messages.length || activated.inflight || activated.queued
// `omit_messages` means the response carries NO transcript, not
// an empty one — the cache is the base and the live projection is
// a tail to graft onto it. Reconciling against the empty list
// instead rebuilds the thread out of the projection alone, so
// activating a session that is mid-turn somewhere else (leaving
// HUD mode is exactly that) collapsed the whole conversation down
// to the in-flight prompt until the turn finished and the
// post-turn hydrate restored it.
let activatedMessages = activated.messages_omitted
? appendLiveSessionProjection(cachedViewState.messages, activated)
: activated.messages.length || activated.inflight || activated.queued
? reconcileAuthoritativeMessages(activated.messages, cachedViewState.messages, activated)
: cachedViewState.messages
@ -805,7 +814,11 @@ export function useSessionActions({
...(runtimeInfo ?? {}),
messages: activatedMessages,
busy: running,
awaitingResponse: running
awaitingResponse: running,
// Adopting someone else's turn: we'll stream its reply
// without ever having received its prompt, so the settle
// path must not take the "I saw it all" shortcut.
adoptedRunningTurn: state.adoptedRunningTurn || running
}),
storedSessionId
)
@ -965,7 +978,15 @@ export function useSessionActions({
? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages)
: currentMessages
const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed)
// Omitted, not empty — same trap as the activate path above.
// The REST prefetch IS the transcript here; the resume payload
// only contributes the live tail, so graft rather than rebuild.
// (Without a usable prefetch there is nothing better to stand
// on, so the projection alone remains the degraded fallback.)
const resumedMessages =
resumed.messages_omitted && prefetchApplied && prefetchMatchesResumedSession
? appendLiveSessionProjection(localSnapshot, resumed)
: reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed)
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
})()
@ -1022,6 +1043,7 @@ export function useSessionActions({
messages: messagesForView,
busy: resumedRunning,
awaitingResponse: resumedRunning && !recoveredInFlightTail,
adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning,
...(inFlightRecovery.applied
? {
sawAssistantPayload: true,

View File

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { type ComponentProps, type MouseEvent, type ReactNode, useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router'
import { hudTargetSessionId } from '@/app/hud/handoff'
import { toggleLayoutEditMode } from '@/components/pane-shell/edit-mode'
import { resetLayoutTree } from '@/components/pane-shell/tree/store'
import { Button } from '@/components/ui/button'
@ -11,6 +12,7 @@ import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $hapticsMuted, toggleHapticsMuted } from '@/store/haptics'
import { toggleHud } from '@/store/hud'
import {
$fileBrowserOpen,
$sidebarOpen,
@ -19,7 +21,7 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import { appViewForPath, isOverlayView, SETTINGS_ROUTE } from '../routes'
import { appViewForPath, isOverlayView } from '../routes'
import { titlebarButtonClass } from './titlebar'
@ -180,6 +182,20 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
},
title: t.titlebar.layoutEditorTitle
},
{
// No `title`: TitlebarToolButton passes `title` to TipKeybindLabel as a
// text OVERRIDE, so a long sentence there replaces the short label and
// crowds the ⌘⇧H hint off the tooltip. Label only — the hint is appended
// from the action registry, same as every other tool here.
actionId: 'view.toggleHud',
icon: <Codicon name="comment-discussion" />,
id: 'hud',
label: t.titlebar.enterHud,
onSelect: () => {
triggerHaptic('open')
toggleHud(hudTargetSessionId())
}
},
{
active: hapticsMuted,
icon: <Codicon name={hapticsMuted ? 'mute' : 'unmute'} />,
@ -187,16 +203,6 @@ export function TitlebarControls({ leftTools = [], tools = [], onOpenSettings }:
label: hapticsMuted ? t.titlebar.unmuteHaptics : t.titlebar.muteHaptics,
onSelect: toggleHaptics
},
{
actionId: 'keybinds.openPanel',
icon: <Codicon name="keyboard" />,
id: 'keybinds',
label: t.titlebar.openKeybinds,
onSelect: () => {
triggerHaptic('open')
navigate(`${SETTINGS_ROUTE}?tab=keybinds`)
}
},
{
actionId: 'nav.settings',
icon: <Codicon name="settings-gear" />,

View File

@ -187,6 +187,12 @@ export interface ClientSessionState {
awaitingResponse: boolean
streamId: string | null
sawAssistantPayload: boolean
/** This window picked up a turn it did not start it resumed onto a session
* that was already running somewhere else (leaving HUD mode, opening a
* pop-out mid-turn). It therefore holds the reply but never received the
* prompt, so the usual "I streamed it, my transcript is complete" shortcut
* is false and the turn must hydrate from stored history when it settles. */
adoptedRunningTurn: boolean
pendingBranchGroup: string | null
interrupted: boolean
/** True after message.interim finalized a bubble in the still-running turn. */

View File

@ -19,6 +19,7 @@ import { useI18n } from '@/i18n'
import { messagePaintWeight } from '@/lib/render-weight'
import { cn } from '@/lib/utils'
import {
$threadScrolledUp,
onScrollToBottomRequest,
onThreadEditClose,
onThreadEditOpen,
@ -410,6 +411,23 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// Floating jump button (outside this subtree) → return to the bottom.
useEffect(() => onScrollToBottomRequest(() => void scrollToBottom()), [scrollToBottom])
// Waking from display: hidden (HUD mode hides the main window; OS hide does
// the same to any window): rAF and ResizeObserver were frozen the whole
// time, so the virtualizer's measurements — and scrollTop itself — are
// stale. If the user was following the bottom, re-anchor once visible;
// leave a scrolled-up reader exactly where they were.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === 'visible' && !$threadScrolledUp.get()) {
requestAnimationFrame(() => void scrollToBottom())
}
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [scrollToBottom])
const endEditHold = useCallback(() => {
scrollRef.current?.removeAttribute('data-editing')
}, [scrollRef])

View File

@ -62,6 +62,19 @@ declare global {
onState: (callback: (payload: PetOverlayStatePayload) => void) => () => void
onControl: (callback: (payload: PetOverlayControl) => void) => () => void
}
// HUD mode: the chrome-free floating chat. A FULL app renderer with its
// own gateway (like an instance window), sized and skinned as a floating
// bar — so it mounts the real composer rather than a lookalike. Main
// owns the window; `onChanged` keeps every window's toggle truthful.
hud?: {
open: (request?: { sessionId?: null | string }) => Promise<{ ok: boolean }>
close: () => Promise<{ ok: boolean }>
setIgnoreMouse: (ignore: boolean) => void
setVibrancy: (on: boolean) => Promise<{ ok: boolean }>
setSession: (sessionId: null | string) => void
onGoto: (callback: (sessionId: string) => void) => () => void
onChanged: (callback: (state: { open: boolean; sessionId: null | string }) => void) => () => void
}
// Quick Entry: a global-hotkey mini composer window. Main owns the OS
// shortcut registration + the persisted preference (it must restore the
// shortcut on a cold launch without the renderer visiting Settings), so

View File

@ -172,7 +172,8 @@ export const ar = defineLocale({
unmuteHaptics: 'تفعيل الاهتزازات',
openSettings: 'فتح الإعدادات',
openStarmap: 'فتح خريطة الذاكرة',
openKeybinds: 'اختصارات لوحة المفاتيح',
enterHud: 'وضع HUD',
exitHud: 'إنهاء وضع HUD',
layoutEditor: 'محرر التخطيط',
layoutEditorTitle: 'محرر التخطيط — انقر مع ⌘ لإعادة ضبط التخطيط'
},

View File

@ -202,7 +202,8 @@ export const en: Translations = {
unmuteHaptics: 'Unmute haptics',
openSettings: 'Open settings',
openStarmap: 'Open memory graph',
openKeybinds: 'Keyboard shortcuts',
enterHud: 'HUD mode',
exitHud: 'Exit HUD mode',
layoutEditor: 'Layout editor',
layoutEditorTitle: 'Layout editor — ⌘-click resets the layout'
},
@ -261,6 +262,7 @@ export const en: Translations = {
'view.toggleReview': 'Toggle review pane',
'view.toggleStatusbar': 'Toggle status bar',
'view.showFiles': 'Show file browser',
'view.toggleHud': 'Toggle HUD mode',
'view.showTerminal': 'Toggle terminal',
'view.newTerminal': 'New terminal',
'view.nextTerminal': 'Next terminal',

View File

@ -244,7 +244,8 @@ export interface Translations {
unmuteHaptics: string
openSettings: string
openStarmap: string
openKeybinds: string
enterHud: string
exitHud: string
layoutEditor: string
layoutEditorTitle: string
}

View File

@ -197,7 +197,8 @@ export const zh: Translations = {
unmuteHaptics: '开启触感反馈',
openSettings: '打开设置',
openStarmap: '打开记忆图谱',
openKeybinds: '键盘快捷键',
enterHud: 'HUD 模式',
exitHud: '退出 HUD 模式',
layoutEditor: '布局编辑器',
layoutEditorTitle: '布局编辑器 — ⌘ 点击重置布局'
},

View File

@ -52,6 +52,7 @@ export function createClientSessionState(
awaitingResponse: false,
streamId: null,
sawAssistantPayload: false,
adoptedRunningTurn: false,
pendingBranchGroup: null,
interrupted: false,
interimBoundaryPending: false,

View File

@ -116,6 +116,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ⌘G — "g" for git; the review pane is the source-control view.
{ id: 'view.toggleReview', category: 'view', defaults: ['mod+g'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
// ⌘⇧H — "h" for HUD. Enters/leaves the chrome-free floating chat: the app
// window steps aside and a composer + live reply float over whatever the
// user is working in. Ships bound because the whole point is leaving the app
// without reaching for it — but the titlebar button is the discoverable door.
{ id: 'view.toggleHud', category: 'view', defaults: ['mod+shift+h'] },
// Control+` everywhere (literal `ctrl`, NOT `mod`): ⌘` is macOS-reserved for
// cycling app windows, so VS Code/Cursor/Zed bind the terminal to Ctrl+` on
// every platform. Off macOS `ctrl` folds to `mod` (= Ctrl), so it's unchanged.

View File

@ -151,6 +151,84 @@ function loadPersistedDraftTexts(): [string, SessionDraft][] {
const draftsBySession = new Map<string, SessionDraft>(loadPersistedDraftTexts())
/**
* Re-read the persisted drafts written by ANOTHER window into this one's map.
*
* Drafts are per-renderer state backed by shared localStorage, and the map
* above is read exactly once at module load. Two windows on the same session
* (HUD mode the app window) therefore diverge the moment either one types:
* whichever window mounted first keeps its stale copy forever, so text typed
* in the HUD is simply gone when you return to the app.
*
* Merge, don't clobber the local map may hold attachments (never persisted)
* that the incoming text-only snapshot can't know about.
*/
export function reloadPersistedDrafts(): void {
const incoming = new Map(loadPersistedDraftTexts())
for (const [key, draft] of incoming) {
const local = draftsBySession.get(key)
draftsBySession.set(key, local?.attachments.length ? { ...local, text: draft.text } : draft)
}
// A key that vanished from storage was cleared (sent) in the other window.
for (const key of [...draftsBySession.keys()]) {
if (!incoming.has(key)) {
draftsBySession.delete(key)
}
}
}
// localStorage `storage` events fire across Electron BrowserWindows of the
// same origin, so the other window's write is the sync signal.
if (typeof window !== 'undefined') {
window.addEventListener('storage', event => {
if (event.key === SESSION_DRAFTS_STORAGE_KEY) {
reloadPersistedDrafts()
}
})
}
/**
* Push a composer's live text into the shared stash (`flush`), or repaint it
* from the stash (`reload`).
*
* Both halves of the HUD handoff need this. The stash is the only draft state
* two windows share, but a mounted composer only consults it when its session
* scope changes so entering HUD mode has to flush the app window's in-editor
* text down to the stash before the HUD boots and reads it, and leaving has to
* repaint the app's editor from whatever the HUD left behind (usually empty,
* because the HUD sent it).
*
* Dispatched synchronously, unlike the focus bus: the flush must complete
* before the HUD window is created.
*/
const DRAFT_SYNC_EVENT = 'hermes:composer-draft-sync'
export type ComposerDraftSyncMode = 'flush' | 'reload'
interface ComposerDraftSyncDetail {
mode: ComposerDraftSyncMode
target: string
}
export function requestComposerDraftSync(mode: ComposerDraftSyncMode, target = 'main'): void {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent<ComposerDraftSyncDetail>(DRAFT_SYNC_EVENT, { detail: { mode, target } }))
}
}
export function onComposerDraftSyncRequest(handler: (detail: ComposerDraftSyncDetail) => void): () => void {
if (typeof window === 'undefined') {
return () => undefined
}
const listener = (event: Event) => handler((event as CustomEvent<ComposerDraftSyncDetail>).detail)
window.addEventListener(DRAFT_SYNC_EVENT, listener)
return () => window.removeEventListener(DRAFT_SYNC_EVENT, listener)
}
function persistDraftTexts() {
try {
const entries = [...draftsBySession]

View File

@ -0,0 +1,99 @@
/**
* HUD mode the chrome-free floating chat.
*
* A transparent, frameless, always-on-top window showing nothing but the REAL
* composer with the reply scrolling above it, so Hermes can be driven while
* the user works in another app (Figma, a browser).
*
* It is NOT a puppet window. Unlike the pet overlay / quick entry, the HUD is
* a full app renderer with its own gateway the same thing `openWindow()`
* spawns, just reshaped. That is the whole design: the HUD renders `ChatView`
* in its `hud` variant, so the composer IS the app's composer (attachments,
* slash commands, queue, voice, model pill) rather than a lookalike that
* drifts. This module owns only the mode flag and the window lifecycle.
*/
import { atom } from 'nanostores'
import { requestComposerDraftSync } from '@/store/composer'
import { isHudWindow } from '@/store/windows'
/** Whether a HUD window is currently up. In the HUD's own renderer this is
* always true (it IS the HUD); in the main window it tracks the child so the
* titlebar toggle reads correctly.
*
* Deliberately NOT persisted. The HUD is a live window main owns, so it can
* never outlive the app a remembered `true` from the last run just makes the
* first toggle a no-op ("the button does nothing after a restart"). Main
* broadcasts the truth on every change, which is the only authority there is. */
export const $hudActive = atom(isHudWindow())
/** True only in the HUD window itself the renderer flag that swaps the app
* shell for the slim floating layout. Constant for the window's life, so it
* never invalidates a render path mid-session. */
export const $hudMode = atom(isHudWindow())
/** Which conversation the HUD is showing, as far as this window knows. Lets the
* toggle tell "switch the HUD to this tab" apart from "dismiss the HUD". */
export const $hudSession = atom<null | string>(null)
/** True when the shell exposes HUD mode (desktop only). */
export const canUseHud = (): boolean =>
typeof window !== 'undefined' && typeof window.hermesDesktop?.hud?.open === 'function'
export function openHud(sessionId?: null | string): void {
const api = window.hermesDesktop?.hud
if (!api) {
return
}
// Push whatever is half-typed here into the shared draft stash BEFORE the
// HUD window exists, so its composer boots with the text rather than racing
// a cross-window storage event that lands after it has already painted.
requestComposerDraftSync('flush')
$hudActive.set(true)
$hudSession.set(sessionId ?? null)
void api.open({ sessionId: sessionId ?? null })
}
/** Leave HUD mode. Callable from either window main closes the child, the
* HUD closes itself; both restore the app window. */
export function closeHud(): void {
const api = window.hermesDesktop?.hud
if (!api) {
return
}
$hudActive.set(false)
$hudSession.set(null)
void api.close()
}
export const toggleHud = (sessionId?: null | string) => ($hudActive.get() ? closeHud() : openHud(sessionId))
/** Tell main which session this HUD is on. Main holds it (the HUD's renderer
* doesn't outlive the window) and hands it back in the close broadcast so the
* app window knows what to re-home onto. */
export const reportHudSession = (sessionId: null | string): void => window.hermesDesktop?.hud?.setSession?.(sessionId)
/**
* Track the HUD window's real state so the titlebar toggle can't go stale when
* the HUD is closed from its own side (W, its exit button), and hand the
* app window the session the HUD ended on. Returns a disposer; no-ops outside
* Electron.
*/
export function watchHudState(onClosed?: (sessionId: null | string) => void): () => void {
const off = window.hermesDesktop?.hud?.onChanged?.(({ open, sessionId }) => {
$hudActive.set(open)
$hudSession.set(open ? sessionId : null)
if (!open) {
onClosed?.(sessionId)
}
})
return off ?? (() => {})
}

View File

@ -29,6 +29,31 @@ export function isSecondaryWindow(): boolean {
let watchWindowCache: boolean | null = null
// A "hud" window is HUD mode: the chrome-free floating chat. Unlike the pet
// overlay / quick entry it is a FULL app renderer with its own gateway — the
// flag only tells the shell to render the slim floating layout (composer +
// scrollback) instead of the pane tree, so the composer it shows is the real
// one. Read from location.search for the same reason as the flag above.
let hudWindowCache: boolean | null = null
export function isHudWindow(): boolean {
if (hudWindowCache !== null) {
return hudWindowCache
}
let result = false
try {
result = new URLSearchParams(window.location.search).get('win') === 'hud'
} catch {
result = false
}
hudWindowCache = result
return result
}
// A "watch" window spectates a session that is being driven elsewhere (a
// running subagent). It resumes lazily — the gateway registers history + a
// transport for the live mirror without building an agent, so opening it is
@ -51,6 +76,13 @@ export function isWatchWindow(): boolean {
return result
}
// True for any window that is NOT the primary app instance — a secondary
// session window or the HUD. Single-claim channels (the quick-entry capture
// bridge, the pet overlay control bridge) and the install/onboarding overlays
// belong to the primary alone: two windows answering one channel turns one
// keystroke into N prompts, and a HUD is the last place to paint onboarding.
export const isAuxiliaryWindow = (): boolean => isSecondaryWindow() || isHudWindow()
// True when running inside the Electron desktop shell (the preload bridge is
// present). The "open in new window" affordance is desktop-only.
export function canOpenSessionWindow(): boolean {

View File

@ -2264,3 +2264,619 @@ button[data-slot='aui_msg-reactions'] svg {
opacity: 0.4;
}
}
/* HUD mode
The chrome-free floating chat (`?win=hud`). Same renderer, same components,
same transcript as any other window this block only removes the frame and
makes the window see-through. It deliberately does NOT restyle how messages
render: the transcript is the app's transcript, and a HUD that reinvents
bubbles is a second design to keep in sync. Layout and surface only. */
/* The HOST LAYERS are made transparent at mount, not here: index.html's
pre-paint script sets an opaque themed background on <html> as an INLINE
style (the anti-white-flash trick), and an inline style beats a stylesheet
rule. Against that opaque wall every translucent panel below is just glass
over white which is exactly how this shipped: a plain opaque slab. The
pet overlay and quick entry inject a style tag at mount for the same
reason; HudShell does it too. See `hud-shell.tsx`. */
[data-hud-shell] {
background: transparent;
/* Empty space drags the window; anything that needs the mouse carves itself
back out below. */
-webkit-app-region: drag;
/* Arrive decisively and settle; leave gently and get out of the way. Linear
both directions is what made this feel mechanical. */
--hud-ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
--hud-ease-exit: cubic-bezier(0.4, 0, 0.7, 0.2);
}
/* Chat surface carries nothing in HUD mode — the visual is the BAND below. */
[data-hud-shell] [data-chat-surface] {
background: transparent !important;
overflow: hidden !important;
}
/* The scrollback the chat BAND, straight from the nous-viz2d chat (which is
the reference for this whole surface): a fixed-height smoked strip hugging
the input, tinted from the theme, with the text simply sitting in it.
- viz2d: `bg-black/20` at rest `bg-black/60` on hover / focus-within /
pending. Same mechanism here, theme-aware: the tint is the theme's own
background colour, so it reads correctly on dark and light themes alike
and text keeps its normal contrast against it.
- VISIBILITY keeps the WoW fade: shown while a turn is recent/streaming
(`data-hud-recent`) or the composer is focused, melted away otherwise.
viz2d's band never hides ours does, because idle HUD mode is just the
Spotlight bar.
- Focus steps the tint to fully solid: typing means reading conditions.
Reveal is fast, fade-out is slow the reveal is an answer to the user, the
fade is furniture leaving the room. */
[data-hud-shell] [data-slot='composer-bounds'] {
position: absolute !important;
/* Anchored to the bar and sized to the transcript, exactly like the sheet
behind it same box, same clock so the text is clipped away as the panel
rolls rather than fading in place.
Edge to edge with no dead margins, and it never computes where the bar's
top is: the bar simply covers this box's bottom. Every earlier attempt
derived that edge from --composer-surface-measured-height, which is the
composer's published height, rounded, while the real box is fractional
so the math left a hairline that appeared and vanished as the composer grew
("the gap is 0px until two lines"). What keeps the TEXT off the bar is the
app's own composer clearance, below. */
inset: 0 !important;
height: auto !important;
width: 100% !important;
max-width: none !important;
flex: none !important;
border: 0 !important;
/* Rounded on top to match the window's own corners; square at the bottom,
where the bar covers it. */
border-radius: 0.75rem 0.75rem 0 0;
/* No fill of its own the sheet is its own layer behind this one
([data-hud-glass]), so it can be sized to the transcript while this box
stays the full window for the drag region and the scroll container. */
background: transparent !important;
/* Three opacity states: gone at rest, half strength when a turn is recent or
you've just let go of the composer (there to glance at, not to demand
attention), full while you're actually reading it. */
opacity: 0;
translate: 0 var(--hud-exit-shift, 2.5rem);
transition:
opacity var(--hud-fade) var(--hud-ease-exit),
translate var(--hud-collapse) var(--hud-ease-exit);
}
/* Shown: recent turn / streaming, or a focused composer.
POINTER EVENTS ARE THE GATE: unfocused, the band is a ghost you can't touch
clicks in that region fall through to the shell's drag region (so the
whole upper window is a drag handle at rest) and the band can't eat a
scroll or a stray click while you're aiming at the app behind it. Focus the
composer and it becomes a real scrollable surface. This also means hover
alone can't reveal the band anymore hover is not engagement. */
/* The band's sheet, behind the transcript: a tint wearing the same gradient
mask as the text, so the whole surface ramps out together and the band has no
top edge at all.
No desktop blur under it, and that is a limit rather than an omission. CSS
backdrop-filter reaches nothing here a transparent window's backdrop root
is the document, and the desktop was never in it (verified on the real
window, not assumed). macOS vibrancy does see the desktop, but WindowServer
composites it below the web contents after this process has finished drawing,
so no mask, clip or stacking order can shape it; left on under a fading tint
it stays a flat slab and the top of the band goes pale exactly where it
should be disappearing. The way through is NSVisualEffectView.maskImage
behind a small native addon, which is its own change. */
[data-hud-shell] [data-hud-glass] {
/* The exit chip rides this box, not the band the band is the whole window,
so anchoring there left the chip stranded in empty space above a short
transcript. */
anchor-name: --hud-band;
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 0;
pointer-events: none;
border-radius: 0.75rem 0.75rem 0 0;
background: color-mix(in srgb, var(--dt-card) 55%, transparent);
/* Slides down behind the bar as it fades. A TRANSFORM, not height: it is
composited, so it stays smooth where animating height re-lays-out the panel
every frame and looked coarse and it leaves the box alone, which matters
because the one time the transcript's own container was collapsed the
messages spilled out of a zero-height box. Translate rather than scale: the
panel keeps its proportions on the way out instead of being squashed.
The height below still tracks the transcript; only the offset animates on
exit, and it finishes ahead of the fade so the panel has left while the last
of the text is still going. */
height: min(100%, calc(var(--hud-band-height, 0px) + var(--hud-sheet-overhang, 0.75rem)));
translate: 0 var(--hud-exit-shift, 2.5rem);
opacity: 0;
transition:
opacity var(--hud-fade) var(--hud-ease-exit),
translate var(--hud-collapse) var(--hud-ease-exit),
height var(--hud-reveal) var(--hud-ease-enter),
background-color var(--hud-dim) var(--hud-ease-enter);
}
/* Engaged: as tall as the transcript needs, capped at the window. An empty
session measures 0, so it stays rolled up. */
[data-hud-shell][data-hud-recent] [data-hud-glass],
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
translate: none;
opacity: 1;
transition-duration: var(--hud-reveal);
}
/* Engaged means the caret is in the composer, not merely that the window holds
focus somewhere. Activating a window restores focus to whatever had it last,
so `:focus-within` counted grabbing the bar to DRAG the HUD as sitting down
to use it, and the transcript flew open every time it was moved. Hit-testing
below stays on :focus-within being permissive about what can be clicked is
harmless, being permissive about what lights up is not. */
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-glass] {
background: color-mix(in srgb, var(--dt-card) 92%, transparent);
}
/* Flipped, the sheet hangs from the bar instead of standing on it. */
[data-hud-shell][data-hud-edge='top'] [data-hud-glass] {
top: 0;
bottom: auto;
--hud-exit-shift: -2.5rem;
border-radius: 0 0 0.75rem 0.75rem;
}
[data-hud-shell] [data-slot='composer-bounds'] {
pointer-events: none;
}
/* Clickable whenever it's actually on screen, not only while the caret is in
the composer a link in the transcript is worth clicking the moment you can
see it. Faded out it goes back to `none`, which is what lets clicks over the
HUD reach whatever you're really working in. */
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'],
[data-hud-shell]:focus-within [data-slot='composer-bounds'] {
pointer-events: auto;
}
/* Three states, not two. A turn landing brings the transcript up, but only part
way it is there to be glanced at over whatever you are actually doing, and
full-strength text over another app reads as the HUD demanding attention it
hasn't earned. Focus is what promotes it to properly readable; the hold and
fade then take it away from wherever it was. */
[data-hud-shell][data-hud-recent] [data-slot='composer-bounds'] {
opacity: 0.5;
translate: none;
/* Same duration AND curve as the sheet's tint step, or the two visibly ease
apart on the way down. */
transition-duration: var(--hud-dim);
transition-timing-function: var(--hud-ease-enter);
}
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-slot='composer-bounds'] {
opacity: 1;
translate: none;
transition-duration: var(--hud-reveal);
transition-delay: 0s;
}
/* User messages ride the band like every other line. In the app each sticky
user row paints an OPAQUE chat-surface slab behind it (so a stuck bubble can
slide over scrolled text) and the bubble is solid --dt-user-bubble inside
the smoked band both read as fully opaque cards that ignore every fade. The
slab goes unconditionally; the bubble smokes at rest and returns solid when
engaged. ONE variable carries the fill so there is no specificity fight
between rest/engaged rules the states just move the var. */
[data-hud-shell] {
--hud-bubble-fill: color-mix(in srgb, var(--dt-user-bubble) 65%, transparent);
}
/* ONE opacity law for the whole band: solid on focus, material otherwise.
Bubbles going solid on `recent` too made them opaque cards floating on the
62% band mid-stream the band only steps solid on focus, so nothing inside
it may step earlier. */
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) {
--hud-bubble-fill: var(--dt-user-bubble);
}
[data-hud-shell] [data-slot='aui_user-message-root'] {
background: transparent !important;
/* Not sticky in HUD mode: the band is a short log, and a pinned bubble
spends a third of it on a line you already read. Static rows scroll away
like everything else (the app keeps its sticky behavior this is the
HUD's call, not a global one). */
position: static !important;
}
/* User bubbles: the ONE thing in the band that paints its own opaque fill
(bg-(--dt-user-bubble) on USER_BUBBLE_BASE_CLASS), so it survives every
fade the band applies to its own background and reads as a solid card
floating on glass. Two things are needed, and only doing one of them is
why this kept coming back:
- the FILL follows the band's state (--hud-bubble-fill), and
- the BORDER does too; an opaque hairline outlines the card even when
the fill is right.
Fill and border both ride the same variable, so they cannot desync. */
[data-hud-shell] .composer-human-message {
background: var(--hud-bubble-fill) !important;
border-color: color-mix(in srgb, var(--ui-stroke-secondary) 45%, transparent) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
transition:
background-color var(--hud-reveal) var(--hud-ease-enter),
border-color 350ms ease;
}
/* The bubble's own container also paints the chat surface behind it. */
[data-hud-shell] .composer-human-message-container {
background: transparent !important;
}
/* ONE glass element in HUD mode: [data-hud-glass]. Everything else drops its
backdrop-filter the composer tree ships several Tailwind backdrop-blur
utilities (surface glass, fallback surface, attachment spinners, voice
pills) that are invisible inside the opaque app but paint as smeared halos
around the pill's rounded corners on a transparent window.
Keep this exemption in step with whatever carries the glass: pointing it at
the wrong element silently deletes the blur, which looks exactly like
backdrop-filter not working at all. */
[data-hud-shell] *:not([data-hud-glass], [data-hud-glass] *) {
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
/* No session header, no jump-to-bottom pill, no timeline rail. */
[data-hud-shell] header,
[data-hud-shell] [data-slot='thread-timeline'],
[data-hud-shell] .thread-jump-button {
display: none !important;
}
/* SCROLLING vs DRAGGING pick one, per element, per STATE.
`-webkit-app-region: drag` hands an element's mouse input to the window
manager, wheel included and the region math is rect-based, so a `no-drag`
band subtracts its rectangle even when it's pointer-transparent. The band is
most of the window, so giving its rect away permanently made the HUD nearly
immovable in practice (the composer almost always has focus while in use).
So the carve-out needs BOTH: composer focused AND actual overflow to scroll
(data-hud-scrollable, set by HudShell). A short conversation leaves the
whole window grabbable; the band only becomes a scroll surface when
scrolling is a real thing you could do with it. */
[data-hud-shell][data-hud-scrollable]:focus-within [data-slot='composer-bounds'] {
-webkit-app-region: no-drag;
}
/* Edge flip
Parked in the top half of the screen (data-hud-edge='top', broadcast by
main on move/resize), the whole HUD mirrors vertically: composer hugs the
window's top edge, the band hangs BELOW it, text melts at the bottom, and
the drag strip moves to the bottom dead zone. Same four surfaces, same
variables only the anchors swap. */
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-dock'] {
top: 0 !important;
bottom: auto !important;
}
[data-hud-shell][data-hud-edge='top'] [data-slot='composer-bounds'] {
border-radius: 0 0 0.75rem 0.75rem;
}
/* Flipped, the bar is at the TOP, so the clearance has to be too the app's
clearance element only pads the end of the thread. */
[data-hud-shell][data-hud-edge='top'] [data-chat-surface] {
--thread-last-message-clearance: 0.25rem;
}
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] {
padding-block-start: calc(var(--hud-bar-height, var(--composer-fallback-height)) + 0.25rem) !important;
}
[data-hud-shell][data-hud-edge='top'] [data-hud-drag-strip] {
top: auto;
bottom: 0;
}
/* The drag handle is the top strip of the WINDOW, above the band. The band
itself scrolls (a drag region eats wheel events, so it can never be the
handle); this strip is dead space either way, so it drags. */
[data-hud-shell] [data-hud-drag-strip] {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2rem;
z-index: 10;
-webkit-app-region: drag;
}
/* Tighten the thread on every side. The docked chat's gutters (px-6 py-8 plus
a 1.5rem inline pad) are sized for a full window column; in a bar a few
lines tall they're most of the surface. Bottom pad drops to a hairline
the band already ends at the bar, so any block-end padding here reads as a
gap between the last message and the composer. */
[data-hud-shell] [data-slot='aui_thread-content'] {
padding-inline: 0.6rem !important;
padding-block: 0.35rem 0.25rem !important;
/* A chat frame fills from the bottom. The docked thread hangs from the top
because it is always taller than its content is short; the band is a few
lines tall, so a two-message conversation left a dead gap between the last
reply and the composer. Filling the viewport and packing to the end puts
that slack ABOVE the text, where it's just more transparent window. */
min-height: 100%;
justify-content: flex-end;
}
/* Parked at the top the band hangs below the bar, so it fills downward. */
[data-hud-shell][data-hud-edge='top'] [data-slot='aui_thread-content'] {
justify-content: flex-start;
}
/* Space under the last message the app's own measured-dock clearance, minus
the 2rem of breathing room a full window can afford. This is what holds the
text off the bar now that the band runs the whole window height, and it is
measured from the live dock, so it tracks the composer growing to two, three,
ten rows with no hairline and no hole. */
[data-hud-shell] [data-chat-surface] {
--thread-last-message-clearance: var(--hud-bar-height, var(--composer-fallback-height));
}
/* Same for the dock's own bottom pad this var also feeds
--composer-measured-height, so the thread and the composer stay in
agreement from one value. */
[data-hud-shell] {
/* Conversation rhythm, tightened for a bar a few lines tall. The docked
thread's spacing is sized for a full-height column; in the HUD that air is
most of the band, and the ramp eats the older turns before you can read
them. Same knobs the app uses, just smaller here. */
--conversation-turn-gap: 0.1875rem;
--turn-block-gap: 0.375rem;
--paragraph-gap: 0.4rem;
--composer-shell-pad-block-end: 0px;
/* The viewport fills the band, period. The app's calc shortens the viewport
to leave the dock's strips visible below it; the HUD hides those strips, so
applying it here just strands a dead zone at the end of the thread that
grows as you type. Room for the bar comes from the clearance instead. */
--thread-viewport-height: 100%;
}
/* The composer BAR is the drag handle: it's the one surface that isn't a
scroll container, so it's the only one that can be. Its frame drags; the
controls carve themselves back out (a drag region eats their clicks too). */
[data-hud-shell] [data-slot='composer-dock'] {
-webkit-app-region: drag;
}
/* Focusables, not a list of the controls that happen to be in the bar today
a drag region eats the clicks of anything it covers, so anything missed here
is a dead control. */
[data-hud-shell] [data-slot='composer-rich-input'],
[data-hud-shell]
[data-slot='composer-dock']
:is(
a[href],
button,
input,
label,
select,
textarea,
[contenteditable],
[role='button'],
[role='combobox'],
[role='menuitem'],
[role='switch'],
[tabindex]:not([tabindex='-1'])
) {
-webkit-app-region: no-drag;
}
/* HUD mode is the input and the log nothing else. The dock stacks
[micro-action pills] · [status stack] · [composer] · [underside slot], and
only the composer belongs here: the status stack (todos, subagents,
background tasks, queue, the coding/cwd/git-branch row) and the strips
around it are ambient chrome for a full window, and in a bar a few lines
tall they'd shove the input off screen.
Written as "keep the composer, hide its siblings" rather than a list of
selectors, because those siblings carry no data-slot of their own a list
would silently miss whatever gets added to the dock next. */
[data-hud-shell] [data-slot='composer-dock'] > *:not([data-slot='composer-root']) {
display: none !important;
}
/* The coding/cwd/git-branch strip is NOT a dock sibling it renders inside
`composer-surface` (it's meant to inherit the composer's width and top
radius), so the rule above never reaches it. Same intent, own selector. */
[data-hud-shell] .coding-status-bar {
display: none !important;
}
/* The composer is the Spotlight bar: centered, capped width, the ONE
permanent object on screen. When the thread is faded out, this is all HUD
mode is.
The dock is `absolute bottom-0 left-1/2 -translate-x-1/2` against the chat
surface. Two traps live in that, and both silently ate earlier attempts:
- `margin-bottom` does NOTHING to an absolutely-positioned box anchored to
an edge; the lift has to come from `bottom`.
- Tailwind centers it with `left-1/2` + a translate, so BOTH `transform`
and `translate` (the standalone property) must be cancelled absolute
centering here is left/right 0 + capped width + margin-inline auto.
Do NOT restyle `position`: the controls row (model pill, mic, send) resolves
against the nearest positioned ancestor, so making the dock static re-anchors
that row and the composer tears in half input in flow at the top, controls
stranded at the bottom. */
[data-hud-shell] [data-slot='composer-dock'],
[data-hud-shell] [data-slot='composer-dock'][data-popped-out] {
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
top: auto !important;
width: 100% !important;
max-width: none !important;
transform: none !important;
translate: none !important;
padding: 0 !important;
}
/* The bar is never "away". In the docked app the composer dims to 30% while
you read back through the thread, and comes back on hover or focus sensible
when it's a panel at the bottom of a big window you're scrolling past. Here
it IS the interface: the one permanent, fully-present object, whatever the
transcript above it is doing. */
[data-hud-shell] [data-slot='composer-fade'] {
opacity: 1 !important;
}
[data-hud-shell] [data-slot='composer-root'] {
/* Opaque, so anything docked to the composer (status stack, queue panel)
paints the same solid fill the bar does they share this var. */
--composer-fill: var(--dt-card);
/* The base rule adds 5px of transparent grab margin for the peel-out drag,
and the dock compensates with +10px of width. Neither applies here (the
HUD composer doesn't peel out), and left in they inset the card unevenly
inside its own float. */
padding: 0 !important;
width: 100% !important;
}
/* The composer surface paints TWO layers: the surface itself, plus an
`absolute inset-0 -z-10` fill+glass div behind it (composerFill /
composerSurfaceGlass). Two stacked fills read as a box inside a box, so the
backing layer goes and the surface carries the single fill. */
[data-hud-shell] [data-slot='composer-surface'] > .pointer-events-none.absolute.inset-0 {
display: none !important;
}
/* Opaque, pill-rounded the Spotlight bar. Same surface tokens the composer
already uses; only the radius steps up so a lone floating bar reads as an
object rather than a snippet of app chrome.
ALWAYS fully opaque. It's the input the one thing that must never be
compromised by whatever's behind the window, in any state. The idle/engaged
breathing belongs to the thread and the chat surface; the bar just sits
there, solid, like Spotlight does. */
[data-hud-shell] [data-slot='composer-surface'] {
border: 1px solid var(--ui-stroke-secondary) !important;
/* Same radius as the band's top and the window's own corners, so the HUD
reads as one object however the band is behaving. Its top corners let a
sliver of band through, which is the point the glass wraps the bar's
shoulders instead of stopping in a straight line above it. */
border-radius: 0.75rem !important;
background: var(--dt-card) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
box-shadow: none !important;
}
/* The exit button: a real control, visible at rest, on a card chip so it
reads against any backdrop.
Square (aspect-ratio 1/1 on the app's titlebar button size) and INSET from
the band's corner tucked flush it read as a mistake; a chip floating in
the corner with its own margin reads as intentional. anchor() to the band:
calc()-based attempts drifted because the composer's measured vars are
inline-scoped and stale outside [data-chat-surface]. */
[data-hud-shell] [data-hud-exit] {
position-anchor: --hud-band;
left: auto;
bottom: auto;
right: calc(anchor(right) + 0.5rem);
top: calc(anchor(top) + 0.5rem);
aspect-ratio: 1;
height: auto;
background: var(--dt-card) !important;
border: 1px solid var(--ui-stroke-tertiary);
border-radius: 0.5rem;
/* Part of the band, so it lives and dies with it same states, same timing.
Left visible at rest it is a lone chip hovering in empty space a hundred
pixels above the bar; revealed on hovering the window it pops up whenever
the cursor crosses the HUD on its way somewhere else. */
opacity: 0;
transition: opacity var(--hud-fade) var(--hud-ease-exit);
/* And it takes clicks on exactly the same terms as the band it sits in
visible or nothing. A faded-out control that still takes clicks is an
invisible button floating over the app behind; a lit one that doesn't is
the dead chip you get from keying this to focus alone. */
pointer-events: none;
}
[data-hud-shell][data-hud-recent] [data-hud-exit],
[data-hud-shell]:focus-within [data-hud-exit] {
pointer-events: auto;
}
/* Flipped, the sheet's top corner is behind the bar drop the chip clear of
it so it stays in the transcript rather than under the composer. */
[data-hud-shell][data-hud-edge='top'] [data-hud-exit] {
top: calc(anchor(top) + var(--hud-bar-height, var(--composer-fallback-height)) + 0.5rem);
}
[data-hud-shell][data-hud-recent] [data-hud-exit],
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-exit] {
opacity: 0.75;
transition-duration: var(--hud-reveal);
transition-delay: 0s;
}
[data-hud-shell][data-hud-recent] [data-hud-exit]:hover,
[data-hud-shell]:has([data-slot='composer-rich-input']:focus) [data-hud-exit]:hover {
opacity: 1;
}
/* The dock's fade-to-surface gradient assumes a chat column behind it; over a
transparent HUD it's a grey smear. */
[data-hud-shell] [data-slot='composer-root'] > .pointer-events-none {
display: none !important;
}
/* Portaled overlays (model picker, menus, dialogs, tooltips, the command
palette) mount into <body>, OUTSIDE [data-hud-shell] so they never inherit
its `no-drag` carve-outs. And Electron's draggable region is RECT math, not
paint order: the shell's window-sized `drag` rect covers wherever the menu
opens, and anything that doesn't explicitly subtract itself is handed to the
window manager. The menu rendered, sat correctly, and swallowed every click
("I can't even select a model"). Carve every portal back out. */
html:has([data-hud-shell]) body > *:not(#root) {
-webkit-app-region: no-drag;
}
/* Popovers in a ~320px-tall window. Two separate problems:
1. Radix clamps the popover to --radix-*-available-height, but fixed
max-heights INSIDE the panels (the model catalog's max-h-[max(150px,
30dvh)] list) don't shrink with it, so the list keeps its own floor and
the menu overflows the window.
2. The window IS the collision boundary, so even a correctly-clamped menu
has only ~320px to live in.
(1) is ours to fix in CSS; inner scrollers yield to the window instead of
their own floor. (2) is why the menus are compact here rather than roomy
a HUD is a small window and a 400px menu cannot fit in it. */
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'] [class*='max-h-'],
html:has([data-hud-shell]) [data-slot='popover-content'] [class*='max-h-'] {
max-height: max(5rem, calc(100dvh - 9rem)) !important;
}
/* The menu panels themselves: never taller than the window minus the bar. */
html:has([data-hud-shell]) [data-slot='dropdown-menu-content'],
html:has([data-hud-shell]) [data-slot='popover-content'] {
max-height: calc(100dvh - 4.5rem) !important;
overflow-y: auto;
}
/* Hide the scrollbar — a HUD with a visible track stops reading as an overlay. */
[data-hud-shell] [data-slot='composer-bounds'] * {
scrollbar-width: none;
}
[data-hud-shell] [data-slot='composer-bounds'] *::-webkit-scrollbar {
display: none;
}