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 { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js' describe('createSlashHandler', () => { beforeEach(() => { resetOverlayState() resetUiState() }) it('opens the resume picker locally', () => { const ctx = buildCtx() expect(createSlashHandler(ctx)('/resume')).toBe(true) expect(getOverlayState().picker).toBe(true) }) it('treats /provider as a local /model alias', () => { const ctx = buildCtx() expect(createSlashHandler(ctx)('/provider')).toBe(true) expect(getOverlayState().modelPicker).toBe(true) expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) 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', { key: 'model', session_id: 'sid-abc', value: 'x-model' }) }) 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', { key: 'model', session_id: 'sid-abc', value: 'anthropic/claude-sonnet-4.6 --provider openrouter' }) }) 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', { key: 'model', session_id: 'sid-abc', value: 'x-model --global' }) }) 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(ctx.gateway.gw.request).not.toHaveBeenCalled() }) it('routes /skills install 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('routes /skills inspect to skills.manage', () => { const ctx = buildCtx() createSlashHandler(ctx)('/skills inspect my-skill') expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { action: 'inspect', query: 'my-skill' }) }) it('routes /skills search to skills.manage', () => { const ctx = buildCtx() createSlashHandler(ctx)('/skills search vibe') expect(ctx.gateway.rpc).toHaveBeenCalledWith('skills.manage', { action: 'search', query: 'vibe' }) }) 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('shows usage for an unknown /skills subcommand', () => { const ctx = buildCtx() createSlashHandler(ctx)('/skills zzz') expect(ctx.gateway.rpc).not.toHaveBeenCalled() expect(ctx.transcript.sys).toHaveBeenCalledWith(expect.stringContaining('usage: /skills')) }) 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(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { key: 'details_mode', value: 'expanded' }) expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded') }) it('sets a per-section override and persists it under details_mode.
', () => { 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' }) expect(ctx.transcript.sys).toHaveBeenCalledWith('details activity: hidden') }) it('clears a per-section override on /details
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: '' }) expect(ctx.transcript.sys).toHaveBeenCalledWith('details tools: reset') }) it('rejects unknown section modes with a usage hint', () => { const ctx = buildCtx() createSlashHandler(ctx)('/details tools blink') expect(getUiState().sections.tools).toBeUndefined() expect(ctx.transcript.sys).toHaveBeenCalledWith('usage: /details
[hidden|collapsed|expanded|reset]') }) it('shows tool enable usage when names are missing', () => { const ctx = buildCtx() expect(createSlashHandler(ctx)('/tools enable')).toBe(true) expect(ctx.transcript.sys).toHaveBeenNthCalledWith(1, 'usage: /tools enable [name ...]') expect(ctx.transcript.sys).toHaveBeenNthCalledWith(2, 'built-in toolset: /tools enable web') expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue') }) it.each([ ['/browser status', 'browser.manage', { action: 'status' }], ['/browser connect', 'browser.manage', { action: 'connect', url: 'http://127.0.0.1:9222' }], ['/reload-mcp', 'reload.mcp', { session_id: null }], ['/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(ctx.gateway.gw.request).not.toHaveBeenCalled() }) it('renders browser connect progress messages from the gateway', async () => { const rpc = vi.fn(() => Promise.resolve({ connected: false, messages: [ "Chrome isn't running with remote debugging — attempting to launch...", 'Browser not connected — start Chrome 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) expect(ctx.transcript.sys).toHaveBeenCalledWith('checking Chrome remote debugging at http://127.0.0.1:9222...') await vi.waitFor(() => { expect(ctx.transcript.sys).toHaveBeenCalledWith( "Chrome isn't running with remote debugging — attempting to launch..." ) expect(ctx.transcript.sys).toHaveBeenCalledWith( 'Browser not connected — start Chrome 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(ctx.gateway.gw.request).not.toHaveBeenCalled() }) it('hot-swaps the live indicator when /indicator