import { JsonRpcGatewayError } from '@hermes/shared/json-rpc-channel'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { createSlashHandler } from '../app/createSlashHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import type * as EnvModule from '../config/env.js'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
import { applyLocale, resetLocale, t } from '../i18n/runtime.js'
import * as ClipboardModule from '../lib/clipboard.js'
import * as Osc52Module from '../lib/osc52.js'
import * as TerminalSetupModule from '../lib/terminalSetup.js'

// DASHBOARD_TUI_MODE resolves once at module load from HERMES_TUI_DASHBOARD,
// so toggling process.env in a test body can't move it. Mock just that one
// export (everything else stays real) and flip the holder per test.
const envState = { dashboardTuiMode: false }
vi.mock('../config/env.js', async importActual => {
  const actual = await importActual<typeof EnvModule>()

  return {
    ...actual,
    get DASHBOARD_TUI_MODE() {
      return envState.dashboardTuiMode
    }
  }
})

describe('createSlashHandler', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
    resetOverlayState()
    resetUiState()
    envState.dashboardTuiMode = false
  })

  it('does not execute a stale fallback command after switching destinations', async () => {
    patchUiState({ sid: 'owner' })
    const ctx = buildCtx()
    let reject!: (error: unknown) => void
    const slashExec = new Promise((_, fail) => { reject = fail })
    ctx.gateway.gw.request.mockImplementation((method: string) =>
      method === 'slash.exec' ? slashExec : Promise.resolve({})
    )
    createSlashHandler(ctx)('/unknown-command')
    patchUiState({ sid: 'other' })
    reject(new Error('worker failed'))
    await new Promise(resolve => setImmediate(resolve))
    expect(gatewayWork(ctx)).toHaveLength(1)
  })

  it('opens the unified sessions overlay for /resume', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/resume')).toBe(true)
    expect(getOverlayState().sessions).toBe(true)
  })

  it('resumes a prior session by id when /resume has an argument', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
    expect(ctx.session.resumeById).toHaveBeenCalledWith('sid-old')
    expect(getOverlayState().sessions).toBe(false)
  })

  it('opens the unified sessions overlay locally even when the current session is busy', () => {
    patchUiState({ busy: true, sid: 'sid-abc' })
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/sessions')).toBe(true)
    expect(getOverlayState().sessions).toBe(true)
    expect(ctx.session.guardBusySessionSwitch).not.toHaveBeenCalled()
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('blocks immediate resume-by-id while a turn is busy', () => {
    patchUiState({ busy: true, sid: 'sid-abc' })
    const ctx = buildCtx({ session: { ...buildSession(), guardBusySessionSwitch: vi.fn(() => true) } })

    expect(createSlashHandler(ctx)('/resume sid-old')).toBe(true)
    expect(ctx.session.guardBusySessionSwitch).toHaveBeenCalled()
    expect(ctx.session.resumeById).not.toHaveBeenCalled()
  })

  it('treats /session (singular) as an alias of the sessions overlay', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/session')).toBe(true)
    expect(getOverlayState().sessions).toBe(true)
  })

  it('opens the grid-test overlay locally', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/grid-test 6x4')).toBe(true)
    expect(getOverlayState().widget).toMatchObject({ appId: 'grid-test' })
    expect(getOverlayState().widget?.state).toMatchObject({ cols: 6, nested: false, rows: 4, streams: false })
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('handles /redraw locally without slash worker fallback', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/redraw')).toBe(true)
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('opens the editor locally for /prompt without slash worker fallback', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/prompt')).toBe(true)
    expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1)
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('routes /compose to the editor and seeds inline text', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/compose draft text')).toBe(true)
    expect(ctx.composer.setInput).toHaveBeenCalledWith('draft text')
    expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1)
  })

  it('exits locally for /quit', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/quit')).toBe(true)
    expect(ctx.session.die).toHaveBeenCalledTimes(1)
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('keeps hosted dashboard chat alive for /exit', () => {
    envState.dashboardTuiMode = true
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/exit')).toBe(true)
    expect(ctx.session.die).not.toHaveBeenCalled()
    expect(gatewayWork(ctx)).toEqual([])
    expect(ctx.transcript.sys).toHaveBeenCalledWith(t('slashCmd.core.quit.dashboardDisabled'))
  })

  it('resolves the /exit refusal at run time so a locale swap is observed', () => {
    envState.dashboardTuiMode = true
    const ctx = buildCtx()

    applyLocale('xx', { lang: 'xx', messages: { 'slashCmd.core.quit.dashboardDisabled': 'ZZ' }, surface: 'tui' })

    try {
      expect(createSlashHandler(ctx)('/exit')).toBe(true)
      expect(ctx.transcript.sys).toHaveBeenCalledWith('ZZ')
    } finally {
      resetLocale()
    }
  })

  it('handles /update locally and exits with code 42 via dieWithCode', () => {
    vi.useFakeTimers()
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/update')).toBe(true)
    expect(gatewayWork(ctx)).toEqual([])

    // Advance past the 100ms setTimeout
    vi.advanceTimersByTime(150)
    expect(ctx.session.dieWithCode).toHaveBeenCalledWith(42)

    vi.useRealTimers()
  })

  it('refuses /update in hosted dashboard chat instead of killing the PTY', () => {
    vi.useFakeTimers()
    envState.dashboardTuiMode = true
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/update')).toBe(true)
    expect(ctx.session.dieWithCode).not.toHaveBeenCalled()
    expect(gatewayWork(ctx)).toEqual([])
    expect(ctx.transcript.sys).toHaveBeenCalledWith(t('slashCmd.core.update.dashboardDisabled'))

    vi.advanceTimersByTime(150)
    expect(ctx.session.dieWithCode).not.toHaveBeenCalled()

    vi.useRealTimers()
  })

  it('routes /status to live session.status instead of slash worker', async () => {
    patchUiState({ sid: 'sid-abc' })
    const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/status')).toBe(true)
    expect(rpc).toHaveBeenCalledWith('session.status', { session_id: 'sid-abc' })
    expect(gatewayWork(ctx)).toEqual([])
    await vi.waitFor(() => {
      expect(ctx.transcript.page).toHaveBeenCalledWith('Hermes TUI Status', t('slashCmd.core.status.pageTitle'))
    })
  })

  it('keeps typed /model switches session-scoped by default', async () => {
    patchUiState({ sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        ...buildGateway(),
        rpc: vi.fn(() => Promise.resolve({ value: 'x-model' }))
      }
    })

    expect(createSlashHandler(ctx)('/model x-model')).toBe(true)
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      confirm_expensive_model: false,
      key: 'model',
      session_id: 'sid-abc',
      value: 'x-model'
    })
  })

  it('opens the model picker with refresh for /model --refresh', () => {
    patchUiState({ sid: 'sid-abc' })
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/model --refresh')).toBe(true)
    expect(getOverlayState().modelPicker).toEqual({ refresh: true })
    expect(ctx.gateway.rpc).not.toHaveBeenCalled()
  })

  it('honors TUI picker session scope without adding --global', async () => {
    patchUiState({ sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        ...buildGateway(),
        rpc: vi.fn(() => Promise.resolve({ value: 'anthropic/claude-sonnet-4.6' }))
      }
    })

    expect(
      createSlashHandler(ctx)(`/model anthropic/claude-sonnet-4.6 --provider openrouter ${TUI_SESSION_MODEL_FLAG}`)
    ).toBe(true)
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      confirm_expensive_model: false,
      key: 'model',
      session_id: 'sid-abc',
      value: 'anthropic/claude-sonnet-4.6 --provider openrouter --session'
    })
  })

  it('does not duplicate --global for explicit persistent model switches', () => {
    patchUiState({ sid: 'sid-abc' })
    const ctx = buildCtx()

    createSlashHandler(ctx)('/model x-model --global')
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      confirm_expensive_model: false,
      key: 'model',
      session_id: 'sid-abc',
      value: 'x-model --global'
    })
  })

  it('prefers OSC52 copy for remote sessions', async () => {
    const writeClipboardText = vi.spyOn(ClipboardModule, 'writeClipboardText').mockResolvedValue(true)
    const writeOsc52Clipboard = vi.spyOn(Osc52Module, 'writeOsc52Clipboard').mockReturnValue(true)
    vi.spyOn(TerminalSetupModule, 'isRemoteShellSession').mockReturnValue(true)

    const ctx = buildCtx({
      local: {
        ...buildLocal(),
        getHistoryItems: vi.fn(() => [{ role: 'assistant', text: 'remote answer' }])
      }
    })

    expect(createSlashHandler(ctx)('/copy')).toBe(true)
    await vi.waitFor(() => {
      expect(writeOsc52Clipboard).toHaveBeenCalledWith('remote answer')
    })
    expect(writeClipboardText).not.toHaveBeenCalled()
  })

  it('keeps native-first copy in local tmux sessions', async () => {
    const tmuxBackup = process.env.TMUX
    process.env.TMUX = '/tmp/tmux-123/default,1,0'

    const writeClipboardText = vi.spyOn(ClipboardModule, 'writeClipboardText').mockResolvedValue(true)
    const writeOsc52Clipboard = vi.spyOn(Osc52Module, 'writeOsc52Clipboard').mockReturnValue(true)
    vi.spyOn(TerminalSetupModule, 'isRemoteShellSession').mockReturnValue(false)

    const ctx = buildCtx({
      local: {
        ...buildLocal(),
        getHistoryItems: vi.fn(() => [{ role: 'assistant', text: 'local answer' }])
      }
    })

    expect(createSlashHandler(ctx)('/copy')).toBe(true)
    await vi.waitFor(() => {
      expect(writeClipboardText).toHaveBeenCalledWith('local answer')
    })
    expect(writeOsc52Clipboard).not.toHaveBeenCalled()

    process.env.TMUX = tmuxBackup
  })

  it('applies /reasoning hide to the thinking section immediately', async () => {
    patchUiState({ sections: { thinking: 'expanded' }, showReasoning: true, sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        ...buildGateway(),
        rpc: vi.fn(() => Promise.resolve({ value: 'hide' }))
      }
    })

    expect(createSlashHandler(ctx)('/reasoning hide')).toBe(true)

    await vi.waitFor(() => {
      expect(getUiState().showReasoning).toBe(false)
      expect(getUiState().sections.thinking).toBe('hidden')
    })
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      key: 'reasoning',
      session_id: 'sid-abc',
      value: 'hide'
    })
  })

  it('applies /reasoning show to the thinking section immediately', async () => {
    patchUiState({ sections: { thinking: 'hidden' }, showReasoning: false, sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        ...buildGateway(),
        rpc: vi.fn(() => Promise.resolve({ value: 'show' }))
      }
    })

    expect(createSlashHandler(ctx)('/reasoning show')).toBe(true)

    await vi.waitFor(() => {
      expect(getUiState().showReasoning).toBe(true)
      expect(getUiState().sections.thinking).toBe('expanded')
    })
  })

  it('reads /reasoning status for the active session', () => {
    patchUiState({ sid: 'sid-abc' })
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/reasoning')).toBe(true)
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.get', {
      key: 'reasoning',
      session_id: 'sid-abc'
    })
  })

  it.each(['max'])('sends plain /reasoning %s without a scope (session default)', effort => {
    patchUiState({ sid: 'sid-abc' })
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)(`/reasoning ${effort}`)).toBe(true)
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      key: 'reasoning',
      session_id: 'sid-abc',
      value: effort
    })
  })

  it('sends /reasoning <level> --global as global config.set', () => {
    patchUiState({ sid: 'sid-abc' })
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/reasoning high --global')).toBe(true)
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      key: 'reasoning',
      scope: 'global',
      session_id: 'sid-abc',
      value: 'high'
    })
  })

  it('strips /reasoning session flags before config.set', () => {
    patchUiState({ sid: 'sid-abc' })
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/reasoning low --session')).toBe(true)
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      key: 'reasoning',
      scope: 'session',
      session_id: 'sid-abc',
      value: 'low'
    })
  })

  it('opens the skills hub locally for bare /skills', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/skills')).toBe(true)
    expect(getOverlayState().skillsHub).toBe(true)
    expect(ctx.gateway.rpc).not.toHaveBeenCalled()
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('routes /skills install <name> to skills.manage without opening overlay', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/skills install foo')).toBe(true)
    expect(getOverlayState().skillsHub).toBe(false)
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
      action: 'install',
      query: 'foo'
    })
  })

  it('opens the pet picker for /pet list only', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/pet list')).toBe(true)
    expect(getOverlayState().petPicker).toBe(true)
    expect(gatewayWork(ctx)).toEqual([])

    resetOverlayState()
    expect(createSlashHandler(ctx)('/pet')).toBe(true)
    expect(getOverlayState().petPicker).toBe(false)
    expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'pet' }))

    resetOverlayState()
    expect(createSlashHandler(ctx)('/pet toggle')).toBe(true)
    expect(getOverlayState().petPicker).toBe(false)
    expect(ctx.gateway.gw.request).toHaveBeenCalledWith(
      'slash.exec',
      expect.objectContaining({ command: 'pet toggle' })
    )
  })

  it('routes /skills browse [page] to skills.manage with a numeric page', () => {
    const ctx = buildCtx()

    createSlashHandler(ctx)('/skills browse 3')
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', {
      action: 'browse',
      page: 3
    })
  })

  it('delegates non-native /skills subcommands to slash.exec', () => {
    const ctx = buildCtx()

    createSlashHandler(ctx)('/skills check')
    expect(ctx.gateway.rpc).not.toHaveBeenCalled()
    expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
      command: 'skills check',
      session_id: null
    })
  })

  it('passes /new <title> through to the session lifecycle', () => {
    const ctx = buildCtx()

    createSlashHandler(ctx)('/new sprint planning')
    getOverlayState().confirm?.onConfirm()

    expect(ctx.session.newSession).toHaveBeenCalledWith(t('slashCmd.core.clear.newSessionStarted'), 'sprint planning')
    expect(ctx.gateway.rpc).not.toHaveBeenCalled()
  })

  it.each([
    ['/new sprint planning', t('slashCmd.core.clear.newSessionStarted'), 'sprint planning'],
    ['/clear', undefined, undefined]
  ])('skips the confirmation for %s when config disables it', (command, message, title) => {
    patchUiState({ destructiveSlashConfirm: false })
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)(command)).toBe(true)

    expect(getOverlayState().confirm).toBeNull()
    expect(ctx.session.newSession).toHaveBeenCalledWith(message, title)
  })

  it('routes the /reset catalog alias through the local fresh-session lifecycle', () => {
    const ctx = buildCtx({
      local: {
        catalog: {
          canon: {
            '/new': '/new',
            '/reset': '/new'
          }
        }
      }
    })

    createSlashHandler(ctx)('/reset')
    getOverlayState().confirm?.onConfirm()

    expect(ctx.session.newSession).toHaveBeenCalledWith(t('slashCmd.core.clear.newSessionStarted'), undefined)
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('hydrates a branch through session resume without prematurely replacing source state', async () => {
    patchUiState({ sid: 'sid-parent' })
    const rpc = vi.fn(() => Promise.resolve({ session_id: 'sid-branch', title: 'branch title' }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/branch branch title')).toBe(true)

    expect(rpc).toHaveBeenCalledWith('session.branch', { name: 'branch title', session_id: 'sid-parent' })
    await vi.waitFor(() => {
      expect(ctx.session.resumeById).toHaveBeenCalledWith('sid-branch')
      expect(ctx.transcript.sys).toHaveBeenCalledWith('branched → branch title')
    })
    expect(getUiState().sid).toBe('sid-parent')
    expect(ctx.session.closeSession).not.toHaveBeenCalled()
    expect(ctx.transcript.setHistoryItems).not.toHaveBeenCalled()
  })

  it('only applies compression snapshots while execution authority is fresh', async () => {
    const source = {
      model: 'test', tools: {}, skills: {}, execution_epoch: 'owner', execution_generation: 2,
      execution_state: 'idle', running: false, stored_session_id: 'stored-source'
    }

    const flush = () => new Promise(resolve => setImmediate(resolve))

    for (const change of [
      { execution_generation: 3, execution_state: 'running', running: true },
      { execution_epoch: 'replacement', execution_generation: 0 },
      { execution_state: 'complete' },
      { stored_session_id: 'other' }
    ]) {
      patchUiState({ sid: 'source', info: source, busy: false })
      let resolve!: (value: any) => void
      const rpc = vi.fn(() => new Promise<any>(done => { resolve = done }))
      const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })
      createSlashHandler(ctx)('/compress')
      const current = { ...source, ...change }
      patchUiState({ info: current, busy: current.running, usage: { calls: 8, input: 8, output: 8, total: 16 } })
      resolve({ info: source, messages: [{ role: 'assistant', text: 'old summary' }], usage: { total: 1 } })
      await flush()
      expect(getUiState().info).toEqual(current)
      expect(getUiState().usage.total).toBe(16)
      expect(ctx.transcript.setHistoryItems).not.toHaveBeenCalled()
    }

    // Stale/missing snapshot versions cannot erase established authority either.
    for (const info of [undefined, { ...source, execution_generation: 1 }, { ...source, execution_epoch: 'old' }]) {
      patchUiState({ sid: 'source', info: source, busy: false })
      const ctx = buildCtx({ gateway: { ...buildGateway(), rpc: vi.fn(async () => ({ info, messages: [] })) } })
      createSlashHandler(ctx)('/compress')
      await flush()
      expect(getUiState().info).toEqual(source)
      expect(ctx.transcript.setHistoryItems).not.toHaveBeenCalled()
    }

    // Ordinary metadata refresh is not a new execution; fresh partial info merges.
    patchUiState({ sid: 'source', info: source, busy: false })
    let resolve!: (value: any) => void
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc: vi.fn(() => new Promise<any>(done => { resolve = done })) } })
    createSlashHandler(ctx)('/compress')
    patchUiState({ info: { ...source, model: 'refreshed' } })
    resolve({ info: { execution_epoch: 'owner', execution_generation: 2 }, messages: [{ role: 'assistant', text: 'fresh summary' }] })
    await flush()
    expect(getUiState().info).toMatchObject({ ...source, model: 'refreshed' })
    expect(ctx.transcript.setHistoryItems).toHaveBeenCalledWith(expect.arrayContaining([
      expect.objectContaining({ role: 'assistant', text: 'fresh summary' })
    ]))
  })

  it('reloads skills in the live gateway and refreshes the catalog', async () => {
    const rpc = vi.fn((method: string) => {
      if (method === 'skills.reload') {
        return Promise.resolve({ output: '42 skill(s) available' })
      }

      if (method === 'commands.catalog') {
        return Promise.resolve({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
      }

      return Promise.resolve({})
    })

    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    createSlashHandler(ctx)('/reload-skills')

    expect(rpc).toHaveBeenCalledWith('skills.reload', {})
    await vi.waitFor(() => {
      expect(ctx.transcript.page).toHaveBeenCalledWith('42 skill(s) available', 'Reload Skills')
      expect(ctx.local.setCatalog).toHaveBeenCalledWith(
        expect.objectContaining({ canon: { '/new-skill': '/new-skill' }, pairs: [['/new-skill', 'demo']] })
      )
    })
    expect(gatewayWork(ctx)).toEqual([])
  })

  // Regressions from Copilot review on #19835: /voice output + frontend
  // binding state must both track the gateway's fresh ``record_key`` on
  // every response, or a config edit shows the new shortcut in text
  // while push-to-talk still fires the old one until the next mtime
  // poll (~5s).
  it('/voice status renders the gateway record_key and pushes it into frontend state', async () => {
    const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'ctrl+space', tts: false }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/voice status')).toBe(true)
    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalledWith('  Record key: Ctrl+Space')
    })
    expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(
      expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space' })
    )
  })

  it('/voice on renders the configured binding for the start/stop hint', async () => {
    const rpc = vi.fn(() => Promise.resolve({ enabled: true, record_key: 'alt+r', tts: false }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/voice on')).toBe(true)
    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalledWith('  Alt+R to start/stop recording')
    })
    expect(ctx.voice.setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'r', mod: 'alt' }))
  })

  // Round-2 Copilot review on #19835: a response missing ``record_key``
  // (e.g. the old tts branch, or any future branch that forgets to
  // include it) MUST NOT clobber the user's cached binding back to
  // Ctrl+B. The label still renders the default for display; the
  // frontend state keeps whatever was last authoritatively set.
  it('/voice tts without record_key does not clobber cached frontend binding', async () => {
    const rpc = vi.fn(() => Promise.resolve({ enabled: true, tts: true }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/voice tts')).toBe(true)
    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalled()
    })
    expect(ctx.voice.setVoiceRecordKey).not.toHaveBeenCalled()
  })

  it('cycles details mode and persists it', async () => {
    const ctx = buildCtx()

    expect(getUiState().detailsMode).toBe('collapsed')
    expect(createSlashHandler(ctx)('/details toggle')).toBe(true)
    expect(getUiState().detailsMode).toBe('expanded')
    expect(getUiState().detailsModeCommandOverride).toBe(true)
    expect(getUiState().sections).toEqual({
      thinking: 'expanded',
      tools: 'expanded',
      subagents: 'expanded',
      activity: 'expanded'
    })
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      key: 'details_mode',
      value: 'expanded'
    })
  })

  it('sets a per-section override and persists it under details_mode.<section>', () => {
    const ctx = buildCtx()

    expect(createSlashHandler(ctx)('/details activity hidden')).toBe(true)
    expect(getUiState().sections.activity).toBe('hidden')
    expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', {
      key: 'details_mode.activity',
      value: 'hidden'
    })
  })

  it('clears a per-section override on /details <section> reset', () => {
    const ctx = buildCtx()
    createSlashHandler(ctx)('/details tools expanded')
    expect(getUiState().sections.tools).toBe('expanded')

    createSlashHandler(ctx)('/details tools reset')
    expect(getUiState().sections.tools).toBeUndefined()
    expect(ctx.gateway.rpc).toHaveBeenLastCalledWith('config.set', {
      key: 'details_mode.tools',
      value: ''
    })
  })

  it('rejects unknown section modes', () => {
    const ctx = buildCtx()
    createSlashHandler(ctx)('/details tools blink')
    expect(getUiState().sections.tools).toBeUndefined()
  })

  it.each([
    ['/browser status', 'browser.manage', { action: 'status', session_id: null }],
    ['/browser connect', 'browser.manage', { action: 'connect', session_id: null, url: 'http://127.0.0.1:9222' }],
    ['/browser use', 'browser.manage', { action: 'use', enabled: true, session_id: null }],
    ['/browser use off', 'browser.manage', { action: 'use', enabled: false, session_id: null }],
    ['/reload-mcp', 'reload.mcp', { session_id: null }],
    ['/reload', 'reload.env', {}],
    ['/stop', 'process.stop', {}],
    ['/fast status', 'config.get', { key: 'fast', session_id: null }],
    ['/busy status', 'config.get', { key: 'busy' }],
    ['/indicator', 'config.get', { key: 'indicator' }]
  ])('routes %s through native RPC (no slash worker)', (command, method, params) => {
    const rpc = vi.fn(() => Promise.resolve({}))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)(command)).toBe(true)
    expect(rpc).toHaveBeenCalledWith(method, params)
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('renders browser connect progress messages from the gateway', async () => {
    const rpc = vi.fn(() =>
      Promise.resolve({
        connected: false,
        messages: [
          "Chromium-family browser isn't running with remote debugging — attempting to launch...",
          'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
        ],
        url: 'http://127.0.0.1:9222'
      })
    )

    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/browser connect')).toBe(true)
    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalledWith(
        "Chromium-family browser isn't running with remote debugging — attempting to launch..."
      )
      expect(ctx.transcript.sys).toHaveBeenCalledWith(
        'Browser not connected — start a Chromium-family browser with remote debugging and retry /browser connect'
      )
      expect(ctx.transcript.sys).not.toHaveBeenCalledWith('browser connect failed')
    })
  })

  it('routes /rollback through native RPC when a session is active', () => {
    patchUiState({ sid: 'sid-abc' })
    const rpc = vi.fn(() => Promise.resolve({}))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/rollback')).toBe(true)
    expect(rpc).toHaveBeenCalledWith('rollback.list', { session_id: 'sid-abc' })
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('hot-swaps the live indicator when /indicator <style> succeeds', async () => {
    const rpc = vi.fn(() => Promise.resolve({ value: 'emoji' }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/indicator emoji')).toBe(true)
    expect(rpc).toHaveBeenCalledWith('config.set', { key: 'indicator', value: 'emoji' })
    await vi.waitFor(() => expect(getUiState().indicatorStyle).toBe('emoji'))
  })

  it('rejects unknown indicator styles before hitting the gateway', () => {
    const rpc = vi.fn(() => Promise.resolve({}))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    expect(createSlashHandler(ctx)('/indicator sparkle')).toBe(true)
    expect(rpc).not.toHaveBeenCalled()
  })

  it('drops stale slash.exec output after a newer slash', async () => {
    let resolveLate: (v: { output?: string }) => void
    let slashExecCalls = 0

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) => {
            if (method === 'slash.exec') {
              slashExecCalls += 1

              if (slashExecCalls === 1) {
                return new Promise<{ output?: string }>(res => {
                  resolveLate = res
                })
              }

              return Promise.resolve({ output: 'fresh' })
            }

            return Promise.resolve({})
          })
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    const h = createSlashHandler(ctx)
    expect(h('/slow')).toBe(true)
    expect(h('/later')).toBe(true)
    resolveLate!({ output: 'too late' })
    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalled()
    })

    expect(ctx.transcript.sys).not.toHaveBeenCalledWith('too late')
  })

  it('dispatches command.dispatch with typed alias', async () => {
    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) => {
            if (method === 'slash.exec') {
              return Promise.reject(new Error('no'))
            }

            if (method === 'command.dispatch') {
              return Promise.resolve({ type: 'alias', target: 'help' })
            }

            return Promise.resolve({})
          })
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    const h = createSlashHandler(ctx)
    expect(h('/zzz')).toBe(true)
    await vi.waitFor(() => {
      expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
    })
  })

  it('resolves unique local aliases through the catalog', () => {
    const ctx = buildCtx({
      local: {
        catalog: {
          canon: {
            '/h': '/help',
            '/help': '/help'
          }
        }
      }
    })

    expect(createSlashHandler(ctx)('/h')).toBe(true)
    expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array))
  })

  it('lets exact catalog commands win over longer prefix matches', async () => {
    const ctx = buildCtx({
      local: {
        catalog: {
          canon: {
            '/profile': '/profile',
            '/plugins': '/plugins'
          }
        }
      }
    })

    expect(createSlashHandler(ctx)('/profile')).toBe(true)
    await vi.waitFor(() => {
      expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
        command: 'profile',
        session_id: null
      })
    })
    expect(ctx.transcript.sys).not.toHaveBeenCalledWith(expect.stringContaining('ambiguous command'))
  })

  it('keeps ambiguous prefix handling when there is no exact catalog match', () => {
    const ctx = buildCtx({
      local: {
        catalog: {
          canon: {
            '/status': '/status',
            '/statusbar': '/statusbar'
          }
        }
      }
    })

    expect(createSlashHandler(ctx)('/stat')).toBe(true)
    expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringMatching(/\/status\b.*\/statusbar/))
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('falls through to command.dispatch for skill commands, sending the body but showing the invocation', async () => {
    const skillMessage =
      '[IMPORTANT: The user has invoked the "hermes-agent-dev" skill, indicating they want you to follow its instructions.\n' +
      'The full skill content is loaded below.]\n\nUse this skill to do X.\n\n## Steps\n1. First step'

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) => {
            if (method === 'slash.exec') {
              return Promise.reject(new Error('skill command: use command.dispatch'))
            }

            if (method === 'command.dispatch') {
              return Promise.resolve({
                type: 'skill',
                message: skillMessage,
                name: 'hermes-agent-dev',
                display: '/hermes-agent-dev'
              })
            }

            return Promise.resolve({})
          })
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    const h = createSlashHandler(ctx)
    expect(h('/hermes-agent-dev')).toBe(true)
    await vi.waitFor(() => {
      expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage, true, '/hermes-agent-dev')
    })

    // The expanded skill body is model-facing: no transcript line may carry it.
    for (const [line] of ctx.transcript.sys.mock.calls) {
      expect(line).not.toContain('Use this skill to do X')
    }
  })

  it('surfaces the slash worker failure itself instead of the command.dispatch refusal', async () => {
    patchUiState({ sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) => {
            if (method === 'slash.exec') {
              return Promise.reject(new JsonRpcGatewayError('slash worker timed out', { code: 5030 }))
            }

            return Promise.reject(
              new JsonRpcGatewayError('not a quick/plugin/bundle/skill command: insights', { code: 4018 })
            )
          })
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    expect(createSlashHandler(ctx)('/insights')).toBe(true)
    await vi.waitFor(() => expect(ctx.transcript.sys).toHaveBeenCalled())

    expect(ctx.gateway.gw.request).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
    const line = String(ctx.transcript.sys.mock.calls.at(-1)?.[0])
    expect(line).toContain('/insights')
    expect(line).toMatch(/timed out/)
    expect(line).not.toMatch(/quick\/plugin\/bundle\/skill/)
  })

  it('still falls back to command.dispatch on a 4018 "not mine" refusal', async () => {
    patchUiState({ sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) =>
            method === 'slash.exec'
              ? Promise.reject(new JsonRpcGatewayError('skill command: use command.dispatch for /x', { code: 4018 }))
              : Promise.resolve({ type: 'alias', target: 'help' })
          )
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    createSlashHandler(ctx)('/x')
    await vi.waitFor(() => expect(ctx.gateway.gw.request).toHaveBeenCalledWith('command.dispatch', expect.anything()))
  })

  it('pages a long owner read from slash.exec like main\'s sidecar output', async () => {
    // The shared owner answers a read as `{type: 'exec', output}` (gateway/session_commands.py);
    // main's sidecar sent bare `{output}`, paged when long. /context and /cron must still open
    // the pager, not collapse to a one-line system row.
    patchUiState({ sid: 'sid-abc' })
    const output = ['Conversation: 4 messages', '  user: 2, assistant: 2, tool: 0, system: 0', 'Model: fake-model'].join('\n')

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) =>
            Promise.resolve(method === 'slash.exec' ? { output, type: 'exec' } : {})
          )
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    expect(createSlashHandler(ctx)('/context')).toBe(true)
    await vi.waitFor(() => expect(ctx.transcript.page).toHaveBeenCalledWith(output, 'Context'))
    expect(ctx.transcript.sys).not.toHaveBeenCalledWith(output)
  })

  it('keeps a short owner read from slash.exec as a system line', async () => {
    patchUiState({ sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) =>
            Promise.resolve(method === 'slash.exec' ? { output: 'Hermes v1', type: 'exec' } : {})
          )
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    expect(createSlashHandler(ctx)('/version')).toBe(true)
    await vi.waitFor(() => expect(ctx.transcript.sys).toHaveBeenCalledWith('Hermes v1'))
    expect(ctx.transcript.page).not.toHaveBeenCalled()
  })

  it('handles command.dispatch payloads returned directly by slash.exec', async () => {
    patchUiState({ sid: 'sid-abc' })

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          request: vi.fn((method: string) => {
            if (method === 'slash.exec') {
              return Promise.resolve({
                message: 'complete all the steps and provide a final report',
                notice: '⊙ Goal set (20-turn budget): complete all the steps and provide a final report',
                type: 'send'
              })
            }

            return Promise.resolve({})
          })
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    const h = createSlashHandler(ctx)
    expect(h('/goal complete all the steps and provide a final report')).toBe(true)

    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalledWith(
        '⊙ Goal set (20-turn budget): complete all the steps and provide a final report'
      )
    })
    expect(ctx.transcript.send).toHaveBeenCalledWith('complete all the steps and provide a final report')
    expect(ctx.transcript.sys).not.toHaveBeenCalledWith('/goal: no output')
    expect(ctx.gateway.gw.request).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
  })

  it('/history pages the current TUI transcript (user + assistant)', () => {
    const ctx = buildCtx({
      local: {
        ...buildLocal(),
        getHistoryItems: vi.fn(() => [
          { role: 'user', text: 'hello' },
          { role: 'system', text: 'ignore me' },
          { role: 'assistant', text: 'hi there' },
          { role: 'user', text: 'test' }
        ])
      }
    })

    createSlashHandler(ctx)('/history')
    expect(ctx.transcript.page).toHaveBeenCalledTimes(1)

    const [body] = ctx.transcript.page.mock.calls[0]!

    expect(body).toContain('hello')
    expect(body).toContain('hi there')
    expect(body).toContain('test')
    expect(body).not.toContain('ignore me')
    expect(gatewayWork(ctx)).toEqual([])
  })

  it('/history reports empty state without paging', () => {
    const ctx = buildCtx()

    createSlashHandler(ctx)('/history')
    expect(ctx.transcript.page).not.toHaveBeenCalled()
    expect(ctx.transcript.sys).toHaveBeenCalled()
  })

  it('/save forwards to session.save RPC and reports the returned file', async () => {
    patchUiState({ sid: 'sid-abc' })

    const rpc = vi.fn(() => Promise.resolve({ file: '/tmp/hermes_conversation_test.json' }))

    const ctx = buildCtx({
      gateway: { ...buildGateway(), rpc },
      local: {
        ...buildLocal(),
        getHistoryItems: vi.fn(() => [
          { role: 'system', text: 'intro' },
          { role: 'user', text: 'hello' },
          { role: 'assistant', text: 'hi there' }
        ])
      }
    })

    createSlashHandler(ctx)('/save')

    expect(gatewayWork(ctx)).toEqual([])
    expect(rpc).toHaveBeenCalledWith('session.save', { session_id: 'sid-abc' })

    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('/tmp/hermes_conversation_test.json'))
    })
  })

  it('/save reports empty state without calling the RPC or slash worker', () => {
    const rpc = vi.fn(() => Promise.resolve({}))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    createSlashHandler(ctx)('/save')

    expect(gatewayWork(ctx)).toEqual([])
    expect(rpc).not.toHaveBeenCalled()
    expect(ctx.transcript.sys).toHaveBeenCalled()
  })

  it('/save without an active session tells the user instead of hitting the RPC', () => {
    // sid stays null (default) but there IS visible conversation
    const rpc = vi.fn(() => Promise.resolve({}))

    const ctx = buildCtx({
      gateway: { ...buildGateway(), rpc },
      local: {
        ...buildLocal(),
        getHistoryItems: vi.fn(() => [{ role: 'user', text: 'hello' }])
      }
    })

    createSlashHandler(ctx)('/save')

    expect(rpc).not.toHaveBeenCalled()
    expect(ctx.transcript.sys).toHaveBeenCalled()
  })

  it('/rollback without an active session tells the user instead of hitting the RPC', () => {
    const rpc = vi.fn(() => Promise.resolve({}))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    createSlashHandler(ctx)('/rollback')

    expect(rpc).not.toHaveBeenCalled()
    expect(ctx.transcript.sys).toHaveBeenCalled()
  })

  // A pasted PR thread / diff / log reaches a skill command as its argument.
  // parseSlashCommand used to split the whole line on `\s+` and rejoin with a
  // single space, so every line break was gone before the skill ran — and the
  // fallback command.dispatch carried that flattened text.
  it('carries a multi-line argument to the backend without flattening it', async () => {
    patchUiState({ sid: 'sid-abc' })

    const arg = 'line one\nline two\n\n  indented tail'

    const ctx = buildCtx({
      gateway: {
        gw: {
          getLogTail: vi.fn(() => ''),
          kill: vi.fn(),
          request: vi.fn((method: string) =>
            method === 'slash.exec' ? Promise.reject(new Error('skill command')) : Promise.resolve({})
          )
        },
        rpc: vi.fn(() => Promise.resolve({}))
      }
    })

    createSlashHandler(ctx)(`/pr-triage ${arg}`)

    expect(ctx.gateway.gw.request).toHaveBeenCalledWith('slash.exec', {
      command: `pr-triage ${arg}`,
      session_id: 'sid-abc'
    })
    await vi.waitFor(() => {
      expect(ctx.gateway.gw.request).toHaveBeenCalledWith('command.dispatch', {
        arg,
        name: 'pr-triage',
        session_id: 'sid-abc'
      })
    })
  })

  it('/title <name> uses session.title RPC and bypasses slash.exec', async () => {
    patchUiState({ sid: 'sid-abc' })
    const rpc = vi.fn(() => Promise.resolve({ pending: false, title: 'my title' }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    createSlashHandler(ctx)('/title my title')

    expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc', title: 'my title' })
    expect(gatewayWork(ctx)).toEqual([])
    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('my title'))
    })
  })

  it('/title with no args fetches and displays the current title', async () => {
    patchUiState({ sid: 'sid-abc' })
    const rpc = vi.fn(() => Promise.resolve({ title: 'demo title' }))
    const ctx = buildCtx({ gateway: { ...buildGateway(), rpc } })

    createSlashHandler(ctx)('/title')

    expect(rpc).toHaveBeenCalledWith('session.title', { session_id: 'sid-abc' })
    expect(gatewayWork(ctx)).toEqual([])
    await vi.waitFor(() => {
      expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('demo title'))
    })
  })
})

const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
  ...overrides,
  slashFlightRef: overrides.slashFlightRef ?? { current: 0 },
  composer: { ...buildComposer(), ...overrides.composer },
  gateway: { ...buildGateway(), ...overrides.gateway },
  local: { ...buildLocal(), ...overrides.local },
  session: { ...buildSession(), ...overrides.session },
  transcript: { ...buildTranscript(), ...overrides.transcript },
  voice: { ...buildVoice(), ...overrides.voice }
})

const buildComposer = () => ({
  enqueue: vi.fn(),
  hasSelection: false,
  openEditor: vi.fn(async () => {}),
  paste: vi.fn(),
  queueRef: { current: [] as string[] },
  selection: { copySelection: vi.fn(async () => '') },
  setInput: vi.fn()
})

// Gateway traffic other than the fire-and-forget shared_metrics.slash_command every typed command reports.
const gatewayWork = (ctx: { gateway: { gw: { request: unknown } } }) =>
  vi
    .mocked(ctx.gateway.gw.request as (method: string) => unknown)
    .mock.calls.filter(([method]) => method !== 'shared_metrics.slash_command')

const buildGateway = () => ({
  gw: {
    getLogTail: vi.fn(() => ''),
    kill: vi.fn(),
    request: vi.fn(() => Promise.resolve({}))
  },
  rpc: vi.fn(() => Promise.resolve({}))
})

const buildLocal = () => ({
  catalog: null,
  getHistoryItems: vi.fn(() => []),
  getLastUserMsg: vi.fn(() => ''),
  maybeWarn: vi.fn(),
  setCatalog: vi.fn()
})

const buildSession = () => ({
  closeSession: vi.fn(() => Promise.resolve(null)),
  die: vi.fn(),
  dieWithCode: vi.fn(),
  guardBusySessionSwitch: vi.fn(() => false),
  newLiveSession: vi.fn(),
  newSession: vi.fn(),
  resetVisibleHistory: vi.fn(),
  resumeById: vi.fn(),
  setSessionStartedAt: vi.fn()
})

const buildTranscript = () => ({
  page: vi.fn(),
  panel: vi.fn(),
  send: vi.fn(),
  setHistoryItems: vi.fn(),
  sys: vi.fn(),
  trimLastExchange: vi.fn(items => items)
})

const buildVoice = () => ({
  setVoiceEnabled: vi.fn(),
  setVoiceRecordKey: vi.fn(),
  setVoiceTts: vi.fn()
})

interface Ctx {
  slashFlightRef: { current: number }
  composer: ReturnType<typeof buildComposer>
  gateway: ReturnType<typeof buildGateway>
  local: ReturnType<typeof buildLocal>
  session: ReturnType<typeof buildSession>
  transcript: ReturnType<typeof buildTranscript>
  voice: ReturnType<typeof buildVoice>
}
