# NousResearch/hermes-agent · Desktop slash commands with newlines in arguments parsing

## Open now

- Nothing open on GitHub.

These members all concern how the Desktop app parses slash commands whose arguments span multiple lines, including a report that /goal with a newline yields an empty command. Several are fix-oriented changes referencing the same issue (#41323); their outcome is not established here.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 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.50`: 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

- #41323: bug(desktop): /goal with newline returns empty slash command

## Prior & competing PRs

- **#41384: fix(desktop): parse multi-line slash commands in Desktop app**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#41522`, `#41323`
- **#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: '' }
     }
    ```
- **#41522: fix(#41323): parse slash commands with newlines in arguments**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#41323`, `#57226`, `#55541`

## 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`
