# NousResearch/hermes-agent · Desktop skill slash command parsing and rendering bugs

## Open now

- Nothing open on GitHub.

These issues and fixes concern bugs in the desktop client where skill slash commands render incorrectly, lose payload content across multiple lines, or produce degraded chip states. The work targets parsing and display reliability for skill and goal slash command inputs.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #57226. Verify it addresses your case.
- **Recommended:** read PR #57226 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-57226`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #55510: bug(desktop): skill slash command can render empty slash command despite non-empty input

## Prior & competing PRs

- **#57226: fix(desktop): parse multiline slash commands so long-context skill/goal payloads stop vanishing (fixes #41323, supersedes #55541)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx`, `apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts`, `apps/desktop/src/lib/chat-runtime.test.ts`, `apps/desktop/src/lib/chat-runtime.ts`
  - fix hunks:
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx`

    ```diff
    @@ -4,7 +4,7 @@ import { useEffect, useRef } from 'react'
     import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
     
     import { textPart } from '@/lib/chat-messages'
    -import { $composerAttachments, type ComposerAttachment } from '@/store/composer'
    +import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
     import { $busy, $connection, $messages, $sessions, setSessions } from '@/store/session'
     import type { SessionInfo } from '@/types/hermes'
     
    ```

    ```diff
    @@ -272,6 +272,70 @@ describe('usePromptActions slash.exec dispatch payloads', () => {
         expect(renderedText).toContain('⊙ Goal set. Starting now.')
         expect(renderedText).not.toContain('/goal: no output')
       })
    +
    +  it('dispatches a slash command with a multiline arg instead of "empty slash command" (#41323, #55510)', async () => {
    +    const calls: { method: string; params?: Record<string, unknown> }[] = []
    +    const states: Record<string, unknown>[] = []
    +
    +    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
    +      calls.push({ method, params })
    +
    +      if (method === 'slash.exec') {
    +        return { type: 'send', message: 'Write a Python script\nthat prints Hello World' } as never
    +      }
    +
    +      return {} as never
    +    })
    +
    +    let handle: HarnessHandle | null = null
    +    render(
    +      <Harness
    +        onReady={h => (handle = h)}
    +        onSeedState={s => states.push(s)}
    +        refreshSessions={async () => undefined}
    +        requestGateway={requestGateway}
    +      />
    +    )
    +
    +    await handle!.submitText('/goal Write a Python script\nthat prints Hello World')
    +
    +    // The newline lives in the arg — the command still reaches the gateway
    +    // whole, exactly as the CLI and Telegram handle it.
    +    expect(calls.map(c => c.method)).toEqual(['slash.exec', 'prompt.submit'])
    +    expect(calls[0]?.params).toEqual({
    +      command: 'goal Write a Python script\nthat prints Hello World',
    +      session_id: RUNTIME_SESSION_ID
    +    })
    +
    +    const renderedText = states
    +      .flatMap(state => {
    +        const messages = Array.isArray(state.messages)
    +          ? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
    +          : []
    +
    +        return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
    +      })
    +      .join('\n')
    +
    +    expect(renderedText).not.toContain('empty slash command')
    +  })
    +
    +  it('restores a degenerate slash payload to the composer instead of losing it', async () => {
    +    setComposerDraft('')
    +
    +    const requestGateway = vi.fn(async () => ({}) as never)
    +
    +    let handle: HarnessHandle | null = null
    +    render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
    +
    +    // `/ text` parses to an empty command name on every surface (CLI parity).
    +    // The composer draft was already cleared on submit and slash input never
    +    // enters the Up-arrow history ring, so the payload must be handed back.
    +    await handle!.submitText('/ pasted context that must not vanish')
    +
    +    expect($composerDraft.get()).toBe('/ pasted context that must not vanish')
    +    expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
    +  })
     })
     
     describe('usePromptActions desktop slash pickers', () => {
    ```
  - `apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts`

    ```diff
    @@ -561,6 +561,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {
             const { name, arg } = parseSlashCommand(command)
     
             if (!name) {
    +          // The composer draft was already cleared on submit, and slash input
    +          // never lands in the Up-arrow history ring (it derives from sent user
    +          // messages) — so without this restore, any payload after a degenerate
    +          // slash (`/ text`, `/` + newline) is lost forever. Hand it back.
    +          if (command.replace(/^\/+/, '').trim()) {
    +            setComposerDraft(command)
    +          }
    +
               const sessionId = await ensureSessionId(sessionHint)
     
               if (sessionId) {
    ```
  - `apps/desktop/src/lib/chat-runtime.test.ts`

    ```diff
    @@ -6,7 +6,8 @@ import {
       attachmentDisplayText,
       coerceThinkingText,
       optimisticAttachmentRef,
    -  parseCommandDispatch
    +  parseCommandDispatch,
    +  parseSlashCommand
     } from './chat-runtime'
     
     const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
    ```

    ```diff
    @@ -111,3 +112,41 @@ describe('parseCommandDispatch', () => {
         expect(parseCommandDispatch({ type: 'prefill', notice: 'x' })).toBeNull()
       })
     })
    +
    +describe('parseSlashCommand', () => {
    +  it('parses a single-line command', () => {
    +    expect(parseSlashCommand('/some-skill do something')).toEqual({
    +      arg: 'do something',
    +      name: 'some-skill'
    +    })
    +  })
    +
    +  it('keeps a multiline arg intact instead of failing the whole parse (#41323)', () => {
    +    expect(parseSlashCommand('/goal Write a Python script\nthat prints Hello World')).toEqual({
    +      arg: 'Write a Python script\nthat prints Hello World',
    +      name: 'goal'
    +    })
    +  })
    +
    +  it('parses a skill command with a long pasted multi-paragraph context (#55510)', () => {
    +    const context = 'summarize this:\n\nparagraph one\nparagraph two\n\nparagraph three'
    +
    +    expect(parseSlashCommand(`/some-skill ${context}`)).toEqual({
    +      arg: context,
    +      name: 'some-skill'
    +    })
    +  })
    +
    +  it('takes the name across a newline boundary like the CLI and gateway (split on any whitespace)', () => {
    +    expect(parseSlashCommand('/goal\npasted block')).toEqual({ arg: 'pasted block', name: 'goal' })
    +  })
    +
    +  it('keeps truly empty slash input empty', () => {
    +    expect(parseSlashCommand('/')).toEqual({ arg: '', name: '' })
    +    expect(parseSlashCommand('/   ')).toEqual({ arg: '', name: '' })
    +  })
    +
    +  it('does not treat text after horizontal whitespace as a command name (CLI parity)', () => {
    +    expect(parseSlashCommand('/ some words')).toEqual({ arg: '', name: '' })
    +  })
    +})
    ```
  - `apps/desktop/src/lib/chat-runtime.ts`

    ```diff
    @@ -223,7 +223,12 @@ export function normalizePersonalityValue(value: string): string {
     }
     
     export function parseSlashCommand(command: string) {
    -  const match = command.replace(/^\/+/, '').match(/^(\S+)\s*(.*)$/)
    +  // `[\s\S]*` (not `.*`): the arg may span newlines — `/goal <multi-line text>`
    +  // or a skill command with a long pasted context. The old `.*$` regex failed
    +  // the whole match on any newline, so every multiline slash command parsed as
    +  // an empty name and got swallowed (#41323, #55510). The backend and CLI both
    +  // split on any whitespace (`split(maxsplit=1)`), so this is the parity fix.
    +  const match = command.replace(/^\/+/, '').match(/^(\S+)([\s\S]*)$/)
     
       return match ? { name: match[1], arg: match[2].trim() } : { name: '', arg: '' }
     }
    ```
- **#55541: fix(desktop): recover degraded skill slash chips**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#55510`

## Fix status

- Merged to main in PR #57226 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-02`.
- Subsystem: `apps/desktop`
