opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
-11
View File
@@ -173,14 +173,3 @@ export function hasAnsiCodes(input: string): boolean {
// eslint-disable-next-line no-control-regex
return /\x1b\[/.test(input)
}
/** Remove all ANSI escape sequences, returning plain text. Use when output is
* rendered as text (e.g. chat system messages) rather than styled segments —
* otherwise the ESC byte is invisible and the `[1;31m…` payload leaks through. */
export function stripAnsi(input: string): string {
if (!input) {
return input
}
return input.replace(OTHER_ESCAPE_RE, '').replace(CSI_RE, '')
}
-5
View File
@@ -58,14 +58,9 @@ export type GatewayEventPayload = {
// approval.request (dangerous command / execute_code) — session-keyed
command?: string
description?: string
// False when a tirith content-security warning forbids a permanent allow.
allow_permanent?: boolean
// secret.request (skill credential capture)
env_var?: string
prompt?: string
// terminal.read.request (GUI agent reading the in-app terminal pane)
start?: number
count?: number
}
export function textPart(text: string): ChatMessagePart {
+1 -37
View File
@@ -1,42 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { ComposerAttachment } from '@/store/composer'
import { coerceThinkingText, optimisticAttachmentRef } from './chat-runtime'
const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'kind'>): ComposerAttachment {
return { id: 'a', label: 'file.png', ...overrides }
}
describe('optimisticAttachmentRef', () => {
it('renders an image from its in-hand base64 preview (no @image: path ref)', () => {
const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL }))
// The raw data URL flows through extractEmbeddedImages → inline thumbnail,
// dodging the remote /api/media 403 an @image:<localpath> ref would hit.
expect(ref).toBe(DATA_URL)
})
it('falls back to an @image: path ref when no preview is available', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBe('@image:/tmp/shot.png')
})
it('ignores a non-data preview url and uses the path ref', () => {
const ref = optimisticAttachmentRef(
attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' })
)
expect(ref).toBe('@image:/tmp/shot.png')
})
it('passes non-image attachments straight through to attachmentDisplayText', () => {
expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe(
'@file:src/a.ts'
)
})
})
import { coerceThinkingText } from './chat-runtime'
describe('coerceThinkingText', () => {
it('strips streaming status prefixes from thinking deltas', () => {
-30
View File
@@ -40,13 +40,6 @@ export function createClientSessionState(
messages,
branch: '',
cwd: '',
model: '',
provider: '',
reasoningEffort: '',
serviceTier: '',
fast: false,
yolo: false,
personality: '',
busy: false,
awaitingResponse: false,
streamId: null,
@@ -172,29 +165,6 @@ export function attachmentDisplayText(attachment: ComposerAttachment): string |
return null
}
/**
* Display ref for the optimistic (in-flight) user bubble.
*
* Images prefer their in-hand base64 preview (a `data:` URL) over a file path.
* `DirectiveContent` runs `extractEmbeddedImages` first, so a raw `data:` URL
* renders as an inline thumbnail with zero network. An `@image:<localpath>` ref
* would instead route through `/api/media`, which in remote mode 403s ("Path
* outside media roots") on a local path the gateway can't read yet — flashing a
* fallback chip until submit uploads the bytes. The preview also survives the
* post-sync rewrite (bytes go to the agent via the attached-image pipeline, not
* this display ref), so the thumbnail stays stable instead of remounting.
*
* Everything else (files, folders, terminals, post-sync `@file:` refs) falls
* through to `attachmentDisplayText`.
*/
export function optimisticAttachmentRef(attachment: ComposerAttachment): string | null {
if (attachment.kind === 'image' && attachment.previewUrl?.startsWith('data:')) {
return attachment.previewUrl
}
return attachmentDisplayText(attachment)
}
export function personalityNamesFromConfig(config: unknown): string[] {
const root = config && typeof config === 'object' ? (config as Record<string, unknown>) : {}
const agent = root.agent && typeof root.agent === 'object' ? (root.agent as Record<string, unknown>) : {}
-116
View File
@@ -1,116 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
selectDesktopPaths,
setDesktopFsRemotePicker
} from './desktop-fs'
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
const gitRoot = vi.fn(async () => '/local')
const selectPaths = vi.fn(async () => ['/local'])
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
throw new Error(`unexpected path ${path}`)
})
function stubBridge() {
vi.stubGlobal('window', {
hermesDesktop: {
api,
gitRoot,
readDir,
readFileDataUrl,
readFileText,
selectPaths
}
})
}
describe('desktop filesystem facade', () => {
beforeEach(() => {
stubBridge()
$connection.set(null)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
setDesktopFsRemotePicker(null)
})
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local' } as never)
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
expect(readDir).toHaveBeenCalledWith('/work')
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
expect(gitRoot).toHaveBeenCalledWith('/work')
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
expect(api).not.toHaveBeenCalled()
})
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
expect(readDir).not.toHaveBeenCalled()
expect(readFileText).not.toHaveBeenCalled()
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(gitRoot).not.toHaveBeenCalled()
})
it('uses the registered in-app directory picker in remote mode', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
'/remote/project'
])
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
expect(selectPaths).not.toHaveBeenCalled()
})
it('does not treat the remote directory picker as a general file picker', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
})
-95
View File
@@ -1,95 +0,0 @@
import { $connection } from '@/store/session'
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
let remotePicker: DesktopFsRemotePicker | null = null
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
remotePicker = next
}
function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {
return connectionCacheKey($connection.get())
}
export function isDesktopFsRemoteMode() {
return $connection.get()?.mode === 'remote'
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
}
function bridge() {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readDir(path)
}
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileText(path)
}
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileDataUrl(path)
}
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
return typeof result === 'string' ? result : result.dataUrl || ''
}
export async function desktopGitRoot(path: string): Promise<string | null> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
return result.root
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
}
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(options)
}
if (!options?.directories || options.multiple !== false) {
return []
}
return remotePicker ? remotePicker.selectPaths(options) : []
}
@@ -7,9 +7,7 @@ import {
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isDesktopSlashSuggestion,
isModelPickerCommand,
isPickerCommand,
resolveDesktopCommand
isModelPickerCommand
} from './desktop-slash-commands'
describe('desktop slash command curation', () => {
@@ -40,18 +38,6 @@ describe('desktop slash command curation', () => {
expect(isDesktopSlashSuggestion('/curator')).toBe(false)
})
it('surfaces /tools, /save, and /personality on the desktop', () => {
expect(isDesktopSlashSuggestion('/tools')).toBe(true)
expect(isDesktopSlashSuggestion('/save')).toBe(true)
expect(isDesktopSlashSuggestion('/personality')).toBe(true)
expect(isDesktopSlashCommand('/tools')).toBe(true)
expect(isDesktopSlashCommand('/save')).toBe(true)
expect(isDesktopSlashCommand('/personality')).toBe(true)
expect(desktopSlashUnavailableMessage('/tools')).toBeNull()
expect(desktopSlashUnavailableMessage('/save')).toBeNull()
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)
@@ -88,24 +74,6 @@ describe('desktop slash command curation', () => {
['/new', 'Start a new desktop chat'],
['/ship-it', 'Run release checklist']
])
// skill_count is recomputed from the filtered output (only /ship-it is an
// extension command — /new is a built-in) so the /help footer matches what
// the user actually sees rather than echoing the unfiltered backend total.
expect(filtered.skill_count).toBe(1)
})
it('recomputes skill_count to reflect only extensions surfaced on desktop', () => {
const filtered = filterDesktopCommandsCatalog({
pairs: [
['/new', 'Start a new session'],
['/clear', 'Clear terminal screen'],
['/gif-search', 'Search for a gif'],
['/ship-it', 'Run release checklist']
],
skill_count: 12
})
expect(filtered.pairs?.map(([cmd]) => cmd)).toEqual(['/new', '/gif-search', '/ship-it'])
expect(filtered.skill_count).toBe(2)
})
@@ -155,26 +123,4 @@ describe('desktop slash command curation', () => {
expect(isModelPickerCommand('/new')).toBe(false)
expect(isModelPickerCommand('/skills')).toBe(false)
})
it('gives /resume (and its aliases) a first-class session picker surface', () => {
expect(isPickerCommand('/resume', 'session')).toBe(true)
expect(isPickerCommand('/sessions', 'session')).toBe(true)
expect(isPickerCommand('/switch', 'session')).toBe(true)
// Unlike /model, /resume shows in the popover; its aliases stay hidden.
expect(isDesktopSlashSuggestion('/resume')).toBe(true)
expect(isDesktopSlashSuggestion('/sessions')).toBe(false)
expect(isDesktopSlashCommand('/switch')).toBe(true)
// The session picker is distinct from the model picker.
expect(isModelPickerCommand('/resume')).toBe(false)
})
it('resolves commands and aliases to their declared surface', () => {
expect(resolveDesktopCommand('/new')?.surface).toEqual({ kind: 'action', action: 'new' })
expect(resolveDesktopCommand('/reset')?.surface).toEqual({ kind: 'action', action: 'new' })
expect(resolveDesktopCommand('/resume')?.surface).toEqual({ kind: 'picker', picker: 'session' })
expect(resolveDesktopCommand('/usage')?.surface).toEqual({ kind: 'exec' })
expect(resolveDesktopCommand('/clear')?.surface).toEqual({ kind: 'unavailable', reason: 'terminal' })
// Skill / quick commands aren't in the registry.
expect(resolveDesktopCommand('/gif-search')).toBeNull()
})
})
+143 -237
View File
@@ -22,161 +22,110 @@ export interface DesktopThemeCommandOption {
name: string
}
/**
* Local client action a command resolves to. Each id maps to exactly one
* handler in the dispatcher (`use-prompt-actions`), so adding a command never
* means adding a branch to a switch ladder — you add a row here + a handler
* keyed by the id.
*/
export type DesktopActionId =
| 'branch'
| 'handoff'
| 'help'
| 'new'
| 'profile'
| 'skin'
| 'title'
| 'yolo'
const DESKTOP_COMMAND_META = [
['/agents', 'Show active desktop sessions and running tasks'],
['/background', 'Run a prompt in the background'],
['/branch', 'Branch the latest message into a new chat'],
['/compress', 'Compress this conversation context'],
['/debug', 'Create a debug report'],
['/goal', 'Manage the standing goal for this session'],
['/help', 'Show desktop slash commands'],
['/new', 'Start a new desktop chat'],
['/profile', 'Switch the active Hermes profile'],
['/queue', 'Queue a prompt for the next turn'],
['/resume', 'Resume a saved session'],
['/retry', 'Retry the last user message'],
['/rollback', 'List or restore filesystem checkpoints'],
['/skin', 'Switch desktop theme or cycle to the next one'],
['/status', 'Show current session status'],
['/steer', 'Steer the current run after the next tool call'],
['/stop', 'Stop running background processes'],
['/title', 'Rename the current session'],
['/undo', 'Remove the last user/assistant exchange'],
['/usage', 'Show token usage for this session'],
['/version', 'Show Hermes Agent version'],
['/yolo', 'Toggle YOLO — auto-approve dangerous commands']
] as const
/** A command fulfilled by opening a desktop overlay picker. */
export type DesktopPickerId = 'model' | 'session'
const DESKTOP_COMMANDS: ReadonlySet<string> = new Set(DESKTOP_COMMAND_META.map(([command]) => command))
/** Why a known Hermes command has no desktop UI surface. */
export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | 'terminal'
const DESKTOP_ALIASES = new Map([
['/bg', '/background'],
['/btw', '/background'],
['/fork', '/branch'],
['/q', '/queue'],
['/reload_mcp', '/reload-mcp'],
['/reload_skills', '/reload-skills'],
['/reset', '/new'],
['/tasks', '/agents']
])
/**
* How the desktop fulfils a command. This is the single discriminator the
* dispatcher, popover, pills, and pickers all read — no parallel block-lists.
*
* - `action` → handled by a local client handler (new chat, branch, …)
* - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is
* resolved by that picker instead of falling through
* - `exec` → runs on the backend via slash.exec / command.dispatch and
* renders its text output inline
* - `unavailable`→ a known command with genuinely no desktop UI (terminal-only,
* messaging-only, …); shows a reason instead of executing
*/
export type DesktopCommandSurface =
| { kind: 'action'; action: DesktopActionId }
| { kind: 'picker'; picker: DesktopPickerId }
| { kind: 'exec' }
| { kind: 'unavailable'; reason: DesktopUnavailableReason }
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
export interface DesktopCommandSpec {
/** Canonical command, leading slash included (e.g. `/resume`). */
name: string
/** Popover/help label; omitted for unavailable commands (never surfaced). */
description?: string
aliases?: string[]
surface: DesktopCommandSurface
/**
* Hide from the slash popover / completions while still letting it execute.
* Used for picker commands reachable from chrome (the model picker lives on
* the status bar), so the popover doesn't dead-end on inline completion.
*/
hidden?: boolean
/**
* The command has an inline options "screen" (theme / personality / session /
* platform / toolset list). Picking the bare command in the popover expands to
* that argument step instead of committing — mirroring typing `/<cmd> ` by hand.
*/
args?: boolean
}
const PICKER_OWNED_COMMANDS = new Set(['/model'])
const exec = (): DesktopCommandSurface => ({ kind: 'exec' })
const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action', action: id })
const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
const TERMINAL_ONLY_COMMANDS = new Set([
'/browser',
'/busy',
'/clear',
'/commands',
'/compact',
'/config',
'/copy',
'/cron',
'/details',
'/exit',
'/footer',
'/gateway',
'/gquota',
'/history',
'/image',
'/indicator',
'/logs',
'/mouse',
'/paste',
'/platforms',
'/plugins',
'/quit',
'/redraw',
'/reload',
'/restart',
'/save',
'/sb',
'/set-home',
'/sethome',
'/snap',
'/snapshot',
'/statusbar',
'/toolsets',
'/tools',
'/update',
'/verbose'
])
/**
* THE source of truth for desktop slash commands. Everything below — execution
* gating, popover suggestions, catalog filtering, pill grouping, and the
* dispatcher's behavior — derives from this one table.
*/
const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// Local client actions
{ name: '/new', description: 'Start a new desktop chat', aliases: ['/reset'], surface: action('new') },
{ name: '/branch', description: 'Branch the latest message into a new chat', aliases: ['/fork'], surface: action('branch') },
{ name: '/yolo', description: 'Toggle YOLO — auto-approve dangerous commands', surface: action('yolo') },
{ name: '/handoff', description: 'Hand off this session to a messaging platform', surface: action('handoff'), args: true },
{ name: '/profile', description: 'Switch the active Hermes profile', surface: action('profile') },
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
{ name: '/title', description: 'Rename the current session', surface: action('title') },
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
const MESSAGING_ONLY_COMMANDS = new Set(['/approve', '/deny'])
// Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
{
name: '/resume',
description: 'Resume a saved session',
aliases: ['/sessions', '/switch'],
surface: picker('session'),
args: true
},
const SETTINGS_OWNED_COMMANDS = new Set(['/skills'])
// Backend-executed commands that render useful inline output
{ name: '/agents', description: 'Show active desktop sessions and running tasks', aliases: ['/tasks'], surface: exec() },
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
{ name: '/compress', description: 'Compress this conversation context', surface: exec() },
{ name: '/debug', description: 'Create a debug report', surface: exec() },
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
{ name: '/save', description: 'Save the current transcript to JSON', surface: exec() },
{ name: '/status', description: 'Show current session status', surface: exec() },
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() },
{ name: '/stop', description: 'Stop running background processes', surface: exec() },
{ name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
{ name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() },
{ name: '/usage', description: 'Show token usage for this session', surface: exec() },
{ name: '/version', description: 'Show Hermes Agent version', surface: exec() },
const ADVANCED_COMMANDS = new Set([
'/curator',
'/fast',
'/insights',
'/kanban',
'/personality',
'/reasoning',
'/reload-mcp',
'/reload-skills',
'/voice'
])
// No desktop surface, but carry an alias (underscore spelling variants).
{ name: '/reload-mcp', aliases: ['/reload_mcp'], surface: unavailable('advanced') },
{ name: '/reload-skills', aliases: ['/reload_skills'], surface: unavailable('advanced') }
]
// Known commands with no desktop surface (and no alias) — a flat name list
// per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
],
messaging: ['/approve', '/deny'],
settings: ['/skills'],
advanced: ['/curator', '/fast', '/insights', '/kanban', '/reasoning', '/voice']
}
const ALL_SPECS: readonly DesktopCommandSpec[] = [
...DESKTOP_COMMAND_SPECS,
...(Object.entries(NO_DESKTOP_SURFACE) as [DesktopUnavailableReason, readonly string[]][]).flatMap(
([reason, names]) => names.map(name => ({ name, surface: unavailable(reason) }))
)
]
const SPEC_BY_NAME = new Map<string, DesktopCommandSpec>(ALL_SPECS.map(spec => [spec.name, spec]))
const ALIAS_TO_CANONICAL = new Map<string, string>(
ALL_SPECS.flatMap(spec => (spec.aliases ?? []).map(alias => [alias, spec.name] as const))
)
const UNAVAILABLE_MESSAGE: Record<DesktopUnavailableReason, (command: string) => string> = {
advanced: command =>
`${command} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`,
messaging: command => `${command} is only used from messaging platforms.`,
settings: command => `${command} is managed from the desktop sidebar.`,
terminal: command => `${command} is only available in the terminal interface.`
}
const PICKER_UNAVAILABLE_MESSAGE: Record<DesktopPickerId, (command: string) => string> = {
model: command => `${command} uses the desktop model picker instead of a slash command.`,
session: command => `${command} uses the desktop session picker instead of a slash command.`
}
const BLOCKED_COMMANDS = new Set([
...PICKER_OWNED_COMMANDS,
...TERMINAL_ONLY_COMMANDS,
...MESSAGING_ONLY_COMMANDS,
...SETTINGS_OWNED_COMMANDS,
...ADVANCED_COMMANDS
])
function normalizeCommand(command: string): string {
const trimmed = command.trim()
@@ -188,25 +137,27 @@ function normalizeCommand(command: string): string {
export function canonicalDesktopSlashCommand(command: string): string {
const normalized = normalizeCommand(command)
return ALIAS_TO_CANONICAL.get(normalized) || normalized
return DESKTOP_ALIASES.get(normalized) || normalized
}
/** Resolve a command (or alias) to its desktop spec, or null for unknown/extension commands. */
export function resolveDesktopCommand(command: string): DesktopCommandSpec | null {
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command)) ?? null
}
function isKnownHermesSlashCommand(command: string): boolean {
export function isDesktopSlashCommand(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
return SPEC_BY_NAME.has(normalized) || ALIAS_TO_CANONICAL.has(normalized)
if (BLOCKED_COMMANDS.has(normalized) || BLOCKED_COMMANDS.has(canonical)) {
return false
}
return DESKTOP_COMMANDS.has(canonical) || !isKnownHermesSlashCommand(normalized)
}
/**
* An "extension" command is anything the backend surfaces that is NOT one of
* Hermes' built-in slash commands — i.e. skill commands (`/gif-search`,
* `/codex`, …) and user-defined quick commands. These are user-activated, so
* they appear in the desktop slash palette and execute when typed.
* they should appear in the desktop slash palette even though they aren't in
* the curated `DESKTOP_COMMANDS` allow-list. This mirrors the predicate in
* `isDesktopSlashCommand` that already lets them EXECUTE when typed.
*/
export function isDesktopSlashExtensionCommand(command: string): boolean {
const normalized = normalizeCommand(command)
@@ -218,85 +169,63 @@ export function isDesktopSlashExtensionCommand(command: string): boolean {
return !isKnownHermesSlashCommand(normalized)
}
/** Gates execution: true unless the command is a known no-desktop-surface command. */
export function isDesktopSlashCommand(command: string): boolean {
const spec = resolveDesktopCommand(command)
if (spec) {
return spec.surface.kind !== 'unavailable'
}
return isDesktopSlashExtensionCommand(command)
}
/** Gates discovery in the popover/completions. */
export function isDesktopSlashSuggestion(command: string): boolean {
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
// Aliases stay hidden so the popover isn't cluttered with duplicates.
if (ALIAS_TO_CANONICAL.has(normalized)) {
return false
// Surface skill / quick commands (extensions the backend provides) alongside
// the curated built-ins. Built-in aliases stay hidden so the popover isn't
// cluttered with duplicates.
if (isDesktopSlashExtensionCommand(normalized)) {
return true
}
const spec = SPEC_BY_NAME.get(normalized)
if (spec) {
return spec.surface.kind !== 'unavailable' && !spec.hidden
}
// Skill / quick commands the backend provides.
return isDesktopSlashExtensionCommand(normalized)
return DESKTOP_COMMANDS.has(canonical) && !DESKTOP_ALIASES.has(normalized)
}
/**
* True for commands the desktop fulfils by opening an overlay picker
* (`/model`, `/resume`/`/sessions`/`/switch`). Optionally pin to one picker.
* True for commands the desktop fulfils by opening the model picker overlay
* (e.g. `/model`) rather than executing a slash command. The caller opens the
* picker UI instead of printing the "uses the desktop model picker" notice.
*/
export function isPickerCommand(command: string, picker?: DesktopPickerId): boolean {
const surface = resolveDesktopCommand(command)?.surface
if (surface?.kind !== 'picker') {
return false
}
return picker ? surface.picker === picker : true
}
/** Back-compat shim for the model picker check. */
export function isModelPickerCommand(command: string): boolean {
return isPickerCommand(command, 'model')
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
return PICKER_OWNED_COMMANDS.has(canonical)
}
export function desktopSlashUnavailableMessage(command: string): string | null {
const canonical = canonicalDesktopSlashCommand(command)
const surface = SPEC_BY_NAME.get(canonical)?.surface
const normalized = normalizeCommand(command)
const canonical = canonicalDesktopSlashCommand(normalized)
if (!surface) {
return null
if (PICKER_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} uses the desktop model picker instead of a slash command.`
}
if (surface.kind === 'unavailable') {
return UNAVAILABLE_MESSAGE[surface.reason](canonical)
if (SETTINGS_OWNED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is managed from the desktop sidebar.`
}
if (surface.kind === 'picker') {
return PICKER_UNAVAILABLE_MESSAGE[surface.picker](canonical)
if (MESSAGING_ONLY_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is only used from messaging platforms.`
}
if (ADVANCED_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is not shown in the desktop slash palette. Use the relevant desktop control or terminal interface instead.`
}
if (TERMINAL_ONLY_COMMANDS.has(normalized) || TERMINAL_ONLY_COMMANDS.has(canonical)) {
return `/${canonical.slice(1)} is only available in the terminal interface.`
}
return null
}
export function desktopSlashDescription(command: string, fallback = ''): string {
return SPEC_BY_NAME.get(canonicalDesktopSlashCommand(command))?.description || fallback
}
const canonical = canonicalDesktopSlashCommand(command)
/**
* True when picking the bare command should expand to its inline argument
* options (theme / personality / session / platform / toolset) rather than
* committing immediately. Lets the popover act as a two-step picker.
*/
export function desktopSlashCommandTakesArgs(command: string): boolean {
return resolveDesktopCommand(command)?.args ?? false
return DESKTOP_COMMAND_DESCRIPTIONS.get(canonical) || fallback
}
export function desktopSkinSlashCompletions(
@@ -345,36 +274,13 @@ export function filterDesktopCommandsCatalog(catalog: CommandsCatalogLike): Comm
?.filter(([command]) => isDesktopSlashSuggestion(command))
.map(([command, description]) => [command, desktopSlashDescription(command, description)] as [string, string])
// Recount skill commands from the filtered output so /help's footer reflects
// what the user actually sees. Backend's skill_count includes commands the
// desktop hides (terminal-only, picker-owned, advanced), producing a footer
// like "60 skill commands available" while only ~29 appear in the list.
const filteredCommands = new Set<string>()
for (const section of categories ?? []) {
for (const [command] of section.pairs) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
}
for (const [command] of pairs ?? []) {
filteredCommands.add(canonicalDesktopSlashCommand(command))
}
let skillCount = 0
for (const command of filteredCommands) {
if (isDesktopSlashExtensionCommand(command)) {
skillCount += 1
}
}
const hasSkillCount = catalog.skill_count !== undefined || skillCount > 0
return {
...catalog,
...(categories ? { categories } : {}),
...(pairs ? { pairs } : {}),
...(hasSkillCount ? { skill_count: skillCount } : {})
...(pairs ? { pairs } : {})
}
}
function isKnownHermesSlashCommand(command: string): boolean {
return DESKTOP_COMMANDS.has(command) || DESKTOP_ALIASES.has(command) || BLOCKED_COMMANDS.has(command)
}
@@ -165,31 +165,4 @@ describe('external link helpers', () => {
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
)
})
it('explicitOnly skips bare filename/domain tokens and only links explicit URLs', () => {
installDesktopBridge()
render(
<LinkifiedText
explicitOnly
pretty={false}
text={'Report https://paste.rs/abc\nagent.log https://paste.rs/def\nerrors.log'}
/>
)
const links = screen.getAllByRole('link')
expect(links.map(a => a.getAttribute('href'))).toEqual(['https://paste.rs/abc', 'https://paste.rs/def'])
// Bare filename-shaped tokens stay as plain text, not links.
expect(screen.queryByText(content => content.includes('agent.log'))).toBeTruthy()
expect(links.some(a => (a.textContent ?? '').includes('.log'))).toBe(false)
})
it('without explicitOnly, bare filename tokens are still linkified (default behavior)', () => {
installDesktopBridge()
render(<LinkifiedText pretty={false} text="open agent.log please" />)
const link = screen.getByRole('link', { name: 'agent.log' })
expect(link.getAttribute('href')).toBe('https://agent.log')
})
})
+2 -9
View File
@@ -12,12 +12,6 @@ const titleSubs = new Map<string, Set<(value: string) => void>>()
const URL_RE =
/(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi
// Explicit-scheme / www. URLs only — no bare-domain matching. Used where the
// surrounding text is full of filename-shaped tokens (e.g. `agent.log`,
// `errors.log` in a /debug report) that the bare-domain branch of URL_RE would
// otherwise mistake for domains and linkify.
const EXPLICIT_URL_RE = /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]/gi
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i
@@ -267,14 +261,13 @@ interface LinkifiedTextProps {
className?: string
text: string
pretty?: boolean
explicitOnly?: boolean
}
export function LinkifiedText({ className, explicitOnly = false, pretty = true, text }: LinkifiedTextProps) {
export function LinkifiedText({ className, pretty = true, text }: LinkifiedTextProps) {
const nodes: ReactNode[] = []
let cursor = 0
for (const match of text.matchAll(explicitOnly ? EXPLICIT_URL_RE : URL_RE)) {
for (const match of text.matchAll(URL_RE)) {
const raw = match[0]
const url = normalizeExternalUrl(raw)
const index = match.index ?? 0
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { gatewayEventRequiresSessionId } from './gateway-events'
describe('gateway event routing', () => {
it('drops only unscoped subagent events (genuinely background work)', () => {
expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true)
expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true)
})
it('attributes unscoped foreground turn events to the active chat', () => {
// These must NOT be dropped when unscoped — they are the focused turn's own
// output, and dropping them loses the live response until a refetch (#42178).
expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)
expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
})
it('allows global events to remain unscoped', () => {
expect(gatewayEventRequiresSessionId('gateway.ready')).toBe(false)
expect(gatewayEventRequiresSessionId('preview.restart.progress')).toBe(false)
expect(gatewayEventRequiresSessionId('session.info')).toBe(false)
expect(gatewayEventRequiresSessionId(undefined)).toBe(false)
})
})
-16
View File
@@ -11,22 +11,6 @@ function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
/**
* Whether an unscoped event (no `session_id`) must be dropped rather than
* attributed to the focused chat.
*
* Only `subagent.*` qualifies: it describes background/async work that must
* never attach to whichever chat happens to be focused. Every other scoped
* event — message/reasoning/thinking/tool/status/prompt — is, when unscoped,
* the active turn's own output. The gateway always stamps a *background*
* session's events with that session's id, so a missing id can only mean "the
* focused turn". #42178 dropped those too, which silently swallowed the live
* answer; it then reappeared only after a transcript refetch (manual refresh).
*/
export function gatewayEventRequiresSessionId(eventType: string | undefined): boolean {
return eventType?.startsWith('subagent.') ?? false
}
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
if (event.type !== 'tool.complete') {
return false
+10 -21
View File
@@ -13,7 +13,13 @@ export const KEYBIND_PANEL_ACTION = 'keybinds.openPanel'
// `composer` is read-only; the rest are rebindable. `view` is the catch-all for
// layout, appearance, and the panel-opener.
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = ['composer', 'profiles', 'session', 'navigation', 'view']
export const KEYBIND_CATEGORIES: readonly KeybindCategory[] = [
'composer',
'profiles',
'session',
'navigation',
'view'
]
export interface KeybindActionMeta {
id: string
@@ -37,20 +43,6 @@ const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE
defaults: [comboForSlot(i + 1)]
}))
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `mod` keeps one binding cross-platform; on macOS this shadows the system
// window-cycler, which is fine for a single-window app.
const TERMINAL_TOGGLE_DEFAULTS = ['mod+`', 'mod+shift+`']
// Positional jumps — ^1…^9, mirroring profiles' ⌘1…⌘9.
export const SESSION_SLOT_COUNT = 9
const SESSION_SLOT_ACTIONS: KeybindActionMeta[] = Array.from({ length: SESSION_SLOT_COUNT }, (_, i) => ({
id: `session.slot.${i + 1}`,
category: 'session' as const,
defaults: [`ctrl+${i + 1}`]
}))
export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Composer ─────────────────────────────────────────────────────────────
{ id: 'composer.focus', category: 'composer', defaults: [] },
@@ -66,11 +58,8 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
// ── Session ──────────────────────────────────────────────────────────────
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
{ id: 'session.prev', category: 'session', defaults: ['ctrl+shift+tab'] },
...SESSION_SLOT_ACTIONS,
{ id: 'session.next', category: 'session', defaults: [] },
{ id: 'session.prev', category: 'session', defaults: [] },
{ id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] },
{ id: 'session.togglePin', category: 'session', defaults: [] },
@@ -89,7 +78,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
{ id: 'view.toggleSidebar', category: 'view', defaults: ['mod+b'] },
{ id: 'view.toggleRightSidebar', category: 'view', defaults: ['mod+j'] },
{ id: 'view.showFiles', category: 'view', defaults: [] },
{ id: 'view.showTerminal', category: 'view', defaults: TERMINAL_TOGGLE_DEFAULTS },
{ id: 'view.showTerminal', category: 'view', defaults: [] },
// ⌘\ — the backslash reads like a mirror line flipping the layout.
{ id: 'view.flipPanes', category: 'view', defaults: ['mod+\\'] },
{ id: 'appearance.toggleMode', category: 'view', defaults: ['shift+x'] },
@@ -1,86 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
// `IS_MAC` is resolved once at module load from `navigator`, so each platform
// case overrides the platform and re-imports the module fresh.
async function loadCombo(platform: string) {
Object.defineProperty(window.navigator, 'platform', { value: platform, configurable: true })
vi.resetModules()
return import('./combo')
}
function keydown(init: KeyboardEventInit): KeyboardEvent {
return new KeyboardEvent('keydown', init)
}
afterEach(() => {
vi.resetModules()
})
describe('comboFromEvent — ctrl as a distinct modifier on macOS', () => {
it('reports Control+Tab as "ctrl+tab" on macOS (not Cmd)', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('ctrl+tab')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('ctrl+shift+tab')
})
it('keeps Cmd as "mod" and distinct from Control on macOS', async () => {
const { comboFromEvent } = await loadCombo('MacIntel')
expect(comboFromEvent(keydown({ code: 'KeyK', metaKey: true }))).toBe('mod+k')
expect(comboFromEvent(keydown({ code: 'KeyK', ctrlKey: true }))).toBe('ctrl+k')
})
it('treats Control as the "mod" accelerator off macOS', async () => {
const { comboFromEvent } = await loadCombo('Win32')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true }))).toBe('mod+tab')
expect(comboFromEvent(keydown({ code: 'Tab', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+tab')
})
})
describe('canonicalizeCombo', () => {
it('leaves "ctrl+…" untouched on macOS', async () => {
const { canonicalizeCombo } = await loadCombo('MacIntel')
expect(canonicalizeCombo('ctrl+tab')).toBe('ctrl+tab')
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('ctrl+shift+tab')
})
it('folds "ctrl+…" to "mod+…" off macOS so a real Control press resolves', async () => {
const { canonicalizeCombo } = await loadCombo('Win32')
expect(canonicalizeCombo('ctrl+tab')).toBe('mod+tab')
expect(canonicalizeCombo('ctrl+shift+tab')).toBe('mod+shift+tab')
// Non-ctrl combos are unchanged.
expect(canonicalizeCombo('mod+k')).toBe('mod+k')
})
})
describe('formatCombo — honest Control labels', () => {
it('renders the Control glyph on macOS', async () => {
const { formatCombo } = await loadCombo('MacIntel')
expect(formatCombo('ctrl+tab')).toBe('⌃⇥')
expect(formatCombo('ctrl+shift+tab')).toBe('⌃⇧⇥')
})
it('renders "Ctrl+…" off macOS (base key keeps its glyph)', async () => {
const { formatCombo } = await loadCombo('Win32')
expect(formatCombo('ctrl+tab')).toBe('Ctrl+⇥')
expect(formatCombo('ctrl+shift+tab')).toBe('Ctrl+Shift+⇥')
})
})
describe('comboAllowedInInput', () => {
it('lets ctrl combos fire while typing (e.g. ⌃Tab from the composer)', async () => {
const { comboAllowedInInput } = await loadCombo('MacIntel')
expect(comboAllowedInInput('ctrl+tab')).toBe(true)
expect(comboAllowedInInput('ctrl+shift+tab')).toBe(true)
expect(comboAllowedInInput('mod+k')).toBe(true)
expect(comboAllowedInInput('shift+x')).toBe(false)
})
})
+30 -56
View File
@@ -4,13 +4,9 @@
// or "r". `mod` is Cmd on macOS / Ctrl elsewhere, so a single binding works on
// both. We derive the base key from `event.code` (not `event.key`) so Shift never
// mutates it ("shift+/" stays "shift+/" instead of becoming "shift+?").
//
// `ctrl` is physical Control, distinct from `mod`. It only matters on macOS,
// where `mod` is Cmd and Cmd+Tab is OS-reserved — so `ctrl+tab` is literally
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
export const IS_MAC =
typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
@@ -85,16 +81,10 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
const parts: string[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`.
if (event.metaKey || (event.ctrlKey && !IS_MAC)) {
if (event.metaKey || event.ctrlKey) {
parts.push('mod')
}
if (event.ctrlKey && IS_MAC) {
parts.push('ctrl')
}
if (event.altKey) {
parts.push('alt')
}
@@ -108,13 +98,6 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
return parts.join('+')
}
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
// the same key a live keypress produces. Off macOS, `ctrl+…` and `mod+…` are
// the one Control chord, so a shipped `ctrl+tab` matches a real Control+Tab.
export function canonicalizeCombo(combo: string): string {
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
}
const TOKEN_LABELS: Record<string, string> = {
enter: '↵',
escape: 'Esc',
@@ -139,38 +122,29 @@ function labelForBase(base: string): string {
return base.length === 1 ? base.toUpperCase() : base
}
function labelForMod(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
return mod
}
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
// one cap per token for <KbdGroup>.
export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
return [...parts.map(labelForMod), labelForBase(base)]
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
const parts = combo.split('+')
const base = parts.pop() ?? ''
const mods = parts
const modLabels = mods.map(mod => {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
return mod
})
const tokens = [...modLabels, labelForBase(base)]
return IS_MAC ? tokens.join('') : tokens.join('+')
}
@@ -182,14 +156,14 @@ export function isEditableTarget(target: EventTarget | null): boolean {
return Boolean(
el?.isContentEditable ||
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
}
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
// Combos with a primary modifier (Cmd/Ctrl) are safe to fire even while typing
// (e.g. ⌘K from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: string): boolean {
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
return combo.startsWith('mod+') || combo === 'mod'
}
+2 -23
View File
@@ -1,4 +1,3 @@
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
import type { PreviewTarget } from '@/store/preview'
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
@@ -108,26 +107,6 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
}
}
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
return target
}
try {
const result = await readDesktopFileText(target.path || target.source)
return {
...target,
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language,
large: (result.byteSize ?? 0) > 512 * 1024,
mimeType: result.mimeType
}
} catch {
return target
}
}
export async function normalizeOrLocalPreviewTarget(
rawTarget: string,
cwd?: string | null
@@ -136,12 +115,12 @@ export async function normalizeOrLocalPreviewTarget(
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
if (normalized) {
return enrichPreviewTarget(normalized)
return normalized
}
} catch {
// Running Electron may still have the old HTML-only preview IPC. Fall
// through to renderer-side local classification so text/images still open.
}
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
return localPreviewTarget(rawTarget, cwd)
}
@@ -5,8 +5,6 @@ import { displayModelName, formatModelStatusLabel, reasoningEffortLabel } from '
describe('model-status-label', () => {
it('formats display names consistently', () => {
expect(displayModelName('anthropic/claude-opus-4.8-fast')).toBe('Opus 4.8')
expect(displayModelName('openai/gpt-5.5-fast')).toBe('GPT-5.5')
expect(displayModelName('deepseek/deepseek-v4-pro-thinking')).toBe('Deepseek V4 Pro')
expect(displayModelName('openai/gpt-5.5')).toBe('GPT-5.5')
})
+1 -3
View File
@@ -5,7 +5,6 @@ import { notify, notifyError } from '@/store/notifications'
interface ExportSessionParams {
sessionId: string
profile?: string | null
title?: string | null
session?: SessionInfo
}
@@ -32,8 +31,7 @@ export async function exportSession(sessionId: string, params: Omit<ExportSessio
}
try {
const profile = params.profile ?? params.session?.profile
const { messages } = await getSessionMessages(sessionId, profile)
const { messages } = await getSessionMessages(sessionId)
const payload = {
exported_at: new Date().toISOString(),
-64
View File
@@ -34,76 +34,12 @@ const SOURCE_ALIASES: Record<string, string[]> = {
whatsapp: ['wa']
}
// Sources that run on the local machine rather than an external messaging
// platform. A handoff *from* one of these isn't a platform origin worth a badge.
// Exported so the recents fetch can keep these in the main list while the
// messaging fetch excludes them.
export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']
const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)
// External messaging platforms that each get their own self-managed sidebar
// section (fetched separately from local recents). Mirrors the gateway platform
// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx.
export const MESSAGING_SESSION_SOURCE_IDS = [
'telegram',
'discord',
'slack',
'mattermost',
'matrix',
'signal',
'whatsapp',
'bluebubbles',
'homeassistant',
'email',
'sms',
'webhook',
'api_server',
'weixin',
'wecom',
'qqbot',
'yuanbao',
'dingtalk',
'feishu'
]
const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS)
/** True when a source id is an external messaging platform (gets its own
* sidebar section) rather than a local/CLI/desktop session. */
export function isMessagingSource(source: null | string | undefined): boolean {
const id = normalizeSessionSource(source)
return id != null && MESSAGING_SOURCE_IDS.has(id)
}
export function normalizeSessionSource(source: null | string | undefined): string | null {
const id = source?.trim().toLowerCase()
return id || null
}
/**
* Resolve the origin messaging platform for a handed-off session. Returns the
* normalized platform id (e.g. 'telegram') when the session completed a handoff
* from a real messaging platform, otherwise null. After a handoff the live
* source is local, so this is what drives the row's origin-platform badge.
*/
export function handoffOriginSource(
handoffState: null | string | undefined,
handoffPlatform: null | string | undefined
): string | null {
if (handoffState !== 'completed') {
return null
}
const id = normalizeSessionSource(handoffPlatform)
if (!id || LOCAL_SOURCE_IDS.has(id)) {
return null
}
return id
}
export function sessionSourceLabel(source: null | string | undefined): string | null {
const id = normalizeSessionSource(source)
-38
View File
@@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveUpdateCopy } from './update-copy'
const copy = {
availableTitle: 'New update available',
availableBody: 'A new version of Hermes is ready to install.',
availableTitleBackend: 'Backend update available',
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
availableBodyNoChangelog: 'A newer version is ready. Release notes arent available for this install type.'
}
describe('resolveUpdateCopy', () => {
it('client target with commits: client title + client body', () => {
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, copy })
expect(r.title).toBe('New update available')
expect(r.body).toBe('A new version of Hermes is ready to install.')
})
it('backend target with commits: names the backend in title and body', () => {
const r = resolveUpdateCopy({ target: 'backend', shownItems: 5, copy })
expect(r.title).toBe('Backend update available')
expect(r.body).toContain('backend')
})
it('no changelog (pip/non-git backend): degrades honestly, still names backend target in title', () => {
const r = resolveUpdateCopy({ target: 'backend', shownItems: 0, copy })
expect(r.title).toBe('Backend update available')
// Body must NOT pretend there are notes — it states they're unavailable.
expect(r.body).toBe(copy.availableBodyNoChangelog)
})
it('no changelog on client: same honest degrade', () => {
const r = resolveUpdateCopy({ target: 'client', shownItems: 0, copy })
expect(r.title).toBe('New update available')
expect(r.body).toBe(copy.availableBodyNoChangelog)
})
})
-44
View File
@@ -1,44 +0,0 @@
/**
* Pure copy-selection for the updates overlay's "available" state.
*
* Names the update target (client vs the connected backend in remote mode) and
* degrades honestly when there's no commit changelog to show (e.g. a pip /
* non-git backend where `git log` yields nothing) instead of generic filler.
*
* Extracted from updates-overlay.tsx so the wording logic is unit-testable.
*/
export type UpdateTarget = 'client' | 'backend'
export interface UpdateCopyStrings {
availableTitle: string
availableBody: string
availableTitleBackend: string
availableBodyBackend: string
availableBodyNoChangelog: string
}
export interface ResolveUpdateCopyInput {
target: UpdateTarget
/** Number of commit rows actually shown in the changelog. 0 → no notes. */
shownItems: number
copy: UpdateCopyStrings
}
export interface UpdateCopyResult {
title: string
body: string
}
export function resolveUpdateCopy({ target, shownItems, copy }: ResolveUpdateCopyInput): UpdateCopyResult {
const title = target === 'backend' ? copy.availableTitleBackend : copy.availableTitle
const body =
shownItems === 0
? copy.availableBodyNoChangelog
: target === 'backend'
? copy.availableBodyBackend
: copy.availableBody
return { title, body }
}