chore: uptick

This commit is contained in:
Brooklyn Nicholson 2026-05-03 12:40:03 -05:00
parent fd97a7cba4
commit fa92720d2c
22 changed files with 1203 additions and 348 deletions

View file

@ -92,6 +92,7 @@ const MEDIA_MIME_TYPES = {
}
const PREVIEW_HTML_EXTENSIONS = new Set(['.html', '.htm'])
const PREVIEW_WATCH_DEBOUNCE_MS = 120
const LOCAL_PREVIEW_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
app.setName(APP_NAME)
@ -690,13 +691,23 @@ function sendPreviewFileChanged(payload) {
function watchPreviewFile(rawUrl) {
const filePath = previewFilePathFromUrl(rawUrl)
const watchDir = path.dirname(filePath)
const targetName = path.basename(filePath)
const id = crypto.randomBytes(12).toString('base64url')
let timer = null
const watcher = fs.watch(filePath, () => {
const watcher = fs.watch(watchDir, (_eventType, filename) => {
const changedName = filename ? path.basename(String(filename)) : ''
if (changedName && changedName !== targetName) {
return
}
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = null
if (!fileExists(filePath)) return
sendPreviewFileChanged({ id, path: filePath, url: pathToFileURL(filePath).toString() })
}, 120)
}, PREVIEW_WATCH_DEBOUNCE_MS)
})
previewWatchers.set(id, {

View file

@ -68,6 +68,9 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
onChangeCwd: (cwd: string) => void
onBrowseCwd: () => void
onOpenModelPicker: () => void
onRestartPreviewServer?: (url: string, context?: string) => Promise<string>
onSetFastMode: (enabled: boolean) => void
onSetReasoningEffort: (effort: string) => void
onSelectPersonality: (name: string) => void
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
onEdit: (message: AppendMessage) => Promise<void>
@ -121,6 +124,9 @@ export function ChatView({
onChangeCwd,
onBrowseCwd,
onOpenModelPicker,
onRestartPreviewServer,
onSetFastMode,
onSetReasoningEffort,
onSelectPersonality,
onThreadMessagesChange,
onEdit,
@ -324,12 +330,14 @@ export function ChatView({
</div>
</div>
<ChatPreviewRail setTitlebarToolGroup={setTitlebarToolGroup} />
<ChatPreviewRail onRestartServer={onRestartPreviewServer} setTitlebarToolGroup={setTitlebarToolGroup} />
<ChatRightRail
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={onOpenModelPicker}
onSelectPersonality={onSelectPersonality}
onSetFastMode={onSetFastMode}
onSetReasoningEffort={onSetReasoningEffort}
/>
</>
)

View file

@ -0,0 +1,108 @@
'use client'
import { useMemo } from 'react'
import { RailActionRow } from './rail-action-row'
import { RailSection } from './rail-section'
import { type RailSelectOption, RailSelectRow } from './rail-select-row'
import { RailToggleRow } from './rail-toggle-row'
const REASONING_OPTIONS: RailSelectOption[] = [
{ value: 'none', label: 'Off' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'xhigh', label: 'XHigh' }
]
interface AgentSectionProps {
modelLabel: string
providerName?: string
reasoningEffort: string
serviceTier: string
fastMode: boolean
personality: string
personalities: string[]
onOpenModelPicker?: () => void
onSetReasoningEffort?: (effort: string) => void
onSetFastMode?: (enabled: boolean) => void
onSelectPersonality?: (name: string) => void
}
export function AgentSection({
modelLabel,
providerName,
reasoningEffort,
serviceTier,
fastMode,
personality,
personalities,
onOpenModelPicker,
onSetReasoningEffort,
onSetFastMode,
onSelectPersonality
}: AgentSectionProps) {
const activeReasoning = normalizeReasoningEffort(reasoningEffort)
const fastEnabled = fastMode || ['fast', 'priority'].includes(serviceTier.trim().toLowerCase())
const activePersonality = personalityOptionKey(personality)
const personalityOptions = useMemo<RailSelectOption[]>(
() =>
[...new Set(['none', ...personalities, personality].map(personalityOptionKey).filter(Boolean))].map(name => ({
value: name,
label: name === 'none' ? 'None' : titleize(name)
})),
[personalities, personality]
)
return (
<RailSection title="Agent">
<RailActionRow
ariaLabel="Change model"
onClick={onOpenModelPicker}
primary={modelLabel || 'Hermes'}
secondary={providerName}
/>
<RailSelectRow
ariaLabel="Change reasoning effort"
label="Reasoning"
menuLabel="Reasoning"
onChange={onSetReasoningEffort}
options={REASONING_OPTIONS}
value={activeReasoning}
/>
<RailToggleRow checked={fastEnabled} label="Fast mode" onChange={onSetFastMode} />
<RailSelectRow
ariaLabel="Change personality"
label="Personality"
menuLabel="Personality"
menuWidthClass="w-52"
onChange={onSelectPersonality}
options={personalityOptions}
value={activePersonality}
/>
</RailSection>
)
}
function personalityOptionKey(value?: string): string {
const key = value?.trim().toLowerCase() || 'none'
return key === 'default' ? 'none' : key
}
function normalizeReasoningEffort(value: string): string {
const normalized = value.trim().toLowerCase()
return REASONING_OPTIONS.some(option => option.value === normalized) ? normalized : 'medium'
}
function titleize(value: string): string {
return value
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/(^|\s)\S/g, m => m.toUpperCase())
}

View file

@ -1,19 +1,22 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { SESSION_INSPECTOR_WIDTH, SessionInspector } from '@/components/session-inspector'
import { cn } from '@/lib/utils'
import { $inspectorOpen } from '@/store/layout'
import { $previewTarget } from '@/store/preview'
import type { SetTitlebarToolGroup } from '@/app/shell/titlebar-controls'
import { $previewReloadRequest, $previewTarget } from '@/store/preview'
import {
$availablePersonalities,
$busy,
$currentBranch,
$currentCwd,
$currentFastMode,
$currentModel,
$currentPersonality,
$currentProvider,
$currentReasoningEffort,
$currentServiceTier,
$gatewayState
} from '@/store/session'
@ -24,6 +27,8 @@ interface ChatRightRailProps extends Pick<
'onBrowseCwd' | 'onChangeCwd'
> {
onOpenModelPicker: () => void
onSetFastMode: (enabled: boolean) => void
onSetReasoningEffort: (effort: string) => void
onSelectPersonality: (name: string) => void
}
@ -31,6 +36,8 @@ export function ChatRightRail({
onBrowseCwd,
onChangeCwd,
onOpenModelPicker,
onSetFastMode,
onSetReasoningEffort,
onSelectPersonality
}: ChatRightRailProps) {
const inspectorOpen = useStore($inspectorOpen)
@ -40,32 +47,51 @@ export function ChatRightRail({
const branch = useStore($currentBranch)
const model = useStore($currentModel)
const provider = useStore($currentProvider)
const reasoningEffort = useStore($currentReasoningEffort)
const serviceTier = useStore($currentServiceTier)
const fastMode = useStore($currentFastMode)
const personality = useStore($currentPersonality)
const personalities = useStore($availablePersonalities)
return (
<div className="col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden">
<div
className={cn(
'col-start-4 col-end-5 row-start-1 min-w-0 overflow-hidden',
inspectorOpen && 'border-l border-border/60'
)}
>
<SessionInspector
branch={branch}
busy={busy}
cwd={cwd}
fastMode={fastMode}
modelLabel={model ? model.split('/').pop() || model : ''}
modelTitle={provider ? `${provider}: ${model || ''}` : model}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
onOpenModelPicker={gatewayOpen ? onOpenModelPicker : undefined}
onSelectPersonality={gatewayOpen ? onSelectPersonality : undefined}
onSetFastMode={gatewayOpen ? onSetFastMode : undefined}
onSetReasoningEffort={gatewayOpen ? onSetReasoningEffort : undefined}
open={inspectorOpen}
personalities={personalities}
personality={personality}
providerName={provider}
reasoningEffort={reasoningEffort}
serviceTier={serviceTier}
/>
</div>
)
}
export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup?: SetTitlebarToolGroup }) {
const inspectorOpen = useStore($inspectorOpen)
export function ChatPreviewRail({
onRestartServer,
setTitlebarToolGroup
}: {
onRestartServer?: (url: string, context?: string) => Promise<string>
setTitlebarToolGroup?: SetTitlebarToolGroup
}) {
const previewReloadRequest = useStore($previewReloadRequest)
const previewTarget = useStore($previewTarget)
if (!previewTarget) {
@ -74,12 +100,14 @@ export function ChatPreviewRail({ setTitlebarToolGroup }: { setTitlebarToolGroup
return (
<div
className={cn(
'pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden',
inspectorOpen && 'border-r border-border/60'
)}
className="pointer-events-none col-start-3 col-end-4 row-start-1 min-w-0 overflow-hidden"
>
<PreviewPane setTitlebarToolGroup={setTitlebarToolGroup} target={previewTarget} />
<PreviewPane
onRestartServer={onRestartServer}
reloadRequest={previewReloadRequest}
setTitlebarToolGroup={setTitlebarToolGroup}
target={previewTarget}
/>
</div>
)
}

View file

@ -1,3 +1,4 @@
import { useStore } from '@nanostores/react'
import { Bug, Check, Copy, PanelBottom, RefreshCw, Send, Trash2, X } from 'lucide-react'
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -6,10 +7,11 @@ import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-co
import { cn } from '@/lib/utils'
import { $composerDraft, setComposerDraft } from '@/store/composer'
import { notify, notifyError } from '@/store/notifications'
import { type PreviewTarget, setPreviewTarget } from '@/store/preview'
import { $previewServerRestart, failPreviewServerRestart, type PreviewTarget, setPreviewTarget } from '@/store/preview'
type PreviewWebview = HTMLElement & {
closeDevTools?: () => void
getURL?: () => string
isDevToolsOpened?: () => boolean
openDevTools?: () => void
reload?: () => void
@ -24,6 +26,13 @@ interface ConsoleEntry {
source?: string
}
interface PreviewPaneProps {
onRestartServer?: (url: string, context?: string) => Promise<string>
reloadRequest?: number
setTitlebarToolGroup?: SetTitlebarToolGroup
target: PreviewTarget
}
interface PreviewLoadErrorState {
code?: number
description: string
@ -48,6 +57,7 @@ const CONSOLE_BOTTOM_THRESHOLD = 24
const CONSOLE_DEFAULT_HEIGHT = 240
const CONSOLE_HEADER_HEIGHT = 32
const FILE_RELOAD_DEBOUNCE_MS = 200
const SERVER_RESTART_TIMEOUT_MS = 45_000
function compactUrl(value: string): string {
try {
@ -85,6 +95,10 @@ function clampConsoleHeight(value: number): number {
function loadErrorTitle(error: PreviewLoadErrorState): string {
const description = error.description.toLowerCase()
if (description.includes('module script') || description.includes('mime type')) {
return 'Preview app failed to boot'
}
if (description.includes('connection') || description.includes('refused') || description.includes('not found')) {
return 'Server not found'
}
@ -92,6 +106,12 @@ function loadErrorTitle(error: PreviewLoadErrorState): string {
return 'Preview failed to load'
}
function isModuleMimeError(message: string): boolean {
const lower = message.toLowerCase()
return lower.includes('failed to load module script') && lower.includes('mime type')
}
interface ConsoleRowProps {
log: ConsoleEntry
onCopy: () => void | Promise<void>
@ -155,11 +175,15 @@ function ConsoleRow({ log, onCopy, onSend, onToggleSelect, selected }: ConsoleRo
function PreviewLoadError({
consoleHeight = 0,
error,
onRetry
onRestartServer,
onRetry,
restarting
}: {
consoleHeight?: number
error: PreviewLoadErrorState
onRestartServer?: () => void
onRetry: () => void
restarting?: boolean
}) {
return (
<div
@ -182,7 +206,7 @@ function PreviewLoadError({
strokeLinejoin="round"
strokeWidth="1.25"
/>
<path d="M20 11.75 44 25.25" fill="none" stroke="currentColor" strokeWidth="0.9" opacity="0.45" />
<path d="M20 11.75 44 25.25" fill="none" opacity="0.45" stroke="currentColor" strokeWidth="0.9" />
</svg>
<div className="grid gap-1.5">
<div className="text-sm font-medium text-foreground">{loadErrorTitle(error)}</div>
@ -201,13 +225,25 @@ function PreviewLoadError({
</div>
<div className="text-[0.6875rem] leading-5 text-muted-foreground/70">{error.description}</div>
</div>
<button
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
onClick={onRetry}
type="button"
>
Try again
</button>
<div className="grid justify-items-center gap-2">
<button
className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground shadow-xs transition-colors hover:bg-accent"
onClick={onRetry}
type="button"
>
Try again
</button>
{onRestartServer && (
<button
className="text-[0.6875rem] font-medium text-muted-foreground underline decoration-muted-foreground/25 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/55 disabled:cursor-default disabled:text-muted-foreground/55 disabled:no-underline"
disabled={restarting}
onClick={onRestartServer}
type="button"
>
{restarting ? 'Hermes is restarting...' : 'Ask Hermes to restart the server'}
</button>
)}
</div>
</div>
</div>
)
@ -230,18 +266,20 @@ async function writeClipboardText(text: string) {
}
export function PreviewPane({
onRestartServer,
reloadRequest = 0,
setTitlebarToolGroup,
target
}: {
setTitlebarToolGroup?: SetTitlebarToolGroup
target: PreviewTarget
}) {
}: PreviewPaneProps) {
const consoleBodyRef = useRef<HTMLDivElement | null>(null)
const consoleShouldStickRef = useRef(true)
const hostRef = useRef<HTMLDivElement | null>(null)
const logIdRef = useRef(0)
const lastReloadRequestRef = useRef(reloadRequest)
const lastRestartEventRef = useRef('')
const previewContentRef = useRef<HTMLDivElement | null>(null)
const webviewRef = useRef<PreviewWebview | null>(null)
const previewServerRestart = useStore($previewServerRestart)
const [consoleHeight, setConsoleHeight] = useState(CONSOLE_DEFAULT_HEIGHT)
const [consoleOpen, setConsoleOpen] = useState(false)
const [currentUrl, setCurrentUrl] = useState(target.url)
@ -254,8 +292,12 @@ export function PreviewPane({
const visibleSelection = useMemo(() => logs.filter(log => selectedLogIds.has(log.id)), [logs, selectedLogIds])
const sendableLogs = visibleSelection.length > 0 ? visibleSelection : logs
const currentLabel = compactUrl(currentUrl)
const previewLabel =
target.label && target.label.replace(/\/$/, '') !== currentLabel.replace(/\/$/, '') ? target.label : currentLabel
const restartingServer =
previewServerRestart?.status === 'running' &&
(previewServerRestart.url === target.url || previewServerRestart.url === currentUrl)
const startConsoleResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@ -317,6 +359,33 @@ export function PreviewPane({
}
}, [])
const appendConsoleEntry = useCallback((entry: Omit<ConsoleEntry, 'id'>) => {
consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current)
setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }])
}, [])
const restartServer = useCallback(async () => {
if (!onRestartServer) {
return
}
try {
const context = logs.slice(-12).map(formatLogLine).join('\n')
const taskId = await onRestartServer(currentUrl, context || undefined)
appendConsoleEntry({
level: 1,
message: `Hermes is looking for a preview server to restart (${taskId})`
})
} catch (error) {
appendConsoleEntry({
level: 2,
message: `Could not start server restart: ${error instanceof Error ? error.message : String(error)}`
})
notifyError(error, 'Server restart failed')
}
}, [appendConsoleEntry, currentUrl, logs, onRestartServer])
function toggleLogSelection(id: number) {
setSelectedLogIds(prev => {
const next = new Set(prev)
@ -441,6 +510,73 @@ export function PreviewPane({
}
}, [consoleOpen])
useEffect(() => {
if (
!previewServerRestart ||
!previewServerRestart.message ||
(previewServerRestart.url !== target.url && previewServerRestart.url !== currentUrl)
) {
return
}
const eventKey = `${previewServerRestart.taskId}:${previewServerRestart.status}:${previewServerRestart.message || ''}`
if (eventKey === lastRestartEventRef.current) {
return
}
lastRestartEventRef.current = eventKey
appendConsoleEntry({
level: previewServerRestart.status === 'error' ? 2 : 1,
message:
previewServerRestart.status === 'running'
? previewServerRestart.message
: previewServerRestart.status === 'complete'
? `Hermes finished restarting the preview server${
previewServerRestart.message ? `: ${previewServerRestart.message}` : ''
}`
: `Server restart failed: ${previewServerRestart.message || 'unknown error'}`
})
if (previewServerRestart.status === 'complete') {
reloadPreview()
}
}, [appendConsoleEntry, currentUrl, previewServerRestart, reloadPreview, target.url])
useEffect(() => {
if (!restartingServer || !previewServerRestart) {
return
}
const taskId = previewServerRestart.taskId
const timer = window.setTimeout(() => {
failPreviewServerRestart(
taskId,
'Hermes is still working, but no restart result has arrived yet. The server command may be running in the foreground.'
)
}, SERVER_RESTART_TIMEOUT_MS)
return () => window.clearTimeout(timer)
}, [previewServerRestart, restartingServer])
useEffect(() => {
if (reloadRequest === lastReloadRequestRef.current) {
return
}
lastReloadRequestRef.current = reloadRequest
if (target.kind !== 'url') {
return
}
appendConsoleEntry({
level: 1,
message: 'Workspace changed, reloading preview'
})
reloadPreview()
}, [appendConsoleEntry, reloadPreview, reloadRequest, target.kind])
useEffect(() => {
if (target.kind !== 'file' || !window.hermesDesktop?.watchPreviewFile || !window.hermesDesktop?.onPreviewFileChanged) {
return
@ -463,18 +599,13 @@ export function PreviewPane({
pendingReloadCount = 0
pendingReloadUrl = ''
consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current)
setLogs(prev => [
...prev.slice(-199),
{
id: ++logIdRef.current,
level: 1,
message:
changedCount === 1
? `File changed, reloading preview: ${compactUrl(changedUrl)}`
: `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}`
}
])
appendConsoleEntry({
level: 1,
message:
changedCount === 1
? `File changed, reloading preview: ${compactUrl(changedUrl)}`
: `${changedCount} file changes, reloading preview: ${compactUrl(changedUrl)}`
})
reloadPreview()
}
@ -509,14 +640,10 @@ export function PreviewPane({
watchId = watch.id
})
.catch(error => {
setLogs(prev => [
...prev.slice(-199),
{
id: ++logIdRef.current,
level: 2,
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
}
])
appendConsoleEntry({
level: 2,
message: `Could not watch preview file: ${error instanceof Error ? error.message : String(error)}`
})
})
return () => {
@ -531,7 +658,7 @@ export function PreviewPane({
void window.hermesDesktop?.stopPreviewFileWatch?.(watchId)
}
}
}, [reloadPreview, target.kind, target.url])
}, [appendConsoleEntry, reloadPreview, target.kind, target.url])
useEffect(() => {
const host = hostRef.current
@ -554,11 +681,6 @@ export function PreviewPane({
webview.setAttribute('src', target.url)
webview.setAttribute('webpreferences', 'contextIsolation=yes,nodeIntegration=no,sandbox=yes')
const appendLog = (entry: Omit<ConsoleEntry, 'id'>) => {
consoleShouldStickRef.current = isNearConsoleBottom(consoleBodyRef.current)
setLogs(prev => [...prev.slice(-199), { ...entry, id: ++logIdRef.current }])
}
const onConsole = (event: Event) => {
const detail = event as Event & {
level?: number
@ -566,13 +688,23 @@ export function PreviewPane({
message?: string
sourceId?: string
}
const message = detail.message || ''
appendLog({
appendConsoleEntry({
level: detail.level ?? 0,
line: detail.line,
message: detail.message || '',
message,
source: detail.sourceId
})
if ((detail.level ?? 0) >= 3 && isModuleMimeError(message)) {
setLoadError({
description:
'Module scripts are being served with the wrong MIME type. This usually means a static file server is serving a Vite/React app instead of the project dev server.',
url: webview.getURL?.() || target.url
})
setLoading(false)
}
}
const onNavigate = (event: Event) => {
@ -590,13 +722,14 @@ export function PreviewPane({
errorDescription?: string
validatedURL?: string
}
const errorCode = detail.errorCode
if (errorCode === -3) {
return
}
appendLog({
appendConsoleEntry({
level: 3,
message: `Load failed${errorCode ? ` (${errorCode})` : ''}: ${
detail.errorDescription || detail.validatedURL || 'unknown error'
@ -605,7 +738,7 @@ export function PreviewPane({
setLoadError({
code: errorCode,
description: detail.errorDescription || 'The preview page could not be reached.',
url: detail.validatedURL || currentUrl || target.url
url: detail.validatedURL || webview.getURL?.() || target.url
})
setLoading(false)
}
@ -631,7 +764,7 @@ export function PreviewPane({
webview.removeEventListener('did-stop-loading', onStop)
webview.remove()
}
}, [target.url])
}, [appendConsoleEntry, target.url])
return (
<aside className="relative flex h-screen w-full min-w-0 flex-col overflow-hidden border-l border-border/60 bg-background text-muted-foreground">
@ -659,7 +792,9 @@ export function PreviewPane({
<PreviewLoadError
consoleHeight={consoleOpen ? consoleHeight : 0}
error={loadError}
onRestartServer={target.kind === 'url' && onRestartServer ? () => void restartServer() : undefined}
onRetry={reloadPreview}
restarting={restartingServer}
/>
)}

View file

@ -0,0 +1,123 @@
'use client'
import { FolderOpen, GitBranch, Pencil } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { RailSection } from './rail-section'
interface ProjectSectionProps {
cwd: string
branch: string
busy: boolean
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
}
export function ProjectSection({ cwd, branch, busy, onChangeCwd, onBrowseCwd }: ProjectSectionProps) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(cwd)
const inputRef = useRef<HTMLInputElement | null>(null)
const canChange = Boolean(onChangeCwd) && !busy
const beginEdit = () => canChange && setEditing(true)
useEffect(() => {
if (!editing) {
setDraft(cwd)
}
}, [cwd, editing])
useEffect(() => {
if (editing) {
inputRef.current?.focus()
}
}, [editing])
const apply = () => {
const next = draft.trim()
if (next && next !== cwd) {
onChangeCwd?.(next)
}
setEditing(false)
}
const branchLabel = branch.trim()
return (
<RailSection title="Project">
{editing ? (
<Input
className="-ml-1.5 w-[calc(100%_+_0.375rem)] h-7 bg-background px-1.5 font-mono text-[0.6875rem]"
onBlur={apply}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
apply()
} else if (e.key === 'Escape') {
e.preventDefault()
setEditing(false)
}
}}
placeholder="/path/to/project"
ref={inputRef}
value={draft}
/>
) : (
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75 transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30">
<button
aria-label="Browse workspace folder"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:text-muted-foreground/60"
disabled={!canChange || !onBrowseCwd}
onClick={onBrowseCwd}
type="button"
>
<FolderOpen className="size-3" />
</button>
<button
aria-label="Edit working directory"
className="min-w-0 truncate text-right focus-visible:outline-none disabled:cursor-default"
dir="rtl"
disabled={!canChange}
onClick={beginEdit}
type="button"
>
<span dir="ltr">{compactPath(cwd) || '—'}</span>
</button>
{canChange && (
<button
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 opacity-60 transition-opacity hover:text-foreground group-hover:opacity-100 focus-visible:outline-none"
onClick={beginEdit}
tabIndex={-1}
type="button"
>
<Pencil className="size-3" />
</button>
)}
</div>
)}
{branchLabel && (
<div className="-ml-1.5 w-[calc(100%_+_0.375rem)] flex min-w-0 items-center gap-1 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-[0.6875rem] transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground">
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
</div>
)}
</RailSection>
)
}
function compactPath(path: string): string {
if (!path) {
return ''
}
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
const parts = normalized.split('/').filter(Boolean)
return parts.length <= 4 ? normalized || path : `.../${parts.slice(-3).join('/')}`
}

View file

@ -0,0 +1,34 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
interface RailActionRowProps {
primary: string
secondary?: string
ariaLabel?: string
onClick?: () => void
}
export function RailActionRow({ primary, secondary, ariaLabel, onClick }: RailActionRowProps) {
return (
<button
aria-label={ariaLabel}
className={cn(
'-ml-1.5 w-[calc(100%_+_0.375rem)] group grid gap-px rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
)}
disabled={!onClick}
onClick={onClick}
type="button"
>
<span className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-foreground/85">{primary}</span>
{onClick && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
)}
</span>
{secondary && <span className="truncate text-[0.625rem] text-muted-foreground/70">{secondary}</span>}
</button>
)
}

View file

@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
export function RailSectionLabel({ children }: { children: ReactNode }) {
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
}
interface RailSectionProps {
title: string
children: ReactNode
}
export function RailSection({ title, children }: RailSectionProps) {
return (
<section className="grid gap-1.5 py-1.5">
<RailSectionLabel>{title}</RailSectionLabel>
{children}
</section>
)
}

View file

@ -0,0 +1,88 @@
'use client'
import { ChevronDown } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
export interface RailSelectOption {
value: string
label: string
}
interface RailSelectRowProps {
label: string
menuLabel?: string
value: string
options: RailSelectOption[]
valueLabel?: ReactNode
ariaLabel?: string
menuWidthClass?: string
onChange?: (value: string) => void
}
export function RailSelectRow({
label,
menuLabel,
value,
options,
valueLabel,
ariaLabel,
menuWidthClass = 'w-44',
onChange
}: RailSelectRowProps) {
const [open, setOpen] = useState(false)
const activeOption = options.find(option => option.value === value)
const displayLabel = valueLabel ?? activeOption?.label ?? value
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild disabled={!onChange}>
<button
aria-label={ariaLabel ?? `Change ${label.toLowerCase()}`}
className="-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30 disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent"
type="button"
>
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
{label}
</span>
<span className="truncate text-[0.6875rem] text-foreground/75">{displayLabel}</span>
{onChange && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className={cn(menuWidthClass, 'border-border/70 bg-popover/95 shadow-md')}
side="bottom"
sideOffset={6}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">{menuLabel ?? label}</DropdownMenuLabel>
<DropdownMenuSeparator />
{options.map(option => (
<DropdownMenuCheckboxItem
checked={value === option.value}
className="text-xs text-muted-foreground focus:text-foreground"
key={option.value}
onSelect={e => {
e.preventDefault()
onChange?.(option.value)
setOpen(false)
}}
>
{option.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,36 @@
'use client'
import { useId } from 'react'
import { Switch } from '@/components/ui/switch'
interface RailToggleRowProps {
label: string
checked: boolean
valueLabel?: string
onChange?: (enabled: boolean) => void
}
export function RailToggleRow({ label, checked, valueLabel, onChange }: RailToggleRowProps) {
const id = useId()
const disabled = !onChange
return (
<label
className={`-ml-1.5 w-[calc(100%_+_0.375rem)] group flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-1.5 py-1 text-left transition-[background-color,border-color,color,box-shadow] ${disabled ? 'cursor-default' : 'cursor-pointer hover:border-input hover:bg-background hover:text-foreground focus-within:border-ring focus-within:bg-background focus-within:ring-[0.1875rem] focus-within:ring-ring/30'}`}
htmlFor={id}
>
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-within:text-foreground">
{label}
</span>
<span className="truncate text-[0.6875rem] text-foreground/75">{valueLabel ?? (checked ? 'On' : 'Off')}</span>
<Switch
checked={checked}
className="h-4 w-7 [&_[data-slot=switch-thumb]]:size-3 [&_[data-slot=switch-thumb]]:data-[state=checked]:translate-x-3"
disabled={disabled}
id={id}
onCheckedChange={next => onChange?.(next)}
/>
</label>
)
}

View file

@ -20,10 +20,20 @@ import { BUILTIN_PERSONALITIES, normalizePersonalityValue, personalityNamesFromC
import { extractPreviewCandidates } from '../lib/preview-targets'
import { $pinnedSessionIds, pinSession, unpinSession } from '../store/layout'
import { notify, notifyError } from '../store/notifications'
import { setPreviewTarget } from '../store/preview'
import {
$previewTarget,
beginPreviewServerRestart,
completePreviewServerRestart,
progressPreviewServerRestart,
requestPreviewReload,
setPreviewTarget
} from '../store/preview'
import {
$activeSessionId,
$currentCwd,
$currentFastMode,
$currentReasoningEffort,
$currentServiceTier,
$freshDraftReady,
$gatewayState,
$messages,
@ -34,9 +44,12 @@ import {
setContextSuggestions,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setIntroPersonality,
setMessages,
setModelPickerOpen,
@ -69,9 +82,9 @@ function normalizeRecordingLimit(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_VOICE_RECORDING_SECONDS
}
function gatewayEventPreviewText(event: { payload?: unknown }): string {
function gatewayEventPreviewText(event: { payload?: unknown; type?: string }): string {
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
const fields = ['text', 'rendered', 'preview', 'context', 'summary', 'message']
const fields = event.type?.startsWith('message.') ? ['text', 'rendered', 'preview'] : ['preview']
return fields
.map(key => payload[key])
@ -79,6 +92,16 @@ function gatewayEventPreviewText(event: { payload?: unknown }): string {
.join('\n')
}
function gatewayEventCompletedFileDiff(event: { payload?: unknown; type?: string }): boolean {
if (event.type !== 'tool.complete' || !event.payload || typeof event.payload !== 'object') {
return false
}
const inlineDiff = (event.payload as Record<string, unknown>).inline_diff
return typeof inlineDiff === 'string' && inlineDiff.trim().length > 0
}
export function DesktopController() {
const queryClient = useQueryClient()
const location = useLocation()
@ -102,9 +125,11 @@ export function DesktopController() {
const chatOpen = currentView === 'chat'
const settingsReturnPathRef = useRef(NEW_CHAT_ROUTE)
const refreshSessionsRequestRef = useRef(0)
const [titlebarToolGroups, setTitlebarToolGroups] = useState<
Record<TitlebarToolSide, Record<string, readonly TitlebarTool[]>>
>({ left: {}, right: {} })
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_RECORDING_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
@ -257,6 +282,30 @@ export function DesktopController() {
}
}, [])
const refreshProjectBranch = useCallback(
async (cwd: string) => {
const targetCwd = cwd.trim()
if (!targetCwd || activeSessionIdRef.current) {
return
}
try {
const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', {
key: 'project',
cwd: targetCwd
})
if (!activeSessionIdRef.current && ($currentCwd.get() || targetCwd) === (info.cwd || targetCwd)) {
setCurrentBranch(info.branch || '')
}
} catch {
setCurrentBranch('')
}
},
[activeSessionIdRef, requestGateway]
)
const changeSessionCwd = useCallback(
async (cwd: string) => {
const trimmed = cwd.trim()
@ -266,15 +315,18 @@ export function DesktopController() {
}
const persistGlobal = async () => {
await requestGateway('config.set', {
const info = await requestGateway<{ branch?: string; cwd?: string; value?: string }>('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'terminal.cwd',
value: trimmed
})
setCurrentCwd(trimmed)
const nextCwd = info.cwd || info.value || trimmed
setCurrentCwd(nextCwd)
if (!activeSessionId) {
setCurrentBranch('')
setCurrentBranch(info.branch || '')
}
}
@ -394,14 +446,24 @@ export function DesktopController() {
if (cwd && cwd !== '.') {
setCurrentCwd(prev => prev || cwd)
void refreshProjectBranch($currentCwd.get() || cwd)
}
const reasoningEffort = (config.agent?.reasoning_effort ?? '').trim()
const serviceTier = (config.agent?.service_tier ?? '').trim()
setCurrentReasoningEffort(prev => (activeSessionIdRef.current ? prev : reasoningEffort))
setCurrentServiceTier(prev => (activeSessionIdRef.current ? prev : serviceTier))
setCurrentFastMode(prev =>
activeSessionIdRef.current ? prev : ['fast', 'priority', 'on'].includes(serviceTier.toLowerCase())
)
setVoiceMaxRecordingSeconds(normalizeRecordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
} catch {
// Config is nice-to-have for the empty-state copy; the chat still works.
}
}, [activeSessionIdRef])
}, [activeSessionIdRef, refreshProjectBranch])
const selectPersonality = useCallback(
async (name: string) => {
@ -435,6 +497,50 @@ export function DesktopController() {
[activeSessionId, refreshHermesConfig, requestGateway]
)
const setReasoningEffort = useCallback(
async (effort: string) => {
const value = effort.trim().toLowerCase()
const previous = $currentReasoningEffort.get()
setCurrentReasoningEffort(value)
try {
await requestGateway('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'reasoning',
value
})
} catch (err) {
setCurrentReasoningEffort(previous)
void refreshHermesConfig()
notifyError(err, 'Reasoning change failed')
}
},
[activeSessionId, refreshHermesConfig, requestGateway]
)
const setFastMode = useCallback(
async (enabled: boolean) => {
const previousFast = $currentFastMode.get()
const previousTier = $currentServiceTier.get()
setCurrentFastMode(enabled)
setCurrentServiceTier(enabled ? 'priority' : '')
try {
await requestGateway('config.set', {
...(activeSessionId && { session_id: activeSessionId }),
key: 'fast',
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
setCurrentFastMode(previousFast)
setCurrentServiceTier(previousTier)
void refreshHermesConfig()
notifyError(err, 'Fast mode change failed')
}
},
[activeSessionId, refreshHermesConfig, requestGateway]
)
const hydrateFromStoredSession = useCallback(
async (
attempts = 1,
@ -512,10 +618,56 @@ export function DesktopController() {
[activeSessionIdRef, currentCwd]
)
const restartPreviewServer = useCallback(
async (url: string, context?: string) => {
const sessionId = activeSessionIdRef.current
if (!sessionId) {
throw new Error('No active session for background restart')
}
const cwd = $currentCwd.get() || currentCwd || ''
const result = await requestGateway<{ task_id?: string }>('preview.restart', {
context: context || undefined,
cwd: cwd || undefined,
session_id: sessionId,
url
})
const taskId = result.task_id || ''
if (!taskId) {
throw new Error('Background restart did not return a task id')
}
beginPreviewServerRestart(taskId, url)
return taskId
},
[activeSessionIdRef, currentCwd, requestGateway]
)
const handleDesktopGatewayEvent = useCallback(
(event: Parameters<typeof handleGatewayEvent>[0]) => {
handleGatewayEvent(event)
if (event.type === 'preview.restart.complete') {
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
if (taskId) {
completePreviewServerRestart(taskId, typeof payload.text === 'string' ? payload.text : '')
}
}
if (event.type === 'preview.restart.progress') {
const payload = event.payload && typeof event.payload === 'object' ? (event.payload as Record<string, unknown>) : {}
const taskId = typeof payload.task_id === 'string' ? payload.task_id : ''
if (taskId) {
progressPreviewServerRestart(taskId, typeof payload.text === 'string' ? payload.text : '')
}
}
if (event.session_id && event.session_id !== activeSessionIdRef.current) {
return
}
@ -525,6 +677,10 @@ export function DesktopController() {
if (previewText) {
void openDetectedPreview(previewText)
}
if ($previewTarget.get()?.kind === 'url' && gatewayEventCompletedFileDiff(event)) {
requestPreviewReload()
}
},
[activeSessionIdRef, handleGatewayEvent, openDetectedPreview]
)
@ -809,12 +965,15 @@ export function DesktopController() {
onPickImages={() => void pickImages()}
onReload={reloadFromMessage}
onRemoveAttachment={id => void removeAttachment(id)}
onRestartPreviewServer={restartPreviewServer}
onSelectPersonality={name => void selectPersonality(name)}
onSetFastMode={enabled => void setFastMode(enabled)}
onSetReasoningEffort={effort => void setReasoningEffort(effort)}
onSubmit={submitText}
onThreadMessagesChange={handleThreadMessagesChange}
setTitlebarToolGroup={setTitlebarToolGroup}
onToggleSelectedPin={toggleSelectedPin}
onTranscribeAudio={transcribeVoiceAudio}
setTitlebarToolGroup={setTitlebarToolGroup}
/>
)

View file

@ -20,9 +20,12 @@ import { notify } from '@/store/notifications'
import {
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier
} from '@/store/session'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
@ -321,6 +324,18 @@ export function useMessageStream({
setCurrentPersonality(normalizePersonalityValue(payload.personality))
}
if (typeof payload?.reasoning_effort === 'string') {
setCurrentReasoningEffort(payload.reasoning_effort)
}
if (typeof payload?.service_tier === 'string') {
setCurrentServiceTier(payload.service_tier)
}
if (typeof payload?.fast === 'boolean') {
setCurrentFastMode(payload.fast)
}
if (runningChanged && sessionId) {
updateSessionState(sessionId, state => {
const busy = Boolean(payload!.running)

View file

@ -17,9 +17,12 @@ import {
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setFreshDraftReady,
setIntroSeed,
setMessages,
@ -54,6 +57,7 @@ interface SessionActionsOptions {
function withAppendedText(message: ChatMessage, suffix: string): ChatMessage {
let appended = false
const parts = message.parts.map(part => {
if (part.type !== 'text' || appended) {
return part
@ -193,6 +197,18 @@ function applyRuntimeInfo(info: SessionCreateResponse['info'] | undefined) {
if (typeof info.personality === 'string') {
setCurrentPersonality(normalizePersonalityValue(info.personality))
}
if (typeof info.reasoning_effort === 'string') {
setCurrentReasoningEffort(info.reasoning_effort)
}
if (typeof info.service_tier === 'string') {
setCurrentServiceTier(info.service_tier)
}
if (typeof info.fast === 'boolean') {
setCurrentFastMode(info.fast)
}
}
export function useSessionActions({
@ -385,6 +401,7 @@ export function useSessionActions({
// Keep the already-painted local snapshot for the view/cache when it
// exists; use gateway messages only as a fallback when no local
// snapshot was available.
const messagesForView = localSnapshot.length > 0
? localSnapshot
: chatMessageArraysEquivalent(currentMessages, resumedMessages)

View file

@ -4,7 +4,6 @@ import { useCallback } from 'react'
import { SidebarProvider } from '@/components/ui/sidebar'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import {
$inspectorOpen,
$sidebarOpen,
@ -66,21 +65,14 @@ export function AppShell({
const inspectorColumn = showInspectorRail ? 'var(--inspector-width)' : '0px'
// Preview yields first because it is the widest rail; keep chat usable before
// letting the webview consume horizontal space.
const previewColumn = showPreviewRail
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width) - 3 * var(--shell-gap))))`
? `min(var(--preview-width), max(0px, calc(100vw - var(--sidebar-width) - ${showInspectorRail ? 'var(--inspector-width)' : '0px'} - var(--chat-min-width))))`
: '0px'
const titlebarToolCount = (titlebarTools?.filter(tool => !tool.hidden).length ?? 0) + (rightRailOpen ? 1 : 0) + 2
// Always keep the shell as fixed columns because sidebar/chat/preview/inspector
// are always rendered as grid children. Hidden rails collapse to 0px so they
// don't float over the chat surface or reorder into a new row.
const shellGridColumns = 'var(--sidebar-width) minmax(0,1fr) var(--preview-col) var(--inspector-col)'
const hasSideGaps = sidebarOpen || showPreviewRail || showInspectorRail
const startSidebarResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault()
@ -149,10 +141,7 @@ export function AppShell({
/>
<main
className={cn(
'relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none',
hasSideGaps ? 'gap-(--shell-gap)' : 'gap-0'
)}
className="relative grid h-screen w-full overflow-hidden bg-background pr-0.75 pb-0.75 pt-0.75 transition-none"
style={
{
'--inspector-col': inspectorColumn,

View file

@ -1,20 +1,12 @@
'use client'
import { ChevronDown, FolderOpen, GitBranch, Pencil } from 'lucide-react'
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
import type { FC } from 'react'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { AgentSection } from '@/app/chat/right-rail/agent-section'
import { ProjectSection } from '@/app/chat/right-rail/project-section'
import { cn } from '@/lib/utils'
export type SessionInspectorProps = {
export interface SessionInspectorProps {
open: boolean
cwd: string
branch: string
@ -22,39 +14,38 @@ export type SessionInspectorProps = {
modelLabel: string
modelTitle?: string
providerName?: string
reasoningEffort: string
serviceTier: string
fastMode: boolean
personality: string
personalities: string[]
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
onOpenModelPicker?: () => void
onSetReasoningEffort?: (effort: string) => void
onSetFastMode?: (enabled: boolean) => void
onSelectPersonality?: (name: string) => void
}
export const SESSION_INSPECTOR_WIDTH = '14rem'
// Quiet button-like row: invisible until hovered/focused.
const quietControl =
'rounded-md border border-transparent bg-transparent transition-[background-color,border-color,color,box-shadow] hover:border-input hover:bg-background hover:text-foreground focus-visible:border-ring focus-visible:bg-background focus-visible:outline-none focus-visible:ring-[0.1875rem] focus-visible:ring-ring/30'
// Bleed interactive rows leftwards by 6px so the hover ring doesn't look
// indented relative to the section labels above them.
const bleed = '-ml-1.5 w-[calc(100%_+_0.375rem)]'
const disabledRow = 'disabled:cursor-default disabled:hover:border-transparent disabled:hover:bg-transparent'
export const SessionInspector: FC<SessionInspectorProps> = ({
open,
cwd,
branch,
busy,
modelLabel,
modelTitle,
providerName,
reasoningEffort,
serviceTier,
fastMode,
personality,
personalities,
onChangeCwd,
onBrowseCwd,
onOpenModelPicker,
onSetFastMode,
onSetReasoningEffort,
onSelectPersonality
}) => (
<aside
@ -66,249 +57,26 @@ export const SessionInspector: FC<SessionInspectorProps> = ({
data-open={open}
>
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overscroll-contain pl-1.5 pr-1 text-xs">
<WorkspaceSection branch={branch} busy={busy} cwd={cwd} onBrowseCwd={onBrowseCwd} onChangeCwd={onChangeCwd} />
<ProjectSection
branch={branch}
busy={busy}
cwd={cwd}
onBrowseCwd={onBrowseCwd}
onChangeCwd={onChangeCwd}
/>
<AgentSection
current={personality}
label={modelLabel}
onOpen={onOpenModelPicker}
onSelect={onSelectPersonality}
options={personalities}
fastMode={fastMode}
modelLabel={modelLabel}
onOpenModelPicker={onOpenModelPicker}
onSelectPersonality={onSelectPersonality}
onSetFastMode={onSetFastMode}
onSetReasoningEffort={onSetReasoningEffort}
personalities={personalities}
personality={personality}
providerName={providerName}
title={modelTitle}
reasoningEffort={reasoningEffort}
serviceTier={serviceTier}
/>
</div>
</aside>
)
function SectionLabel({ children }: { children: React.ReactNode }) {
return <div className="text-xs font-medium text-muted-foreground/90">{children}</div>
}
function WorkspaceSection({
cwd,
branch,
busy,
onChangeCwd,
onBrowseCwd
}: {
cwd: string
branch: string
busy: boolean
onChangeCwd?: (cwd: string) => void
onBrowseCwd?: () => void
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(cwd)
const inputRef = useRef<HTMLInputElement | null>(null)
const canChange = Boolean(onChangeCwd) && !busy
const beginEdit = () => canChange && setEditing(true)
useEffect(() => {
if (!editing) {
setDraft(cwd)
}
}, [cwd, editing])
useEffect(() => {
if (editing) {
inputRef.current?.focus()
}
}, [editing])
const apply = () => {
const next = draft.trim()
if (next && next !== cwd) {
onChangeCwd?.(next)
}
setEditing(false)
}
const branchLabel = branch.trim()
return (
<section className="grid gap-1.5 py-1.5">
<SectionLabel>cwd</SectionLabel>
{editing ? (
<Input
className={cn(bleed, 'h-7 bg-background px-1.5 font-mono text-[0.6875rem]')}
onBlur={apply}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
apply()
} else if (e.key === 'Escape') {
e.preventDefault()
setEditing(false)
}
}}
placeholder="/path/to/project"
ref={inputRef}
value={draft}
/>
) : (
<div
className={cn(
quietControl,
bleed,
'group grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1 px-1.5 py-1 font-mono text-[0.6875rem] text-foreground/75'
)}
>
<button
aria-label="Browse workspace folder"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 hover:text-foreground focus-visible:outline-none disabled:cursor-default disabled:hover:text-muted-foreground/60"
disabled={!canChange || !onBrowseCwd}
onClick={onBrowseCwd}
type="button"
>
<FolderOpen className="size-3" />
</button>
<button
aria-label="Edit working directory"
className="min-w-0 truncate text-right focus-visible:outline-none disabled:cursor-default"
dir="rtl"
disabled={!canChange}
onClick={beginEdit}
type="button"
>
<span dir="ltr">{compactPath(cwd) || '—'}</span>
</button>
{canChange && (
<button
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded text-muted-foreground/60 opacity-60 transition-opacity hover:text-foreground group-hover:opacity-100 focus-visible:outline-none"
onClick={beginEdit}
tabIndex={-1}
type="button"
>
<Pencil className="size-3" />
</button>
)}
</div>
)}
{branchLabel && (
<div className={cn(quietControl, bleed, 'flex min-w-0 items-center gap-1 px-1.5 py-1 text-[0.6875rem]')}>
<GitBranch className="size-3 shrink-0 text-muted-foreground/60" />
<span className="min-w-0 truncate font-mono text-foreground/75">{branchLabel}</span>
</div>
)}
</section>
)
}
function personalityOptionKey(value?: string): string {
const key = value?.trim().toLowerCase() || 'none'
return key === 'default' ? 'none' : key
}
function AgentSection({
label: modelLabel,
onOpen,
providerName,
current,
options,
onSelect
}: {
label: string
title?: string
providerName?: string
onOpen?: () => void
current: string
options: string[]
onSelect?: (name: string) => void
}) {
const [open, setOpen] = useState(false)
const merged = useMemo(
() => [...new Set(['none', ...options, current].map(personalityOptionKey).filter(Boolean))],
[current, options]
)
const activeKey = personalityOptionKey(current)
const personalityLabel = activeKey === 'none' ? 'None' : titleize(activeKey)
return (
<section className="grid gap-1.5 py-1.5">
<SectionLabel>Agent</SectionLabel>
<button
aria-label="Change model"
className={cn(quietControl, bleed, disabledRow, 'group grid gap-px px-1.5 py-1 text-left')}
disabled={!onOpen}
onClick={onOpen}
type="button"
>
<span className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-foreground/85">
{modelLabel || 'Hermes'}
</span>
{onOpen && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</span>
{providerName && <span className="truncate text-[0.625rem] text-muted-foreground/70">{providerName}</span>}
</button>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild disabled={!onSelect}>
<button
aria-label="Change personality"
className={cn(quietControl, bleed, disabledRow, 'group flex items-center gap-1.5 px-1.5 py-1 text-left')}
type="button"
>
<span className="min-w-0 flex-1 truncate text-[0.6875rem] text-muted-foreground group-hover:text-foreground group-focus-visible:text-foreground">
{personalityLabel}
</span>
{onSelect && (
<ChevronDown className="size-3 shrink-0 text-muted-foreground/60 opacity-0 transition-opacity group-hover:opacity-100" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-52 border-border/70 bg-popover/95 shadow-md"
side="bottom"
sideOffset={6}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">Personality</DropdownMenuLabel>
<DropdownMenuSeparator />
{merged.map(name => (
<DropdownMenuCheckboxItem
checked={activeKey === name}
className="text-xs text-muted-foreground focus:text-foreground"
key={name}
onSelect={e => {
e.preventDefault()
onSelect?.(name)
setOpen(false)
}}
>
{titleize(name)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</section>
)
}
function compactPath(path: string): string {
if (!path) {
return ''
}
const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
const parts = normalized.split('/').filter(Boolean)
return parts.length <= 4 ? normalized || path : `.../${parts.slice(-3).join('/')}`
}
function titleize(value: string): string {
return value
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/(^|\s)\S/g, m => m.toUpperCase())
}

View file

@ -31,6 +31,9 @@ export type GatewayEventPayload = {
todos?: unknown
model?: string
provider?: string
reasoning_effort?: string
service_tier?: string
fast?: boolean
running?: boolean
cwd?: string
branch?: string

View file

@ -25,6 +25,21 @@ describe('preview target detection', () => {
])
})
it('accepts bare html files and common preview directories', () => {
expect(extractPreviewCandidates('Open index.html, nested/demo.html, ./dist, and /tmp/site/.')).toEqual([
'index.html',
'nested/demo.html',
'./dist',
'/tmp/site/'
])
})
it('rejects non-html file URLs and obvious local API or asset URLs', () => {
expect(isLikelyPreviewCandidate('file:///tmp/demo.png')).toBe(false)
expect(isLikelyPreviewCandidate('http://localhost:3000/api/users')).toBe(false)
expect(isLikelyPreviewCandidate('http://localhost:3000/src/main.tsx')).toBe(false)
})
it('ignores remote web URLs', () => {
expect(isLikelyPreviewCandidate('https://example.com/demo')).toBe(false)
expect(isLikelyPreviewCandidate('http://127.0.0.1:3000')).toBe(true)

View file

@ -1,9 +1,29 @@
const LOCAL_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::1', '[::1]', 'localhost'])
const PREVIEW_DIRECTORY_NAMES = new Set(['build', 'dist', 'out', 'public', 'site', 'web', 'www'])
const HTML_EXT_RE = /\.html?(?:[?#].*)?$/i
const ASSET_EXT_RE =
/\.(?:cjs|css|csv|gif|ico|jpe?g|js|json|jsx|map|mjs|otf|png|svg|ts|tsx|ttf|txt|wasm|webp|woff2?|xml)$/i
const URL_RE = /\bhttps?:\/\/[^\s<>"'`)\]]+/gi
const FILE_URL_RE = /\bfile:\/\/[^\s<>"'`)\]]+/gi
const POSIX_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const RELATIVE_HTML_PATH_RE = /(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const POSIX_HTML_PATH_RE =
/(?:^|[\s("'`])(?<path>\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const RELATIVE_HTML_PATH_RE =
/(?:^|[\s("'`])(?<path>\.{1,2}\/[^\s<>"'`]*?\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const BARE_HTML_PATH_RE =
/(?:^|[\s("'`])(?<path>(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+\.html?(?:[?#][^\s<>"'`)\]]*)?)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const POSIX_PATH_RE = /(?:^|[\s("'`])(?<path>\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const RELATIVE_PATH_RE = /(?:^|[\s("'`])(?<path>(?:\.{1,2}|~)\/[^\s<>"'`]+)(?:[),.;:!?]*)(?=$|[\s)"'`])/gi
const PREVIEW_MARKDOWN_RE = /\[Preview:[^\]]+\]\((?<href>#preview[:/][^)]+)\)/gi
interface PreviewCandidateMatch {
@ -16,6 +36,40 @@ function stripTrailingPunctuation(value: string): string {
return value.replace(/[),.;:!?]+$/, '')
}
function pathWithoutQuery(value: string): string {
return value.split(/[?#]/, 1)[0]
}
function pathBasename(value: string): string {
return pathWithoutQuery(value).replace(/\/+$/, '').split(/[\\/]/).filter(Boolean).pop()?.toLowerCase() || ''
}
function isHtmlFileUrl(value: string): boolean {
try {
const url = new URL(value)
return url.protocol === 'file:' && HTML_EXT_RE.test(url.pathname)
} catch {
return false
}
}
function isPreviewDirectoryCandidate(value: string): boolean {
const path = pathWithoutQuery(value)
if (!/^(?:\/|\.{1,2}\/|~\/)/.test(path) || HTML_EXT_RE.test(value)) {
return false
}
const name = pathBasename(path)
if (!name || /\.[a-z0-9]{1,8}$/i.test(name)) {
return false
}
return path.endsWith('/') || PREVIEW_DIRECTORY_NAMES.has(name)
}
function isLocalPreviewUrl(value: string): boolean {
try {
const url = new URL(value)
@ -24,7 +78,17 @@ function isLocalPreviewUrl(value: string): boolean {
return false
}
return LOCAL_HOSTS.has(url.hostname.toLowerCase())
if (!LOCAL_HOSTS.has(url.hostname.toLowerCase())) {
return false
}
const pathname = url.pathname.toLowerCase()
if (/^\/(?:api|graphql|health|metrics|rpc)(?:\/|$)/.test(pathname)) {
return false
}
return !ASSET_EXT_RE.test(pathname)
} catch {
return false
}
@ -33,7 +97,7 @@ function isLocalPreviewUrl(value: string): boolean {
export function isLikelyPreviewCandidate(value: string): boolean {
const trimmed = stripTrailingPunctuation(value.trim())
return trimmed.startsWith('file://') || HTML_EXT_RE.test(trimmed) || isLocalPreviewUrl(trimmed)
return isHtmlFileUrl(trimmed) || HTML_EXT_RE.test(trimmed) || isPreviewDirectoryCandidate(trimmed) || isLocalPreviewUrl(trimmed)
}
function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
@ -76,6 +140,18 @@ function collectPreviewMatches(text: string): PreviewCandidateMatch[] {
collect(match.index, match[0], match.groups?.path || '')
}
for (const match of text.matchAll(BARE_HTML_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
for (const match of text.matchAll(POSIX_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
for (const match of text.matchAll(RELATIVE_PATH_RE)) {
collect(match.index, match[0], match.groups?.path || '')
}
return matches.sort((a, b) => a.index - b.index)
}

View file

@ -7,7 +7,16 @@ export interface PreviewTarget {
url: string
}
export interface PreviewServerRestart {
message?: string
status: 'complete' | 'error' | 'running'
taskId: string
url: string
}
export const $previewTarget = atom<PreviewTarget | null>(null)
export const $previewReloadRequest = atom(0)
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null): boolean {
if (a === b) {
@ -28,3 +37,52 @@ export function setPreviewTarget(target: PreviewTarget | null) {
$previewTarget.set(target)
}
export function requestPreviewReload() {
$previewReloadRequest.set($previewReloadRequest.get() + 1)
}
export function beginPreviewServerRestart(taskId: string, url: string) {
$previewServerRestart.set({ status: 'running', taskId, url })
}
export function completePreviewServerRestart(taskId: string, text: string) {
const current = $previewServerRestart.get()
if (current?.taskId !== taskId) {
return
}
$previewServerRestart.set({
...current,
message: text,
status: text.trim().toLowerCase().startsWith('error:') ? 'error' : 'complete'
})
}
export function progressPreviewServerRestart(taskId: string, text: string) {
const current = $previewServerRestart.get()
if (current?.taskId !== taskId || current.status !== 'running') {
return
}
$previewServerRestart.set({
...current,
message: text
})
}
export function failPreviewServerRestart(taskId: string, message: string) {
const current = $previewServerRestart.get()
if (current?.taskId !== taskId || current.status !== 'running') {
return
}
$previewServerRestart.set({
...current,
message,
status: 'error'
})
}

View file

@ -29,6 +29,9 @@ export const $busy = atom(false)
export const $awaitingResponse = atom(false)
export const $currentModel = atom('')
export const $currentProvider = atom('')
export const $currentReasoningEffort = atom('')
export const $currentServiceTier = atom('')
export const $currentFastMode = atom(false)
export const $currentCwd = atom('')
export const $currentBranch = atom('')
export const $introPersonality = atom('')
@ -51,6 +54,9 @@ export const setBusy = (next: Updater<boolean>) => updateAtom($busy, next)
export const setAwaitingResponse = (next: Updater<boolean>) => updateAtom($awaitingResponse, next)
export const setCurrentModel = (next: Updater<string>) => updateAtom($currentModel, next)
export const setCurrentProvider = (next: Updater<string>) => updateAtom($currentProvider, next)
export const setCurrentReasoningEffort = (next: Updater<string>) => updateAtom($currentReasoningEffort, next)
export const setCurrentServiceTier = (next: Updater<string>) => updateAtom($currentServiceTier, next)
export const setCurrentFastMode = (next: Updater<boolean>) => updateAtom($currentFastMode, next)
export const setCurrentCwd = (next: Updater<string>) => updateAtom($currentCwd, next)
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setIntroPersonality = (next: Updater<string>) => updateAtom($introPersonality, next)

View file

@ -51,7 +51,9 @@ export interface GatewayReadyPayload {
export interface HermesConfig {
agent?: {
reasoning_effort?: string
personalities?: Record<string, unknown>
service_tier?: string
}
display?: {
personality?: string

View file

@ -1838,6 +1838,74 @@ def _background_agent_kwargs(agent, task_id: str) -> dict:
}
def _ephemeral_preview_agent_kwargs(agent, task_id: str) -> dict:
kwargs = _background_agent_kwargs(agent, task_id)
kwargs.update(
{
"enabled_toolsets": ["terminal", "file"],
"session_db": None,
"skip_memory": True,
}
)
return kwargs
def _preview_tool_result_preview(name: str, result: str) -> str:
try:
data = json.loads(result)
except Exception:
return ""
if not isinstance(data, dict):
return ""
if name == "terminal":
output = str(data.get("output") or "").strip()
exit_code = data.get("exit_code")
if output:
return output[-1200:]
if data.get("session_id"):
return f"Background process started: {data.get('session_id')}"
if exit_code is not None:
return f"terminal exited with code {exit_code}"
return str(data.get("error") or "").strip()[:1200]
def _preview_restart_callbacks(parent: str, task_id: str) -> dict:
started_at: dict[str, float] = {}
def progress(message: str, level: str = "info") -> None:
text = str(message or "").strip()
if text:
_emit("preview.restart.progress", parent, {"task_id": task_id, "level": level, "text": text})
def tool_start(tool_call_id: str, name: str, args: dict) -> None:
started_at[tool_call_id] = time.time()
ctx = _tool_ctx(name, args)
progress(f"Running {name}{f': {ctx}' if ctx else ''}")
def tool_complete(tool_call_id: str, name: str, _args: dict, result: str) -> None:
duration_s = time.time() - started_at.get(tool_call_id, time.time())
summary = _tool_summary(name, result, duration_s) or f"Finished {name}{f' in {_fmt_tool_duration(duration_s)}' if duration_s else ''}"
output = _preview_tool_result_preview(name, result)
progress(summary + (f"\n{output}" if output else ""))
def tool_progress(event_type: str, name: str | None = None, preview: str | None = None, **_kwargs) -> None:
if preview:
progress(str(preview))
elif name:
progress(f"{event_type.replace('.', ' ')}: {name}")
return {
"tool_start_callback": tool_start,
"tool_complete_callback": tool_complete,
"tool_progress_callback": tool_progress,
"tool_gen_callback": lambda name: progress(f"Preparing {name}"),
"status_callback": lambda kind, text=None: progress(text if text is not None else kind),
}
def _reset_session_agent(sid: str, session: dict) -> dict:
tokens = _set_session_context(session["session_key"])
try:
@ -3506,6 +3574,87 @@ def _(rid, params: dict) -> dict:
return _ok(rid, {"task_id": task_id})
@method("preview.restart")
def _(rid, params: dict) -> dict:
session, err = _sess(params, rid)
if err:
return err
url = str(params.get("url") or "").strip()
cwd = str(params.get("cwd") or "").strip()
context = str(params.get("context") or "").strip()
if not url:
return _err(rid, 4012, "url required")
task_id = f"preview_{uuid.uuid4().hex[:6]}"
parent = params.get("session_id", "")
prompt = "\n".join(
line
for line in [
"The desktop preview pane cannot load a local server URL.",
"",
f"Preview URL: {url}",
f"Current working directory: {cwd or '(unknown)'}",
"",
f"Preview console:\n{context}" if context else "",
"" if context else "",
"Restart exactly the app intended for the Preview URL, not Hermes Desktop itself.",
"The Preview URL and port are the target. Preserve that target unless you conclude it is impossible.",
"First inspect what process, if any, owns the Preview URL port. If a stale server exists, inspect its cwd and prefer that cwd over the Hermes/Desktop process cwd.",
"The Current working directory is only a hint. Do not assume it is the preview app root when the port owner or files indicate another root.",
"If the console shows a module-script MIME error for src/main.tsx or similar, a static server is serving source files. Do not restart python -m http.server or any dumb static server for that app.",
"For module-script MIME failures, inspect package.json/vite config in the candidate app root and start the real dev server/bundler (for example npm/pnpm/yarn dev) so module transforms happen.",
"Before declaring success, verify the Preview URL responds with the intended app, not Hermes Desktop. If it serves Hermes/Desktop UI or another unrelated app, stop that process and report failure.",
"Do not modify files. Do not ask the user unless blocked.",
"Prefer existing project scripts or commands when they are clear.",
"If a stale process owns the needed port, handle it safely.",
"Start long-running servers detached/in the background, then return immediately.",
"Do not run a foreground dev server command that blocks this background task.",
"Keep the final response short: what command/server was started, or why it could not be restarted.",
]
if line
)
def run():
session_tokens = _set_session_context(task_id)
try:
from run_agent import AIAgent
from tools.terminal_tool import register_task_env_overrides
if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))):
register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))})
_emit("preview.restart.progress", parent, {"task_id": task_id, "text": "Starting hidden restart agent"})
result = AIAgent(
**_ephemeral_preview_agent_kwargs(session["agent"], task_id),
**_preview_restart_callbacks(parent, task_id),
).run_conversation(user_message=prompt, task_id=task_id)
text = (
result.get("final_response", str(result))
if isinstance(result, dict)
else str(result)
)
_emit("preview.restart.complete", parent, {"task_id": task_id, "text": text})
except Exception as e:
_emit(
"preview.restart.complete",
parent,
{"task_id": task_id, "text": f"error: {e}"},
)
finally:
try:
from tools.terminal_tool import clear_task_env_overrides
clear_task_env_overrides(task_id)
except Exception:
pass
_clear_session_context(session_tokens)
threading.Thread(target=run, daemon=True).start()
return _ok(rid, {"task_id": task_id})
# ── Methods: respond ─────────────────────────────────────────────────
@ -3900,7 +4049,10 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4002, f"working directory does not exist: {raw}")
_write_config_key("terminal.cwd", cwd)
os.environ["TERMINAL_CWD"] = cwd
return _ok(rid, {"key": "terminal.cwd", "value": cwd})
return _ok(
rid,
{"key": "terminal.cwd", "value": cwd, "cwd": cwd, "branch": _git_branch_for_cwd(cwd)},
)
if key in ("prompt", "personality", "skin"):
try:
@ -3964,6 +4116,11 @@ def _(rid, params: dict) -> dict:
from hermes_constants import display_hermes_home
return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()})
if key == "project":
cfg_terminal = _load_cfg().get("terminal") or {}
raw = str(params.get("cwd", "") or cfg_terminal.get("cwd", "") or "").strip()
cwd = _completion_cwd({"cwd": raw} if raw else {})
return _ok(rid, {"cwd": cwd, "branch": _git_branch_for_cwd(cwd)})
if key == "full":
return _ok(rid, {"config": _load_cfg()})
if key == "prompt":