Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/cli/src/llm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,46 @@ export async function main() {
// startInteractiveUI) and so the first paint uses the refined theme
// when the probe finishes in time.
await themeAutoDetectionComplete;
// Renderer dispatch for the ink→OpenTUI migration: QWEN_TUI_RENDERER
// selects the experimental backend only on a runtime that can drive it;
// every other case — including a failed load or boot of the entry —
// falls through to ink, which stays the default renderer. The try/catch
// is load-bearing: importing the entry evaluates opentui modules whose
// module scope touches the native FFI, which can still throw on a
// runtime that passed the version gate.
const { selectTuiRenderer } = await import(
'./ui/opentui/renderer-selection.js'
);
const selection = selectTuiRenderer();
if (selection.renderer === 'opentui') {
try {
const { startOpenTuiUI } = await import(
'./ui/opentui/start-opentui-ui.js'
);
const started = await startOpenTuiUI(
config,
settings,
startupWarnings,
process.cwd(),
initializationResult!,
{
postRenderConnectIde: deferIdeConnection,
extensionRefreshState,
},
);
if (started) {
clearCorruptionEnvVars();
return;
}
} catch (err) {
debugLogger.error('OpenTUI boot failed; falling back to ink:', err);
writeStderrLine(
`Warning: OpenTUI failed to start — ${err instanceof Error ? err.message : String(err)} (falling back to ink)`,
);
}
} else {
debugLogger.debug(`TUI renderer: ${selection.reason}`);
}
const { startInteractiveUI } = await import('./ui/startInteractiveUI.js');
await startInteractiveUI(
config,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/ui/opentui/commands-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ describe('OPEN_TUI_COMMAND_ROUTES (built-in registry parity)', () => {
['history', ['message']],
['restore', ['message', 'tool']],
['setup-github', ['tool']],
['goal', ['goal_control', 'message', 'submit_prompt']],
['goal', ['goal_control', 'message']],
['cd', ['confirm_action', 'message']],
['init', ['confirm_action', 'message', 'submit_prompt']],
];
Expand Down
5 changes: 1 addition & 4 deletions packages/cli/src/ui/opentui/commands-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,7 @@ export const OPEN_TUI_COMMAND_ROUTES: readonly CommandRouteSpec[] = [
gatedBy: 'managed-memory',
},
{ name: 'forget', results: ['message'], gatedBy: 'managed-memory' },
{
name: 'goal',
results: ['goal_control', 'message', 'submit_prompt'],
},
{ name: 'goal', results: ['goal_control', 'message'] },
{ name: 'memory', results: ['dialog'], dialogs: ['memory'] },
{
name: 'model',
Expand Down
233 changes: 233 additions & 0 deletions packages/cli/src/ui/opentui/dialogs-confirm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Tests for the tool-confirmation dialog: outcome-option construction and
* the settle paths of {@link OpenTuiToolConfirmation} — Esc cancels, Enter
* commits the highlighted outcome, ask_user_question answers flow through the
* payload, a question with no options settles as cancel, and a settled call
* can never settle twice.
*/

import { beforeEach, describe, it, expect, vi } from 'vitest';
import { act, render } from '@testing-library/react';

// 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 },
}));

const mocks = vi.hoisted(() => {
const state = {
keyboardHandlers: [] as Array<(key: unknown) => void>,
};
// The components carry the @opentui/react JSX import source; map its
// primitive elements to DOM nodes so @testing-library/react can mount them.
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<string, unknown>,
children,
);
};
return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment };
}
return { state, buildJsxRuntime };
});

vi.mock('@opentui/react', () => ({
useKeyboard: (handler: (key: unknown) => void) => {
mocks.state.keyboardHandlers.push(handler);
},
}));
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());

import {
ToolConfirmationOutcome,
type ToolCallConfirmationDetails,
type ToolConfirmationPayload,
} from '@qwen-code/qwen-code-core';
import {
buildOutcomeOptions,
OpenTuiToolConfirmation,
} from './dialogs-confirm.js';

const onConfirmNoop = async () => {};

const execDetails = (
hideAlwaysAllow?: boolean,
): ToolCallConfirmationDetails => ({
type: 'exec',
title: 'Run command',
onConfirm: onConfirmNoop,
hideAlwaysAllow,
command: 'ls -la',
rootCommand: 'ls',
});

const askDetails = (
options?: Array<{ label: string; description: string }>,
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void> = async () => {},
): ToolCallConfirmationDetails => ({
type: 'ask_user_question',
title: 'A question',
questions: [
{
question: 'Pick one',
header: 'Choice',
options: options ?? [{ label: 'A', description: 'option a' }],
},
],
onConfirm,
});

describe('buildOutcomeOptions', () => {
it('offers allow-once, both always-allow rows, and cancel by default', () => {
const values = buildOutcomeOptions(execDetails()).map((o) => o.value);
expect(values).toEqual([
ToolConfirmationOutcome.ProceedOnce,
ToolConfirmationOutcome.ProceedAlwaysProject,
ToolConfirmationOutcome.ProceedAlwaysUser,
ToolConfirmationOutcome.Cancel,
]);
});

it('drops the always-allow rows when hideAlwaysAllow is set', () => {
const values = buildOutcomeOptions(execDetails(true)).map((o) => o.value);
expect(values).toEqual([
ToolConfirmationOutcome.ProceedOnce,
ToolConfirmationOutcome.Cancel,
]);
});

it('handles details without the hideAlwaysAllow field at all', () => {
// ask_user_question has no hideAlwaysAllow — reading it unguarded is a
// type error and would misrender the dialog for every question card.
const values = buildOutcomeOptions(askDetails()).map((o) => o.value);
expect(values).toContain(ToolConfirmationOutcome.ProceedOnce);
expect(values).toContain(ToolConfirmationOutcome.Cancel);
});
});

describe('OpenTuiToolConfirmation', () => {
function press(key: { name: string; sequence?: string }) {
act(() => {
for (const handler of mocks.state.keyboardHandlers) handler(key);
});
}

beforeEach(() => {
mocks.state.keyboardHandlers = [];
});

it('settles Cancel on Esc exactly once, whatever arrives afterwards', () => {
const onConfirm = vi.fn(async () => {});
const onSettled = vi.fn();
render(
<OpenTuiToolConfirmation
call={{
callId: 'call-1',
name: 'run_shell_command',
confirmationDetails: { ...execDetails(), onConfirm },
}}
onSettled={onSettled}
/>,
);
press({ name: 'escape' });
press({ name: 'return', sequence: '\r' });
press({ name: 'escape' });
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.Cancel,
undefined,
);
expect(onSettled).toHaveBeenCalledTimes(1);
});

it('commits the highlighted outcome on Enter', () => {
const onConfirm = vi.fn(async () => {});
render(
<OpenTuiToolConfirmation
call={{
callId: 'call-1',
name: 'run_shell_command',
confirmationDetails: { ...execDetails(), onConfirm },
}}
onSettled={() => {}}
/>,
);
press({ name: 'return', sequence: '\r' });
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
undefined,
);
});

it('answers an ask_user_question as ProceedOnce with the answers payload', () => {
const onConfirm = vi.fn<
(
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>
>(async () => {});
render(
<OpenTuiToolConfirmation
call={{
callId: 'call-1',
name: 'ask_user_question',
confirmationDetails: askDetails(undefined, onConfirm),
}}
onSettled={() => {}}
/>,
);
press({ name: 'return', sequence: '\r' });
expect(onConfirm).toHaveBeenCalledTimes(1);
const [outcome, payload] = onConfirm.mock.calls[0];
expect(outcome).toBe(ToolConfirmationOutcome.ProceedOnce);
expect(payload).toEqual({ answers: { '0': 'A' } });
});

it('settles Cancel when a question offers no options (nothing to answer)', () => {
const onConfirm = vi.fn(async () => {});
render(
<OpenTuiToolConfirmation
call={{
callId: 'call-1',
name: 'ask_user_question',
confirmationDetails: askDetails([], onConfirm),
}}
onSettled={() => {}}
/>,
);
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.Cancel,
undefined,
);
});
});
Loading
Loading