diff --git a/apps/desktop/src/app/contrib/controller.tsx b/apps/desktop/src/app/contrib/controller.tsx
index cd40143dcd8..678f131cf7f 100644
--- a/apps/desktop/src/app/contrib/controller.tsx
+++ b/apps/desktop/src/app/contrib/controller.tsx
@@ -36,7 +36,7 @@ import { useContributions } from '@/contrib/react/use-contributions'
import { registry } from '@/contrib/registry'
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
import { sessionTitle as storedSessionTitle } from '@/lib/chat-runtime'
-import { LayoutDashboard } from '@/lib/icons'
+import { LayoutDashboard, PanelBottom } from '@/lib/icons'
import { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
import { Codecs, persistentAtom } from '@/lib/persisted'
import { $artifactTabs } from '@/store/artifacts'
@@ -56,6 +56,7 @@ import { $filePreviewTabs, $filePreviewTarget, $previewTarget, closeRightRail }
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { watchSessionPins } from '@/store/session-pin-sync'
+import { $statusbarVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@@ -299,6 +300,20 @@ registry.registerMany([
run: resetLayoutTree
} satisfies PaletteContribution
},
+ // Hiding the bar removes the surface that would otherwise offer it back, so
+ // ⌘K is the guaranteed door in (alongside the rebindable ⌘⇧S).
+ {
+ id: 'view.toggleStatusbar',
+ area: PALETTE_AREA,
+ data: {
+ id: 'view.toggleStatusbar',
+ label: 'Toggle status bar',
+ action: 'view.toggleStatusbar',
+ icon: PanelBottom,
+ keywords: ['status bar', 'statusbar', 'bottom bar', 'hide', 'show', 'chrome'],
+ run: toggleStatusbarVisible
+ } satisfies PaletteContribution
+ },
// The keybind panel's non-titlebar door (the keyboard icon is gone).
{
id: 'keybinds.panel',
@@ -641,6 +656,7 @@ function TitlebarSlot({ area, className, style }: TitlebarSlotProps) {
export function ContribController() {
const sidebarOpen = useStore($sidebarOpen)
+ const statusbarVisible = useStore($statusbarVisible)
return (
{/* The REAL statusbar (model pill, command center, agents, …) with
- statusBar.left/right contributions merged in. */}
-
+ statusBar.left/right contributions merged in. Unmounted — not
+ just hidden — while toggled off, so its 15s status poll and the
+ per-turn readouts stop with it. */}
+ {statusbarVisible && }
diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts
index 7e49029d1ce..66d74e9411f 100644
--- a/apps/desktop/src/app/hooks/use-keybinds.ts
+++ b/apps/desktop/src/app/hooks/use-keybinds.ts
@@ -48,6 +48,7 @@ import {
switcherActive,
switcherJustClosed
} from '@/store/session-switcher'
+import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
import { openNewWindow } from '@/store/windows'
import { useTheme } from '@/themes/context'
@@ -174,6 +175,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.toggleRightSidebar': () =>
layoutHasRootSide('right') ? toggleFileBrowserOpen() : setTerminalTakeover(!$terminalTakeover.get()),
'view.toggleReview': toggleReview,
+ 'view.toggleStatusbar': toggleStatusbarVisible,
'view.showFiles': showFiles,
'view.showTerminal': () => setTerminalTakeover(!$terminalTakeover.get()),
// Create first so the pane's open-effect ensure sees a non-empty set and
diff --git a/apps/desktop/src/app/shell/statusbar-controls.tsx b/apps/desktop/src/app/shell/statusbar-controls.tsx
index a33608b5edd..7dbc9335f71 100644
--- a/apps/desktop/src/app/shell/statusbar-controls.tsx
+++ b/apps/desktop/src/app/shell/statusbar-controls.tsx
@@ -6,6 +6,7 @@ import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
+ ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger
@@ -13,8 +14,9 @@ import {
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Tip, TipKeybindLabel, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
+import { useKeybindHint } from '@/lib/keybinds/use-keybind-hint'
import { cn } from '@/lib/utils'
-import { $statusbarHiddenIds, setStatusbarItemVisible } from '@/store/statusbar-prefs'
+import { $statusbarHiddenIds, setStatusbarItemVisible, toggleStatusbarVisible } from '@/store/statusbar-prefs'
// Shared chrome styling for interactive statusbar items (button / link / menu
// trigger). The 'text' variant intentionally omits hover/transition/disabled.
@@ -121,7 +123,8 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
/** Right-click the bar to choose what it shows. Lists every item that named
* itself with `toggleLabel`, in bar order (left cluster then right), so the
- * menu reads like the surface it edits. */
+ * menu reads like the surface it edits. Hiding the whole bar lives at the
+ * bottom — VS Code puts it on the same context menu. */
function StatusbarVisibilityMenu({
hiddenIds,
items,
@@ -151,32 +154,44 @@ function StatusbarVisibilityMenu({
})
}, [items, leftItems])
- if (toggles.length === 0) {
- return null
- }
-
return (
- {copy.customizeTitle}
-
- {toggles.map(item => (
- setStatusbarItemVisible(item.id, checked)}
- // Radix closes the menu on select; keep it open so several items can
- // be toggled in one pass (this is a preferences surface, not a
- // command list).
- onSelect={event => event.preventDefault()}
- >
- {item.toggleLabel}
-
- ))}
+ {toggles.length > 0 && (
+ <>
+ {copy.customizeTitle}
+
+ {toggles.map(item => (
+ setStatusbarItemVisible(item.id, checked)}
+ // Radix closes the menu on select; keep it open so several items can
+ // be toggled in one pass (this is a preferences surface, not a
+ // command list).
+ onSelect={event => event.preventDefault()}
+ >
+ {item.toggleLabel}
+
+ ))}
+
+ >
+ )}
+
+ {copy.hideStatusbar}
+
+
)
}
+/** The live ⌘⇧S hint on the hide row — the way back once the bar is gone. */
+function StatusbarHideHint() {
+ const hint = useKeybindHint('view.toggleStatusbar')
+
+ return hint ? {hint} : null
+}
+
/** Memoized: `useStatusbarItems` rebuilds the item array whenever ANY of its
* inputs change, but each individual item object is usually identical across
* those rebuilds. Without this, one changed item (the running timer, say)
diff --git a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
index 04defbe2947..9d334a0330d 100644
--- a/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
+++ b/apps/desktop/src/app/shell/statusbar-visibility.test.tsx
@@ -3,7 +3,12 @@ import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { StatusbarControls, type StatusbarItem } from '@/app/shell/statusbar-controls'
-import { $statusbarHiddenIds, STATUSBAR_HIDDEN_BY_DEFAULT } from '@/store/statusbar-prefs'
+import {
+ $statusbarHiddenIds,
+ $statusbarVisible,
+ STATUSBAR_HIDDEN_BY_DEFAULT,
+ toggleStatusbarVisible
+} from '@/store/statusbar-prefs'
class TestResizeObserver {
observe() {}
@@ -22,6 +27,7 @@ beforeAll(() => {
afterEach(() => {
cleanup()
$statusbarHiddenIds.set([...STATUSBAR_HIDDEN_BY_DEFAULT])
+ $statusbarVisible.set(true)
})
const item = (id: string, label: string, extra: Partial = {}): StatusbarItem => ({
@@ -118,3 +124,26 @@ describe('statusbar item visibility', () => {
expect(within(statusbar).getByText('Session timer')).toBeTruthy()
})
})
+
+describe('whole-bar visibility', () => {
+ it('hides the bar from the context menu, leaving the keybind as the way back', async () => {
+ const statusbar = bar([item('gateway-health', 'Gateway')])
+
+ openContextMenu(statusbar)
+ fireEvent.click(await screen.findByRole('menuitem', { name: /hide status bar/i }))
+
+ expect($statusbarVisible.get()).toBe(false)
+
+ toggleStatusbarVisible()
+ expect($statusbarVisible.get()).toBe(true)
+ })
+
+ it('offers the hide row even when no item opted into the show/hide list', async () => {
+ const statusbar = bar([{ id: 'plugin-thing', label: 'Plugin thing', variant: 'action' }])
+
+ openContextMenu(statusbar)
+
+ expect(await screen.findByRole('menuitem', { name: /hide status bar/i })).toBeTruthy()
+ expect(screen.queryByRole('menuitemcheckbox')).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 962116d280f..18ef5fe917a 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -257,6 +257,7 @@ export const en: Translations = {
'view.toggleSidebar': 'Toggle sessions sidebar',
'view.toggleRightSidebar': 'Toggle file browser',
'view.toggleReview': 'Toggle review pane',
+ 'view.toggleStatusbar': 'Toggle status bar',
'view.showFiles': 'Show file browser',
'view.showTerminal': 'Toggle terminal',
'view.newTerminal': 'New terminal',
@@ -2427,6 +2428,7 @@ export const en: Translations = {
gatewayRestarting: 'restarting…',
gatewayTitle: 'Hermes inference gateway status',
customizeTitle: 'Show in status bar',
+ hideStatusbar: 'Hide status bar',
toggleApprovalMode: 'Approvals',
toggleBackendVersion: 'Backend version',
toggleCommandCenter: 'Command Center',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 8187517b061..a7e90bed02c 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -2033,6 +2033,7 @@ export interface Translations {
gatewayRestarting: string
gatewayTitle: string
customizeTitle: string
+ hideStatusbar: string
toggleApprovalMode: string
toggleBackendVersion: string
toggleCommandCenter: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index bc46aa6aaf4..2b9e9c5b29e 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -252,6 +252,7 @@ export const zh: Translations = {
'view.toggleSidebar': '切换会话侧边栏',
'view.toggleRightSidebar': '切换文件浏览器',
'view.toggleReview': '切换审查面板',
+ 'view.toggleStatusbar': '切换状态栏',
'view.showFiles': '显示文件浏览器',
'view.showTerminal': '显示终端',
'view.terminalSelection': '将终端选区发送到输入框',
@@ -2603,6 +2604,7 @@ export const zh: Translations = {
gatewayRestarting: '重启中…',
gatewayTitle: 'Hermes 推理网关状态',
customizeTitle: '在状态栏中显示',
+ hideStatusbar: '隐藏状态栏',
toggleApprovalMode: '审批',
toggleBackendVersion: '后端版本',
toggleCommandCenter: '命令中心',
diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts
index 618b386d2f3..368ab48e6bd 100644
--- a/apps/desktop/src/lib/keybinds/actions.ts
+++ b/apps/desktop/src/lib/keybinds/actions.ts
@@ -100,6 +100,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── View (layout + appearance + the shortcuts panel itself) ───────────────
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
+ // ⌘⇧S — "s" for status bar. VS Code ships
+ // `workbench.action.toggleStatusbarVisibility` unbound (it's a chord-free
+ // gap in their View family) and Hermes has no chord dispatcher, so this
+ // takes the nearest free single combo instead of a ⌘K ⌘S two-stroke.
+ { id: 'view.toggleStatusbar', category: 'view', defaults: ['mod+shift+s'] },
// ⌘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: [] },
diff --git a/apps/desktop/src/store/statusbar-prefs.ts b/apps/desktop/src/store/statusbar-prefs.ts
index 177aa393f08..cc3a330ae34 100644
--- a/apps/desktop/src/store/statusbar-prefs.ts
+++ b/apps/desktop/src/store/statusbar-prefs.ts
@@ -1,6 +1,16 @@
import { Codecs, persistentAtom } from '@/lib/persisted'
const STATUSBAR_HIDDEN_STORAGE_KEY = 'hermes.desktop.statusbarHidden'
+const STATUSBAR_VISIBLE_STORAGE_KEY = 'hermes.desktop.statusbarVisible'
+
+// Whole-bar visibility, VS Code's `workbench.statusBar.visible`. Hiding it
+// unmounts the bar (its 15s status poll goes with it), so the way back is the
+// `view.toggleStatusbar` keybind or the ⌘K row — never the bar itself.
+export const $statusbarVisible = persistentAtom(STATUSBAR_VISIBLE_STORAGE_KEY, true, Codecs.bool)
+
+export function toggleStatusbarVisible() {
+ $statusbarVisible.set(!$statusbarVisible.get())
+}
// Items the bar hides until the user turns them on from its context menu. The
// bar's job is to answer "is the backend healthy, where am I, what's it doing" —