diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 4fa7ff2dca..6afd025587 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -482,6 +482,19 @@ describe('createSlashHandler', () => { expect(ctx.gateway.rpc).not.toHaveBeenCalled() }) + it.each([ + ['/new sprint planning', 'new session started', '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: { @@ -501,6 +514,26 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) + it('skips the confirmation for the /reset alias when config disables it', () => { + patchUiState({ destructiveSlashConfirm: false }) + + const ctx = buildCtx({ + local: { + catalog: { + canon: { + '/new': '/new', + '/reset': '/new' + } + } + } + }) + + expect(createSlashHandler(ctx)('/reset')).toBe(true) + + expect(getOverlayState().confirm).toBeNull() + expect(ctx.session.newSession).toHaveBeenCalledWith('new session started', undefined) + }) + it('keeps visible scrollback when branching a TUI session', async () => { patchUiState({ sid: 'sid-parent' }) const rpc = vi.fn(() => Promise.resolve({ session_id: 'sid-branch', title: 'branch title' })) diff --git a/ui-tui/src/__tests__/useConfigSync.test.ts b/ui-tui/src/__tests__/useConfigSync.test.ts index 9191b26d70..569e10ea1f 100644 --- a/ui-tui/src/__tests__/useConfigSync.test.ts +++ b/ui-tui/src/__tests__/useConfigSync.test.ts @@ -47,6 +47,54 @@ describe('applyDisplay', () => { expect(s.streaming).toBe(false) }) + it('hydrates the destructive slash confirmation policy from approvals', () => { + const setBell = vi.fn() + + applyDisplay( + { + config: { + approvals: { destructive_slash_confirm: false }, + display: {} + } + }, + setBell + ) + + expect($uiState.get().destructiveSlashConfirm).toBe(false) + + applyDisplay( + { + config: { + approvals: { destructive_slash_confirm: true }, + display: {} + } + }, + setBell + ) + + expect($uiState.get().destructiveSlashConfirm).toBe(true) + }) + + it('defaults destructive slash confirmation on and preserves it across config RPC failure', () => { + const setBell = vi.fn() + + applyDisplay({ config: { display: {} } }, setBell) + expect($uiState.get().destructiveSlashConfirm).toBe(true) + + applyDisplay( + { + config: { + approvals: { destructive_slash_confirm: false }, + display: {} + } + }, + setBell + ) + applyDisplay(null, setBell) + + expect($uiState.get().destructiveSlashConfirm).toBe(false) + }) + it('coerces legacy true + "on" alias to top', () => { const setBell = vi.fn() diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 4583ccfe60..e8a1a1d9ff 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -322,6 +322,7 @@ export interface UiState { busy: boolean busyInputMode: BusyInputMode compact: boolean + destructiveSlashConfirm: boolean detailsMode: DetailsMode detailsModeCommandOverride: boolean // Focus view (/focus) — display-only reduced-output mode. Drives the diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 62f64ccaff..794457a168 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -199,7 +199,7 @@ export const coreCommands: SlashCommand[] = [ ctx.session.newSession(isNew ? 'new session started' : undefined, requestedTitle || undefined) } - if (NO_CONFIRM_DESTRUCTIVE) { + if (NO_CONFIRM_DESTRUCTIVE || !ctx.ui.destructiveSlashConfirm) { return commit() } diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index fe7a6674da..e43b38e501 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -14,6 +14,7 @@ const buildUiState = (): UiState => ({ busy: false, busyInputMode: 'queue', compact: false, + destructiveSlashConfirm: true, detailsMode: 'collapsed', detailsModeCommandOverride: false, focusView: false, diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index e8dd8b1334..9509f9b44d 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -253,6 +253,7 @@ export const applyDisplay = ( setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void ) => { const d = cfg?.config?.display ?? {} + const approvals = cfg?.config?.approvals setBell(!!d.bell_on_complete) @@ -273,6 +274,10 @@ export const applyDisplay = ( battery: !!d.battery, busyInputMode: normalizeBusyInputMode(d.busy_input_mode), compact: !!d.tui_compact, + // Fail safe: only YAML boolean false disables the prompt. A transient + // config RPC failure (cfg=null) preserves the last known policy instead + // of silently changing approval behavior until the next successful poll. + ...(cfg ? { destructiveSlashConfirm: approvals?.destructive_slash_confirm !== false } : {}), detailsMode: resolveDetailsMode(d), detailsModeCommandOverride: false, focusView: !!d.focus_view, diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index d5ab544ab5..a701b45fd5 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -119,8 +119,14 @@ export interface ConfigVoiceConfig { submit_mode?: unknown } +export interface ConfigApprovalsConfig { + // Raw config value: only the explicit boolean false disables the safety gate. + destructive_slash_confirm?: unknown +} + export interface ConfigFullResponse { config?: { + approvals?: ConfigApprovalsConfig display?: ConfigDisplayConfig voice?: ConfigVoiceConfig paste_collapse_threshold?: number