desktop: registry-driven slash commands + first-class /resume & /handoff (#42351)

* desktop: surface /tools, /save, /personality and fix /help skill count

Move /tools and /save out of TERMINAL_ONLY_COMMANDS and /personality out of
ADVANCED_COMMANDS so they appear in the desktop slash palette and execute via
the existing slash.exec → command.dispatch fallback. The backend gateway already
accepts these through slash.exec (none are in _PENDING_INPUT_COMMANDS or the
skill list), so no backend change is required.

Recompute skill_count in filterDesktopCommandsCatalog from the filtered pairs.
Previously the /help footer echoed the unfiltered backend total — e.g. "60
skill commands available" while only ~29 actually appeared in the rendered
list, because the desktop hides terminal-only, picker-owned, and advanced
commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* desktop: keep slash popover live while typing args

The trigger regex `(?:^|[\s])([@/])([^\s@/]*)$` stopped matching the moment
the user typed a space after a slash command, so the popover never showed arg
completions for `/personality`, `/tools`, etc. — even though the backend's
`complete.slash` already returns them with a `replace_from` indicator.

Split the trigger detection so `/` allows args (`/cmd arg1 arg2`) while `@`
keeps the strict no-space behavior. Restrict the slash command name to
`[a-zA-Z][\w-]*` so file paths like `src/foo/bar` don't accidentally trigger
the popover.

Rewrite arg-completion items in useSlashCompletions to insert the full
`/personality alice` token instead of stranding `/alice`: when `replace_from`
is past the command base, prepend the existing prefix to each item's text so
the chip serializer produces a coherent replacement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cli: complete toolset names after /tools enable|disable

SlashCommandCompleter previously only auto-derived the first subcommand level
from args_hint, so `/tools enable <tab>` yielded nothing — the user had to
remember every toolset key (web, file, spotify, …) and every MCP server prefix.

Add `_tools_completions` that handles both stages: subcommand (list|disable|enable)
and tool name. Filter by current enable state so `/tools enable <tab>` only
offers disabled toolsets and `/tools disable <tab>` only offers enabled ones —
no point suggesting a no-op. MCP server prefixes (server:) come from the
saved mcp_servers config; per-tool completion under a server would require
runtime MCP introspection and is left as follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* desktop: registry-driven slash commands with first-class pickers

Collapse the if/else slash dispatch into one DESKTOP_COMMAND_SPECS table
that drives popover suggestions, per-type composer pills, and execution.

- /resume, /sessions, /switch: inline session completions (like /skin) plus
  a "Browse all sessions…" entry that opens a dedicated session picker overlay
- /handoff: inline platform completion + handoff.request/handoff.state
  gateway bridge so desktop reaches CLI parity
- colored per-type pills (command/skill/theme) in the composer
- strip ANSI and fix width/alignment of slash output in the chat panel

* desktop: fold repeated slash session/output boilerplate into one helper

runExec, /title, /help and the unavailable case each re-derived the same
ensure-session → bail-with-notify → build-renderSlashOutput dance.
withSlashOutput() returns {sessionId, render} or null, so each handler is
a two-line resolve instead of an eight-line preamble.

* desktop: keep backend meta on slash arg completions

Arg suggestions (/personality <name>, /tools enable <toolset>, /handoff
<platform>) were having their meta overwritten with the parent command's
registry description: desktopSlashDescription("/personality none") canonicalizes
back to /personality and returns its blurb. Skip the lookup for arg rows so the
backend's own display_meta ("clear personality overlay", etc.) survives.

* cli: list real personalities in /personality completion

_personality_completions resolved load_config().agent.personalities — but that
schema has no agent.personalities key, so completion always returned just
`none` even though the runtime (load_cli_config().agent.personalities) ships a
dozen built-ins (helpful, kawaii, pirate, …). Read from the same source the
command actually applies, so `/personality ` surfaces the real options.

* desktop: expand bare arg-commands to their options on pick

Picking a command like /personality from the slash popover committed it
immediately instead of advancing to its argument list. Mark arg-taking
commands (/skin, /resume, /handoff, /personality, /tools) in the registry
and, when one is picked bare, insert "/cmd " as plain text and re-open the
popover on its inline options — mirroring typing "/cmd " by hand. Arg picks
(serialized text already contains a space) still commit a single pill.

Also realign trigger-popover loading test with the redesigned popover (the
/help empty-state hint shows when resolved, not while the spinner is up);
the merge from main reintroduced the pre-redesign expectation.

* tui_gateway: fold session-db close into a context manager

Both handoff RPCs repeated the same `db, close_db = _session_db_handle()`
+ `finally: if close_db: db.close()` dance. Turn the helper into a
`_session_db` contextmanager that owns the close, so callers just
`with _session_db(session) as db:`.

* desktop: unblock handoff retries and exact resume ids

Clear timed-out desktop handoffs through the gateway so retries are not stuck behind a pending row, and let typed /resume session ids bypass the loaded sidebar cache.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
brooklyn!
2026-06-11 01:49:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 615ad97928
commit 3ffbdfbcc0
31 changed files with 1973 additions and 537 deletions
@@ -1,6 +1,6 @@
import { cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
@@ -55,6 +55,7 @@ function Harness({
onSeedState,
refreshSessions,
requestGateway,
resumeStoredSession,
storedSessionId
}: {
busyRef?: MutableRefObject<boolean>
@@ -62,6 +63,7 @@ function Harness({
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
@@ -69,6 +71,12 @@ function Harness({
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const localBusyRef = busyRef ?? { current: false }
const stateRef = useRef({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never)
const actions = usePromptActions({
activeSessionId: RUNTIME_SESSION_ID,
@@ -79,17 +87,14 @@ function Harness({
handleSkinCommand: () => '',
refreshSessions,
requestGateway,
resumeStoredSession: resumeStoredSession ?? (() => undefined),
selectedStoredSessionIdRef,
startFreshSessionDraft: () => undefined,
sttEnabled: false,
updateSessionState: (_sessionId, updater) => {
// Seed with interrupted:true so we can prove a fresh submit clears it.
const next = updater({
messages: [],
busy: false,
awaitingResponse: false,
interrupted: true
} as never) as unknown as Record<string, unknown>
const next = updater(stateRef.current) as unknown as Record<string, unknown>
stateRef.current = next as never
onSeedState?.(next)
return next as never
@@ -190,6 +195,68 @@ describe('usePromptActions /title', () => {
})
})
describe('usePromptActions desktop slash pickers', () => {
beforeEach(() => {
setSessions(() => [sessionInfo({ id: '20260610_120000_abcdef', title: 'Loaded session' })])
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('resumes an exact session id even when it is not in the loaded sidebar cache', async () => {
const resumeStoredSession = vi.fn(async () => undefined)
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
resumeStoredSession={resumeStoredSession}
/>
)
await handle!.submitText('/resume 20260610_130000_123abc')
expect(resumeStoredSession).toHaveBeenCalledWith('20260610_130000_123abc')
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
})
it('marks a timed-out handoff as failed so the next attempt can retry', async () => {
vi.useFakeTimers()
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'handoff.state') {
return { state: 'pending' } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const result = handle!.submitText('/handoff telegram')
await vi.advanceTimersByTimeAsync(61_000)
await result
expect(calls.some(call => call.method === 'handoff.request')).toBe(true)
expect(calls).toContainEqual({
method: 'handoff.fail',
params: {
error: expect.stringContaining('Timed out'),
session_id: RUNTIME_SESSION_ID
}
})
})
})
describe('usePromptActions submit / queue drain semantics', () => {
afterEach(() => {
cleanup()
@@ -4,20 +4,24 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { getProfiles, transcribeAudio } from '@/hermes'
import { translateNow, type Translations, useI18n } from '@/i18n'
import { stripAnsi } from '@/lib/ansi'
import { branchGroupForUser, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import {
optimisticAttachmentRef,
parseCommandDispatch,
parseSlashCommand,
pathLabel,
sessionTitle,
SLASH_COMMAND_RE
} from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
type DesktopActionId,
type DesktopPickerId,
desktopSlashUnavailableMessage,
filterDesktopCommandsCatalog,
isDesktopSlashCommand,
isModelPickerCommand
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { triggerHaptic } from '@/lib/haptics'
import { setMutableRef } from '@/lib/mutable-ref'
@@ -38,11 +42,13 @@ import {
$busy,
$connection,
$messages,
$sessions,
$yoloActive,
setAwaitingResponse,
setBusy,
setMessages,
setModelPickerOpen,
setSessionPickerOpen,
setSessions,
setYoloActive
} from '@/store/session'
@@ -50,12 +56,30 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
HandoffStateResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
SlashExecResponse
} from '../../types'
interface HandoffResult {
ok: boolean
error?: string
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isSessionIdCandidate(value: string): boolean {
const trimmed = value.trim()
return /^\d{8}_\d{6}_[A-Fa-f0-9]{6}$/.test(trimmed) || /^[A-Fa-f0-9]{32}$/.test(trimmed)
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
@@ -245,6 +269,7 @@ interface PromptActionsOptions {
handleSkinCommand: (arg: string) => string
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
sttEnabled: boolean
@@ -260,6 +285,15 @@ interface SubmitTextOptions {
fromQueue?: boolean
}
/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
arg: string
command: string
name: string
recordInput: boolean
sessionHint?: string
}
function renderCommandsCatalog(catalog: CommandsCatalogLike, copy: Translations['desktop']): string {
const desktopCatalog = filterDesktopCommandsCatalog(catalog)
@@ -310,6 +344,7 @@ export function usePromptActions({
handleSkinCommand,
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
sttEnabled,
@@ -320,7 +355,11 @@ export function usePromptActions({
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string) => {
const body = text.trim()
// Strip ANSI: slash-command output from the backend worker carries SGR
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
// literal text.
const body = stripAnsi(text).trim()
if (!body) {
return
@@ -696,230 +735,124 @@ export function usePromptActions({
]
)
// Queue a handoff of this session to a messaging platform and watch it to
// a terminal state. We only write the request through the gateway; the
// separate `hermes gateway` process performs the actual transfer, so we
// poll `handoff.state` (mirror of the CLI's block-poll) for the result.
const handoffSession = useCallback(
async (
platform: string,
options?: { onProgress?: (state: string) => void; sessionId?: string }
): Promise<HandoffResult> => {
const sid = options?.sessionId || activeSessionIdRef.current
if (!sid) {
return { error: copy.sessionUnavailable, ok: false }
}
const target = platform.trim().toLowerCase()
if (!target) {
return { error: copy.handoff.failed(''), ok: false }
}
try {
options?.onProgress?.('pending')
await requestGateway<HandoffRequestResponse>('handoff.request', {
platform: target,
session_id: sid
})
} catch (err) {
return { error: inlineErrorMessage(err, copy.handoff.failed(target)), ok: false }
}
const deadline = Date.now() + 60_000
let lastState = 'pending'
while (Date.now() < deadline) {
await delay(800)
let record: HandoffStateResponse
try {
record = await requestGateway<HandoffStateResponse>('handoff.state', { session_id: sid })
} catch {
continue
}
const state = record.state || 'pending'
if (state !== lastState) {
options?.onProgress?.(state)
lastState = state
}
if (state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
if (state === 'failed') {
return { error: record.error || copy.handoff.failed(target), ok: false }
}
}
const cleanup = await requestGateway<HandoffFailResponse>('handoff.fail', {
error: copy.handoff.timedOut,
session_id: sid
}).catch(() => null)
if (cleanup?.state === 'completed') {
appendSessionTextMessage(sid, 'system', copy.handoff.systemNote(target))
notify({ kind: 'success', message: copy.handoff.success(target) })
return { ok: true }
}
return { error: copy.handoff.timedOut, ok: false }
},
[activeSessionIdRef, appendSessionTextMessage, copy, requestGateway]
)
const executeSlashCommand = useCallback(
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
const runSlash = async (commandText: string, sessionHint?: string, recordInput = true): Promise<void> => {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
const normalizedName = name.toLowerCase()
const ensureSessionId = async (sessionHint?: string) =>
sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!name) {
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (sessionId) {
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
}
return
}
if (normalizedName === 'new' || normalizedName === 'reset') {
startFreshSessionDraft()
return
}
if (normalizedName === 'branch' || normalizedName === 'fork') {
await branchCurrentSession()
return
}
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
if (normalizedName === 'yolo') {
const sid = sessionHint || activeSessionIdRef.current
const next = !$yoloActive.get()
if (!sid) {
setYoloActive(next)
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
return
}
try {
const active = await setSessionYolo(requestGateway, sid, next)
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
} catch {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
return
}
// /model opens the desktop model picker overlay — the same full
// provider+model picker reachable from the status-bar model button —
// instead of the headless prompt_toolkit modal the slash worker can't
// render. With explicit args (`/model <name> [--provider ...]`) run the
// switch directly through slash.exec so power users can still type it.
if (isModelPickerCommand(`/${normalizedName}`)) {
if (!arg.trim()) {
setModelPickerOpen(true)
return
}
const sid = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
if (!sid) {
notify({ kind: 'error', title: 'Session unavailable', message: 'Could not create a new session' })
return
}
try {
const result = await requestGateway<SlashExecResponse>('slash.exec', {
session_id: sid,
command: command.replace(/^\/+/, '')
})
const body = result?.output || `/${name}: model switched`
appendSessionTextMessage(
sid,
'system',
recordInput ? slashStatusText(command, body) : body
)
} catch (err) {
appendSessionTextMessage(
sid,
'system',
`error: ${err instanceof Error ? err.message : String(err)}`
)
}
return
}
if (normalizedName === 'skin' && !sessionHint && !activeSessionIdRef.current) {
notify({ kind: 'success', message: handleSkinCommand(arg) })
return
}
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` instead points the next new chat
// (and the current empty draft) at that profile's backend.
if (normalizedName === 'profile') {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
notify({
kind: 'success',
message: copy.profileStatus(current)
})
return
}
try {
const { profiles } = await getProfiles()
const match = profiles.find(profile => profile.name === target)
if (!match) {
notify({
kind: 'error',
title: copy.unknownProfile,
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
})
return
}
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
// Swap the live gateway now so an empty draft sends into this
// profile immediately; an existing thread keeps its own profile.
await ensureGatewayProfile(key)
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
} catch (err) {
notifyError(err, copy.setProfileFailed)
}
return
}
const sessionId = sessionHint || activeSessionIdRef.current || (await createBackendSessionForSend())
// Resolve the target session plus a writer for inline slash output, or
// notify + return null when none can be created. Folds the ensure / bail /
// build-renderSlashOutput boilerplate every exec-style handler repeats.
const withSlashOutput = async (
ctx: SlashActionCtx
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
const sessionId = await ensureSessionId(ctx.sessionHint)
if (!sessionId) {
notify({
kind: 'error',
title: copy.sessionUnavailable,
message: copy.createSessionFailed
})
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return null
}
const render = (text: string) =>
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
return { render, sessionId }
}
// `exec` commands (and unknown skill / quick commands the backend owns)
// run on the gateway and render their text output inline. This is the only
// path that talks to slash.exec / command.dispatch.
async function runExec(ctx: SlashActionCtx): Promise<void> {
const { arg, command, name } = ctx
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const renderSlashOutput = (text: string) =>
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
// /title <name> renames the session. Route through the gateway's
// `session.title` RPC — the same path the TUI uses — NOT the REST
// renameSession endpoint and NOT the slash worker.
//
// Why not the slash worker: it's a separate HermesCLI subprocess whose
// SQLite write to the shared state.db can silently fail (notably on
// Windows), and it never refreshes the sidebar.
//
// Why not REST renameSession: `sessionId` here is the *runtime* session
// id returned by session.create — it is NOT the stored DB `sessions.id`,
// and session.create deliberately does not persist a DB row until the
// first turn. The REST PATCH endpoint resolves against the sessions
// table, so a runtime id (or a brand-new, not-yet-persisted session)
// 404s with "Session not found" on every platform. See #38508 / #38576.
//
// session.title maps the runtime id to the in-memory session, writes
// through the gateway's own DB connection, and QUEUES the title
// (`pending: true`) when the row isn't persisted yet — so it works for a
// fresh chat too. refreshSessions() then pulls the authoritative title
// back into the sidebar. A bare `/title` (no arg) still falls through to
// the worker to display the current title.
if (normalizedName === 'title' && arg) {
try {
const result = await requestGateway<SessionTitleResponse>('session.title', {
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
await refreshSessions().catch(() => undefined)
renderSlashOutput(
finalTitle
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
: 'Session title cleared.'
)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
if (normalizedName === 'skin') {
renderSlashOutput(handleSkinCommand(arg))
return
}
if (name === 'help' || name === 'commands') {
try {
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog, copy))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
return
}
const { render: renderSlashOutput, sessionId } = resolved
if (!isDesktopSlashCommand(name)) {
renderSlashOutput(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
@@ -943,11 +876,7 @@ export function usePromptActions({
try {
const dispatch = parseCommandDispatch(
await requestGateway<unknown>('command.dispatch', {
session_id: sessionId,
name,
arg
})
await requestGateway<unknown>('command.dispatch', { session_id: sessionId, name, arg })
)
if (!dispatch) {
@@ -994,6 +923,261 @@ export function usePromptActions({
}
}
// One handler per `action` command. Adding a desktop-native command is a
// registry row in desktop-slash-commands.ts plus an entry here — never a
// new branch in a dispatch ladder.
const actionHandlers: Record<DesktopActionId, (ctx: SlashActionCtx) => Promise<void>> = {
new: async () => {
startFreshSessionDraft()
},
branch: async () => {
await branchCurrentSession()
},
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
yolo: async ({ sessionHint }) => {
const sid = sessionHint || activeSessionIdRef.current
const next = !$yoloActive.get()
if (!sid) {
setYoloActive(next)
notify({ kind: 'success', message: next ? copy.yoloArmed : copy.yoloOff })
return
}
try {
const active = await setSessionYolo(requestGateway, sid, next)
appendSessionTextMessage(sid, 'system', copy.yoloSystem(active))
} catch {
notify({ kind: 'error', title: copy.yoloTitle, message: copy.yoloToggleFailed })
}
},
// /handoff hands this session to a messaging platform. The platform is
// completed inline in the slash popover (backend _handoff_completions),
// so there is no overlay: `/handoff <platform>` runs the desktop's own
// handoff RPC. cli_only on the backend, so it must not reach slash.exec.
handoff: async ({ arg, command, recordInput, sessionHint }) => {
const platform = arg.trim()
if (!platform) {
notify({ kind: 'success', message: copy.handoff.pickPlatform })
return
}
const sid = sessionHint || activeSessionIdRef.current
if (!sid) {
notify({ kind: 'error', title: copy.sessionUnavailable, message: copy.createSessionFailed })
return
}
const result = await handoffSession(platform, { sessionId: sid })
if (!result.ok && result.error) {
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, result.error) : result.error)
}
},
// /profile selects which profile new chats open in — no app relaunch.
// A profile is per-session now, so an existing thread can't change its
// profile mid-stream; `/profile <name>` points the next new chat (and
// the current empty draft) at that profile's backend.
profile: async ({ arg }) => {
const target = arg.trim()
const current = normalizeProfileKey($activeGatewayProfile.get())
if (!target) {
notify({ kind: 'success', message: copy.profileStatus(current) })
return
}
try {
const { profiles } = await getProfiles()
const match = profiles.find(profile => profile.name === target)
if (!match) {
notify({
kind: 'error',
title: copy.unknownProfile,
message: copy.noProfileNamed(target, profiles.map(profile => profile.name).join(', '))
})
return
}
const key = normalizeProfileKey(match.name)
$newChatProfile.set(key)
await ensureGatewayProfile(key)
notify({ kind: 'success', message: copy.newChatsProfile(match.name) })
} catch (err) {
notifyError(err, copy.setProfileFailed)
}
},
skin: async ({ arg, command, recordInput, sessionHint }) => {
const sid = sessionHint || activeSessionIdRef.current
const message = handleSkinCommand(arg)
// No session to print into yet — surface it as a toast instead of
// spinning up a backend session just to change the theme.
if (!sid) {
notify({ kind: 'success', message })
return
}
appendSessionTextMessage(sid, 'system', recordInput ? slashStatusText(command, message) : message)
},
// /title <name> renames via the gateway's session.title RPC — the same
// path the TUI uses, NOT REST renameSession (which 404s on runtime ids)
// nor the slash worker (whose DB write can silently fail). Bare /title
// shows the current title, which the worker owns, so delegate to exec.
title: async ctx => {
if (!ctx.arg) {
await runExec(ctx)
return
}
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
const { arg } = ctx
try {
const result = await requestGateway<SessionTitleResponse>('session.title', {
session_id: sessionId,
title: arg
})
const finalTitle = (result?.title || arg).trim()
const queued = result?.pending === true
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
await refreshSessions().catch(() => undefined)
renderSlashOutput(
finalTitle
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
: 'Session title cleared.'
)
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
help: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
try {
const catalog = await requestGateway<CommandsCatalogLike>('commands.catalog', { session_id: sessionId })
renderSlashOutput(renderCommandsCatalog(catalog, copy))
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}
// Picker commands open a desktop overlay; a typed arg is resolved by that
// picker so the command never dead-ends or falls through to the backend.
const openPicker = async (pickerId: DesktopPickerId, ctx: SlashActionCtx): Promise<void> => {
if (pickerId === 'model') {
if (!ctx.arg.trim()) {
setModelPickerOpen(true)
return
}
// Power users can still type `/model <name>` — run it on the backend.
await runExec(ctx)
return
}
// session picker — /resume, /sessions, /switch
const query = ctx.arg.trim()
if (!query) {
setSessionPickerOpen(true)
return
}
const sessions = $sessions.get()
const lower = query.toLowerCase()
const match =
sessions.find(session => session.id === query) ||
sessions.find(session => sessionTitle(session).toLowerCase().includes(lower)) ||
sessions.find(session => (session.preview ?? '').toLowerCase().includes(lower))
if (!match) {
if (isSessionIdCandidate(query)) {
await resumeStoredSession(query)
return
}
notify({ kind: 'error', message: copy.resumeFailed })
return
}
await resumeStoredSession(match.id)
}
// The whole dispatcher: resolve the command's desktop surface, then act on
// its kind. No per-command ladder — behavior lives in the registry.
async function runSlash(commandText: string, sessionHint?: string, recordInput = true): Promise<void> {
const command = commandText.trim()
const { name, arg } = parseSlashCommand(command)
if (!name) {
const sessionId = await ensureSessionId(sessionHint)
if (sessionId) {
appendSessionTextMessage(sessionId, 'system', copy.emptySlashCommand)
}
return
}
const ctx: SlashActionCtx = { arg, command, name, recordInput, sessionHint }
const surface = resolveDesktopCommand(`/${name}`)?.surface
switch (surface?.kind) {
case 'unavailable': {
const resolved = await withSlashOutput(ctx)
resolved?.render(desktopSlashUnavailableMessage(name) || `/${name} is not available in the desktop app.`)
return
}
case 'picker':
return openPicker(surface.picker, ctx)
case 'action':
return actionHandlers[surface.action](ctx)
default:
// exec spec, or an unknown skill / quick command the backend owns.
return runExec(ctx)
}
}
await runSlash(rawCommand, options?.sessionId, options?.recordInput ?? true)
},
[
@@ -1004,8 +1188,10 @@ export function usePromptActions({
copy,
createBackendSessionForSend,
handleSkinCommand,
handoffSession,
refreshSessions,
requestGateway,
resumeStoredSession,
startFreshSessionDraft,
submitPromptText
]
@@ -1314,6 +1500,7 @@ export function usePromptActions({
cancelRun,
editMessage,
handleThreadMessagesChange,
handoffSession,
reloadFromMessage,
steerPrompt,
submitText,