diff --git a/package-lock.json b/package-lock.json index 7aca721fc92..ad2924b1854 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24635,6 +24635,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/remend/-/remend-1.3.1.tgz", "integrity": "sha512-N3DiY5qbRPoa5vkxn1oDLMyXOVTeo6Hp+XOj6SIqJAYUgLS0Q587gILPMom/qm86AQ/ZrcOdwEIzCz8V3J0nxQ==", + "dev": true, "license": "Apache-2.0" }, "node_modules/require-directory": { @@ -29855,6 +29856,7 @@ "jsdom": "^26.1.0", "pretty-format": "^30.0.2", "react-dom": "^19.1.0", + "remend": "^1.3.1", "supertest": "^7.2.2", "typescript": "^5.3.3", "vitest": "^3.1.1" diff --git a/packages/cli/package.json b/packages/cli/package.json index 5e98b031760..982ed155ceb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -126,6 +126,7 @@ "jsdom": "^26.1.0", "pretty-format": "^30.0.2", "react-dom": "^19.1.0", + "remend": "^1.3.1", "supertest": "^7.2.2", "typescript": "^5.3.3", "vitest": "^3.1.1" diff --git a/packages/cli/src/ui/opentui/commands-dispatch.test.ts b/packages/cli/src/ui/opentui/commands-dispatch.test.ts new file mode 100644 index 00000000000..0e922c6a1a8 --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-dispatch.test.ts @@ -0,0 +1,1303 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI full-parity dispatcher against the ink + * `useSlashCommandProcessor.handleSlashCommand` behavior, using the + * ORIGINAL shared parser and stub commands that return each + * `SlashCommandActionReturn` kind. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + SlashCommandStatus, + ToolConfirmationOutcome, +} from '@qwen-code/qwen-code-core'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + CommandKind, + type SlashCommand, + type SlashCommandActionReturn, +} from '../commands/types.js'; +import type { HistoryItem } from '../types.js'; +import type { SessionStatsState } from '../contexts/SessionContext.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; +import { + OpenTuiSlashDispatcher, + shouldHideSlashCommandInvocation, + type OpenTuiDispatchOutcome, +} from './commands-dispatch.js'; +import type { OpenTuiCommandHost } from './commands-context.js'; + +const { logSlashCommandSpy, loadInteractiveCommandsMock } = vi.hoisted(() => ({ + logSlashCommandSpy: vi.fn(), + loadInteractiveCommandsMock: vi.fn(), +})); + +vi.mock('./slash-dispatch.js', () => ({ + loadInteractiveCommands: (...args: unknown[]) => + loadInteractiveCommandsMock(...args), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + logSlashCommand: (...args: unknown[]) => logSlashCommandSpy(...args), + }; +}); + +function stub( + overrides: Partial & { name: string }, +): SlashCommand { + return { + description: `stub ${overrides.name}`, + kind: CommandKind.BUILT_IN, + ...overrides, + }; +} + +interface FakeHost extends OpenTuiCommandHost { + items: HistoryItem[]; + updates: Array<{ id: number; updates: Record }>; + calls: string[]; + sessionNames: Array; + allowlistAdds: string[][]; + processingFlags: boolean[]; + shellConfirmations: string[][]; + actionConfirmations: number; + resumedSessions: string[]; + branchNames: Array; +} + +function createFakeHost(): FakeHost { + let nextId = 0; + const items: FakeHost['items'] = []; + const updates: FakeHost['updates'] = []; + const calls: string[] = []; + const sessionNames: Array = []; + const allowlistAdds: string[][] = []; + const processingFlags: boolean[] = []; + const shellConfirmations: string[][] = []; + const resumedSessions: string[] = []; + const branchNames: Array = []; + let shellResolution = { + outcome: ToolConfirmationOutcome.Cancel, + approvedCommands: [] as string[], + }; + let actionConfirmation = false; + const push = (name: string) => calls.push(name); + + const host: FakeHost = { + items, + updates, + calls, + sessionNames, + allowlistAdds, + processingFlags, + shellConfirmations, + actionConfirmations: 0, + resumedSessions, + branchNames, + getHistory: () => items, + addItem: (item, timestamp) => { + const id = nextId++; + items.push({ ...item, id, timestamp } as HistoryItem); + return id; + }, + updateItem: (id, updatesArg) => { + updates.push({ id, updates: updatesArg as never }); + }, + clearItems: () => { + push('clearItems'); + items.length = 0; + }, + loadHistory: () => push('loadHistory'), + refreshStatic: () => push('refreshStatic'), + clearPendingState: () => push('clearPendingState'), + cancelBtw: () => push('cancelBtw'), + btwItem: null, + setBtwItem: () => push('setBtwItem'), + btwAbortControllerRef: { current: null }, + pendingItem: null, + setPendingItem: (item) => { + host.pendingItem = item; + push('setPendingItem'); + }, + setDebugMessage: () => push('setDebugMessage'), + toggleVimEnabled: async () => true, + setMemoryFileCount: () => push('setMemoryFileCount'), + reloadCommands: () => { + push('reloadCommands'); + }, + setSessionName: (name) => { + sessionNames.push(name); + }, + isIdle: () => true, + extensionsUpdateState: new Map(), + dispatchExtensionStateUpdate: () => push('dispatchExtensionStateUpdate'), + addConfirmUpdateExtensionRequest: () => + push('addConfirmUpdateExtensionRequest'), + sessionStats: { + sessionId: 'sess-1', + sessionStartTime: new Date(), + metrics: {}, + lastPromptTokenCount: 0, + promptCount: 0, + } as unknown as SessionStatsState, + sessionShellAllowlist: new Set(), + addSessionShellAllowlist: (commands) => { + allowlistAdds.push([...commands]); + for (const cmd of commands) host.sessionShellAllowlist.add(cmd); + }, + setIsProcessing: (flag) => processingFlags.push(flag), + presentShellConfirmation: async (commands) => { + shellConfirmations.push([...commands]); + return shellResolution; + }, + presentActionConfirmation: async () => { + host.actionConfirmations += 1; + return actionConfirmation; + }, + handleResume: async (sessionId) => { + resumedSessions.push(sessionId); + }, + handleBranch: async (name) => { + branchNames.push(name); + }, + }; + Object.defineProperty(host, '__setShellResolution', { + value: (resolution: typeof shellResolution) => { + shellResolution = resolution; + }, + }); + Object.defineProperty(host, '__setActionConfirmation', { + value: (confirmed: boolean) => { + actionConfirmation = confirmed; + }, + }); + return host; +} + +function setShellResolution( + host: FakeHost, + resolution: { + outcome: ToolConfirmationOutcome; + approvedCommands?: string[]; + }, +): void { + ( + host as unknown as { + __setShellResolution: (r: typeof resolution) => void; + } + ).__setShellResolution(resolution); +} + +function setActionConfirmation(host: FakeHost, confirmed: boolean): void { + ( + host as unknown as { __setActionConfirmation: (c: boolean) => void } + ).__setActionConfirmation(confirmed); +} + +const services = { + config: null, + settings: {} as LoadedSettings, + logger: null, +}; + +async function dispatch( + input: string, + commands: SlashCommand[], + hostOverride?: Partial, +): Promise<{ outcome: OpenTuiDispatchOutcome | false; host: FakeHost }> { + const host = createFakeHost(); + Object.assign(host, hostOverride); + const dispatcher = new OpenTuiSlashDispatcher(host, services, commands); + return { outcome: await dispatcher.handle(input), host }; +} + +describe('guards (ink handleSlashCommand parity)', () => { + it('returns false for non-slash input and path-like input', async () => { + const { outcome: plain } = await dispatch('hello world', []); + expect(plain).toBe(false); + const { outcome: pathLike } = await dispatch('/usr/bin/ls', []); + expect(pathLike).toBe(false); + const { outcome: question } = await dispatch('?', [ + stub({ name: 'help', altNames: ['?'] }), + ]); + expect(question).not.toBe(false); + }); + + it('echoes the invocation as a user item, skipped for /btw', async () => { + const commands = [ + stub({ + name: 'greet', + action: () => ({ + type: 'message', + messageType: 'info', + content: 'hi', + }), + }), + stub({ + name: 'btw', + action: () => ({ + type: 'message', + messageType: 'info', + content: 'side', + }), + }), + ]; + const { host } = await dispatch('/greet', commands); + expect(host.items[0]).toMatchObject({ + type: 'user', + text: '/greet', + sentToModel: false, + }); + + const { host: btwHost } = await dispatch('/btw something', commands); + expect(btwHost.items.some((item) => item.type === 'user')).toBe(false); + }); + + it('hides the invocation echo for dialog-opening bare roots (ink parity)', async () => { + const dialogStub = (name: string): SlashCommand => + stub({ + name, + action: () => ({ type: 'message', messageType: 'info', content: name }), + }); + const commands = ['help', 'settings', 'status', 'stats'].map(dialogStub); + const { host } = await dispatch('/help', commands); + expect(host.items.some((item) => item.type === 'user')).toBe(false); + }); + + it('keeps the invocation echo for work-performing subcommands', async () => { + const commands = [ + stub({ + name: 'status', + subCommands: [ + stub({ + name: 'paths', + action: () => ({ + type: 'message', + messageType: 'info', + content: 'paths', + }), + }), + ], + }), + ]; + const { host } = await dispatch('/status paths', commands); + expect(host.items[0]).toMatchObject({ + type: 'user', + text: '/status paths', + sentToModel: false, + }); + }); +}); + +describe('shouldHideSlashCommandInvocation (slashCommandProcessor parity)', () => { + const cmd = (name: string, kind = CommandKind.BUILT_IN): SlashCommand => + stub({ name, kind }); + + it.each([ + 'auth', + 'diff', + 'editor', + 'help', + 'settings', + 'status', + 'stats', + 'theme', + ])('hides bare /%s', (root) => { + expect(shouldHideSlashCommandInvocation(cmd(root), [root], '')).toBe(true); + }); + + it('keeps /theme visible under NO_COLOR (it prints feedback instead)', () => { + const prev = process.env['NO_COLOR']; + process.env['NO_COLOR'] = '1'; + try { + expect( + shouldHideSlashCommandInvocation(cmd('theme'), ['theme'], ''), + ).toBe(false); + } finally { + if (prev === undefined) delete process.env['NO_COLOR']; + else process.env['NO_COLOR'] = prev; + } + }); + + it.each([ + ['effort', ''], + ['statusline', ''], + ['model', ''], + ['model', '--fast'], + ['model', '--vision --global'], + ])('hides bare /%s %j (picker-only)', (root, args) => { + expect(shouldHideSlashCommandInvocation(cmd(root), [root], args)).toBe( + true, + ); + }); + + it.each([ + ['model', 'qwen-max'], + ['model', '--fast qwen3-coder-flash'], + ['effort', 'high'], + ['statusline', 'show'], + ])('keeps /%s %j (work-performing)', (root, args) => { + expect(shouldHideSlashCommandInvocation(cmd(root), [root], args)).toBe( + false, + ); + }); + + it('never hides non-builtin commands', () => { + expect( + shouldHideSlashCommandInvocation( + cmd('help', CommandKind.SKILL), + ['help'], + '', + ), + ).toBe(false); + expect(shouldHideSlashCommandInvocation(undefined, ['help'], '')).toBe( + false, + ); + }); +}); + +describe('canRunDuringStreaming (ink AppContainer fast path)', () => { + it('reports the command opt-in flag', () => { + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher(host, services, [ + stub({ name: 'help', canRunDuringStreaming: true }), + stub({ name: 'clear' }), + ]); + expect(dispatcher.canRunDuringStreaming('/help')).toBe(true); + expect(dispatcher.canRunDuringStreaming('/clear')).toBe(false); + expect(dispatcher.canRunDuringStreaming('not a command')).toBe(false); + }); +}); + +describe('startup-window registry self-heal', () => { + beforeEach(() => { + loadInteractiveCommandsMock.mockReset(); + }); + + const skillCommand = (): SlashCommand => + stub({ + name: 'qc-helper', + kind: CommandKind.SKILL, + action: () => ({ + type: 'message', + messageType: 'info', + content: 'expanded', + }), + }); + + // The startup race attaches the first dispatcher before + // config.initialize() finishes: the registry has the builtin commands + // but no skills, so /qc-helper fails to resolve. + const servicesWithSkillManager = (getSkillManager: () => object | null) => ({ + ...services, + config: { + getSkillManager, + } as unknown as Config, + }); + + it('reloads the registry when a command fails to resolve instead of reporting Unknown', async () => { + loadInteractiveCommandsMock.mockResolvedValue([skillCommand()]); + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + servicesWithSkillManager(() => ({})), + [stub({ name: 'help' })], + ); + + const outcome = await dispatcher.handle('/qc-helper fix the issue'); + + expect(outcome).toEqual({ kind: 'handled' }); + expect(loadInteractiveCommandsMock).toHaveBeenCalledTimes(1); + expect(host.items.at(-1)).toMatchObject({ + type: 'info', + text: 'expanded', + }); + }); + + it('a signal aborted before the action race skips the action entirely (R3-5)', async () => { + // ESC lands while the registry reload is still in flight: by the time + // the re-parse reaches the action race the signal is already aborted, + // and a late 'abort' listener never fires — the action's side effects + // must not run on the cancelled submission. + const action = vi.fn( + (): SlashCommandActionReturn => ({ + type: 'message', + messageType: 'info', + content: 'side effects ran', + }), + ); + let resolveLoad: (commands: SlashCommand[]) => void = () => {}; + loadInteractiveCommandsMock.mockReturnValue( + new Promise((resolve) => { + resolveLoad = resolve; + }), + ); + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + servicesWithSkillManager(() => ({})), + [stub({ name: 'help' })], + ); + + const pending = dispatcher.handle('/greet world'); + dispatcher.cancel(); + resolveLoad([stub({ name: 'greet', action })]); + const outcome = await pending; + + expect(outcome).toEqual({ kind: 'handled' }); + expect(action).not.toHaveBeenCalled(); + }); + + it('waits for the skill manager to appear before reloading the registry', async () => { + loadInteractiveCommandsMock.mockResolvedValue([skillCommand()]); + const host = createFakeHost(); + let skillManager: object | null = null; + const dispatcher = new OpenTuiSlashDispatcher( + host, + servicesWithSkillManager(() => skillManager), + [], + ); + + vi.useFakeTimers(); + try { + const pending = dispatcher.handle('/qc-helper wait'); + // While config.initialize() is still in flight, the reload must + // not run against the incomplete state. + await vi.advanceTimersByTimeAsync(1_000); + expect(loadInteractiveCommandsMock).not.toHaveBeenCalled(); + skillManager = {}; + await vi.advanceTimersByTimeAsync(1_000); + expect(await pending).toEqual({ kind: 'handled' }); + } finally { + vi.useRealTimers(); + } + expect(loadInteractiveCommandsMock).toHaveBeenCalledTimes(1); + }); + + it('retries once per dispatcher and reuses the reloaded registry', async () => { + loadInteractiveCommandsMock.mockResolvedValue([skillCommand()]); + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + servicesWithSkillManager(() => ({})), + [], + ); + + expect(await dispatcher.handle('/qc-helper one')).toEqual({ + kind: 'handled', + }); + // The second dispatch resolves from the reloaded registry: the + // startup retry is one-shot, so no further loader call happens. + expect(await dispatcher.handle('/qc-helper two')).toEqual({ + kind: 'handled', + }); + expect(loadInteractiveCommandsMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the Unknown message when there is no config to reload from', async () => { + const { outcome, host } = await dispatch('/nope', []); + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items.at(-1)).toMatchObject({ + type: 'error', + text: 'Unknown command: /nope', + }); + expect(loadInteractiveCommandsMock).not.toHaveBeenCalled(); + }); +}); + +describe('result mapping (all SlashCommandActionReturn kinds)', () => { + it('unknown commands produce the ink error message', async () => { + const { outcome, host } = await dispatch('/nope', []); + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items.at(-1)).toMatchObject({ + type: 'error', + text: 'Unknown command: /nope', + }); + }); + + it('message results become history items by messageType', async () => { + const commands = [ + stub({ + name: 'warn', + action: () => ({ + type: 'message', + messageType: 'warning', + content: 'careful', + }), + }), + ]; + const { outcome, host } = await dispatch('/warn', commands); + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items.at(-1)).toMatchObject({ + type: 'warning', + text: 'careful', + }); + }); + + it('reveals the hidden /model invocation when the command emits a message (ink revealHiddenInvocation parity)', async () => { + const commands = [ + stub({ + name: 'model', + // Bare `/model` is picker-only (hidden invocation), but the + // command can still reject its arguments / environment and return + // a message — the invocation echo must then appear paired with it. + action: () => ({ + type: 'message', + messageType: 'error', + content: 'bad model id', + }), + }), + ]; + const { outcome, host } = await dispatch('/model', commands); + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items).toHaveLength(2); + expect(host.items[0]).toMatchObject({ type: 'user', text: '/model' }); + expect(host.items[1]).toMatchObject({ + type: 'error', + text: 'bad model id', + }); + }); + + it('message results are recorded in the chat-recording output phase (ink parity)', async () => { + const recordSlashCommand = vi.fn(); + const config = { + getChatRecordingService: () => ({ recordSlashCommand }), + } as unknown as Config; + const commands = [ + stub({ + name: 'warn', + action: () => ({ + type: 'message', + messageType: 'warning', + content: 'careful', + }), + }), + ]; + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + commands, + ); + await dispatcher.handle('/warn'); + expect(recordSlashCommand).toHaveBeenCalledTimes(2); + const resultPhase = recordSlashCommand.mock.calls[1][0]; + expect(resultPhase.phase).toBe('result'); + expect(resultPhase.outputHistoryItems).toEqual([ + { type: 'warning', text: 'careful' }, + ]); + }); + + it('submit_prompt outcomes carry refreshContextFilesOnWrite (ink parity)', async () => { + const commands = [ + stub({ + name: 'memory-add', + action: () => ({ + type: 'submit_prompt', + content: 'remember this', + refreshContextFilesOnWrite: true, + }), + }), + stub({ + name: 'plain', + action: () => ({ + type: 'submit_prompt', + content: 'plain prompt', + }), + }), + ]; + const marked = await dispatch('/memory-add remember this', commands); + expect(marked.outcome).toMatchObject({ + kind: 'submit_prompt', + refreshContextFilesOnWrite: true, + }); + const unmarked = await dispatch('/plain', commands); + expect(unmarked.outcome).toMatchObject({ kind: 'submit_prompt' }); + expect( + (unmarked.outcome as { refreshContextFilesOnWrite?: boolean }) + .refreshContextFilesOnWrite, + ).toBeUndefined(); + }); + + it('replaces the vim toggle message with a faithful unsupported notice (G-11b)', async () => { + const commands = [ + stub({ + name: 'vim', + action: async (context) => { + const enabled = await context.ui.toggleVimEnabled(); + return { + type: 'message', + messageType: 'info', + content: enabled + ? 'Entered Vim mode. Run /vim again to exit.' + : 'Exited Vim mode.', + }; + }, + }), + ]; + // The host reports vim off (the renderer has no vim mode); without the + // override the ink message would misleadingly say "Exited Vim mode." + const { outcome, host } = await dispatch('/vim', commands, { + toggleVimEnabled: async () => false, + }); + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items.at(-1)).toMatchObject({ + type: 'info', + text: 'Vim mode is not yet available in the OpenTUI renderer.', + }); + }); + + it('parent commands without an action list subcommands (info)', async () => { + const commands = [ + stub({ + name: 'memory', + subCommands: [ + stub({ name: 'add', description: 'add memory' }), + stub({ name: 'show', description: 'show memory' }), + ], + }), + ]; + const { outcome, host } = await dispatch('/memory', commands); + expect(outcome).toEqual({ kind: 'handled' }); + const item = host.items.at(-1); + expect(item).toMatchObject({ type: 'info' }); + expect(item?.text).toContain("'/memory' requires a subcommand"); + }); + + it('dialog results route through the registry', async () => { + const commands = [ + stub({ + name: 'theme', + action: () => ({ type: 'dialog', dialog: 'theme' }), + }), + stub({ + name: 'model', + action: () => ({ + type: 'dialog', + dialog: 'fast-model', + persistScope: 'workspace', + }), + }), + stub({ + name: 'arena', + action: () => ({ type: 'dialog', dialog: 'arena_start' }), + }), + ]; + const theme = await dispatch('/theme', commands); + expect(theme.outcome).toEqual({ + kind: 'open_dialog', + request: { dialog: 'theme' }, + }); + const model = await dispatch('/model', commands); + expect(model.outcome).toEqual({ + kind: 'open_dialog', + request: { dialog: 'model', mode: 'fast', persistScope: 'workspace' }, + }); + const arena = await dispatch('/arena', commands); + expect(arena.outcome).toEqual({ + kind: 'open_dialog', + request: { dialog: 'arena', mode: 'start' }, + }); + }); + + it('/resume awaits handleResume', async () => { + const commands = [ + stub({ + name: 'resume', + action: () => ({ type: 'dialog', dialog: 'resume', sessionId: 's-9' }), + }), + stub({ + name: 'resume-picker', + action: () => ({ + type: 'dialog', + dialog: 'resume', + matchedSessions: [], + }), + }), + ]; + const resume = await dispatch('/resume', commands); + expect(resume.outcome).toEqual({ kind: 'handled' }); + expect(resume.host.resumedSessions).toEqual(['s-9']); + + const picker = await dispatch('/resume-picker', commands); + expect(picker.outcome).toEqual({ + kind: 'open_dialog', + request: { dialog: 'resume', matchedSessions: [] }, + }); + }); + + it('/branch awaits handleBranch', async () => { + // Gate is held closed. If dispatch properly awaits handleBranch, its promise + // stays pending. If dispatch uses void, it resolves immediately. + let resolveHandleBranch!: () => void; + const handleBranchGate = new Promise((res) => { + resolveHandleBranch = res; + }); + const branchNames: Array = []; + const host = createFakeHost(); + host.handleBranch = async (name) => { + await handleBranchGate; + branchNames.push(name); + }; + + const commands = [ + stub({ + name: 'branch', + action: () => ({ type: 'dialog', dialog: 'branch', name: 'wip' }), + }), + ]; + const dispatcher = new OpenTuiSlashDispatcher(host, services, commands); + + const handlePromise = dispatcher.handle('/branch'); + + // Race: if dispatch did NOT await handleBranch, it already resolved and wins. + // If dispatch IS awaiting, the race times out (undefined sentinel wins). + const sentinel = Symbol('pending'); + const raceResult = await Promise.race([ + handlePromise.then(() => 'resolved'), + Promise.resolve() + .then(() => Promise.resolve()) + .then(() => sentinel), + ]); + + // dispatch must still be pending (blocked on the gate) — not yet resolved. + expect(raceResult).toBe(sentinel); + + // Now unblock and let everything complete. + resolveHandleBranch(); + const outcome = await handlePromise; + expect(outcome).toEqual({ kind: 'handled' }); + expect(branchNames).toEqual(['wip']); + }); + + it('quit and tool results surface untouched', async () => { + const commands = [ + stub({ + name: 'quit', + action: () => ({ type: 'quit', messages: [] }), + }), + stub({ + name: 'github', + action: () => ({ + type: 'tool', + toolName: 'run_shell_command', + toolArgs: { command: 'gh auth' }, + }), + }), + ]; + const quit = await dispatch('/quit', commands); + expect(quit.outcome).toEqual({ kind: 'quit', messages: [] }); + const tool = await dispatch('/github', commands); + expect(tool.outcome).toEqual({ + kind: 'schedule_tool', + toolName: 'run_shell_command', + toolArgs: { command: 'gh auth' }, + }); + }); + + it('submit_prompt passes content, modelOverride and onComplete', async () => { + const onComplete = async () => {}; + const commands = [ + stub({ + name: 'skill', + action: () => ({ + type: 'submit_prompt', + content: [{ text: 'do it' }], + modelOverride: 'fast-model-x', + onComplete, + }), + }), + ]; + const { outcome, host } = await dispatch('/skill', commands); + expect(outcome).toEqual({ + kind: 'submit_prompt', + content: [{ text: 'do it' }], + modelOverride: 'fast-model-x', + onComplete, + }); + // Invocation item marked as sent to the model, like ink updateItem. + expect(host.updates).toEqual([{ id: 0, updates: { sentToModel: true } }]); + }); + + it('goal_control renders per the ink idle/cause rules', async () => { + const snapshotWithGoal = { + v: 2, + goal: { objective: 'x' }, + activity: 'idle', + }; + const statusCommand = stub({ + name: 'goal', + action: () => + ({ + type: 'goal_control', + operation: { kind: 'status' }, + response: { snapshot: snapshotWithGoal }, + }) as never, + }); + const { host: statusHost } = await dispatch('/goal', [statusCommand]); + expect(statusHost.items.at(-1)).toMatchObject({ + type: 'goal_state', + snapshot: snapshotWithGoal, + }); + + const busyCommand = stub({ + name: 'goal', + action: () => + ({ + type: 'goal_control', + operation: { kind: 'pause' }, + response: { snapshot: snapshotWithGoal }, + cause: 'user', + }) as never, + }); + const { host: busyHost } = await dispatch('/goal', [busyCommand], { + isIdle: () => false, + }); + expect(busyHost.items.some((item) => item.type === 'goal_state')).toBe( + false, + ); + + const noGoalCommand = stub({ + name: 'goal', + action: () => + ({ + type: 'goal_control', + operation: { kind: 'status' }, + response: { snapshot: { v: 2, goal: null, activity: 'idle' } }, + }) as never, + }); + const { host: noGoalHost } = await dispatch('/goal', [noGoalCommand]); + expect(noGoalHost.items.at(-1)).toMatchObject({ + type: 'info', + text: 'No Goal set.', + }); + }); + + it('load_history applies client history, clears, then re-adds items', async () => { + const setHistory = vi.fn(); + const config = { + getGeminiClient: () => ({ setHistory }), + } as unknown as Config; + const commands = [ + stub({ + name: 'restore', + action: () => ({ + type: 'load_history', + history: [{ type: 'info', text: 'restored' }], + clientHistory: [{ role: 'user', parts: [{ text: 'hi' }] }], + }), + }), + ]; + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + commands, + ); + const outcome = await dispatcher.handle('/restore'); + expect(outcome).toEqual({ kind: 'handled' }); + expect(setHistory).toHaveBeenCalledWith([ + { role: 'user', parts: [{ text: 'hi' }] }, + ]); + expect(host.calls).toContain('clearItems'); + expect(host.items.at(-1)).toMatchObject({ type: 'info', text: 'restored' }); + }); + + it('confirm_action: decline cancels, accept re-runs with overwriteConfirmed', async () => { + const seenContexts: Array = []; + let firstRun = true; + const commands = [ + stub({ + name: 'cd', + action: (context) => { + seenContexts.push(context.overwriteConfirmed); + if (firstRun) { + firstRun = false; + return { + type: 'confirm_action', + prompt: 'Overwrite?', + originalInvocation: { raw: '/cd /tmp' }, + }; + } + return { type: 'message', messageType: 'info', content: 'done' }; + }, + }), + ]; + + const decline = await dispatch('/cd /tmp', commands); + expect(decline.outcome).toEqual({ kind: 'handled' }); + expect(decline.host.actionConfirmations).toBe(1); + expect(decline.host.items.at(-1)).toMatchObject({ + type: 'info', + text: 'Operation cancelled.', + }); + + const host = createFakeHost(); + setActionConfirmation(host, true); + firstRun = true; + seenContexts.length = 0; + const dispatcher = new OpenTuiSlashDispatcher(host, services, commands); + const outcome = await dispatcher.handle('/cd /tmp'); + expect(outcome).toEqual({ kind: 'handled' }); + expect(seenContexts).toEqual([undefined, true]); + // No duplicate invocation echo on the recursive run. + expect(host.items.filter((item) => item.type === 'user')).toHaveLength(1); + }); + + it('confirm_shell_commands honors outcomes and one-time allowlists', async () => { + const seenAllowlists: Array> = []; + let firstRun = true; + const commands = [ + stub({ + name: 'cd', + action: (context) => { + seenAllowlists.push(new Set(context.session.sessionShellAllowlist)); + if (firstRun) { + firstRun = false; + return { + type: 'confirm_shell_commands', + commandsToConfirm: ['rm -rf /'], + originalInvocation: { raw: '/cd' }, + }; + } + return { type: 'message', messageType: 'info', content: 'ok' }; + }, + }), + ]; + + // Cancel → nothing re-runs. + const cancel = await dispatch('/cd', commands); + expect(cancel.outcome).toEqual({ kind: 'handled' }); + expect(cancel.host.shellConfirmations).toEqual([['rm -rf /']]); + + // ProceedOnce → re-run sees the approved commands once. + const host = createFakeHost(); + setShellResolution(host, { + outcome: ToolConfirmationOutcome.ProceedOnce, + approvedCommands: ['rm -rf /'], + }); + firstRun = true; + seenAllowlists.length = 0; + const dispatcher = new OpenTuiSlashDispatcher(host, services, commands); + await dispatcher.handle('/cd'); + expect(seenAllowlists.map((set) => [...set])).toEqual([[], ['rm -rf /']]); + expect(host.allowlistAdds).toEqual([]); + + // ProceedAlways → the session allowlist grows persistently. + const alwaysHost = createFakeHost(); + setShellResolution(alwaysHost, { + outcome: ToolConfirmationOutcome.ProceedAlways, + approvedCommands: ['ls'], + }); + firstRun = true; + const alwaysDispatcher = new OpenTuiSlashDispatcher( + alwaysHost, + services, + commands, + ); + await alwaysDispatcher.handle('/cd'); + expect(alwaysHost.allowlistAdds).toEqual([['ls']]); + expect(alwaysHost.sessionShellAllowlist.has('ls')).toBe(true); + }); + + it('stream_messages is rejected in interactive mode', async () => { + const commands = [ + stub({ + name: 'compress', + action: () => ({ + type: 'stream_messages', + messages: (async function* () {})(), + }), + }), + ]; + const { outcome, host } = await dispatch('/compress', commands); + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items.at(-1)).toMatchObject({ + type: 'error', + text: 'stream_messages result type is not supported in interactive mode', + }); + }); + + it('thrown actions produce the error text as an item', async () => { + const commands = [ + stub({ + name: 'boom', + action: () => { + throw new Error('kaboom'); + }, + }), + ]; + const { outcome, host } = await dispatch('/boom', commands); + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items.at(-1)).toMatchObject({ type: 'error', text: 'kaboom' }); + }); +}); + +describe('stacked skills (ink merge parity)', () => { + function skillStub(name: string): SlashCommand { + return stub({ + name, + kind: CommandKind.SKILL, + description: `skill ${name}`, + action: () => ({ + type: 'submit_prompt', + content: [{ text: `${name} content ` }], + }), + }); + } + + it('merges multiple skills plus trailing text into one submission', async () => { + const commands = [skillStub('alpha'), skillStub('beta')]; + const { outcome, host } = await dispatch( + '/alpha /beta do the thing', + commands, + ); + if (outcome === false || outcome.kind !== 'submit_prompt') { + throw new Error(`expected submit_prompt, got ${String(outcome)}`); + } + expect(outcome.content).toEqual([ + { text: 'alpha content ' }, + { text: 'beta content ' }, + { text: 'do the thing' }, + ]); + expect(host.updates).toEqual([{ id: 0, updates: { sentToModel: true } }]); + }); +}); + +describe('cancellation, telemetry and recording', () => { + beforeEach(() => { + logSlashCommandSpy.mockClear(); + }); + + it('cancel() aborts the action and reports like ink', async () => { + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher(host, services, [ + stub({ + name: 'slow', + action: () => + new Promise(() => { + // Never resolves; cancellation must unblock. + }), + }), + ]); + const pending = dispatcher.handle('/slow'); + await new Promise((resolve) => setTimeout(resolve, 10)); + dispatcher.cancel(); + const outcome = await pending; + expect(outcome).toEqual({ kind: 'handled' }); + expect(host.items.some((item) => item.text === 'Command cancelled.')).toBe( + true, + ); + expect(host.processingFlags.at(-1)).toBe(false); + }); + + it('logs SUCCESS/ERROR slash-command telemetry like ink', async () => { + const config = { + getChatRecordingService: () => undefined, + } as unknown as Config; + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + [ + stub({ + name: 'greet', + action: () => ({ + type: 'message', + messageType: 'info', + content: 'hi', + }), + }), + stub({ + name: 'boom', + action: () => { + throw new Error('x'); + }, + }), + ], + ); + await dispatcher.handle('/greet'); + expect(logSlashCommandSpy).toHaveBeenCalledTimes(1); + expect(logSlashCommandSpy.mock.calls[0][1]).toMatchObject({ + command: 'greet', + status: SlashCommandStatus.SUCCESS, + }); + + await dispatcher.handle('/boom'); + expect(logSlashCommandSpy).toHaveBeenCalledTimes(2); + expect(logSlashCommandSpy.mock.calls[1][1]).toMatchObject({ + command: 'boom', + status: SlashCommandStatus.ERROR, + }); + }); + + it('records invocations + output items, honoring the skip list', async () => { + const recordSlashCommand = vi.fn(); + const config = { + getChatRecordingService: () => ({ recordSlashCommand }), + } as unknown as Config; + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + [ + stub({ + name: 'greet', + action: (context) => { + context.ui.addItem({ type: 'info', text: 'from action' }, 1); + return undefined; + }, + }), + stub({ + name: 'clear', + altNames: ['reset', 'new'], + action: () => ({ + type: 'message', + messageType: 'info', + content: 'cleared', + }), + }), + ], + ); + + await dispatcher.handle('/greet'); + expect(recordSlashCommand).toHaveBeenCalledTimes(2); + expect(recordSlashCommand.mock.calls[0][0]).toEqual({ + phase: 'invocation', + rawCommand: '/greet', + sentToModel: false, + hiddenInvocation: false, + }); + const resultPhase = recordSlashCommand.mock.calls[1][0]; + expect(resultPhase.phase).toBe('result'); + expect(resultPhase.outputHistoryItems).toEqual([ + { type: 'info', text: 'from action' }, + ]); + + recordSlashCommand.mockClear(); + await dispatcher.handle('/clear'); + expect(recordSlashCommand).not.toHaveBeenCalled(); + }); + + it('records hiddenInvocation=true for bare picker invocations (ink parity)', async () => { + const recordSlashCommand = vi.fn(); + const config = { + getChatRecordingService: () => ({ recordSlashCommand }), + } as unknown as Config; + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + [ + stub({ + name: 'settings', + action: () => undefined, + }), + ], + ); + + await dispatcher.handle('/settings'); + expect(recordSlashCommand).toHaveBeenCalledTimes(2); + expect(recordSlashCommand.mock.calls[0][0]).toEqual({ + phase: 'invocation', + rawCommand: '/settings', + sentToModel: false, + hiddenInvocation: true, + }); + // The hidden invocation never echoed, so the result phase has no output. + expect(recordSlashCommand.mock.calls[1][0].outputHistoryItems).toEqual([]); + }); + + it('skips recording for the built-in /advisor by identity (ink parity)', async () => { + const recordSlashCommand = vi.fn(); + const config = { + getChatRecordingService: () => ({ recordSlashCommand }), + } as unknown as Config; + const host = createFakeHost(); + const advisorAction = (): SlashCommandActionReturn => ({ + type: 'message', + messageType: 'info', + content: 'advisor says hi', + }); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, config }, + [ + stub({ name: 'advisor', action: advisorAction }), + // A user-defined command shadowing the name is NOT the built-in and + // must still be recorded. + { + ...stub({ name: 'advisor', action: advisorAction }), + kind: CommandKind.SKILL, + }, + ], + ); + + await dispatcher.handle('/advisor'); + expect(recordSlashCommand).not.toHaveBeenCalled(); + + // Re-dispatch through the non-built-in shadow (remove the built-in from + // the registry by dispatching with only the shadow installed). + recordSlashCommand.mockClear(); + const shadowOnly = new OpenTuiSlashDispatcher( + createFakeHost(), + { ...services, config }, + [ + { + ...stub({ name: 'advisor', action: advisorAction }), + kind: CommandKind.SKILL, + }, + ], + ); + await shadowOnly.handle('/advisor'); + expect(recordSlashCommand).toHaveBeenCalled(); + }); +}); + +describe('extension refresh subscription (ink processor parity)', () => { + it('subscribes to the shared ExtensionRefreshState and surfaces reload notices', () => { + const extensionRefreshState = new ExtensionRefreshState(); + const host = createFakeHost(); + const dispatcher = new OpenTuiSlashDispatcher( + host, + { ...services, extensionRefreshState }, + [], + ); + + extensionRefreshState.markExtensionsChanged(); + expect( + host.items.some( + (item) => + item.type === 'info' && + item.text === + 'Extensions changed on disk. Run /reload-plugins to apply updates.', + ), + ).toBe(true); + + extensionRefreshState.markExtensionsReloadFailed(); + expect( + host.items.some( + (item) => + item.type === 'info' && + item.text === + 'Extension reload did not complete. Run /reload-plugins to try again.', + ), + ).toBe(true); + + dispatcher.dispose(); + extensionRefreshState.resetForTesting(); + const itemsAfterDispose = host.items.length; + extensionRefreshState.markExtensionsChanged(); + expect(host.items.length).toBe(itemsAfterDispose); + }); +}); diff --git a/packages/cli/src/ui/opentui/commands-dispatch.ts b/packages/cli/src/ui/opentui/commands-dispatch.ts new file mode 100644 index 00000000000..8a7757839aa --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-dispatch.ts @@ -0,0 +1,1004 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Full-parity slash-command dispatch for the OpenTUI renderer (PR1 slice 5). + * + * `OpenTuiSlashDispatcher.handle` reproduces the ink + * `useSlashCommandProcessor.handleSlashCommand` pipeline end to end against + * the OpenTUI backend (`OpenTuiCommandHost`): + * + * - the same guards ('/' or '?' prefix, no path-like input) + * - the same invocation echo item (skipped for /btw) + * - stacked skill handling (`/a /b prompt` merged into one submit) + * - ESC cancellation via an AbortController raced against the action + * - identical result mapping for every `SlashCommandActionReturn` kind: + * messages, all dialogs (routed by commands-registry.ts), quit, tool + * scheduling, load_history, submit_prompt (with user-prompt-expansion + * hooks), goal_control render rules, and both confirmation flows with + * their recursive re-invocation + * - identical telemetry (logSlashCommand) and chat-recording behavior, + * including the skip list and the recording-aware addItem wrapper + * + * The result is a neutral `OpenTuiDispatchOutcome` the OpenTUI backend + * applies, where ink returned `SlashCommandProcessorResult` to AppContainer. + */ + +import type { PartListUnion } from '@google/genai'; +import { + createDebugLogger, + logSlashCommand, + makeSlashCommandEvent, + recordSkillInvocation, + SlashCommandStatus, + ToolConfirmationOutcome, +} from '@qwen-code/qwen-code-core'; +import { + MessageType, + type HistoryItem, + type HistoryItemWithoutId, + type Message, +} from '../types.js'; +import { + CommandKind, + type CommandContext, + type SlashCommand, +} from '../commands/types.js'; +import { + MAX_STACKED_SKILLS, + parseSlashCommand, + parseStackedSlashCommands, +} from '../commands/commands.js'; +import { + hasSlashCommandPathSeparator, + isBtwCommand, +} from '../utils/commandUtils.js'; +import { recordAutoSkillCommandUsage } from '../../services/SkillCommandLoader.js'; +import { + ExtensionRefreshState, + EXTENSION_RELOAD_FAILED_REASON, +} from '../../config/extension-refresh-state.js'; +import { AppEvent } from '../../utils/events.js'; +import { refreshExtensionContentRuntime } from '../../config/extension-runtime-reload.js'; +import { isPickerOnlyModelInvocation } from '../commands/modelCommand.js'; +import { + appendUserPromptExpansionAdditionalContext, + formatUserPromptExpansionBlockedMessage, + serializeUserPromptExpansionPrompt, +} from '../../utils/userPromptExpansionHook.js'; +import type { RecentSlashCommand } from '../hooks/useSlashCompletion.js'; +import { loadInteractiveCommands } from './slash-dispatch.js'; +import { + createOpenTuiCommandContext, + type OpenTuiCommandHost, + type OpenTuiCommandServices, +} from './commands-context.js'; +import { + routeDialogToOpenTui, + type OpenTuiDialogRequest, +} from './commands-registry.js'; +import { + commandMessageItem, + messageToHistoryItem, + serializeHistoryItemForRecording, + SLASH_COMMANDS_SKIP_RECORDING, +} from './commands-output.js'; + +const debugLogger = createDebugLogger('OPENTUI_SLASH_DISPATCH'); + +/** + * Parity of the same-named sets and helper in slashCommandProcessor.ts: + * commands whose bare invocation just opens a dialog (rather than + * performing work) keep their echo out of the transcript. + */ +const SLASH_COMMAND_ROOTS_HIDE_INVOCATION = new Set([ + 'auth', + 'diff', + 'editor', + 'help', + 'settings', + 'status', + 'stats', + 'theme', +]); +const BARE_SLASH_COMMANDS_HIDE_INVOCATION = new Set([ + 'effort', + 'model', + 'statusline', +]); + +export function shouldHideSlashCommandInvocation( + command: SlashCommand | undefined, + canonicalPath: string[], + args: string, +): boolean { + if (command?.kind !== CommandKind.BUILT_IN) { + return false; + } + + // Bare-root match only: subcommands that produce output (e.g. `/status + // paths`) keep their invocation like any other work-performing command. + if ( + canonicalPath.length === 1 && + SLASH_COMMAND_ROOTS_HIDE_INVOCATION.has(canonicalPath[0] ?? '') + ) { + // NO_COLOR prevents the theme dialog from opening, so /theme prints + // feedback instead and keeps its invocation like any work-performing + // command. + if (canonicalPath[0] === 'theme' && process.env['NO_COLOR']) { + return false; + } + return true; + } + + const path = canonicalPath.join(' '); + if (BARE_SLASH_COMMANDS_HIDE_INVOCATION.has(path)) { + if (path === 'model') { + return isPickerOnlyModelInvocation(args); + } + return args.trim() === ''; + } + + return false; +} + +/** Neutral outcome the OpenTUI backend applies (ink: SlashCommandProcessorResult + dialog actions). */ +export type OpenTuiDispatchOutcome = + | { kind: 'handled' } + | { + kind: 'schedule_tool'; + toolName: string; + toolArgs: Record; + } + | { + kind: 'submit_prompt'; + content: PartListUnion; + onComplete?: () => Promise; + modelOverride?: string; + /** ink parity: refresh memory when the turn writes a context file. */ + refreshContextFilesOnWrite?: boolean; + } + | { kind: 'quit'; messages: HistoryItem[] } + | { kind: 'open_dialog'; request: OpenTuiDialogRequest }; + +interface RunOptions { + oneTimeShellAllowlist?: Set; + overwriteConfirmed?: boolean; + existingInvocationItemId?: number; +} + +/** + * Parity of `hasUserPromptExpansionHooks` in slashCommandProcessor.ts. + */ +function hasUserPromptExpansionHooks( + services: OpenTuiCommandServices, +): boolean { + const config = services.config; + return ( + !!config && + !config.getDisableAllHooks?.() && + (config.hasHooksForEvent?.('UserPromptExpansion') ?? false) + ); +} + +const MAX_EXTENSION_CONTENT_REFRESH_PASSES = 5; +// How long the dispatch retry waits for the skill manager (created inside +// config.initialize()) before reloading the registry. Bounded so a config +// that never finishes initializing degrades to the "Unknown command" +// message instead of a stuck prompt. +const STARTUP_REGISTRY_WAIT_MS = 15_000; +const STARTUP_REGISTRY_POLL_MS = 100; + +export class OpenTuiSlashDispatcher { + private activeAbortController: AbortController | null = null; + private recentCommands = new Map(); + private readonly extensionRefreshState: ExtensionRefreshState; + private readonly extensionRefreshListeners: Array<() => void> = []; + private extensionContentRefreshTimer: ReturnType | null = + null; + private extensionContentRefreshRunning = false; + private extensionContentRefreshPending = false; + private startupRetryUsed = false; + + constructor( + private readonly host: OpenTuiCommandHost, + private readonly services: OpenTuiCommandServices, + private commandList: readonly SlashCommand[], + ) { + // ink parity: the slash processor subscribes to the shared + // ExtensionRefreshState (created once in gemini.tsx and also driving the + // extension file watcher) so /reload-plugins and disk-driven reload + // notices reach this renderer too. Without the shared instance every + // dispatch would build a fresh fallback no watcher ever sees. + this.extensionRefreshState = + services.extensionRefreshState ?? new ExtensionRefreshState(); + this.subscribeToExtensionRefresh(); + } + + private subscribeToExtensionRefresh(): void { + const refreshNeededListener = (reason?: unknown) => { + this.host.addItem( + { + type: MessageType.INFO, + text: + reason === EXTENSION_RELOAD_FAILED_REASON + ? 'Extension reload did not complete. Run /reload-plugins to try again.' + : 'Extensions changed on disk. Run /reload-plugins to apply updates.', + }, + Date.now(), + ); + }; + this.extensionRefreshState.on( + AppEvent.ExtensionRefreshNeeded, + refreshNeededListener, + ); + this.extensionRefreshListeners.push(() => { + this.extensionRefreshState.off( + AppEvent.ExtensionRefreshNeeded, + refreshNeededListener, + ); + }); + + // ink's processor debounce ExtensionContentChanged by 250ms and then + // re-runs the runtime refresh (command registry + extension content). + const contentChangedListener = () => { + if (this.extensionContentRefreshTimer) { + clearTimeout(this.extensionContentRefreshTimer); + } + this.extensionContentRefreshTimer = setTimeout(() => { + this.extensionContentRefreshTimer = null; + void this.runExtensionContentRefresh(); + }, 250); + }; + this.extensionRefreshState.on( + AppEvent.ExtensionContentChanged, + contentChangedListener, + ); + this.extensionRefreshListeners.push(() => { + this.extensionRefreshState.off( + AppEvent.ExtensionContentChanged, + contentChangedListener, + ); + }); + } + + private async runExtensionContentRefresh(): Promise { + const config = this.services.config; + if (!config) return; + if (this.extensionContentRefreshRunning) { + this.extensionContentRefreshPending = true; + return; + } + this.extensionContentRefreshRunning = true; + let refreshPasses = 0; + try { + do { + if (refreshPasses >= MAX_EXTENSION_CONTENT_REFRESH_PASSES) { + this.extensionContentRefreshPending = false; + this.host.addItem( + { + type: MessageType.ERROR, + text: 'Failed to refresh extension content: too many extension content changes are still pending. Run /reload-plugins to apply updates.', + }, + Date.now(), + ); + return; + } + refreshPasses++; + this.extensionContentRefreshPending = false; + if (this.extensionRefreshState.isReloadInProgress()) return; + if (this.extensionRefreshState.needsExtensionRefresh()) return; + await refreshExtensionContentRuntime({ + config, + reloadCommands: () => this.loadCommands(), + }); + } while (this.extensionContentRefreshPending); + } catch { + this.extensionContentRefreshPending = false; + this.host.addItem( + { + type: MessageType.ERROR, + text: 'Failed to refresh extension content. Run /reload-plugins to apply updates.', + }, + Date.now(), + ); + } finally { + this.extensionContentRefreshRunning = false; + } + } + + /** Detaches the extension-refresh subscriptions (backend unmount). */ + dispose(): void { + for (const off of this.extensionRefreshListeners) off(); + this.extensionRefreshListeners.length = 0; + if (this.extensionContentRefreshTimer) { + clearTimeout(this.extensionContentRefreshTimer); + this.extensionContentRefreshTimer = null; + } + } + + get commands(): readonly SlashCommand[] { + return this.commandList; + } + + /** Parity of the processor's `reloadCommands` result swap. */ + setCommands(commands: readonly SlashCommand[]): void { + this.commandList = commands; + } + + /** Rebuilds the registry through the original loader stack. */ + async loadCommands(signal?: AbortSignal): Promise { + this.commandList = await loadInteractiveCommands( + this.services.config, + signal, + this.services.settings, + ); + } + + /** + * Startup-window self-heal: the first dispatcher can attach a registry + * built while config.initialize() was still in flight — the second + * initialize() call throws "already initialized", the catch proceeds, and + * the skill loaders run before the skill manager exists, so builtin + * commands resolve but every skill (e.g. /qc-helper) reports "Unknown + * command" until the config-ready dispatcher replaces this one. One + * bounded retry per dispatcher lifetime: wait for the skill manager, then + * reload the registry so the re-parse sees the complete list. + */ + private async ensureCommandsLoaded(): Promise { + if (this.startupRetryUsed || !this.services.config) { + return false; + } + this.startupRetryUsed = true; + const config = this.services.config; + const deadline = Date.now() + STARTUP_REGISTRY_WAIT_MS; + while (!config.getSkillManager?.() && Date.now() < deadline) { + await new Promise((resolve) => + setTimeout(resolve, STARTUP_REGISTRY_POLL_MS), + ); + } + try { + await this.loadCommands(); + } catch { + // Keep the current registry; the caller re-parses and reports + // "Unknown command" if the command really doesn't exist. + } + return true; + } + + /** Parity of `recentSlashCommands` (hidden commands are not tracked). */ + get recentCommandList(): ReadonlyMap { + return this.recentCommands; + } + + /** + * Whether the command in `text` opted into running while a model turn + * streams (ink AppContainer's canRunDuringStreaming fast path). + */ + canRunDuringStreaming(text: string): boolean { + const { commandToExecute } = parseSlashCommand( + text.trim(), + this.commandList, + ); + return commandToExecute?.canRunDuringStreaming === true; + } + + /** + * Parity of `cancelSlashCommand` in slashCommandProcessor.ts: ESC while a + * slash command runs aborts it and reports the cancellation. + */ + cancel(): void { + this.host.cancelBtw(); + if (!this.activeAbortController) { + return; + } + this.activeAbortController.abort(); + this.host.addItem( + { type: MessageType.INFO, text: 'Command cancelled.' }, + Date.now(), + ); + this.host.setPendingItem(null); + this.host.setIsProcessing(false); + } + + /** + * Entry point — parity of the top of `handleSlashCommand`: returns `false` + * when the input is not a slash command at all. + */ + async handle(rawQuery: string): Promise { + const trimmed = rawQuery.trim(); + if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) { + return false; + } + if (trimmed.startsWith('/') && hasSlashCommandPathSeparator(trimmed)) { + return false; + } + return this.run(trimmed, {}); + } + + private addMessage(message: Message): void { + this.host.addItem( + messageToHistoryItem(message), + message.timestamp.getTime(), + ); + } + + private async run( + trimmed: string, + options: RunOptions, + ): Promise { + const recordedItems: HistoryItemWithoutId[] = []; + const addItemWithRecording = ( + item: HistoryItemWithoutId, + timestamp: number, + ): number => { + recordedItems.push(item); + return this.host.addItem(item, timestamp); + }; + + this.host.setIsProcessing(true); + const abortController = new AbortController(); + this.activeAbortController = abortController; + + const userMessageTimestamp = Date.now(); + let invocationItemId = options.existingInvocationItemId; + let invocationSentToModel = false; + let { + commandToExecute, + args, + canonicalPath: resolvedCommandPath, + } = parseSlashCommand(trimmed, this.commandList); + if (!commandToExecute && (await this.ensureCommandsLoaded())) { + ({ + commandToExecute, + args, + canonicalPath: resolvedCommandPath, + } = parseSlashCommand(trimmed, this.commandList)); + } + let hideInvocation = + isBtwCommand(trimmed) || + shouldHideSlashCommandInvocation( + commandToExecute, + resolvedCommandPath, + args, + ); + if (!hideInvocation && invocationItemId === undefined) { + invocationItemId = addItemWithRecording( + { type: MessageType.USER, text: trimmed, sentToModel: false }, + userMessageTimestamp, + ); + } + + // ink parity: a picker-shaped command that rejects its arguments before + // opening a dialog (e.g. `/model` with bad args) still owes the user the + // invocation echo — otherwise the error message floats context-less. + const revealHiddenInvocation = () => { + if ( + resolvedCommandPath.join(' ') !== 'model' || + !hideInvocation || + invocationItemId !== undefined + ) { + return; + } + hideInvocation = false; + invocationItemId = addItemWithRecording( + { type: MessageType.USER, text: trimmed, sentToModel: false }, + userMessageTimestamp, + ); + }; + + let hasError = false; + let delegatedToRecursiveInvocation = false; + + const subcommand = + resolvedCommandPath.length > 1 + ? resolvedCommandPath.slice(1).join(' ') + : undefined; + const isSkillCommand = commandToExecute?.kind === CommandKind.SKILL; + let skillInvocationRecorded = false; + const recordSkillCommandInvocation = (success: boolean) => { + const config = this.services.config; + if ( + !config || + !commandToExecute || + !isSkillCommand || + skillInvocationRecorded + ) { + return; + } + recordSkillInvocation(config, { + skillName: commandToExecute.skillDetail?.name ?? commandToExecute.name, + success, + }); + skillInvocationRecorded = true; + }; + + try { + const stackedResult = parseStackedSlashCommands( + trimmed, + this.commandList, + ); + if (stackedResult.skills.length >= 2) { + const combinedContent: PartListUnion[] = []; + let firstModelOverride: string | undefined; + const onCompleteCallbacks: Array<() => Promise> = []; + let refreshContextFilesOnWrite = false; + + for (const skill of stackedResult.skills) { + if (!skill.action) continue; + const skillContext: CommandContext = { + invocation: { + raw: `/${skill.name}`, + name: skill.name, + args: '', + }, + services: { + config: this.services.config, + settings: this.services.settings, + logger: null, + }, + } as unknown as CommandContext; + + const skillResult = await skill.action(skillContext, ''); + if (skillResult?.type === 'submit_prompt') { + combinedContent.push(skillResult.content); + firstModelOverride ??= skillResult.modelOverride; + refreshContextFilesOnWrite ||= Boolean( + skillResult.refreshContextFilesOnWrite, + ); + if (skillResult.onComplete) { + onCompleteCallbacks.push(skillResult.onComplete); + } + } else if ( + skillResult?.type === 'message' && + skillResult.messageType === 'error' + ) { + this.addMessage({ + type: MessageType.ERROR, + content: `Skill "/${skill.name}" error: ${skillResult.content}`, + timestamp: new Date(), + }); + } + + if (this.services.config) { + const succeeded = skillResult?.type === 'submit_prompt'; + recordSkillInvocation(this.services.config, { + skillName: skill.skillDetail?.name ?? skill.name, + success: succeeded, + }); + if (succeeded) { + void recordAutoSkillCommandUsage(this.services.config, skill); + } + } + } + + if (stackedResult.remainingText) { + combinedContent.push([{ text: stackedResult.remainingText }]); + } + + if (stackedResult.exceededMax) { + this.addMessage({ + type: MessageType.WARNING, + content: `Only the first ${MAX_STACKED_SKILLS} skills were loaded. Additional /skill tokens were treated as prompt text.`, + timestamp: new Date(), + }); + } + + invocationSentToModel = true; + if (invocationItemId !== undefined) { + this.host.updateItem(invocationItemId, { sentToModel: true }); + } + + const mergedContent: PartListUnion = combinedContent.flat(); + return { + kind: 'submit_prompt', + content: mergedContent, + ...(firstModelOverride ? { modelOverride: firstModelOverride } : {}), + ...(refreshContextFilesOnWrite + ? { refreshContextFilesOnWrite: true } + : {}), + ...(onCompleteCallbacks.length + ? { + onComplete: async () => { + for (const cb of onCompleteCallbacks) await cb(); + }, + } + : {}), + }; + } + + if (commandToExecute) { + if (!commandToExecute.hidden) { + const existing = this.recentCommands.get(commandToExecute.name); + this.recentCommands.set(commandToExecute.name, { + name: commandToExecute.name, + usedAt: Date.now(), + count: (existing?.count ?? 0) + 1, + }); + } + + if (commandToExecute.action) { + const baseContext = createOpenTuiCommandContext( + this.host, + this.services, + ); + const fullCommandContext: CommandContext = { + ...baseContext, + ui: { + ...baseContext.ui, + addItem: (item, timestamp) => + addItemWithRecording(item, timestamp), + }, + invocation: { + raw: trimmed, + name: commandToExecute.name, + args, + }, + overwriteConfirmed: options.overwriteConfirmed, + abortSignal: abortController.signal, + }; + + // Parity: a "Proceed" confirmation temporarily augments the session + // allowlist for this single execution only. + const sessionShellAllowlist = + options.oneTimeShellAllowlist && + options.oneTimeShellAllowlist.size > 0 + ? new Set([ + ...fullCommandContext.session.sessionShellAllowlist, + ...options.oneTimeShellAllowlist, + ]) + : fullCommandContext.session.sessionShellAllowlist; + fullCommandContext.session = { + ...fullCommandContext.session, + sessionShellAllowlist, + }; + + const abortPromise = new Promise((resolve) => { + abortController.signal.addEventListener( + 'abort', + () => resolve(undefined), + { once: true }, + ); + }); + // A pre-aborted signal must skip the action entirely: an 'abort' + // listener registered after the signal already aborted never + // fires, so the race would otherwise await the action's side + // effects (session rotation, telemetry reset) before discarding. + const result = abortController.signal.aborted + ? undefined + : await Promise.race([ + commandToExecute.action(fullCommandContext, args), + abortPromise, + ]); + + if (abortController.signal.aborted) { + return { kind: 'handled' }; + } + + if (result) { + switch (result.type) { + case 'tool': + return { + kind: 'schedule_tool', + toolName: result.toolName, + toolArgs: result.toolArgs, + }; + case 'message': { + let messageContent = result.content; + // The OpenTUI renderer has no vim key mode; the toggle host + // reports the real (off) state, and the ink command would + // render that as "Exited Vim mode." — actively misleading. + // Replace it with the faithful notice (audit 01 G-11b). + if (commandToExecute.name === 'vim') { + messageContent = + 'Vim mode is not yet available in the OpenTUI renderer.'; + } + // Picker-shaped commands can still reject their arguments + // before opening a dialog. Keep those failures paired with + // the invocation in both live and reconstructed history, and + // route the message through addItemWithRecording so the + // chat-recording output phase sees it (ink parity). + revealHiddenInvocation(); + const messageType = + result.messageType === 'info' + ? MessageType.INFO + : result.messageType === 'warning' + ? MessageType.WARNING + : MessageType.ERROR; + addItemWithRecording( + { type: messageType, text: messageContent }, + Date.now(), + ); + return { kind: 'handled' }; + } + case 'goal_control': { + const rendersHere = + result.cause === undefined || this.host.isIdle(); + if (rendersHere) { + const snapshot = result.response.snapshot; + if (snapshot.goal || result.cause === 'clear') { + this.host.addItem( + { + type: MessageType.GOAL_STATE, + snapshot, + ...(result.cause ? { cause: result.cause } : {}), + }, + Date.now(), + ); + } else { + this.addMessage({ + type: MessageType.INFO, + content: 'No Goal set.', + timestamp: new Date(), + }); + } + } + return { kind: 'handled' }; + } + case 'dialog': { + if (result.dialog === 'resume') { + if (result.sessionId) { + await this.host.handleResume(result.sessionId); + return { kind: 'handled' }; + } + } + if (result.dialog === 'branch') { + await this.host.handleBranch(result.name); + return { kind: 'handled' }; + } + return { + kind: 'open_dialog', + request: routeDialogToOpenTui(result), + }; + } + case 'load_history': { + this.services.config + ?.getGeminiClient() + ?.setHistory(result.clientHistory); + fullCommandContext.ui.clear(); + const now = Date.now(); + result.history.forEach((item, index) => { + fullCommandContext.ui.addItem(item, now + index); + }); + return { kind: 'handled' }; + } + case 'quit': + return { kind: 'quit', messages: result.messages }; + case 'submit_prompt': { + const invocation = fullCommandContext.invocation; + let content = result.content; + const output = hasUserPromptExpansionHooks(this.services) + ? await this.services.config + ?.getHookSystem() + ?.fireUserPromptExpansionEvent( + invocation?.name ?? '', + invocation?.args ?? '', + serializeUserPromptExpansionPrompt(content), + abortController.signal, + ) + : undefined; + if (abortController.signal.aborted) { + hasError = true; + return { kind: 'handled' }; + } + if (output) { + const blockingError = output.getBlockingError(); + if (blockingError.blocked || output.shouldStopExecution()) { + hasError = true; + recordSkillCommandInvocation(false); + this.addMessage({ + type: MessageType.ERROR, + content: formatUserPromptExpansionBlockedMessage( + blockingError.reason || output.getEffectiveReason(), + ), + timestamp: new Date(), + }); + return { kind: 'handled' }; + } + content = appendUserPromptExpansionAdditionalContext( + content, + output.getAdditionalContext(), + ); + } + if (invocationItemId !== undefined) { + invocationSentToModel = true; + this.host.updateItem(invocationItemId, { sentToModel: true }); + } + recordSkillCommandInvocation(true); + void recordAutoSkillCommandUsage( + this.services.config, + commandToExecute, + ); + return { + kind: 'submit_prompt', + content, + ...(result.onComplete + ? { onComplete: result.onComplete } + : {}), + ...(result.modelOverride + ? { modelOverride: result.modelOverride } + : {}), + ...(result.refreshContextFilesOnWrite + ? { refreshContextFilesOnWrite: true } + : {}), + }; + } + case 'confirm_shell_commands': { + const { outcome, approvedCommands } = + await this.host.presentShellConfirmation( + result.commandsToConfirm, + ); + + if ( + outcome === ToolConfirmationOutcome.Cancel || + !approvedCommands || + approvedCommands.length === 0 + ) { + return { kind: 'handled' }; + } + + if (outcome === ToolConfirmationOutcome.ProceedAlways) { + this.host.addSessionShellAllowlist(approvedCommands); + } + + delegatedToRecursiveInvocation = true; + return await this.run(result.originalInvocation.raw, { + // Approved commands are a one-time grant for this execution. + oneTimeShellAllowlist: new Set(approvedCommands), + existingInvocationItemId: invocationItemId, + }); + } + case 'confirm_action': { + const confirmed = await this.host.presentActionConfirmation( + result.prompt, + ); + + if (!confirmed) { + addItemWithRecording( + commandMessageItem('info', 'Operation cancelled.'), + Date.now(), + ); + return { kind: 'handled' }; + } + + delegatedToRecursiveInvocation = true; + return await this.run(result.originalInvocation.raw, { + overwriteConfirmed: true, + existingInvocationItemId: invocationItemId, + }); + } + case 'stream_messages': { + // stream_messages is only used in ACP/Zed integration mode + // and should not be returned in interactive UI mode + throw new Error( + 'stream_messages result type is not supported in interactive mode', + ); + } + default: { + const unhandled: never = result; + throw new Error(`Unhandled slash command result: ${unhandled}`); + } + } + } + + return { kind: 'handled' }; + } else if (commandToExecute.subCommands) { + const helpText = `Command '/${commandToExecute.name}' requires a subcommand. Available:\n${commandToExecute.subCommands + .map((sc) => ` - ${sc.name}: ${sc.description || ''}`) + .join('\n')}`; + this.addMessage({ + type: MessageType.INFO, + content: helpText, + timestamp: new Date(), + }); + return { kind: 'handled' }; + } + } + + this.addMessage({ + type: MessageType.ERROR, + content: `Unknown command: ${trimmed}`, + timestamp: new Date(), + }); + + return { kind: 'handled' }; + } catch (e: unknown) { + // If cancelled via ESC, `cancel` already handled cleanup + if (abortController.signal.aborted) { + return { kind: 'handled' }; + } + hasError = true; + recordSkillCommandInvocation(false); + if (this.services.config) { + const event = makeSlashCommandEvent({ + command: resolvedCommandPath[0], + subcommand, + status: SlashCommandStatus.ERROR, + }); + logSlashCommand(this.services.config, event); + } + addItemWithRecording( + { + type: MessageType.ERROR, + text: e instanceof Error ? e.message : String(e), + }, + Date.now(), + ); + return { kind: 'handled' }; + } finally { + const chatRecordingService = + this.services.config?.getChatRecordingService?.(); + const primaryCommand = + resolvedCommandPath[0] || + trimmed.replace(/^[/?]/, '').split(/\s+/u)[0] || + trimmed; + // The built-in /advisor is skipped by identity (kind + name) so a + // user-defined command shadowing the name is still recorded like + // any other custom command (ink parity). + const isBuiltInAdvisor = + primaryCommand === 'advisor' && + commandToExecute?.kind === CommandKind.BUILT_IN; + const shouldRecord = + !delegatedToRecursiveInvocation && + !isBuiltInAdvisor && + !SLASH_COMMANDS_SKIP_RECORDING.has(primaryCommand); + try { + if (shouldRecord) { + chatRecordingService?.recordSlashCommand({ + phase: 'invocation', + rawCommand: trimmed, + sentToModel: invocationSentToModel, + hiddenInvocation: hideInvocation, + }); + const outputItems = recordedItems + .filter((item) => item.type !== 'user') + .map(serializeHistoryItemForRecording); + chatRecordingService?.recordSlashCommand({ + phase: 'result', + rawCommand: trimmed, + outputHistoryItems: outputItems, + }); + } + } catch (recordError) { + debugLogger.error( + '[slashCommand] Failed to record slash command:', + recordError, + ); + } + if ( + this.services.config && + resolvedCommandPath[0] && + !hasError && + !delegatedToRecursiveInvocation + ) { + const event = makeSlashCommandEvent({ + command: resolvedCommandPath[0], + subcommand, + status: SlashCommandStatus.SUCCESS, + }); + logSlashCommand(this.services.config, event); + } + this.host.setIsProcessing(false); + } + } +} + +/** + * Convenience: loads the interactive registry through the original loader + * stack (same as the ink processor) and returns a ready dispatcher. + */ +export async function createOpenTuiSlashDispatcher( + host: OpenTuiCommandHost, + services: OpenTuiCommandServices, + signal?: AbortSignal, +): Promise { + const commands = await loadInteractiveCommands( + services.config, + signal, + services.settings, + ); + return new OpenTuiSlashDispatcher(host, services, commands); +} diff --git a/packages/cli/src/ui/opentui/commands-registry.test.ts b/packages/cli/src/ui/opentui/commands-registry.test.ts new file mode 100644 index 00000000000..a4706aa0a20 --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-registry.test.ts @@ -0,0 +1,364 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI slash-command registry against the ORIGINAL sources: + * - every dialog kind an original command can return is routed (the full + * `OpenDialogActionReturn['dialog']` union), matching the ink actions + * - the built-in route table covers exactly the commands registered by + * services/BuiltinCommandLoader.ts — names, aliases, and gates checked + * against the real loader output and command objects. + */ + +import { describe, it, expect } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { OpenDialogActionReturn } from '../commands/types.js'; +import { + commandRouteFor, + OPEN_TUI_COMMAND_ROUTES, + routeDialogToOpenTui, + type InkDialogKind, +} from './commands-registry.js'; +import { loadInteractiveCommands } from './slash-dispatch.js'; + +/** + * Config stub with every BuiltinCommandLoader gate ON plus the checkpointing + * flag the /restore factory needs — loading through it registers every + * built-in command, so the route table can be checked by set equality + * against the real loader output instead of a hand-maintained gate list. + */ +function createAllGatesOnConfig(): Config { + return { + initialize: async () => {}, + getDisabledSlashCommands: () => [], + setModelInvocableCommandsProvider: () => {}, + setModelInvocableCommandsExecutor: () => {}, + getBareMode: () => true, + isWorkflowsEnabled: () => true, + isManagedMemoryAvailable: () => true, + getFolderTrust: () => true, + getFolderTrustFeature: () => true, + getFileCheckpointingEnabled: () => true, + isLspEnabled: () => true, + isCronEnabled: () => false, + getMcpServers: () => ({}), + getSkillManager: () => undefined, + getDisabledSkillNames: () => new Set(), + getPermissionManager: () => undefined, + getModel: () => undefined, + getCliVersion: () => undefined, + getProjectRoot: () => '/nonexistent-opentui-test-root', + } as unknown as Config; +} + +/** Every dialog kind from ui/commands/types.ts (checked exhaustively). */ +const ALL_DIALOG_KINDS: readonly InkDialogKind[] = [ + 'help', + 'arena_start', + 'arena_select', + 'arena_stop', + 'arena_status', + 'auth', + 'theme', + 'editor', + 'settings', + 'statusline', + 'memory', + 'model', + 'fast-model', + 'voice-model', + 'vision-model', + 'compaction-model', + 'image-model', + 'subagent_create', + 'subagent_list', + 'skills_manage', + 'trust', + 'permissions', + 'approval-mode', + 'effort', + 'resume', + 'delete', + 'branch', + 'extensions_manage', + 'hooks', + 'mcp', + 'rewind', + 'diff', + 'stats', +]; + +describe('routeDialogToOpenTui (ink dialog-switch parity)', () => { + it('routes every dialog kind; none falls through to the error case', () => { + // 'branch' is the one exception: a host action both renderers intercept + // before routing (asserted separately below). + const routable = ALL_DIALOG_KINDS.filter((d) => d !== 'branch'); + expect(routable).toHaveLength(ALL_DIALOG_KINDS.length - 1); + for (const dialog of routable) { + const request = routeDialogToOpenTui({ + type: 'dialog', + dialog, + } as OpenDialogActionReturn); + expect(request).toBeTruthy(); + expect(request.dialog).toBeTruthy(); + } + }); + + it("throws on 'branch' — a host action that must never route to a dialog", () => { + // If commands-dispatch ever drops its handleBranch interception, this + // loud failure replaces a silently unrenderable dialog request. + expect(() => + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'branch', + name: 'wip', + } as OpenDialogActionReturn), + ).toThrow(/host action/); + }); + + it('maps each dialog kind onto its exact OpenTUI target', () => { + // Pins the mapping itself, not just its existence: mis-routing one + // dialog onto another (e.g. theme → settings) must fail here. + const targets: Array<[InkDialogKind, string]> = [ + ['help', 'help'], + ['theme', 'theme'], + ['editor', 'editor'], + ['settings', 'settings'], + ['statusline', 'statusline'], + ['memory', 'memory'], + ['auth', 'auth'], + ['trust', 'trust'], + ['permissions', 'permissions'], + ['approval-mode', 'approval-mode'], + ['effort', 'effort'], + ['delete', 'delete'], + ['resume', 'resume'], + ['extensions_manage', 'extensions_manage'], + ['hooks', 'hooks'], + ['mcp', 'mcp'], + ['rewind', 'rewind'], + ['diff', 'diff'], + ['stats', 'stats'], + ]; + for (const [dialog, target] of targets) { + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog, + } as OpenDialogActionReturn), + ).toEqual({ dialog: target }); + } + }); + + it('maps the model family onto the model dialog with the ink mode', () => { + const cases: Array<[InkDialogKind, string]> = [ + ['model', 'primary'], + ['fast-model', 'fast'], + ['voice-model', 'voice'], + ['vision-model', 'vision'], + ['compaction-model', 'compaction'], + ['image-model', 'image'], + ]; + for (const [dialog, mode] of cases) { + const request = routeDialogToOpenTui({ + type: 'dialog', + dialog, + } as OpenDialogActionReturn); + expect(request).toEqual({ dialog: 'model', mode }); + } + }); + + it('carries persistScope for the model dialogs like openModelDialog', () => { + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'model', + persistScope: 'workspace', + } as OpenDialogActionReturn), + ).toEqual({ dialog: 'model', mode: 'primary', persistScope: 'workspace' }); + }); + + it('maps the arena dialogs onto the arena dialog modes', () => { + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'arena_start', + } as OpenDialogActionReturn), + ).toEqual({ dialog: 'arena', mode: 'start' }); + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'arena_select', + } as OpenDialogActionReturn), + ).toEqual({ dialog: 'arena', mode: 'select' }); + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'arena_stop', + } as OpenDialogActionReturn), + ).toEqual({ dialog: 'arena', mode: 'stop' }); + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'arena_status', + } as OpenDialogActionReturn), + ).toEqual({ dialog: 'arena', mode: 'status' }); + }); + + it('keeps resume matchedSessions payloads', () => { + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'resume', + } as OpenDialogActionReturn), + ).toEqual({ dialog: 'resume' }); + expect( + routeDialogToOpenTui({ + type: 'dialog', + dialog: 'resume', + matchedSessions: [], + } as OpenDialogActionReturn), + ).toEqual({ dialog: 'resume', matchedSessions: [] }); + }); +}); + +describe('OPEN_TUI_COMMAND_ROUTES (built-in registry parity)', () => { + it('has unique canonical names with no duplicate routes', () => { + const names = OPEN_TUI_COMMAND_ROUTES.map((route) => route.name); + expect(new Set(names).size).toBe(names.length); + }); + + it('covers exactly the commands the original loader registers', async () => { + // Load with every gate ON plus the checkpointing flag the /restore + // factory needs: every built-in registers, so route table and loader + // output must be equal as sets — no hand-maintained gate list, no + // escape hatches. + const loaded = await loadInteractiveCommands(createAllGatesOnConfig()); + const builtins = loaded.filter((cmd) => cmd.source === 'builtin-command'); + expect(builtins.length).toBeGreaterThan(0); + + const loadedNames = new Set(builtins.map((cmd) => cmd.name)); + const routeNames = new Set(OPEN_TUI_COMMAND_ROUTES.map((r) => r.name)); + + for (const name of loadedNames) { + expect(routeNames.has(name), `missing route for /${name}`).toBe(true); + } + for (const name of routeNames) { + expect( + loadedNames.has(name), + `route /${name} is not a registered built-in`, + ).toBe(true); + } + expect(routeNames.has('restore')).toBe(true); + }, 30000); + + it('gated routes are genuinely gated — absent from a gates-off load', async () => { + // A route claiming gatedBy must NOT register when the gates are off; + // a bogus gate on an always-registered command fails here because that + // command IS present in the null-config load. + const off = await loadInteractiveCommands(null); + const offNames = new Set( + off.filter((cmd) => cmd.source === 'builtin-command').map((c) => c.name), + ); + const gatedRoutes = OPEN_TUI_COMMAND_ROUTES.filter((r) => r.gatedBy); + expect(gatedRoutes.length).toBeGreaterThan(0); + for (const route of gatedRoutes) { + expect( + offNames.has(route.name), + `/${route.name} declares gatedBy but registers with gates off`, + ).toBe(false); + } + }, 30000); + + it('matches the config-built /restore factory command', async () => { + const { restoreCommand } = await import('../commands/restoreCommand.js'); + const config = { + getFileCheckpointingEnabled: () => true, + } as never; + const restored = restoreCommand(config); + expect(restored).not.toBeNull(); + const route = commandRouteFor(restored?.name ?? ''); + expect(restored?.name).toBe('restore'); + expect(route, 'no route for /restore').toBeTruthy(); + expect([...(route?.altNames ?? [])].sort()).toEqual( + [...(restored?.altNames ?? [])].sort(), + ); + }); + + it('matches the real commands’ aliases', async () => { + const loaded = await loadInteractiveCommands(createAllGatesOnConfig()); + const builtins = loaded.filter((cmd) => cmd.source === 'builtin-command'); + for (const cmd of builtins) { + const route = commandRouteFor(cmd.name); + expect(route, `no route for /${cmd.name}`).toBeTruthy(); + expect([...(route?.altNames ?? [])].sort()).toEqual( + [...(cmd.altNames ?? [])].sort(), + ); + } + }, 30000); + + it('only lists dialog kinds that exist in the original union', () => { + const known = new Set(ALL_DIALOG_KINDS); + for (const route of OPEN_TUI_COMMAND_ROUTES) { + for (const dialog of route.dialogs ?? []) { + expect(known.has(dialog)).toBe(true); + } + } + }); + + it('every command that opens a dialog declares the dialog result kind', () => { + const dialogRoutes = OPEN_TUI_COMMAND_ROUTES.filter( + (route) => (route.dialogs?.length ?? 0) > 0, + ); + expect(dialogRoutes.length).toBeGreaterThan(0); + for (const route of dialogRoutes) { + expect(route.results).toContain('dialog'); + } + }); + + it('spot-checks the headline commands from the parity list', () => { + const expectations: Array<[string, readonly string[]]> = [ + ['help', ['dialog']], + ['clear', ['message']], + ['quit', ['quit']], + ['config', ['message']], + ['theme', ['dialog', 'message']], + ['model', ['dialog', 'message', 'submit_prompt']], + ['auth', ['dialog', 'message']], + ['permissions', ['dialog']], + ['compress', ['message', 'stream_messages']], + ['context', ['message']], + ['memory', ['dialog']], + ['resume', ['dialog', 'message']], + ['rewind', ['dialog']], + ['fork', ['message']], + ['diff', ['dialog', 'message']], + ['export', ['message']], + ['copy', ['message']], + ['stats', ['dialog', 'message']], + ['doctor', ['message']], + ['skills', ['dialog', 'message']], + ['extensions', ['dialog', 'message']], + ['mcp', ['dialog', 'message']], + ['plan', ['message', 'submit_prompt']], + ['effort', ['dialog', 'message']], + ['language', ['message']], + ['vim', ['message']], + ['settings', ['dialog']], + ['history', ['message']], + ['restore', ['message', 'tool']], + ['setup-github', ['tool']], + ['goal', ['goal_control', 'message', 'submit_prompt']], + ['cd', ['confirm_action', 'message']], + ['init', ['confirm_action', 'message', 'submit_prompt']], + ]; + for (const [name, results] of expectations) { + const route = commandRouteFor(name); + expect(route, `no route for /${name}`).toBeTruthy(); + expect([...(route?.results ?? [])].sort()).toEqual([...results].sort()); + } + }); +}); diff --git a/packages/cli/src/ui/opentui/commands-registry.ts b/packages/cli/src/ui/opentui/commands-registry.ts new file mode 100644 index 00000000000..d9f6f209b39 --- /dev/null +++ b/packages/cli/src/ui/opentui/commands-registry.ts @@ -0,0 +1,369 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Full-parity slash-command registry for the OpenTUI renderer (PR1 slice 5). + * + * Two registrations, both checked against the ORIGINAL sources: + * + * 1. Dialog routing — `routeDialogToOpenTui` maps every dialog kind the + * original commands can return (`OpenDialogActionReturn['dialog']`, + * ui/commands/types.ts) onto the OpenTUI dialog family, mirroring the + * `actions.open*` switch in ui/hooks/slashCommandProcessor.ts. The + * model-family dialogs all land on OpenTuiModelDialog (dialogs-model.tsx) + * with the mode the ink ModelDialog receives; `/resume ` and + * `/branch` are host actions (handleResume / handleBranch), never dialogs. + * + * 2. Command routes — one entry per built-in command module registered by + * services/BuiltinCommandLoader.ts (69 modules). Each entry lists the + * action-result kinds the command can produce and the dialogs it opens, + * so the OpenTUI dispatcher covers every built-in command the ink TUI + * does. `commands-registry.test.ts` cross-checks names, aliases, and + * gates against the real command objects. + */ + +import type { SessionListItem } from '@qwen-code/qwen-code-core'; +import type { OpenDialogActionReturn } from '../commands/types.js'; +import type { ModelDialogMode } from './dialogs-model.js'; + +/** Every dialog kind an original slash command can request. */ +export type InkDialogKind = OpenDialogActionReturn['dialog']; + +/** The OpenTUI dialog a routed dialog request opens. */ +export type OpenTuiDialogRequest = + | { dialog: 'help' } + | { dialog: 'theme' } + | { dialog: 'editor' } + | { dialog: 'settings' } + | { dialog: 'statusline' } + | { dialog: 'memory' } + | { dialog: 'auth' } + | { dialog: 'trust' } + | { dialog: 'permissions' } + | { dialog: 'approval-mode' } + | { dialog: 'effort' } + | { dialog: 'delete' } + | { dialog: 'resume'; matchedSessions?: SessionListItem[] } + | { dialog: 'extensions_manage' } + | { dialog: 'hooks' } + | { dialog: 'mcp' } + | { dialog: 'rewind' } + | { dialog: 'diff' } + | { dialog: 'stats' } + | { dialog: 'arena'; mode: 'start' | 'select' | 'stop' | 'status' } + | { dialog: 'subagent_create' } + | { dialog: 'subagent_list' } + | { dialog: 'skills_manage' } + | { + dialog: 'model'; + mode: ModelDialogMode; + persistScope?: 'workspace' | 'user'; + }; + +/** + * Parity of the `case 'dialog'` switch in ui/hooks/slashCommandProcessor.ts: + * every dialog kind maps to exactly the dialog (and mode) the ink actions + * open. Exhaustive — a new dialog kind fails the `never` check at compile + * time. + */ +export function routeDialogToOpenTui( + result: OpenDialogActionReturn, +): OpenTuiDialogRequest { + const dialog = result.dialog; + switch (dialog) { + case 'help': + return { dialog: 'help' }; + case 'theme': + return { dialog: 'theme' }; + case 'editor': + return { dialog: 'editor' }; + case 'settings': + return { dialog: 'settings' }; + case 'statusline': + return { dialog: 'statusline' }; + case 'memory': + return { dialog: 'memory' }; + case 'auth': + return { dialog: 'auth' }; + case 'trust': + return { dialog: 'trust' }; + case 'permissions': + return { dialog: 'permissions' }; + case 'approval-mode': + return { dialog: 'approval-mode' }; + case 'effort': + return { dialog: 'effort' }; + case 'delete': + return { dialog: 'delete' }; + case 'resume': + return result.matchedSessions + ? { dialog: 'resume', matchedSessions: result.matchedSessions } + : { dialog: 'resume' }; + case 'branch': + // Never reached: commands-dispatch intercepts dialog-branch as a host + // action (handleBranch) before routing. A compile-time exclusion is + // not expressible — OpenDialogActionReturn is one interface with a + // union dialog field — so fail loudly if a refactor ever drops the + // interception instead of returning a request nothing can render. + throw new Error( + "'/branch' is a host action (handleBranch) and must not route to a dialog", + ); + case 'extensions_manage': + return { dialog: 'extensions_manage' }; + case 'hooks': + return { dialog: 'hooks' }; + case 'mcp': + return { dialog: 'mcp' }; + case 'rewind': + return { dialog: 'rewind' }; + case 'diff': + return { dialog: 'diff' }; + case 'stats': + return { dialog: 'stats' }; + case 'arena_start': + return { dialog: 'arena', mode: 'start' }; + case 'arena_select': + return { dialog: 'arena', mode: 'select' }; + case 'arena_stop': + return { dialog: 'arena', mode: 'stop' }; + case 'arena_status': + return { dialog: 'arena', mode: 'status' }; + case 'subagent_create': + return { dialog: 'subagent_create' }; + case 'subagent_list': + return { dialog: 'subagent_list' }; + case 'skills_manage': + return { dialog: 'skills_manage' }; + case 'model': + return { + dialog: 'model', + mode: 'primary', + ...(result.persistScope ? { persistScope: result.persistScope } : {}), + }; + case 'fast-model': + return { + dialog: 'model', + mode: 'fast', + ...(result.persistScope ? { persistScope: result.persistScope } : {}), + }; + case 'voice-model': + return { + dialog: 'model', + mode: 'voice', + ...(result.persistScope ? { persistScope: result.persistScope } : {}), + }; + case 'vision-model': + return { + dialog: 'model', + mode: 'vision', + ...(result.persistScope ? { persistScope: result.persistScope } : {}), + }; + case 'compaction-model': + return { + dialog: 'model', + mode: 'compaction', + ...(result.persistScope ? { persistScope: result.persistScope } : {}), + }; + case 'image-model': + return { + dialog: 'model', + mode: 'image', + ...(result.persistScope ? { persistScope: result.persistScope } : {}), + }; + default: { + const unhandled: never = dialog; + throw new Error(`Unhandled slash command dialog: ${unhandled}`); + } + } +} + +/** Action-result kinds the original commands can produce. */ +export type SlashResultKind = + | 'none' + | 'message' + | 'dialog' + | 'quit' + | 'tool' + | 'submit_prompt' + | 'load_history' + | 'confirm_shell_commands' + | 'confirm_action' + | 'goal_control' + | 'stream_messages'; + +/** Config-based gates mirrored from services/BuiltinCommandLoader.ts. */ +export type CommandGate = + | 'workflows' + | 'managed-memory' + | 'folder-trust' + | 'lsp'; + +/** Route entry for one built-in command module. */ +export interface CommandRouteSpec { + readonly name: string; + readonly altNames?: readonly string[]; + /** Union of result kinds across the command and its subcommands. */ + readonly results: readonly SlashResultKind[]; + /** Dialog kinds the command may open (subset of `results` ∋ 'dialog'). */ + readonly dialogs?: readonly InkDialogKind[]; + readonly gatedBy?: CommandGate; +} + +/** + * All 69 built-in command modules, one entry each, in the registration order + * of BuiltinCommandLoader.ts. `/status` is aboutCommand's canonical name + * ('about' is the alias); names with subcommands list the union of the whole + * command tree. + */ +export const OPEN_TUI_COMMAND_ROUTES: readonly CommandRouteSpec[] = [ + { name: 'status', altNames: ['about'], results: ['none', 'message'] }, + { + name: 'agents', + results: ['dialog'], + dialogs: ['subagent_create', 'subagent_list'], + }, + { name: 'tasks', results: ['message'] }, + { name: 'workflows', results: ['message'], gatedBy: 'workflows' }, + { + name: 'arena', + results: ['dialog', 'message', 'confirm_action'], + dialogs: ['arena_start', 'arena_select', 'arena_stop', 'arena_status'], + }, + { + name: 'approval-mode', + results: ['dialog', 'message'], + dialogs: ['approval-mode'], + }, + { name: 'advisor', results: ['message'] }, + { + name: 'auth', + altNames: ['connect', 'login'], + results: ['dialog', 'message'], + dialogs: ['auth'], + }, + // /branch returns a dialog-kind result but no renderer opens a dialog for + // it — both ink and OpenTUI intercept it as a host action (handleBranch). + { name: 'branch', results: ['dialog', 'message'] }, + { name: 'btw', results: ['message'] }, + { name: 'fork', results: ['message'] }, + { name: 'bug', results: ['none'] }, + { name: 'cd', results: ['confirm_action', 'message'] }, + { name: 'clear', altNames: ['reset', 'new'], results: ['message'] }, + { + name: 'compress', + altNames: ['summarize'], + results: ['message', 'stream_messages'], + }, + { name: 'compress-fast', results: ['message', 'stream_messages'] }, + { name: 'config', results: ['message'] }, + { name: 'context', results: ['message'] }, + { name: 'curator', results: ['message'] }, + { name: 'copy', results: ['message'] }, + { name: 'diff', results: ['dialog', 'message'], dialogs: ['diff'] }, + { name: 'delete', results: ['dialog'], dialogs: ['delete'] }, + { name: 'docs', results: ['message'] }, + { name: 'doctor', results: ['message'] }, + { name: 'directory', altNames: ['dir'], results: ['message'] }, + { name: 'editor', results: ['dialog'], dialogs: ['editor'] }, + { name: 'effort', results: ['dialog', 'message'], dialogs: ['effort'] }, + { name: 'export', results: ['message'] }, + { + name: 'extensions', + results: ['dialog', 'message'], + dialogs: ['extensions_manage'], + }, + { name: 'help', altNames: ['?'], results: ['dialog'], dialogs: ['help'] }, + { name: 'history', results: ['message'] }, + { name: 'hooks', results: ['dialog', 'message'], dialogs: ['hooks'] }, + { name: 'ide', results: ['message'] }, + { name: 'import-config', results: ['message'] }, + { name: 'init', results: ['confirm_action', 'message', 'submit_prompt'] }, + { name: 'language', results: ['message'] }, + { name: 'learn', results: ['message', 'submit_prompt'] }, + { name: 'mcp', results: ['dialog', 'message'], dialogs: ['mcp'] }, + { + name: 'dream', + results: ['message', 'submit_prompt'], + gatedBy: 'managed-memory', + }, + { name: 'forget', results: ['message'], gatedBy: 'managed-memory' }, + { + name: 'goal', + results: ['goal_control', 'message', 'submit_prompt'], + }, + { name: 'memory', results: ['dialog'], dialogs: ['memory'] }, + { + name: 'model', + results: ['dialog', 'message', 'submit_prompt'], + dialogs: [ + 'model', + 'fast-model', + 'voice-model', + 'vision-model', + 'compaction-model', + 'image-model', + ], + }, + { name: 'remember', results: ['message', 'submit_prompt'] }, + { name: 'plan', results: ['message', 'submit_prompt'] }, + { name: 'peers', results: ['message'] }, + { name: 'permissions', results: ['dialog'], dialogs: ['permissions'] }, + { + name: 'trust', + results: ['dialog'], + dialogs: ['trust'], + gatedBy: 'folder-trust', + }, + { name: 'quit', altNames: ['exit'], results: ['quit'] }, + { name: 'recap', results: ['message'] }, + { name: 'reload-plugins', results: ['message'] }, + { name: 'rename', altNames: ['tag'], results: ['message'] }, + { name: 'restore', results: ['message', 'tool'] }, + { + name: 'resume', + altNames: ['continue'], + results: ['dialog', 'message'], + dialogs: ['resume'], + }, + { + name: 'rewind', + altNames: ['rollback'], + results: ['dialog'], + dialogs: ['rewind'], + }, + { + name: 'skills', + results: ['dialog', 'message'], + dialogs: ['skills_manage'], + }, + { + name: 'stats', + altNames: ['usage'], + results: ['dialog', 'message'], + dialogs: ['stats'], + }, + { name: 'summary', results: ['message', 'stream_messages'] }, + { name: 'theme', results: ['dialog', 'message'], dialogs: ['theme'] }, + { name: 'tools', results: ['none'] }, + { name: 'settings', results: ['dialog'], dialogs: ['settings'] }, + { name: 'vim', results: ['message'] }, + { name: 'update', results: ['message'] }, + { name: 'voice', results: ['message'] }, + { name: 'setup-github', results: ['tool'] }, + { name: 'terminal-setup', results: ['message'] }, + { name: 'insight', results: ['message', 'stream_messages'] }, + { + name: 'statusline', + results: ['dialog', 'submit_prompt'], + dialogs: ['statusline'], + }, + { name: 'lsp', results: ['message'], gatedBy: 'lsp' }, +]; + +/** Route lookup by canonical command name. */ +export function commandRouteFor(name: string): CommandRouteSpec | undefined { + return OPEN_TUI_COMMAND_ROUTES.find((route) => route.name === name); +} diff --git a/packages/cli/src/ui/opentui/dialog-data.test.ts b/packages/cli/src/ui/opentui/dialog-data.test.ts new file mode 100644 index 00000000000..a764956fbea --- /dev/null +++ b/packages/cli/src/ui/opentui/dialog-data.test.ts @@ -0,0 +1,1661 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Backend-facing integration tests for the dialog data/result wiring (R2): + * model entries + selection persistence, theme application, permissions data + * + mutations, and the MCP/extension feeds for the mounted dialogs. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; + +// theme.ts (via the dialog modules) builds a SyntaxStyle at module scope, +// which needs the OpenTUI native FFI — unavailable in the test runtime. Stub +// the graphics surface like the other dialog tests do. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { + AuthType, + MCPServerStatus, + type AvailableModel, + type Config, + type OAuthToken, +} from '@qwen-code/qwen-code-core'; +import { SettingScope } from '../../config/settings.js'; +import type { LoadedSettings, Settings } from '../../config/settings.js'; +import { themeManager } from '../themes/theme-manager.js'; +import { + addPermissionRule, + applyExtensionFavorite, + applyExtensionScopeChange, + applyExtensionToggle, + applyExtensionUninstall, + applyExtensionUpdateCheck, + applyMcpServerAction, + applyModelSelection, + applyThemeSelection, + buildExtensionRows, + buildMcpServers, + buildModelEntries, + buildPermissionsData, + computeModelDialogInitialKey, + deletePermissionRule, + enrichMcpOAuthState, + getMcpServerResources, + getMcpServerTools, +} from './dialog-data.js'; +import type { McpServerInfo } from './dialogs-mcp.js'; +import { + buildModelSelectionKey, + type OpenTuiModelEntry, +} from './dialogs-model.js'; + +interface WrittenValue { + scope: SettingScope; + key: string; + value: unknown; +} + +interface FakeSettings { + settings: LoadedSettings; + written: WrittenValue[]; +} + +function createFakeSettings(options?: { + isTrusted?: boolean; + user?: Settings; + workspace?: Settings; +}): FakeSettings { + const written: WrittenValue[] = []; + const user = options?.user ?? {}; + const workspace = options?.workspace ?? {}; + const merged: Record = {}; + const settings = { + user: { settings: user }, + workspace: { settings: workspace }, + get merged() { + return merged as Settings; + }, + isTrusted: options?.isTrusted ?? false, + setValue: (scope: SettingScope, key: string, value: unknown) => { + written.push({ scope, key, value }); + if (key === 'ui.theme') { + const ui = (merged['ui'] ?? {}) as Record; + ui['theme'] = value; + const customThemes: Record = {}; + for (const scopeSettings of [user, workspace]) { + const uiCustomThemes = (( + (scopeSettings.ui ?? {}) as Record + )['customThemes'] ?? {}) as Record; + Object.assign(customThemes, uiCustomThemes); + } + if (Object.keys(customThemes).length > 0) { + ui['customThemes'] = customThemes; + } + merged['ui'] = ui; + } + if (key.startsWith('permissions.')) { + const type = key.split('.')[1] as string; + const permissions = (merged['permissions'] ?? {}) as Record< + string, + string[] + >; + permissions[type] = value as string[]; + merged['permissions'] = permissions; + const target = + scope === SettingScope.User + ? user + : scope === SettingScope.Workspace + ? workspace + : null; + if (target) { + (target as Record)['permissions'] = permissions; + } + } + }, + forScope: (scope: SettingScope) => + scope === SettingScope.User + ? { settings: user } + : { settings: workspace }, + } as unknown as LoadedSettings; + return { settings, written }; +} + +function stubConfig(overrides: Partial): Config { + return overrides as Config; +} + +describe('buildModelEntries', () => { + const models = [ + { + id: 'm1', + label: 'M1', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://provider.example', + contextWindowSize: 8192, + }, + { + id: 'fast1', + label: 'Fast', + authType: AuthType.USE_OPENAI, + fastOnly: true, + }, + { + id: 'img1', + label: 'Image', + authType: AuthType.USE_OPENAI, + imageOnly: true, + }, + { + id: 'dual1', + label: 'Dual vision/image', + authType: AuthType.USE_OPENAI, + visionOnly: true, + supportsImageGeneration: true, + }, + { + id: 'oauth1', + label: 'OAuth', + authType: AuthType.QWEN_OAUTH, + }, + { + id: 'rt1', + label: 'Runtime', + authType: AuthType.USE_OPENAI, + isRuntimeModel: true, + runtimeSnapshotId: '$runtime|openai|rt1', + }, + ]; + + it('lists registry + runtime models for the primary selector', () => { + const config = stubConfig({ + getAllConfiguredModels: (() => + models) as Config['getAllConfiguredModels'], + getAuthType: () => AuthType.USE_OPENAI, + } as Partial); + const entries = buildModelEntries(config, 'primary'); + const ids = entries.map((entry) => entry.modelId); + expect(ids).toEqual(['m1', 'rt1']); + expect(entries[0]?.key).toBe( + buildModelSelectionKey( + String(AuthType.USE_OPENAI), + 'm1', + 'https://provider.example', + ), + ); + }); + + it('includes fast-only entries only in fast mode', () => { + const config = stubConfig({ + getAllConfiguredModels: (() => + models) as Config['getAllConfiguredModels'], + getAuthType: () => AuthType.USE_OPENAI, + } as Partial); + expect( + buildModelEntries(config, 'fast').map((entry) => entry.modelId), + ).toEqual(['m1', 'fast1', 'rt1']); + }); + + it('lists only image models in image mode', () => { + const config = stubConfig({ + getAllConfiguredModels: (() => + models) as Config['getAllConfiguredModels'], + getAuthType: () => AuthType.USE_OPENAI, + resolveImageGenerationModel: ((selector: string) => + selector.includes('img1') || selector.includes('dual1') + ? { model: 'img1', baseUrl: 'https://img.example', apiKeyEnv: 'K' } + : undefined) as Config['resolveImageGenerationModel'], + } as Partial); + // dual1 is visionOnly AND image-capable: ink keeps it in the image + // selector (isVisionModelMode || isImageModelMode || !m.visionOnly). + expect( + buildModelEntries(config, 'image').map((entry) => entry.modelId), + ).toEqual(['img1', 'dual1']); + }); + + it('shows QWEN_OAUTH models only under that auth type', () => { + const config = stubConfig({ + getAllConfiguredModels: (() => + models) as Config['getAllConfiguredModels'], + getAuthType: () => AuthType.QWEN_OAUTH, + } as Partial); + expect( + buildModelEntries(config, 'primary').map((entry) => entry.modelId), + ).toContain('oauth1'); + }); + + it('keys runtime rows by their snapshot id and carries the raw model', () => { + const config = stubConfig({ + getAllConfiguredModels: (() => + models) as Config['getAllConfiguredModels'], + getAuthType: () => AuthType.USE_OPENAI, + } as Partial); + const entries = buildModelEntries(config, 'primary'); + const runtime = entries.find((entry) => entry.modelId === 'rt1'); + expect(runtime?.key).toBe('$runtime|openai|rt1'); + expect(runtime?.model?.id).toBe('rt1'); + }); + + it('drops image rows the runtime cannot resolve', () => { + const noResolver = stubConfig({ + getAllConfiguredModels: (() => + models) as Config['getAllConfiguredModels'], + getAuthType: () => AuthType.USE_OPENAI, + } as Partial); + expect(buildModelEntries(noResolver, 'image')).toEqual([]); + + const rejectingResolver = stubConfig({ + getAllConfiguredModels: (() => + models) as Config['getAllConfiguredModels'], + getAuthType: () => AuthType.USE_OPENAI, + resolveImageGenerationModel: (() => + undefined) as Config['resolveImageGenerationModel'], + } as Partial); + expect(buildModelEntries(rejectingResolver, 'image')).toEqual([]); + }); + + it('returns no entries without a config', () => { + expect(buildModelEntries(undefined, 'primary')).toEqual([]); + }); +}); + +describe('applyModelSelection', () => { + function modelRow( + model: Partial & { id: string }, + ): OpenTuiModelEntry { + const full: AvailableModel = { + label: model.id, + authType: AuthType.USE_OPENAI, + ...model, + }; + const key = buildModelSelectionKey( + String(full.authType), + full.id, + full.baseUrl, + ); + return { + key, + value: key, + authType: String(full.authType), + label: full.label, + modelId: full.id, + model: full, + }; + } + + function resolvedConfig(overrides: Partial): Config { + return stubConfig({ + getAuthType: () => AuthType.USE_OPENAI, + getContentGeneratorConfig: (() => ({ + authType: AuthType.USE_OPENAI, + model: 'm2', + baseUrl: 'https://provider.example', + apiKey: 'sk-1234567', + })) as Config['getContentGeneratorConfig'], + getUsageStatisticsEnabled: (() => + false) as Config['getUsageStatisticsEnabled'], + ...overrides, + } as Partial); + } + + describe('primary mode (default selection)', () => { + it('switches the runtime model before persisting model.name', async () => { + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [ + modelRow({ id: 'm2', baseUrl: 'https://provider.example' }), + ]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'primary', + selectionKey: entries[0].key, + }); + + expect(switchModel).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'm2', + expect.objectContaining({ baseUrl: 'https://provider.example' }), + ); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'model.name', + value: 'm2', + }); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'model.baseUrl', + value: 'https://provider.example', + }); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'security.auth.selectedType', + value: AuthType.USE_OPENAI, + }); + // Persisted only after the runtime switch resolved, with its outcome. + expect( + written.findIndex((write) => write.key === 'model.name'), + ).toBeGreaterThan(-1); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.message).toContain('Using model: m2'); + } + }); + + it('does not persist before the switch resolves', async () => { + const writesSeenDuringSwitch: number[] = []; + const { settings, written } = createFakeSettings(); + const switchModel = vi.fn(async () => { + writesSeenDuringSwitch.push(written.length); + }); + const config = resolvedConfig({ + switchModel: switchModel as Config['switchModel'], + }); + const entries = [modelRow({ id: 'm2' })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'primary', + selectionKey: entries[0].key, + }); + + expect(outcome.ok).toBe(true); + expect(writesSeenDuringSwitch).toEqual([0]); + }); + + it('keeps the dialog open and settings untouched when switchModel fails', async () => { + const switchModel = vi.fn(async () => { + throw new Error('network down'); + }); + const config = resolvedConfig({ + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [modelRow({ id: 'm2' })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'primary', + selectionKey: entries[0].key, + }); + + expect(switchModel).toHaveBeenCalled(); + expect(written).toEqual([]); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain("Failed to switch model to 'm2'."); + expect(outcome.error).toContain('network down'); + } + }); + + it('honors an explicit persist scope and falls back for untrusted workspaces', async () => { + const trusted = createFakeSettings({ isTrusted: true }); + await applyModelSelection({ + config: resolvedConfig({ + switchModel: (async () => {}) as Config['switchModel'], + }), + settings: trusted.settings, + entries: [modelRow({ id: 'm2' })], + mode: 'primary', + selectionKey: buildModelSelectionKey(String(AuthType.USE_OPENAI), 'm2'), + persistScope: 'workspace', + }); + expect( + trusted.written.every( + (write) => write.scope === SettingScope.Workspace, + ), + ).toBe(true); + + const untrusted = createFakeSettings({ isTrusted: false }); + await applyModelSelection({ + config: resolvedConfig({ + switchModel: (async () => {}) as Config['switchModel'], + }), + settings: untrusted.settings, + entries: [modelRow({ id: 'm2' })], + mode: 'primary', + selectionKey: buildModelSelectionKey(String(AuthType.USE_OPENAI), 'm2'), + persistScope: 'workspace', + }); + expect( + untrusted.written.every((write) => write.scope === SettingScope.User), + ).toBe(true); + }); + + it('blocks discontinued qwen-oauth selections without switching', async () => { + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [ + modelRow({ id: 'old-model', authType: AuthType.QWEN_OAUTH }), + ]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'primary', + selectionKey: entries[0].key, + }); + + expect(switchModel).not.toHaveBeenCalled(); + expect(written).toEqual([]); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain('discontinued'); + } + }); + + it('passes runtime snapshot ids straight to switchModel', async () => { + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + switchModel: switchModel as Config['switchModel'], + }); + const { settings } = createFakeSettings(); + const snapshotId = `$runtime|${AuthType.USE_OPENAI}|rt-model`; + + const outcome = await applyModelSelection({ + config, + settings, + entries: [], + mode: 'primary', + selectionKey: snapshotId, + }); + + expect(outcome.ok).toBe(true); + expect(switchModel).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + snapshotId, + expect.objectContaining({ baseUrl: undefined }), + ); + }); + }); + + describe('mode-specific selections', () => { + it('fast mode writes fastModel and syncs Config.setFastModel', async () => { + const setFastModel = vi.fn(); + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + setFastModel: setFastModel as Config['setFastModel'], + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [modelRow({ id: 'fast1' })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'fast', + selectionKey: entries[0].key, + }); + + expect(setFastModel).toHaveBeenCalledWith('openai:fast1'); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'fastModel', + value: 'openai:fast1', + }); + expect( + written.find((write) => write.key === 'model.name'), + ).toBeUndefined(); + expect(switchModel).not.toHaveBeenCalled(); + expect(outcome.ok).toBe(true); + if (outcome.ok) { + expect(outcome.message).toContain('Fast Model: openai:fast1'); + } + }); + + it('vision mode writes visionModel and syncs Config.setVisionModel', async () => { + const setVisionModel = vi.fn(); + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + setVisionModel: setVisionModel as Config['setVisionModel'], + isCurrentPrimaryModel: (() => false) as Config['isCurrentPrimaryModel'], + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [ + modelRow({ + id: 'qv', + baseUrl: 'https://v.example', + modalities: { image: true }, + }), + ]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'vision', + selectionKey: entries[0].key, + }); + + expect(setVisionModel).toHaveBeenCalledWith( + 'openai:qv\0https://v.example', + ); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'visionModel', + value: 'openai:qv\0https://v.example', + }); + expect( + written.find((write) => write.key === 'model.name'), + ).toBeUndefined(); + expect(switchModel).not.toHaveBeenCalled(); + expect(outcome.ok).toBe(true); + }); + + it('vision mode rejects pinning the current primary model', async () => { + const setVisionModel = vi.fn(); + const config = resolvedConfig({ + setVisionModel: setVisionModel as Config['setVisionModel'], + isCurrentPrimaryModel: (() => true) as Config['isCurrentPrimaryModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [modelRow({ id: 'qv' })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'vision', + selectionKey: entries[0].key, + }); + + expect(setVisionModel).not.toHaveBeenCalled(); + expect(written).toEqual([]); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain('current primary model'); + } + }); + + it('compaction mode writes compactionModel and syncs Config.setCompactionModel', async () => { + const setCompactionModel = vi.fn(); + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + setCompactionModel: setCompactionModel as Config['setCompactionModel'], + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [modelRow({ id: 'compact1' })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'compaction', + selectionKey: entries[0].key, + }); + + expect(setCompactionModel).toHaveBeenCalledWith('openai:compact1'); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'compactionModel', + value: 'openai:compact1', + }); + expect( + written.find((write) => write.key === 'model.name'), + ).toBeUndefined(); + expect(switchModel).not.toHaveBeenCalled(); + expect(outcome.ok).toBe(true); + }); + + it('image mode applies the runtime image model before persisting imageModel', async () => { + const setImageModel = vi.fn(async () => {}); + const switchModel = vi.fn(async () => {}); + const baseUrl = 'https://img.example/v1'; + const selector = `openai:img-gen\0${baseUrl}`; + const config = resolvedConfig({ + setImageModel: setImageModel as Config['setImageModel'], + resolveImageGenerationModel: ((candidate: string) => + candidate === selector + ? { model: 'img-gen', baseUrl, apiKeyEnv: 'IMG_KEY' } + : undefined) as Config['resolveImageGenerationModel'], + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [modelRow({ id: 'img-gen', baseUrl, imageOnly: true })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'image', + selectionKey: entries[0].key, + }); + + expect(setImageModel).toHaveBeenCalledWith(selector); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'imageModel', + value: selector, + }); + expect( + written.find((write) => write.key === 'model.name'), + ).toBeUndefined(); + expect(switchModel).not.toHaveBeenCalled(); + expect(outcome.ok).toBe(true); + }); + + it('image mode rejects models the runtime cannot resolve', async () => { + const setImageModel = vi.fn(async () => {}); + const config = resolvedConfig({ + setImageModel: setImageModel as Config['setImageModel'], + resolveImageGenerationModel: (() => + undefined) as Config['resolveImageGenerationModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [modelRow({ id: 'img-gen', imageOnly: true })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'image', + selectionKey: entries[0].key, + }); + + expect(setImageModel).not.toHaveBeenCalled(); + expect(written).toEqual([]); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain('valid HTTPS baseUrl'); + } + }); + + it('voice mode writes voiceModel for a selectable ASR model', async () => { + const setFastModel = vi.fn(); + const switchModel = vi.fn(async () => {}); + const config = resolvedConfig({ + setFastModel: setFastModel as Config['setFastModel'], + switchModel: switchModel as Config['switchModel'], + }); + const { settings, written } = createFakeSettings(); + const entries = [ + modelRow({ + id: 'qwen3-asr-flash', + baseUrl: 'https://asr.example/v1', + }), + ]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'voice', + selectionKey: entries[0].key, + }); + + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'voiceModel', + value: 'qwen3-asr-flash', + }); + expect( + written.find((write) => write.key === 'model.name'), + ).toBeUndefined(); + expect(switchModel).not.toHaveBeenCalled(); + expect(setFastModel).not.toHaveBeenCalled(); + expect(outcome.ok).toBe(true); + }); + + it('voice mode rejects models without transcription support', async () => { + const config = resolvedConfig({}); + const { settings, written } = createFakeSettings(); + const entries = [modelRow({ id: 'qwen3-coder' })]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'voice', + selectionKey: entries[0].key, + }); + + expect(written).toEqual([]); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain('cannot be used for transcription'); + } + }); + + it('voice mode rejects duplicate model ids across providers', async () => { + const config = resolvedConfig({}); + const { settings, written } = createFakeSettings(); + const entries = [ + modelRow({ + id: 'qwen3-asr-flash', + baseUrl: 'https://asr-one.example/v1', + }), + modelRow({ + id: 'qwen3-asr-flash', + baseUrl: 'https://asr-two.example/v1', + }), + ]; + + const outcome = await applyModelSelection({ + config, + settings, + entries, + mode: 'voice', + selectionKey: entries[0].key, + }); + + expect(written).toEqual([]); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.error).toContain('configured more than once'); + } + }); + }); +}); + +describe('applyThemeSelection', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('cancels silently for an undefined theme', () => { + const { settings, written } = createFakeSettings(); + expect(applyThemeSelection(settings, undefined, SettingScope.User)).toEqual( + {}, + ); + expect(written).toEqual([]); + }); + + it('persists and applies a valid theme', () => { + const setActiveTheme = vi + .spyOn(themeManager, 'setActiveTheme') + .mockReturnValue(true); + const { settings, written } = createFakeSettings(); + const result = applyThemeSelection(settings, 'Default', SettingScope.User); + expect(result).toEqual({ applied: 'Default' }); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'ui.theme', + value: 'Default', + }); + expect(setActiveTheme).toHaveBeenCalledWith('Default'); + }); + + it('applies a scope-local custom theme', () => { + const setActiveTheme = vi + .spyOn(themeManager, 'setActiveTheme') + .mockReturnValue(true); + const loadCustomThemes = vi + .spyOn(themeManager, 'loadCustomThemes') + .mockImplementation(() => {}); + const { settings } = createFakeSettings({ + user: { + ui: { customThemes: { mytheme: { name: 'mytheme' } } }, + } as unknown as Settings, + }); + const result = applyThemeSelection(settings, 'mytheme', SettingScope.User); + expect(result).toEqual({ applied: 'mytheme' }); + expect(loadCustomThemes).toHaveBeenCalled(); + expect(setActiveTheme).toHaveBeenCalledWith('mytheme'); + }); + + it('rejects themes unknown to the selected scope', () => { + vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false); + const { settings } = createFakeSettings(); + const result = applyThemeSelection( + settings, + 'no-such-theme', + SettingScope.User, + ); + expect(result.applied).toBeUndefined(); + expect(result.error).toContain('no-such-theme'); + }); +}); + +describe('permissions data and mutations', () => { + const addPersistentRule = vi.fn(); + const removePersistentRule = vi.fn(); + const config = stubConfig({ + getPermissionManager: (() => ({ + listRules: () => [ + { + rule: { raw: 'Bash(git *)', toolName: 'Bash' }, + type: 'allow', + scope: 'user', + }, + { + rule: { raw: 'Write', toolName: 'Write' }, + type: 'deny', + scope: 'session', + }, + ], + addPersistentRule, + removePersistentRule, + })) as unknown as Config['getPermissionManager'], + getWorkspaceContext: (() => ({ + getDirectories: () => ['/workspace/a'], + getInitialDirectories: () => ['/workspace/a'], + })) as unknown as Config['getWorkspaceContext'], + } as Partial); + + afterEach(() => { + addPersistentRule.mockClear(); + removePersistentRule.mockClear(); + }); + + it('maps PermissionManager rules and workspace directories', () => { + const data = buildPermissionsData(config); + expect(data.rules).toEqual([ + { raw: 'Bash(git *)', toolName: 'Bash', type: 'allow', scope: 'user' }, + { raw: 'Write', toolName: 'Write', type: 'deny', scope: 'session' }, + ]); + expect(data.directories).toEqual(['/workspace/a']); + expect(data.initialDirectories).toEqual(['/workspace/a']); + }); + + it('adds a rule to the manager and persists it to the chosen scope', () => { + const { settings, written } = createFakeSettings(); + addPermissionRule( + config, + settings, + 'Read(./src/**)', + 'allow', + SettingScope.Workspace, + ); + expect(addPersistentRule).toHaveBeenCalledWith('Read(./src/**)', 'allow'); + expect(written).toContainEqual({ + scope: SettingScope.Workspace, + key: 'permissions.allow', + value: ['Read(./src/**)'], + }); + }); + + it('does not duplicate an already-present rule', () => { + const { settings, written } = createFakeSettings({ + user: { permissions: { allow: ['Bash(git *)'] } } as Settings, + }); + // Reflect the existing rule in the merged view the code reads. + (settings.merged as Record)['permissions'] = { + allow: ['Bash(git *)'], + }; + addPermissionRule( + config, + settings, + 'Bash(git *)', + 'allow', + SettingScope.User, + ); + expect(written).toEqual([]); + }); + + it('deletes a rule from the manager and the persisting scope', () => { + const { settings, written } = createFakeSettings({ + user: { permissions: { allow: ['Bash(git *)'] } } as Settings, + }); + deletePermissionRule(config, settings, 'Bash(git *)', 'allow'); + expect(removePersistentRule).toHaveBeenCalledWith('Bash(git *)', 'allow'); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'permissions.allow', + value: [], + }); + }); +}); + +describe('mcp and extension feeds', () => { + const config = stubConfig({ + getMcpServers: (() => ({ + docs: { command: 'npx docs-mcp', scope: 'project' }, + legacy: {}, + })) as unknown as Config['getMcpServers'], + getToolRegistry: (() => ({ + getAllTools: () => [ + { serverName: 'docs', name: 'search', description: 'Search docs' }, + { serverName: 'docs', name: 'broken' }, + { + serverName: 'docs', + name: 'annotated', + description: 'Annotated', + annotations: { readOnlyHint: true }, + }, + { serverName: 'other', name: 'unrelated' }, + ], + })) as unknown as Config['getToolRegistry'], + getPromptRegistry: (() => ({ + getAllPrompts: () => [], + })) as unknown as Config['getPromptRegistry'], + getResourceRegistry: (() => ({ + getResourcesByServer: () => [], + })) as unknown as Config['getResourceRegistry'], + isMcpServerDisabled: ((name: string) => + name === 'legacy') as Config['isMcpServerDisabled'], + } as Partial); + + it('builds the MCP server inventory from config + registries', () => { + const servers = buildMcpServers(config); + expect(servers).toHaveLength(2); + const docs = servers.find((server) => server.name === 'docs'); + expect(docs).toMatchObject({ + source: 'project', + toolCount: 3, + invalidToolCount: 1, + isDisabled: false, + command: 'npx docs-mcp', + }); + expect(docs?.status).toBeTruthy(); + const legacy = servers.find((server) => server.name === 'legacy'); + expect(legacy).toMatchObject({ source: 'user', isDisabled: true }); + }); + + it('feeds the tool list with validity, invalidReason and annotations', () => { + const tools = getMcpServerTools(config, 'docs'); + expect(tools).toEqual([ + { name: 'search', description: 'Search docs', isValid: true }, + { + name: 'broken', + isValid: false, + invalidReason: 'missing description', + }, + { + name: 'annotated', + description: 'Annotated', + annotations: { readOnlyHint: true }, + isValid: true, + }, + ]); + }); + + it('builds extension rows from the loaded extensions', () => { + const rows = buildExtensionRows( + stubConfig({ + getExtensions: (() => [ + { name: 'ext-one', path: '/x/ext-one', isActive: true }, + { name: 'ext-two', path: '/x/ext-two', isActive: false }, + ]) as unknown as Config['getExtensions'], + } as Partial), + ); + expect(rows).toMatchObject([ + { key: 'ext-one', label: 'ext-one', meta: '/x/ext-one', enabled: true }, + { key: 'ext-two', label: 'ext-two', meta: '/x/ext-two', enabled: false }, + ]); + }); + + it('enriches extension rows with favorites, scopes and components', () => { + const manager = { + getFavorites: () => ['fav-ext'], + getExtensionScopes: () => ({ 'proj-ext': 'project' }), + }; + const rows = buildExtensionRows( + stubConfig({ + getExtensionManager: (() => + manager) as unknown as Config['getExtensionManager'], + getExtensions: (() => [ + { + name: 'fav-ext', + path: '/x/fav', + isActive: true, + version: '1.2.3', + installMetadata: { + source: 'https://github.com/a/b', + originSource: 'GitHub', + }, + mcpServers: { a: {}, b: {} }, + skills: [{}], + }, + { + name: 'proj-ext', + path: '/x/proj', + isActive: false, + }, + ]) as unknown as Config['getExtensions'], + } as Partial), + ); + expect(rows[0]).toMatchObject({ + favorite: true, + scope: 'user', + version: '1.2.3', + source: 'https://github.com/a/b', + origin: 'GitHub', + components: '2 MCP · 1 Skills', + }); + expect(rows[1]).toMatchObject({ + favorite: false, + scope: 'project', + components: 'None', + }); + }); + + it('feeds the resource list for one server', () => { + const config = stubConfig({ + getResourceRegistry: (() => ({ + getResourcesByServer: (name: string) => + name === 'docs' + ? [{ uri: 'file:///a.md', name: 'a', title: 'Doc A' }] + : [], + })) as unknown as Config['getResourceRegistry'], + } as Partial); + expect(getMcpServerResources(config, 'docs')).toEqual([ + { uri: 'file:///a.md', name: 'a', title: 'Doc A' }, + ]); + expect(getMcpServerResources(config, 'other')).toEqual([]); + }); +}); + +describe('MCP OAuth enrichment (real token state)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function serverInfo(name: string): McpServerInfo { + return { + name, + status: MCPServerStatus.DISCONNECTED, + source: 'user', + toolCount: 0, + invalidToolCount: 0, + promptCount: 0, + resourceCount: 0, + isDisabled: false, + hasOAuthTokens: false, + requiresAuth: false, + }; + } + + it('reads hasOAuthTokens from the token storage and derives requiresAuth', async () => { + const { MCPOAuthTokenStorage } = await import('@qwen-code/qwen-code-core'); + vi.spyOn( + MCPOAuthTokenStorage.prototype, + 'getCredentials', + ).mockImplementation(async (name: string) => + name === 'with-token' ? ({} as never) : null, + ); + const config = stubConfig({ + getMcpServers: (() => ({ + 'with-token': { oauth: { enabled: true } }, + 'no-token-oauth': { oauth: { enabled: true } }, + plain: {}, + })) as unknown as Config['getMcpServers'], + } as Partial); + const enriched = await enrichMcpOAuthState(config, [ + serverInfo('with-token'), + serverInfo('no-token-oauth'), + serverInfo('plain'), + ]); + expect(enriched[0]).toMatchObject({ + hasOAuthTokens: true, + requiresAuth: false, + }); + expect(enriched[1]).toMatchObject({ + hasOAuthTokens: false, + requiresAuth: true, + }); + expect(enriched[2]).toMatchObject({ + hasOAuthTokens: false, + requiresAuth: false, + }); + }); + + it('carries approvalState for gated scopes, unset otherwise', async () => { + const { MCPOAuthTokenStorage } = await import('@qwen-code/qwen-code-core'); + vi.spyOn( + MCPOAuthTokenStorage.prototype, + 'getCredentials', + ).mockResolvedValue(null); + const config = stubConfig({ + getMcpServers: (() => ({ + 'gated-srv': { scope: 'project' }, + 'user-srv': {}, + })) as unknown as Config['getMcpServers'], + getWorkingDir: (() => '/proj') as Config['getWorkingDir'], + } as Partial); + const enriched = await enrichMcpOAuthState(config, [ + serverInfo('gated-srv'), + serverInfo('user-srv'), + ]); + expect(enriched[0].approvalState).toBe('pending'); + expect(enriched[1].approvalState).toBeUndefined(); + }); +}); + +vi.mock('../../config/mcpApprovals.js', () => ({ + loadMcpApprovals: () => ({ + setState: vi.fn(), + getState: vi.fn(() => 'pending'), + }), +})); + +describe('applyMcpServerAction (real server actions)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function serverInfo(overrides: Partial): McpServerInfo { + return { + name: 'srv', + status: MCPServerStatus.DISCONNECTED, + source: 'user', + toolCount: 0, + invalidToolCount: 0, + promptCount: 0, + resourceCount: 0, + isDisabled: false, + hasOAuthTokens: false, + requiresAuth: false, + ...overrides, + }; + } + + it('reconnect rediscovers the server tools', async () => { + const discoverToolsForServer = vi.fn(async () => {}); + const config = stubConfig({ + getToolRegistry: (() => ({ + discoverToolsForServer, + })) as unknown as Config['getToolRegistry'], + } as Partial); + const { settings } = createFakeSettings(); + const result = await applyMcpServerAction( + config, + settings, + serverInfo({}), + 'reconnect', + ); + expect(discoverToolsForServer).toHaveBeenCalledWith('srv'); + expect(result.changed).toBe(true); + }); + + it('disable writes mcp.excluded to the user scope and disables the server', async () => { + const disableMcpServer = vi.fn(async () => {}); + const config = stubConfig({ + getMcpServers: (() => ({ + srv: {}, + })) as unknown as Config['getMcpServers'], + getToolRegistry: (() => ({ + disableMcpServer, + })) as unknown as Config['getToolRegistry'], + } as Partial); + const { settings, written } = createFakeSettings(); + const result = await applyMcpServerAction( + config, + settings, + serverInfo({}), + 'toggle-disable', + ); + expect(disableMcpServer).toHaveBeenCalledWith('srv'); + expect(written).toContainEqual({ + scope: SettingScope.User, + key: 'mcp.excluded', + value: ['srv'], + }); + expect(result.message).toContain('Disabled'); + }); + + it('project-scoped servers disable into the workspace scope', async () => { + const disableMcpServer = vi.fn(async () => {}); + const config = stubConfig({ + getMcpServers: (() => ({ + srv: { scope: 'project' }, + })) as unknown as Config['getMcpServers'], + getToolRegistry: (() => ({ + disableMcpServer, + })) as unknown as Config['getToolRegistry'], + } as Partial); + const { settings, written } = createFakeSettings(); + await applyMcpServerAction( + config, + settings, + serverInfo({ source: 'project' }), + 'toggle-disable', + ); + expect( + written.some( + (w) => w.scope === SettingScope.Workspace && w.key === 'mcp.excluded', + ), + ).toBe(true); + }); + + it('enable removes the exclusion and rediscovers', async () => { + const discoverToolsForServer = vi.fn(async () => {}); + const setExcludedMcpServers = vi.fn(); + const config = stubConfig({ + getMcpServers: (() => ({ + srv: {}, + })) as unknown as Config['getMcpServers'], + getToolRegistry: (() => ({ + discoverToolsForServer, + })) as unknown as Config['getToolRegistry'], + getExcludedMcpServers: (() => [ + 'srv', + ]) as unknown as Config['getExcludedMcpServers'], + setExcludedMcpServers, + } as Partial); + const { settings, written } = createFakeSettings({ + user: { mcp: { excluded: ['srv'] } } as Settings, + }); + const result = await applyMcpServerAction( + config, + settings, + serverInfo({ isDisabled: true }), + 'toggle-disable', + ); + expect(discoverToolsForServer).toHaveBeenCalledWith('srv'); + expect(setExcludedMcpServers).toHaveBeenCalledWith([]); + expect( + written.some( + (w) => w.key === 'mcp.excluded' && (w.value as string[]).length === 0, + ), + ).toBe(true); + expect(result.message).toContain('Enabled'); + }); + + it('clear-auth deletes stored tokens and disconnects', async () => { + const { MCPOAuthTokenStorage } = await import('@qwen-code/qwen-code-core'); + const deleteCredentials = vi + .spyOn(MCPOAuthTokenStorage.prototype, 'deleteCredentials') + .mockResolvedValue(undefined); + const disconnectServer = vi.fn(async () => {}); + const config = stubConfig({ + getToolRegistry: (() => ({ + disconnectServer, + })) as unknown as Config['getToolRegistry'], + } as Partial); + const { settings } = createFakeSettings(); + const result = await applyMcpServerAction( + config, + settings, + serverInfo({ hasOAuthTokens: true }), + 'clear-auth', + ); + expect(deleteCredentials).toHaveBeenCalledWith('srv'); + expect(disconnectServer).toHaveBeenCalledWith('srv'); + expect(result.changed).toBe(true); + }); + + it('authenticate passes httpUrl ahead of the SSE url', async () => { + const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core'); + const authenticate = vi + .spyOn(MCPOAuthProvider.prototype, 'authenticate') + .mockResolvedValue({} as OAuthToken); + const config = stubConfig({ + getMcpServers: (() => ({ + srv: { + oauth: { enabled: true }, + httpUrl: 'https://mcp.example/mcp', + url: 'https://mcp.example/sse', + }, + 'sse-only': { url: 'https://mcp.example/sse' }, + })) as unknown as Config['getMcpServers'], + getToolRegistry: (() => ({ + discoverToolsForServer: vi.fn(async () => {}), + })) as unknown as Config['getToolRegistry'], + } as Partial); + const { settings } = createFakeSettings(); + await applyMcpServerAction( + config, + settings, + serverInfo({ name: 'srv', hasOAuthTokens: true }), + 'authenticate', + ); + expect(authenticate).toHaveBeenCalledWith( + 'srv', + { enabled: true }, + 'https://mcp.example/mcp', + expect.any(EventEmitter), + ); + await applyMcpServerAction( + config, + settings, + serverInfo({ name: 'sse-only' }), + 'authenticate', + ); + expect(authenticate).toHaveBeenLastCalledWith( + 'sse-only', + { enabled: false }, + 'https://mcp.example/sse', + expect.any(EventEmitter), + ); + }); + + it('reports failures instead of throwing', async () => { + const config = stubConfig({ + getToolRegistry: (() => ({ + discoverToolsForServer: async () => { + throw new Error('boom'); + }, + })) as unknown as Config['getToolRegistry'], + } as Partial); + const { settings } = createFakeSettings(); + const result = await applyMcpServerAction( + config, + settings, + serverInfo({}), + 'reconnect', + ); + expect(result.message).toContain('boom'); + }); +}); + +describe('computeModelDialogInitialKey (/model opens on the current model)', () => { + function entry(overrides: Partial): OpenTuiModelEntry { + return { + key: overrides.key ?? 'key', + value: overrides.key ?? 'key', + authType: 'api_key', + label: overrides.label ?? 'Model', + modelId: overrides.modelId ?? overrides.model?.id ?? 'model', + ...overrides, + } as OpenTuiModelEntry; + } + + const baseEntries = [ + entry({ + key: buildModelSelectionKey( + AuthType.USE_OPENAI, + 'm1', + 'https://provider.example', + ), + authType: AuthType.USE_OPENAI, + modelId: 'm1', + }), + entry({ + key: buildModelSelectionKey(AuthType.USE_OPENAI, 'm2'), + authType: AuthType.USE_OPENAI, + modelId: 'm2', + }), + ]; + + it('highlights the exact current auth/model/baseUrl row in primary mode', () => { + const { settings } = createFakeSettings(); + const key = computeModelDialogInitialKey({ + config: stubConfig({ + getModel: () => 'm1', + getAuthType: () => AuthType.USE_OPENAI, + getContentGeneratorConfig: (() => ({ + baseUrl: 'https://provider.example', + })) as Config['getContentGeneratorConfig'], + } as Partial), + settings, + entries: baseEntries, + mode: 'primary', + }); + expect(key).toBe(baseEntries[0].key); + }); + + it('falls back to the same-id row when the baseUrl drifted', () => { + const { settings } = createFakeSettings(); + const key = computeModelDialogInitialKey({ + config: stubConfig({ + getModel: () => 'm2', + getAuthType: () => AuthType.USE_OPENAI, + getContentGeneratorConfig: (() => ({ + baseUrl: 'https://moved.example', + })) as Config['getContentGeneratorConfig'], + } as Partial), + settings, + entries: baseEntries, + mode: 'primary', + }); + expect(key).toBe(baseEntries[1].key); + }); + + it('prefers the active runtime snapshot id when present', () => { + const { settings } = createFakeSettings(); + const snapshotEntry = entry({ key: '$runtime|api_key|m1', modelId: 'm1' }); + const key = computeModelDialogInitialKey({ + config: stubConfig({ + getModel: () => 'm2', + getAuthType: () => AuthType.USE_OPENAI, + getActiveRuntimeModelSnapshot: (() => ({ + id: '$runtime|api_key|m1', + })) as Config['getActiveRuntimeModelSnapshot'], + } as Partial), + settings, + entries: [...baseEntries, snapshotEntry], + mode: 'primary', + }); + expect(key).toBe('$runtime|api_key|m1'); + }); + + it('highlights the entry owning the persisted aux selector', () => { + const settings = { + merged: { fastModel: `${AuthType.USE_OPENAI}:m1` }, + } as unknown as LoadedSettings; + const key = computeModelDialogInitialKey({ + config: stubConfig({}), + settings, + entries: baseEntries, + mode: 'fast', + }); + expect(key).toBe(baseEntries[0].key); + }); + + it('does not split a colon-bearing model id that is not an authType prefix', () => { + const settings = { + merged: { fastModel: 'gpt-4o:online' }, + } as unknown as LoadedSettings; + const colonEntry = entry({ + key: buildModelSelectionKey(AuthType.USE_OPENAI, 'gpt-4o:online'), + authType: AuthType.USE_OPENAI, + modelId: 'gpt-4o:online', + }); + const key = computeModelDialogInitialKey({ + config: stubConfig({}), + settings, + entries: [...baseEntries, colonEntry], + mode: 'fast', + }); + expect(key).toBe(colonEntry.key); + }); + + it('returns undefined without a current model (dialog starts on row 1)', () => { + const { settings } = createFakeSettings(); + const key = computeModelDialogInitialKey({ + config: stubConfig({ getModel: () => 'unknown-model' }), + settings, + entries: baseEntries, + mode: 'primary', + }); + expect(key).toBeUndefined(); + expect( + computeModelDialogInitialKey({ + config: stubConfig({}), + settings, + entries: [], + mode: 'primary', + }), + ).toBeUndefined(); + }); +}); + +describe('extension management actions (audit 01 G-4)', () => { + function createFakeManager(overrides: Record = {}) { + return { + getExtensionScope: vi.fn(() => 'user' as const), + getExtensionScopes: vi.fn(() => ({})), + getFavorites: vi.fn(() => [] as string[]), + toggleFavorite: vi.fn(() => true), + enableExtension: vi.fn(async () => ({ warnings: [] })), + disableExtension: vi.fn(async () => ({ warnings: [] })), + refreshCache: vi.fn(async () => {}), + uninstallExtension: vi.fn(async () => ({ warnings: [] })), + setExtensionActivationScope: vi.fn(async () => ({ warnings: [] })), + setExtensionScope: vi.fn(() => {}), + updateExtension: vi.fn(async () => ({ warnings: [] })), + ...overrides, + }; + } + + function extensionConfig( + manager: unknown, + extensions: Array> = [ + { name: 'ext-a', id: 'id-a', path: '/x/a', isActive: true }, + ], + ): Config { + return stubConfig({ + getExtensionManager: (() => + manager) as unknown as Config['getExtensionManager'], + getExtensions: (() => extensions) as unknown as Config['getExtensions'], + } as Partial); + } + + it('toggles an active extension off through disableExtension', async () => { + const manager = createFakeManager(); + const result = await applyExtensionToggle( + extensionConfig(manager), + 'ext-a', + true, + ); + expect(manager.disableExtension).toHaveBeenCalledWith('ext-a', 'User'); + expect(manager.refreshCache).toHaveBeenCalled(); + expect(result).toEqual({ + message: '"ext-a" disabled.', + changed: true, + level: 'success', + }); + }); + + it('toggles a project-scoped extension on through enableExtension(Workspace)', async () => { + const manager = createFakeManager({ + getExtensionScope: vi.fn(() => 'project' as const), + }); + const result = await applyExtensionToggle( + extensionConfig(manager), + 'ext-a', + false, + ); + expect(manager.enableExtension).toHaveBeenCalledWith('ext-a', 'Workspace'); + expect(result).toMatchObject({ changed: true, level: 'success' }); + expect(result.message).toContain('enabled'); + }); + + it('surfaces manager warnings and failures on toggle', async () => { + const warnManager = createFakeManager({ + disableExtension: vi.fn(async () => ({ + warnings: [{ error: 'stale cache' }], + })), + }); + const warned = await applyExtensionToggle( + extensionConfig(warnManager), + 'ext-a', + true, + ); + expect(warned.level).toBe('warning'); + expect(warned.message).toContain('stale cache'); + + const failing = createFakeManager({ + disableExtension: vi.fn(async () => { + throw new Error('boom'); + }), + }); + const errored = await applyExtensionToggle( + extensionConfig(failing), + 'ext-a', + true, + ); + expect(errored).toMatchObject({ changed: false, level: 'error' }); + expect(errored.message).toContain('boom'); + }); + + it('toggles the favorite preference and reports the new state', () => { + const manager = createFakeManager({ + toggleFavorite: vi.fn(() => false), + }); + const result = applyExtensionFavorite(extensionConfig(manager), 'ext-a'); + expect(manager.toggleFavorite).toHaveBeenCalledWith('ext-a'); + expect(result).toEqual({ + message: 'Removed "ext-a" from favorites.', + changed: true, + level: 'info', + }); + }); + + it('uninstalls an extension and reloads the cache', async () => { + const manager = createFakeManager(); + const result = await applyExtensionUninstall( + extensionConfig(manager), + 'ext-a', + ); + expect(manager.uninstallExtension).toHaveBeenCalledWith('ext-a', false); + expect(manager.refreshCache).toHaveBeenCalled(); + expect(result).toMatchObject({ changed: true, level: 'success' }); + expect(result.message).toContain('Uninstalled'); + }); + + it('changes the scope with both activation-scope and preference writes', async () => { + const manager = createFakeManager(); + const result = await applyExtensionScopeChange( + extensionConfig(manager), + 'ext-a', + 'project', + ); + expect(manager.setExtensionActivationScope).toHaveBeenCalledWith('id-a', { + scope: 'workspace', + workspacePath: process.cwd(), + }); + expect(manager.setExtensionScope).toHaveBeenCalledWith('ext-a', 'project'); + expect(result).toMatchObject({ changed: true, level: 'success' }); + expect(result.message).toContain('Project'); + }); + + it('reports a preference-write failure as a scope warning', async () => { + const manager = createFakeManager({ + setExtensionScope: vi.fn(() => { + throw new Error('pref locked'); + }), + }); + const result = await applyExtensionScopeChange( + extensionConfig(manager), + 'ext-a', + 'user', + ); + expect(result.level).toBe('warning'); + expect(result.message).toContain('pref locked'); + }); + + it('maps the update check onto dialog states (not updatable path)', async () => { + const config = extensionConfig(createFakeManager(), [ + { + name: 'ext-a', + id: 'id-a', + path: '/x/a', + isActive: true, + installMetadata: { type: 'local', source: 'upload:foo' }, + }, + ]); + const result = await applyExtensionUpdateCheck(config, 'ext-a'); + expect(result.state).toBe('not-updatable'); + expect(result.message).toContain('does not support update checks'); + }); + + it('degrades honestly without an extension manager', async () => { + const config = stubConfig({} as Partial); + expect(await applyExtensionToggle(config, 'ext-a', true)).toMatchObject({ + changed: false, + level: 'error', + }); + expect(applyExtensionFavorite(config, 'ext-a')).toMatchObject({ + changed: false, + level: 'error', + }); + expect(await applyExtensionUninstall(config, 'ext-a')).toMatchObject({ + changed: false, + level: 'error', + }); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialog-data.ts b/packages/cli/src/ui/opentui/dialog-data.ts new file mode 100644 index 00000000000..7fc9764c91a --- /dev/null +++ b/packages/cli/src/ui/opentui/dialog-data.ts @@ -0,0 +1,1282 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Dialog data + result wiring for the mounted OpenTUI dialog family (R2). + * + * The ported dialogs are presentational; ink fed them through hooks and + * UIActions (DialogManager.tsx). The OpenTUI backend reproduces that feeding + * here with plain functions over `Config` / `LoadedSettings`: + * + * - model list entries + the ModelDialog selection pipeline + * (`applyModelSelection`): mode-specific validation, the runtime + * switch/setter (`Config.switchModel`, setFastModel/setVisionModel/ + * setCompactionModel/setImageModel), and persistence only after the + * runtime change succeeded; + * - permission rules / workspace directories (+ mutation handlers), + * - MCP server inventory, + * - extension rows, + * - theme selection (useThemeCommand parity). + */ + +import process from 'node:process'; +import { EventEmitter } from 'node:events'; +import { + AuthType, + checkForExtensionUpdate, + ExtensionUpdateState, + getMCPServerStatus, + isGatedMcpScope, + isImageCapable, + isImageGenerationCapable, + logModelSlashCommand, + matchesAnyServerPattern, + MCPOAuthProvider, + MCPOAuthTokenStorage, + mcpServerRequiresOAuth, + MCPServerStatus, + ModelSlashCommandEvent, + OAUTH_AUTH_URL_EVENT, + OAUTH_DISPLAY_MESSAGE_EVENT, + parseVisionModelSetting, + redactUrlCredentials, + removeMCPServerStatus, + resolveModelId, + SettingScope as CoreSettingScope, +} from '@qwen-code/qwen-code-core'; +import type { + Config, + ContentGeneratorConfig, + Extension, + MCPServerConfig, + ResolvedModelId, +} from '@qwen-code/qwen-code-core'; +import { SettingScope } from '../../config/settings.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { loadMcpApprovals } from '../../config/mcpApprovals.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { t } from '../../i18n/index.js'; +import { getErrorMessage } from '../../utils/errors.js'; +import { getToolInvalidReasons, isToolValid } from '../components/mcp/utils.js'; +import { themeManager, AUTO_THEME_NAME } from '../themes/theme-manager.js'; +import { + isSelectableVoiceModel, + formatUnsupportedVoiceModelMessage, +} from '../voice/voice-model.js'; +import { + buildModelSelectionKey, + encodeAuxModelSelector, + encodeVisionModelSelector, + maskApiKey, + parseModelSelectionKey, + type ModelDialogMode, + type OpenTuiModelEntry, +} from './dialogs-model.js'; +import type { PermissionRuleEntry } from './dialogs-permissions.js'; +import type { + McpResourceInfo, + McpServerAction, + McpServerInfo, + McpToolInfo, +} from './dialogs-mcp.js'; +import type { ExtensionRow } from './dialogs-extensions.js'; + +/** + * Model list parity of ModelDialog's `availableModelEntries`: runtime entries + * are listed (tagged) outside image mode, QWEN_OAUTH models only under that + * auth type, imageOnly/fastOnly/voiceOnly/visionOnly entries only in their + * own selector modes, and image-mode rows additionally must resolve through + * `Config.resolveImageGenerationModel`. Rows are keyed like ink's option + * values: runtime rows by their `$runtime|...` snapshot id, registry rows by + * `authType::modelId[\0baseUrl]`. The raw registry entry travels on `model` + * so selection-time validation matches the ink dialog. + */ +export function buildModelEntries( + config: Config | null | undefined, + mode: ModelDialogMode, +): OpenTuiModelEntry[] { + const allModels = config?.getAllConfiguredModels?.() ?? []; + const authType = config?.getAuthType?.(); + const entries: OpenTuiModelEntry[] = []; + for (const model of allModels) { + if (mode === 'image') { + // ink gates on isImageGenerationCapable (not just imageOnly): dual-role + // models with supportsImageGeneration and visionOnly image-capable + // models must both appear in the image selector. + if (model.isRuntimeModel || !isImageGenerationCapable(model)) continue; + const selector = encodeVisionModelSelector( + buildModelSelectionKey(model.authType, model.id, model.baseUrl), + ); + if (config?.resolveImageGenerationModel?.(selector) === undefined) { + continue; + } + } + if (mode !== 'image' && model.imageOnly) continue; + if (!model.isRuntimeModel) { + if ( + model.authType === AuthType.QWEN_OAUTH && + authType !== AuthType.QWEN_OAUTH + ) { + continue; + } + if (mode !== 'fast' && model.fastOnly) continue; + if (mode !== 'voice' && model.voiceOnly) continue; + // ink keeps visionOnly models in vision AND image mode + // (ModelDialog.tsx: isVisionModelMode || isImageModelMode || !m.visionOnly). + if (mode !== 'vision' && mode !== 'image' && model.visionOnly) continue; + } + const key = + model.isRuntimeModel && model.runtimeSnapshotId + ? model.runtimeSnapshotId + : buildModelSelectionKey( + String(model.authType ?? ''), + model.id, + model.baseUrl, + ); + entries.push({ + key, + value: key, + authType: String(model.authType ?? ''), + label: model.label || model.id, + modelId: model.id, + ...(model.description ? { description: model.description } : {}), + isRuntime: model.isRuntimeModel ?? false, + isQwenOAuth: model.authType === AuthType.QWEN_OAUTH, + ...(model.modalities ? { modalities: model.modalities } : {}), + ...(model.contextWindowSize + ? { contextWindowSize: model.contextWindowSize } + : {}), + ...(model.baseUrl ? { baseUrl: model.baseUrl } : {}), + ...(model.envKey ? { envKey: model.envKey } : {}), + model, + }); + } + return entries; +} + +/** Parity of ModelDialog's `resolvePersistScope`. */ +export function resolveModelPersistScope( + settings: LoadedSettings, + persistScope?: 'workspace' | 'user', +): SettingScope { + // Workspace settings are ignored when untrusted, so fall back to user scope. + if (persistScope === 'workspace' && !settings.isTrusted) { + return SettingScope.User; + } + if (persistScope === 'workspace') return SettingScope.Workspace; + if (persistScope === 'user') return SettingScope.User; + return getPersistScopeForModelSelection(settings); +} + +/** + * The selection key to highlight when the `/model` dialog opens (ink + * ModelDialog `preferredKey` parity): the active runtime snapshot or the + * current auth/model/baseUrl row in primary mode, and the entry owning the + * persisted selector in the auxiliary modes (fast/voice/vision/compaction/ + * image). Returns undefined when nothing matches (the dialog then starts on + * the first row, as in ink's `initialIndex === -1 → 0` fallback). + */ +export function computeModelDialogInitialKey(params: { + config: Config | null | undefined; + settings: LoadedSettings; + entries: readonly OpenTuiModelEntry[]; + mode: ModelDialogMode; +}): string | undefined { + const { config, settings, entries, mode } = params; + if (entries.length === 0) return undefined; + const byKey = new Map(entries.map((entry) => [entry.key, entry])); + + if (mode === 'primary') { + const snapshotId = config?.getActiveRuntimeModelSnapshot?.()?.id?.trim(); + if (snapshotId && byKey.has(snapshotId)) return snapshotId; + + const rawModel = config?.getModel(); + const modelId = + typeof rawModel === 'string' + ? rawModel + : ((rawModel as { id?: string } | undefined)?.id ?? undefined); + if (!modelId) return undefined; + const baseUrl = config?.getContentGeneratorConfig?.()?.baseUrl; + const authType = String(config?.getAuthType?.() ?? ''); + const exact = buildModelSelectionKey(authType, modelId, baseUrl); + if (byKey.has(exact)) return exact; + // No same-provider row (e.g. baseUrl drift): any same-id row beats + // defaulting to the list head. + return entries.find((entry) => entry.modelId === modelId)?.key; + } + + const rawSetting = + mode === 'fast' + ? settings.merged.fastModel + : mode === 'voice' + ? settings.merged.voiceModel + : mode === 'vision' + ? settings.merged.visionModel + : mode === 'compaction' + ? settings.merged.compactionModel + : settings.merged.imageModel; + if (typeof rawSetting !== 'string' || !rawSetting.trim()) return undefined; + const trimmed = rawSetting.trim(); + if (byKey.has(trimmed)) return trimmed; + const parsed = parseVisionModelSetting(trimmed); + if (!parsed) return undefined; + // Core splits only on a known-AuthType prefix — model IDs may themselves + // contain colons (e.g. gpt-4o:online), which a raw first-colon split + // mangles (ink: parsed*ModelSetting in ModelDialog). + let resolved: ResolvedModelId | undefined; + try { + resolved = resolveModelId(parsed.selector); + } catch { + resolved = undefined; + } + if (!resolved) return undefined; + const selectorModelId = resolved.modelId; + const selectorAuth = resolved.authType + ? String(resolved.authType) + : undefined; + const match = entries.find((entry) => { + if (entry.modelId !== selectorModelId) return false; + if (selectorAuth && entry.authType !== selectorAuth) return false; + if (parsed.baseUrl && entry.baseUrl !== parsed.baseUrl) return false; + return true; + }); + return match?.key ?? entries.find((e) => e.modelId === selectorModelId)?.key; +} + +function persistScopeSuffix(persistScope?: 'workspace' | 'user'): string { + return persistScope === 'workspace' + ? t(' (this project)') + : persistScope === 'user' + ? t(' (global)') + : ''; +} + +/** Parity of ModelDialog's `hydrateApiKeyEnvFromSettings`. */ +function hydrateApiKeyEnvFromSettings( + settings: LoadedSettings, + envKey: string | undefined, +): void { + if (!envKey || process.env[envKey]) { + return; + } + const settingsEnvValue = ( + settings?.merged?.env as Record | undefined + )?.[envKey]; + if ( + typeof settingsEnvValue === 'string' && + settingsEnvValue.trim().length > 0 + ) { + process.env[envKey] = settingsEnvValue; + } +} + +/** Outcome of one model-dialog selection (parity of ModelDialog.handleSelect). */ +export type ModelSelectionOutcome = + /** Apply succeeded; the dialog closes; `message` goes to the history. */ + | { ok: true; message?: string } + /** Validation/runtime switch failed; the dialog stays open with `error`. */ + | { ok: false; error: string }; + +export interface ApplyModelSelectionParams { + config: Config | null | undefined; + settings: LoadedSettings; + /** The entries the dialog shows (selection-time validation input). */ + entries: readonly OpenTuiModelEntry[]; + mode: ModelDialogMode; + selectionKey: string; + persistScope?: 'workspace' | 'user'; +} + +/** + * Parity of ModelDialog's `handleSelect`: mode-specific validation and the + * runtime switch/setter come FIRST; settings are persisted only after the + * runtime change succeeded. Fast/vision/compaction/image modes write their + * own setting key (`fastModel` / `visionModel` / `compactionModel` / + * `imageModel`) — never the generic `model.name` — and primary mode calls + * `Config.switchModel` before persisting `model.name` / `model.baseUrl`. + * Validation failures return the error so the dialog stays open. + */ +export async function applyModelSelection( + params: ApplyModelSelectionParams, +): Promise { + const { config, settings, entries, mode, selectionKey, persistScope } = + params; + const selectedEntry = entries.find((entry) => entry.key === selectionKey); + const scopeSuffix = persistScopeSuffix(persistScope); + + if (mode === 'voice') { + if (!selectedEntry?.model) { + return { ok: false, error: t('Selected voice model is unavailable.') }; + } + const voiceModel = selectedEntry.model.id; + if (!isSelectableVoiceModel(selectedEntry.model)) { + return { + ok: false, + error: formatUnsupportedVoiceModelMessage(voiceModel), + }; + } + const matchingEntries = entries.filter( + (entry) => entry.model?.id === voiceModel, + ); + if (matchingEntries.length > 1) { + return { + ok: false, + error: t( + "Voice model '{{model}}' is configured more than once. Remove duplicate model ids before selecting it for voice transcription.", + { model: voiceModel }, + ), + }; + } + const scope = resolveModelPersistScope(settings, persistScope); + settings.setValue(scope, 'voiceModel', voiceModel); + return { + ok: true, + message: `${t('Voice Model')}: ${voiceModel}${scopeSuffix}`, + }; + } + + hydrateApiKeyEnvFromSettings(settings, selectedEntry?.model?.envKey); + + // Fast model mode: save authType:modelId so duplicate model ids across + // providers remain unambiguous. baseUrl is intentionally discarded. + if (mode === 'fast') { + const fastModel = encodeAuxModelSelector(selectionKey); + // Sync the runtime Config so forked agents pick up the change immediately. + config?.setFastModel?.(fastModel); + const scope = resolveModelPersistScope(settings, persistScope); + settings.setValue(scope, 'fastModel', fastModel); + return { + ok: true, + message: `${t('Fast Model')}: ${fastModel}${scopeSuffix}`, + }; + } + + if (mode === 'vision') { + const visionModel = encodeVisionModelSelector(selectionKey); + const visionModelDisplay = + parseVisionModelSetting(visionModel)?.selector ?? visionModel; + // Pinning the primary itself is ignored by the bridge at runtime, so + // reject it here instead of persisting a dead pin and reporting success. + if ( + selectedEntry?.model && + config?.isCurrentPrimaryModel?.(selectedEntry.model) + ) { + return { + ok: false, + error: t( + "'{{model}}' is the current primary model and cannot be used as the vision bridge.", + { model: visionModelDisplay }, + ), + }; + } + // Sync runtime Config so the vision bridge picks it up without a restart. + config?.setVisionModel?.(visionModel); + const scope = resolveModelPersistScope(settings, persistScope); + settings.setValue(scope, 'visionModel', visionModel); + // Honor the pin even if the model isn't image-capable, but warn — the + // bridge will send images to it. + const visionWarning = + selectedEntry?.model && !isImageCapable(selectedEntry.model) + ? `\n${t("⚠ '{{model}}' is not a known image-capable model; the vision bridge may fail on images.", { model: visionModelDisplay })}` + : ''; + return { + ok: true, + message: `${t('Vision Model')}: ${visionModelDisplay}${scopeSuffix}${visionWarning}`, + }; + } + + if (mode === 'compaction') { + if (!selectedEntry || !config) { + return { + ok: false, + error: t('Selected compaction model is unavailable.'), + }; + } + const compactionModelId = encodeAuxModelSelector(selectionKey); + // Sync runtime Config so the compression service picks it up immediately. + config.setCompactionModel(compactionModelId); + const scope = resolveModelPersistScope(settings, persistScope); + settings.setValue(scope, 'compactionModel', compactionModelId); + return { + ok: true, + message: `${t('Compaction Model')}: ${compactionModelId}${scopeSuffix}`, + }; + } + + if (mode === 'image') { + if (!selectedEntry || !config) { + return { ok: false, error: t('Selected image model is unavailable.') }; + } + const imageModel = encodeVisionModelSelector(selectionKey); + const imageModelDisplay = + parseVisionModelSetting(imageModel)?.selector ?? imageModel; + if (!config.resolveImageGenerationModel?.(imageModel)) { + return { + ok: false, + error: t( + "'{{model}}' must declare a valid HTTPS baseUrl and credential environment variable.", + { model: imageModelDisplay }, + ), + }; + } + await config.setImageModel(imageModel); + const scope = resolveModelPersistScope(settings, persistScope); + settings.setValue(scope, 'imageModel', imageModel); + return { + ok: true, + message: `${t('Image Model')}: ${imageModelDisplay}${scopeSuffix}`, + }; + } + + // Primary mode. Block selection of discontinued qwen-oauth models + // (only block non-runtime OAuth; runtime OAuth models from existing + // cached tokens are still allowed to work until the server rejects them). + const isQwenOAuthSelection = + selectionKey.startsWith(`${AuthType.QWEN_OAUTH}::`) || + (selectionKey.startsWith('$runtime|') && + selectionKey.split('|')[1] === AuthType.QWEN_OAUTH); + const isRuntimeOAuthSelection = selectionKey.startsWith( + `$runtime|${AuthType.QWEN_OAUTH}|`, + ); + if (isQwenOAuthSelection && !isRuntimeOAuthSelection) { + return { + ok: false, + error: t( + 'Qwen OAuth free tier was discontinued on 2026-04-15. Please select a model from another provider or run /auth to switch.', + ), + }; + } + + if (!config) { + return { ok: true }; + } + + // Runtime model format: $runtime|${authType}|${modelId} + const isRuntime = selectionKey.startsWith('$runtime|'); + const authType = config.getAuthType?.(); + let selectedAuthType: AuthType; + let modelId: string; + let selectedBaseUrl: string | undefined; + if (isRuntime) { + const parts = selectionKey.split('|'); + selectedAuthType = ( + parts.length >= 2 && parts[0] === '$runtime' ? parts[1] : authType + ) as AuthType; + modelId = selectionKey; // Pass the full snapshot ID to switchModel + } else { + const parsed = parseModelSelectionKey(selectionKey); + selectedAuthType = (parsed.authType || authType) as AuthType; + modelId = parsed.modelId; + selectedBaseUrl = parsed.baseUrl; + } + + let after: ContentGeneratorConfig | undefined; + try { + await config.switchModel(selectedAuthType, modelId, { + ...(selectedAuthType !== authType && + selectedAuthType === AuthType.QWEN_OAUTH + ? { requireCachedCredentials: true } + : {}), + baseUrl: selectedBaseUrl, + }); + if (!isRuntime) { + logModelSlashCommand(config, new ModelSlashCommandEvent(modelId)); + } + after = config.getContentGeneratorConfig?.(); + } catch (e) { + const baseErrorMessage = e instanceof Error ? e.message : String(e); + // Use parsed modelId for display to avoid showing raw selection key + // (which contains invisible \0 separator between modelId and baseUrl). + const displayModelId = isRuntime + ? modelId + : parseModelSelectionKey(selectionKey).modelId; + const errorPrefix = isRuntime + ? 'Failed to switch to runtime model.' + : `Failed to switch model to '${displayModelId}'.`; + return { ok: false, error: `${errorPrefix}\n\n${baseErrorMessage}` }; + } + + const effectiveAuthType = after?.authType ?? selectedAuthType ?? authType; + const effectiveModelId = after?.model ?? modelId; + // Persist the selected provider's baseUrl so the right provider is restored + // next launch when several share the same id; fall back to the picker + // entry's baseUrl. Runtime models are keyed by snapshot id, so no + // disambiguator. + const effectiveBaseUrl = isRuntime + ? undefined + : (after?.baseUrl ?? selectedEntry?.model?.baseUrl); + + // Persist only after the runtime switch succeeded. + const scope = resolveModelPersistScope(settings, persistScope); + settings.setValue(scope, 'model.name', effectiveModelId); + settings.setValue(scope, 'model.baseUrl', effectiveBaseUrl ?? ''); + if (effectiveAuthType) { + settings.setValue(scope, 'security.auth.selectedType', effectiveAuthType); + } + + const baseUrl = after?.baseUrl ?? t('(default)'); + const maskedKey = maskApiKey(after?.apiKey); + return { + ok: true, + message: + `authType: ${effectiveAuthType ?? `(${t('none')})`}` + + `\n` + + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + + `\n` + + `Base URL: ${baseUrl}` + + `\n` + + `API key: ${maskedKey}`, + }; +} + +function persistScopeToSettingScope( + persistScope: 'workspace' | 'user', +): SettingScope { + return persistScope === 'workspace' + ? SettingScope.Workspace + : SettingScope.User; +} + +/** Parity of useThemeCommand's handleThemeSelect (cancel = undefined). */ +export interface ThemeSelectionResult { + applied?: string; + error?: string; +} + +export function applyThemeSelection( + settings: LoadedSettings, + themeName: string | undefined, + scope: SettingScope, +): ThemeSelectionResult { + if (themeName === undefined) { + return {}; + } + const mergedCustomThemes = { + ...(settings.user.settings.ui?.customThemes || {}), + ...(settings.workspace.settings.ui?.customThemes || {}), + }; + const isAuto = themeName === AUTO_THEME_NAME; + const isBuiltIn = themeManager.findThemeByName(themeName); + const isCustom = themeName && mergedCustomThemes[themeName]; + if (!isAuto && !isBuiltIn && !isCustom) { + return { + error: t('Theme "{{themeName}}" not found in selected scope.', { + themeName: themeName ?? '', + }), + }; + } + settings.setValue(scope, 'ui.theme', themeName); + if (settings.merged.ui?.customThemes) { + themeManager.loadCustomThemes(settings.merged.ui.customThemes); + } + const effective = settings.merged.ui?.theme; + themeManager.setActiveTheme(effective ?? AUTO_THEME_NAME); + return { applied: themeName }; +} + +export interface PermissionsData { + rules: PermissionRuleEntry[]; + directories: readonly string[]; + initialDirectories: readonly string[]; +} + +export function buildPermissionsData( + config: Config | null | undefined, +): PermissionsData { + const manager = config?.getPermissionManager?.(); + const rules = (manager?.listRules() ?? []).map((entry) => ({ + raw: entry.rule.raw, + toolName: entry.rule.toolName, + type: entry.type, + scope: entry.scope, + })); + const workspace = config?.getWorkspaceContext(); + return { + rules, + directories: workspace?.getDirectories() ?? [], + initialDirectories: workspace?.getInitialDirectories() ?? [], + }; +} + +/** Parity of PermissionsDialog's scope-select mutation. */ +export function addPermissionRule( + config: Config | null | undefined, + settings: LoadedSettings, + ruleText: string, + type: PermissionRuleEntry['type'], + scope: SettingScope, +): void { + const manager = config?.getPermissionManager?.(); + manager?.addPersistentRule(ruleText, type); + const key = `permissions.${type}`; + const current = + ( + (settings.merged as Record)['permissions'] as + | Record + | undefined + )?.[type] ?? []; + if (!current.includes(ruleText)) { + settings.setValue(scope, key, [...current, ruleText]); + } +} + +/** Parity of PermissionsDialog's delete-confirm mutation. */ +export function deletePermissionRule( + config: Config | null | undefined, + settings: LoadedSettings, + raw: string, + type: PermissionRuleEntry['type'], +): void { + const manager = config?.getPermissionManager?.(); + manager?.removePersistentRule(raw, type); + for (const scope of ['user', 'workspace'] as const) { + const settingScope = persistScopeToSettingScope(scope); + const scopeSettings = settings.forScope(settingScope).settings; + const rules = (scopeSettings as Record)['permissions'] as + | Record + | undefined; + const scopeRules = rules?.[type]; + if (scopeRules?.includes(raw)) { + settings.setValue( + settingScope, + `permissions.${type}`, + scopeRules.filter((rule) => rule !== raw), + ); + break; + } + } +} + +/** MCP server inventory parity of MCPManagementDialog's fetchServerData. */ +export function buildMcpServers( + config: Config | null | undefined, +): McpServerInfo[] { + const servers = config?.getMcpServers?.() ?? {}; + const toolRegistry = config?.getToolRegistry?.(); + const promptRegistry = config?.getPromptRegistry?.(); + const resourceRegistry = config?.getResourceRegistry?.(); + const infos: McpServerInfo[] = []; + for (const [name, serverConfig] of Object.entries(servers)) { + const typedConfig = serverConfig as { + extensionName?: string; + scope?: string; + command?: string; + cwd?: string; + }; + let source: McpServerInfo['source'] = 'user'; + if (typedConfig.extensionName) source = 'extension'; + else if (typedConfig.scope === 'project') source = 'project'; + else if (typedConfig.scope === 'workspace') source = 'workspace'; + else if (typedConfig.scope === 'system') source = 'system'; + const allTools = toolRegistry?.getAllTools() ?? []; + const serverTools = allTools.filter( + (tool) => (tool as { serverName?: string }).serverName === name, + ); + const allPrompts = promptRegistry?.getAllPrompts() ?? []; + const serverPrompts = allPrompts.filter((prompt) => + 'serverName' in prompt + ? (prompt as { serverName?: string }).serverName === name + : false, + ); + infos.push({ + name, + status: getMCPServerStatus(name), + source, + toolCount: serverTools.length, + invalidToolCount: serverTools.filter( + (tool) => !tool.name || !tool.description, + ).length, + promptCount: serverPrompts.length, + resourceCount: resourceRegistry?.getResourcesByServer(name)?.length ?? 0, + isDisabled: config?.isMcpServerDisabled(name) ?? false, + hasOAuthTokens: false, + requiresAuth: false, + ...(typedConfig.command ? { command: typedConfig.command } : {}), + ...(typedConfig.cwd ? { workingDirectory: typedConfig.cwd } : {}), + }); + } + return infos; +} + +/** + * Tool detail feed for the MCP dialog's tool list step (ink getServerTools + * parity: validity via isToolValid, invalidReason, and tool annotations). + */ +export function getMcpServerTools( + config: Config | null | undefined, + serverName: string, +): McpToolInfo[] { + const allTools = config?.getToolRegistry?.()?.getAllTools() ?? []; + return allTools + .filter( + (tool) => (tool as { serverName?: string }).serverName === serverName, + ) + .map((tool) => { + const discovered = tool as { + name?: string; + description?: string; + annotations?: McpToolInfo['annotations']; + }; + const isValid = isToolValid(discovered.name, discovered.description); + const invalidReason = isValid + ? undefined + : getToolInvalidReasons(discovered.name, discovered.description).join( + ', ', + ); + return { + name: discovered.name ?? '', + ...(discovered.description + ? { description: discovered.description } + : {}), + ...(discovered.annotations + ? { annotations: discovered.annotations } + : {}), + isValid, + ...(invalidReason ? { invalidReason } : {}), + }; + }); +} + +/** Resource feed for the MCP dialog's resource list step. */ +export function getMcpServerResources( + config: Config | null | undefined, + serverName: string, +): McpResourceInfo[] { + const resources = + config?.getResourceRegistry?.()?.getResourcesByServer(serverName) ?? []; + return resources.map((resource) => { + const r = resource as { uri?: string; name?: string; title?: string }; + return { + uri: r.uri ?? '', + ...(r.name ? { name: r.name } : {}), + ...(r.title ? { title: r.title } : {}), + }; + }); +} + +/** + * Real OAuth token state (audit 01 G-6): ink's MCPManagementDialog reads + * `MCPOAuthTokenStorage.getCredentials` per server and derives `requiresAuth` + * from the 401 marker / declared-but-tokenless OAuth. `buildMcpServers` is + * synchronous, so this async pass enriches its output before mounting. + */ +export async function enrichMcpOAuthState( + config: Config | null | undefined, + servers: McpServerInfo[], +): Promise { + const mcpServers = config?.getMcpServers?.() ?? {}; + const tokenStorage = new MCPOAuthTokenStorage(); + // Approval state is keyed by the same project root discovery gated on + // (`config.getWorkingDir()`, ink fetchServerData parity). + const approvalRoot = config?.getWorkingDir?.(); + const approvals = approvalRoot ? loadMcpApprovals() : undefined; + const enriched: McpServerInfo[] = []; + for (const info of servers) { + let hasOAuthTokens = false; + try { + hasOAuthTokens = (await tokenStorage.getCredentials(info.name)) !== null; + } catch { + // Unreadable token store = no tokens. + } + const serverConfig = mcpServers[info.name] as MCPServerConfig | undefined; + const status = getMCPServerStatus(info.name); + const requiresAuth = + status !== MCPServerStatus.CONNECTED && + (mcpServerRequiresOAuth.get(info.name) === true || + (Boolean(serverConfig?.oauth?.enabled) && !hasOAuthTokens)); + // Why a gated (#4615) server is skipped by discovery: pending or + // rejected. `approved` (and non-gated scopes) leave approvalState unset. + let approvalState: McpServerInfo['approvalState']; + if ( + approvals && + approvalRoot && + serverConfig && + isGatedMcpScope(serverConfig.scope) + ) { + const state = approvals.getState(approvalRoot, info.name, serverConfig); + if (state !== 'approved') { + approvalState = state; + } + } + enriched.push({ + ...info, + status, + hasOAuthTokens, + requiresAuth, + ...(approvalState ? { approvalState } : {}), + }); + } + return enriched; +} + +export interface McpActionResult { + /** User-facing outcome line (null = silent success). */ + message: string | null; + /** The server inventory changed and should be reloaded. */ + changed: boolean; +} + +/** + * Real server actions for the OpenTUI MCP dialog (audit 01 G-6 / 05 G-13), + * mirroring MCPManagementDialog's handlers: enable/disable via the + * extension-scoped flag or the user/workspace `mcp.excluded` lists, + * reconnect via re-discovery, clear-auth via the token storage + disconnect, + * approve via the gated-approval store, and authenticate via the real + * MCPOAuthProvider (auth URL surfaced through the returned message). + */ +export async function applyMcpServerAction( + config: Config | null | undefined, + settings: LoadedSettings, + server: McpServerInfo, + action: McpServerAction, +): Promise { + if (!config) return { message: null, changed: false }; + const toolRegistry = config.getToolRegistry(); + try { + switch (action) { + case 'reconnect': { + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(server.name); + } + return { message: `Reconnecting '${server.name}'…`, changed: true }; + } + case 'clear-auth': { + const tokenStorage = new MCPOAuthTokenStorage(); + await tokenStorage.deleteCredentials(server.name); + if (toolRegistry) { + await toolRegistry.disconnectServer(server.name); + } + return { + message: `Cleared OAuth tokens for '${server.name}'.`, + changed: true, + }; + } + case 'approve': { + const serverConfig = (config.getMcpServers?.() ?? {})[server.name]; + if (serverConfig) { + const approvals = loadMcpApprovals(); + await approvals.setState( + config.getWorkingDir(), + server.name, + serverConfig, + 'approved', + ); + } + config.approveMcpServerForSession(server.name); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(server.name); + } + return { message: `Approved '${server.name}'.`, changed: true }; + } + case 'toggle-disable': { + if (server.isDisabled) { + // Enable: clear the extension flag and both exclusion lists. + const rawConfig = (config.getMcpServers?.() ?? {})[server.name] as + | { extensionName?: string } + | undefined; + const extensionName = rawConfig?.extensionName; + if (extensionName) { + config + .getExtensionManager() + ?.setMcpServerDisabled(extensionName, server.name, false); + } + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const scopeSettings = settings.forScope(scope).settings; + const currentExcluded = scopeSettings.mcp?.excluded ?? []; + if (currentExcluded.includes(server.name)) { + settings.setValue( + scope, + 'mcp.excluded', + currentExcluded.filter((name: string) => name !== server.name), + ); + } + } + const currentExcluded = config.getExcludedMcpServers() ?? []; + config.setExcludedMcpServers( + currentExcluded.filter((name: string) => name !== server.name), + ); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(server.name); + } + return { message: `Enabled '${server.name}'.`, changed: true }; + } + // Disable. + if (server.source === 'extension') { + const rawConfig = (config.getMcpServers?.() ?? {})[server.name] as + | { extensionName?: string } + | undefined; + const extensionName = rawConfig?.extensionName; + const manager = config.getExtensionManager(); + if (!extensionName || !manager) { + return { + message: `Cannot disable extension MCP server '${server.name}'.`, + changed: false, + }; + } + manager.setMcpServerDisabled(extensionName, server.name, true); + await toolRegistry?.disconnectServer(server.name); + removeMCPServerStatus(server.name); + return { message: `Disabled '${server.name}'.`, changed: true }; + } + // Scope by config location (ink parity): project → workspace. + const targetScope = + server.source === 'project' + ? SettingScope.Workspace + : SettingScope.User; + const scopeSettings = settings.forScope(targetScope).settings; + const currentExcluded = scopeSettings.mcp?.excluded ?? []; + if (!matchesAnyServerPattern(server.name, currentExcluded)) { + settings.setValue(targetScope, 'mcp.excluded', [ + ...currentExcluded, + server.name, + ]); + } + if (toolRegistry) { + await toolRegistry.disableMcpServer(server.name); + } + return { message: `Disabled '${server.name}'.`, changed: true }; + } + case 'authenticate': { + const rawConfig = (config.getMcpServers?.() ?? {})[server.name] as + | { oauth?: { enabled?: boolean }; url?: string; httpUrl?: string } + | undefined; + const oauthConfig = rawConfig?.oauth ?? { enabled: false }; + const events = new EventEmitter(); + const notices: string[] = []; + events.on(OAUTH_DISPLAY_MESSAGE_EVENT, (message: unknown) => { + if (typeof message === 'string') notices.push(message); + }); + events.on(OAUTH_AUTH_URL_EVENT, (url: unknown) => { + if (typeof url === 'string') { + notices.push(`Open this URL to authenticate:\n${url}`); + } + }); + const provider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); + // Streamable-http servers expose httpUrl; ink passes it ahead of the + // SSE url (AuthenticateStep parity) — the provider's discovery only + // runs when a server URL is present. + await provider.authenticate( + server.name, + oauthConfig, + rawConfig?.httpUrl || rawConfig?.url, + events, + ); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(server.name); + } + return { + message: + notices.length > 0 + ? notices.join('\n') + : `Authenticated '${server.name}'.`, + changed: true, + }; + } + default: + return { message: null, changed: false }; + } + } catch (error) { + return { + message: `MCP action failed: ${ + error instanceof Error ? error.message : String(error) + }`, + changed: true, + }; + } +} + +/** Installed-extension rows for the extensions dialog. */ +export function buildExtensionRows( + config: Config | null | undefined, +): ExtensionRow[] { + const manager = config?.getExtensionManager?.(); + const favorites = new Set(manager?.getFavorites() ?? []); + const scopes = manager?.getExtensionScopes() ?? {}; + const extensions = config?.getExtensions?.() ?? []; + return extensions.map((extension) => ({ + key: extension.name, + label: extension.name, + meta: extension.path ?? '', + enabled: extension.isActive, + favorite: favorites.has(extension.name), + scope: scopes[extension.name] ?? 'user', + version: extension.version, + source: extension.installMetadata?.source + ? redactUrlCredentials(extension.installMetadata.source) + : undefined, + origin: extension.installMetadata?.originSource, + components: extensionComponentsSummary(extension), + })); +} + +/** Parity of componentSummary in extensions/views/PluginDetailView.tsx. */ +function extensionComponentsSummary(extension: Extension): string { + const parts: string[] = []; + const mcpCount = extension.mcpServers + ? Object.keys(extension.mcpServers).length + : 0; + if (mcpCount) parts.push(t('{{count}} MCP', { count: String(mcpCount) })); + if (extension.skills?.length) + parts.push( + t('{{count}} Skills', { count: String(extension.skills.length) }), + ); + if (extension.commands?.length) + parts.push( + t('{{count}} Commands', { count: String(extension.commands.length) }), + ); + if (extension.agents?.length) + parts.push( + t('{{count}} Agents', { count: String(extension.agents.length) }), + ); + return parts.length ? parts.join(' · ') : t('None'); +} + +export interface ExtensionActionResult { + message: string; + changed: boolean; + level: 'info' | 'success' | 'warning' | 'error'; +} + +/** Update-check result feeding the "Update Now" action (detail view). */ +export type ExtensionUpdateCheckState = + | 'update-available' + | 'up-to-date' + | 'not-updatable' + | 'error'; + +const extensionUnavailable = (): ExtensionActionResult => ({ + message: t('Extensions are not available in this environment.'), + changed: false, + level: 'error', +}); + +function loadedExtension( + config: Config | null | undefined, + name: string, +): Extension | undefined { + return config?.getExtensions?.().find((ext) => ext.name === name); +} + +function warningsDetail(warnings: Array<{ error: string }>): string { + return warnings.map((warning) => warning.error).join('; '); +} + +/** Space on an installed row: enable/disable via the extension manager. */ +export async function applyExtensionToggle( + config: Config | null | undefined, + name: string, + currentlyActive: boolean, +): Promise { + const manager = config?.getExtensionManager?.(); + if (!manager) return extensionUnavailable(); + const scope = + manager.getExtensionScope(name) === 'project' + ? CoreSettingScope.Workspace + : CoreSettingScope.User; + try { + const result = currentlyActive + ? await manager.disableExtension(name, scope) + : await manager.enableExtension(name, scope); + await manager.refreshCache(); + const warnings = result.warnings ?? []; + return { + message: + warnings.length > 0 + ? t('"{{name}}" changed with warnings: {{detail}}', { + name, + detail: warningsDetail(warnings), + }) + : t('"{{name}}" {{state}}.', { + name, + state: currentlyActive ? t('disabled') : t('enabled'), + }), + changed: true, + level: warnings.length > 0 ? 'warning' : 'success', + }; + } catch (error) { + return { message: getErrorMessage(error), changed: false, level: 'error' }; + } +} + +/** `f` on an installed row: toggle the favorite preference. */ +export function applyExtensionFavorite( + config: Config | null | undefined, + name: string, +): ExtensionActionResult { + const manager = config?.getExtensionManager?.(); + if (!manager) return extensionUnavailable(); + try { + const nowFavorite = manager.toggleFavorite(name); + return { + message: nowFavorite + ? t('Added "{{name}}" to favorites.', { name }) + : t('Removed "{{name}}" from favorites.', { name }), + changed: true, + level: 'info', + }; + } catch (error) { + return { message: getErrorMessage(error), changed: false, level: 'error' }; + } +} + +/** Uninstall action (after the in-dialog confirm). */ +export async function applyExtensionUninstall( + config: Config | null | undefined, + name: string, +): Promise { + const manager = config?.getExtensionManager?.(); + if (!manager) return extensionUnavailable(); + try { + const result = await manager.uninstallExtension(name, false); + await manager.refreshCache(); + const warnings = result.warnings ?? []; + return { + message: + warnings.length > 0 + ? t('Uninstalled "{{name}}" with warnings: {{detail}}', { + name, + detail: warningsDetail(warnings), + }) + : t('Uninstalled "{{name}}".', { name }), + changed: true, + level: warnings.length > 0 ? 'warning' : 'success', + }; + } catch (error) { + return { message: getErrorMessage(error), changed: false, level: 'error' }; + } +} + +/** Change-scope action: user <-> project (parity of InstalledTab handleScope). */ +export async function applyExtensionScopeChange( + config: Config | null | undefined, + name: string, + nextScope: 'user' | 'project', +): Promise { + const manager = config?.getExtensionManager?.(); + const extension = loadedExtension(config, name); + if (!manager || !extension) return extensionUnavailable(); + try { + const result = + nextScope === 'project' + ? await manager.setExtensionActivationScope(extension.id, { + scope: 'workspace', + workspacePath: process.cwd(), + }) + : await manager.setExtensionActivationScope(extension.id, { + scope: 'user', + }); + let preferenceWarning: string | undefined; + try { + manager.setExtensionScope(name, nextScope); + } catch (error) { + preferenceWarning = getErrorMessage(error); + } + await manager.refreshCache(); + const warnings = [ + ...(result.warnings ?? []).map((warning) => warning.error), + ...(preferenceWarning ? [preferenceWarning] : []), + ]; + return { + message: + warnings.length > 0 + ? t('Set "{{name}}" scope with warnings: {{detail}}', { + name, + detail: warnings.join('; '), + }) + : t('Set "{{name}}" scope to {{scope}}.', { + name, + scope: nextScope === 'user' ? t('User') : t('Project'), + }), + changed: true, + level: warnings.length > 0 ? 'warning' : 'success', + }; + } catch (error) { + return { message: getErrorMessage(error), changed: false, level: 'error' }; + } +} + +/** Mark-for-Update action: check one extension for updates. */ +export async function applyExtensionUpdateCheck( + config: Config | null | undefined, + name: string, +): Promise<{ + message: string; + state: ExtensionUpdateCheckState; +}> { + const manager = config?.getExtensionManager?.(); + const extension = loadedExtension(config, name); + if (!manager || !extension) + return { + message: t('Extensions are not available in this environment.'), + state: 'error', + }; + try { + const checked = await checkForExtensionUpdate(extension, manager); + switch (checked) { + case ExtensionUpdateState.UPDATE_AVAILABLE: + return { + message: t('Update available for "{{name}}".', { name }), + state: 'update-available', + }; + case ExtensionUpdateState.ERROR: + return { + message: t('Failed to check "{{name}}" for updates.', { name }), + state: 'error', + }; + case ExtensionUpdateState.NOT_UPDATABLE: + return { + message: + extension.installMetadata?.originSource === 'Claude' + ? t( + '"{{name}}" cannot be update-checked (Claude marketplace plugins update by reinstalling).', + { name }, + ) + : t('"{{name}}" does not support update checks.', { name }), + state: 'not-updatable', + }; + default: + return { + message: t('"{{name}}" is already up to date.', { name }), + state: 'up-to-date', + }; + } + } catch (error) { + return { message: getErrorMessage(error), state: 'error' }; + } +} + +/** Update Now action (offered after a positive update check). */ +export async function applyExtensionUpdate( + config: Config | null | undefined, + name: string, +): Promise { + const manager = config?.getExtensionManager?.(); + const extension = loadedExtension(config, name); + if (!manager || !extension) return extensionUnavailable(); + try { + const result = await manager.updateExtension( + extension, + ExtensionUpdateState.UPDATE_AVAILABLE, + () => {}, + ); + const warnings = result?.warnings ?? []; + return { + message: + warnings.length > 0 + ? t('Updated "{{name}}" with warnings: {{warnings}}.', { + name, + warnings: warnings + .map((warning) => `${warning.code}: ${warning.error}`) + .join('; '), + }) + : t('Updated "{{name}}".', { name }), + changed: true, + level: warnings.length > 0 ? 'warning' : 'success', + }; + } catch (error) { + return { message: getErrorMessage(error), changed: false, level: 'error' }; + } +} diff --git a/packages/cli/src/ui/opentui/dialogs-arena.tsx b/packages/cli/src/ui/opentui/dialogs-arena.tsx new file mode 100644 index 00000000000..eb42cf1aba6 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-arena.tsx @@ -0,0 +1,818 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI arena dialogs (M4 deep-fidelity port of ink arena/*): + * + * - start: MultiSelect over config.getAllConfiguredModels() (runtime / + * image-only models filtered out, qwen-oauth disabled); confirming fills + * the composer with `/arena start --models …` — the dialog never launches + * the session itself, exactly like ink's handleArenaModelsSelected. + * - status: live agent table (status/time/tokens/rounds/tools) refreshed on + * an interval, reading AgentInteractive stats for in-process backends. + * - stop: cleanup vs preserve radio, then cancel → settle → cleanup via + * the ArenaManager, reporting progress as chat messages. + * - select: winner picker with per-agent diff stats, p/d preview panes and + * x discard; applying runs applyAgentResult + cleanupArenaRuntime. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { useKeyboard, useTerminalDimensions } from '@opentui/react'; +import { + ArenaSessionStatus, + AuthType, + DISPLAY_MODE, + isSettledStatus, + isSuccessStatus, + type AgentStatsSummary, + type ArenaAgentResult, + type ArenaAgentState, + type Config, + type InProcessBackend, +} from '@qwen-code/qwen-code-core'; +import { formatDuration } from '../utils/formatters.js'; +import { getArenaStatusLabel } from '../utils/displayUtils.js'; +import { toOriginalKey } from './key-map.js'; +import { nextEnabledIndex } from './dialogs-misc.js'; +import { C } from './theme.js'; + +export type ArenaDialogMode = 'start' | 'select' | 'stop' | 'status'; + +export interface OpenTuiArenaDialogProps { + config?: Config; + mode: ArenaDialogMode; + onClose: () => void; + /** Command-style chat messages (ink addItem parity). */ + notify: (text: string) => void; + /** ink handleArenaModelsSelected: fill the composer, keep it unsubmitted. */ + onFillInput?: (text: string) => void; +} + +const MODEL_PROVIDERS_DOCUMENTATION_URL = + 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders'; + +const STATUS_REFRESH_INTERVAL_MS = 2000; +const IN_PROCESS_REFRESH_INTERVAL_MS = 1000; +const MAX_MODEL_NAME_LENGTH = 35; +const MAX_TASK_DISPLAY_LENGTH = 60; +const DETAILED_DIFF_MAX_LINES = 180; + +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + '…'; +} + +function pad(str: string, len: number): string { + if (str.length >= len) return str.slice(0, len); + return ' '.repeat(len - str.length) + str; +} + +function sessionStatusLabel(status: ArenaSessionStatus): { + text: string; + color: string; +} { + switch (status) { + case ArenaSessionStatus.RUNNING: + return { text: 'Running', color: C.green }; + case ArenaSessionStatus.INITIALIZING: + return { text: 'Initializing', color: C.yellow }; + case ArenaSessionStatus.IDLE: + return { text: 'Idle', color: C.green }; + case ArenaSessionStatus.COMPLETED: + return { text: 'Completed', color: C.green }; + case ArenaSessionStatus.CANCELLED: + return { text: 'Cancelled', color: C.yellow }; + case ArenaSessionStatus.FAILED: + return { text: 'Failed', color: C.red }; + default: + return { text: String(status), color: C.dim }; + } +} + +function ArenaFrame({ + title, + hint, + children, +}: { + title: React.ReactNode; + hint: string; + children?: React.ReactNode; +}) { + return ( + + + + {typeof title === 'string' ? title : ''} + + {typeof title !== 'string' ? title : null} + + {children} + + {hint} + + + ); +} + +/** `/arena start` — multi-select of configured models → fill the composer. */ +function ArenaStart({ config, onClose, onFillInput }: OpenTuiArenaDialogProps) { + const modelItems = useMemo(() => { + const all = config?.getAllConfiguredModels?.() ?? []; + return all + .filter((m) => !m.isRuntimeModel && !m.imageOnly) + .map((m) => { + const token = `${m.authType}:${m.id}`; + return { + key: token, + label: `[${m.authType}] ${m.label}`, + disabled: m.authType === AuthType.QWEN_OAUTH, + }; + }); + }, [config]); + const [cursor, setCursor] = useState(0); + const [checked, setChecked] = useState>(new Set()); + const [error, setError] = useState(null); + + const hasDisabledQwenOauth = modelItems.some((m) => m.disabled); + const selectableCount = modelItems.filter((m) => !m.disabled).length; + const needsMoreModels = selectableCount < 2; + const showMoreModelsHint = selectableCount >= 2 && selectableCount < 3; + + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'escape') { + onClose(); + } else if (o.name === 'up' || o.name === 'down') { + const d = o.name === 'up' ? -1 : 1; + setCursor((c) => nextEnabledIndex(modelItems, c, d)); + } else if (o.name === 'space') { + const item = modelItems[cursor]; + if (!item || item.disabled) return; + setChecked((prev) => { + const next = new Set(prev); + if (next.has(item.key)) next.delete(item.key); + else next.add(item.key); + return next; + }); + } else if (o.name === 'return') { + if (checked.size < 2) { + setError('Please select at least 2 models to start an Arena session.'); + return; + } + const values = modelItems + .filter((m) => checked.has(m.key)) + .map((m) => m.key); + onFillInput?.(`/arena start --models ${values.join(',')} `); + onClose(); + } + }); + + return ( + + {modelItems.length === 0 ? ( + + + {'No models available. Please configure models first.'} + + + ) : ( + + {modelItems.map((m, i) => ( + + + {checked.has(m.key) ? '[x] ' : '[ ] '} + + + {m.label} + + + ))} + + )} + {error && ( + + {error} + + )} + {(hasDisabledQwenOauth || needsMoreModels) && ( + + {hasDisabledQwenOauth && ( + + {'Note: qwen-oauth models are not supported in Arena.'} + + )} + {needsMoreModels && ( + <> + + {'Arena requires at least 2 models. To add more:'} + + + { + ' - Run /auth to set up a Coding Plan (includes multiple models)' + } + + + {' - Or configure modelProviders in settings.json'} + + + )} + + )} + {showMoreModelsHint && ( + + + {'Configure more models with the modelProviders guide:'} + + {MODEL_PROVIDERS_DOCUMENTATION_URL} + + )} + + ); +} + +function agentElapsedMs(agent: ArenaAgentState): number { + if (isSettledStatus(agent.status)) return agent.stats.durationMs; + return Date.now() - agent.startedAt; +} + +/** `/arena status` — live agent stats table. */ +function ArenaStatus({ config, onClose }: OpenTuiArenaDialogProps) { + const manager = config?.getArenaManager?.() ?? null; + const { width } = useTerminalDimensions(); + const [, setTick] = useState(0); + + const backend = manager?.getBackend(); + const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS; + const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null; + + useEffect(() => { + const interval = isInProcess + ? IN_PROCESS_REFRESH_INTERVAL_MS + : STATUS_REFRESH_INTERVAL_MS; + const timer = setInterval(() => setTick((t) => t + 1), interval); + return () => clearInterval(timer); + }, [isInProcess]); + + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'escape' || o.name === 'return' || o.name === 'q') { + onClose(); + } + }); + + if (!manager) { + return ( + + + {'No running Arena session found.'} + + + ); + } + + const sessionLabel = sessionStatusLabel(manager.getSessionStatus()); + const agents = manager.getAgentStates(); + const task = truncate(manager.getTask() ?? '', MAX_TASK_DISPLAY_LENGTH); + + const liveStats = new Map(); + if (inProcessBackend) { + for (const agent of agents) { + const interactive = inProcessBackend.getAgent(agent.agentId); + if (interactive) liveStats.set(agent.agentId, interactive.getStats()); + } + } + + const colStatus = 14; + const colTime = 8; + const colTokens = 10; + const colRounds = 8; + const colTools = 8; + const innerWidth = Math.max(10, (width ?? 80) - 6); + + return ( + + + {'Arena Status'} + + {' · '} + {sessionLabel.text} + + } + hint="Esc to close" + > + + {'Task: '} + {`"${task}"`} + + + + + {'Agent'} + + + + + {'Status'} + + + + + {'Time'} + + + + + {'Tokens'} + + + + + {'Rounds'} + + + + + {'Tools'} + + + + {'─'.repeat(innerWidth)} + {agents.length === 0 ? ( + {'No agents registered yet.'} + ) : ( + agents.map((agent) => { + const label = truncate(agent.model.modelId, MAX_MODEL_NAME_LENGTH); + const statusInfo = getArenaStatusLabel(agent.status); + const live = liveStats.get(agent.agentId); + const outputTokens = live?.outputTokens ?? agent.stats.outputTokens; + const rounds = live?.rounds ?? agent.stats.rounds; + const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls; + const okCalls = + live?.successfulToolCalls ?? agent.stats.successfulToolCalls; + const failedCalls = + live?.failedToolCalls ?? agent.stats.failedToolCalls; + return ( + + + {label} + + + {statusInfo.text} + + + + {pad(formatDuration(agentElapsedMs(agent)), colTime - 1)} + + + + + {pad(outputTokens.toLocaleString(), colTokens - 1)} + + + + {pad(String(rounds), colRounds - 1)} + + + {failedCalls > 0 ? ( + <> + {String(okCalls)} + {'/'} + {String(failedCalls)} + + ) : ( + 0 ? C.green : C.text}> + {pad(String(toolCalls), colTools - 1)} + + )} + + + ); + }) + )} + + ); +} + +type StopAction = 'cleanup' | 'preserve'; + +/** `/arena stop` — cleanup vs preserve radio + manager teardown. */ +function ArenaStop({ config, onClose, notify }: OpenTuiArenaDialogProps) { + const [processing, setProcessing] = useState(false); + const preserveDefault = + config?.getAgentsSettings?.().arena?.preserveArtifacts ?? false; + const items: Array<{ key: StopAction; label: string; desc: string }> = [ + { + key: 'cleanup', + label: 'Stop and clean up', + desc: 'Remove all worktrees and session files', + }, + { + key: 'preserve', + label: 'Stop and preserve artifacts', + desc: 'Keep worktrees and session files for later inspection', + }, + ]; + const [sel, setSel] = useState(preserveDefault ? 1 : 0); + + const runStop = async (action: StopAction) => { + if (processing) return; + setProcessing(true); + onClose(); + const mgr = config?.getArenaManager?.(); + if (!mgr) { + notify('✗ No running Arena session found.'); + return; + } + try { + const status = mgr.getSessionStatus(); + if ( + status === ArenaSessionStatus.RUNNING || + status === ArenaSessionStatus.INITIALIZING + ) { + notify('Stopping Arena agents…'); + await mgr.cancel(); + } + await mgr.waitForSettled(); + notify('Cleaning up Arena resources…'); + if (action === 'preserve') { + await mgr.cleanupRuntime(); + } else { + await mgr.cleanup(); + } + config?.setArenaManager?.(null); + notify( + action === 'preserve' + ? 'Arena session stopped. Worktrees and session files were preserved. Use /arena select --discard to manually clean up later.' + : 'Arena session stopped. All Arena resources (including Git worktrees) were cleaned up.', + ); + } catch (error) { + notify( + `✗ Failed to stop Arena session: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + useKeyboard((key) => { + if (processing) return; + const o = toOriginalKey(key); + if (o.name === 'escape') { + onClose(); + } else if (o.name === 'up' || o.name === 'down') { + setSel((s) => (s === 0 ? 1 : 0)); + } else if (o.name === 'return') { + void runStop(items[sel]?.key ?? 'cleanup'); + } + }); + + return ( + + + {'Choose what to do with Arena artifacts:'} + + + {items.map((it, i) => ( + + + + {i === sel ? '● ' : '○ '} + + + {it.label} + + + + {it.desc} + + + ))} + + {preserveDefault && ( + + + {'Default: preserve (agents.arena.preserveArtifacts is enabled)'} + + + )} + + ); +} + +function diffLineColor(line: string): string { + if (line.startsWith('+') && !line.startsWith('+++')) return C.green; + if (line.startsWith('-') && !line.startsWith('---')) return C.red; + if ( + line.startsWith('diff --git') || + line.startsWith('@@') || + line.startsWith('---') || + line.startsWith('+++') + ) { + return C.accent; + } + return C.dim; +} + +function visibleDiffLines(diff: string | undefined): string[] { + if (!diff) return []; + const lines = diff.split('\n'); + if (lines.length <= DETAILED_DIFF_MAX_LINES) return lines; + return [ + ...lines.slice(0, DETAILED_DIFF_MAX_LINES), + `... truncated ${lines.length - DETAILED_DIFF_MAX_LINES} diff lines`, + ]; +} + +function formatFileList(files: string[]): string { + if (files.length === 0) return 'none'; + const visible = files.slice(0, 6); + const suffix = + files.length > visible.length + ? `, +${files.length - visible.length} more` + : ''; + return `${visible.join(', ')}${suffix}`; +} + +function AgentPreview({ result }: { result: ArenaAgentResult }) { + const files = result.diffSummary?.files ?? []; + return ( + + + {`Quick Preview · ${result.model.modelId}`} + + + {'Approach: '} + + {result.approachSummary ?? 'No approach summary available.'} + + + + {'Major files: '} + {formatFileList(files.map((f) => f.path))} + + + {'Metrics: '} + {`${result.stats.outputTokens.toLocaleString()} tokens · ${formatDuration(result.stats.durationMs)} · ${result.stats.toolCalls} tools`} + + + ); +} + +function AgentDetailedDiff({ result }: { result: ArenaAgentResult }) { + const lines = visibleDiffLines(result.diff); + return ( + + + {`Detailed Diff · ${result.model.modelId}`} + + {lines.length === 0 ? ( + + {'No diff available.'} + + ) : ( + + {lines.map((line, index) => ( + + {line} + + ))} + + )} + + ); +} + +/** `/arena select` — winner picker with preview panes and discard. */ +function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) { + const manager = config?.getArenaManager?.() ?? null; + const agents = useMemo(() => manager?.getAgentStates() ?? [], [manager]); + const result = manager?.getResult(); + const [sel, setSel] = useState(() => + Math.max( + 0, + agents.findIndex((a) => isSuccessStatus(a.status)), + ), + ); + const [showPreview, setShowPreview] = useState(false); + const [showDetailedDiff, setShowDetailedDiff] = useState(false); + + const rows = useMemo( + () => + agents.map((agent) => { + let additions = 0; + let deletions = 0; + let fileCount = 0; + if (isSuccessStatus(agent.status) && result) { + const agentResult = result.agents.find( + (a) => a.agentId === agent.agentId, + ); + if (agentResult?.diffSummary) { + additions = agentResult.diffSummary.additions; + deletions = agentResult.diffSummary.deletions; + fileCount = agentResult.diffSummary.files.length; + } else if (agentResult?.diff) { + for (const line of agentResult.diff.split('\n')) { + if (line.startsWith('+') && !line.startsWith('+++')) additions++; + else if (line.startsWith('-') && !line.startsWith('---')) + deletions++; + } + } + fileCount = agentResult?.modifiedFiles?.length ?? fileCount; + } + return { + key: agent.agentId, + label: agent.model.modelId, + status: getArenaStatusLabel(agent.status), + duration: formatDuration(agent.stats.durationMs), + tokens: agent.stats.outputTokens.toLocaleString(), + additions, + deletions, + fileCount, + disabled: !isSuccessStatus(agent.status), + }; + }), + [agents, result], + ); + + const selectedAgentId = rows[sel]?.key; + const selectedResult = result?.agents.find( + (a) => a.agentId === selectedAgentId, + ); + + const applyWinner = async (agentId: string) => { + onClose(); + const mgr = config?.getArenaManager?.(); + if (!mgr) { + notify('✗ No arena session found. Start one with /arena start.'); + return; + } + const agent = + mgr.getAgentState(agentId) ?? + mgr.getAgentStates().find((a) => a.agentId === agentId); + const label = agent?.model.modelId || agentId; + notify(`Applying changes from ${label}…`); + const applyResult = await mgr.applyAgentResult(agentId); + if (!applyResult.success) { + notify(`✗ Failed to apply changes from ${label}: ${applyResult.error}`); + return; + } + try { + await config?.cleanupArenaRuntime?.(true); + } catch (err) { + notify( + `✗ Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`, + ); + } + notify( + `Applied changes from ${label} to workspace. Arena session complete.`, + ); + }; + + const discardAll = async () => { + onClose(); + const mgr = config?.getArenaManager?.(); + if (!mgr) { + notify('✗ No arena session found. Start one with /arena start.'); + return; + } + try { + notify('Discarding Arena results and cleaning up…'); + await config?.cleanupArenaRuntime?.(true); + notify('Arena results discarded. All worktrees cleaned up.'); + } catch (err) { + notify( + `✗ Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }; + + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'escape') { + onClose(); + } else if (o.name === 'up' || o.name === 'down') { + setSel((s) => nextEnabledIndex(rows, s, o.name === 'up' ? -1 : 1)); + } else if (o.name === 'return') { + const row = rows[sel]; + if (row && !row.disabled) void applyWinner(row.key); + } else if (!o.ctrl && !o.meta) { + if (o.name === 'p') setShowPreview((v) => !v); + else if (o.name === 'd') setShowDetailedDiff((v) => !v); + else if (o.name === 'x') void discardAll(); + } + }); + + if (!manager) { + return ( + + + + {'No arena session found. Start one with /arena start.'} + + + + ); + } + + const task = truncate(result?.task ?? '', MAX_TASK_DISPLAY_LENGTH); + + return ( + + + {'Task: '} + {`"${task}"`} + + + {'Select a winner to apply changes:'} + + + {rows.map((row, i) => ( + + + + {i === sel ? '● ' : '○ '} + + + {row.label} + + + + {row.status.text} + {` · ${row.duration} · ${row.tokens} tokens`} + {row.fileCount > 0 && ( + {` · ${row.fileCount} files`} + )} + {(row.additions > 0 || row.deletions > 0) && ( + <> + {' · '} + {`+${row.additions}`} + {'/'} + {`-${row.deletions}`} + {' lines'} + + )} + + + ))} + + {showPreview && selectedResult && ( + + )} + {showDetailedDiff && selectedResult && ( + + )} + + ); +} + +export function OpenTuiArenaDialog(props: OpenTuiArenaDialogProps) { + switch (props.mode) { + case 'start': + return ; + case 'status': + return ; + case 'stop': + return ; + case 'select': + return ; + default: + return null; + } +} diff --git a/packages/cli/src/ui/opentui/dialogs-auth.test.tsx b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx new file mode 100644 index 00000000000..b5214815f33 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-auth.test.tsx @@ -0,0 +1,412 @@ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Component wiring tests for the OpenTUI /auth dialog (#57). The native + * renderer (Bun/FFI) is exercised by the separate PTY gate; here the OpenTUI + * hooks/jsx runtime are replaced with fakes (same harness as + * input-prompt.test.tsx) so the tests verify what the dialog guarantees: + * + * - the main menu renders the three top-level entries (ink AuthDialog + * parity) and Esc is blocked while unauthenticated; + * - main → sub-menu navigation and back follow the ink view stack; + * - the custom-provider wizard walks the full six-step flow + * (protocol → baseUrl → apiKey → models → advancedConfig → review) and + * the final Enter drives the same install-plan write path as ink's + * useAuth.handleProviderSubmit (buildInstallPlan → applyProviderInstall + * Plan → feedback + close); + * - a rejected install plan surfaces the error and keeps the dialog open. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; + +const mocks = vi.hoisted(() => { + const state = { + inputHandlers: [] as Array<(sequence: string) => boolean>, + keyboardHandlers: [] as Array<(key: unknown) => void>, + pasteHandlers: [] as Array<(event: unknown) => void>, + }; + const renderer = { + addInputHandler(handler: (sequence: string) => boolean) { + state.inputHandlers.push(handler); + }, + removeInputHandler(handler: (sequence: string) => boolean) { + const index = state.inputHandlers.indexOf(handler); + if (index >= 0) state.inputHandlers.splice(index, 1); + }, + }; + async function buildJsxRuntime() { + const React = await import('react'); + const jsx = ( + type: unknown, + props: { children?: unknown; key?: React.Key } | null, + key?: React.Key, + ) => { + const config = key === undefined ? props : { ...props, key }; + const children = (config?.children ?? null) as React.ReactNode; + if (type === 'box' || type === 'text') { + return React.createElement( + type === 'box' ? 'div' : 'span', + key === undefined ? null : { key }, + children, + ); + } + return React.createElement( + type as React.ElementType, + config as Record, + children, + ); + }; + return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment }; + } + return { state, renderer, buildJsxRuntime }; +}); + +const core = vi.hoisted(() => ({ + applyProviderInstallPlan: vi.fn(), + logAuth: vi.fn(), +})); + +vi.mock('@opentui/react', () => ({ + useKeyboard: (handler: (key: unknown) => void) => { + mocks.state.keyboardHandlers.push(handler); + }, + usePaste: (handler: (event: unknown) => void) => { + mocks.state.pasteHandlers.push(handler); + }, + useRenderer: () => mocks.renderer, +})); + +vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); +vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); +vi.mock('./theme.js', () => ({ + C: new Proxy({}, { get: () => '#ffffff' }), +})); +vi.mock('../../config/loadedSettingsAdapter.js', () => ({ + createLoadedSettingsAdapter: () => ({}), +})); +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + applyProviderInstallPlan: core.applyProviderInstallPlan, + logAuth: core.logAuth, + }; +}); + +import { AuthType } from '@qwen-code/qwen-code-core'; +import { OpenTuiAuthDialog } from './dialogs-auth.js'; + +function baseKeyEvent(overrides: Record = {}) { + return { + name: 'a', + sequence: 'a', + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + hyper: false, + eventType: 'press', + preventDefault: () => {}, + stopPropagation: () => {}, + ...overrides, + }; +} + +function lastKeyboardHandler(): (key: unknown) => void { + const handler = mocks.state.keyboardHandlers.at(-1); + if (!handler) throw new Error('no keyboard handler registered'); + return handler; +} + +async function press(name: string): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + handler(baseKeyEvent({ name, sequence: name })); + }); +} + +async function typeText(text: string): Promise { + // One act per character: the flow state lives in React state, so each + // keystroke must flush a render before the next handler closure is fresh. + for (const char of text) { + await act(async () => { + const handler = lastKeyboardHandler(); + handler(baseKeyEvent({ name: char, sequence: char })); + }); + } +} + +async function pressEsc(): Promise { + const handler = mocks.state.inputHandlers.at(-1); + if (!handler) throw new Error('no raw input handler registered'); + let consumed = false; + await act(async () => { + consumed = handler('\x1b'); + }); + return consumed; +} + +interface FakePasteEvent { + type: 'paste'; + bytes: Uint8Array; + preventDefault: ReturnType; + stopPropagation: ReturnType; +} + +function makePasteEvent(text: string): FakePasteEvent { + return { + type: 'paste', + bytes: new TextEncoder().encode(text), + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + }; +} + +/** Dispatch one bracketed paste to the most recently mounted input. */ +async function pasteText(text: string): Promise { + const handler = mocks.state.pasteHandlers.at(-1); + if (!handler) throw new Error('no paste handler registered'); + const event = makePasteEvent(text); + await act(async () => { + handler(event); + }); + return event; +} + +function createMockConfig(authType?: AuthType): Config { + return { + getAuthType: vi.fn(() => authType), + getContentGeneratorConfig: vi.fn(() => ({})), + getModelsConfig: vi.fn(() => ({ + syncAfterAuthRefresh: vi.fn(), + })), + reloadModelProvidersConfig: vi.fn(), + refreshAuth: vi.fn(), + } as unknown as Config; +} + +function createMockSettings(): LoadedSettings { + return { + merged: { env: {}, modelProviders: {} }, + forScope: () => ({ settings: {}, path: '', originalSettings: {} }), + } as unknown as LoadedSettings; +} + +function renderDialog(overrides?: { authType?: AuthType }) { + const onClose = vi.fn(); + const notify = vi.fn(); + const config = createMockConfig(overrides?.authType); + const settings = createMockSettings(); + render( + , + ); + return { onClose, notify, config }; +} + +/** Drive main → Custom Provider → through the full six-step wizard. */ +async function runCustomProviderFlow(): Promise<{ + onClose: ReturnType; + notify: ReturnType; +}> { + const { onClose, notify } = renderDialog(); + await press('down'); + await press('down'); + await press('return'); // main: CUSTOM_PROVIDER → provider-setup (protocol) + await press('return'); // protocol: OpenAI-compatible → baseUrl input + await typeText('https://api.example.com/v1'); + await press('return'); // baseUrl → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models + await typeText('model-1, model-2'); + await press('return'); // models → advancedConfig + await press('return'); // advancedConfig: skip → review + return { onClose, notify }; +} + +describe('OpenTuiAuthDialog (#57 onboarding flow)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + core.applyProviderInstallPlan.mockReset().mockResolvedValue(undefined); + core.logAuth.mockReset(); + }); + + it('renders the main menu with the three top-level options', () => { + renderDialog(); + expect(screen.getByText('Connect a Provider')).toBeTruthy(); + expect(screen.getByText('Alibaba ModelStudio')).toBeTruthy(); + expect(screen.getByText('Third-party Providers')).toBeTruthy(); + expect(screen.getByText('Custom Provider')).toBeTruthy(); + }); + + it('blocks Esc on the main view while unauthenticated', async () => { + const { onClose } = renderDialog(); + const consumed = await pressEsc(); + expect(consumed).toBe(true); + expect(onClose).not.toHaveBeenCalled(); + expect( + screen.getByText(/You must connect a provider to proceed/), + ).toBeTruthy(); + }); + + it('closes via Esc on the main view when authenticated', async () => { + const { onClose } = renderDialog({ authType: AuthType.USE_OPENAI }); + await pressEsc(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('navigates main → sub-menu and back with Esc', async () => { + const { onClose } = renderDialog(); + await press('return'); // main: Alibaba ModelStudio → alibaba-select + expect( + screen.getByText('Alibaba ModelStudio · Access Method'), + ).toBeTruthy(); + await pressEsc(); // back to main + expect(screen.getByText('Connect a Provider')).toBeTruthy(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('walks the custom-provider wizard and submits the install plan', async () => { + const { onClose, notify } = await runCustomProviderFlow(); + // review: step title reflects the last step before saving + expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); + await press('return'); // save + + await vi.waitFor(() => { + expect(core.applyProviderInstallPlan).toHaveBeenCalledTimes(1); + }); + expect(core.logAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ status: 'success' }), + ); + expect(notify).toHaveBeenCalledWith( + expect.stringContaining('Successfully configured'), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('surfaces the model-ids error on empty submit (ink modelIdsError parity)', async () => { + renderDialog(); + await press('down'); + await press('down'); + await press('return'); // main: CUSTOM_PROVIDER → protocol + await press('return'); // protocol: OpenAI-compatible → baseUrl input + await typeText('https://api.example.com/v1'); + await press('return'); // baseUrl → apiKey + await typeText('sk-test'); + await press('return'); // apiKey → models (custom input focused) + await press('return'); // empty submit → flow sets modelIdsError + expect(screen.getByText(/Model IDs cannot be empty/)).toBeTruthy(); + // the error is non-fatal: the step stays mounted + expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); + }); + + it('keeps the dialog open and shows the error when the plan fails', async () => { + core.applyProviderInstallPlan.mockRejectedValueOnce( + new Error('disk on fire'), + ); + const { onClose, notify } = await runCustomProviderFlow(); + await press('return'); // save → rejects + + await vi.waitFor(() => { + expect(screen.getByText(/Failed to authenticate/)).toBeTruthy(); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + expect(core.logAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ status: 'error' }), + ); + }); +}); + +describe('bracketed-paste into dialog inputs (#57)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + mocks.state.pasteHandlers.length = 0; + core.applyProviderInstallPlan.mockReset().mockResolvedValue(undefined); + core.logAuth.mockReset(); + }); + + /** Walk the wizard up to the API-key step (custom provider, default protocol). */ + async function runToApiKeyStep(): Promise { + renderDialog(); + await press('down'); + await press('down'); + await press('return'); // main: CUSTOM_PROVIDER → protocol + await press('return'); // protocol: OpenAI-compatible → baseUrl input + await typeText('https://api.example.com/v1'); + await press('return'); // baseUrl → apiKey + } + + it('inserts a paste into the API-key input and prevents default', async () => { + await runToApiKeyStep(); + const event = await pasteText('sk-pasted-key'); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect(screen.getByText('sk-pasted-key')).toBeTruthy(); + // the pasted key is what the wizard carries forward, not a lost paste + await press('return'); // apiKey → models + expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy(); + }); + + it('normalizes CRLF pastes onto LF before inserting', async () => { + await runToApiKeyStep(); + await pasteText('key-1\r\nkey-2'); + // testing-library collapses whitespace in getByText, so match on the raw + // textContent where the \r must be gone + const match = screen.getByText( + (_, element) => element?.textContent === 'key-1\nkey-2', + ); + expect(match).toBeTruthy(); + }); + + it('appends a paste after typed text in the models custom-ID input', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models (custom input focused) + await typeText('typed-'); + const event = await pasteText('pasted-model'); + expect(event.preventDefault).toHaveBeenCalledTimes(1); + expect( + screen.getByText( + (_, element) => element?.textContent === 'typed-pasted-model', + ), + ).toBeTruthy(); + await press('return'); // models → advancedConfig + expect( + screen.getByText(/Optional: configure advanced generation settings/), + ).toBeTruthy(); + }); + + it('ignores a paste while a toggle row owns the advanced-config focus', async () => { + await runToApiKeyStep(); + await typeText('sk-test'); + await press('return'); // apiKey → models (custom input focused) + await pasteText('debug-model'); // fill the custom-ID input via paste + await press('return'); // models → advancedConfig (focus on the first toggle) + const event = await pasteText('12345'); + // guard bails before consuming: no preventDefault, ctx stays auto + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(screen.getByText('auto')).toBeTruthy(); + await press('return'); // advancedConfig: skip → review + expect(screen.getByText(/Step 6\/6 · Review/)).toBeTruthy(); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-auth.tsx b/packages/cli/src/ui/opentui/dialogs-auth.tsx new file mode 100644 index 00000000000..48fc9698a1a --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-auth.tsx @@ -0,0 +1,1072 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Full /auth onboarding flow (#57, M4 fidelity pass): a native OpenTUI port + * of ink's AuthDialog + ProviderSetupSteps. The navigation state machine + * (main → alibaba/thirdparty-select → provider-setup), the setup-flow state + * (useProviderSetupFlow — renderer-agnostic, reused verbatim from the ink + * tree) and the final submit (buildInstallPlan → applyProviderInstallPlan, + * the same write path useAuth.handleProviderSubmit drives) mirror the ink + * implementation; only the view layer is OpenTUI. + * + * Known simplifications vs ink (recorded in the gap tracker): + * - the models step omits the recommended-list search box (list is short); + * - documentation/TOS links render as plain text (no OSC 8 in dialogs). + */ + +import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { useKeyboard, usePaste, useRenderer } from '@opentui/react'; +import type { PasteEvent } from '@opentui/core'; +import { decodePasteBytes } from '@opentui/core'; +import type { + BaseUrlOption, + Config, + ProviderConfig, + ProviderSetupInputs, +} from '@qwen-code/qwen-code-core'; +import { + ALIBABA_PROVIDERS, + THIRD_PARTY_PROVIDERS, + AuthEvent, + AuthType, + applyProviderInstallPlan, + buildInstallPlan, + customProvider, + findExistingProviderModels, + findProviderByCredentials, + findProviderById, + getDefaultModelIds, + getErrorMessage, + logAuth, +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { createLoadedSettingsAdapter } from '../../config/loadedSettingsAdapter.js'; +import { t } from '../../i18n/index.js'; +import { + useProviderSetupFlow, + type ProviderSetupFlow, +} from '../auth/useProviderSetupFlow.js'; +import { normalizeModelIds } from '../auth/useAuth.js'; +import { toOriginalKey } from './key-map.js'; +import { isPrintableKeyInput } from './input-prompt-key.js'; +import { normalizePastedText } from './input-prompt-model.js'; +import { Shell } from './dialogs-misc.js'; +import { C } from './theme.js'; + +// --------------------------------------------------------------------------- +// Types & static data (AuthDialog parity) +// --------------------------------------------------------------------------- + +type ViewLevel = + | 'main' + | 'alibaba-select' + | 'thirdparty-select' + | 'provider-setup'; + +type MainOption = + | 'ALIBABA_MODELSTUDIO' + | 'THIRD_PARTY_PROVIDERS' + | 'CUSTOM_PROVIDER'; + +interface RadioItem { + key: string; + label: string; + description?: string; + value: string; +} + +const MAIN_ITEMS: RadioItem[] = [ + { + key: 'ALIBABA_MODELSTUDIO', + label: t('Alibaba ModelStudio'), + description: t( + 'Official recommended setup: Coding Plan, Token Plan, or Standard API Key', + ), + value: 'ALIBABA_MODELSTUDIO', + }, + { + key: 'THIRD_PARTY_PROVIDERS', + label: t('Third-party Providers'), + description: t('Choose a built-in provider and connect with an API key'), + value: 'THIRD_PARTY_PROVIDERS', + }, + { + key: 'CUSTOM_PROVIDER', + label: t('Custom Provider'), + description: t( + 'Manually connect a local server, proxy, or unsupported provider', + ), + value: 'CUSTOM_PROVIDER', + }, +]; + +const PROTOCOL_ITEMS: RadioItem[] = [ + { + key: AuthType.USE_OPENAI, + label: t('OpenAI-compatible'), + description: t('Standard OpenAI API format (most common)'), + value: AuthType.USE_OPENAI, + }, + { + key: AuthType.USE_ANTHROPIC, + label: t('Anthropic-compatible'), + description: t('Anthropic Messages API format'), + value: AuthType.USE_ANTHROPIC, + }, + { + key: AuthType.USE_GEMINI, + label: t('Gemini-compatible'), + description: t('Google Gemini API format'), + value: AuthType.USE_GEMINI, + }, +]; + +const VIEW_TITLES: Record = { + main: t('Connect a Provider'), + 'alibaba-select': t('Alibaba ModelStudio · Access Method'), + 'thirdparty-select': t('Third-party Providers · Provider'), +}; + +function providerToItem(config: ProviderConfig): RadioItem { + return { + key: config.id, + label: t(config.label), + description: t(config.description), + value: config.id, + }; +} + +function getStepLabel(step: string | null, p: ProviderConfig): string { + if (step === 'protocol') return t('Protocol'); + if (step === 'baseUrl') { + if (p.uiLabels?.baseUrlStepTitle) return t(p.uiLabels.baseUrlStepTitle); + return Array.isArray(p.baseUrl) ? t('Endpoint') : t('Base URL'); + } + if (step === 'apiKey') return t('API Key'); + if (step === 'models') return t('Model IDs'); + if (step === 'advancedConfig') return t('Advanced Config'); + if (step === 'review') return t('Review'); + return ''; +} + +function resolveDocumentationUrl( + config: ProviderConfig, + baseUrl: string, +): string | undefined { + if (!config.documentationUrl) return undefined; + return typeof config.documentationUrl === 'function' + ? config.documentationUrl(baseUrl) + : config.documentationUrl; +} + +const NAV_HINT_SELECT = t('Enter to select, ↑↓ to navigate, Esc to go back'); +const NAV_HINT_INPUT = t('Enter to submit, Esc to go back'); + +// --------------------------------------------------------------------------- +// Shared view primitives +// --------------------------------------------------------------------------- + +function RadioList({ items, cursor }: { items: RadioItem[]; cursor: number }) { + return ( + + {items.map((item, i) => { + const selected = i === cursor; + return ( + + + + {selected ? '● ' : '○ '} + + + {item.label} + + + {item.description ? ( + + {item.description} + + ) : null} + + ); + })} + + ); +} + +function InputLine({ + value, + placeholder, + active, +}: { + value: string; + placeholder?: string; + active?: boolean; +}) { + const empty = value.length === 0; + return ( + + + {empty ? (placeholder ?? '') : value} + + {active && {'█'}} + + ); +} + +/** Shared single-line text-input key handling (backend ask-user parity). */ +function useLineInputKeys( + value: string, + onChange: (next: string) => void, + onSubmit: () => void, +) { + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'return' || o.name === 'enter') { + onSubmit(); + return; + } + if (o.name === 'backspace' || o.name === 'delete') { + onChange(value.slice(0, -1)); + return; + } + if (isPrintableKeyInput(key)) { + onChange(value + key.sequence); + } + }); + // Bracketed pastes arrive as one PasteEvent with no keypress per character + // (ink parity: its keypress state machine broadcasts the buffered paste as a + // single `paste` key, which TextInput's buffer inserts verbatim). The main + // composer's editor is unfocused while a dialog owns input, so consume the + // paste here instead of letting it drop. + usePaste((event: PasteEvent) => { + const text = normalizePastedText(decodePasteBytes(event.bytes)); + if (!text) return; + event.preventDefault(); + onChange(value + text); + }); +} + +// --------------------------------------------------------------------------- +// Setup steps (ProviderSetupSteps parity) +// --------------------------------------------------------------------------- + +function ProtocolStep({ flow }: { flow: ProviderSetupFlow }) { + const provider = flow.state.provider!; + const items = useMemo(() => { + const protocolOpts = provider.protocolOptions ?? [provider.protocol]; + return PROTOCOL_ITEMS.filter((p) => + protocolOpts.includes(p.value as AuthType), + ); + }, [provider]); + const [cursor, setCursor] = useState(0); + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'up') { + setCursor((c) => Math.max(0, c - 1)); + } else if (o.name === 'down') { + setCursor((c) => Math.min(items.length - 1, c + 1)); + } else if (o.name === 'return') { + const item = items[cursor]; + if (item) flow.selectProtocol(item.value as AuthType); + } + }); + return ( + <> + + + {NAV_HINT_SELECT} + + + ); +} + +function BaseUrlSelectStep({ + provider, + flow, +}: { + provider: ProviderConfig; + flow: ProviderSetupFlow; +}) { + const options = provider.baseUrl as BaseUrlOption[]; + const items: RadioItem[] = options.map((opt) => ({ + key: opt.id, + label: t(opt.label), + description: opt.url, + value: opt.url, + })); + const [cursor, setCursor] = useState(flow.state.baseUrlOptionIndex); + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'up' || o.name === 'down') { + const next = + o.name === 'up' + ? Math.max(0, cursor - 1) + : Math.min(items.length - 1, cursor + 1); + setCursor(next); + // ink onHighlight parity: remember the highlighted option so a + // go-back later restores the cursor. + const item = items[next]; + if (item) flow.highlightBaseUrl(item.value); + } else if (o.name === 'return') { + const item = items[cursor]; + if (item) flow.selectBaseUrl(item.value); + } + }); + return ( + <> + + + {NAV_HINT_SELECT} + + + ); +} + +function BaseUrlInputStep({ + flow, + documentationUrl, +}: { + flow: ProviderSetupFlow; + documentationUrl?: string; +}) { + useLineInputKeys(flow.state.baseUrl, flow.changeBaseUrl, () => + flow.submitBaseUrl(), + ); + return ( + + {t('Enter the API endpoint for this protocol.')} + + {flow.state.baseUrlError && ( + + {flow.state.baseUrlError} + + )} + {documentationUrl && ( + + {`${t('Documentation')}: ${documentationUrl}`} + + )} + + {NAV_HINT_INPUT} + + + ); +} + +function ApiKeyStep({ + provider, + flow, +}: { + provider: ProviderConfig; + flow: ProviderSetupFlow; +}) { + const docUrl = resolveDocumentationUrl(provider, flow.state.baseUrl); + useLineInputKeys(flow.state.apiKey, flow.changeApiKey, () => + flow.submitApiKey(flow.state.apiKey), + ); + return ( + + {docUrl && ( + + {`${t('Documentation')}: ${docUrl}`} + + )} + + {flow.state.apiKeyError && ( + + {flow.state.apiKeyError} + + )} + + {NAV_HINT_INPUT} + + + ); +} + +const MODEL_CUSTOM_INPUT_FOCUS_INDEX = -2; + +function uniqueIds(ids: string[]): string[] { + return [...new Set(ids)]; +} + +/** + * Model IDs step. Simplified vs ink: no search box over the recommended + * list; custom IDs and recommended multi-select both feed the shared + * flow.state.modelIds like the ink ModelIdsStep. + */ +function ModelsStep({ + provider, + flow, +}: { + provider: ProviderConfig; + flow: ProviderSetupFlow; +}) { + const modelOptions = useMemo( + () => provider.models?.map((m) => m.id) ?? [], + [provider.models], + ); + const hasSelectableModels = modelOptions.length > 0; + const selectedModelIds = useMemo( + () => normalizeModelIds(flow.state.modelIds), + [flow.state.modelIds], + ); + const recommendedIds = useMemo(() => new Set(modelOptions), [modelOptions]); + const [focus, setFocus] = useState(MODEL_CUSTOM_INPUT_FOCUS_INDEX); + const [customText, setCustomText] = useState(() => + selectedModelIds.filter((id) => !recommendedIds.has(id)).join(', '), + ); + const [checked, setChecked] = useState>( + () => new Set(selectedModelIds.filter((id) => recommendedIds.has(id))), + ); + + const syncModelIds = useCallback( + (custom: string, keys: ReadonlySet) => { + flow.changeModelIds( + uniqueIds([...normalizeModelIds(custom), ...keys]).join(', '), + ); + }, + [flow], + ); + + const updateCustom = useCallback( + (next: string) => { + setCustomText(next); + syncModelIds(next, checked); + }, + [checked, syncModelIds], + ); + + const toggleRecommended = useCallback( + (id: string) => { + const next = new Set(checked); + if (next.has(id)) next.delete(id); + else next.add(id); + setChecked(next); + syncModelIds(customText, next); + }, + [checked, customText, syncModelIds], + ); + + const submit = useCallback(() => { + flow.submitModelIds({ + modelIds: uniqueIds([...normalizeModelIds(customText), ...checked]), + }); + }, [customText, checked, flow]); + + useKeyboard((key) => { + const o = toOriginalKey(key); + if (focus >= 0) { + if (o.name === 'tab') { + setFocus(MODEL_CUSTOM_INPUT_FOCUS_INDEX); + } else if (o.name === 'up') { + setFocus((f) => (f <= 0 ? MODEL_CUSTOM_INPUT_FOCUS_INDEX : f - 1)); + } else if (o.name === 'down') { + setFocus((f) => Math.min(f + 1, modelOptions.length - 1)); + } else if (o.name === 'space') { + const id = modelOptions[focus]; + if (id) toggleRecommended(id); + } else if (o.name === 'return') { + submit(); + } + return; + } + // Custom-ID input focus. + if (o.name === 'tab' || o.name === 'down') { + if (hasSelectableModels) setFocus(0); + return; + } + if (o.name === 'return' || o.name === 'enter') { + submit(); + return; + } + if (o.name === 'backspace' || o.name === 'delete') { + updateCustom(customText.slice(0, -1)); + return; + } + if (isPrintableKeyInput(key)) { + updateCustom(customText + key.sequence); + } + }); + // Pastes land in the custom-ID input only when it owns focus; while the + // recommended list is focused there is no text field to receive them. + usePaste((event: PasteEvent) => { + if (focus >= 0) return; + const text = normalizePastedText(decodePasteBytes(event.bytes)); + if (!text) return; + event.preventDefault(); + updateCustom(customText + text); + }); + + return ( + + + {t( + 'Enter model IDs directly. Use commas to configure multiple models.', + )} + + + {flow.state.modelIdsError && ( + + {flow.state.modelIdsError} + + )} + {hasSelectableModels ? ( + <> + + {t('Recommended models')} + + + {modelOptions.map((id, i) => { + const isChecked = checked.has(id); + const focused = i === focus; + return ( + + + {focused ? '› ' : ' '} + + + {isChecked ? '◉ ' : '○ '} + + {id} + + ); + })} + + + + {t( + 'Tab toggles input/list, Space toggles a model, Enter to continue, Esc to go back', + )} + + + + ) : ( + + {NAV_HINT_INPUT} + + )} + + ); +} + +function AdvancedConfigStep({ flow }: { flow: ProviderSetupFlow }) { + const { + thinkingEnabled, + modalityEnabled, + modalityImage, + modalityVideo, + modalityAudio, + modalityPdf, + contextWindowSize, + focusedConfigIndex, + } = flow.state; + const ctxIdx = modalityEnabled ? 6 : 2; + const onCtxRow = focusedConfigIndex === ctxIdx; + useKeyboard((key) => { + const o = toOriginalKey(key); + // Focus-row navigation restricted to unambiguous shortcuts (ink parity: + // a letter typed into the context-window field must not move the row). + if (o.name === 'up' || (o.ctrl && o.name === 'p')) { + flow.moveAdvancedFocusUp(); + return; + } + if (o.name === 'down' || (o.ctrl && o.name === 'n')) { + flow.moveAdvancedFocusDown(); + return; + } + if (o.name === 'space') { + // On the context row Space inserts a space into the field; the flow's + // toggleFocusedAdvancedOption has no case for ctxIdx (ink parity). + if (onCtxRow) flow.changeContextWindowSize(contextWindowSize + ' '); + else flow.toggleFocusedAdvancedOption(); + return; + } + if (o.name === 'return') { + flow.submitAdvancedConfig(); + return; + } + if (onCtxRow) { + if (o.name === 'backspace' || o.name === 'delete') { + flow.changeContextWindowSize(contextWindowSize.slice(0, -1)); + return; + } + if (isPrintableKeyInput(key)) { + flow.changeContextWindowSize(contextWindowSize + key.sequence); + } + } + }); + // Only the context-window field accepts text; a paste while another row is + // focused should not move any toggle. + usePaste((event: PasteEvent) => { + if (!onCtxRow) return; + const text = normalizePastedText(decodePasteBytes(event.bytes)); + if (!text) return; + event.preventDefault(); + flow.changeContextWindowSize(contextWindowSize + text); + }); + const checkmark = (v: boolean) => (v ? '◉' : '○'); + const cursor = (index: number) => (focusedConfigIndex === index ? '›' : ' '); + const rowFg = (index: number) => + focusedConfigIndex === index ? C.green : undefined; + return ( + + + {t('Optional: configure advanced generation settings.')} + + + + {`${cursor(0)} ${checkmark(thinkingEnabled)} ${t('Enable thinking')}`} + + + + + {t( + 'Allows the model to perform extended reasoning before responding.', + )} + + + + + {`${cursor(1)} ${checkmark(modalityEnabled)} ${t('Enable modality')}`} + + + + + {t('Enables multimodal input capabilities (image, video, etc.).')} + + + {modalityEnabled && ( + + {`${cursor(2)} ${checkmark(modalityImage)} Image `} + {`${cursor(3)} ${checkmark(modalityVideo)} Video `} + {`${cursor(4)} ${checkmark(modalityAudio)} Audio `} + {`${cursor(5)} ${checkmark(modalityPdf)} PDF`} + + )} + + {`${cursor(ctxIdx)} ${t('Context window')}: `} + + {contextWindowSize || 'auto'} + + {onCtxRow && {'█'}} + + + + {t('Max input tokens (leave empty to auto-detect from model name).')} + + + + + {t( + '↑↓ to navigate, Space to toggle, Enter to continue, Esc to go back', + )} + + + + ); +} + +function ReviewStep({ flow }: { flow: ProviderSetupFlow }) { + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'return') flow.submit(); + }); + return ( + + + {t('The following JSON will be saved to settings.json:')} + + + {flow.state.previewJson} + + + {t('Enter to save, Esc to go back')} + + + ); +} + +function SetupSteps({ flow }: { flow: ProviderSetupFlow }) { + const { provider, step } = flow.state; + if (!provider || !step) return null; + switch (step) { + case 'protocol': + return ; + case 'baseUrl': + return Array.isArray(provider.baseUrl) ? ( + + ) : ( + + ); + case 'apiKey': + return ; + case 'models': + return ; + case 'advancedConfig': + return ; + case 'review': + return ; + default: + return null; + } +} + +// --------------------------------------------------------------------------- +// AuthDialog +// --------------------------------------------------------------------------- + +type AuthDialogProps = { + config?: Config; + settings: LoadedSettings; + onClose: () => void; + /** Append a command-style message to the chat history (success feedback). */ + notify?: (text: string) => void; +}; + +export function OpenTuiAuthDialog(props: AuthDialogProps) { + // Without a live config (settings-only mount) there is nothing to connect: + // keep the pre-flow read-only summary instead of a broken wizard. + if (!props.config) { + return ( + + + + {t('Credentials resolved from settings/env; use /model to switch.')} + + + + ); + } + return ; +} + +function AuthDialogFlow({ + config, + settings, + onClose, + notify, +}: AuthDialogProps & { config: Config }) { + const [errorMessage, setErrorMessage] = useState(null); + const [viewLevel, setViewLevel] = useState('main'); + const [_viewStack, setViewStack] = useState([]); + const [mainIndex, setMainIndex] = useState(null); + const [subMenuIndex, setSubMenuIndex] = useState>({}); + + // -- Submit (useAuth.handleProviderSubmit parity: same install-plan write + // path, feedback message and auth telemetry; dialog-local error surface) -- + + const handleProviderSubmit = useCallback( + async (providerConfig: ProviderConfig, inputs: ProviderSetupInputs) => { + const protocol = inputs.protocol ?? providerConfig.protocol; + try { + const plan = buildInstallPlan(providerConfig, inputs); + await applyProviderInstallPlan(plan, { + settings: createLoadedSettingsAdapter(settings), + reloadModelProviders: (mp) => config.reloadModelProvidersConfig(mp), + syncAuthState: (authType, modelId, baseUrl) => + config + .getModelsConfig() + .syncAfterAuthRefresh(authType, modelId, baseUrl), + refreshAuth: (authType) => config.refreshAuth(authType), + }); + notify?.( + t( + 'Successfully configured {{provider}}. Use /model to switch models.', + { + provider: providerConfig.label, + }, + ), + ); + logAuth(config, new AuthEvent(protocol, 'manual', 'success')); + onClose(); + } catch (error) { + const msg = t('Failed to authenticate. Message: {{message}}', { + message: getErrorMessage(error), + }); + setErrorMessage(msg); + logAuth(config, new AuthEvent(protocol, 'manual', 'error', msg)); + } + }, + [settings, config, notify, onClose], + ); + + const setupFlow = useProviderSetupFlow(handleProviderSubmit); + + // -- Navigation (AuthDialog parity) --------------------------------------- + + const clearErrors = useCallback(() => setErrorMessage(null), []); + + const pushView = useCallback( + (view: ViewLevel) => { + setViewStack((prev) => [...prev, viewLevel]); + setViewLevel(view); + }, + [viewLevel], + ); + + const goBack = useCallback(() => { + clearErrors(); + if (viewLevel === 'provider-setup') { + if (setupFlow.goBack()) return; + } + setViewStack((prev) => { + const next = [...prev]; + const parent = next.pop() ?? 'main'; + setViewLevel(parent); + return next; + }); + }, [viewLevel, setupFlow, clearErrors]); + + // -- Sub-menu items --------------------------------------------------------- + + const alibabaItems = useMemo(() => ALIBABA_PROVIDERS.map(providerToItem), []); + const thirdPartyItems = useMemo( + () => THIRD_PARTY_PROVIDERS.map(providerToItem), + [], + ); + + const existingEnv = (settings.merged.env ?? {}) as Record; + + const getExistingModelIds = (providerConfig: ProviderConfig): string[] => { + const saved = findExistingProviderModels( + providerConfig, + settings.merged.modelProviders as Record | undefined, + ); + if (!saved) return []; + const builtinIds = new Set(getDefaultModelIds(providerConfig)); + return saved.models.map((m) => m.id).filter((id) => !builtinIds.has(id)); + }; + + const handleProviderSelect = useCallback( + (providerId: string) => { + clearErrors(); + const providerConfig = findProviderById(providerId); + if (!providerConfig) return; + setupFlow.start( + providerConfig, + undefined, + existingEnv, + getExistingModelIds(providerConfig), + ); + pushView('provider-setup'); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [clearErrors, setupFlow, pushView, settings], + ); + + const subMenus: Record = { + 'alibaba-select': alibabaItems, + 'thirdparty-select': thirdPartyItems, + }; + const activeSubMenu = subMenus[viewLevel]; + + // -- Default main index from current auth state --------------------------- + + const contentGenConfig = config.getContentGeneratorConfig(); + const matchedProvider = findProviderByCredentials( + contentGenConfig?.baseUrl, + contentGenConfig?.apiKeyEnvKey, + ); + // Land on the tab matching the active provider's uiGroup (ink parity). + const defaultMainIndex = useMemo(() => { + if (matchedProvider?.uiGroup === 'third-party') return 1; + if (matchedProvider?.uiGroup === 'custom') return 2; + return 0; + }, [matchedProvider]); + + // -- Main menu select ------------------------------------------------------- + + const handleMainSelect = useCallback( + (value: MainOption) => { + clearErrors(); + switch (value) { + case 'ALIBABA_MODELSTUDIO': + pushView('alibaba-select'); + break; + case 'THIRD_PARTY_PROVIDERS': + pushView('thirdparty-select'); + break; + case 'CUSTOM_PROVIDER': + setupFlow.start( + customProvider, + undefined, + existingEnv, + getExistingModelIds(customProvider), + ); + pushView('provider-setup'); + break; + default: + break; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [clearErrors, pushView, setupFlow, settings], + ); + + // -- Keyboard: main / sub-menu lists -------------------------------------- + + const mainCursor = mainIndex ?? defaultMainIndex; + const subCursor = activeSubMenu ? (subMenuIndex[viewLevel] ?? 0) : 0; + + useKeyboard((key) => { + const o = toOriginalKey(key); + if (viewLevel === 'main') { + if (o.name === 'up') { + setMainIndex(Math.max(0, mainCursor - 1)); + } else if (o.name === 'down') { + setMainIndex(Math.min(MAIN_ITEMS.length - 1, mainCursor + 1)); + } else if (o.name === 'return') { + const item = MAIN_ITEMS[mainCursor]; + if (item) handleMainSelect(item.value as MainOption); + } + return; + } + if (activeSubMenu) { + const items = activeSubMenu; + if (o.name === 'up') { + setSubMenuIndex((prev) => ({ + ...prev, + [viewLevel]: Math.max(0, subCursor - 1), + })); + } else if (o.name === 'down') { + setSubMenuIndex((prev) => ({ + ...prev, + [viewLevel]: Math.min(items.length - 1, subCursor + 1), + })); + } else if (o.name === 'return') { + const item = items[subCursor]; + if (item) handleProviderSelect(item.value); + } + } + }); + + // -- Esc (raw input, consumed before parsed-key dispatch) ----------------- + + const renderer = useRenderer(); + useLayoutEffect(() => { + const onRaw = (seq: string): boolean => { + if (seq !== '\x1b') return false; + if (viewLevel !== 'main') { + goBack(); + return true; + } + if (errorMessage) return true; + if (config.getAuthType() === undefined) { + setErrorMessage( + t( + 'You must connect a provider to proceed. Press Ctrl+C again to exit.', + ), + ); + return true; + } + onClose(); + return true; + }; + renderer.addInputHandler(onRaw); + return () => renderer.removeInputHandler(onRaw); + }, [renderer, viewLevel, goBack, errorMessage, config, onClose]); + + // -- View title ------------------------------------------------------------- + + const viewTitle = useMemo(() => { + if (viewLevel !== 'provider-setup') { + return VIEW_TITLES[viewLevel] ?? VIEW_TITLES['main']; + } + const p = setupFlow.state.provider; + if (!p) return t('Provider Setup'); + const flowTitle = p.uiLabels?.flowTitle ?? p.label; + const { stepIndex, totalSteps, step } = setupFlow.state; + return t('{{flowTitle}} · Step {{step}}/{{total}} · {{stepLabel}}', { + flowTitle, + step: String(stepIndex), + total: String(totalSteps), + stepLabel: getStepLabel(step, p), + }); + }, [viewLevel, setupFlow.state]); + + // -- Render ------------------------------------------------------------------- + + return ( + + {viewLevel === 'main' && ( + <> + + + {'─'.repeat(60)} + + + {`${t('Terms of Services and Privacy Notice')}:`} + + + + { + 'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/' + } + + + + )} + + {activeSubMenu && ( + <> + + + {NAV_HINT_SELECT} + + + )} + + {viewLevel === 'provider-setup' && } + + {errorMessage && ( + + {errorMessage} + + )} + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-extensions.test.tsx b/packages/cli/src/ui/opentui/dialogs-extensions.test.tsx new file mode 100644 index 00000000000..c37fe596020 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-extensions.test.tsx @@ -0,0 +1,380 @@ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Component wiring tests for the OpenTUI /extensions dialog management keys + * (audit 01 G-4 / 05 G-12). The native renderer (Bun/FFI) is exercised by + * the PTY gate; here the OpenTUI hooks/jsx runtime are faked (same harness + * family as dialogs-auth.test.tsx — with a mount-stable useKeyboard so the + * dialog's several parallel keyboard consumers can be driven at once) and + * the tests verify the footer-promised keys end-to-end through the dialog + * state machine: + * + * - Installed list: ↑↓ navigate, Space toggle, f favorite, Enter details; + * - detail view: action list, scope select, y/n uninstall confirm, + * mark-update surfacing "Update Now"; + * - Esc walks back one level (detail → list → close); + * - Discover/Sources degrade honestly (no fake keys, no fake loading). + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { render, screen } from '@testing-library/react'; + +const mocks = vi.hoisted(() => { + const state = { + keyboardHandlers: [] as Array<(key: unknown) => void>, + }; + // Shared fake jsx runtime (box→div, text→span); built inside hoisted so + // neither mock factory needs an internal-module import. + async function buildJsxRuntime() { + const React = await import('react'); + const jsx = ( + type: unknown, + props: { children?: unknown; key?: React.Key } | null, + key?: React.Key, + ) => { + const config = key === undefined ? props : { ...props, key }; + const children = (config?.children ?? null) as React.ReactNode; + if (type === 'box' || type === 'text') { + return React.createElement( + type === 'box' ? 'div' : 'span', + key === undefined ? null : { key }, + children, + ); + } + return React.createElement( + type as React.ElementType, + config as Record, + children, + ); + }; + return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment }; + } + return { state, buildJsxRuntime }; +}); + +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +vi.mock('@opentui/react', async () => { + const React = await import('react'); + return { + // Mount-stable registration: the wrapper is registered once per consumer + // and always invokes the latest handler closure — the real renderer's + // semantics — so a key event can be broadcast to every mounted consumer. + useKeyboard: (handler: (key: unknown) => void) => { + const ref = React.useRef(handler); + ref.current = handler; + React.useEffect(() => { + const fn = (key: unknown) => ref.current(key); + mocks.state.keyboardHandlers.push(fn); + return () => { + const index = mocks.state.keyboardHandlers.indexOf(fn); + if (index >= 0) mocks.state.keyboardHandlers.splice(index, 1); + }; + }, []); + }, + useRenderer: () => ({ + addInputHandler: () => {}, + removeInputHandler: () => {}, + }), + }; +}); + +vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); + +vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); + +vi.mock('./theme.js', () => ({ + C: new Proxy({}, { get: () => '#ffffff' }), +})); + +import { + EXTENSIONS_TAB_ORDER, + EXTENSIONS_TABS, + extensionsFooterHint, + extensionsStatusColor, + extensionsTabLabel, + OpenTuiExtensionsDialog, + type ExtensionRow, +} from './dialogs-extensions.js'; +import { C } from './theme.js'; + +describe('extensions tabs (shell parity)', () => { + it('keeps the original tab ids and order', () => { + expect(EXTENSIONS_TABS).toEqual({ + INSTALLED: 'installed', + DISCOVER: 'discover', + SOURCES: 'sources', + }); + expect([...EXTENSIONS_TAB_ORDER]).toEqual([ + 'installed', + 'discover', + 'sources', + ]); + }); + + it('labels tabs like the original TabBar', () => { + expect(extensionsTabLabel('installed')).toBe('Installed'); + expect(extensionsTabLabel('discover')).toBe('Discover'); + expect(extensionsTabLabel('sources')).toBe('Sources'); + }); + + it('keeps the original Installed hint; Discover/Sources get an honest one', () => { + expect(extensionsFooterHint('installed')).toBe( + '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close', + ); + // The ink hints promise keys this renderer does not implement; repeating + // them would be a lie, so the degraded tabs say what they actually do. + expect(extensionsFooterHint('discover')).toBe( + 'Tab / ←→ to switch · Esc to close', + ); + expect(extensionsFooterHint('sources')).toBe( + 'Tab / ←→ to switch · Esc to close', + ); + }); + + it('maps status types onto the shared palette', () => { + expect(extensionsStatusColor({ type: 'error', text: '' })).toBe(C.red); + expect(extensionsStatusColor({ type: 'warning', text: '' })).toBe(C.yellow); + expect(extensionsStatusColor({ type: 'success', text: '' })).toBe(C.green); + expect(extensionsStatusColor({ type: 'info', text: '' })).toBe(C.dim); + }); +}); + +function baseKeyEvent(overrides: Record = {}) { + return { + name: 'a', + sequence: 'a', + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + hyper: false, + eventType: 'press', + preventDefault: () => {}, + stopPropagation: () => {}, + ...overrides, + }; +} + +// The real renderer dispatches one key event synchronously to every +// consumer: each handler sees the state as of the event, not the state the +// previous handler just produced. A single act keeps that semantics — +// flushing between handlers would let a later consumer react to a view +// transition the same event already caused. +async function broadcast(key: Record): Promise { + await act(async () => { + for (const handler of [...mocks.state.keyboardHandlers]) { + handler(baseKeyEvent(key)); + } + }); +} + +async function press(name: string, sequence?: string): Promise { + await broadcast({ name, sequence: sequence ?? name }); +} + +const ROWS: ExtensionRow[] = [ + { + key: 'ext-a', + label: 'ext-a', + meta: '/x/a', + enabled: true, + favorite: true, + scope: 'user', + version: '1.0.0', + components: '2 MCP', + }, + { + key: 'ext-b', + label: 'ext-b', + meta: '/x/b', + enabled: false, + scope: 'project', + }, +]; + +function renderDialog(overrides?: { + rows?: ExtensionRow[]; + busy?: boolean; + onDetailAction?: ReturnType; +}) { + const onClose = vi.fn(); + const onRowAction = vi.fn(); + const onDetailAction = overrides?.onDetailAction ?? vi.fn(); + render( + , + ); + return { onClose, onRowAction, onDetailAction }; +} + +describe('OpenTuiExtensionsDialog management keys (#44)', () => { + beforeEach(() => { + mocks.state.keyboardHandlers.length = 0; + }); + + it('renders the installed rows with their status', () => { + renderDialog(); + expect(screen.getByText('ext-a')).toBeTruthy(); + expect(screen.getByText('ext-b')).toBeTruthy(); + expect(screen.getByText(/active/)).toBeTruthy(); + expect(screen.getByText(/disabled/)).toBeTruthy(); + }); + + it('Space toggles the highlighted row and f favorites it', async () => { + const { onRowAction } = renderDialog(); + await press('space', ' '); + expect(onRowAction).toHaveBeenCalledWith(ROWS[0], 'toggle'); + await press('f', 'f'); + expect(onRowAction).toHaveBeenCalledWith(ROWS[0], 'favorite'); + }); + + it('ignores Space/f while a mutation is in flight', async () => { + const { onRowAction } = renderDialog({ busy: true }); + await press('space', ' '); + await press('f', 'f'); + expect(onRowAction).not.toHaveBeenCalled(); + }); + + it('↑↓ moves the highlight and Space acts on the new row', async () => { + const { onRowAction } = renderDialog(); + await press('down'); + await press('space', ' '); + expect(onRowAction).toHaveBeenCalledWith(ROWS[1], 'toggle'); + await press('up'); + await press('space', ' '); + expect(onRowAction).toHaveBeenLastCalledWith(ROWS[0], 'toggle'); + }); + + it('Enter opens the detail view with the info panel and actions', async () => { + renderDialog(); + await press('return'); + expect(screen.getByText('ext-a')).toBeTruthy(); + expect(screen.getByText('1.0.0')).toBeTruthy(); + expect(screen.getByText('Disable')).toBeTruthy(); + // ext-a is a favorite already, so the action reads "Remove". + expect(screen.getByText('Remove from Favorites')).toBeTruthy(); + expect(screen.getByText('Change scope')).toBeTruthy(); + expect(screen.getByText('Mark for Update')).toBeTruthy(); + expect(screen.getByText('Uninstall')).toBeTruthy(); + }); + + it('detail Enter runs the highlighted action; Esc walks back to list then closes', async () => { + const { onDetailAction, onClose } = renderDialog(); + await press('return'); // list → detail + await press('return'); // detail: Disable (highlighted) + expect(onDetailAction).toHaveBeenCalledWith(ROWS[0], 'toggle'); + await press('escape'); // detail → list + await press('escape'); // list → close + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('scope select runs change-scope with the chosen scope', async () => { + const { onDetailAction } = renderDialog(); + await press('return'); // → detail + await press('down'); // favorite + await press('down'); // change-scope + await press('return'); // → scope-select + expect(screen.getByText('Global (User Scope)')).toBeTruthy(); + expect(screen.getByText('Project (Workspace)')).toBeTruthy(); + await press('return'); // select user (highlighted) + expect(onDetailAction).toHaveBeenCalledWith( + ROWS[0], + 'change-scope', + 'user', + ); + }); + + it('uninstall confirm: y executes, n backs out to the detail', async () => { + const { onDetailAction } = renderDialog(); + await press('return'); // → detail + await press('down'); // favorite + await press('down'); // change-scope + await press('down'); // mark-update + await press('down'); // uninstall + await press('return'); // → uninstall-confirm + expect(screen.getByText(/Are you sure you want to uninstall/)).toBeTruthy(); + await press('n', 'n'); // back to detail (cursor re-syncs to 0 via resyncKey) + // Backing out must not uninstall. + expect(onDetailAction).not.toHaveBeenCalledWith(ROWS[0], 'uninstall'); + // Re-navigate to uninstall (cursor reset by resyncKey on view change). + await press('down'); // favorite + await press('down'); // change-scope + await press('down'); // mark-update + await press('down'); // uninstall + await press('return'); // re-enter confirm + await press('y', 'y'); // confirm + expect(onDetailAction).toHaveBeenCalledWith(ROWS[0], 'uninstall'); + }); + + it('mark-update surfaces Update Now when an update is available', async () => { + const onDetailAction = vi.fn().mockResolvedValue('update-available'); + renderDialog({ onDetailAction }); + await press('return'); // → detail + await press('down'); // favorite + await press('down'); // change-scope + await press('down'); // mark-update + await press('return'); // run check + expect(onDetailAction).toHaveBeenCalledWith(ROWS[0], 'mark-update'); + // The async state lands after the promise resolves; flush it. + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText('Update Now')).toBeTruthy(); + }); + + it('falls back to the list when the open row disappears after a reload', async () => { + const { rerender } = render(
); + const onClose = vi.fn(); + const onRowAction = vi.fn(); + rerender( + , + ); + await press('return'); // → detail on ext-a + // Reload removes every row (e.g. uninstall): the view falls back. + rerender( + , + ); + expect(screen.getByText('No extensions installed.')).toBeTruthy(); + }); + + it('Discover/Sources degrade honestly (no fake footer hints)', async () => { + renderDialog(); + await press('tab'); + expect( + screen.getByText( + 'Discover is not yet available in the OpenTUI renderer.', + ), + ).toBeTruthy(); + expect(screen.getByText(/Tab \/ ←→ to switch · Esc to close/)).toBeTruthy(); + await press('tab'); + expect( + screen.getByText( + 'Marketplace sources are not yet available in the OpenTUI renderer.', + ), + ).toBeTruthy(); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-extensions.tsx b/packages/cli/src/ui/opentui/dialogs-extensions.tsx new file mode 100644 index 00000000000..268c4c68759 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-extensions.tsx @@ -0,0 +1,669 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink `/extensions` dialog (audit 01 G-4 / 05 G-12): + * the Installed / Discover / Sources tab shell with its cycling rules + * (Tab/Shift+Tab/←/→, Discover's marketplace filter clears in place on Tab, + * Esc to close, status message coloring) plus the Installed-tab management + * keys the footer promises — ↑↓ navigate (wrap-around), Space enable/disable, + * f favorite, Enter details. The detail view reproduces the ink + * ExtensionActionsView stack: info panel + actions list, scope select, and a + * y/n uninstall confirm. Discover/Sources degrade honestly (they say so — + * no fake loading state, no fake footer hints). Rows and mutations are + * backend work (dialog-data.ts); MCP servers are managed in the /mcp dialog. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { C } from './theme.js'; +import { t } from '../../i18n/index.js'; +import { toOriginalKey } from './key-map.js'; +import { useKeyboard } from '@opentui/react'; +import { cycleTab } from './dialogs-core.js'; +import { + DialogSelect, + useDialogSelect, + type DialogListItem, +} from './dialogs-shared.js'; +import type { ExtensionUpdateCheckState } from './dialog-data.js'; + +export const EXTENSIONS_TABS = { + INSTALLED: 'installed', + DISCOVER: 'discover', + SOURCES: 'sources', +} as const; + +export type ExtensionsTab = + (typeof EXTENSIONS_TABS)[keyof typeof EXTENSIONS_TABS]; + +export const EXTENSIONS_TAB_ORDER: readonly ExtensionsTab[] = [ + EXTENSIONS_TABS.INSTALLED, + EXTENSIONS_TABS.DISCOVER, + EXTENSIONS_TABS.SOURCES, +]; + +/** Parity of tabLabel in extensions/TabBar.tsx. */ +export function extensionsTabLabel(tab: ExtensionsTab): string { + switch (tab) { + case EXTENSIONS_TABS.DISCOVER: + return t('Discover'); + case EXTENSIONS_TABS.INSTALLED: + return t('Installed'); + case EXTENSIONS_TABS.SOURCES: + return t('Sources'); + default: + return tab; + } +} + +/** + * Footer hint for the Installed tab (the original text). Discover/Sources + * get an honest hint instead: their ink footers promise keys this renderer + * does not implement, so repeating them would be a lie. + */ +export function extensionsFooterHint(tab: ExtensionsTab): string { + switch (tab) { + case EXTENSIONS_TABS.INSTALLED: + return t( + '↑↓ navigate · Space enable/disable · f favorite · Enter details · Esc close', + ); + case EXTENSIONS_TABS.DISCOVER: + case EXTENSIONS_TABS.SOURCES: + return t('Tab / ←→ to switch · Esc to close'); + default: + return ''; + } +} + +export interface ExtensionsStatusMessage { + type: 'info' | 'success' | 'warning' | 'error'; + text: string; +} + +/** Parity of the status Text coloring in ExtensionsManagerDialog. */ +export function extensionsStatusColor(status: ExtensionsStatusMessage): string { + switch (status.type) { + case 'error': + return C.red; + case 'warning': + return C.yellow; + case 'success': + return C.green; + default: + return C.dim; + } +} + +export interface ExtensionRow { + key: string; + label: string; + meta?: string; + enabled?: boolean; + favorite?: boolean; + scope?: 'user' | 'project'; + version?: string; + source?: string; + origin?: string; + components?: string; +} + +/** Detail-view actions (parity of PluginDetailAction). */ +export type ExtensionDetailAction = + | 'toggle' + | 'favorite' + | 'change-scope' + | 'mark-update' + | 'update' + | 'uninstall'; + +type InstalledView = 'list' | 'detail' | 'scope-select' | 'uninstall-confirm'; + +export interface OpenTuiExtensionsDialogProps { + onClose: () => void; + initialTab?: ExtensionsTab; + status?: ExtensionsStatusMessage | null; + /** True while a tab owns a sub-view (locks tab cycling). */ + tabLocked?: boolean; + /** Optional tab-provided footer hint wins over the generic hint. */ + tabFooter?: string | null; + /** Marketplace filter for the Discover tab (set via Sources "Browse"). */ + discoverFilter?: string | null; + onDiscoverFilterChange?: (filter: string | null) => void; + rowsByTab?: Partial>; + /** Space / f on the highlighted Installed row. */ + onRowAction?: (row: ExtensionRow, action: 'toggle' | 'favorite') => void; + /** + * Detail-view actions. `mark-update` resolves to the check state so the + * detail view can offer "Update Now" right away (ink checkedUpdateState). + */ + onDetailAction?: ( + row: ExtensionRow, + action: ExtensionDetailAction, + arg?: 'user' | 'project', + ) => + | Promise + | ExtensionUpdateCheckState + | void; + /** True while a mutation is in flight (mashing Space is ignored). */ + busy?: boolean; +} + +export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) { + const { + onClose, + initialTab, + status, + tabLocked = false, + tabFooter, + discoverFilter: discoverFilterProp, + onDiscoverFilterChange, + rowsByTab, + onRowAction, + onDetailAction, + busy = false, + } = props; + + const [activeTab, setActiveTab] = useState( + initialTab ?? EXTENSIONS_TABS.INSTALLED, + ); + const [discoverFilter, setDiscoverFilter] = useState( + discoverFilterProp ?? null, + ); + const [view, setView] = useState('list'); + // Selected row tracked by key: rows are re-read after every mutation, so + // keying keeps the cursor (and any open detail view) on the SAME item even + // when it moves or changes state. + const [selectedKey, setSelectedKey] = useState(null); + const [checkedUpdateState, setCheckedUpdateState] = useState< + ExtensionUpdateCheckState | undefined + >(undefined); + + const clearDiscoverFilter = useCallback(() => { + setDiscoverFilter(null); + onDiscoverFilterChange?.(null); + }, [onDiscoverFilterChange]); + + const cycle = useCallback((direction: 1 | -1) => { + setDiscoverFilter(null); + setActiveTab((current) => + cycleTab(EXTENSIONS_TAB_ORDER, current, direction), + ); + }, []); + + const rows: readonly ExtensionRow[] = useMemo( + () => rowsByTab?.[EXTENSIONS_TABS.INSTALLED] ?? [], + [rowsByTab], + ); + const currentRow = useMemo( + () => + selectedKey + ? (rows.find((row) => row.key === selectedKey) ?? null) + : null, + [rows, selectedKey], + ); + + // A reload that removed the item whose detail is open falls back to the + // list (ink parity) — otherwise the view stays locked with no target. + useEffect(() => { + if (view !== 'list' && !currentRow) { + setView('list'); + setSelectedKey(null); + } + }, [view, currentRow]); + + const listItems: Array & { row: ExtensionRow }> = + useMemo( + () => + rows.map((row) => ({ + key: row.key, + value: row.key, + row, + })), + [rows], + ); + + const listSelect = useDialogSelect({ + items: listItems, + numbers: false, + focused: activeTab === EXTENSIONS_TABS.INSTALLED && view === 'list', + onSelect: (key) => { + setSelectedKey(key); + setCheckedUpdateState(undefined); + setView('detail'); + }, + }); + + const detailActions = useMemo(() => { + if (!currentRow) return []; + const enabled = currentRow.enabled !== false; + const items: Array< + DialogListItem & { label: string } + > = [ + { + key: 'toggle', + value: 'toggle', + label: enabled ? t('Disable') : t('Enable'), + }, + { + key: 'favorite', + value: 'favorite', + label: currentRow.favorite + ? t('Remove from Favorites') + : t('Add to Favorites'), + }, + { + key: 'change-scope', + value: 'change-scope', + label: t('Change scope'), + }, + { + key: 'mark-update', + value: 'mark-update', + label: t('Mark for Update'), + }, + ]; + if (checkedUpdateState === 'update-available') { + items.push({ + key: 'update', + value: 'update', + label: t('Update Now'), + }); + } + items.push({ + key: 'uninstall', + value: 'uninstall', + label: t('Uninstall'), + }); + return items; + }, [currentRow, checkedUpdateState]); + + const detailSelect = useDialogSelect({ + items: detailActions, + // Re-sync the cursor when re-entering detail: the action list shrinks + // (checked-update state resets) so the stale activeIndex can be out of + // range — Enter would read items[5] = undefined. + resyncKey: view, + numbers: false, + focused: view === 'detail', + onSelect: (action) => { + if (!currentRow) return; + if (action === 'change-scope') { + setView('scope-select'); + return; + } + if (action === 'uninstall') { + setView('uninstall-confirm'); + return; + } + if (action === 'mark-update') { + void Promise.resolve(onDetailAction?.(currentRow, 'mark-update')) + .then((state) => { + if (state) setCheckedUpdateState(state); + }) + .catch(() => {}); + return; + } + void Promise.resolve(onDetailAction?.(currentRow, action)).catch( + () => {}, + ); + }, + }); + + const scopeItems = useMemo< + Array & { label: string }> + >( + () => [ + { + key: 'user', + value: 'user' as const, + label: t('Global (User Scope)'), + }, + { + key: 'project', + value: 'project' as const, + label: t('Project (Workspace)'), + }, + ], + [], + ); + + const scopeSelect = useDialogSelect({ + items: scopeItems, + numbers: false, + // Default the cursor to the current scope so the user sees what is in + // effect (ink scopeItems initialIndex parity). + initialIndex: currentRow?.scope === 'project' ? 1 : 0, + // ink remounts the radio select on every scope-view entry; resync on + // entry re-applies the live-scope initialIndex (the mount-time lazy + // initializer runs before any row is selected). + resyncKey: view, + focused: view === 'scope-select', + onSelect: (scope) => { + if (currentRow) + void Promise.resolve( + onDetailAction?.(currentRow, 'change-scope', scope), + ).catch(() => {}); + setView('detail'); + }, + }); + + useKeyboard((key) => { + const original = toOriginalKey(key); + const name = original.name; + + if (view !== 'list') { + // Locked sub-view: Esc walks back one level; Tab/←→ stay inert. + if (name === 'escape') { + setView((current) => (current === 'detail' ? 'list' : 'detail')); + return; + } + if (view === 'uninstall-confirm') { + // y/Enter confirms (ink UninstallConfirmStep), n backs out. + if (original.sequence === 'y' || name === 'return') { + if (currentRow) + void Promise.resolve( + onDetailAction?.(currentRow, 'uninstall'), + ).catch(() => {}); + setView('list'); + } else if (original.sequence === 'n') { + setView('detail'); + } + } + return; + } + + if (tabLocked) return; + if (name === 'tab') { + // On Discover with an active marketplace filter, Tab clears the + // filter in place instead of leaving the tab — the "(Tab to clear)" + // promise from the original. + if (activeTab === EXTENSIONS_TABS.DISCOVER && discoverFilter) { + clearDiscoverFilter(); + } else { + cycle(original.shift ? -1 : 1); + } + } else if (name === 'right') { + cycle(1); + } else if (name === 'left') { + cycle(-1); + } else if (name === 'escape') { + onClose(); + } else if (activeTab === EXTENSIONS_TABS.INSTALLED && !busy) { + const row = listItems[listSelect.activeIndex]?.row; + if (!row) return; + if (name === 'space' || original.sequence === ' ') { + onRowAction?.(row, 'toggle'); + } else if ( + original.sequence === 'f' && + !original.ctrl && + !original.meta + ) { + onRowAction?.(row, 'favorite'); + } + } + }); + + const hint = + tabFooter ?? + (tabLocked || view !== 'list' + ? t('Enter to select · Esc to go back') + : extensionsFooterHint(activeTab)); + + const renderInstalledContent = () => { + if (view === 'detail' && currentRow) { + const enabled = currentRow.enabled !== false; + return ( + + + + + {t('Name:')} + + {currentRow.label} + + {currentRow.version !== undefined && ( + + + {t('Version:')} + + {currentRow.version} + + )} + + + {t('Scope:')} + + + {currentRow.scope === 'project' ? t('Project') : t('User')} + + + + + {t('Status:')} + + + {enabled ? t('active') : t('disabled')} + + {currentRow.favorite ? : null} + + {currentRow.source && ( + + + {t('Source:')} + + {currentRow.source} + + )} + {currentRow.origin && ( + + + {t('Origin:')} + + {currentRow.origin} + + )} + + + {t('Components:')} + + {currentRow.components ?? t('None')} + + + + {t('Actions')} + + detailSelect.highlightIndex( + Math.max( + 0, + Math.min( + detailActions.length - 1, + detailSelect.activeIndex + + (direction === 'down' ? 1 : -1), + ), + ), + ) + } + onSelectIndex={detailSelect.selectIndex} + renderLabel={(item, context) => ( + {item.label} + )} + /> + + + ); + } + + if (view === 'scope-select' && currentRow) { + return ( + + + {t('Change scope for "{{name}}":', { name: currentRow.label })} + + + scopeSelect.highlightIndex( + Math.max( + 0, + Math.min( + scopeItems.length - 1, + scopeSelect.activeIndex + (direction === 'down' ? 1 : -1), + ), + ), + ) + } + onSelectIndex={scopeSelect.selectIndex} + renderLabel={(item, context) => ( + {item.label} + )} + /> + + ); + } + + if (view === 'uninstall-confirm' && currentRow) { + return ( + + + {t('Are you sure you want to uninstall extension "{{name}}"?', { + name: currentRow.label, + })} + + + {t('Note: Uninstall permanently removes this extension.')} + + {t('y to confirm · n/Esc to go back')} + + ); + } + + if (rows.length === 0) { + return {t('No extensions installed.')}; + } + + return ( + + listSelect.highlightIndex( + Math.max( + 0, + Math.min( + listItems.length - 1, + listSelect.activeIndex + (direction === 'down' ? 1 : -1), + ), + ), + ) + } + onSelectIndex={listSelect.selectIndex} + renderLabel={(item, context) => { + const row = item.row; + const enabled = row.enabled !== false; + const color = context.isSelected ? C.green : enabled ? C.text : C.dim; + return ( + + + {row.label} + {row.favorite ? : null} + + + {row.scope === 'project' ? ` (${t('project')})` : ''} + {enabled ? ` (${t('active')})` : ` (${t('disabled')})`} + + + ); + }} + /> + ); + }; + + return ( + + + {EXTENSIONS_TAB_ORDER.map((tab) => { + const isActive = tab === activeTab; + return ( + + + {` ${extensionsTabLabel(tab)} `} + + + ); + })} + + {t('(Tab / ←→ to switch)')} + + + + + {activeTab === EXTENSIONS_TABS.DISCOVER && discoverFilter ? ( + + {t('Marketplace: {{name}}', { name: discoverFilter })}{' '} + {t('(Tab to clear)')} + + ) : null} + {activeTab === EXTENSIONS_TABS.DISCOVER ? ( + + + {t('Discover is not yet available in the OpenTUI renderer.')} + + + {t('Manage installed extensions on the Installed tab.')} + + + ) : activeTab === EXTENSIONS_TABS.SOURCES ? ( + + {t( + 'Marketplace sources are not yet available in the OpenTUI renderer.', + )} + + ) : ( + renderInstalledContent() + )} + + + {status && ( + + {status.text} + + )} + + + {hint} + + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-mcp.test.ts b/packages/cli/src/ui/opentui/dialogs-mcp.test.ts new file mode 100644 index 00000000000..374e26f4c6b --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-mcp.test.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI MCP dialog reproduces the original ink + * MCPManagementDialog content: status icons/colors, source grouping order, + * the approval/auth status-text overrides, the conditional detail actions, + * per-step footers, and the clamp (non-wrap) list navigation. + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { MCPServerStatus } from '@qwen-code/qwen-code-core'; +import { + buildMcpServerActions, + clampNavIndex, + groupMcpServersBySource, + MCP_MANAGEMENT_STEPS, + mcpServerRowColor, + mcpServerStatusText, + mcpSourceDisplayName, + mcpStatusColor, + mcpStatusIcon, + mcpStepFooter, + type McpServerInfo, +} from './dialogs-mcp.js'; + +function server(overrides: Partial = {}): McpServerInfo { + return { + name: 'srv', + status: MCPServerStatus.CONNECTED, + source: 'user', + toolCount: 0, + invalidToolCount: 0, + promptCount: 0, + resourceCount: 0, + isDisabled: false, + hasOAuthTokens: false, + requiresAuth: false, + ...overrides, + }; +} + +describe('status icon/color parity', () => { + it('maps connection states to the original glyphs', () => { + expect(mcpStatusIcon('connected')).toBe('✓'); + expect(mcpStatusIcon('connecting')).toBe('…'); + expect(mcpStatusIcon('disconnected')).toBe('✗'); + expect(mcpStatusIcon('unknown')).toBe('?'); + }); + + it('maps connection states to the original colors', () => { + expect(mcpStatusColor('connected')).toBe('green'); + expect(mcpStatusColor('connecting')).toBe('yellow'); + expect(mcpStatusColor('disconnected')).toBe('red'); + expect(mcpStatusColor('other')).toBe('gray'); + }); +}); + +describe('mcpSourceDisplayName', () => { + it('uses the original group names', () => { + expect(mcpSourceDisplayName('user')).toBe('User MCPs'); + expect(mcpSourceDisplayName('project')).toBe('Project MCPs'); + expect(mcpSourceDisplayName('workspace')).toBe('Workspace Settings'); + expect(mcpSourceDisplayName('system')).toBe('System Settings'); + expect(mcpSourceDisplayName('extension')).toBe('Extension MCPs'); + expect(mcpSourceDisplayName('weird')).toBe('weird'); + }); +}); + +describe('groupMcpServersBySource', () => { + it('groups in SOURCE_ORDER regardless of input order', () => { + const groups = groupMcpServersBySource([ + server({ name: 'ext', source: 'extension' }), + server({ name: 'u1', source: 'user' }), + server({ name: 'p1', source: 'project' }), + server({ name: 'u2', source: 'user' }), + ]); + expect(groups.map((g) => g.source)).toEqual([ + 'user', + 'project', + 'extension', + ]); + expect(groups[0].servers.map((s) => s.name)).toEqual(['u1', 'u2']); + }); + + it('omits empty sources', () => { + expect(groupMcpServersBySource([])).toEqual([]); + }); +}); + +describe('mcpServerStatusText', () => { + it('prefers the disabled marker', () => { + expect(mcpServerStatusText(server({ isDisabled: true }))).toBe('disabled'); + }); + + it('shows approval states before auth', () => { + expect( + mcpServerStatusText( + server({ approvalState: 'pending', requiresAuth: true }), + ), + ).toBe('needs approval'); + expect( + mcpServerStatusText( + server({ approvalState: 'rejected', requiresAuth: true }), + ), + ).toBe('rejected — edit config to re-approve'); + }); + + it('shows needs authentication for unconnected auth-required servers', () => { + expect( + mcpServerStatusText( + server({ + status: MCPServerStatus.DISCONNECTED, + requiresAuth: true, + }), + ), + ).toBe('needs authentication'); + }); + + it('falls back to the raw status once connected', () => { + expect( + mcpServerStatusText( + server({ status: MCPServerStatus.CONNECTED, requiresAuth: true }), + ), + ).toBe('connected'); + }); + + it('colors approval/auth/disabled rows yellow', () => { + expect(mcpServerRowColor(server({ isDisabled: true }))).toBe('yellow'); + expect(mcpServerRowColor(server({ approvalState: 'pending' }))).toBe( + 'yellow', + ); + expect( + mcpServerRowColor( + server({ status: MCPServerStatus.DISCONNECTED, requiresAuth: true }), + ), + ).toBe('yellow'); + expect(mcpServerRowColor(server())).toBe('green'); + expect( + mcpServerRowColor(server({ status: MCPServerStatus.DISCONNECTED })), + ).toBe('red'); + }); +}); + +describe('buildMcpServerActions', () => { + it('offers browse + toggle + authenticate for a healthy server', () => { + const actions = buildMcpServerActions( + server({ toolCount: 2, resourceCount: 1 }), + { resourcesSupported: true }, + ); + expect(actions.map((a) => a.key)).toEqual([ + 'view-tools', + 'view-resources', + 'toggle-disable', + 'authenticate', + ]); + }); + + it('adds reconnect when disconnected (and not awaiting approval)', () => { + const actions = buildMcpServerActions( + server({ status: MCPServerStatus.DISCONNECTED }), + ); + expect(actions.map((a) => a.key)).toEqual([ + 'reconnect', + 'toggle-disable', + 'authenticate', + ]); + }); + + it('offers approve when pending and hides reconnect/authenticate', () => { + const actions = buildMcpServerActions( + server({ approvalState: 'pending' }), + { approveSupported: true }, + ); + expect(actions.map((a) => a.key)).toEqual(['approve', 'toggle-disable']); + }); + + it('offers only Enable for a disabled server', () => { + const actions = buildMcpServerActions(server({ isDisabled: true })); + expect(actions).toEqual([ + { key: 'toggle-disable', label: 'Enable', action: 'toggle-disable' }, + ]); + }); + + it('uses Re-authenticate + Clear Authentication when tokens exist', () => { + const actions = buildMcpServerActions(server({ hasOAuthTokens: true })); + expect(actions.map((a) => a.label)).toContain('Re-authenticate'); + expect(actions.map((a) => a.label)).toContain('Clear Authentication'); + }); + + it('hides resources unless the caller supports them', () => { + const actions = buildMcpServerActions(server({ resourceCount: 3 })); + expect(actions.some((a) => a.key === 'view-resources')).toBe(false); + }); +}); + +describe('mcpStepFooter', () => { + it('uses the original per-step hints', () => { + expect(mcpStepFooter(MCP_MANAGEMENT_STEPS.SERVER_LIST, 0)).toBe( + 'Esc to close', + ); + expect(mcpStepFooter(MCP_MANAGEMENT_STEPS.SERVER_LIST, 2)).toBe( + '↑↓ to navigate · Enter to select · Esc to close', + ); + expect(mcpStepFooter(MCP_MANAGEMENT_STEPS.SERVER_DETAIL, 2)).toBe( + '↑↓ to navigate · Enter to select · Esc to back', + ); + expect(mcpStepFooter(MCP_MANAGEMENT_STEPS.TOOL_DETAIL, 2)).toBe( + 'Esc to back', + ); + expect(mcpStepFooter(MCP_MANAGEMENT_STEPS.AUTHENTICATE, 2)).toBe( + 'Esc to go back', + ); + }); +}); + +describe('clampNavIndex', () => { + it('clamps instead of wrapping — MCP lists are not circular', () => { + expect(clampNavIndex(0, 3, 'up')).toBe(0); + expect(clampNavIndex(2, 3, 'down')).toBe(2); + expect(clampNavIndex(0, 3, 'down')).toBe(1); + expect(clampNavIndex(2, 3, 'up')).toBe(1); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-mcp.tsx b/packages/cli/src/ui/opentui/dialogs-mcp.tsx new file mode 100644 index 00000000000..097aa0bfe21 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-mcp.tsx @@ -0,0 +1,872 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink `/mcp` dialog + * (ui/components/mcp/MCPManagementDialog.tsx): the server-list → detail → + * tool/resource step navigation stack, per-step headers and footers, the + * source-grouped server list with status icons and approval/auth states, + * the tool and resource lists with their scroll hints. Server actions and + * mutations are reported to the backend via callbacks. + */ + +import { useState } from 'react'; +import { useKeyboard } from '@opentui/react'; +import { C } from './theme.js'; +import { t } from '../../i18n/index.js'; +import { MCPServerStatus } from '@qwen-code/qwen-code-core'; +import { ICON } from '../constants.js'; +import { toOriginalKey } from './key-map.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { DialogFrame, FooterHint } from './dialogs-shared.js'; + +export const MCP_MANAGEMENT_STEPS = { + SERVER_LIST: 'server-list', + SERVER_DETAIL: 'server-detail', + TOOL_LIST: 'tool-list', + TOOL_DETAIL: 'tool-detail', + RESOURCE_LIST: 'resource-list', + RESOURCE_DETAIL: 'resource-detail', + AUTHENTICATE: 'authenticate', +} as const; + +export type McpManagementStep = + (typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS]; + +export type McpServerSource = + | 'user' + | 'project' + | 'workspace' + | 'system' + | 'extension'; + +export const MCP_SOURCE_ORDER: readonly McpServerSource[] = [ + 'user', + 'project', + 'workspace', + 'system', + 'extension', +]; + +export interface McpServerInfo { + name: string; + status: MCPServerStatus; + source: McpServerSource; + configPath?: string; + toolCount: number; + invalidToolCount: number; + promptCount: number; + resourceCount: number; + isDisabled: boolean; + hasOAuthTokens: boolean; + requiresAuth: boolean; + approvalState?: 'pending' | 'rejected'; + command?: string; + workingDirectory?: string; + error?: string; +} + +export interface McpToolInfo { + name: string; + description?: string; + isValid: boolean; + invalidReason?: string; + annotations?: { + destructiveHint?: boolean; + idempotentHint?: boolean; + readOnlyHint?: boolean; + openWorldHint?: boolean; + }; +} + +export interface McpResourceInfo { + uri: string; + name?: string; + title?: string; +} + +/** Parity of getStatusIcon in mcp/utils.ts. */ +export function mcpStatusIcon(status: string): string { + switch (status) { + case 'connected': + return '✓'; + case 'connecting': + return '…'; + case 'disconnected': + return '✗'; + default: + return '?'; + } +} + +/** Parity of getStatusColor in mcp/utils.ts. */ +export function mcpStatusColor( + status: string, +): 'green' | 'yellow' | 'red' | 'gray' { + switch (status) { + case 'connected': + return 'green'; + case 'connecting': + return 'yellow'; + case 'disconnected': + return 'red'; + default: + return 'gray'; + } +} + +/** Parity of getSourceDisplayName in mcp/utils.ts. */ +export function mcpSourceDisplayName(source: string): string { + switch (source) { + case 'user': + return t('User MCPs'); + case 'project': + return t('Project MCPs'); + case 'workspace': + return t('Workspace Settings'); + case 'system': + return t('System Settings'); + case 'extension': + return t('Extension MCPs'); + default: + return source; + } +} + +export interface McpServerGroup { + source: McpServerSource; + displayName: string; + servers: McpServerInfo[]; +} + +/** Parity of groupServersBySource: SOURCE_ORDER grouping. */ +export function groupMcpServersBySource( + servers: readonly McpServerInfo[], +): McpServerGroup[] { + const groups = new Map(); + for (const server of servers) { + const existing = groups.get(server.source); + if (existing) existing.push(server); + else groups.set(server.source, [server]); + } + const result: McpServerGroup[] = []; + for (const source of MCP_SOURCE_ORDER) { + const groupServers = groups.get(source); + if (groupServers && groupServers.length > 0) { + result.push({ + source, + displayName: mcpSourceDisplayName(source), + servers: groupServers, + }); + } + } + return result; +} + +/** Parity of the server-row status text (approval/auth overrides first). */ +export function mcpServerStatusText(server: McpServerInfo): string { + const awaitingApproval = !server.isDisabled && !!server.approvalState; + const needsAuth = + !server.isDisabled && + !awaitingApproval && + !!server.requiresAuth && + server.status !== MCPServerStatus.CONNECTED; + if (server.isDisabled) return t('disabled'); + if (awaitingApproval) { + return server.approvalState === 'rejected' + ? t('rejected — edit config to re-approve') + : t('needs approval'); + } + if (needsAuth) return t('needs authentication'); + return t(server.status); +} + +/** Parity of the server-row status color rules. */ +export function mcpServerRowColor( + server: McpServerInfo, +): 'green' | 'yellow' | 'red' | 'gray' { + const awaitingApproval = !server.isDisabled && !!server.approvalState; + const needsAuth = + !server.isDisabled && + !awaitingApproval && + !!server.requiresAuth && + server.status !== MCPServerStatus.CONNECTED; + if (server.isDisabled || awaitingApproval || needsAuth) return 'yellow'; + return mcpStatusColor(server.status); +} + +export type McpServerAction = + | 'view-tools' + | 'view-resources' + | 'reconnect' + | 'approve' + | 'toggle-disable' + | 'authenticate' + | 'clear-auth'; + +/** Parity of ServerDetailStep's conditional action list. */ +export function buildMcpServerActions( + server: McpServerInfo, + options: { resourcesSupported?: boolean; approveSupported?: boolean } = {}, +): Array<{ key: string; label: string; action: McpServerAction }> { + const result: Array<{ key: string; label: string; action: McpServerAction }> = + []; + const awaitingApproval = !server.isDisabled && !!server.approvalState; + + if (!server.isDisabled && server.toolCount > 0) { + result.push({ + key: 'view-tools', + label: t('View tools'), + action: 'view-tools', + }); + } + if ( + options.resourcesSupported && + !server.isDisabled && + server.resourceCount > 0 + ) { + result.push({ + key: 'view-resources', + label: t('View resources'), + action: 'view-resources', + }); + } + if ( + !server.isDisabled && + !awaitingApproval && + server.status === 'disconnected' + ) { + result.push({ + key: 'reconnect', + label: t('Reconnect'), + action: 'reconnect', + }); + } + if (awaitingApproval && options.approveSupported) { + result.push({ key: 'approve', label: t('Approve'), action: 'approve' }); + } + result.push({ + key: 'toggle-disable', + label: server.isDisabled ? t('Enable') : t('Disable'), + action: 'toggle-disable', + }); + if (!server.isDisabled && !awaitingApproval) { + result.push({ + key: 'authenticate', + label: server.hasOAuthTokens ? t('Re-authenticate') : t('Authenticate'), + action: 'authenticate', + }); + } + if (!server.isDisabled && server.hasOAuthTokens) { + result.push({ + key: 'clear-auth', + label: t('Clear Authentication'), + action: 'clear-auth', + }); + } + return result; +} + +/** Parity of the per-step footer hints in MCPManagementDialog. */ +export function mcpStepFooter( + step: McpManagementStep, + serverCount: number, +): string { + switch (step) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + return serverCount === 0 + ? t('Esc to close') + : t('↑↓ to navigate · Enter to select · Esc to close'); + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + case MCP_MANAGEMENT_STEPS.RESOURCE_LIST: + return t('↑↓ to navigate · Enter to select · Esc to back'); + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + case MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL: + return t('Esc to back'); + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + return t('Esc to go back'); + default: + return t('Esc to close'); + } +} + +/** Clamp-style navigation — MCP lists do NOT wrap (unlike the radio lists). */ +export function clampNavIndex( + current: number, + count: number, + direction: 'up' | 'down', +): number { + return direction === 'down' + ? Math.min(count - 1, current + 1) + : Math.max(0, current - 1); +} + +export interface OpenTuiMcpDialogProps { + servers: readonly McpServerInfo[]; + /** Backend feeds the selected server's tools/resources on demand. */ + getServerTools?: (server: McpServerInfo) => readonly McpToolInfo[]; + getServerResources?: (server: McpServerInfo) => readonly McpResourceInfo[]; + onClose: () => void; + onServerAction?: (server: McpServerInfo, action: McpServerAction) => void; +} + +export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) { + const { + servers, + getServerTools, + getServerResources, + onClose, + onServerAction, + } = props; + + const [navigationStack, setNavigationStack] = useState([ + MCP_MANAGEMENT_STEPS.SERVER_LIST, + ]); + // ink derives the selected server from the live list (useMemo on + // [servers, selectedServerIndex]) so a reload after an action refreshes + // the detail view; keying by name survives the host rebuilding the array. + const [selectedServerName, setSelectedServerName] = useState( + null, + ); + const selectedServer = selectedServerName + ? (servers.find((server) => server.name === selectedServerName) ?? null) + : null; + const [selectedTool, setSelectedTool] = useState(null); + const [selectedResource, setSelectedResource] = + useState(null); + const [serverCursor, setServerCursor] = useState(0); + const [actionCursor, setActionCursor] = useState(0); + const [toolCursor, setToolCursor] = useState(0); + const [resourceCursor, setResourceCursor] = useState(0); + + const currentStep = (navigationStack[navigationStack.length - 1] ?? + MCP_MANAGEMENT_STEPS.SERVER_LIST) as McpManagementStep; + + const navigateToStep = (step: string) => + setNavigationStack((prev) => [...prev, step]); + const navigateBack = () => + setNavigationStack((prev) => (prev.length <= 1 ? prev : prev.slice(0, -1))); + + const groupedServers = groupMcpServersBySource(servers); + // Derive the flat navigation list from the grouped render order, not the + // raw prop order: groupMcpServersBySource reorders by source (user first), + // so indexing the raw prop would open a different server than highlighted. + const flatServers = groupedServers.flatMap((group) => group.servers); + const serverTools = selectedServer + ? (getServerTools?.(selectedServer) ?? []) + : []; + const serverResources = selectedServer + ? (getServerResources?.(selectedServer) ?? []) + : []; + const detailActions = selectedServer + ? buildMcpServerActions(selectedServer, { + resourcesSupported: !!getServerResources, + approveSupported: !!onServerAction, + }) + : []; + + useKeyboard((key) => { + const original = toOriginalKey(key); + const { name } = original; + + if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_LIST) { + if (name === 'escape') { + onClose(); + return; + } + if (keyMatchers[Command.SELECTION_UP](original)) { + setServerCursor((prev) => + clampNavIndex(prev, flatServers.length, 'up'), + ); + } else if (keyMatchers[Command.SELECTION_DOWN](original)) { + setServerCursor((prev) => + clampNavIndex(prev, flatServers.length, 'down'), + ); + } else if (name === 'return') { + const server = flatServers[serverCursor]; + if (server) { + setSelectedServerName(server.name); + setActionCursor(0); + navigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL); + } + } + return; + } + + if (name === 'escape') { + navigateBack(); + return; + } + + if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_DETAIL) { + if (keyMatchers[Command.SELECTION_UP](original)) { + setActionCursor((prev) => + clampNavIndex(prev, detailActions.length, 'up'), + ); + } else if (keyMatchers[Command.SELECTION_DOWN](original)) { + setActionCursor((prev) => + clampNavIndex(prev, detailActions.length, 'down'), + ); + } else if (name === 'return') { + const action = detailActions[actionCursor]; + if (!action || !selectedServer) return; + switch (action.action) { + case 'view-tools': + setToolCursor(0); + navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); + break; + case 'view-resources': + setResourceCursor(0); + navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_LIST); + break; + default: + onServerAction?.(selectedServer, action.action); + } + } + return; + } + + if (currentStep === MCP_MANAGEMENT_STEPS.TOOL_LIST) { + if (keyMatchers[Command.SELECTION_UP](original)) { + setToolCursor((prev) => clampNavIndex(prev, serverTools.length, 'up')); + } else if (keyMatchers[Command.SELECTION_DOWN](original)) { + setToolCursor((prev) => + clampNavIndex(prev, serverTools.length, 'down'), + ); + } else if (name === 'return') { + const tool = serverTools[toolCursor]; + if (tool) { + setSelectedTool(tool); + navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL); + } + } + return; + } + + if (currentStep === MCP_MANAGEMENT_STEPS.RESOURCE_LIST) { + if (keyMatchers[Command.SELECTION_UP](original)) { + setResourceCursor((prev) => + clampNavIndex(prev, serverResources.length, 'up'), + ); + } else if (keyMatchers[Command.SELECTION_DOWN](original)) { + setResourceCursor((prev) => + clampNavIndex(prev, serverResources.length, 'down'), + ); + } else if (name === 'return') { + const resource = serverResources[resourceCursor]; + if (resource) { + setSelectedResource(resource); + navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL); + } + } + } + }); + + const statusTextColor = (color: 'green' | 'yellow' | 'red' | 'gray') => + color === 'green' + ? C.green + : color === 'yellow' + ? C.yellow + : color === 'red' + ? C.red + : C.dim; + + // --- Header --- + const header = (() => { + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + return ( + + {selectedServer?.name || t('Server Detail')} + + ); + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + return ( + + + {t('Tools for {{serverName}}', { + serverName: selectedServer?.name || 'Server', + })} + + + ({serverTools.length}{' '} + {serverTools.length === 1 ? t('tool') : t('tools')}) + + + ); + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + return ( + + + + {selectedTool?.name || t('Tool Detail')} + + {selectedTool?.annotations?.destructiveHint && ( + [{t('destructive')}] + )} + {selectedTool?.annotations?.idempotentHint && ( + [{t('idempotent')}] + )} + {selectedTool?.annotations?.readOnlyHint && ( + [{t('read-only')}] + )} + {selectedTool?.annotations?.openWorldHint && ( + [{t('open-world')}] + )} + + {t('Server')} + + ); + case MCP_MANAGEMENT_STEPS.RESOURCE_LIST: + return ( + + + {t('Resources for {{serverName}}', { + serverName: selectedServer?.name || 'Server', + })} + + + ({serverResources.length}{' '} + {serverResources.length === 1 ? t('resource') : t('resources')}) + + + ); + case MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL: + return ( + + + {selectedResource?.uri || t('Resource Detail')} + + {t('Server')} + + ); + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + return ( + + {t('OAuth Authentication')} + + ); + default: + return ( + + + {t('Manage MCP servers')} + + + {servers.length}{' '} + {servers.length === 1 ? t('server') : t('servers')} + + + ); + } + })(); + + // --- Content --- + const renderServerList = () => { + if (servers.length === 0) { + return ( + + {t('No MCP servers configured.')} + + {t('Add MCP servers to your settings to get started.')} + + + ); + } + let flatIndex = 0; + return ( + + {groupedServers.map((group, groupIndex) => { + const startIndex = flatIndex; + flatIndex += group.servers.length; + return ( + + + {` ${group.displayName}`} + {group.servers[0]?.configPath ? ( + ({group.servers[0].configPath}) + ) : null} + + {group.servers.map((server, itemIndex) => { + const globalIndex = startIndex + itemIndex; + const isSelected = globalIndex === serverCursor; + const color = mcpServerRowColor(server); + return ( + setServerCursor(globalIndex)} + onMouseUp={() => { + setServerCursor(globalIndex); + setSelectedServerName(server.name); + setActionCursor(0); + navigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL); + }} + > + + + {isSelected ? '❯' : ' '} + + + + + {server.name} + + + · + + {mcpStatusIcon(server.status)}{' '} + {mcpServerStatusText(server)} + + {server.invalidToolCount > 0 && ( + + {' '} + {t('{{count}} invalid tools', { + count: String(server.invalidToolCount), + })} + + )} + + ); + })} + + ); + })} + {servers.some( + (s) => + s.status === 'disconnected' && !s.isDisabled && !s.approvalState, + ) && ( + + + {ICON.REFERENCE} {t('Run qwen --debug to see error logs')} + + + )} + + ); + }; + + const renderServerDetail = () => { + if (!selectedServer) { + return {t('No server selected')}; + } + const rows: Array<{ label: string; value: string }> = [ + { label: t('Status:'), value: mcpServerStatusText(selectedServer) }, + { + label: t('Source:'), + value: mcpSourceDisplayName(selectedServer.source), + }, + { + label: t('Tools:'), + value: `${selectedServer.toolCount} ${selectedServer.toolCount === 1 ? t('tool') : t('tools')}`, + }, + { + label: t('Prompts:'), + value: String(selectedServer.promptCount), + }, + { + label: t('Resources:'), + value: String(selectedServer.resourceCount), + }, + ]; + if (selectedServer.command) { + rows.splice(2, 0, { + label: t('Command:'), + value: selectedServer.command, + }); + } + return ( + + {rows.map((row) => ( + + + {row.label} + + {row.value} + + ))} + {selectedServer.error && ( + + + {t('Error:')} + + {selectedServer.error} + + )} + + {detailActions.map((action, index) => { + const isSelected = index === actionCursor; + return ( + setActionCursor(index)} + onMouseUp={() => { + setActionCursor(index); + if (!selectedServer) return; + switch (action.action) { + case 'view-tools': + setToolCursor(0); + navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); + break; + case 'view-resources': + setResourceCursor(0); + navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_LIST); + break; + default: + onServerAction?.(selectedServer, action.action); + } + }} + > + + + {isSelected ? '›' : ' '} + + + {action.label} + + ); + })} + + ); + }; + + const renderToolList = () => { + if (serverTools.length === 0) { + return {t('No tools available for this server.')}; + } + return ( + + {serverTools.map((tool, index) => { + const isSelected = index === toolCursor; + const hints: string[] = []; + if (tool.annotations?.destructiveHint) hints.push(t('destructive')); + if (tool.annotations?.readOnlyHint) hints.push(t('read-only')); + if (tool.annotations?.openWorldHint) hints.push(t('open-world')); + if (tool.annotations?.idempotentHint) hints.push(t('idempotent')); + return ( + setToolCursor(index)} + onMouseUp={() => { + setToolCursor(index); + setSelectedTool(tool); + navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL); + }} + > + + + {isSelected ? '❯' : ' '} + + + + {tool.name} + + {!tool.isValid ? ( + + {t('invalid: {{reason}}', { + reason: tool.invalidReason || t('unknown'), + })} + + ) : hints.length > 0 ? ( + {hints.join(', ')} + ) : null} + + ); + })} + + ); + }; + + const renderToolDetail = () => ( + + {selectedTool?.description ? ( + {selectedTool.description} + ) : ( + {t('(no description)')} + )} + + ); + + const renderResourceList = () => { + if (serverResources.length === 0) { + return ( + {t('No resources available for this server.')} + ); + } + return ( + + {serverResources.map((resource, index) => { + const isSelected = index === resourceCursor; + const friendly = + resource.title && resource.title !== resource.uri + ? resource.title + : resource.name && resource.name !== resource.uri + ? resource.name + : ''; + return ( + setResourceCursor(index)} + onMouseUp={() => { + setResourceCursor(index); + setSelectedResource(resource); + navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL); + }} + > + + + {isSelected ? '❯' : ' '} + + + {resource.uri} + {friendly ? {friendly} : null} + + ); + })} + + ); + }; + + const renderResourceDetail = () => ( + + {selectedResource?.uri} + {selectedResource?.name ? ( + {selectedResource.name} + ) : null} + + ); + + return ( + + {header} + + {currentStep === MCP_MANAGEMENT_STEPS.SERVER_LIST && renderServerList()} + {currentStep === MCP_MANAGEMENT_STEPS.SERVER_DETAIL && + renderServerDetail()} + {currentStep === MCP_MANAGEMENT_STEPS.TOOL_LIST && renderToolList()} + {currentStep === MCP_MANAGEMENT_STEPS.TOOL_DETAIL && renderToolDetail()} + {currentStep === MCP_MANAGEMENT_STEPS.RESOURCE_LIST && + renderResourceList()} + {currentStep === MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL && + renderResourceDetail()} + {currentStep === MCP_MANAGEMENT_STEPS.AUTHENTICATE && ( + {t('Loading...')} + )} + + + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-memory-status.test.ts b/packages/cli/src/ui/opentui/dialogs-memory-status.test.ts new file mode 100644 index 00000000000..d92e701265e --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-memory-status.test.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Memory dialog path resolution tests (audit 01 G-8): the configured + * context file names (getAllGeminiMdFilenames) drive the displayed paths — + * never a hardcoded memory.md. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { + getAllGeminiMdFilenames, + setGeminiMdFilename, +} from '@qwen-code/qwen-code-core'; +import { + resolvePreferredMemoryFile, + readMemoryToggle, +} from './dialogs-memory-status.js'; + +describe('readMemoryToggle (ink readToggle parity)', () => { + const off = { bareMode: false, safeMode: false }; + + it('defaults managed-memory toggles ON and honors explicit values', () => { + expect(readMemoryToggle(undefined, off)).toBe(true); + expect(readMemoryToggle(true, off)).toBe(true); + expect(readMemoryToggle(false, off)).toBe(false); + }); + + it('gates every toggle off in bare and safe modes', () => { + expect(readMemoryToggle(true, { bareMode: true, safeMode: false })).toBe( + false, + ); + expect(readMemoryToggle(true, { bareMode: false, safeMode: true })).toBe( + false, + ); + }); +}); + +describe('resolvePreferredMemoryFile', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opentui-memory-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('picks the first configured filename that exists', () => { + // Default configured names include QWEN.md and AGENTS.md. + const filenames = getAllGeminiMdFilenames(); + const present = filenames.at(-1) ?? 'AGENTS.md'; + fs.writeFileSync(path.join(dir, present), '# memory'); + expect(resolvePreferredMemoryFile(dir)).toBe(path.join(dir, present)); + }); + + it('falls back to the primary configured filename when none exist', () => { + expect(resolvePreferredMemoryFile(dir)).toBe( + path.join(dir, getAllGeminiMdFilenames()[0] ?? 'QWEN.md'), + ); + }); + + it('honors overridden configured filenames', () => { + setGeminiMdFilename(['CUSTOM.md']); + try { + fs.writeFileSync(path.join(dir, 'CUSTOM.md'), '# custom'); + expect(resolvePreferredMemoryFile(dir)).toBe(path.join(dir, 'CUSTOM.md')); + } finally { + // Restore the default list for other tests. + setGeminiMdFilename(['QWEN.md', 'AGENTS.md']); + } + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-memory-status.tsx b/packages/cli/src/ui/opentui/dialogs-memory-status.tsx new file mode 100644 index 00000000000..5db4fb40fc2 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-memory-status.tsx @@ -0,0 +1,175 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Compact native OpenTUI Memory and StatusLine dialogs (M3 long-tail, #8677). + * Faithful-enough display ports: Memory lists the user/project memory sources + * + toggles from settings; StatusLine lists the preset items. Esc closes. + */ + +import { useLayoutEffect } from 'react'; +import { useRenderer } from '@opentui/react'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { getAllGeminiMdFilenames, Storage } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { STATUS_LINE_PRESET_ITEMS } from '../statusLinePresets.js'; +import { C } from './theme.js'; + +/** + * Parity of MemoryDialog.resolvePreferredMemoryFile: the first configured + * context filename that exists in `dir`, else the configured primary + * filename (the file the editor would create). + */ +export function resolvePreferredMemoryFile(dir: string): string { + const filenames = getAllGeminiMdFilenames(); + for (const filename of filenames) { + const filePath = path.join(dir, filename); + try { + if (fs.existsSync(filePath)) return filePath; + } catch { + // Unreadable — try the next candidate. + } + } + return path.join(dir, filenames[0] ?? 'QWEN.md'); +} + +function useEsc(onClose: () => void) { + const renderer = useRenderer(); + useLayoutEffect(() => { + const onRaw = (seq: string): boolean => { + if (seq !== '\x1b') return false; + onClose(); + return true; + }; + renderer.addInputHandler(onRaw); + return () => renderer.removeInputHandler(onRaw); + }, [renderer, onClose]); +} + +const Shell = ({ + title, + children, +}: { + title: string; + children?: React.ReactNode; +}) => ( + + + + {title} + + {'esc to close'} + + {children} + +); + +const Row = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + + {value} + + +); + +/** + * ink MemoryDialog readToggle parity: managed-memory toggles default ON, + * and bare/safe modes gate every one of them off (runtime gates the config + * getters on !getBareMode() && !isSafeMode()). + */ +export function readMemoryToggle( + value: unknown, + modes: { bareMode: boolean; safeMode: boolean }, +): boolean { + return !modes.bareMode && !modes.safeMode && Boolean(value ?? true); +} + +export function OpenTuiMemoryDialog(props: { + config?: Config; + settings: LoadedSettings; + onClose: () => void; +}) { + const { config, settings, onClose } = props; + useEsc(onClose); + const mem = (settings.merged as { memory?: Record })?.memory; + const modes = { + bareMode: config?.getBareMode?.() ?? false, + safeMode: config?.isSafeMode?.() ?? false, + }; + const toggle = (k: string) => readMemoryToggle(mem?.[k], modes); + // Real memory file names (audit 01 G-8): ink's MemoryDialog resolves the + // user file from Storage.getGlobalQwenDir() and the project file from the + // working dir, both through getAllGeminiMdFilenames() (default QWEN.md) — + // never a hardcoded memory.md. + const cwd = config?.getWorkingDir?.() ?? process.cwd(); + const filenames = getAllGeminiMdFilenames(); + const userMem = path.join( + Storage.getGlobalQwenDir(), + filenames[0] ?? 'QWEN.md', + ); + const projectMem = resolvePreferredMemoryFile(cwd); + return ( + + + + + {filenames.length > 1 && ( + + )} + + + + + + ); +} + +export function OpenTuiStatusLineDialog(props: { + settings: LoadedSettings; + onClose: () => void; +}) { + const { onClose } = props; + useEsc(onClose); + return ( + + + {'Preset items:'} + {STATUS_LINE_PRESET_ITEMS.map((it) => ( + + {'• '} + {it.label} + + ))} + + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-misc.test.tsx b/packages/cli/src/ui/opentui/dialogs-misc.test.tsx new file mode 100644 index 00000000000..bec17f77fcf --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-misc.test.tsx @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the disabled-skipping radio navigation used by the editor dialog + * (ink BaseSelectionList parity): arrows clamp at the edges and walk past + * disabled entries. + */ + +import { describe, it, expect, vi } from 'vitest'; + +// theme.ts builds a SyntaxStyle at module scope, which needs the OpenTUI +// native FFI — unavailable in the test runtime. Stub the graphics surface. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { nextEnabledIndex, readHooksEnabled } from './dialogs-misc.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +const settingsWith = (merged: Record): LoadedSettings => + ({ merged }) as unknown as LoadedSettings; + +describe('readHooksEnabled (the real disableAllHooks switch)', () => { + it('reads the top-level setting; default is enabled', () => { + expect(readHooksEnabled(undefined, settingsWith({}))).toBe(true); + expect( + readHooksEnabled(undefined, settingsWith({ disableAllHooks: true })), + ).toBe(false); + expect( + readHooksEnabled(undefined, settingsWith({ disableAllHooks: false })), + ).toBe(true); + }); + + it('prefers the runtime gate (includes bare/safe modes)', () => { + expect( + readHooksEnabled( + { getDisableAllHooks: () => false }, + settingsWith({ disableAllHooks: true }), + ), + ).toBe(true); + expect( + readHooksEnabled( + { getDisableAllHooks: () => true }, + settingsWith({ disableAllHooks: false }), + ), + ).toBe(false); + }); +}); + +describe('nextEnabledIndex (ink BaseSelectionList parity)', () => { + const items = [ + { disabled: false }, + { disabled: true }, + { disabled: false }, + { disabled: false }, + ]; + + it('moves to the next enabled entry, skipping disabled ones', () => { + expect(nextEnabledIndex(items, 0, 1)).toBe(2); + expect(nextEnabledIndex(items, 2, -1)).toBe(0); + }); + + it('clamps at the edges', () => { + expect(nextEnabledIndex(items, 0, -1)).toBe(0); + expect(nextEnabledIndex(items, 3, 1)).toBe(3); + }); + + it('stays put when only disabled entries remain in that direction', () => { + const tail = [{ disabled: false }, { disabled: true }, { disabled: true }]; + expect(nextEnabledIndex(tail, 0, 1)).toBe(0); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-misc.tsx b/packages/cli/src/ui/opentui/dialogs-misc.tsx new file mode 100644 index 00000000000..ce6dcd4b958 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-misc.tsx @@ -0,0 +1,712 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Compact native OpenTUI dialogs for the remaining long-tail commands + * (M3, #8677): editor/auth/trust/delete/resume/branch/hooks/rewind/diff/ + * arena/subagent_create/subagent_list. Each mounts a real panel (info or + * confirm) instead of "unsupported". Heavy ones (diff/resume/arena/subagents/ + * editor) are compact here and get fidelity passes in M4. + */ + +import { + useEffect, + useLayoutEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { useRenderer, useKeyboard } from '@opentui/react'; +import type { + Config, + EditorType, + SessionListItem, +} from '@qwen-code/qwen-code-core'; +import { + allowEditorTypeInSandbox, + checkHasEditorType, + isEditorAvailable, +} from '@qwen-code/qwen-code-core'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import { + EDITOR_DISPLAY_NAMES, + editorSettingsManager, +} from '../editors/editorSettingsManager.js'; +import { getScopeItems } from '../../config/dialogScopeUtils.js'; +import { toOriginalKey } from './key-map.js'; +import { fireSessionDeleteHook } from '../../hooks/session-delete-hook.js'; +import { C } from './theme.js'; + +function useEsc(onClose: () => void) { + const renderer = useRenderer(); + useLayoutEffect(() => { + const onRaw = (seq: string): boolean => { + if (seq !== '\x1b') return false; + onClose(); + return true; + }; + renderer.addInputHandler(onRaw); + return () => renderer.removeInputHandler(onRaw); + }, [renderer, onClose]); +} + +export function Shell({ + title, + children, +}: { + title: string; + onClose?: () => void; + children?: ReactNode; +}) { + return ( + + + + {title} + + {'esc to close'} + + {children} + + ); +} + +const Row = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + + {value} + + +); + +type P = { + config?: Config; + settings: LoadedSettings; + onClose: () => void; + /** Delete/Resume report their outcome as a command-style message. */ + notify?: (text: string) => void; + /** Resume: sessions pre-filtered by the command (multiple title matches). */ + matchedSessions?: SessionListItem[]; + /** Resume: selection runs the real session switch (host.handleResume). */ + onSelect?: (sessionId: string) => void; +}; + +/** + * ink BaseSelectionList navigation parity: arrow keys clamp at the edges and + * skip disabled entries (keeps walking in the same direction; stays put when + * no enabled entry remains in that direction). + */ +export function nextEnabledIndex( + items: ReadonlyArray<{ disabled?: boolean }>, + current: number, + delta: 1 | -1, +): number { + let next = current; + for (let i = 0; i < items.length; i++) { + next = Math.min(items.length - 1, Math.max(0, next + delta)); + if (!items[next]?.disabled) return next; + if (next === 0 || next === items.length - 1) break; + } + return current; +} + +/** + * ink EditorSettingsDialog parity: two-pane dialog — left a radio list of + * available editors (unavailable ones disabled, like RadioButtonSelect over + * editorSettingsManager displays), Tab switches to the User/Workspace scope + * list and back; right pane shows the merged preference. Enter persists via + * settings.setValue (useEditorSettings.handleEditorSelect guard included). + */ +export function OpenTuiEditorDialog({ settings, onClose, notify }: P) { + useEsc(onClose); + const editors = useMemo( + () => editorSettingsManager.getAvailableEditorDisplays(), + [], + ); + const [mode, setMode] = useState<'editor' | 'scope'>('editor'); + const [scope, setScope] = useState(SettingScope.User); + const scopeIndexOf = (s: SettingScope) => { + const pref = settings.forScope(s).settings.general?.preferredEditor; + const idx = pref ? editors.findIndex((e) => e.type === pref) : 0; + return idx >= 0 ? idx : 0; + }; + const [sel, setSel] = useState(() => scopeIndexOf(SettingScope.User)); + const scopeItems = useMemo(() => getScopeItems(), []); + const [scopeSel, setScopeSel] = useState(0); + + // ink: highlighting a scope previews that scope's current preference. + const highlightScope = (idx: number) => { + const item = scopeItems[idx]; + if (!item) return; + setScope(item.value); + setSel(scopeIndexOf(item.value)); + }; + const applyScope = () => { + const item = scopeItems[scopeSel]; + if (!item) return; + setScope(item.value); + setSel(scopeIndexOf(item.value)); + setMode('editor'); + }; + + const moveEditor = (d: 1 | -1) => + setSel((s) => nextEnabledIndex(editors, s, d)); + + const pick = () => { + const item = editors[sel]; + if (!item || item.disabled) return; + const editorType = item.type === 'not_set' ? undefined : item.type; + if ( + editorType && + (!checkHasEditorType(editorType) || !allowEditorTypeInSandbox(editorType)) + ) { + return; + } + try { + settings.setValue(scope, 'general.preferredEditor', editorType); + notify?.( + `Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`, + ); + } catch { + return; + } + onClose(); + }; + + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'tab') { + setMode((m) => (m === 'editor' ? 'scope' : 'editor')); + } else if (o.name === 'up' || o.name === 'down') { + const d = o.name === 'up' ? -1 : 1; + if (mode === 'editor') { + moveEditor(d); + } else { + const next = Math.min(scopeItems.length - 1, Math.max(0, scopeSel + d)); + setScopeSel(next); + highlightScope(next); + } + } else if (o.name === 'return') { + if (mode === 'editor') pick(); + else applyScope(); + } + }); + + const otherScope = + scope === SettingScope.User ? SettingScope.Workspace : SettingScope.User; + const otherModified = + settings.forScope(otherScope).settings.general?.preferredEditor !== + undefined; + const scopeMessage = otherModified + ? settings.forScope(scope).settings.general?.preferredEditor !== undefined + ? `(Also modified in ${otherScope})` + : `(Modified in ${otherScope})` + : ''; + + const merged = settings.merged.general?.preferredEditor; + const mergedName = + merged && isEditorAvailable(merged as EditorType) + ? EDITOR_DISPLAY_NAMES[merged as EditorType] + : 'None'; + + return ( + + + + {mode === 'editor' ? ( + + + + {'> Select Editor '} + + {scopeMessage} + + + {editors.map((e, i) => ( + + + {i === sel ? '● ' : '○ '} + + + {e.name} + + + ))} + + + ) : ( + + + {'> Apply To'} + + + {scopeItems.map((s, i) => ( + + + {i === scopeSel ? '● ' : '○ '} + + {s.label} + + ))} + + + )} + + + {mode === 'editor' + ? '(Use Enter to select, Tab to configure scope)' + : '(Use Enter to apply scope, Tab to go back)'} + + + + + + {'Editor Preference'} + + + + { + 'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.' + } + + + {'Your preferred editor is: '} + + {mergedName} + + {'.'} + + + + + + ); +} + +export function OpenTuiTrustDialog({ config, onClose }: P) { + useEsc(onClose); + const trusted = config?.isTrustedFolder?.() ?? false; + return ( + + + + + {'Untrusted folders block privileged approval modes.'} + + + + ); +} + +/** + * Real session deletion (audit 01 G-10): a session picker over + * `SessionService.listSessions` (the current session is disabled, ink + * parity) whose Enter runs `removeSession` + the SessionDelete hook — + * the same services ink's useDeleteCommand drives. + */ +export function OpenTuiDeleteDialog({ config, onClose, notify }: P) { + useEsc(onClose); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [cursor, setCursor] = useState(0); + const [busy, setBusy] = useState(false); + const currentSessionId = config?.getSessionId?.() ?? ''; + useEffect(() => { + let alive = true; + const svc = config?.getSessionService?.(); + if (!svc) { + setLoading(false); + return; + } + svc + .listSessions({ size: 20 }) + .then((res) => { + if (!alive) return; + setRows(res.items ?? []); + setLoading(false); + }) + .catch(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [config]); + const isDisabled = (row: SessionListItem) => + row.sessionId === currentSessionId; + const move = (dir: 1 | -1) => { + setCursor((prev) => { + let next = prev; + for (let i = 0; i < rows.length; i++) { + next = (next + dir + rows.length) % rows.length; + const row = rows[next]; + if (row && !isDisabled(row)) return next; + } + return prev; + }); + }; + useKeyboard((key) => { + const o = toOriginalKey(key); + if (busy || loading) return; + if (o.name === 'up') { + move(-1); + return; + } + if (o.name === 'down') { + move(1); + return; + } + if (o.name === 'return') { + const row = rows[cursor]; + if (!row || isDisabled(row) || !config) return; + setBusy(true); + const svc = config.getSessionService(); + void svc + .removeSession(row.sessionId) + .then((success) => { + if (success) { + fireSessionDeleteHook(config, row.sessionId); + notify?.('Session deleted successfully.'); + } else { + notify?.('Failed to delete session. Session not found.'); + } + onClose(); + }) + .catch(() => { + notify?.('Failed to delete session.'); + setBusy(false); + }); + } + }); + return ( + + + + {'Select a session to delete · enter to delete · esc to cancel'} + + + {loading ? ( + {'loading sessions…'} + ) : rows.length === 0 ? ( + {'no previous sessions'} + ) : ( + rows.map((r, i) => { + const disabled = isDisabled(r); + const selected = i === cursor; + const title = r.customTitle || r.prompt || '(untitled)'; + return ( + + + {selected ? '› ' : ' '} + + + {title.length > 40 ? `${title.slice(0, 40)}…` : title} + + {` ${r.sessionId.slice(0, 8)}${ + disabled ? ' (current)' : '' + }`} + + ); + }) + )} + + {busy && {'deleting…'}} + + + ); +} + +/** + * Interactive resume picker (audit 01 G-5 / 05 G-04): ↑↓ navigation + Enter + * selects, running the real session switch through `onSelect` + * (host.handleResume). `matchedSessions` is the command's pre-filtered list + * (`/resume ` with multiple matches); without it the 10 most + * recent sessions are listed, like ink's SessionPicker default. + */ +export function OpenTuiResumeDialog({ + config, + onClose, + matchedSessions, + onSelect, +}: P) { + useEsc(onClose); + const [rows, setRows] = useState(matchedSessions ?? []); + const [loading, setLoading] = useState(!matchedSessions); + const [cursor, setCursor] = useState(0); + useEffect(() => { + if (matchedSessions) { + setRows(matchedSessions); + setLoading(false); + return; + } + let alive = true; + const svc = config?.getSessionService?.(); + if (!svc) { + setLoading(false); + return; + } + svc + .listSessions({ size: 10 }) + .then((res) => { + if (!alive) return; + setRows(res.items ?? []); + setLoading(false); + }) + .catch(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [config, matchedSessions]); + useKeyboard((key) => { + const o = toOriginalKey(key); + if (loading) return; + if (o.name === 'up') { + setCursor((prev) => Math.max(0, prev - 1)); + return; + } + if (o.name === 'down') { + setCursor((prev) => Math.min(rows.length - 1, prev + 1)); + return; + } + if (o.name === 'return') { + const row = rows[cursor]; + if (!row) return; + onClose(); + onSelect?.(row.sessionId); + } + }); + return ( + + + + {'↑↓ to navigate · enter to resume · esc to cancel'} + + + {loading ? ( + {'loading sessions…'} + ) : rows.length === 0 ? ( + {'no previous sessions'} + ) : ( + rows.map((r, i) => { + const selected = i === cursor; + const title = r.customTitle || r.prompt || '(untitled)'; + const when = r.startTime ? r.startTime.slice(0, 10) : ''; + return ( + + + {selected ? '› ' : ' '} + + + {title.length > 40 ? `${title.slice(0, 40)}…` : title} + + {` ${r.sessionId.slice(0, 8)}${ + when ? ` · ${when}` : '' + }`} + + ); + }) + )} + + + + ); +} + +export function OpenTuiBranchDialog({ onClose }: P) { + useEsc(onClose); + return ( + + + + {'Creates a fork of the current session to explore a new path.'} + + + + ); +} + +/** + * The real hooks switch is the top-level `disableAllHooks` setting (default + * false = enabled); `hooks` is an event-name → hook-arrays map with no + * `enabled` field. Runtime additionally disables hooks in bare/safe mode + * (config.getDisableAllHooks). + */ +export function readHooksEnabled( + config: Pick | undefined, + settings: LoadedSettings, +): boolean { + return config?.getDisableAllHooks + ? !config.getDisableAllHooks() + : !( + (settings.merged as { disableAllHooks?: boolean }).disableAllHooks ?? + false + ); +} + +export function OpenTuiHooksDialog({ config, settings, onClose }: P) { + useEsc(onClose); + const enabled = readHooksEnabled(config, settings); + return ( + + + + + {'Lifecycle hooks run around tool/session events.'} + + + + ); +} + +export function OpenTuiRewindDialog({ onClose }: P) { + useEsc(onClose); + return ( + + + + {'Checkpoints let you rewind the conversation to an earlier turn.'} + + + + ); +} + +export function OpenTuiDiffDialog({ onClose }: P) { + useEsc(onClose); + const [lines, setLines] = useState([]); + useEffect(() => { + let alive = true; + import('node:child_process') + .then(({ execFile }) => { + execFile( + 'git', + ['diff', '--color=never'], + { maxBuffer: 1024 * 1024 * 8 }, + (_err, stdout) => { + if (alive) + setLines( + (stdout ?? '').split('\n').filter(Boolean).slice(0, 200), + ); + }, + ); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + return ( + + + {lines.length === 0 ? ( + {'no working-tree changes'} + ) : ( + lines.map((l, i) => ( + + {l} + + )) + )} + + + ); +} + +export function OpenTuiSubagentCreateDialog({ onClose }: P) { + useEsc(onClose); + return ( + + + {'Define a new subagent (name, tools, prompt).'} + + + ); +} + +export function OpenTuiSubagentListDialog({ config, onClose }: P) { + useEsc(onClose); + const [rows, setRows] = useState>([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + let alive = true; + const mgr = ( + config as unknown as { + getSubagentManager?: () => { + listSubagents: () => Promise>>; + }; + } + )?.getSubagentManager?.(); + if (!mgr) { + setLoading(false); + return; + } + mgr + .listSubagents() + .then((list) => { + if (!alive) return; + setRows( + (list ?? []).map((s) => ({ + name: String(s['name'] ?? '(unnamed)'), + desc: String(s['description'] ?? ''), + })), + ); + setLoading(false); + }) + .catch(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [config]); + return ( + + + {loading ? ( + {'loading subagents…'} + ) : rows.length === 0 ? ( + {'no subagents configured'} + ) : ( + rows.map((r) => ( + + {'• '} + {r.name} + {` ${r.desc}`} + + )) + )} + + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-model.test.ts b/packages/cli/src/ui/opentui/dialogs-model.test.ts new file mode 100644 index 00000000000..87cf2a75495 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-model.test.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI model dialog reproduces the original ink ModelDialog + * display logic: modality formatting, API-key masking, context-window + * formatting, the authType::modelId[\0baseUrl] selection keys, the dialog + * title per mode/persist-scope, and the row label markers. + */ + +import { describe, it, expect, vi } from 'vitest'; + +// theme.ts builds a SyntaxStyle at module scope, which needs the OpenTUI +// native FFI — unavailable in the test runtime. Stub the graphics surface. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { + buildModelSelectionKey, + computeModelDialogMaxItems, + formatContextWindow, + formatModalities, + formatModelOptionLabel, + maskApiKey, + modelDialogTitle, + parseModelSelectionKey, + MAX_MODEL_ITEMS_TO_SHOW, + type OpenTuiModelEntry, +} from './dialogs-model.js'; + +describe('formatModalities', () => { + it('reports text-only when nothing is declared', () => { + expect(formatModalities(undefined)).toBe('text-only'); + expect(formatModalities({})).toBe('text-only'); + }); + + it('lists enabled input modalities after text', () => { + expect( + formatModalities({ image: true, pdf: true, audio: true, video: true }), + ).toBe('text · image · pdf · audio · video'); + expect(formatModalities({ image: true })).toBe('text · image'); + }); +}); + +describe('maskApiKey', () => { + it('shows (not set) for missing/blank keys', () => { + expect(maskApiKey(undefined)).toBe('(not set)'); + expect(maskApiKey(' ')).toBe('(not set)'); + }); + + it('masks short keys completely', () => { + expect(maskApiKey('abc123')).toBe('***'); + }); + + it('keeps 3 head + 4 tail characters for long keys', () => { + expect(maskApiKey('sk-abcdef123456')).toBe('sk-…3456'); + }); +}); + +describe('formatContextWindow', () => { + it('formats unknown and known sizes like the original', () => { + expect(formatContextWindow(undefined)).toBe('(unknown)'); + expect(formatContextWindow(1048576)).toBe('1,048,576 tokens'); + }); +}); + +describe('model selection keys', () => { + it('round-trips authType and modelId', () => { + const key = buildModelSelectionKey('use-openai', 'gpt-x'); + expect(parseModelSelectionKey(key)).toEqual({ + authType: 'use-openai', + modelId: 'gpt-x', + }); + }); + + it('preserves baseUrl through the \\0 separator', () => { + const key = buildModelSelectionKey('use-openai', 'gpt-x', 'https://a'); + expect(parseModelSelectionKey(key)).toEqual({ + authType: 'use-openai', + modelId: 'gpt-x', + baseUrl: 'https://a', + }); + }); + + it('falls back to a bare id when no separator exists', () => { + expect(parseModelSelectionKey('plain-id')).toEqual({ + authType: '', + modelId: 'plain-id', + }); + }); +}); + +describe('modelDialogTitle', () => { + it('uses the mode-specific title', () => { + expect(modelDialogTitle('primary')).toBe('Select Model'); + expect(modelDialogTitle('fast')).toBe('Select Fast Model'); + expect(modelDialogTitle('voice')).toBe('Select Voice Model'); + expect(modelDialogTitle('vision')).toBe('Select Vision Model'); + expect(modelDialogTitle('compaction')).toBe('Select Compaction Model'); + expect(modelDialogTitle('image')).toBe('Select Image Model'); + }); + + it('appends the persist-scope suffix', () => { + expect(modelDialogTitle('primary', 'workspace')).toBe( + 'Select Model (this project)', + ); + expect(modelDialogTitle('primary', 'user')).toBe('Select Model (global)'); + }); +}); + +describe('formatModelOptionLabel', () => { + const base: OpenTuiModelEntry = { + key: 'k', + value: 'use-openai::gpt-x', + authType: 'use-openai', + label: 'GPT X', + modelId: 'gpt-x', + }; + + it('shows the authType tag, label, and model id suffix', () => { + expect(formatModelOptionLabel(base)).toBe('[use-openai] GPT X (gpt-x)'); + }); + + it('omits the id suffix when id equals the label', () => { + expect(formatModelOptionLabel({ ...base, label: 'gpt-x' })).toBe( + '[use-openai] gpt-x', + ); + }); + + it('marks runtime and discontinued rows', () => { + expect(formatModelOptionLabel({ ...base, isRuntime: true })).toBe( + '[use-openai] GPT X (gpt-x) (Runtime)', + ); + expect(formatModelOptionLabel({ ...base, isQwenOAuth: true })).toBe( + '[use-openai] GPT X (gpt-x) (Discontinued)', + ); + // Runtime wins over the discontinued marker (original behavior). + expect( + formatModelOptionLabel({ ...base, isRuntime: true, isQwenOAuth: true }), + ).toBe('[use-openai] GPT X (gpt-x) (Runtime)'); + }); +}); + +describe('computeModelDialogMaxItems (availableTerminalHeight parity)', () => { + it('uses the full window when no height is provided', () => { + expect(computeModelDialogMaxItems(undefined, false, 0)).toBe( + MAX_MODEL_ITEMS_TO_SHOW, + ); + }); + + it('caps the list so the detail panel and footer stay visible', () => { + // 24-row terminal, no descriptions: (24 - 14 fixed) / 1 row = 10, but + // never more than MAX_MODEL_ITEMS_TO_SHOW. + expect(computeModelDialogMaxItems(24, false, 0)).toBeLessThanOrEqual( + MAX_MODEL_ITEMS_TO_SHOW, + ); + // Short terminal shrinks the window below the default. + expect(computeModelDialogMaxItems(18, false, 0)).toBe(4); + }); + + it('counts two rows for entries with descriptions', () => { + expect(computeModelDialogMaxItems(24, true, 0)).toBe( + Math.floor((24 - 14) / 2), + ); + }); + + it('reserves rows for a visible error box', () => { + expect(computeModelDialogMaxItems(24, false, 4)).toBe( + Math.floor((24 - 14 - 4) / 1), + ); + }); + + it('never shows fewer than one row', () => { + expect(computeModelDialogMaxItems(5, false, 0)).toBe(1); + expect(computeModelDialogMaxItems(0, true, 10)).toBe(1); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-model.tsx b/packages/cli/src/ui/opentui/dialogs-model.tsx new file mode 100644 index 00000000000..2e072c325e6 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-model.tsx @@ -0,0 +1,408 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink `/model` dialog + * (ui/components/ModelDialog.tsx): model list with `[authType]` tags, the + * highlighted-entry detail panel (Modality / Context Window / Base URL / + * API Key), runtime/discontinued markers, the empty state, error box, and + * the original footer hint. Esc closes; in auxiliary modes (fast/voice/ + * vision/compaction/image) ← closes too — both straight from the original. + * Model switching/persisting is the backend's job: `onSelect` receives the + * row's selection key unchanged. + */ + +import { useState } from 'react'; +import { useKeyboard } from '@opentui/react'; +import { C } from './theme.js'; +import { t } from '../../i18n/index.js'; +import { toOriginalKey } from './key-map.js'; +import type { + InputModalities, + AuthType, + AvailableModel, +} from '@qwen-code/qwen-code-core'; +import { + DialogFrame, + DialogSelect, + FooterHint, + useDialogSelect, + type DialogListItem, +} from './dialogs-shared.js'; +import { useDialogFrameKeys } from './dialogs-shared.js'; + +export const MAX_MODEL_ITEMS_TO_SHOW = 10; + +/** + * Height-cap parity of the ink ModelDialog: non-list chrome to reserve when + * capping the visible model rows — outer border (2) + title/gap (2) + the + * highlighted-entry detail panel (divider + up to 4 rows + margin, ~7) + + * footer hint (2) + error-box rows when present. Mirrors + * `MODEL_DIALOG_FIXED_ROWS` in ui/components/ModelDialog.tsx. + */ +export const MODEL_DIALOG_FIXED_ROWS = 14; +export const MODEL_OPTION_ROW_HEIGHT = 1; +export const MODEL_OPTION_ROW_HEIGHT_WITH_DESCRIPTION = 2; + +/** Parity of the ink dialog's `maxModelItemsToShow` computation. */ +export function computeModelDialogMaxItems( + availableTerminalHeight: number | undefined, + hasDescriptions: boolean, + errorMessageRows: number, +): number { + if (availableTerminalHeight === undefined) { + return MAX_MODEL_ITEMS_TO_SHOW; + } + const rowHeight = hasDescriptions + ? MODEL_OPTION_ROW_HEIGHT_WITH_DESCRIPTION + : MODEL_OPTION_ROW_HEIGHT; + return Math.max( + 1, + Math.min( + MAX_MODEL_ITEMS_TO_SHOW, + Math.floor( + (availableTerminalHeight - MODEL_DIALOG_FIXED_ROWS - errorMessageRows) / + rowHeight, + ), + ), + ); +} + +export type ModelDialogMode = + | 'primary' + | 'fast' + | 'voice' + | 'vision' + | 'compaction' + | 'image'; + +/** Parity of `formatModalities` in ModelDialog.tsx. */ +export function formatModalities(modalities?: InputModalities): string { + if (!modalities) return t('text-only'); + const parts: string[] = []; + if (modalities.image) parts.push(t('image')); + if (modalities.pdf) parts.push(t('pdf')); + if (modalities.audio) parts.push(t('audio')); + if (modalities.video) parts.push(t('video')); + if (parts.length === 0) return t('text-only'); + return `${t('text')} · ${parts.join(' · ')}`; +} + +/** Parity of `maskApiKey` in ModelDialog.tsx. */ +export function maskApiKey(apiKey: string | undefined): string { + if (!apiKey) return `(${t('not set')})`; + const trimmed = apiKey.trim(); + if (trimmed.length === 0) return `(${t('not set')})`; + if (trimmed.length <= 6) return '***'; + const head = trimmed.slice(0, 3); + const tail = trimmed.slice(-4); + return `${head}…${tail}`; +} + +/** Parity of `formatContextWindow` in ModelDialog.tsx. */ +export function formatContextWindow(size?: number): string { + if (!size) return `(${t('unknown')})`; + return `${size.toLocaleString('en-US')} tokens`; +} + +/** + * Parity of `buildModelSelectionKey` / `parseModelSelectionKey`: the \0 + * separator keeps same-id models on different baseUrls distinct. + */ +export function buildModelSelectionKey( + authType: string, + modelId: string, + baseUrl?: string, +): string { + const base = `${authType}::${modelId}`; + return baseUrl ? `${base}\0${baseUrl}` : base; +} + +export function parseModelSelectionKey(key: string): { + authType: string; + modelId: string; + baseUrl?: string; +} { + const sep = '::'; + const idx = key.indexOf(sep); + if (idx < 0) return { authType: '', modelId: key }; + + const authType = key.slice(0, idx); + const rest = key.slice(idx + sep.length); + const nullIdx = rest.indexOf('\0'); + if (nullIdx >= 0) { + return { + authType, + modelId: rest.slice(0, nullIdx), + baseUrl: rest.slice(nullIdx + 1), + }; + } + return { authType, modelId: rest }; +} + +/** + * Parity of `encodeAuxModelSelector` in ModelDialog.tsx: encode a selection + * key into the `authType:modelId` form persisted for the fast/vision auxiliary + * models (baseUrl discarded). Handles the three selection-key shapes. + */ +export function encodeAuxModelSelector(selected: string): string { + if (selected.includes('::')) { + const parsed = parseModelSelectionKey(selected); + return `${parsed.authType}:${parsed.modelId}`; + } + if (selected.startsWith('$runtime|')) { + const parts = selected.split('|'); + return parts[1] && parts[2] ? `${parts[1]}:${parts[2]}` : selected; + } + return selected; +} + +/** + * Parity of `encodeVisionModelSelector` in ModelDialog.tsx: keep the selected + * row's baseUrl when present (so same-provider same-id endpoints stay + * distinct), otherwise fall back to the aux encoding. + */ +export function encodeVisionModelSelector(selected: string): string { + if (!selected.includes('::')) { + return encodeAuxModelSelector(selected); + } + const parsed = parseModelSelectionKey(selected); + const selector = `${parsed.authType}:${parsed.modelId}`; + return parsed.baseUrl ? `${selector}\0${parsed.baseUrl}` : selector; +} + +/** Parity of the ModelDialog title line. */ +export function modelDialogTitle( + mode: ModelDialogMode, + persistScope?: 'workspace' | 'user', +): string { + const base = + mode === 'voice' + ? t('Select Voice Model') + : mode === 'vision' + ? t('Select Vision Model') + : mode === 'compaction' + ? t('Select Compaction Model') + : mode === 'image' + ? t('Select Image Model') + : mode === 'fast' + ? t('Select Fast Model') + : t('Select Model'); + const suffix = + persistScope === 'workspace' + ? t(' (this project)') + : persistScope === 'user' + ? t(' (global)') + : ''; + return base + suffix; +} + +export interface OpenTuiModelEntry extends DialogListItem { + authType: string; + /** model.label — the human display name. */ + label: string; + modelId: string; + description?: string; + isRuntime?: boolean; + isQwenOAuth?: boolean; + modalities?: InputModalities; + contextWindowSize?: number; + baseUrl?: string; + envKey?: string; + /** The registry entry behind this row (selection-time validation parity). */ + model?: AvailableModel; +} + +/** Plain-text row title (colors are applied at render time). */ +export function formatModelOptionLabel(entry: OpenTuiModelEntry): string { + let label = `[${entry.authType}] ${entry.label}`; + if (entry.modelId !== entry.label) label += ` (${entry.modelId})`; + if (entry.isRuntime) label += ' (Runtime)'; + if (entry.isQwenOAuth && !entry.isRuntime) label += ` (${t('Discontinued')})`; + return label; +} + +export interface OpenTuiModelDialogProps { + entries: readonly OpenTuiModelEntry[]; + mode: ModelDialogMode; + authType?: AuthType; + persistScope?: 'workspace' | 'user'; + initialKey?: string; + errorMessage?: string | null; + onSelect: (selectionKey: string) => void; + onClose: () => void; + availableTerminalHeight?: number; +} + +export function OpenTuiModelDialog(props: OpenTuiModelDialogProps) { + const { + entries, + mode, + authType, + persistScope, + initialKey, + errorMessage, + onSelect, + onClose, + availableTerminalHeight, + } = props; + + const isAuxMode = mode !== 'primary'; + const [highlightedKey, setHighlightedKey] = useState(null); + + const initialIndex = initialKey + ? Math.max( + 0, + entries.findIndex((entry) => entry.key === initialKey), + ) + : 0; + + // Height-capped list window (ink parity): on short terminals the detail + // panel and footer stay visible instead of being pushed off-screen. + const errorMessageRows = errorMessage + ? 2 + errorMessage.split('\n').length + : 0; + const maxItemsToShow = computeModelDialogMaxItems( + availableTerminalHeight, + entries.some( + (entry) => + typeof entry.description === 'string' && + entry.description.trim().length > 0, + ), + errorMessageRows, + ); + + const list = useDialogSelect({ + items: entries, + initialIndex, + focused: true, + numbers: true, + // The original intentionally omits the ▲/▼ arrows; window only. + maxItemsToShow, + onSelect: (key) => onSelect(key), + onHighlight: (key) => setHighlightedKey(key), + }); + + useDialogFrameKeys({ + onEscape: onClose, + }); + useLeftCloses({ enabled: isAuxMode, onClose }); + + const highlightedEntry = + entries.find((entry) => entry.key === (highlightedKey ?? initialKey)) ?? + undefined; + const hasModels = entries.length > 0; + + return ( + + + {modelDialogTitle(mode, persistScope)} + + + {!hasModels ? ( + + + {t( + 'No models available for the current authentication type ({{authType}}).', + { authType: authType ? String(authType) : t('(none)') }, + )} + + + + {t( + 'Please configure models in settings.modelProviders or use environment variables.', + )} + + + + ) : ( + + + list.setActiveIndex( + list.activeIndex + (direction === 'down' ? 1 : -1), + ) + } + renderLabel={(item, { titleColor }) => ( + {formatModelOptionLabel(item)} + )} + /> + + )} + + {highlightedEntry && ( + + {'─'.repeat(20)} + {highlightedEntry.isQwenOAuth && !highlightedEntry.isRuntime && ( + + + ⚠ {t('Discontinued — switch to Coding Plan or API Key')} + + + )} + + + {!highlightedEntry.isQwenOAuth && ( + + + + + )} + + )} + + {errorMessage && ( + + ✕ {errorMessage} + + )} + + + + ); +} + +function DetailRow(props: { label: string; value: string }) { + return ( + + + {props.label}: + + {props.value} + + ); +} + +/** In auxiliary model modes the original binds ← to close as well. */ +function useLeftCloses(options: { enabled: boolean; onClose: () => void }) { + useKeyboard((key) => { + if (!options.enabled) return; + const original = toOriginalKey(key); + if (original.name === 'left') options.onClose(); + }); +} diff --git a/packages/cli/src/ui/opentui/dialogs-modes.tsx b/packages/cli/src/ui/opentui/dialogs-modes.tsx new file mode 100644 index 00000000000..c44e7cf8c92 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-modes.tsx @@ -0,0 +1,222 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Native OpenTUI ApprovalMode and Effort dialogs (parity follow-up to #8677), + * ported from ink ApprovalModeDialog/EffortDialog: a radio list navigated with + * up/down, Enter applies (settings + config), Esc cancels. + */ + +import { useLayoutEffect, useState, type ReactNode } from 'react'; +import { useRenderer, useKeyboard } from '@opentui/react'; +import { + applyReasoningEffort, + APPROVAL_MODES, + REASONING_EFFORT_TIERS, + type ApprovalMode, + type ReasoningEffort, + type Config, +} from '@qwen-code/qwen-code-core'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { toOriginalKey } from './key-map.js'; +import { C } from './theme.js'; + +function useEsc(onClose: () => void) { + const renderer = useRenderer(); + useLayoutEffect(() => { + const onRaw = (seq: string): boolean => { + if (seq !== '\x1b') return false; + onClose(); + return true; + }; + renderer.addInputHandler(onRaw); + return () => renderer.removeInputHandler(onRaw); + }, [renderer, onClose]); +} + +function RadioList({ + items, + selected, + onMove, + onPick, +}: { + items: Array<{ key: string; label: string; desc?: string }>; + selected: number; + onMove: (d: 1 | -1) => void; + onPick: () => void; +}) { + useKeyboard((key) => { + const o = toOriginalKey(key); + if (o.name === 'up') onMove(-1); + else if (o.name === 'down') onMove(1); + else if (o.name === 'return') onPick(); + }); + return ( + + {items.map((it, i) => ( + + + {i === selected ? '● ' : '○ '} + + + {it.label} + + {it.desc ? {` ${it.desc}`} : null} + + ))} + + ); +} + +const Shell = ({ + title, + children, +}: { + title: string; + children?: ReactNode; +}) => ( + + + + {title} + + {'↑↓ · enter · esc'} + + {children} + +); + +const MODE_DESC: Record = { + default: 'Prompt for each tool', + 'auto-edit': 'Auto-approve edits', + auto: 'Full auto, safer rules', + yolo: 'Auto-approve everything', + plan: 'Plan only, no execution', +}; + +export function OpenTuiApprovalModeDialog(props: { + config?: Config; + settings: LoadedSettings; + onClose: () => void; + onApprovalModeChanged: (m: ApprovalMode) => void; +}) { + const { config, settings, onClose, onApprovalModeChanged } = props; + const modes = APPROVAL_MODES as ApprovalMode[]; + const current = config?.getApprovalMode?.(); + const [sel, setSel] = useState( + Math.max(0, modes.indexOf(current as ApprovalMode)), + ); + useEsc(onClose); + const pick = () => { + const mode = modes[sel]; + if (mode) { + try { + // ink defaults the persist scope to User (its scope picker) — an + // untrusted workspace never receives writes; the runtime applies the + // merged setting (useApprovalModeCommand parity). + settings.setValue(SettingScope.User, 'tools.approvalMode', mode); + config?.setApprovalMode?.(settings.merged.tools?.approvalMode ?? mode); + onApprovalModeChanged(mode); + } catch { + /* trust gate */ + } + } + onClose(); + }; + return ( + + ({ + key: m, + label: String(m), + desc: MODE_DESC[String(m)], + }))} + selected={sel} + onMove={(d) => + setSel((s) => Math.min(modes.length - 1, Math.max(0, s + d))) + } + onPick={pick} + /> + + ); +} + +const EFFORT_DESC: Record = { + low: 'Fastest and cheapest', + medium: 'Balanced speed/cost', + high: 'Default strong reasoning', + xhigh: 'Extended agentic reasoning', + max: 'Maximum reasoning', +}; + +export function OpenTuiEffortDialog(props: { + config?: Config; + settings: LoadedSettings; + onClose: () => void; +}) { + const { config, settings, onClose } = props; + const tiers = REASONING_EFFORT_TIERS as ReasoningEffort[]; + // Pre-select the live tier only when one is configured; an unset effort + // starts at the top (ink EffortDialog initialIndex parity). + const currentEffort = config?.getReasoningEffort?.(); + const [sel, setSel] = useState( + currentEffort ? Math.max(0, tiers.indexOf(currentEffort)) : 0, + ); + useEsc(onClose); + const pick = () => { + const effort = tiers[sel]; + if (effort) { + try { + // Apply at runtime (next turn) and persist for future sessions; + // provider adapters clamp the tier per model (ink useEffortCommand + // parity — the request pipeline reads the live config per request). + if (config) { + applyReasoningEffort(config, effort); + } + settings.setValue( + getPersistScopeForModelSelection(settings), + 'model.reasoningEffort', + effort, + ); + } catch { + /* ignore */ + } + } + onClose(); + }; + return ( + + ({ + key: t, + label: String(t), + desc: EFFORT_DESC[String(t)], + }))} + selected={sel} + onMove={(d) => + setSel((s) => Math.min(tiers.length - 1, Math.max(0, s + d))) + } + onPick={pick} + /> + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-permissions.test.ts b/packages/cli/src/ui/opentui/dialogs-permissions.test.ts new file mode 100644 index 00000000000..722f08f9679 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-permissions.test.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI permissions dialog reproduces the original ink + * PermissionsDialog content: the four tabs, rule descriptions, scope + * labels, the rule-save scope items, and the workspace directory input + * validation (same fs checks, same error strings). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as nodePath from 'node:path'; + +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { + describePermissionRule, + getPermissionsTabs, + getPermissionScopeItems, + permissionScopeLabel, + validateWorkspaceDirectory, +} from './dialogs-permissions.js'; +import { SettingScope } from '../../config/settings.js'; + +describe('getPermissionsTabs', () => { + it('keeps the original four tabs, order, and copy', () => { + const tabs = getPermissionsTabs(); + expect(tabs.map((tab) => tab.id)).toEqual([ + 'allow', + 'ask', + 'deny', + 'workspace', + ]); + expect(tabs[0]).toEqual({ + id: 'allow', + label: 'Allow', + description: "Qwen Code won't ask before using allowed tools.", + }); + expect(tabs[3]).toEqual({ + id: 'workspace', + label: 'Workspace', + description: 'Manage trusted directories for this workspace.', + }); + }); +}); + +describe('describePermissionRule', () => { + it('describes a bare tool as any use', () => { + expect(describePermissionRule('Bash')).toBe('Any use of the Bash tool'); + }); + + it('describes a specifier rule as a pattern match', () => { + expect(describePermissionRule('Bash(ls:*)')).toBe( + "Bash commands matching 'ls:*'", + ); + }); + + it('returns the raw text when it cannot be parsed', () => { + expect(describePermissionRule('(weird)')).toBe('(weird)'); + }); +}); + +describe('permissionScopeLabel', () => { + it('maps rule sources to the original labels', () => { + expect(permissionScopeLabel('user')).toBe('From user settings'); + expect(permissionScopeLabel('workspace')).toBe('From project settings'); + expect(permissionScopeLabel('session')).toBe('From session'); + expect(permissionScopeLabel('elsewhere')).toBe('elsewhere'); + }); +}); + +describe('getPermissionScopeItems', () => { + it('lists project first, then user, with the save locations', () => { + const items = getPermissionScopeItems(); + expect(items).toHaveLength(2); + expect(items[0]).toEqual({ + label: 'Project settings', + description: 'Checked in at .qwen/settings.json', + value: SettingScope.Workspace, + key: 'project', + }); + expect(items[1]).toEqual({ + label: 'User settings', + description: 'Saved in at ~/.qwen/settings.json', + value: SettingScope.User, + key: 'user', + }); + }); +}); + +describe('validateWorkspaceDirectory', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'perm-dialog-')); + }); + + afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('returns the falsy empty-input sentinel (handler must short-circuit)', () => { + // ink short-circuits empty input in the submit handler before calling + // the validator — the sentinel keeps that contract visible here. + expect(validateWorkspaceDirectory(' ', [])).toEqual({ error: '' }); + }); + + it('rejects paths that do not exist', () => { + const result = validateWorkspaceDirectory( + nodePath.join(tmpRoot, 'missing'), + [], + ); + expect(result.error).toBe('Directory does not exist.'); + }); + + it('rejects file paths', () => { + const file = nodePath.join(tmpRoot, 'file.txt'); + fs.writeFileSync(file, 'x'); + const result = validateWorkspaceDirectory(file, []); + expect(result.error).toBe('Path is not a directory.'); + }); + + it('resolves an existing directory', () => { + const dir = nodePath.join(tmpRoot, 'dir'); + fs.mkdirSync(dir); + const result = validateWorkspaceDirectory(dir, []); + expect(result.error).toBeUndefined(); + expect(result.resolved).toBe(fs.realpathSync(dir)); + }); + + it('rejects duplicates of an existing workspace directory', () => { + const result = validateWorkspaceDirectory(tmpRoot, [ + fs.realpathSync(tmpRoot), + ]); + expect(result.error).toBe('This directory is already in the workspace.'); + }); + + it('rejects subdirectories of an existing workspace directory', () => { + const child = nodePath.join(tmpRoot, 'child'); + fs.mkdirSync(child); + const result = validateWorkspaceDirectory(child, [ + fs.realpathSync(tmpRoot), + ]); + expect(result.error).toContain('Already covered by existing directory'); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-permissions.tsx b/packages/cli/src/ui/opentui/dialogs-permissions.tsx new file mode 100644 index 00000000000..49713ce4766 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-permissions.tsx @@ -0,0 +1,734 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink `/permissions` dialog + * (ui/components/PermissionsDialog.tsx): the Allow/Ask/Deny/Workspace tab + * bar, the type-to-search rule list ("Add a new rule…" first), the + * add-rule → scope-select and delete-confirm flows, and the workspace + * directory views (initial dirs inline, "Add directory…" entry, remove + * confirm). Rule parsing reuses the original core `parseRule`; fs-based + * directory validation is a pure exported helper. Mutation is the + * backend's job — the dialog reports intents through callbacks. + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as nodePath from 'node:path'; +import { useState } from 'react'; +import { useKeyboard } from '@opentui/react'; +import { C } from './theme.js'; +import { t } from '../../i18n/index.js'; +import { SettingScope } from '../../config/settings.js'; +import { isPathWithinRoot, parseRule } from '@qwen-code/qwen-code-core'; +import { toOriginalKey } from './key-map.js'; +import { matchesSearchQuery } from './dialogs-core.js'; +import { + DialogFrame, + DialogSelect, + DialogTabBar, + FooterHint, + useDialogSelect, +} from './dialogs-shared.js'; + +export type PermissionsTabId = 'allow' | 'ask' | 'deny' | 'workspace'; + +export interface PermissionsTabDef { + id: PermissionsTabId; + label: string; + description: string; +} + +/** Parity of getTabs() in PermissionsDialog.tsx. */ +export function getPermissionsTabs(): PermissionsTabDef[] { + return [ + { + id: 'allow', + label: t('Allow'), + description: t("Qwen Code won't ask before using allowed tools."), + }, + { + id: 'ask', + label: t('Ask'), + description: t('Qwen Code will ask before using these tools.'), + }, + { + id: 'deny', + label: t('Deny'), + description: t('Qwen Code is not allowed to use denied tools.'), + }, + { + id: 'workspace', + label: t('Workspace'), + description: t('Manage trusted directories for this workspace.'), + }, + ]; +} + +/** Parity of describeRule in PermissionsDialog.tsx. */ +export function describePermissionRule(raw: string): string { + const match = raw.match(/^([^(]+?)(?:\((.+)\))?$/); + if (!match) return raw; + const toolName = match[1]!.trim(); + const specifier = match[2]?.trim(); + if (!specifier) { + return t('Any use of the {{tool}} tool', { tool: toolName }); + } + return t("{{tool}} commands matching '{{pattern}}'", { + tool: toolName, + pattern: specifier, + }); +} + +/** Parity of scopeLabel in PermissionsDialog.tsx. */ +export function permissionScopeLabel(scope: string): string { + switch (scope) { + case 'user': + return t('From user settings'); + case 'workspace': + return t('From project settings'); + case 'session': + return t('From session'); + default: + return scope; + } +} + +/** Parity of getPermScopeItems in PermissionsDialog.tsx. */ +export function getPermissionScopeItems(): Array<{ + label: string; + description: string; + value: SettingScope; + key: string; +}> { + return [ + { + label: t('Project settings'), + description: t('Checked in at .qwen/settings.json'), + value: SettingScope.Workspace, + key: 'project', + }, + { + label: t('User settings'), + description: t('Saved in at ~/.qwen/settings.json'), + value: SettingScope.User, + key: 'user', + }, + ]; +} + +export interface PermissionRuleEntry { + raw: string; + toolName: string; + type: 'allow' | 'ask' | 'deny'; + scope: string; +} + +/** The workspace-directory add validation, exactly as the ink dialog runs it. */ +export function validateWorkspaceDirectory( + input: string, + currentDirectories: readonly string[], +): { error?: string; resolved?: string } { + const trimmed = input.trim(); + if (!trimmed) return { error: '' }; + + const expanded = trimmed.startsWith('~') + ? trimmed.replace(/^~/, os.homedir()) + : trimmed; + const absoluteExpanded = nodePath.isAbsolute(expanded) + ? expanded + : nodePath.resolve(expanded); + + if (!fs.existsSync(absoluteExpanded)) { + return { error: t('Directory does not exist.') }; + } + if (!fs.statSync(absoluteExpanded).isDirectory()) { + return { error: t('Path is not a directory.') }; + } + + let resolved: string; + try { + resolved = fs.realpathSync(absoluteExpanded); + } catch { + resolved = absoluteExpanded; + } + + if (currentDirectories.includes(resolved)) { + return { error: t('This directory is already in the workspace.') }; + } + for (const existingDir of currentDirectories) { + if (isPathWithinRoot(resolved, existingDir)) { + return { + error: t('Already covered by existing directory: {{dir}}', { + dir: existingDir, + }), + }; + } + } + return { resolved }; +} + +type PermissionsView = + | 'rule-list' + | 'add-rule-input' + | 'add-rule-scope' + | 'delete-confirm' + | 'ws-dir-list' + | 'ws-add-dir-input' + | 'ws-remove-confirm'; + +export interface OpenTuiPermissionsDialogProps { + rules: readonly PermissionRuleEntry[]; + directories: readonly string[]; + initialDirectories: readonly string[]; + onAddRule: ( + ruleText: string, + type: PermissionRuleEntry['type'], + scope: SettingScope, + ) => void; + onDeleteRule: (raw: string, type: PermissionRuleEntry['type']) => void; + onAddDirectory: (resolvedDir: string) => void; + onRemoveDirectory: (dir: string) => void; + onExit: () => void; +} + +export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) { + const { + rules, + directories, + initialDirectories, + onAddRule, + onDeleteRule, + onAddDirectory, + onRemoveDirectory, + onExit, + } = props; + + const tabs = getPermissionsTabs(); + const [activeTabIndex, setActiveTabIndex] = useState(0); + const activeTab = tabs[activeTabIndex]!; + const [view, setView] = useState('rule-list'); + const [searchQuery, setSearchQuery] = useState(''); + const [newRuleInput, setNewRuleInput] = useState(''); + const [ruleInputError, setRuleInputError] = useState(''); + const [pendingRuleText, setPendingRuleText] = useState(''); + const [deleteTarget, setDeleteTarget] = useState( + null, + ); + const [newDirInput, setNewDirInput] = useState(''); + const [dirInputError, setDirInputError] = useState(''); + const [removeDirTarget, setRemoveDirTarget] = useState(null); + + const currentTabRules = + activeTab.id === 'workspace' + ? [] + : rules.filter((r) => r.type === activeTab.id); + const filteredRules = currentTabRules.filter((r) => + matchesSearchQuery(searchQuery, [r.raw, r.toolName]), + ); + + const ruleListItems = [ + { label: t('Add a new rule…'), key: '__add__', value: '__add__' }, + ...filteredRules.map((r) => ({ + label: r.raw, + value: r.raw, + key: `${r.type}-${r.scope}-${r.raw}`, + })), + ]; + const initialDirSet = new Set(initialDirectories); + const dirListItems = [ + { label: t('Add directory…'), key: '__add_dir__', value: '__add_dir__' }, + ...directories + .filter((dir) => !initialDirSet.has(dir)) + .map((dir) => ({ label: dir, value: dir, key: `dir-${dir}` })), + ]; + + const ruleList = useDialogSelect({ + items: ruleListItems, + focused: view === 'rule-list' && activeTab.id !== 'workspace', + numbers: true, + maxItemsToShow: 15, + onSelect: (value) => { + if (value === '__add__') { + setNewRuleInput(''); + setRuleInputError(''); + setView('add-rule-input'); + return; + } + const found = filteredRules.find((r) => r.raw === value); + if (found) { + setDeleteTarget(found); + setView('delete-confirm'); + } + }, + }); + + const dirList = useDialogSelect({ + items: dirListItems, + focused: view === 'ws-dir-list' && activeTab.id === 'workspace', + numbers: true, + maxItemsToShow: 15, + onSelect: (value) => { + if (value === '__add_dir__') { + setNewDirInput(''); + setView('ws-add-dir-input'); + return; + } + if (!initialDirSet.has(value)) { + setRemoveDirTarget(value); + setView('ws-remove-confirm'); + } + }, + }); + + const scopeItems = getPermissionScopeItems().map((s) => ({ + ...s, + key: s.key, + value: s.value, + })); + const scopeList = useDialogSelect({ + items: scopeItems, + focused: view === 'add-rule-scope', + numbers: true, + onSelect: (scope) => { + onAddRule( + pendingRuleText, + activeTab.id as PermissionRuleEntry['type'], + scope, + ); + setPendingRuleText(''); + setView('rule-list'); + }, + }); + + const cycleTab = (direction: 1 | -1) => { + const newIndex = (activeTabIndex + direction + tabs.length) % tabs.length; + setActiveTabIndex(newIndex); + setSearchQuery(''); + const newTab = tabs[newIndex]!; + setView(newTab.id === 'workspace' ? 'ws-dir-list' : 'rule-list'); + }; + + useKeyboard((key) => { + const original = toOriginalKey(key); + const { name, ctrl } = original; + + if (view === 'rule-list') { + if (name === 'escape') { + if (searchQuery) setSearchQuery(''); + else onExit(); + return; + } + if (name === 'tab') { + cycleTab(1); + return; + } + if (name === 'right' || name === 'left') { + cycleTab(name === 'right' ? 1 : -1); + return; + } + if (name === 'backspace' || name === 'delete') { + if (searchQuery.length > 0) setSearchQuery((q) => q.slice(0, -1)); + return; + } + if ( + original.sequence && + !ctrl && + !original.meta && + original.sequence.length === 1 && + original.sequence >= ' ' + ) { + setSearchQuery((q) => q + original.sequence); + return; + } + } + if (view === 'add-rule-input') { + if (name === 'escape') { + setView('rule-list'); + return; + } + if (name === 'return') { + const trimmed = newRuleInput.trim(); + if (!trimmed) return; + const rule = parseRule(trimmed); + if (rule.invalid) { + setRuleInputError( + t( + 'Malformed rule: unbalanced parentheses. Use the format ToolName(specifier).', + ), + ); + return; + } + setRuleInputError(''); + setPendingRuleText(trimmed); + setView('add-rule-scope'); + return; + } + if (name === 'backspace') { + setNewRuleInput((v) => v.slice(0, -1)); + return; + } + if (!ctrl && original.sequence.length === 1 && original.sequence >= ' ') { + setNewRuleInput((v) => v + original.sequence); + setRuleInputError(''); + } + return; + } + if (view === 'add-rule-scope') { + if (name === 'escape') { + setView('add-rule-input'); + } + return; + } + if (view === 'delete-confirm') { + if (name === 'escape') { + setDeleteTarget(null); + setView('rule-list'); + return; + } + if (name === 'return' && deleteTarget) { + onDeleteRule(deleteTarget.raw, deleteTarget.type); + setDeleteTarget(null); + setView('rule-list'); + } + return; + } + if (view === 'ws-dir-list') { + if (name === 'escape') { + onExit(); + return; + } + if (name === 'tab') { + cycleTab(1); + return; + } + if (name === 'right' || name === 'left') { + cycleTab(name === 'right' ? 1 : -1); + } + return; + } + if (view === 'ws-add-dir-input') { + if (name === 'escape') { + setDirInputError(''); + setView('ws-dir-list'); + return; + } + if (name === 'return') { + // ink's handleAddDirSubmit returns early on empty input — the user + // stays in the form instead of silently dropping back to the list + // (validateWorkspaceDirectory's empty-input sentinel is falsy). + if (!newDirInput.trim()) return; + const result = validateWorkspaceDirectory(newDirInput, directories); + if (result.error) { + setDirInputError(result.error); + return; + } + if (result.resolved) onAddDirectory(result.resolved); + setDirInputError(''); + setNewDirInput(''); + setView('ws-dir-list'); + return; + } + if (name === 'backspace') { + setNewDirInput((v) => v.slice(0, -1)); + return; + } + if (!ctrl && original.sequence.length === 1 && original.sequence >= ' ') { + setNewDirInput((v) => v + original.sequence); + if (dirInputError) setDirInputError(''); + } + return; + } + if (view === 'ws-remove-confirm') { + if (name === 'escape') { + setRemoveDirTarget(null); + setView('ws-dir-list'); + return; + } + if (name === 'return' && removeDirTarget) { + onRemoveDirectory(removeDirTarget); + setRemoveDirTarget(null); + setView('ws-dir-list'); + } + } + }); + + const footerText = + view === 'rule-list' || view === 'ws-dir-list' + ? t( + 'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel', + ) + : ''; + + // --- Workspace sub-views --- + + if (activeTab.id === 'workspace' && view === 'ws-add-dir-input') { + return ( + + + {t('Add directory to workspace')} + + + + {t( + 'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.', + )} + + + {t('Enter the path to the directory:')} + + + {newDirInput || t('Enter directory path…')} + + + {dirInputError && {dirInputError}} + + + ); + } + + if ( + activeTab.id === 'workspace' && + view === 'ws-remove-confirm' && + removeDirTarget + ) { + return ( + + + + {t('Remove directory?')} + + + + + {removeDirTarget} + + + + + {t( + 'Are you sure you want to remove this directory from the workspace?', + )} + + + + {t('Enter to confirm · Esc to cancel')} + + + ); + } + + if (activeTab.id === 'workspace') { + return ( + + + + {t( + 'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.', + )} + + + {initialDirectories.map((dir, idx) => ( + + {'- '} + {dir} + + {idx === 0 + ? t(' (Original working directory)') + : t(' (from settings)')} + + + ))} + ( + {item.label} + )} + /> + {footerText ? : null} + + ); + } + + // --- Rule sub-views --- + + if (view === 'add-rule-input') { + return ( + + + + {t('Add {{type}} permission rule', { type: activeTab.id })} + + + + {t( + 'Permission rules are a tool name, optionally followed by a specifier in parentheses.', + )} + + + {t('e.g.,')} WebFetch {t('or')}{' '} + Bash(ls:*) + + + + + {newRuleInput || t('Enter permission rule…')} + + + {ruleInputError ? ( + + {ruleInputError} + + ) : null} + + + {t('Enter to submit · Esc to cancel')} + + + ); + } + + if (view === 'add-rule-scope') { + return ( + + + + {t('Add {{type}} permission rule', { type: activeTab.id })} + + + + + {pendingRuleText} + + {describePermissionRule(pendingRuleText)} + + + {t('Where should this rule be saved?')} + ( + + {item.label} {item.description} + + )} + /> + + + {t('Enter to confirm · Esc to cancel')} + + + ); + } + + if (view === 'delete-confirm' && deleteTarget) { + return ( + + + + {t('Delete {{type}} rule?', { type: deleteTarget.type })} + + + + + {deleteTarget.raw} + + {describePermissionRule(deleteTarget.raw)} + {permissionScopeLabel(deleteTarget.scope)} + + + + {t('Are you sure you want to delete this permission rule?')} + + + + {t('Enter to confirm · Esc to cancel')} + + + ); + } + + // --- Default: rule list view --- + + return ( + + + + {t('Permissions:')}{' '} + + {tabs.map((tab, i) => ( + + + {` ${tab.label} `} + + + ))} + {t('(←/→ or tab to cycle)')} + + + {activeTab.description} + + + {'> '} + {searchQuery ? ( + {searchQuery} + ) : ( + {t('Search…')} + )} + + + ( + {item.label} + )} + /> + + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-settings.test.ts b/packages/cli/src/ui/opentui/dialogs-settings.test.ts new file mode 100644 index 00000000000..db6d602e293 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-settings.test.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI settings dialog reproduces the original ink + * SettingsDialog data logic: tab labels, the schema-sourced settings list, + * the search filter, the toggle/cycle value transitions, and the inline + * edit buffer operations. + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); + +import { + buildSettingsListItems, + editBackspace, + editDelete, + editInsert, + editMoveCursor, + filterSettingsItems, + isSubDialogSetting, + nextToggleValue, + parseEditCommit, + SETTINGS_LIST_MAX_ITEMS, + settingsTabLabel, + SETTINGS_TAB_ORDER, + SUB_DIALOG_SETTING_KEYS, +} from './dialogs-settings.js'; + +describe('settingsTabLabel', () => { + it('labels the three original tabs', () => { + expect(settingsTabLabel('settings')).toBe('Settings'); + expect(settingsTabLabel('status')).toBe('Status'); + expect(settingsTabLabel('stats')).toBe('Stats'); + }); + + it('keeps the original tab order', () => { + expect([...SETTINGS_TAB_ORDER]).toEqual(['settings', 'status', 'stats']); + }); + + it('keeps the original list window height', () => { + expect(SETTINGS_LIST_MAX_ITEMS).toBe(8); + }); +}); + +describe('sub-dialog settings', () => { + it('matches the four rows that open a picker', () => { + expect([...SUB_DIALOG_SETTING_KEYS]).toEqual([ + 'ui.theme', + 'general.preferredEditor', + 'fastModel', + 'visionModel', + ]); + expect(isSubDialogSetting('ui.theme')).toBe(true); + expect(isSubDialogSetting('general.vimMode')).toBe(false); + }); +}); + +describe('buildSettingsListItems', () => { + it('sources rows from the settings schema, in dialog order', () => { + const items = buildSettingsListItems(); + expect(items.length).toBeGreaterThan(0); + const keys = items.map((item) => item.key); + expect(keys).toContain('ui.theme'); + // Labels are resolved from the schema definitions. + const themeItem = items.find((item) => item.key === 'ui.theme'); + expect(themeItem?.label).toBeTruthy(); + expect(themeItem?.type).toBeDefined(); + }); +}); + +describe('filterSettingsItems', () => { + const items = [ + { + key: 'general.vimMode', + label: 'Vim Mode', + description: 'Configure vim mode', + type: 'boolean' as const, + }, + { key: 'ui.theme', label: 'Theme', description: undefined }, + ]; + + it('returns everything for an empty query', () => { + expect(filterSettingsItems(items, '', () => '')).toHaveLength(2); + }); + + it('matches key, label, description, and scope message', () => { + expect(filterSettingsItems(items, 'vimmode', () => '')).toHaveLength(1); + expect(filterSettingsItems(items, 'VIM', () => '')).toHaveLength(1); + expect(filterSettingsItems(items, 'configure', () => '')).toHaveLength(1); + expect( + filterSettingsItems(items, 'workspace', () => 'workspace only'), + ).toHaveLength(2); + expect(filterSettingsItems(items, 'zzz', () => '')).toHaveLength(0); + }); +}); + +describe('nextToggleValue', () => { + it('flips booleans', () => { + expect(nextToggleValue({ type: 'boolean' }, true)).toBe(false); + expect(nextToggleValue({ type: 'boolean' }, false)).toBe(true); + }); + + it('cycles enums and loops back to the first option', () => { + const def = { + type: 'enum' as const, + options: [ + { value: 'a' as const }, + { value: 'b' as const }, + { value: 'c' as const }, + ], + }; + expect(nextToggleValue(def, 'a')).toBe('b'); + expect(nextToggleValue(def, 'c')).toBe('a'); + }); + + it('returns undefined for non-toggle types', () => { + expect(nextToggleValue({ type: 'string' }, 'x')).toBeUndefined(); + expect(nextToggleValue(undefined, true)).toBeUndefined(); + }); +}); + +describe('inline edit buffer', () => { + it('inserts at the cursor', () => { + const state = { buffer: 'ac', cursor: 1 }; + expect(editInsert(state, 'b')).toEqual({ buffer: 'abc', cursor: 2 }); + }); + + it('backspaces behind the cursor only', () => { + expect(editBackspace({ buffer: 'ab', cursor: 1 })).toEqual({ + buffer: 'b', + cursor: 0, + }); + expect(editBackspace({ buffer: 'ab', cursor: 0 })).toEqual({ + buffer: 'ab', + cursor: 0, + }); + }); + + it('deletes at the cursor only', () => { + expect(editDelete({ buffer: 'ab', cursor: 0 })).toEqual({ + buffer: 'b', + cursor: 0, + }); + expect(editDelete({ buffer: 'ab', cursor: 2 })).toEqual({ + buffer: 'ab', + cursor: 2, + }); + }); + + it('moves the cursor with bounds and counts graphemes', () => { + const emoji = { buffer: '😀x', cursor: 1 }; + expect(editMoveCursor(emoji, 'left')).toEqual({ + buffer: '😀x', + cursor: 0, + }); + expect(editMoveCursor(emoji, 'right')).toEqual({ + buffer: '😀x', + cursor: 2, + }); + expect(editMoveCursor(emoji, 'home').cursor).toBe(0); + expect(editMoveCursor(emoji, 'end').cursor).toBe(2); + expect(editMoveCursor({ buffer: 'a', cursor: 0 }, 'left').cursor).toBe(0); + }); +}); + +describe('parseEditCommit', () => { + it('commits outputLanguage trimmed, with empty meaning auto', () => { + expect(parseEditCommit('general.outputLanguage', 'string', ' zh ')).toBe( + 'zh', + ); + expect(parseEditCommit('general.outputLanguage', 'string', ' ')).toBe( + 'auto', + ); + }); + + it('keeps the raw buffer for other string keys (ink parity)', () => { + expect(parseEditCommit('general.telemetry', 'string', ' a ')).toBe(' a '); + }); + + it('parses numbers and cancels empty or NaN input', () => { + expect(parseEditCommit('general.maxInitEvents', 'number', ' 12 ')).toBe(12); + expect(parseEditCommit('general.maxInitEvents', 'number', '')).toBeNull(); + expect(parseEditCommit('general.maxInitEvents', 'number', 'x')).toBeNull(); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-settings.tsx b/packages/cli/src/ui/opentui/dialogs-settings.tsx new file mode 100644 index 00000000000..909ac0afc3f --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-settings.tsx @@ -0,0 +1,870 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink `/settings` dialog + * (ui/components/SettingsDialog.tsx): the Settings/Status/Stats top tab + * bar, the search box, the windowed settings list (toggle booleans, cycle + * enums, inline-edit numbers/strings, sub-dialog rows like ui.theme), the + * Tab scope-mode selector, description line, restart prompt, and every + * original key binding and footer string. Settings data and side effects + * reuse the framework-neutral utils/settingsUtils helpers, so the rows are + * sourced from the same schema as the ink dialog. + */ + +import { useEffect, useState } from 'react'; +import { useKeyboard } from '@opentui/react'; +import { C } from './theme.js'; +import { t } from '../../i18n/index.js'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings, Settings } from '../../config/settings.js'; +import { SettingScope } from '../../config/settings.js'; +import { + getScopeItems, + getScopeMessageForSetting, +} from '../../config/dialogScopeUtils.js'; +import { + getDialogSettingKeys, + getSettingDefinition, + getEffectiveValue, + setPendingSettingValueAny, + saveModifiedSettings, + getDisplayValue, + isDefaultValue, + requiresRestart, + getRestartRequiredFromModified, + getDefaultValue, + getNestedValue, + validateSettingValue, +} from '../../config/settingsUtils.js'; +import { + TOGGLE_TYPES, + type SettingsType, + type SettingsValue, +} from '../../config/settingsSchema.js'; +import { isAutoLanguage } from '../../i18n/languageUtils.js'; +import { + getExtendedSystemInfo, + type ExtendedSystemInfo, +} from '../systemInfo.js'; +import { getSystemInfoFields } from '../systemInfoFields.js'; +import { ICON } from '../constants.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { toOriginalKey } from './key-map.js'; +import { + DialogFrame, + DialogSelect, + FooterHint, + useDialogSelect, +} from './dialogs-shared.js'; +import { OpenTuiStatsDialog } from './dialogs-stats-skills.js'; + +export type SettingsTab = 'settings' | 'status' | 'stats'; + +export const SETTINGS_TAB_ORDER: readonly SettingsTab[] = [ + 'settings', + 'status', + 'stats', +]; + +export const SETTINGS_LIST_MAX_ITEMS = 8; + +/** Parity of configTabLabel in SettingsDialog.tsx. */ +export function settingsTabLabel(tab: SettingsTab): string { + switch (tab) { + case 'settings': + return t('Settings'); + case 'status': + return t('Status'); + case 'stats': + return t('Stats'); + default: + return tab; + } +} + +export const SUB_DIALOG_SETTING_KEYS = [ + 'ui.theme', + 'general.preferredEditor', + 'fastModel', + 'visionModel', +] as const; + +export function isSubDialogSetting(key: string): boolean { + return (SUB_DIALOG_SETTING_KEYS as readonly string[]).includes(key); +} + +export interface SettingsListItem { + key: string; + label: string; + description?: string; + type?: SettingsType; +} + +/** The settings rows, sourced from the same schema as the ink dialog. */ +export function buildSettingsListItems(options?: { + excludeWorkspaceRestricted?: boolean; +}): SettingsListItem[] { + return getDialogSettingKeys(options).map((key) => { + const definition = getSettingDefinition(key); + return { + key, + label: definition?.label ? t(definition.label) || definition.label : key, + description: definition?.description + ? t(definition.description) || definition.description + : undefined, + type: definition?.type, + }; + }); +} + +/** Parity of the settings-list search filter (label, key, desc, scope msg). */ +export function filterSettingsItems( + items: readonly SettingsListItem[], + query: string, + scopeMessageOf: (key: string) => string | undefined, +): SettingsListItem[] { + const normalized = query.trim().toLowerCase(); + if (!normalized) return [...items]; + return items.filter((item) => { + const scopeMsg = scopeMessageOf(item.key); + return ( + item.label.toLowerCase().includes(normalized) || + item.key.toLowerCase().includes(normalized) || + (item.description?.toLowerCase().includes(normalized) ?? false) || + (scopeMsg?.toLowerCase().includes(normalized) ?? false) + ); + }); +} + +/** + * Parity of the toggle action's value computation: booleans flip, enums + * advance to the next option and loop back to the first. + */ +export function nextToggleValue( + definition: + | { + type?: SettingsType; + options?: ReadonlyArray<{ value: SettingsValue }>; + } + | undefined, + currentValue: SettingsValue, +): SettingsValue | undefined { + if (!definition || !TOGGLE_TYPES.has(definition.type)) return undefined; + if (definition.type === 'boolean') { + return !(currentValue as boolean); + } + if (definition.type === 'enum' && definition.options) { + const options = definition.options; + const currentIndex = options.findIndex((opt) => opt.value === currentValue); + if (currentIndex !== -1 && currentIndex < options.length - 1) { + return options[currentIndex + 1].value; + } + return options[0].value; + } + return undefined; +} + +/** + * Parity of commitEdit's value parsing. Numbers must parse (empty or NaN + * cancels the edit, returned as null); outputLanguage commits the trimmed + * value with empty meaning 'auto'; other string keys keep the raw buffer. + */ +export function parseEditCommit( + key: string, + type: SettingsType | undefined, + buffer: string, +): string | number | null | undefined { + const trimmed = buffer.trim(); + if (type === 'number') { + if (trimmed === '') return null; + const numParsed = Number(trimmed); + return Number.isNaN(numParsed) ? null : numParsed; + } + if (key === 'general.outputLanguage') { + return trimmed === '' ? 'auto' : trimmed; + } + return buffer; +} + +export interface EditBufferState { + buffer: string; + cursor: number; +} + +/** Inline-edit buffer operations (grapheme-counted like the ink editor). */ +export function editInsert( + state: EditBufferState, + ch: string, +): EditBufferState { + const chars = [...state.buffer]; + chars.splice(state.cursor, 0, ch); + return { buffer: chars.join(''), cursor: state.cursor + 1 }; +} + +export function editBackspace(state: EditBufferState): EditBufferState { + if (state.cursor <= 0) return state; + const chars = [...state.buffer]; + chars.splice(state.cursor - 1, 1); + return { buffer: chars.join(''), cursor: state.cursor - 1 }; +} + +export function editDelete(state: EditBufferState): EditBufferState { + const chars = [...state.buffer]; + if (state.cursor >= chars.length) return state; + chars.splice(state.cursor, 1); + return { buffer: chars.join(''), cursor: state.cursor }; +} + +export function editMoveCursor( + state: EditBufferState, + movement: 'left' | 'right' | 'home' | 'end', +): EditBufferState { + const len = [...state.buffer].length; + switch (movement) { + case 'left': + return { ...state, cursor: Math.max(0, state.cursor - 1) }; + case 'right': + return { ...state, cursor: Math.min(len, state.cursor + 1) }; + case 'home': + return { ...state, cursor: 0 }; + case 'end': + return { ...state, cursor: len }; + default: + return state; + } +} + +export interface OpenTuiSettingsDialogProps { + settings: LoadedSettings; + onSelect: (settingName: string | undefined, scope: SettingScope) => void; + onRestartRequest?: () => void; + /** Backend seam for runtime side effects (vim sync, approval mode). */ + onSettingApplied?: (key: string, value: SettingsValue) => void; + config?: Config; + availableTerminalHeight?: number; +} + +export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) { + const { settings, onSelect, onRestartRequest, onSettingApplied, config } = + props; + + const [mode, setMode] = useState<'settings' | 'scope'>('settings'); + const [selectedScope, setSelectedScope] = useState( + SettingScope.User, + ); + const [activeSettingIndex, setActiveSettingIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [activeTab, setActiveTab] = useState('settings'); + const [focusZone, setFocusZone] = useState<'tabs' | 'search' | 'list'>( + 'list', + ); + const [searchQuery, setSearchQuery] = useState(''); + const [pendingSettings, setPendingSettings] = useState(() => + structuredClone(settings.forScope(SettingScope.User).settings), + ); + const [modifiedSettings, setModifiedSettings] = useState>( + new Set(), + ); + const [restartRequiredSettings, setRestartRequiredSettings] = useState< + Set + >(new Set()); + const [editingKey, setEditingKey] = useState(null); + const [edit, setEdit] = useState({ buffer: '', cursor: 0 }); + const [systemInfo, setSystemInfo] = useState(null); + const [statusError, setStatusError] = useState(false); + const [statusReloadNonce, setStatusReloadNonce] = useState(0); + + const showRestartPrompt = restartRequiredSettings.size > 0; + + // Rebase the pending snapshot on scope switches, mirroring the ink effect. + useEffect(() => { + setPendingSettings( + structuredClone(settings.forScope(selectedScope).settings), + ); + setModifiedSettings(new Set()); + }, [selectedScope, settings]); + + // Status tab data (same source as `/status`). + useEffect(() => { + if (activeTab !== 'status') { + setSystemInfo(null); + setStatusError(false); + return; + } + let cancelled = false; + setStatusError(false); + const ctx = { services: { config, settings } }; + getExtendedSystemInfo(ctx) + .then((info) => { + if (!cancelled) setSystemInfo(info); + }) + .catch(() => { + if (!cancelled) setStatusError(true); + }); + return () => { + cancelled = true; + }; + }, [activeTab, config, settings, statusReloadNonce]); + + // Keep the selection valid as the search query narrows the list (ink has + // the same [searchQuery] reset effect). + useEffect(() => { + setActiveSettingIndex(0); + setScrollOffset(0); + }, [searchQuery]); + + const allItems = buildSettingsListItems({ + excludeWorkspaceRestricted: selectedScope === SettingScope.Workspace, + }); + const items = filterSettingsItems(allItems, searchQuery, (key) => + getScopeMessageForSetting(key, selectedScope, settings), + ); + + const maxItemsToShow = SETTINGS_LIST_MAX_ITEMS; + const visibleItems = items.slice(scrollOffset, scrollOffset + maxItemsToShow); + const showScrollUp = scrollOffset > 0; + const showScrollDown = scrollOffset + maxItemsToShow < items.length; + + const applySettingValue = (key: string, value: SettingsValue) => { + setPendingSettings((prev) => setPendingSettingValueAny(key, value, prev)); + if (!requiresRestart(key)) { + saveModifiedSettings( + new Set([key]), + setPendingSettingValueAny(key, value, {} as Settings), + settings, + selectedScope, + ); + onSettingApplied?.(key, value); + setModifiedSettings((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + setRestartRequiredSettings((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + } else { + saveModifiedSettings( + new Set([key]), + setPendingSettingValueAny(key, value, {} as Settings), + settings, + selectedScope, + ); + setRestartRequiredSettings((prev) => new Set(prev).add(key)); + } + }; + + const toggleCurrent = (key: string) => { + const definition = getSettingDefinition(key); + const currentValue = getEffectiveValue(key, pendingSettings, {}); + const newValue = nextToggleValue(definition, currentValue); + if (newValue === undefined) return; + applySettingValue(key, newValue); + }; + + const startEditing = (key: string, initial?: string) => { + setEditingKey(key); + const initialValue = initial ?? ''; + setEdit({ buffer: initialValue, cursor: [...initialValue].length }); + }; + + const commitEdit = (key: string) => { + const definition = getSettingDefinition(key); + const parsed = parseEditCommit(key, definition?.type, edit.buffer); + if (parsed === null) { + setEditingKey(null); + setEdit({ buffer: '', cursor: 0 }); + return; + } + if (definition && validateSettingValue(definition, parsed)) { + setEditingKey(null); + setEdit({ buffer: '', cursor: 0 }); + return; + } + if (parsed !== undefined) applySettingValue(key, parsed); + setEditingKey(null); + setEdit({ buffer: '', cursor: 0 }); + }; + + const resetCurrentToDefault = (key: string) => { + const currentSetting = items[activeSettingIndex]; + if (!currentSetting || currentSetting.key !== key) return; + const defaultValue = getDefaultValue(key); + applySettingValue(key, defaultValue); + setModifiedSettings((prev) => { + const updated = new Set(prev); + updated.delete(key); + return updated; + }); + }; + + const applyRestart = () => { + const restartRequiredSet = new Set( + getRestartRequiredFromModified(modifiedSettings), + ); + if (restartRequiredSet.size > 0) { + saveModifiedSettings( + restartRequiredSet, + pendingSettings, + settings, + selectedScope, + ); + } + setRestartRequiredSettings(new Set()); + if (onRestartRequest) onRestartRequest(); + }; + + const scopeItems = getScopeItems().map((item) => ({ + label: t(item.label), + key: item.value, + value: item.value, + })); + const initialScopeIndex = scopeItems.findIndex( + (item) => item.value === selectedScope, + ); + const scopeList = useDialogSelect({ + items: scopeItems, + initialIndex: initialScopeIndex >= 0 ? initialScopeIndex : 0, + focused: activeTab === 'settings' && mode === 'scope', + onSelect: (scope) => { + setSelectedScope(scope); + setMode('settings'); + }, + onHighlight: (scope) => setSelectedScope(scope), + }); + + useKeyboard((key) => { + const original = toOriginalKey(key); + const { name, ctrl } = original; + + const cycleTab = (direction: 1 | -1) => { + setActiveTab((current) => { + const index = SETTINGS_TAB_ORDER.indexOf(current); + const next = + (index + direction + SETTINGS_TAB_ORDER.length) % + SETTINGS_TAB_ORDER.length; + return SETTINGS_TAB_ORDER[next]; + }); + }; + + // Status-tab retry affordance works from any focus zone. + if (activeTab === 'status' && statusError && name === 'r') { + setStatusError(false); + setStatusReloadNonce((n) => n + 1); + return; + } + + if (focusZone === 'tabs') { + if (name === 'left' || (name === 'tab' && original.shift)) cycleTab(-1); + else if (name === 'right' || (name === 'tab' && !original.shift)) + cycleTab(1); + else if (name === 'down' || name === 'return') { + setFocusZone(activeTab === 'settings' ? 'search' : 'list'); + } else if (name === 'escape') { + onSelect(undefined, selectedScope); + } + return; + } + + if (activeTab !== 'settings') { + if (name === 'up') { + setFocusZone('tabs'); + return; + } + // The Stats tab embeds OpenTuiStatsDialog whose own handlers drive + // Tab and Esc (Esc defocuses to the tab bar via onClose) — don't + // double-handle them here. + if (activeTab === 'stats') return; + if (name === 'escape') onSelect(undefined, selectedScope); + return; + } + + if (activeTab === 'settings' && mode === 'scope') { + if (name === 'escape') { + setMode('settings'); + return; + } + if (name === 'tab') { + setMode('settings'); + return; + } + // List keys (↑/↓/Enter/digits) handled by the scope useDialogSelect. + return; + } + + if (focusZone === 'search') { + if (name === 'up') { + setFocusZone('tabs'); + } else if (name === 'down' || name === 'return') { + setFocusZone('list'); + } else if (name === 'tab') { + setMode('scope'); + setFocusZone('list'); + } else if (name === 'escape') { + if (searchQuery) setSearchQuery(''); + else onSelect(undefined, selectedScope); + } else if (name === 'backspace' || name === 'delete') { + setSearchQuery((q) => [...q].slice(0, -1).join('')); + } else if ( + !ctrl && + original.sequence.length === 1 && + original.sequence >= ' ' + ) { + setSearchQuery((q) => q + original.sequence); + } + return; + } + + // Settings tab, list focused. Tab toggles the scope selector (ink + // parity: setMode from the previous value — a fixed 'settings' would be + // a no-op, since mode is always 'settings' in this branch). + if (name === 'tab') { + setMode((prev) => (prev === 'settings' ? 'scope' : 'settings')); + return; + } + if (editingKey) { + if (name === 'backspace') { + setEdit((s) => editBackspace(s)); + return; + } + if (name === 'delete') { + setEdit((s) => editDelete(s)); + return; + } + if (name === 'escape' || name === 'return') { + commitEdit(editingKey); + return; + } + if (name === 'left') { + setEdit((s) => editMoveCursor(s, 'left')); + return; + } + if (name === 'right') { + setEdit((s) => editMoveCursor(s, 'right')); + return; + } + if (name === 'home') { + setEdit((s) => editMoveCursor(s, 'home')); + return; + } + if (name === 'end') { + setEdit((s) => editMoveCursor(s, 'end')); + return; + } + const definition = getSettingDefinition(editingKey); + const ch = original.sequence; + let isValidChar = false; + if (definition?.type === 'number') { + isValidChar = /^[0-9\-+.]$/.test(ch); + } else { + isValidChar = ch.length === 1 && ch >= ' ' && !ctrl; + } + if (isValidChar) { + setEdit((s) => editInsert(s, ch)); + } + return; + } + if (keyMatchers[Command.SELECTION_UP](original)) { + if (activeSettingIndex === 0) { + setFocusZone('search'); + setScrollOffset(0); + } else { + const newIndex = activeSettingIndex - 1; + setActiveSettingIndex(newIndex); + if (newIndex < scrollOffset) setScrollOffset(newIndex); + } + } else if (keyMatchers[Command.SELECTION_DOWN](original)) { + const newIndex = + activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0; + setActiveSettingIndex(newIndex); + if (newIndex === 0) setScrollOffset(0); + else if (newIndex >= scrollOffset + maxItemsToShow) + setScrollOffset(newIndex - maxItemsToShow + 1); + } else if (name === 'return' || name === 'space') { + const currentItem = items[activeSettingIndex]; + if (!currentItem) return; + if (isSubDialogSetting(currentItem.key)) { + if (name === 'return') onSelect(currentItem.key, selectedScope); + return; + } + if (currentItem.type === 'number' || currentItem.type === 'string') { + startEditing(currentItem.key); + } else { + toggleCurrent(currentItem.key); + } + } else if (name === 'right') { + const currentItem = items[activeSettingIndex]; + if (currentItem && isSubDialogSetting(currentItem.key)) { + onSelect(currentItem.key, selectedScope); + } + } else if (/^[0-9]$/.test(original.sequence)) { + const currentItem = items[activeSettingIndex]; + if (currentItem?.type === 'number') { + startEditing(currentItem.key, original.sequence); + } else { + setFocusZone('search'); + setSearchQuery((q) => q + original.sequence); + } + } else if (ctrl && (name === 'c' || name === 'l')) { + const currentItem = items[activeSettingIndex]; + if (currentItem) resetCurrentToDefault(currentItem.key); + } else if (showRestartPrompt && name === 'r') { + applyRestart(); + return; + } else if ( + !ctrl && + original.sequence.length === 1 && + original.sequence >= ' ' + ) { + setFocusZone('search'); + setSearchQuery((q) => q + original.sequence); + } + + if (name === 'escape') { + if (searchQuery) setSearchQuery(''); + else onSelect(undefined, selectedScope); + } + }); + + const activeDescription = + activeTab === 'settings' && + mode === 'settings' && + focusZone === 'list' && + items[activeSettingIndex]?.description; + + return ( + + + {SETTINGS_TAB_ORDER.map((tab) => { + const isActive = tab === activeTab; + return ( + + + {` ${settingsTabLabel(tab)} `} + + + ); + })} + + {' '} + {focusZone === 'tabs' + ? t('(←/→ to switch, ↓ to return)') + : t('(↑ to switch tabs)')} + + + + + {activeTab === 'status' ? ( + systemInfo ? ( + + + {t('Status')} + + {getSystemInfoFields(systemInfo).map((field) => ( + + + + {field.label} + + + {field.value} + + ))} + + ) : statusError ? ( + + {t('Failed to load status. Press r to retry.')} + + ) : ( + {t('Loading status…')} + ) + ) : activeTab === 'stats' ? ( + setFocusZone('tabs')} + /> + ) : mode === 'scope' ? ( + + + {'> '} + {t('Apply To')} + + + + scopeList.setActiveIndex( + scopeList.activeIndex + (direction === 'down' ? 1 : -1), + ) + } + renderLabel={(item, { titleColor }) => ( + {item.label} + )} + /> + + ) : ( + + + + {searchQuery ? ( + {searchQuery} + ) : ( + {t('Search settings…')} + )} + + + {showScrollUp && } + {items.length === 0 && ( + {t('No settings match your search.')} + )} + {visibleItems.map((item, idx) => { + const itemIndex = scrollOffset + idx; + const isActive = + focusZone === 'list' && activeSettingIndex === itemIndex; + const isEditing = editingKey === item.key; + + let displayValue: string; + if (isEditing) { + displayValue = edit.buffer; + } else if (item.type === 'number' || item.type === 'string') { + const path = item.key.split('.'); + const currentValue = getNestedValue(pendingSettings, path); + const defaultValue = getDefaultValue(item.key); + const effectiveCurrentValue = + currentValue !== undefined && currentValue !== null + ? currentValue + : defaultValue; + if ( + item.key === 'general.outputLanguage' && + isAutoLanguage( + effectiveCurrentValue as string | null | undefined, + ) + ) { + displayValue = t('Auto (follow user input)'); + } else if ( + effectiveCurrentValue !== undefined && + effectiveCurrentValue !== null + ) { + displayValue = String(effectiveCurrentValue); + } else { + displayValue = ''; + } + const isModified = modifiedSettings.has(item.key); + if (isModified || effectiveCurrentValue !== defaultValue) { + displayValue += '*'; + } + if (isSubDialogSetting(item.key)) { + displayValue = displayValue ? `${displayValue} ▸` : '▸'; + } + } else { + displayValue = getDisplayValue( + item.key, + settings.forScope(selectedScope).settings, + settings.merged, + modifiedSettings, + pendingSettings, + ); + } + const greyedOut = isDefaultValue( + item.key, + settings.forScope(selectedScope).settings, + ); + const scopeMessage = getScopeMessageForSetting( + item.key, + selectedScope, + settings, + ); + + return ( + { + setActiveSettingIndex(itemIndex); + setFocusZone('list'); + }} + > + + + {isActive ? ICON.CIRCLE_FILLED : ''} + + + + + {item.label} + {scopeMessage ? ( + {scopeMessage} + ) : null} + + + + + {displayValue} + + + + ); + })} + {showScrollDown && } + + )} + + {activeDescription && mode === 'settings' && activeTab === 'settings' ? ( + + {activeDescription} + + ) : null} + + {activeTab === 'settings' && ( + + )} + {showRestartPrompt && + activeTab === 'settings' && + mode === 'settings' && + focusZone === 'list' && ( + + {t( + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.', + )} + + )} + + ); +} diff --git a/packages/cli/src/ui/opentui/dialogs-shared.test.tsx b/packages/cli/src/ui/opentui/dialogs-shared.test.tsx index 22466ecd7c7..d9365e3a060 100644 --- a/packages/cli/src/ui/opentui/dialogs-shared.test.tsx +++ b/packages/cli/src/ui/opentui/dialogs-shared.test.tsx @@ -155,4 +155,83 @@ describe('useDialogSelect resyncKey', () => { rerender({ resyncKey: 'scope-select', initialIndex: 1 }); expect(result.current.activeIndex).toBe(2); }); + + it('disarms an armed numeric flush on view swap (R4-3)', () => { + vi.useFakeTimers(); + try { + const onSelect = vi.fn(); + const { rerender } = renderHook( + (props: { resyncKey: string }) => + useDialogSelect({ items, numbers: true, onSelect, ...props }), + { initialProps: { resyncKey: 'mount' } }, + ); + // Arm a digit flush in the first view. + press({ name: '1', sequence: '1' }); + // Tab to another view before the flush timeout fires. + rerender({ resyncKey: 'scope-select' }); + act(() => { + vi.advanceTimersByTime(NUMBER_SELECT_TIMEOUT_MS + 10); + }); + // The stale flush must not commit a selection in the new view. + expect(onSelect).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('useDialogSelect items re-sync (ink INITIALIZE parity)', () => { + beforeEach(() => { + handlers.length = 0; + }); + + it('clamps the cursor when the list shrinks below activeIndex', () => { + const onSelect = vi.fn(); + const shrinking = items.slice(0, 3); + const { result, rerender } = renderHook( + (props: { items: typeof items }) => + useDialogSelect({ items: props.items, numbers: false, onSelect }), + { initialProps: { items: shrinking } }, + ); + press({ name: 'down' }); + press({ name: 'down' }); + expect(result.current.activeIndex).toBe(2); + + // Uninstalling the last row: the cursor's item key is gone, ink falls + // back to the initial index instead of stranding it past the end. + rerender({ items: items.slice(0, 2) }); + expect(result.current.activeIndex).toBe(0); + // Enter on the clamped cursor still selects a real row. + press({ name: 'return' }); + expect(onSelect).toHaveBeenCalledWith('item-0'); + }); + + it('follows the active item by key when the list is reordered', () => { + const onSelect = vi.fn(); + const { result, rerender } = renderHook( + (props: { items: typeof items }) => + useDialogSelect({ items: props.items, numbers: false, onSelect }), + { initialProps: { items: items.slice(0, 3) } }, + ); + press({ name: 'down' }); + expect(result.current.activeIndex).toBe(1); + + // item-1 moves to the front; the cursor follows the item, not the slot. + rerender({ items: [items[1]!, items[0]!, items[2]!] }); + expect(result.current.activeIndex).toBe(0); + }); + + it('keeps the slot when a new array has the same key at the same index', () => { + const onSelect = vi.fn(); + const { result, rerender } = renderHook( + (props: { items: typeof items }) => + useDialogSelect({ items: props.items, numbers: false, onSelect }), + { initialProps: { items: items.slice(0, 3) } }, + ); + press({ name: 'down' }); + expect(result.current.activeIndex).toBe(1); + + rerender({ items: [...items.slice(0, 3)] }); + expect(result.current.activeIndex).toBe(1); + }); }); diff --git a/packages/cli/src/ui/opentui/dialogs-shared.tsx b/packages/cli/src/ui/opentui/dialogs-shared.tsx index 316d5717801..b442c9da597 100644 --- a/packages/cli/src/ui/opentui/dialogs-shared.tsx +++ b/packages/cli/src/ui/opentui/dialogs-shared.tsx @@ -184,6 +184,11 @@ export function useDialogSelect>( const numberBuffer = useRef(''); const numberTimer = useRef | null>(null); + // Last items array this hook synced its cursor against (see the items + // re-sync below). Declared before the resync block because a view swap + // must count as a sync too. + const itemsRef = useRef(items); + // Resync during render when the key changes (React's adjust-state-during- // render pattern): consumers that swap views over one mounted hook get // the fresh initialIndex instead of the mount-time snapshot. @@ -197,6 +202,9 @@ export function useDialogSelect>( numberTimer.current = null; } numberBuffer.current = ''; + // The swapped-in items are already accounted for by this reset; the + // key-follow below must not override it with the previous view's key. + itemsRef.current = items; const next = computeInitialActiveIndex(initialIndex, items); setActiveIndexState(next); setScrollOffset( @@ -210,6 +218,32 @@ export function useDialogSelect>( const latestRef = useRef({ items, activeIndex, onSelect }); latestRef.current = { items, activeIndex, onSelect }; + // Ink parity: useSelectionList re-runs its INITIALIZE reducer on every + // items change — the cursor follows the active item's key when it + // survives the change and falls back to the initial index otherwise, so + // a shrinking list (uninstalling the last extension) never strands the + // cursor beyond the end where Enter would read items[activeIndex] === + // undefined. + if (itemsRef.current !== items) { + const prevItems = itemsRef.current; + itemsRef.current = items; + const prevKey = prevItems[activeIndex]?.key; + const followed = + prevKey === undefined + ? -1 + : items.findIndex((item) => item.key === prevKey); + if (followed !== activeIndex) { + const next = + followed >= 0 + ? followed + : computeInitialActiveIndex(initialIndex, items); + setActiveIndexState(next); + setScrollOffset( + getSelectionScrollOffset(next, items.length, maxItemsToShow), + ); + } + } + useEffect( () => () => { if (numberTimer.current) clearTimeout(numberTimer.current); diff --git a/packages/cli/src/ui/opentui/dialogs-stats-skills.tsx b/packages/cli/src/ui/opentui/dialogs-stats-skills.tsx new file mode 100644 index 00000000000..471b1a9ecde --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-stats-skills.tsx @@ -0,0 +1,458 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI-native Stats and Skills dialogs (parity follow-up to #8677). + * The Stats dialog is a faithful port of the ink `StatsDialog` Session tab + * (ui/components/StatsSessionTab.tsx) driven by the real + * `uiTelemetryService` metrics + `computeSessionStats`, so `/stats` shows the + * same numbers/sections as the original. Tab/shift+tab switch, Esc closes. + */ + +import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react'; +import { useRenderer, useKeyboard } from '@opentui/react'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { uiTelemetryService } from '@qwen-code/qwen-code-core'; +import { computeSessionStats } from '../utils/computeStats.js'; +import { formatDuration } from '../utils/formatters.js'; +import { + getStatusColor, + TOOL_SUCCESS_RATE_HIGH, + TOOL_SUCCESS_RATE_MEDIUM, +} from '../utils/displayUtils.js'; +import { fmtTokens, getSeriesColors } from '../components/stats-helpers.js'; +import { ICON } from '../constants.js'; +import { toOriginalKey } from './key-map.js'; +import { C } from './theme.js'; + +/** Close the dialog on a raw Escape, like the other dialog hosts. */ +function useEscToClose(onClose: () => void, enabled: boolean) { + const renderer = useRenderer(); + useLayoutEffect(() => { + if (!enabled) return; + const onRawInput = (sequence: string): boolean => { + if (sequence !== '\x1b') return false; + onClose(); + return true; + }; + renderer.addInputHandler(onRawInput); + return () => renderer.removeInputHandler(onRawInput); + }, [renderer, onClose, enabled]); + // Fallback: also close via the parsed-key path in case the lone-ESC raw + // sequence is swallowed by the input parser. + useKeyboard((key) => { + if (!enabled) return; + if (toOriginalKey(key).name === 'escape') onClose(); + }); +} + +const LABEL_W = 28; + +const Row = ({ label, children }: { label: string; children?: ReactNode }) => ( + + + {label} + + + {children} + + +); + +const SubRow = ({ + label, + children, +}: { + label: string; + children?: ReactNode; +}) => ( + + + {`» ${label}`} + + + {children} + + +); + +const SectionTitle = ({ children }: { children?: ReactNode }) => ( + + + {children} + + +); + +type StatsTabName = 'session' | 'activity' | 'efficiency'; +const TABS: Array<{ name: StatsTabName; label: string }> = [ + { name: 'session', label: 'Session' }, + { name: 'activity', label: 'Activity' }, + { name: 'efficiency', label: 'Efficiency' }, +]; + +export function OpenTuiStatsDialog(props: { + config: Config | null | undefined; + onClose: () => void; + /** Embedded hosts pass false while their own focus zone owns the keys. */ + isFocused?: boolean; +}) { + const { config, onClose, isFocused = true } = props; + const [tab, setTab] = useState('session'); + // Re-render on every telemetry update so stats stay live while the dialog + // is open (ink re-renders via SessionStatsProvider's update event). + const [, forceUpdate] = useState(0); + useEffect(() => { + const handler = () => forceUpdate((n) => n + 1); + uiTelemetryService.on('update', handler); + return () => { + uiTelemetryService.off('update', handler); + }; + }, []); + useEscToClose(onClose, isFocused); + useKeyboard((key) => { + if (!isFocused) return; + const original = toOriginalKey(key); + if (original.name === 'tab') { + const order = TABS.map((t) => t.name); + const idx = order.indexOf(tab); + setTab( + order[(idx + (original.shift ? -1 : 1) + order.length) % order.length], + ); + } + }); + + const sessionId = config?.getSessionId?.(); + const metrics = sessionId + ? uiTelemetryService.getMetricsForSession(sessionId) + : uiTelemetryService.getMetrics(); + const computed = computeSessionStats(metrics); + const wallDuration = + Date.now() - uiTelemetryService.getSessionStartTime().getTime(); + + let totalInput = 0; + let totalOutput = 0; + let totalCached = 0; + for (const m of Object.values(metrics.models)) { + totalInput += m.tokens.prompt; + totalOutput += m.tokens.candidates; + totalCached += m.tokens.cached; + } + const cacheRate = totalInput > 0 ? (totalCached / totalInput) * 100 : 0; + const generation = metrics.generation; + const lastGeneration = generation?.last; + const lastTps = + lastGeneration && lastGeneration.generationDurationMs > 0 + ? lastGeneration.outputTokens / + (lastGeneration.generationDurationMs / 1000) + : undefined; + const averageTtft = + generation && generation.timedRequests > 0 + ? generation.totalTtftMs / generation.timedRequests + : undefined; + const sessionTps = + generation && generation.totalGenerationDurationMs > 0 + ? generation.totalThroughputOutputTokens / + (generation.totalGenerationDurationMs / 1000) + : undefined; + + const successColor = getStatusColor(computed.successRate, { + green: TOOL_SUCCESS_RATE_HIGH, + yellow: TOOL_SUCCESS_RATE_MEDIUM, + }); + const SERIES_COLORS = getSeriesColors(); + + return ( + + {/* Tab bar */} + + {TABS.map((t) => { + const active = t.name === tab; + return ( + + + {` ${t.label} `} + + + ); + })} + + + + {tab !== 'session' ? ( + + + {tab === 'activity' + ? 'Activity (this session)' + : 'Efficiency (this session)'} + + + + {Object.values(metrics.models) + .reduce((s, m) => s + m.api.totalRequests, 0) + .toLocaleString()} + + + + {totalInput.toLocaleString()} + + + {totalOutput.toLocaleString()} + + {totalCached > 0 && ( + + + {`${totalCached.toLocaleString()} (${cacheRate.toFixed(1)}%)`} + + + )} + Models + {Object.entries(metrics.models).map(([name, m], i) => ( + + + {`${ICON.CIRCLE_FILLED} `} + + {`${name} `} + + {`${m.api.totalRequests} reqs · in=${fmtTokens(m.tokens.prompt)} · out=${fmtTokens(m.tokens.candidates)}`} + + + ))} + + ) : ( + + + {sessionId ?? 'n/a'} + + + Interaction Summary + + + {`${metrics.tools.totalCalls} ( `} + {`✓ ${metrics.tools.totalSuccess}`} + + {`✗ ${metrics.tools.totalFail}`} + {' )'} + + + + {`${computed.successRate.toFixed(1)}%`} + + {(metrics.files.totalLinesAdded > 0 || + metrics.files.totalLinesRemoved > 0) && ( + + + {`+${metrics.files.totalLinesAdded}`} + + {`-${metrics.files.totalLinesRemoved}`} + + + )} + + Performance + + {formatDuration(wallDuration)} + + + {formatDuration(computed.agentActiveTime)} + + + + {formatDuration(computed.totalApiTime)} + {` (${computed.apiTimePercent.toFixed(1)}%)`} + + + + + {formatDuration(computed.totalToolTime)} + {` (${computed.toolTimePercent.toFixed(1)}%)`} + + + + {lastGeneration && ( + + + {`Generation Metrics (Latest Request)`} + + + {lastGeneration.model} + + + {formatDuration(lastGeneration.ttftMs)} + + + + {formatDuration(lastGeneration.generationDurationMs)} + + + + + {lastGeneration.outputTokens.toLocaleString()} + + + + + {lastTps === undefined ? '—' : `${lastTps.toFixed(1)} tok/s`} + + + + {generation?.timedRequests} + + + + {averageTtft === undefined + ? '—' + : formatDuration(averageTtft)} + + + + + {sessionTps === undefined + ? '—' + : `${sessionTps.toFixed(1)} tok/s`} + + + + )} + + Tokens + + {totalInput.toLocaleString()} + + + {totalOutput.toLocaleString()} + + {totalCached > 0 && ( + + + {`${totalCached.toLocaleString()} (${cacheRate.toFixed(1)}%)`} + + + )} + + {Object.keys(metrics.models).length > 0 && ( + + Models + {Object.entries(metrics.models).map(([name, m], i) => ( + + + {`${ICON.CIRCLE_FILLED} `} + + {`${name} `} + + {`${m.api.totalRequests} reqs · in=${fmtTokens(m.tokens.prompt)} · out=${fmtTokens(m.tokens.candidates)}`} + + + ))} + + )} + + )} + + + {'tab · esc'} + + + ); +} + +interface SkillRow { + name: string; + description: string; +} + +export function OpenTuiSkillsDialog(props: { + config: Config | null | undefined; + onClose: () => void; +}) { + const { config, onClose } = props; + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + useEscToClose(onClose, true); + useEffect(() => { + let alive = true; + const mgr = config?.getSkillManager?.(); + if (!mgr) { + setLoading(false); + return; + } + mgr + .listSkills() + .then((skills) => { + if (!alive) return; + setRows( + (skills as Array<{ name: string; description?: string }>).map( + (s) => ({ name: s.name, description: s.description ?? '' }), + ), + ); + setLoading(false); + }) + .catch(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [config]); + + return ( + + + + {'Skills'} + + {'esc to close'} + + + {loading ? ( + {'loading skills…'} + ) : rows.length === 0 ? ( + {'no skills available'} + ) : ( + rows.map((r) => ( + + + {r.name} + + {` ${r.description}`} + + )) + )} + + + ); +} diff --git a/packages/cli/src/ui/opentui/folder-trust-gate.test.tsx b/packages/cli/src/ui/opentui/folder-trust-gate.test.tsx new file mode 100644 index 00000000000..7a89ae6d109 --- /dev/null +++ b/packages/cli/src/ui/opentui/folder-trust-gate.test.tsx @@ -0,0 +1,289 @@ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Component wiring tests for the folder-trust startup gate (#56). Same + * fake-hook harness as dialogs-auth.test.tsx (the native renderer is + * exercised by the PTY gate); the tests cover what the gate guarantees + * against the ink useFolderTrust + FolderTrustDialog pair: + * + * - the gate only opens for an undecided workspace and renders the three + * trust options with the cwd-derived labels; + * - Enter / digits select the highlighted option, persist it through + * loadTrustedFolders().setValue, and close the gate without a restart + * (a first run already assumes trusted); + * - Esc selects DO_NOT_TRUST, which flips the trust state and drives the + * 250ms relaunch flow, ignoring further keys while restarting. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import type { LoadedSettings } from '../../config/settings.js'; + +const mocks = vi.hoisted(() => { + const state = { + inputHandlers: [] as Array<(sequence: string) => boolean>, + keyboardHandlers: [] as Array<(key: unknown) => void>, + }; + const renderer = { + addInputHandler(handler: (sequence: string) => boolean) { + state.inputHandlers.push(handler); + }, + removeInputHandler(handler: (sequence: string) => boolean) { + const index = state.inputHandlers.indexOf(handler); + if (index >= 0) state.inputHandlers.splice(index, 1); + }, + }; + async function buildJsxRuntime() { + const React = await import('react'); + const jsx = ( + type: unknown, + props: { children?: unknown; key?: React.Key } | null, + key?: React.Key, + ) => { + const config = key === undefined ? props : { ...props, key }; + const children = (config?.children ?? null) as React.ReactNode; + if (type === 'box' || type === 'text') { + return React.createElement( + type === 'box' ? 'div' : 'span', + key === undefined ? null : { key }, + children, + ); + } + return React.createElement( + type as React.ElementType, + config as Record, + children, + ); + }; + return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment }; + } + return { state, renderer, buildJsxRuntime }; +}); + +const trust = vi.hoisted(() => ({ + isWorkspaceTrusted: vi.fn(), + setValue: vi.fn(), + relaunchApp: vi.fn(), +})); + +vi.mock('@opentui/react', () => ({ + useKeyboard: (handler: (key: unknown) => void) => { + mocks.state.keyboardHandlers.push(handler); + }, + useRenderer: () => mocks.renderer, +})); + +vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); +vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); +// dialogs-shared imports MouseButton from the native core; stub the FFI +// surface like dialogs-misc.test.tsx does. +vi.mock('@opentui/core', () => ({ + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); +vi.mock('./theme.js', () => ({ + C: new Proxy({}, { get: () => '#ffffff' }), +})); +vi.mock('../../config/trustedFolders.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isWorkspaceTrusted: trust.isWorkspaceTrusted, + loadTrustedFolders: () => ({ setValue: trust.setValue }), + }; +}); +vi.mock('../../utils/processUtils.js', () => ({ + relaunchApp: trust.relaunchApp, +})); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStderrLineSafe: vi.fn(), +})); +vi.mock('node:process', async () => { + const actual = + await vi.importActual('node:process'); + return { + ...actual, + cwd: () => '/home/user/project', + }; +}); + +import { TrustLevel } from '../../config/trustedFolders.js'; +import { OpenTuiFolderTrustGate } from './folder-trust-gate.js'; + +function baseKeyEvent(overrides: Record = {}) { + return { + name: 'a', + sequence: 'a', + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + hyper: false, + eventType: 'press', + preventDefault: () => {}, + stopPropagation: () => {}, + ...overrides, + }; +} + +function lastKeyboardHandler(): (key: unknown) => void { + const handler = mocks.state.keyboardHandlers.at(-1); + if (!handler) throw new Error('no keyboard handler registered'); + return handler; +} + +async function press(name: string): Promise { + const handler = lastKeyboardHandler(); + await act(async () => { + handler(baseKeyEvent({ name, sequence: name })); + }); +} + +async function pressEsc(): Promise { + const handler = mocks.state.inputHandlers.at(-1); + if (!handler) throw new Error('no raw input handler registered'); + let consumed = false; + await act(async () => { + consumed = handler('\x1b'); + }); + return consumed; +} + +function createMockSettings(): LoadedSettings { + return { + merged: { security: { folderTrust: { enabled: true } } }, + forScope: () => ({ settings: {}, path: '', originalSettings: {} }), + } as unknown as LoadedSettings; +} + +async function renderGate( + trustResult: boolean | undefined, +): Promise> { + trust.isWorkspaceTrusted.mockReturnValueOnce({ isTrusted: trustResult }); + const onOpenChange = vi.fn(); + render( + , + ); + // The mount effect decides the gate state asynchronously of render(). + await act(async () => {}); + return onOpenChange; +} + +describe('OpenTuiFolderTrustGate (#56 startup gate)', () => { + beforeEach(() => { + mocks.state.inputHandlers.length = 0; + mocks.state.keyboardHandlers.length = 0; + trust.isWorkspaceTrusted.mockReset(); + trust.setValue.mockReset(); + trust.relaunchApp.mockReset(); + }); + + it('renders the three trust options for an undecided workspace', async () => { + const onOpenChange = await renderGate(undefined); + expect(onOpenChange).toHaveBeenCalledWith(true); + expect(screen.getByText('Do you trust this folder?')).toBeTruthy(); + expect(screen.getByText('Trust folder (project)')).toBeTruthy(); + expect(screen.getByText('Trust parent folder (user)')).toBeTruthy(); + expect(screen.getByText("Don't trust (esc)")).toBeTruthy(); + }); + + it('stays closed for a trusted workspace', async () => { + const onOpenChange = await renderGate(true); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.queryByText('Do you trust this folder?')).toBeNull(); + }); + + it('Enter persists the highlighted option and closes without restart', async () => { + const onOpenChange = await renderGate(undefined); + await press('return'); + expect(trust.setValue).toHaveBeenCalledWith( + '/home/user/project', + TrustLevel.TRUST_FOLDER, + ); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(trust.relaunchApp).not.toHaveBeenCalled(); + expect( + screen.queryByText(/restarting to apply the trust changes/), + ).toBeNull(); + expect(screen.queryByText('Do you trust this folder?')).toBeNull(); + }); + + it('arrow keys move the highlight and Enter picks the parent option', async () => { + await renderGate(undefined); + await press('down'); + await press('return'); + expect(trust.setValue).toHaveBeenCalledWith( + '/home/user/project', + TrustLevel.TRUST_PARENT, + ); + }); + + it('digit keys quick-select by row number', async () => { + await renderGate(undefined); + await press('3'); + expect(trust.setValue).toHaveBeenCalledWith( + '/home/user/project', + TrustLevel.DO_NOT_TRUST, + ); + }); + + it('Esc selects DO_NOT_TRUST, shows the restart notice, and relaunches', async () => { + vi.useFakeTimers(); + try { + await renderGate(undefined); + const consumed = await pressEsc(); + expect(consumed).toBe(true); + expect(trust.setValue).toHaveBeenCalledWith( + '/home/user/project', + TrustLevel.DO_NOT_TRUST, + ); + expect( + screen.getByText(/Qwen Code is restarting to apply the trust changes/), + ).toBeTruthy(); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(trust.relaunchApp).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('ignores Esc and Enter while restarting', async () => { + vi.useFakeTimers(); + try { + await renderGate(undefined); + await pressEsc(); // -> DO_NOT_TRUST, restarting + await pressEsc(); + await press('return'); + expect(trust.setValue).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps the gate open when persisting the decision fails', async () => { + const stderr = vi.mocked( + await import('../../utils/stdioHelpers.js'), + ).writeStderrLineSafe; + trust.setValue.mockImplementationOnce(() => { + throw new Error('locked'); + }); + const onOpenChange = await renderGate(undefined); + await press('return'); + expect(stderr).toHaveBeenCalledWith('Error saving trusted folders file.'); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + expect(trust.relaunchApp).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/opentui/folder-trust-gate.tsx b/packages/cli/src/ui/opentui/folder-trust-gate.tsx new file mode 100644 index 00000000000..3de0ed8404b --- /dev/null +++ b/packages/cli/src/ui/opentui/folder-trust-gate.tsx @@ -0,0 +1,203 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Folder-trust startup gate (#56): OpenTUI port of the ink useFolderTrust + * hook + FolderTrustDialog pair. The ink hook imports FolderTrustChoice from + * the ink component file, so reusing it would drag ink into the opentui + * graph — the logic is inlined here, keyed directly on TrustLevel. + */ + +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useRenderer } from '@opentui/react'; +import * as process from 'node:process'; +import * as path from 'node:path'; +import type { LoadedSettings } from '../../config/settings.js'; +import { + loadTrustedFolders, + TrustLevel, + isWorkspaceTrusted, +} from '../../config/trustedFolders.js'; +import { relaunchApp } from '../../utils/processUtils.js'; +import { writeStderrLineSafe } from '../../utils/stdioHelpers.js'; +import { C } from './theme.js'; +import { + DialogFrame, + DialogSelect, + useDialogSelect, + type DialogListItem, +} from './dialogs-shared.js'; + +type TrustOption = DialogListItem & { label: string }; + +export function OpenTuiFolderTrustGate({ + settings, + onOpenChange, +}: { + settings: LoadedSettings; + /** Reports the gate state so the backend can suppress the composer. */ + onOpenChange?: (open: boolean) => void; +}) { + const renderer = useRenderer(); + const [isTrusted, setIsTrusted] = useState(undefined); + const [open, setOpen] = useState(false); + const [isRestarting, setIsRestarting] = useState(false); + + // useFolderTrust mount effect parity: with folder trust disabled, + // isWorkspaceTrusted resolves to true, so the gate only opens for an + // undecided (undefined) workspace. + useEffect(() => { + const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged); + setIsTrusted(trusted); + setOpen(trusted === undefined); + }, [settings.merged]); + + // Change notifications only: the backend starts with the gate closed, so + // the initial render (still undecided) reports nothing and the first + // meaningful callback is the post-evaluation state. + const lastReportedOpenRef = useRef(false); + useEffect(() => { + if (lastReportedOpenRef.current === open) return; + lastReportedOpenRef.current = open; + onOpenChange?.(open); + }, [open, onOpenChange]); + + const items = useMemo(() => { + const dirName = path.basename(process.cwd()); + const parentFolder = path.basename(path.dirname(process.cwd())); + return [ + { + key: 'trust-folder', + value: TrustLevel.TRUST_FOLDER, + label: `Trust folder (${dirName})`, + }, + { + key: 'trust-parent', + value: TrustLevel.TRUST_PARENT, + label: `Trust parent folder (${parentFolder})`, + }, + { + key: 'do-not-trust', + value: TrustLevel.DO_NOT_TRUST, + label: "Don't trust (esc)", + }, + ]; + }, []); + + // useFolderTrust.handleFolderTrustSelect parity (FolderTrustChoice maps 1:1 + // onto TrustLevel): persist the decision, then either close the gate or + // flip into the restart flow. A first run treats the workspace as trusted + // (isTrusted ?? true), so only a "don't trust" answer relaunches. + const select = useCallback( + (choice: TrustLevel) => { + const trustedFolders = loadTrustedFolders(); + const cwd = process.cwd(); + const wasTrusted = isTrusted ?? true; + try { + trustedFolders.setValue(cwd, choice); + } catch (error) { + writeStderrLineSafe('Error saving trusted folders file.'); + writeStderrLineSafe( + error instanceof Error ? error.message : String(error), + ); + return; + } + const currentIsTrusted = + choice === TrustLevel.TRUST_FOLDER || + choice === TrustLevel.TRUST_PARENT; + setIsTrusted(currentIsTrusted); + if (wasTrusted !== currentIsTrusted) { + setIsRestarting(true); + } else { + setOpen(false); + } + }, + [isTrusted], + ); + + // FolderTrustDialog relaunch parity: 250ms grace, then exit with the + // relaunch code so the parent respawns the CLI under the new trust level. + useEffect(() => { + if (!isRestarting) return; + const timer = setTimeout(() => void relaunchApp(), 250); + return () => clearTimeout(timer); + }, [isRestarting]); + + // Esc selects DO_NOT_TRUST (ink useKeypress parity); the raw handler runs + // before parsed-key dispatch so the list never sees the escape. Inactive + // while restarting, like the ink dialog's isActive guard. + useLayoutEffect(() => { + if (!open) return; + const onRaw = (seq: string): boolean => { + if (seq !== '\x1b' || isRestarting) return false; + select(TrustLevel.DO_NOT_TRUST); + return true; + }; + renderer.addInputHandler(onRaw); + return () => renderer.removeInputHandler(onRaw); + }, [renderer, open, isRestarting, select]); + + const { activeIndex, scrollOffset, selectIndex } = useDialogSelect({ + items, + focused: open && !isRestarting, + onSelect: select, + }); + + if (!open) return null; + + return ( + + + + + {'Do you trust this folder?'} + + + {'Trusting a folder allows Qwen Code to execute commands it'} + + + {'suggests. This is a security feature to prevent accidental'} + + {'execution in untrusted directories.'} + + ( + {item.label} + )} + onSelectIndex={(index) => { + if (!isRestarting) selectIndex(index); + }} + /> + + {isRestarting && ( + + + {'Qwen Code is restarting to apply the trust changes...'} + + + )} + + ); +} diff --git a/packages/cli/src/ui/opentui/help-overlay.tsx b/packages/cli/src/ui/opentui/help-overlay.tsx new file mode 100644 index 00000000000..0ad9f56c85b --- /dev/null +++ b/packages/cli/src/ui/opentui/help-overlay.tsx @@ -0,0 +1,281 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Help overlay for the OpenTUI renderer (PR1 slice 1): renders the same + * content as the original ink `Help` dialog — tab bar (general / commands / + * custom-commands), shortcut grid, grouped command listing with the original + * 18-line scroll window, docs footer. Tab/Shift+Tab/Esc/↑/↓ key handling + * lives in the backend (the composer owns focus); this component is pure + * presentation fed by `help-content.ts`. + * + * Reconciler-safe: `` children are only strings or `` segments + * (TextNodeRenderable rejects nested `` renderables). The tab body is + * height-capped via `computeHelpBodyRows`, so the docs footer and the key + * hints remain visible on an 80x24 terminal. + */ + +import { C } from './theme.js'; +import type { SlashCommand } from '../commands/types.js'; +import { t } from '../../i18n/index.js'; +import { + HELP_COMMAND_LIST_VISIBLE_LINES, + HELP_DOCS_URL, + HELP_KEY_COL_WIDTH, + HELP_TABS, + buildHelpCommandsLines, + buildHelpCustomCommandLines, + computeHelpWidthLayout, + getHelpShortcuts, + truncateHelpText, + type HelpLine, + type HelpTab, + type HelpWidthLayout, +} from './help-content.js'; + +type SignatureLine = Extract; + +function ShortcutRow(props: { + shortcutKey: string; + desc: string; + descWidth: number; +}) { + return ( + + + {props.shortcutKey} + + {truncateHelpText(props.desc, props.descWidth)} + + ); +} + +function GeneralHelp(props: { layout: HelpWidthLayout }) { + const { layout } = props; + const shortcuts = getHelpShortcuts(); + const left = shortcuts.slice(0, Math.ceil(shortcuts.length / 2)); + const right = shortcuts.slice(Math.ceil(shortcuts.length / 2)); + // Fixed-width columns (ink parity): flex-grow columns without truncation + // overlapped each other below ~100 terminal columns and wrapped rows out + // of the capped body window at 80. + const column = (rows: typeof left, width: number, descWidth: number) => ( + + {rows.map((s) => ( + + ))} + + ); + return ( + + + + {t( + 'Qwen Code understands your codebase, makes edits with your permission, and executes commands right from your terminal.', + )} + + + + {t('Shortcuts')} + + + {column(left, layout.colWidth, layout.descWidth)} + {column(right, layout.colWidth, layout.descWidth)} + + + ); +} + +function CommandListLine(props: { line: HelpLine }) { + const { line } = props; + if (line.type === 'blank') { + return ; + } + if (line.type === 'group') { + return ( + + {line.text} {`(${line.count})`} + + ); + } + if (line.type === 'signature') { + return ( + + {line.text} + {line.meta ? {line.meta} : null} + + ); + } + return ( + + {line.text} + + ); +} + +/** Scrollable command listing with the original 18-line window. */ +export function helpScrollMax(lines: readonly HelpLine[]): number { + return Math.max(0, lines.length - HELP_COMMAND_LIST_VISIBLE_LINES); +} + +function CommandsHelp(props: { + commands: readonly SlashCommand[]; + customOnly: boolean; + scroll: number; + width: number; +}) { + const { commands, customOnly, scroll, width } = props; + const lines = customOnly + ? buildHelpCustomCommandLines(commands, width) + : buildHelpCommandsLines(commands, width); + if (lines.length === 0) { + return ( + + {customOnly + ? t('No custom commands are currently available.') + : t('No commands are currently available.')} + + ); + } + const maxScroll = helpScrollMax(lines); + const offset = Math.max(0, Math.min(scroll, maxScroll)); + const visible = lines.slice(offset, offset + HELP_COMMAND_LIST_VISIBLE_LINES); + const signatures = lines.filter( + (l): l is SignatureLine => l.type === 'signature', + ); + const visibleSignatures = visible.filter( + (l): l is SignatureLine => l.type === 'signature', + ); + const firstCmd = + visibleSignatures.length > 0 + ? signatures.indexOf(visibleSignatures[0]) + 1 + : 0; + const lastCmd = + visibleSignatures.length > 0 + ? signatures.indexOf(visibleSignatures[visibleSignatures.length - 1]) + 1 + : 0; + return ( + + + + {customOnly + ? t('Browse custom, skill, plugin, and MCP commands:') + : t('Browse built-in commands:')} + + + + {visible.map((line, index) => ( + + ))} + + {maxScroll > 0 && ( + + + {t('Use ↑/↓ to scroll')}{' '} + {`(${firstCmd === lastCmd ? `${firstCmd}` : `${firstCmd}-${lastCmd}`}/${signatures.length})`} + + + )} + + ); +} + +export function HelpOverlay(props: { + commands: readonly SlashCommand[]; + tab: HelpTab; + scroll: number; + /** + * Row budget for the tab body (computeHelpBodyRows). The body is capped to + * this height so the footer/hints below it always fit on screen. + */ + bodyRows: number; + /** + * Available terminal width (the live main-area width in ink terms). Drives + * the border-box width and the two fixed-width shortcut columns so narrow + * terminals truncate with an ellipsis instead of overlapping. + */ + width: number; +}) { + const { commands, tab, scroll, bodyRows } = props; + const layout = computeHelpWidthLayout(props.width); + return ( + + + + + + Qwen Code + + + {HELP_TABS.map(({ tab: tabId, label }) => { + const active = tabId === tab; + return ( + + + {` ${t(label)} `} + + + ); + })} + + + {tab === 'general' && } + {tab === 'commands' && ( + + )} + {tab === 'custom-commands' && ( + + )} + + + + {t('For more help:')} {HELP_DOCS_URL} + + + + + {t('Tab/Shift+Tab to switch tabs · Esc to cancel')} + + + + + + ); +} diff --git a/packages/cli/src/ui/opentui/session-rewind-model.ts b/packages/cli/src/ui/opentui/session-rewind-model.ts new file mode 100644 index 00000000000..addeb31dc8c --- /dev/null +++ b/packages/cli/src/ui/opentui/session-rewind-model.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Pure half of the OpenTUI rewind parity (ink RewindSelector): turn + * filtering, the pick-list scroll window, the restore-option list, and the + * pick → restore-options/confirm → restoring state machine. Kept free of + * JSX so the interaction parity is unit-testable without a renderer. + */ + +import { t } from '../../i18n/index.js'; +import { isSlashCommand } from '../utils/commandUtils.js'; +import { isUserTextContent } from '../utils/historyMapping.js'; +import { getStartupContextLength } from '@qwen-code/qwen-code-core'; +import type { Content } from '@google/genai'; + +export const REWIND_MAX_VISIBLE_ITEMS = 7; + +export interface RewindTurn { + id: string; + text: string; + promptId?: string; + sentToModel?: boolean; +} + +/** Parity of historyMapping.isRealUserTurn for neutral history items. */ +export function isRewindableTurn(turn: RewindTurn): boolean { + if (!turn.text) return false; + if (typeof turn.sentToModel === 'boolean') return turn.sentToModel; + return !isSlashCommand(turn.text) && !turn.text.startsWith('?'); +} + +export function rewindableTurns(turns: readonly RewindTurn[]): RewindTurn[] { + return turns.filter(isRewindableTurn); +} + +/** + * Locates the API-history cut point for conversation rewind positionally + * (ink `computeApiTruncationIndex` parity): the index of the + * `occurrence`-th real user prompt, skipping startup-context and + * tool-result entries. Never matches on text, so projected-transcript + * decorations (attachment suffixes, compression) cannot break the match. + * Returns -1 when the history holds fewer real user prompts than + * requested (e.g. the turn was absorbed by chat compression). + */ +export function rewindApiCutPoint( + apiHistory: Content[], + occurrence: number, +): number { + const startIndex = getStartupContextLength(apiHistory, { + includeCompressed: true, + }); + let seen = 0; + for (let idx = startIndex; idx < apiHistory.length; idx++) { + if (!isUserTextContent(apiHistory[idx]!)) continue; + seen += 1; + if (seen === occurrence) return idx; + } + return -1; +} + +export interface RewindScrollWindow { + offset: number; + visibleCount: number; + showScrollUp: boolean; + showScrollDown: boolean; +} + +/** Parity of the RewindSelector pick-list scroll offset computation. */ +export function rewindScrollWindow( + total: number, + maxVisibleCap: number, + selectedIndex: number, +): RewindScrollWindow { + const visibleCount = Math.min(Math.max(0, maxVisibleCap), Math.max(0, total)); + if (total <= visibleCount) { + return { + offset: 0, + visibleCount, + showScrollUp: false, + showScrollDown: false, + }; + } + const halfVisible = Math.floor(visibleCount / 2); + let offset = selectedIndex - halfVisible; + offset = Math.max(0, offset); + offset = Math.min(total - visibleCount, offset); + return { + offset, + visibleCount, + showScrollUp: offset > 0, + showScrollDown: offset + visibleCount < total, + }; +} + +/** Structural parity of core DiffStats (the fields RewindSelector reads). */ +export interface RewindDiffStats { + filesChanged: string[]; + insertions: number; + deletions: number; +} + +export interface RestoreOptionItem { + key: 'both' | 'conversation' | 'code' | 'cancel'; + label: string; + detail?: string; +} + +/** Parity of RewindSelector.getRestoreOptions. */ +export function buildRestoreOptions( + diffStats: RewindDiffStats | undefined, +): RestoreOptionItem[] { + const hasChanges = !!diffStats && diffStats.filesChanged.length > 0; + const options: RestoreOptionItem[] = []; + + if (hasChanges) { + const fileCount = diffStats!.filesChanged.length; + const detail = t( + fileCount === 1 + ? '(+{{insertions}} -{{deletions}} in {{count}} file)' + : '(+{{insertions}} -{{deletions}} in {{count}} files)', + { + insertions: String(diffStats!.insertions), + deletions: String(diffStats!.deletions), + count: String(fileCount), + }, + ); + options.push({ + key: 'both', + label: t('Restore code and conversation'), + detail, + }); + } + + options.push({ + key: 'conversation', + label: t('Restore conversation only'), + }); + + if (hasChanges) { + options.push({ + key: 'code', + label: t('Restore code only'), + }); + } + + options.push({ + key: 'cancel', + label: t('Never mind'), + }); + + return options; +} + +export type RewindPhase = 'pick' | 'restore-options' | 'confirm' | 'restoring'; + +export interface RewindState { + phase: RewindPhase; + turnCount: number; + selectedIndex: number; + selectedTurnIndex: number | null; + restoreOptionIndex: number; +} + +export type RewindAction = + | { type: 'select-up' } + | { type: 'select-down' } + | { type: 'enter-pick'; fileCheckpointingEnabled: boolean } + | { type: 'option-up' } + | { type: 'option-down'; optionCount: number } + | { type: 'back' } + | { type: 'begin-restore' } + | { type: 'restore-error' }; + +/** Ink starts the pick list on the most recent turn. */ +export function createRewindState(turnCount: number): RewindState { + return { + phase: 'pick', + turnCount, + selectedIndex: Math.max(0, Math.floor(turnCount) - 1), + selectedTurnIndex: null, + restoreOptionIndex: 0, + }; +} + +export function rewindReducer( + state: RewindState, + action: RewindAction, +): RewindState { + switch (action.type) { + case 'select-up': { + if (state.phase !== 'pick') return state; + return { ...state, selectedIndex: Math.max(0, state.selectedIndex - 1) }; + } + case 'select-down': { + if (state.phase !== 'pick') return state; + return { + ...state, + selectedIndex: Math.min(state.turnCount - 1, state.selectedIndex + 1), + }; + } + case 'enter-pick': { + if (state.phase !== 'pick' || state.turnCount === 0) return state; + return { + ...state, + phase: action.fileCheckpointingEnabled ? 'restore-options' : 'confirm', + selectedTurnIndex: state.selectedIndex, + restoreOptionIndex: 0, + }; + } + case 'option-up': { + if (state.phase !== 'restore-options') return state; + return { + ...state, + restoreOptionIndex: Math.max(0, state.restoreOptionIndex - 1), + }; + } + case 'option-down': { + if (state.phase !== 'restore-options') return state; + return { + ...state, + restoreOptionIndex: Math.min( + Math.max(0, action.optionCount - 1), + state.restoreOptionIndex + 1, + ), + }; + } + case 'back': { + if (state.phase === 'pick' || state.phase === 'restoring') return state; + return { + ...state, + phase: 'pick', + selectedTurnIndex: null, + restoreOptionIndex: 0, + }; + } + case 'begin-restore': { + if (state.phase === 'pick' || state.phase === 'restoring') return state; + return { ...state, phase: 'restoring' }; + } + case 'restore-error': { + if (state.phase !== 'restoring') return state; + return { + ...state, + phase: 'pick', + selectedTurnIndex: null, + restoreOptionIndex: 0, + }; + } + default: + return state; + } +} diff --git a/packages/cli/src/ui/opentui/session-rewind.test.ts b/packages/cli/src/ui/opentui/session-rewind.test.ts new file mode 100644 index 00000000000..313b546e674 --- /dev/null +++ b/packages/cli/src/ui/opentui/session-rewind.test.ts @@ -0,0 +1,317 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Verifies the OpenTUI rewind state machine and helpers reproduce the ink + * RewindSelector behavior: real-user-turn filtering, the 7-row scroll + * window, the checkpoint-dependent restore option list, and the + * pick → restore-options/confirm → restoring phase transitions. + */ + +import { describe, it, expect } from 'vitest'; +import type { Content, Part } from '@google/genai'; +import { + SYSTEM_REMINDER_OPEN, + SYSTEM_REMINDER_CLOSE, +} from '@qwen-code/qwen-code-core'; +import { + isRewindableTurn, + rewindableTurns, + rewindScrollWindow, + buildRestoreOptions, + createRewindState, + rewindReducer, + rewindApiCutPoint, + REWIND_MAX_VISIBLE_ITEMS, + type RewindTurn, +} from './session-rewind-model.js'; + +function turn(overrides: Partial = {}): RewindTurn { + return { id: 't1', text: 'fix the bug', ...overrides }; +} + +describe('session-rewind turn filtering (isRealUserTurn parity)', () => { + it('keeps real prompts', () => { + expect(isRewindableTurn(turn())).toBe(true); + }); + + it('drops empty prompts', () => { + expect(isRewindableTurn(turn({ text: '' }))).toBe(false); + }); + + it('drops slash commands and ?-prefixed queries', () => { + expect(isRewindableTurn(turn({ text: '/compact' }))).toBe(false); + expect(isRewindableTurn(turn({ text: '?quick question' }))).toBe(false); + }); + + it('keeps file paths that only look like commands', () => { + expect(isRewindableTurn(turn({ text: '/Users/me/x.ts' }))).toBe(true); + }); + + it('honors an explicit sentToModel flag', () => { + expect(isRewindableTurn(turn({ sentToModel: false }))).toBe(false); + expect( + isRewindableTurn(turn({ text: '/compact', sentToModel: true })), + ).toBe(true); + }); + + it('filters a mixed transcript', () => { + const turns = [ + turn({ id: 'a', text: 'first prompt' }), + turn({ id: 'b', text: '/compact' }), + turn({ id: 'c', text: 'second prompt' }), + ]; + expect(rewindableTurns(turns).map((t) => t.id)).toEqual(['a', 'c']); + }); +}); + +describe('session-rewind scroll window (RewindSelector parity)', () => { + it('shows everything without arrows when the list fits', () => { + expect(rewindScrollWindow(3, REWIND_MAX_VISIBLE_ITEMS, 2)).toEqual({ + offset: 0, + visibleCount: 3, + showScrollUp: false, + showScrollDown: false, + }); + }); + + it('caps the visible rows at the max', () => { + const win = rewindScrollWindow(20, REWIND_MAX_VISIBLE_ITEMS, 19); + expect(win.visibleCount).toBe(7); + }); + + it('centers the selection with clamped offsets', () => { + // selected 4 -> 4 - floor(7/2) = 1 + expect(rewindScrollWindow(10, 7, 4)).toMatchObject({ + offset: 1, + showScrollUp: true, + showScrollDown: true, + }); + }); + + it('pins the window to the end for the newest turn', () => { + expect(rewindScrollWindow(10, 7, 9)).toMatchObject({ + offset: 3, + showScrollUp: true, + showScrollDown: false, + }); + }); + + it('pins the window to the start for the oldest turn', () => { + expect(rewindScrollWindow(10, 7, 0)).toMatchObject({ + offset: 0, + showScrollUp: false, + showScrollDown: true, + }); + }); +}); + +describe('session-rewind restore options (getRestoreOptions parity)', () => { + it('offers only conversation + cancel without captured changes', () => { + expect(buildRestoreOptions(undefined).map((o) => o.key)).toEqual([ + 'conversation', + 'cancel', + ]); + expect( + buildRestoreOptions({ + filesChanged: [], + insertions: 3, + deletions: 1, + }).map((o) => o.key), + ).toEqual(['conversation', 'cancel']); + }); + + it('offers both/conversation/code with the diff detail line', () => { + const options = buildRestoreOptions({ + filesChanged: ['a.ts', 'b.ts'], + insertions: 10, + deletions: 2, + }); + expect(options.map((o) => o.key)).toEqual([ + 'both', + 'conversation', + 'code', + 'cancel', + ]); + expect(options[0]?.detail).toBe('(+10 -2 in 2 files)'); + }); + + it('uses the singular file wording for one changed file', () => { + const options = buildRestoreOptions({ + filesChanged: ['a.ts'], + insertions: 1, + deletions: 0, + }); + expect(options[0]?.detail).toBe('(+1 -0 in 1 file)'); + }); +}); + +describe('session-rewind state machine', () => { + it('starts on the most recent turn', () => { + const state = createRewindState(3); + expect(state).toEqual({ + phase: 'pick', + turnCount: 3, + selectedIndex: 2, + selectedTurnIndex: null, + restoreOptionIndex: 0, + }); + }); + + it('clamps pick-list navigation', () => { + let state = createRewindState(3); + state = rewindReducer(state, { type: 'select-up' }); + state = rewindReducer(state, { type: 'select-up' }); + state = rewindReducer(state, { type: 'select-up' }); + expect(state.selectedIndex).toBe(0); + state = rewindReducer(state, { type: 'select-down' }); + state = rewindReducer(state, { type: 'select-down' }); + state = rewindReducer(state, { type: 'select-down' }); + state = rewindReducer(state, { type: 'select-down' }); + expect(state.selectedIndex).toBe(2); + }); + + it('opens restore options when file checkpointing is on', () => { + let state = createRewindState(3); + state = rewindReducer(state, { type: 'select-up' }); + state = rewindReducer(state, { + type: 'enter-pick', + fileCheckpointingEnabled: true, + }); + expect(state.phase).toBe('restore-options'); + expect(state.selectedTurnIndex).toBe(1); + expect(state.restoreOptionIndex).toBe(0); + }); + + it('opens the legacy confirm when file checkpointing is off', () => { + let state = createRewindState(2); + state = rewindReducer(state, { + type: 'enter-pick', + fileCheckpointingEnabled: false, + }); + expect(state.phase).toBe('confirm'); + expect(state.selectedTurnIndex).toBe(1); + }); + + it('refuses to open with zero turns', () => { + const state = createRewindState(0); + const next = rewindReducer(state, { + type: 'enter-pick', + fileCheckpointingEnabled: true, + }); + expect(next.phase).toBe('pick'); + }); + + it('navigates restore options with clamping', () => { + let state = createRewindState(2); + state = rewindReducer(state, { + type: 'enter-pick', + fileCheckpointingEnabled: true, + }); + state = rewindReducer(state, { type: 'option-up' }); + expect(state.restoreOptionIndex).toBe(0); + state = rewindReducer(state, { type: 'option-down', optionCount: 4 }); + state = rewindReducer(state, { type: 'option-down', optionCount: 4 }); + state = rewindReducer(state, { type: 'option-down', optionCount: 4 }); + state = rewindReducer(state, { type: 'option-down', optionCount: 4 }); + expect(state.restoreOptionIndex).toBe(3); + }); + + it('goes back to the pick list and clears the selection', () => { + let state = createRewindState(2); + state = rewindReducer(state, { + type: 'enter-pick', + fileCheckpointingEnabled: true, + }); + state = rewindReducer(state, { type: 'back' }); + expect(state.phase).toBe('pick'); + expect(state.selectedTurnIndex).toBeNull(); + // back in pick is a no-op + expect(rewindReducer(state, { type: 'back' })).toEqual(state); + }); + + it('enters restoring from a sub-phase and then ignores keys', () => { + let state = createRewindState(2); + state = rewindReducer(state, { + type: 'enter-pick', + fileCheckpointingEnabled: false, + }); + state = rewindReducer(state, { type: 'begin-restore' }); + expect(state.phase).toBe('restoring'); + expect(rewindReducer(state, { type: 'back' })).toEqual(state); + expect(rewindReducer(state, { type: 'select-up' })).toEqual(state); + expect( + rewindReducer(state, { + type: 'enter-pick', + fileCheckpointingEnabled: true, + }), + ).toEqual(state); + }); +}); + +function userPrompt(text: string): Content { + return { role: 'user', parts: [{ text } as Part] }; +} + +function imagePrompt(text: string): Content { + return { + role: 'user', + parts: [ + { text } as Part, + { + inlineData: { mimeType: 'image/png', data: 'AAA=' }, + } as unknown as Part, + ], + }; +} + +function toolResult(): Content { + return { + role: 'user', + parts: [ + { + functionResponse: { name: 'tool', response: { result: 'ok' } }, + } as unknown as Part, + ], + }; +} + +function startupContext(): Content { + return userPrompt( + `${SYSTEM_REMINDER_OPEN}\nEnvironment context...\n${SYSTEM_REMINDER_CLOSE}`, + ); +} + +describe('rewindApiCutPoint (positional, text-independent)', () => { + it('locates the N-th real user prompt regardless of transcript text', () => { + // The UI transcript projects an image turn as `${text} 📎1`, but the + // API entry carries raw parts — matching must not depend on text. + const api: Content[] = [ + userPrompt('first'), + { role: 'model', parts: [{ text: 'reply' } as Part] }, + imagePrompt('second'), + ]; + expect(rewindApiCutPoint(api, 1)).toBe(0); + expect(rewindApiCutPoint(api, 2)).toBe(2); + }); + + it('skips startup-context and tool-result entries', () => { + const api: Content[] = [ + startupContext(), + userPrompt('first'), + toolResult(), + userPrompt('second'), + ]; + expect(rewindApiCutPoint(api, 1)).toBe(1); + expect(rewindApiCutPoint(api, 2)).toBe(3); + }); + + it('returns -1 when the history holds fewer prompts (compressed turn)', () => { + const api: Content[] = [userPrompt('first')]; + expect(rewindApiCutPoint(api, 2)).toBe(-1); + expect(rewindApiCutPoint([], 1)).toBe(-1); + }); +}); diff --git a/packages/cli/src/ui/opentui/session-rewind.tsx b/packages/cli/src/ui/opentui/session-rewind.tsx new file mode 100644 index 00000000000..d90206b8f44 --- /dev/null +++ b/packages/cli/src/ui/opentui/session-rewind.tsx @@ -0,0 +1,391 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI parity of the ink RewindSelector + * (ui/components/RewindSelector.tsx): the multi-phase rewind flow — + * pick-list → restore options (file checkpointing on) or legacy Y/N + * confirm (off) → restoring — with the same scroll window, turn rows, + * option details, '─' separators, and footer hints. The pure helpers and + * the state machine are exported for unit testing. + */ + +import { useEffect, useReducer, useRef, useState } from 'react'; +import { useKeyboard, useTerminalDimensions } from '@opentui/react'; +import { C } from './theme.js'; +import { t } from '../../i18n/index.js'; +import { toOriginalKey } from './key-map.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { truncateText } from '../utils/sessionPickerUtils.js'; +import { DialogFrame } from './dialogs-shared.js'; +import { REWIND_MAX_VISIBLE_ITEMS } from './session-rewind-model.js'; + +export type RestoreOption = 'both' | 'conversation' | 'code' | 'cancel'; + +export { REWIND_MAX_VISIBLE_ITEMS }; +export { + type RewindTurn, + type RewindDiffStats, + type RestoreOptionItem, + isRewindableTurn, + rewindableTurns, + rewindScrollWindow, + type RewindScrollWindow, + buildRestoreOptions, + rewindReducer, + createRewindState, + type RewindState, + type RewindPhase, + type RewindAction, +} from './session-rewind-model.js'; + +import { + type RewindTurn, + type RewindDiffStats, + rewindableTurns, + rewindScrollWindow, + buildRestoreOptions, + rewindReducer, + createRewindState, +} from './session-rewind-model.js'; + +export interface OpentuiRewindSelectorProps { + turns: readonly RewindTurn[]; + fileCheckpointingEnabled: boolean; + getDiffStats?: (promptId: string) => Promise; + onRewind: (turn: RewindTurn, option: RestoreOption) => void | Promise; + onCancel: () => void; +} + +export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) { + const { turns, fileCheckpointingEnabled, getDiffStats, onRewind, onCancel } = + props; + const { width } = useTerminalDimensions(); + const userTurns = rewindableTurns(turns); + + const [state, dispatch] = useReducer( + rewindReducer, + userTurns.length, + createRewindState, + ); + const [diffStats, setDiffStats] = useState( + undefined, + ); + const [loadingDiff, setLoadingDiff] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + + const selectedTurn = + state.selectedTurnIndex === null + ? null + : (userTurns[state.selectedTurnIndex] ?? null); + const restoreOptions = buildRestoreOptions(diffStats); + + useEffect(() => { + if (state.phase !== 'restore-options' || !selectedTurn) return; + if (!fileCheckpointingEnabled) return; + const promptId = selectedTurn.promptId ?? selectedTurn.id; + if (!getDiffStats) { + setDiffStats(undefined); + setLoadingDiff(false); + return; + } + let cancelled = false; + setLoadingDiff(true); + getDiffStats(promptId) + .then((stats) => { + if (!cancelled) { + setDiffStats(stats); + setLoadingDiff(false); + } + }) + .catch(() => { + if (!cancelled) { + setDiffStats(undefined); + setLoadingDiff(false); + } + }); + return () => { + cancelled = true; + }; + }, [state.phase, selectedTurn, fileCheckpointingEnabled, getDiffStats]); + + const restoringRef = useRef(false); + const startRestore = (turn: RewindTurn, option: RestoreOption) => { + if (restoringRef.current) return; + restoringRef.current = true; + setIsRestoring(true); + dispatch({ type: 'begin-restore' }); + Promise.resolve(onRewind(turn, option)) + .catch(() => { + dispatch({ type: 'restore-error' }); + }) + .finally(() => { + restoringRef.current = false; + setIsRestoring(false); + }); + }; + + useKeyboard((key) => { + const original = toOriginalKey(key); + const isCancelKey = + original.name === 'escape' || (original.ctrl && original.name === 'c'); + + if (state.phase === 'pick') { + if (isCancelKey) { + onCancel(); + return; + } + if (original.name === 'return') { + dispatch({ + type: 'enter-pick', + fileCheckpointingEnabled, + }); + return; + } + if (keyMatchers[Command.SELECTION_UP](original)) { + dispatch({ type: 'select-up' }); + return; + } + if (keyMatchers[Command.SELECTION_DOWN](original)) { + dispatch({ type: 'select-down' }); + } + return; + } + + if (state.phase === 'restore-options') { + if (isRestoring) return; + if (isCancelKey) { + setDiffStats(undefined); + dispatch({ type: 'back' }); + return; + } + if (loadingDiff) return; + if (original.name === 'return') { + const option = restoreOptions[state.restoreOptionIndex]; + if (!option || !selectedTurn) return; + if (option.key === 'cancel') { + setDiffStats(undefined); + dispatch({ type: 'back' }); + } else { + startRestore(selectedTurn, option.key); + } + return; + } + if (original.name === 'up' || original.name === 'k') { + dispatch({ type: 'option-up' }); + return; + } + if (original.name === 'down' || original.name === 'j') { + dispatch({ + type: 'option-down', + optionCount: restoreOptions.length, + }); + } + return; + } + + if (state.phase === 'confirm') { + if (isRestoring) return; + if (isCancelKey) { + dispatch({ type: 'back' }); + return; + } + if ( + original.name === 'return' || + original.sequence === 'y' || + original.sequence === 'Y' + ) { + if (selectedTurn) startRestore(selectedTurn, 'conversation'); + return; + } + if (original.sequence === 'n' || original.sequence === 'N') { + dispatch({ type: 'back' }); + } + } + }); + + const boxWidth = Math.max(20, width - 4); + // Ink draws a full-width '─' divider below the title and above the + // footer ('─'.repeat(boxWidth - 2) inside a paddingX=1 border box). + const separator = '─'.repeat(boxWidth - 4); + + if (userTurns.length === 0) { + return ( + + {t('No user turns to rewind to.')} + + ); + } + + if (state.phase !== 'pick' && selectedTurn) { + const promptPreview = truncateText( + selectedTurn.text || '(empty)', + boxWidth - 10, + ); + + // Ink keeps showing the legacy confirm while the rewind runs; the + // reducer collapses both sub-phases into 'restoring', so recover the + // origin from the checkpointing flag. + if ( + state.phase === 'confirm' || + (state.phase === 'restoring' && !fileCheckpointingEnabled) + ) { + return ( + + + {t('Rewind Conversation')} + + {separator} + + + {t('Rewind to: ')} + + {promptPreview} + + + + {t( + 'This will remove all conversation after this turn. The prompt will be pre-populated in the input for editing.', + )} + + + {separator} + {t('Enter/Y to confirm · Esc/N to go back')} + + ); + } + + const hasFileOptions = restoreOptions.some( + (o) => o.key === 'code' || o.key === 'both', + ); + return ( + + + {t('Rewind Conversation')} + + {separator} + + + {t('Rewind to: ')} + + {promptPreview} + + + {loadingDiff ? ( + {t('Computing file changes...')} + ) : isRestoring ? ( + {t('Restoring...')} + ) : ( + + {restoreOptions.map((option, index) => { + const isSelected = index === state.restoreOptionIndex; + return ( + + + {isSelected ? '› ' : ' '} + {option.label} + + {option.detail ? ( + {option.detail} + ) : null} + + ); + })} + + + {hasFileOptions + ? t( + 'Rewinding does not affect files edited manually or via shell commands.', + ) + : t( + 'File restore is unavailable for this turn (no captured file changes, or this turn predates the current session).', + )} + + + + )} + + {separator} + + {t('↑↓ to navigate · Enter to select · Esc to go back')} + + + ); + } + + const window_ = rewindScrollWindow( + userTurns.length, + REWIND_MAX_VISIBLE_ITEMS, + state.selectedIndex, + ); + const visibleTurns = userTurns.slice( + window_.offset, + window_.offset + window_.visibleCount, + ); + + return ( + + + + {t('Rewind Conversation')} + + + {' '} + {t('({{count}} turns)', { count: String(userTurns.length) })} + + + {separator} + + {visibleTurns.map((turn, visibleIndex) => { + const actualIndex = window_.offset + visibleIndex; + const isSelected = actualIndex === state.selectedIndex; + const isLast = visibleIndex === visibleTurns.length - 1; + const showUpIndicator = visibleIndex === 0 && window_.showScrollUp; + const showDownIndicator = isLast && window_.showScrollDown; + const prefix = isSelected + ? '› ' + : showUpIndicator + ? '↑ ' + : showDownIndicator + ? '↓ ' + : ' '; + const prefixColor = isSelected + ? C.accent + : showUpIndicator || showDownIndicator + ? C.dim + : C.text; + return ( + + + {prefix} + + {`#${actualIndex + 1} `} + + {truncateText(turn.text || '(empty prompt)', boxWidth - 10)} + + + ); + })} + + {separator} + + {t('↑↓ to navigate · Enter to select · Esc to cancel')} + + + ); +} diff --git a/packages/cli/src/ui/opentui/session-switch.test.ts b/packages/cli/src/ui/opentui/session-switch.test.ts index 6e2dabe47e6..a3dbc880436 100644 --- a/packages/cli/src/ui/opentui/session-switch.test.ts +++ b/packages/cli/src/ui/opentui/session-switch.test.ts @@ -271,6 +271,22 @@ describe('handleResumeSession', () => { expect.any(Number), ); }); + + it('settles the unarmed swap transaction when the session is not found (R4-4)', async () => { + vi.spyOn(SessionService.prototype, 'loadSession').mockResolvedValue( + null as never, + ); + const { config, calls } = createFakeConfig(); + const host = createFakeHost(config); + await handleResumeSession(host, 'missing-session'); + // The transaction opened but nothing was replayed: it must be closed + // with a commit (not an abort), or the single swap slot stays latched + // and every later /resume or /branch is rejected until restart. + expect(calls.swapBegin).toBe(1); + expect(calls.swapCommit).toBe(1); + expect(calls.swapAbort).toBe(0); + expect(calls.startNewSession).toHaveLength(0); + }); }); describe('handleBranchSession', () => { diff --git a/packages/cli/src/ui/opentui/session-switch.ts b/packages/cli/src/ui/opentui/session-switch.ts index 6a837b229a6..cc5ba64169a 100644 --- a/packages/cli/src/ui/opentui/session-switch.ts +++ b/packages/cli/src/ui/opentui/session-switch.ts @@ -141,13 +141,6 @@ export async function handleResumeSession( if (!sessionData) { // Nothing was replayed — close this attempt's unarmed transaction. config.getLlmClient()?.commitTelemetrySwap?.(); - host.addItem( - { - type: MessageType.ERROR, - text: `Session ${sessionId} could not be loaded.`, - }, - Date.now(), - ); return; } const customTitle = sessionService.getSessionTitle(sessionId); @@ -194,7 +187,6 @@ export async function handleResumeSession( // 2. UI swap. The commit point is the UI-side session re-key: from here // on a failure must not roll core back OR undo the telemetry replay. host.startNewSession(sessionId); - uiSwapped = true; host.setSessionName(customTitle ?? null); host.clearPendingState(); host.clearItems(); @@ -206,6 +198,7 @@ export async function handleResumeSession( Date.now(), ); } + uiSwapped = true; config.getLlmClient()?.commitTelemetrySwap?.(); } catch (error) { if (coreSwapped && !uiSwapped) { @@ -365,11 +358,11 @@ export async function handleBranchSession( // 8. UI swap. const uiHistoryItems = buildUiHistoryItems(resumed, host); host.startNewSession(newSessionId); - uiSwapped = true; host.clearPendingState(); host.clearItems(); host.loadHistory(uiHistoryItems); host.resetTranscript(resumeEventsFromSession(resumed, config)); + uiSwapped = true; resetBackgroundStateForSessionSwitch(config); // The UI re-key commits the swap: from here on a failure keeps the // replay — it belongs to the session the user is on. diff --git a/packages/cli/src/ui/opentui/slash-dispatch.test.ts b/packages/cli/src/ui/opentui/slash-dispatch.test.ts index 6dbf7eeaa26..f7aa8218769 100644 --- a/packages/cli/src/ui/opentui/slash-dispatch.test.ts +++ b/packages/cli/src/ui/opentui/slash-dispatch.test.ts @@ -298,27 +298,23 @@ describe('executeSlashCommand ink-processor guards (R1-96/100/101/102)', () => { }); it('an AbortError from a pre-aborted signal is handled, not a failure (R3-1)', async () => { - // ESC before dispatch reaches the race: the signal is already aborted, - // addEventListener never fires, and the action's I/O rejects with - // AbortError into the catch — the user's own cancellation must not be - // recorded as a command failure or shown as an error message. + // ESC before dispatch reaches the race: the signal is already aborted + // and addEventListener never fires, so the action must be skipped + // entirely — a cancelled submission must not run its side effects nor + // surface its (possibly abort-shaped) error as a command failure. const controller = new AbortController(); controller.abort(); - const commands = [ - stub({ - name: 'doctor', - action: (ctx) => { - void ctx.abortSignal; - return Promise.reject(new Error('This operation was aborted')); - }, - }), - ]; + const action = vi.fn(() => + Promise.reject(new Error('This operation was aborted')), + ); + const commands = [stub({ name: 'doctor', action })]; const effect = await executeSlashCommand( '/doctor', commands, makeEnv({ abortSignal: controller.signal }), ); expect(effect).toEqual({ kind: 'handled' }); + expect(action).not.toHaveBeenCalled(); }); it('defers stacked skill invocations instead of leaking the second skill', async () => { diff --git a/packages/cli/src/ui/opentui/slash-gateway.test.ts b/packages/cli/src/ui/opentui/slash-gateway.test.ts new file mode 100644 index 00000000000..ac8ef031eed --- /dev/null +++ b/packages/cli/src/ui/opentui/slash-gateway.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Backend-level reachability tests for the slash gateway (R2): slash input is + * queued until the dispatcher is ready, initialization errors are exposed + * instead of falling through to the model, concurrent commands are rejected, + * and Esc routes to dispatcher.cancel(). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { OpenTuiSlashGateway } from './slash-gateway.js'; +import type { + OpenTuiDispatchOutcome, + OpenTuiSlashDispatcher, +} from './commands-dispatch.js'; + +function fakeDispatcher( + handle: () => Promise = async () => ({ + kind: 'handled', + }), +): OpenTuiSlashDispatcher { + return { + handle: vi.fn(handle), + cancel: vi.fn(), + commands: [], + setCommands: vi.fn(), + loadCommands: vi.fn(async () => {}), + } as unknown as OpenTuiSlashDispatcher; +} + +/** Lets an already-ready dispatch reach its busy section. */ +const flush = () => Promise.resolve(); + +describe('OpenTuiSlashGateway', () => { + it('queues slash input until the dispatcher attaches', async () => { + const gateway = new OpenTuiSlashGateway(); + const dispatcher = fakeDispatcher(async () => ({ kind: 'handled' })); + + // Submitted while initialization is still pending: not lost, not sent to + // the model, and not reaching a not-yet-built dispatcher. + const pending = gateway.dispatch('/help'); + expect(gateway.isReady()).toBe(false); + + gateway.attach(dispatcher); + const settlement = await pending; + expect(settlement).toEqual({ + kind: 'dispatched', + outcome: { kind: 'handled' }, + }); + expect(dispatcher.handle).toHaveBeenCalledWith('/help'); + }); + + it('rejects dispatches after the queue drains post-attach', async () => { + const gateway = new OpenTuiSlashGateway(); + const dispatcher = fakeDispatcher(async () => false); + gateway.attach(dispatcher); + const settlement = await gateway.dispatch('not-a-command'); + // `false` (not a slash command) passes through to the normal prompt path. + expect(settlement).toEqual({ kind: 'dispatched', outcome: false }); + }); + + it('exposes initialization errors to every later submission', async () => { + const gateway = new OpenTuiSlashGateway(); + const pending = gateway.dispatch('/help'); + gateway.failInit(new Error('loader exploded')); + + const first = await pending; + expect(first.kind).toBe('rejected'); + if (first.kind === 'rejected') { + expect(first.reason).toContain('failed to initialize'); + expect(first.reason).toContain('loader exploded'); + } + + // The gateway stays rejected — '/help' never falls through to the model. + const second = await gateway.dispatch('/help'); + expect(second.kind).toBe('rejected'); + expect(gateway.getInitError()).toBe('loader exploded'); + }); + + it('prevents concurrent command submission', async () => { + const gateway = new OpenTuiSlashGateway(); + let release: () => void = () => {}; + const firstDone = new Promise((resolve) => { + release = resolve; + }); + const dispatcher = fakeDispatcher(async () => { + await firstDone; + return { kind: 'handled' }; + }); + gateway.attach(dispatcher); + + const first = gateway.dispatch('/first'); + await flush(); // let the in-flight dispatch reach its busy section + expect(gateway.isBusy()).toBe(true); + + const second = await gateway.dispatch('/second'); + expect(second).toEqual({ + kind: 'rejected', + reason: 'A slash command is already running.', + }); + expect(dispatcher.handle).toHaveBeenCalledTimes(1); + + release(); + await first; + expect(gateway.isBusy()).toBe(false); + + // The gate reopens once the command finishes. + const third = await gateway.dispatch('/third'); + expect(third.kind).toBe('dispatched'); + }); + + it('releases the busy gate even when the command throws', async () => { + const gateway = new OpenTuiSlashGateway(); + let attempt = 0; + const dispatcher = fakeDispatcher(async () => { + attempt += 1; + if (attempt === 1) throw new Error('command exploded'); + return { kind: 'handled' }; + }); + gateway.attach(dispatcher); + + await expect(gateway.dispatch('/boom')).rejects.toThrow('command exploded'); + expect(gateway.isBusy()).toBe(false); + + const next = await gateway.dispatch('/after'); + expect(next.kind).toBe('dispatched'); + }); + + it('routes cancel() to the attached dispatcher (Esc parity)', () => { + const gateway = new OpenTuiSlashGateway(); + gateway.cancel(); // before attach: a safe no-op + const dispatcher = fakeDispatcher(); + gateway.attach(dispatcher); + gateway.cancel(); + expect(dispatcher.cancel).toHaveBeenCalledTimes(1); + }); + + it('replaces the dispatcher on re-attach after a reload', async () => { + const gateway = new OpenTuiSlashGateway(); + const first = fakeDispatcher(async () => ({ kind: 'quit', messages: [] })); + const second = fakeDispatcher(async () => ({ kind: 'handled' })); + gateway.attach(first); + gateway.attach(second); + const settlement = await gateway.dispatch('/help'); + expect(settlement).toEqual({ + kind: 'dispatched', + outcome: { kind: 'handled' }, + }); + expect(second.handle).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/ui/opentui/slash-gateway.ts b/packages/cli/src/ui/opentui/slash-gateway.ts new file mode 100644 index 00000000000..020b9f32ea7 --- /dev/null +++ b/packages/cli/src/ui/opentui/slash-gateway.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Backend-level gate between the prompt and the real slash dispatcher (R2). + * + * Dispatcher construction is asynchronous (the loader stack builds the + * registry), so slash input that arrives first must not fall through to the + * model. The gateway: + * + * - queues slash submissions until initialization settles (`ready`); + * - records initialization failures and reports them to every later + * submission instead of silently misrouting `/help` to the model; + * - rejects a submission while a command is already running (the ink + * processor gates on isProcessing; the OpenTUI prompt does not disable); + * - routes Esc to `dispatcher.cancel()` while a command runs. + */ + +import type { + OpenTuiDispatchOutcome, + OpenTuiSlashDispatcher, +} from './commands-dispatch.js'; + +export type SlashSettlement = + /** The dispatcher processed the input (false = not a slash command). */ + | { kind: 'dispatched'; outcome: OpenTuiDispatchOutcome | false } + /** The submission was refused before reaching the dispatcher. */ + | { kind: 'rejected'; reason: string }; + +export class OpenTuiSlashGateway { + private dispatcher: OpenTuiSlashDispatcher | null = null; + private initError: string | null = null; + private busy = false; + private readonly ready: Promise; + private readonly settleReady: () => void; + + constructor() { + let resolveReady: () => void = () => {}; + this.ready = new Promise((resolve) => { + resolveReady = resolve; + }); + this.settleReady = resolveReady; + } + + /** Marks the command stack ready (or replaces it after a reload). */ + attach(dispatcher: OpenTuiSlashDispatcher): void { + this.dispatcher = dispatcher; + this.settleReady(); + } + + /** Records a dispatcher initialization failure and unblocks queued input. */ + failInit(error: unknown): void { + this.initError = error instanceof Error ? error.message : String(error); + this.settleReady(); + } + + /** True once the dispatcher is attached and serving. */ + isReady(): boolean { + return this.dispatcher !== null; + } + + /** + * Whether the command in `text` opted into running while a model turn + * streams (dispatcher passthrough; false before the dispatcher attaches). + */ + canRunDuringStreaming(text: string): boolean { + return this.dispatcher?.canRunDuringStreaming(text) ?? false; + } + + /** True while a dispatched command is still running. */ + isBusy(): boolean { + return this.busy; + } + + getInitError(): string | null { + return this.initError; + } + + /** Esc route: cancel the running command (parity of dispatcher.cancel). */ + cancel(): void { + this.dispatcher?.cancel(); + } + + /** + * Waits for readiness, then runs one input through the dispatcher. Rejects + * while initialization failed or another command is in flight. + */ + async dispatch(text: string): Promise { + await this.ready; + if (!this.dispatcher) { + return { + kind: 'rejected', + reason: + 'The command stack failed to initialize' + + (this.initError ? ` (${this.initError})` : '') + + '; slash commands are unavailable.', + }; + } + if (this.busy) { + return { + kind: 'rejected', + reason: 'A slash command is already running.', + }; + } + this.busy = true; + try { + const outcome = await this.dispatcher.handle(text); + return { kind: 'dispatched', outcome }; + } finally { + this.busy = false; + } + } +} diff --git a/packages/cli/src/ui/utils/historyMapping.ts b/packages/cli/src/ui/utils/historyMapping.ts index 8c81d125b7d..a41e9ba1053 100644 --- a/packages/cli/src/ui/utils/historyMapping.ts +++ b/packages/cli/src/ui/utils/historyMapping.ts @@ -40,7 +40,7 @@ export function isRealUserTurn( * Checks if a Content entry is a user-initiated text prompt * as opposed to a tool result (functionResponse). */ -function isUserTextContent(content: Content): boolean { +export function isUserTextContent(content: Content): boolean { if (content.role !== 'user') return false; if (!content.parts || content.parts.length === 0) return false; diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index aa137b022f4..42b2663eb19 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -947,9 +947,9 @@ describe('ToolRegistry', () => { 'hidden_by_allowlist', ); expect(registry.isDeferredAndHidden('hidden_by_allowlist')).toBe(false); - expect(registry.getDeferredToolSummary().map((t) => t.name)).not.toContain( - 'hidden_by_allowlist', - ); + expect( + registry.getDeferredToolSummary().map((t) => t.name), + ).not.toContain('hidden_by_allowlist'); }); it('reveals the schema once ToolSearch loads the tool', async () => { diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index f1fecfcadfa..b9dc573ddb1 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -103,10 +103,12 @@ export class DiffManager { const rightUri = rightDocUri.toString(); for (const group of vscode.window.tabGroups.all) { const containsDiff = group.tabs.some((tab) => { - const input = tab.input as { - original?: vscode.Uri; - modified?: vscode.Uri; - } | undefined; + const input = tab.input as + | { + original?: vscode.Uri; + modified?: vscode.Uri; + } + | undefined; return ( input?.original?.toString() === leftUri && input?.modified?.toString() === rightUri