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:
@@ -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, '')
|
||||
}
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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>) : {}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 aren’t 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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user