diff --git a/packages/cli/src/llm.tsx b/packages/cli/src/llm.tsx index 00b3209d8b3..185d00e0e63 100644 --- a/packages/cli/src/llm.tsx +++ b/packages/cli/src/llm.tsx @@ -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, diff --git a/packages/cli/src/ui/opentui/commands-registry.test.ts b/packages/cli/src/ui/opentui/commands-registry.test.ts index a4706aa0a20..c8973076d34 100644 --- a/packages/cli/src/ui/opentui/commands-registry.test.ts +++ b/packages/cli/src/ui/opentui/commands-registry.test.ts @@ -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']], ]; diff --git a/packages/cli/src/ui/opentui/commands-registry.ts b/packages/cli/src/ui/opentui/commands-registry.ts index d9f6f209b39..7cc0e1dbcb6 100644 --- a/packages/cli/src/ui/opentui/commands-registry.ts +++ b/packages/cli/src/ui/opentui/commands-registry.ts @@ -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', diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx new file mode 100644 index 00000000000..9e048c08798 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-confirm.test.tsx @@ -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, + 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 = 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( + , + ); + 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( + {}} + />, + ); + 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 + >(async () => {}); + render( + {}} + />, + ); + 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( + {}} + />, + ); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.Cancel, + undefined, + ); + }); +}); diff --git a/packages/cli/src/ui/opentui/dialogs-confirm.tsx b/packages/cli/src/ui/opentui/dialogs-confirm.tsx new file mode 100644 index 00000000000..f4c512fdb45 --- /dev/null +++ b/packages/cli/src/ui/opentui/dialogs-confirm.tsx @@ -0,0 +1,592 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Real confirmation renderers for the OpenTUI backend (Batch 6). + * + * Batch 5 shipped a deny-everything confirmation bridge because no confirmation + * renderer existed yet; a pending promise there would hang the dispatcher. This + * module replaces that stub with actual dialogs so model turns and shell + * commands can be approved interactively: + * + * - {@link OpenTuiToolConfirmation} renders a scheduler tool call that parked + * in `awaiting_approval` (edit / exec / mcp / info / plan / ask_user_question) + * and resolves it through `confirmationDetails.onConfirm`. Every code path + * calls `onConfirm` — a request that never settles would hang the whole turn. + * - {@link OpenTuiShellConfirmation} renders the slash-processor shell-command + * gate and resolves a {@link ShellConfirmationResolution}. + * - {@link OpenTuiActionConfirmation} renders a plain yes/no prompt (extension + * consent and friends) and resolves a boolean. + * + * Deliberate parity gaps (tracked as deferred review items, not silently + * dropped): the ink "modify with editor" flow is not offered because the + * live-turn scheduler is constructed with `getPreferredEditor: () => undefined`, + * and ask_user_question has no free-text "Other" option yet. + */ + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { + ToolConfirmationOutcome, + type ToolCallConfirmationDetails, + type ToolConfirmationPayload, +} from '@qwen-code/qwen-code-core'; +import { useKeyboard } from '@opentui/react'; +import { C } from './theme.js'; +import { toOriginalKey } from './key-map.js'; +import { + DialogFrame, + DialogSelect, + FooterHint, + useDialogSelect, + type DialogListItem, +} from './dialogs-shared.js'; +import { renderDiffBody } from './diff-render.js'; +import { tailWindow } from './messages.js'; +import { sanitizeTerminalText } from '../utils/textUtils.js'; +import type { ShellConfirmationResolution } from './commands-context.js'; +import { t } from '../../i18n/index.js'; + +/** Structural mirror of live-session's `WaitingCallInfo` (no import cycle). */ +export interface PendingToolConfirmation { + callId: string; + name: string; + confirmationDetails: ToolCallConfirmationDetails; +} + +/** Max body rows before the tail window truncates (keeps dialogs bounded). */ +const MAX_BODY_ROWS = 20; + +interface OutcomeOption { + label: string; + value: ToolConfirmationOutcome; +} + +/** + * Builds the approval choices for a tool call, honoring `hideAlwaysAllow` + * (explicit-interaction / PM ask rules that a persisted allow rule must not + * replace). Cancel is always present so the user can always decline. + */ +export function buildOutcomeOptions( + details: ToolCallConfirmationDetails, +): OutcomeOption[] { + const options: OutcomeOption[] = [ + { label: t('Yes, allow once'), value: ToolConfirmationOutcome.ProceedOnce }, + ]; + // hideAlwaysAllow lives on only some union members (not ask_user_question). + const hideAlways = + 'hideAlwaysAllow' in details && details.hideAlwaysAllow === true; + if (!hideAlways) { + options.push( + { + label: t('Always allow in this project'), + value: ToolConfirmationOutcome.ProceedAlwaysProject, + }, + { + label: t('Always allow for this user'), + value: ToolConfirmationOutcome.ProceedAlwaysUser, + }, + ); + } + options.push({ label: t('No (esc)'), value: ToolConfirmationOutcome.Cancel }); + return options; +} + +/** Renders a colored diff body within a bounded row window. */ +function DiffBody({ fileDiff }: { fileDiff: string }) { + const lines = useMemo(() => renderDiffBody(fileDiff), [fileDiff]); + const window = tailWindow(lines, MAX_BODY_ROWS); + return ( + + {window.hiddenCount > 0 ? ( + {`... ${window.hiddenCount} earlier line${window.hiddenCount === 1 ? '' : 's'} hidden ...`} + ) : null} + {window.visible.map((line, i) => ( + + {line.map((span, j) => ( + + {span.text} + + ))} + + ))} + + ); +} + +/** Plain, sanitized, line-bounded text body. */ +function TextBody({ text }: { text: string }) { + const rows = useMemo(() => { + const clean = sanitizeTerminalText(text); + const window = tailWindow(clean.split('\n'), MAX_BODY_ROWS); + return window.visible; + }, [text]); + return ( + + {rows.map((row, i) => ( + {row} + ))} + + ); +} + +/** The type-specific body of a tool confirmation. */ +function ConfirmationBody({ + details, +}: { + details: ToolCallConfirmationDetails; +}) { + switch (details.type) { + case 'edit': + return ( + + + {sanitizeTerminalText(details.fileName)} + + {details.warnings?.map((warning, i) => ( + + {sanitizeTerminalText(warning)} + + ))} + + + ); + case 'exec': + return ( + + + {sanitizeTerminalText(details.command)} + + {details.warnings?.map((warning, i) => ( + + {sanitizeTerminalText(warning)} + + ))} + + ); + case 'mcp': + return ( + + + {sanitizeTerminalText(details.toolDisplayName)} + + + {sanitizeTerminalText( + `${details.serverName} · ${details.toolName}`, + )} + + + ); + case 'info': + return ( + + + {details.urls?.map((url, i) => ( + + {sanitizeTerminalText(url)} + + ))} + + ); + case 'plan': + return ; + case 'ask_user_question': + // Handled by the dedicated question flow; this branch is unreachable + // when the caller routes questions to AskUserQuestionFlow. + return null; + default: { + const exhaustive: never = details; + return exhaustive; + } + } +} + +/** A row in the outcome selection list. */ +interface OutcomeItem extends DialogListItem { + label: string; +} + +/** + * Approve/decline selector shared by the tool and shell confirmations. Drives + * the outcome list with the shared selection-list keyboard behavior. + */ +function OutcomeSelect(props: { + options: OutcomeOption[]; + onChoose: (outcome: ToolConfirmationOutcome) => void; +}) { + const items = useMemo( + () => + props.options.map((option, index) => ({ + key: `${option.value}-${index}`, + value: option.value, + label: option.label, + })), + [props.options], + ); + const select = useDialogSelect({ + items, + numbers: false, + onSelect: (value) => props.onChoose(value), + }); + return ( + + select.setActiveIndex( + direction === 'up' ? select.activeIndex - 1 : select.activeIndex + 1, + ) + } + onSelectIndex={select.selectIndex} + renderLabel={(item, { isSelected }) => ( + {item.label} + )} + /> + ); +} + +export interface OpenTuiToolConfirmationProps { + call: PendingToolConfirmation; + /** Called after the call has been settled (approved, declined, or answered). */ + onSettled: () => void; +} + +/** + * Renders one awaiting tool call and settles it through + * `confirmationDetails.onConfirm`. ask_user_question gets its own flow; every + * other type shows its body plus the outcome list. + */ +export function OpenTuiToolConfirmation(props: OpenTuiToolConfirmationProps) { + const { call, onSettled } = props; + const details = call.confirmationDetails; + + const settledRef = useRef(false); + const settle = useCallback( + (outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => { + if (settledRef.current) return; + settledRef.current = true; + void details.onConfirm(outcome, payload); + onSettled(); + }, + [details, onSettled], + ); + + // Esc declines, matching the "No (esc)" option and the footer hint. + useKeyboard((key) => { + if (toOriginalKey(key).name === 'escape') { + settle(ToolConfirmationOutcome.Cancel); + } + }); + + if (details.type === 'ask_user_question') { + return ( + + + + {sanitizeTerminalText(details.title)} + + { + if (answers === null) { + settle(ToolConfirmationOutcome.Cancel); + } else { + settle(ToolConfirmationOutcome.ProceedOnce, { answers }); + } + }} + /> + + + ); + } + + const options = buildOutcomeOptions(details); + return ( + + + + {sanitizeTerminalText(details.title)} + + + + + settle(outcome)} + /> + + + + ); +} + +/** + * Sequential ask_user_question flow: walks the questions one at a time, + * collects single- or multi-select answers, and hands back an ink-parity + * answers record keyed by question index — or null when the user escapes. + */ +function AskUserQuestionFlow(props: { + details: Extract; + onAnswered: (answers: Record | null) => void; +}) { + const { details, onAnswered } = props; + const [index, setIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [selected, setSelected] = useState>(new Set()); + + const question = details.questions[index]; + const isMulti = question?.multiSelect === true; + + const commitQuestion = useCallback( + (value: string | undefined) => { + if (value === undefined) return; + const nextAnswers = { ...answers, [index]: value }; + setAnswers(nextAnswers); + setSelected(new Set()); + if (index + 1 < details.questions.length) { + setIndex(index + 1); + } else { + const out: Record = {}; + for (const [key, val] of Object.entries(nextAnswers)) { + out[String(key)] = val; + } + onAnswered(out); + } + }, + [answers, index, details.questions.length, onAnswered], + ); + + const items = useMemo>>( + () => + (question?.options ?? []).map((option, i) => ({ + key: `${option.label}-${i}`, + value: option.label, + })), + [question], + ); + + const select = useDialogSelect>({ + items, + numbers: false, + // For single-select we commit directly on Enter; for multi-select Enter is + // handled by the keyboard hook below (it submits the accumulated set), so + // onSelect must stay unset in that mode to avoid a double commit. + onSelect: isMulti ? undefined : (value) => commitQuestion(value), + resyncKey: index, + }); + + useKeyboard((key) => { + // Escape is owned by OpenTuiToolConfirmation (it settles the whole call). + if (!isMulti) return; + const original = toOriginalKey(key); + const current = items[select.activeIndex]; + if (!current) return; + if (original.name === 'space' || original.sequence === ' ') { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(current.value)) next.delete(current.value); + else next.add(current.value); + return next; + }); + return; + } + if (original.name === 'return') { + if (selected.size === 0) return; + commitQuestion([...selected].join(', ')); + } + }); + + // Defensive: an empty question list, or a question with no options, has + // nothing to answer; settle as cancel (from an effect — settling during + // render would update the parent mid-render) so the waiting call never + // hangs. + useEffect(() => { + if (details.questions.length === 0 || !question?.options?.length) { + onAnswered(null); + } + }, [details.questions.length, question, onAnswered]); + + if (!question) return null; + + return ( + + + {sanitizeTerminalText(question.header)} ({index + 1}/ + {details.questions.length}) + + {sanitizeTerminalText(question.question)} + + { + const checked = isMulti && selected.has(item.value); + const marker = isMulti ? (checked ? '[x] ' : '[ ] ') : ''; + return ( + + {marker + item.value} + + ); + }} + /> + + + + ); +} + +export interface OpenTuiShellConfirmationProps { + commands: readonly string[]; + onResolve: (resolution: ShellConfirmationResolution) => void; +} + +/** + * The slash-processor shell-command gate (ink ShellConfirmationDialog parity): + * shows the requested commands and resolves an approval outcome. Approving + * authorizes every requested command, exactly like the original. + */ +export function OpenTuiShellConfirmation(props: OpenTuiShellConfirmationProps) { + const { commands, onResolve } = props; + const options = useMemo( + () => [ + { + label: t('Yes, allow once'), + value: ToolConfirmationOutcome.ProceedOnce, + }, + { + label: t('Always allow in this project'), + value: ToolConfirmationOutcome.ProceedAlwaysProject, + }, + { + label: t('Always allow for this user'), + value: ToolConfirmationOutcome.ProceedAlwaysUser, + }, + { label: t('No (esc)'), value: ToolConfirmationOutcome.Cancel }, + ], + [], + ); + + useKeyboard((key) => { + if (toOriginalKey(key).name === 'escape') { + onResolve({ outcome: ToolConfirmationOutcome.Cancel }); + } + }); + + return ( + + + + {t('Shell Command Execution')} + + + {t('A custom command wants to run the following shell commands:')} + + + {commands.map((command, i) => ( + + {sanitizeTerminalText(command)} + + ))} + + + onResolve( + outcome === ToolConfirmationOutcome.Cancel + ? { outcome } + : { outcome, approvedCommands: [...commands] }, + ) + } + /> + + + + ); +} + +export interface OpenTuiActionConfirmationProps { + prompt: ReactNode; + onResolve: (confirmed: boolean) => void; +} + +/** + * A yes/no confirmation (extension consent and friends). Enter confirms, Esc + * declines; both paths resolve the promise so the caller never hangs. + */ +export function OpenTuiActionConfirmation( + props: OpenTuiActionConfirmationProps, +) { + const { prompt, onResolve } = props; + const options = useMemo>>( + () => [ + { key: 'yes', value: true }, + { key: 'no', value: false }, + ], + [], + ); + const select = useDialogSelect>({ + items: options, + numbers: false, + onSelect: (value) => onResolve(value), + }); + + useKeyboard((key) => { + if (toOriginalKey(key).name === 'escape') onResolve(false); + }); + + return ( + + + {prompt} + + ( + + {item.value ? t('Yes') : t('No')} + + )} + /> + + + + + ); +} diff --git a/packages/cli/src/ui/opentui/live-session-model.ts b/packages/cli/src/ui/opentui/live-session-model.ts index 53747de41ec..5ea54918fa4 100644 --- a/packages/cli/src/ui/opentui/live-session-model.ts +++ b/packages/cli/src/ui/opentui/live-session-model.ts @@ -528,27 +528,6 @@ export function settleOpenTools( return changed ? items : prev; } -export type LivePhase = - | 'idle' - | 'thinking' - | 'tool' - | 'approving' - | 'responding'; - -/** Streaming phase exposed to the status bar / spinner / border. */ -export function livePhase( - items: readonly LiveHistoryItem[], - streaming: boolean, -): LivePhase { - if (!streaming) return 'idle'; - const last = items[items.length - 1]; - if (last?.kind === 'thinking' && !last.done) return 'thinking'; - if (last?.kind === 'tool' && !last.done) - return last.confirm === 'pending' ? 'approving' : 'tool'; - if (last?.kind === 'task' && !last.done) return 'tool'; - return 'responding'; -} - /** Semantic palette slot of a goal card; the backend maps it to theme colors. */ export type GoalCardColor = | 'secondary' diff --git a/packages/cli/src/ui/opentui/live-turn.test.ts b/packages/cli/src/ui/opentui/live-turn.test.ts new file mode 100644 index 00000000000..67f5f446d3d --- /dev/null +++ b/packages/cli/src/ui/opentui/live-turn.test.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Pure-logic coverage for the live-turn driver: composer attachment folding + * (unsupported/unreadable images must surface as notices, never vanish) and + * the replay-batch fold (the transcript reset path for session switches). + */ + +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { foldBatch, imagePathsToParts } from './live-turn.js'; + +describe('imagePathsToParts', () => { + const dir = mkdtempSync(join(tmpdir(), 'opentui-live-turn-')); + + it('encodes a readable image as an inlineData part', () => { + const path = join(dir, 'ok.png'); + writeFileSync(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const { parts, notices } = imagePathsToParts([path]); + expect(notices).toEqual([]); + expect(parts).toHaveLength(1); + expect(parts[0]?.inlineData?.mimeType).toBe('image/png'); + expect(parts[0]?.inlineData?.data).toBe( + Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64'), + ); + }); + + it('reports unsupported extensions as notices instead of parts', () => { + const path = join(dir, 'notes.txt'); + writeFileSync(path, 'not an image'); + const { parts, notices } = imagePathsToParts([path]); + expect(parts).toEqual([]); + expect(notices).toEqual([`Unsupported image type: ${path}`]); + }); + + it('reports unreadable image paths as notices instead of parts', () => { + const missing = join(dir, 'missing.jpg'); + const { parts, notices } = imagePathsToParts([missing]); + expect(parts).toEqual([]); + expect(notices).toEqual([`Could not read image: ${missing}`]); + }); +}); + +describe('foldBatch', () => { + it('folds a replay batch into transcript items in order', () => { + const items = foldBatch([ + { type: 'user', text: 'hello', sentToModel: true }, + { type: 'text', delta: 'hi ' }, + { type: 'text', delta: 'there' }, + { type: 'error', text: 'boom', hint: 'retry later' }, + ]); + expect(items.map((item) => item.kind)).toEqual([ + 'user', + 'assistant', + 'error', + ]); + const assistant = items[1]; + // Consecutive text deltas merge into one streaming assistant row. + expect(assistant && 'text' in assistant ? assistant.text : '').toBe( + 'hi there', + ); + }); + + it('returns an empty transcript for an empty batch', () => { + expect(foldBatch([])).toEqual([]); + }); +}); diff --git a/packages/cli/src/ui/opentui/live-turn.ts b/packages/cli/src/ui/opentui/live-turn.ts new file mode 100644 index 00000000000..71e0264e20c --- /dev/null +++ b/packages/cli/src/ui/opentui/live-turn.ts @@ -0,0 +1,330 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Live-turn driver for the OpenTUI backend (Batch 6): wraps + * {@link livePromptEvents} in a React hook that folds stream events into + * {@link LiveHistoryItem}s, tracks scheduler confirmation requests, supports + * Esc-interrupt, and queues prompts submitted mid-turn. + * + * Mid-turn input semantics (ink useGeminiStream parity): a prompt submitted + * while a turn is in flight is queued; queued texts drain at the next tool + * boundary as genuine steering content (`drainSteering`), and whatever is + * still queued when the turn ends becomes the next turn — so user input is + * never silently dropped. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { readFileSync } from 'node:fs'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + collectText, + normalizeParts, + ToolConfirmationOutcome, +} from '@qwen-code/qwen-code-core'; +import type { Part, PartListUnion } from '@google/genai'; +import { + foldLiveEvent, + settleOpenTools, + type LiveHistoryItem, +} from './live-session-model.js'; +import { + livePromptEvents, + nextLivePromptId, + type WaitingCallInfo, +} from './live-session.js'; +import type { OpenTuiStreamEvent } from './event-adapter.js'; + +/** Extension → MIME for composer attachments (core SUPPORTED subset). */ +const IMAGE_MIME_BY_EXTENSION: Readonly> = { + bmp: 'image/bmp', + gif: 'image/gif', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + tiff: 'image/tiff', + webp: 'image/webp', + heic: 'image/heic', +}; + +/** + * Converts pasted/composer image paths into inlineData parts. Unreadable or + * unsupported paths come back as notices so nothing disappears silently. + */ +export function imagePathsToParts(imagePaths: readonly string[]): { + parts: Part[]; + notices: string[]; +} { + const parts: Part[] = []; + const notices: string[] = []; + for (const path of imagePaths) { + const ext = path.split('.').pop()?.toLowerCase() ?? ''; + const mimeType = IMAGE_MIME_BY_EXTENSION[ext]; + if (!mimeType) { + notices.push(`Unsupported image type: ${path}`); + continue; + } + try { + const data = readFileSync(path).toString('base64'); + parts.push({ inlineData: { mimeType, data } }); + } catch { + notices.push(`Could not read image: ${path}`); + } + } + return { parts, notices }; +} + +export interface UseOpenTuiLiveTurnOptions { + config: Config; +} + +/** + * Options a `submit_prompt` dispatcher outcome carries into the turn + * (ink SubmitPromptResult parity): per-turn model override, context-file + * memory refresh, and the post-turn callback. + */ +export interface OpenTuiSubmitOptions { + modelOverride?: string; + refreshContextFilesOnWrite?: boolean; + onComplete?: () => Promise; +} + +export interface OpenTuiLiveTurn { + items: readonly LiveHistoryItem[]; + streaming: boolean; + /** Scheduler calls parked in awaiting_approval, awaiting a dialog. */ + waitingCalls: readonly WaitingCallInfo[]; + /** Number of mid-turn prompts queued (composer queueLength parity). */ + queueLength: number; + /** Pops the whole queue back into the composer (Esc parity). */ + popQueue(): string | null; + /** + * Submits a prompt (or queues it when a turn is in flight). A + * `submit_prompt` outcome's per-turn options travel in `options`. + */ + submit( + content: PartListUnion, + imagePaths?: readonly string[], + options?: OpenTuiSubmitOptions, + ): void; + /** Aborts the in-flight turn (Esc). */ + interrupt(): void; + /** Replaces the transcript from a replay batch (session switch/resume). */ + resetTranscript(events: readonly OpenTuiStreamEvent[]): void; + /** Folds one externally produced event (update notices, startup warnings). */ + applyEvent(event: OpenTuiStreamEvent): void; + /** Drops a waiting call after its dialog settled. */ + settleWaitingCall(callId: string): void; +} + +/** Folds a replay batch into a fresh item list (single commit). */ +export function foldBatch( + events: readonly OpenTuiStreamEvent[], +): LiveHistoryItem[] { + let items: LiveHistoryItem[] = []; + for (const ev of events) items = foldLiveEvent(items, ev); + return items; +} + +export function useOpenTuiLiveTurn( + options: UseOpenTuiLiveTurnOptions, +): OpenTuiLiveTurn { + const { config } = options; + const [items, setItems] = useState([]); + const [streaming, setStreaming] = useState(false); + const [waitingCalls, setWaitingCalls] = useState( + [], + ); + const queueRef = useRef([]); + const [queueLength, setQueueLength] = useState(0); + const abortRef = useRef(null); + + const streamingRef = useRef(false); + // Generation counter: resetTranscript invalidates the in-flight turn so + // its late events, settles, and queue resubmits cannot touch the fresh + // transcript (P2-2). + const turnSeqRef = useRef(0); + // Render-synced mirror: resetTranscript reads parked calls synchronously. + const waitingCallsRef = useRef([]); + waitingCallsRef.current = waitingCalls; + + const setBusy = useCallback((busy: boolean) => { + if (streamingRef.current === busy) return; + streamingRef.current = busy; + setStreaming(busy); + }, []); + + const apply = useCallback((ev: OpenTuiStreamEvent) => { + setItems((prev) => foldLiveEvent(prev, ev)); + }, []); + + const pushQueue = useCallback((text: string) => { + queueRef.current.push(text); + setQueueLength(queueRef.current.length); + }, []); + + const drainQueue = useCallback((): string[] => { + const drained = queueRef.current; + queueRef.current = []; + setQueueLength(0); + return drained; + }, []); + + const runTurn = useCallback( + async ( + prompt: PartListUnion, + promptId: string, + turnOptions?: OpenTuiSubmitOptions, + ) => { + const seq = ++turnSeqRef.current; + const abort = new AbortController(); + abortRef.current = abort; + setBusy(true); + try { + for await (const ev of livePromptEvents(config, prompt, abort.signal, { + promptId, + modelOverride: turnOptions?.modelOverride, + refreshContextFilesOnWrite: turnOptions?.refreshContextFilesOnWrite, + drainSteering: drainQueue, + onWaitingCall: (call) => { + if (seq !== turnSeqRef.current) return; + setWaitingCalls((prev) => + prev.some((c) => c.callId === call.callId) + ? prev + : [...prev, call], + ); + }, + })) { + if (seq !== turnSeqRef.current) return; + apply(ev); + } + // ink parity (use-llm-stream submitPromptOnCompleteRef): fired once + // after the turn completes successfully, never on error/abort. + if (seq === turnSeqRef.current) { + void turnOptions?.onComplete?.().catch(() => {}); + } + } catch (error) { + if (seq !== turnSeqRef.current) return; + if (abort.signal.aborted) { + // Esc: ink settles every open tool as interrupted. + setItems((prev) => settleOpenTools([...prev], 'interrupted')); + } else { + apply({ + type: 'error', + text: error instanceof Error ? error.message : String(error), + }); + } + } finally { + // A stale turn must not clear a successor's controller. + if (abortRef.current === abort) abortRef.current = null; + if (seq === turnSeqRef.current) { + setBusy(false); + // Whatever survived the tool-boundary drain becomes the next turn. + const rest = queueRef.current; + if (rest.length > 0) { + const text = drainQueue().join('\n'); + if (text.trim()) { + apply({ type: 'user', text }); + void runTurn(text, nextLivePromptId(config)); + } + } + } + } + }, + [config, apply, drainQueue, setBusy], + ); + + const submit = useCallback( + ( + content: PartListUnion, + imagePaths?: readonly string[], + options?: OpenTuiSubmitOptions, + ) => { + const text = + typeof content === 'string' + ? content + : collectText(normalizeParts(content)); + if (streamingRef.current) { + // The steering queue is text-only; say so instead of losing the + // attachments without a trace. Per-turn options ride on the queued + // text's own submit_prompt, never on the steering drain. + if (imagePaths && imagePaths.length > 0) { + apply({ + type: 'warning', + text: 'Image attachments cannot be queued mid-turn and were dropped.', + }); + } + if (text.trim()) pushQueue(text); + return; + } + const { parts, notices } = imagePathsToParts(imagePaths ?? []); + for (const notice of notices) apply({ type: 'warning', text: notice }); + const prompt: PartListUnion = + parts.length > 0 ? [{ text }, ...parts] : content; + const promptId = nextLivePromptId(config); + apply({ type: 'user', text, promptId, sentToModel: true }); + void runTurn(prompt, promptId, options); + }, + [config, apply, pushQueue, runTurn], + ); + + const interrupt = useCallback(() => { + abortRef.current?.abort(); + }, []); + + const resetTranscript = useCallback( + (events: readonly OpenTuiStreamEvent[]) => { + // Invalidate the in-flight generation first: its late events and + // settles are dead on arrival (P2-2). + turnSeqRef.current += 1; + const abort = abortRef.current; + abortRef.current = null; + // Settle parked confirmations as Cancel so the scheduler's queue wakes + // and its completion callback fires — otherwise the generator parks + // forever (P2-3). Post-abort answers are treated as Cancel anyway + // (coreToolScheduler handleConfirmationResponse). + for (const call of waitingCallsRef.current) { + void call.confirmationDetails + .onConfirm(ToolConfirmationOutcome.Cancel) + .catch(() => {}); + } + abort?.abort(); + queueRef.current = []; + setQueueLength(0); + waitingCallsRef.current = []; + setWaitingCalls([]); + // Synchronous: a submit right after the reset must start a fresh turn, + // not land in the dying one's queue. + setBusy(false); + setItems(foldBatch(events)); + }, + [setBusy], + ); + + const settleWaitingCall = useCallback((callId: string) => { + setWaitingCalls((prev) => prev.filter((c) => c.callId !== callId)); + }, []); + + const popQueue = useCallback((): string | null => { + if (queueRef.current.length === 0) return null; + return drainQueue().join('\n'); + }, [drainQueue]); + + useEffect(() => () => abortRef.current?.abort(), []); + + return { + items, + streaming, + waitingCalls, + queueLength, + popQueue, + submit, + interrupt, + resetTranscript, + applyEvent: apply, + settleWaitingCall, + }; +} diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx index bfb3f47face..68175f6c9fc 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.test.tsx @@ -22,8 +22,9 @@ * forwarded as a structured argument rather than folded into the text; * - a failed dispatcher initialization rejects later submissions with the * recorded reason instead of misrouting to the model; - * - the confirmation bridge auto-denies (Cancel / false) so a command can never - * hang waiting for a renderer this shell does not own; + * - the confirmation bridge renders a real modal (shell / action) and the + * returned promise settles with the dialog's resolution, so a command can + * never hang waiting for a renderer; * - the session re-key reaches the entry seam, or reports that no owner is * wired to re-key the UI-side session state; * - user history rows drive the composer's history, and an error thrown in the @@ -47,6 +48,9 @@ const mocks = vi.hoisted(() => { host: null as unknown, inputProps: null as Record | null, dialogProps: null as Record | null, + toolConfirmProps: null as Record | null, + shellConfirmProps: null as Record | null, + actionConfirmProps: null as Record | null, keyboardHandlers: [] as Array<(key: unknown) => void>, }; async function buildJsxRuntime() { @@ -137,6 +141,20 @@ vi.mock('./input-prompt.js', () => ({ return 'input-prompt'; }, })); +vi.mock('./dialogs-confirm.js', () => ({ + OpenTuiToolConfirmation: (props: Record) => { + mocks.state.toolConfirmProps = props; + return 'tool-confirm'; + }, + OpenTuiShellConfirmation: (props: Record) => { + mocks.state.shellConfirmProps = props; + return 'shell-confirm'; + }, + OpenTuiActionConfirmation: (props: Record) => { + mocks.state.actionConfirmProps = props; + return 'action-confirm'; + }, +})); const CONFIG = {} as unknown as Config; const SETTINGS = { merged: {} } as unknown as LoadedSettings; @@ -180,6 +198,9 @@ describe('OpenTuiApp shell wiring', () => { mocks.state.host = null; mocks.state.inputProps = null; mocks.state.dialogProps = null; + mocks.state.toolConfirmProps = null; + mocks.state.shellConfirmProps = null; + mocks.state.actionConfirmProps = null; mocks.state.keyboardHandlers.length = 0; }); @@ -239,7 +260,36 @@ describe('OpenTuiApp shell wiring', () => { } satisfies OpenTuiDispatchOutcome; await submit('/rewind apply'); - expect(onSubmitPrompt).toHaveBeenCalledWith('rewind to checkpoint'); + expect(onSubmitPrompt).toHaveBeenCalledWith( + 'rewind to checkpoint', + undefined, + { + modelOverride: undefined, + onComplete: undefined, + refreshContextFilesOnWrite: undefined, + }, + ); + }); + + it("forwards a submit_prompt outcome's per-turn options to the seam", async () => { + const onSubmitPrompt = vi.fn(); + const onComplete = async () => {}; + renderApp({ onSubmitPrompt }); + await settle(); + mocks.state.handleResult = { + kind: 'submit_prompt', + content: 'summarize', + modelOverride: 'qwen3-max', + refreshContextFilesOnWrite: true, + onComplete, + } satisfies OpenTuiDispatchOutcome; + + await submit('/model summarize'); + expect(onSubmitPrompt).toHaveBeenCalledWith('summarize', undefined, { + modelOverride: 'qwen3-max', + refreshContextFilesOnWrite: true, + onComplete, + }); }); it('reaches the entry seam on a quit outcome', async () => { @@ -349,7 +399,7 @@ describe('OpenTuiApp shell wiring', () => { ).toBeTruthy(); }); - it('auto-denies the confirmation bridge so no command can hang', async () => { + it('renders the shell confirmation modal and settles with its resolution', async () => { renderApp(); await settle(); const host = mocks.state.host as { @@ -358,17 +408,69 @@ describe('OpenTuiApp shell wiring', () => { ) => Promise<{ outcome: ToolConfirmationOutcome }>; presentActionConfirmation: (prompt: unknown) => Promise; }; - // The dispatcher awaits this with the real, non-empty allowlist; a list - // left pending would park `run()` and the gateway busy flag forever. - await expect(host.presentShellConfirmation([])).resolves.toEqual({ - outcome: ToolConfirmationOutcome.Cancel, + + const pending = host.presentShellConfirmation([ + 'rm -rf build', + 'npm publish', + ]); + await act(async () => { + await Promise.resolve(); }); - await expect( - host.presentShellConfirmation(['rm -rf build', 'npm publish']), - ).resolves.toEqual({ outcome: ToolConfirmationOutcome.Cancel }); - await expect(host.presentActionConfirmation('delete?')).resolves.toBe( - false, - ); + expect(screen.getByText('shell-confirm')).toBeTruthy(); + expect(screen.queryByText('input-prompt')).toBeNull(); + const resolution = { + outcome: ToolConfirmationOutcome.ProceedOnce, + approvedCommands: ['rm -rf build', 'npm publish'], + }; + await act(async () => { + (mocks.state.shellConfirmProps?.['onResolve'] as (r: unknown) => void)( + resolution, + ); + }); + await expect(pending).resolves.toEqual(resolution); + expect(screen.getByText('input-prompt')).toBeTruthy(); + + const actionPending = host.presentActionConfirmation('delete?'); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByText('action-confirm')).toBeTruthy(); + await act(async () => { + (mocks.state.actionConfirmProps?.['onResolve'] as (c: boolean) => void)( + true, + ); + }); + await expect(actionPending).resolves.toBe(true); + }); + + it('gives a waiting tool call priority over the composer and settles it', async () => { + const onToolCallSettled = vi.fn(); + const call = { + callId: 'call-1', + name: 'run_shell_command', + confirmationDetails: { type: 'info', title: 'ok?' }, + } as never; + renderApp({ + waitingToolCalls: [call], + onToolCallSettled, + }); + await settle(); + expect(screen.getByText('tool-confirm')).toBeTruthy(); + expect(screen.queryByText('input-prompt')).toBeNull(); + + await act(async () => { + (mocks.state.toolConfirmProps?.['onSettled'] as () => void)(); + }); + expect(onToolCallSettled).toHaveBeenCalledWith('call-1'); + }); + + it('passes streaming state and interrupt through to the composer', async () => { + const onInterrupt = vi.fn(); + renderApp({ streaming: true, onInterrupt }); + await settle(); + expect(mocks.state.inputProps?.['streaming']).toBe(true); + (mocks.state.inputProps?.['onInterrupt'] as () => void)(); + expect(onInterrupt).toHaveBeenCalled(); }); it('catches a subtree render error inside the error boundary', async () => { diff --git a/packages/cli/src/ui/opentui/opentui-app-shell.tsx b/packages/cli/src/ui/opentui/opentui-app-shell.tsx index 9e6954b2c9b..cab706455e0 100644 --- a/packages/cli/src/ui/opentui/opentui-app-shell.tsx +++ b/packages/cli/src/ui/opentui/opentui-app-shell.tsx @@ -39,8 +39,7 @@ import { useSyncExternalStore, type ReactNode, } from 'react'; -import type { Config, Logger } from '@qwen-code/qwen-code-core'; -import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; +import type { Config, Logger, ApprovalMode } from '@qwen-code/qwen-code-core'; import type { PartListUnion } from '@google/genai'; import type { LoadedSettings } from '../../config/settings.js'; import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; @@ -51,6 +50,8 @@ import type { OpenTuiRuntime } from './opentui-runtime.js'; import type { OpenTuiDialogRequest } from './commands-registry.js'; import type { OpenTuiStreamEvent } from './event-adapter.js'; import type { ShellConfirmationResolution } from './commands-context.js'; +import type { WaitingCallInfo } from './live-session.js'; +import type { OpenTuiSubmitOptions } from './live-turn.js'; import { OpenTuiAppHost } from './opentui-host.js'; import { OpenTuiSlashGateway } from './slash-gateway.js'; import { @@ -60,6 +61,11 @@ import { import { OpenTuiErrorBoundary } from './opentui-error-boundary.js'; import { OpenTuiDialogMount } from './opentui-dialog-mount.js'; import { OpenTuiInputPrompt } from './input-prompt.js'; +import { + OpenTuiActionConfirmation, + OpenTuiShellConfirmation, + OpenTuiToolConfirmation, +} from './dialogs-confirm.js'; export interface OpenTuiAppProps { config: Config; @@ -80,11 +86,13 @@ export interface OpenTuiAppProps { * Runs a model turn for a plain prompt or a `submit_prompt` outcome. A * composer prompt passes its pasted image paths as a second, structured * argument: turning them into image parts (ink: attachments) belongs to the - * entry layer, so the shell must not flatten them into the prompt text. + * entry layer, so the shell must not flatten them into the prompt text. A + * `submit_prompt` outcome's per-turn options travel in the third argument. */ onSubmitPrompt?: ( content: PartListUnion, imagePaths?: readonly string[], + options?: OpenTuiSubmitOptions, ) => void; /** Reaches the entry after `/quit`; receives the closing history rows. */ onQuit?: (messages: readonly HistoryItem[]) => void; @@ -106,8 +114,48 @@ export interface OpenTuiAppProps { */ updateNotice?: string | null; availableTerminalHeight?: number; + + // --- Batch 6: live-turn + confirmation wiring --------------------------- + /** A live model turn is in flight (composer Esc interrupts, footer spins). */ + streaming?: boolean; + /** Aborts the in-flight turn (Esc while streaming). */ + onInterrupt?: () => void; + approvalMode?: ApprovalMode; + /** Mid-turn queued prompts (composer badge + Esc pop-back). */ + queueLength?: number; + onPopQueue?: () => string | null; + /** + * Scheduler calls parked in `awaiting_approval`. The shell renders the + * first one as a modal dialog; settlement flows through the call's own + * `onConfirm` and is reported back via {@link onToolCallSettled}. + */ + waitingToolCalls?: readonly WaitingCallInfo[]; + /** Drops a waiting call after its dialog settled. */ + onToolCallSettled?: (callId: string) => void; + /** U-10 parity: the entry logs/echoes render crashes (error boundary). */ + onRenderError?: (error: Error) => void; + /** Entry-owned composer buffer handle (early-input injection). */ + composerHandle?: { + current: { getText: () => string; setText: (text: string) => void } | null; + }; +} + +interface ShellModal { + kind: 'shell'; + id: number; + commands: readonly string[]; + resolve: (resolution: ShellConfirmationResolution) => void; } +interface ActionModal { + kind: 'action'; + id: number; + prompt: ReactNode; + resolve: (confirmed: boolean) => void; +} + +type ConfirmationModal = ShellModal | ActionModal; + export function OpenTuiApp(props: OpenTuiAppProps) { const { config, @@ -123,6 +171,14 @@ export function OpenTuiApp(props: OpenTuiAppProps) { onStartNewSession, onToggleVim, updateNotice, + streaming, + onInterrupt, + approvalMode, + queueLength, + onPopQueue, + waitingToolCalls, + onToolCallSettled, + onRenderError, } = props; const [dialog, setDialog] = useState(null); @@ -133,20 +189,57 @@ export function OpenTuiApp(props: OpenTuiAppProps) { const notify = useCallback((text: string) => setNoticeText(text), []); - // Neither confirmation renderer exists in this batch, so the bridge denies - // every request outright: a pending promise here would hang the dispatcher's - // `run()` (and the gateway's busy flag) for the rest of the session. + // Modal confirmation bridge (Batch 6): presentShell/presentAction enqueue + // a dialog and hand back the promise that its resolution settles. Both + // functions stay referentially stable (U-8) — they only touch setState and + // a sequence ref, never per-render state. + const [modals, setModals] = useState([]); + const modalSeq = useRef(0); const confirmations = useMemo( () => ({ - presentShell: () => - Promise.resolve({ - outcome: ToolConfirmationOutcome.Cancel, + presentShell: (commandsToRun: readonly string[]) => + new Promise((resolve) => { + modalSeq.current += 1; + setModals((prev) => [ + ...prev, + { + kind: 'shell', + id: modalSeq.current, + commands: commandsToRun, + resolve, + }, + ]); + }), + presentAction: (prompt: ReactNode) => + new Promise((resolve) => { + modalSeq.current += 1; + setModals((prev) => [ + ...prev, + { kind: 'action', id: modalSeq.current, prompt, resolve }, + ]); }), - presentAction: () => Promise.resolve(false), }), [], ); + const activeModal = modals[0] ?? null; + const closeShellModal = useCallback( + (modal: ShellModal, resolution: ShellConfirmationResolution) => { + setModals((prev) => prev.filter((m) => m.id !== modal.id)); + modal.resolve(resolution); + }, + [], + ); + const closeActionModal = useCallback( + (modal: ActionModal, confirmed: boolean) => { + setModals((prev) => prev.filter((m) => m.id !== modal.id)); + modal.resolve(confirmed); + }, + [], + ); + + const activeToolCall = waitingToolCalls?.[0] ?? null; + const transcript = useMemo( () => ({ reset: (events: OpenTuiStreamEvent[]) => onTranscriptReset?.(events), @@ -190,6 +283,12 @@ export function OpenTuiApp(props: OpenTuiAppProps) { useCallback(() => host.getVersion(), [host]), ); + // Mirror the live-turn state onto the host so command gating (isIdle) + // reflects an in-flight model turn, not just dispatcher processing. + useEffect(() => { + host.setStreaming(!!streaming); + }, [host, streaming]); + const gateway = useMemo(() => new OpenTuiSlashGateway(), []); const reloadRef = useRef<(() => void | Promise) | null>(null); @@ -238,7 +337,12 @@ export function OpenTuiApp(props: OpenTuiAppProps) { setDialog(outcome.request); return; case 'submit_prompt': - if (onSubmitPrompt) onSubmitPrompt(outcome.content); + if (onSubmitPrompt) + onSubmitPrompt(outcome.content, undefined, { + modelOverride: outcome.modelOverride, + refreshContextFilesOnWrite: outcome.refreshContextFilesOnWrite, + onComplete: outcome.onComplete, + }); else notify('The live prompt turn is not wired in this shell.'); return; case 'schedule_tool': @@ -285,12 +389,41 @@ export function OpenTuiApp(props: OpenTuiAppProps) { ); return ( - + onRenderError?.(error)} + > {renderMain ? renderMain() : null} - {!dialog && updateNotice ? {updateNotice} : null} + {!dialog && !activeModal && !activeToolCall && updateNotice ? ( + {updateNotice} + ) : null} {noticeText ? {noticeText} : null} - {dialog ? ( + {activeToolCall ? ( + onToolCallSettled?.(activeToolCall.callId)} + /> + ) : activeModal ? ( + activeModal.kind === 'shell' ? ( + + closeShellModal(activeModal, resolution) + } + /> + ) : ( + + closeActionModal(activeModal, confirmed) + } + /> + ) + ) : dialog ? ( )} diff --git a/packages/cli/src/ui/opentui/renderer-selection.test.ts b/packages/cli/src/ui/opentui/renderer-selection.test.ts new file mode 100644 index 00000000000..c24d54d0dc4 --- /dev/null +++ b/packages/cli/src/ui/opentui/renderer-selection.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + compareVersions, + isOpenTuiRuntimeSupported, + parseVersion, + selectTuiRenderer, +} from './renderer-selection.js'; + +describe('parseVersion', () => { + it('parses dotted versions', () => { + expect(parseVersion('1.3.0')).toEqual([1, 3, 0]); + expect(parseVersion('26.4.0')).toEqual([26, 4, 0]); + }); + + it('tolerates a leading v and pre-release suffixes', () => { + expect(parseVersion('v1.3.14')).toEqual([1, 3, 14]); + expect(parseVersion('1.3.0-beta')).toEqual([1, 3, 0]); + }); + + it('stops at the first non-numeric segment', () => { + expect(parseVersion('2.x')).toEqual([2]); + expect(parseVersion('')).toEqual([]); + }); +}); + +describe('compareVersions', () => { + it('orders versions numerically, not lexically', () => { + expect(compareVersions('1.3.0', '1.3.0')).toBe(0); + expect(compareVersions('1.10.0', '1.9.9')).toBe(1); + expect(compareVersions('1.2.9', '1.3.0')).toBe(-1); + // Missing segments compare as zero. + expect(compareVersions('1.3', '1.3.0')).toBe(0); + expect(compareVersions('26', '26.4.0')).toBe(-1); + }); + + it('sorts a pre-release below its numerically equal release', () => { + expect(compareVersions('1.3.0-beta', '1.3.0')).toBe(-1); + expect(compareVersions('1.3.0', '1.3.0-beta')).toBe(1); + expect(compareVersions('1.3.0-rc.1', '1.3.0-rc.1')).toBe(0); + // The floor gate therefore rejects a pre-release of the floor itself. + expect(isOpenTuiRuntimeSupported({ bun: '1.3.0-beta' })).toBe(false); + }); +}); + +describe('isOpenTuiRuntimeSupported', () => { + it('accepts Bun at or above the floor', () => { + expect(isOpenTuiRuntimeSupported({ bun: '1.3.0' })).toBe(true); + expect(isOpenTuiRuntimeSupported({ bun: '1.3.14' })).toBe(true); + expect(isOpenTuiRuntimeSupported({ bun: '2.0.0' })).toBe(true); + }); + + it('rejects Bun below the floor', () => { + expect(isOpenTuiRuntimeSupported({ bun: '1.2.9' })).toBe(false); + expect(isOpenTuiRuntimeSupported({ bun: '1.0.0' })).toBe(false); + }); + + it('accepts Node at or above the floor', () => { + expect(isOpenTuiRuntimeSupported({ node: '26.4.0' })).toBe(true); + expect(isOpenTuiRuntimeSupported({ node: '27.0.0' })).toBe(true); + }); + + it('rejects Node below the floor, including the project minimum', () => { + expect(isOpenTuiRuntimeSupported({ node: '24.15.0' })).toBe(false); + expect(isOpenTuiRuntimeSupported({ node: '22.0.0' })).toBe(false); + expect(isOpenTuiRuntimeSupported({ node: '26.3.9' })).toBe(false); + }); + + it('prefers the Bun version when both are reported', () => { + expect(isOpenTuiRuntimeSupported({ bun: '1.3.0', node: '22.0.0' })).toBe( + true, + ); + expect(isOpenTuiRuntimeSupported({ bun: '1.0.0', node: '27.0.0' })).toBe( + false, + ); + }); + + it('rejects an unknown runtime', () => { + expect(isOpenTuiRuntimeSupported({})).toBe(false); + }); +}); + +describe('selectTuiRenderer', () => { + const supported = { bun: '1.3.14' }; + const unsupported = { node: '24.15.0' }; + + it('defaults to ink when the flag is unset or empty', () => { + expect(selectTuiRenderer(undefined, supported).renderer).toBe('ink'); + expect(selectTuiRenderer('', supported).renderer).toBe('ink'); + expect(selectTuiRenderer(' ', supported).renderer).toBe('ink'); + }); + + it('selects opentui only for the exact value (case-insensitive)', () => { + expect(selectTuiRenderer('opentui', supported).renderer).toBe('opentui'); + expect(selectTuiRenderer('OpenTUI', supported).renderer).toBe('opentui'); + expect(selectTuiRenderer(' opentui ', supported).renderer).toBe('opentui'); + expect(selectTuiRenderer('ink', supported).renderer).toBe('ink'); + expect(selectTuiRenderer('bogus', supported).renderer).toBe('ink'); + }); + + it('falls back to ink when the runtime is unsupported', () => { + const selection = selectTuiRenderer('opentui', unsupported); + expect(selection.renderer).toBe('ink'); + expect(selection.reason).toContain('native FFI'); + }); + + it('keeps ink when the flag explicitly asks for ink', () => { + expect(selectTuiRenderer('ink', supported).renderer).toBe('ink'); + }); +}); diff --git a/packages/cli/src/ui/opentui/renderer-selection.ts b/packages/cli/src/ui/opentui/renderer-selection.ts new file mode 100644 index 00000000000..9a38b74311d --- /dev/null +++ b/packages/cli/src/ui/opentui/renderer-selection.ts @@ -0,0 +1,135 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Renderer selection for the OpenTUI migration (Batch 6). + * + * This is the single place that reads `QWEN_TUI_RENDERER` and decides whether + * the interactive entry mounts the OpenTUI backend or the ink tree. It is + * deliberately dependency-free — it must NOT import `@opentui/*` or any + * renderer module, because it runs on every interactive startup (including on + * runtimes where OpenTUI cannot load) and before the renderer of record is + * chosen. The actual `createCliRenderer` probe lives in the entry, which can + * fall back to ink if construction throws even when this gate said "opentui". + * + * `QWEN_TUI_RENDERER` is the first reader of the flag: nothing else in the + * repository references it, so the default renderer stays ink until this gate + * is both requested and supported. + */ + +/** The environment variable that selects the TUI renderer. */ +export const TUI_RENDERER_ENV_VAR = 'QWEN_TUI_RENDERER'; + +/** The only non-default renderer value this gate recognizes. */ +export const OPEN_TUI_RENDERER_VALUE = 'opentui'; + +export type TuiRendererChoice = 'ink' | 'opentui'; + +export interface RendererSelection { + renderer: TuiRendererChoice; + /** Human-readable reason, for debug logging. Never shown to the user. */ + reason: string; +} + +/** Minimum runtime versions that can initialize the OpenTUI native FFI. */ +const MIN_BUN_VERSION = '1.3.0'; +const MIN_NODE_VERSION = '26.4.0'; + +/** + * Parses a dotted version string into its numeric segments. Non-numeric or + * missing segments stop the parse; a leading `v` is tolerated. + */ +export function parseVersion(version: string): number[] { + const cleaned = version.trim().replace(/^v/i, ''); + const segments: number[] = []; + for (const part of cleaned.split('.')) { + // A segment like "3-beta" contributes its leading integer only. + const match = /^(\d+)/.exec(part); + if (!match) break; + segments.push(Number(match[1])); + } + return segments; +} + +/** + * Returns -1 when a < b, 0 when equal, 1 when a > b. Numerically equal + * versions break the tie semver-style: a pre-release sorts below its release + * (`1.3.0-beta < 1.3.0`), so a floor of `1.3.0` rejects `1.3.0-beta`. + */ +export function compareVersions(a: string, b: string): -1 | 0 | 1 { + const as = parseVersion(a); + const bs = parseVersion(b); + const length = Math.max(as.length, bs.length); + for (let i = 0; i < length; i++) { + const left = as[i] ?? 0; + const right = bs[i] ?? 0; + if (left !== right) return left < right ? -1 : 1; + } + const aPre = a.includes('-'); + const bPre = b.includes('-'); + if (aPre !== bPre) return aPre ? -1 : 1; + return 0; +} + +export interface RuntimeVersionProbe { + /** e.g. "1.3.14" when running under Bun, otherwise undefined. */ + bun?: string; + /** e.g. "26.4.0" when running under Node, otherwise undefined. */ + node?: string; +} + +/** + * Whether the current runtime can initialize the OpenTUI native FFI. Bun >= + * 1.3.0 and Node >= 26.4.0 (with `--experimental-ffi`) are the supported + * floors; anything older returns false so the entry stays on ink. + */ +export function isOpenTuiRuntimeSupported( + probe: RuntimeVersionProbe = { + bun: process.versions['bun'], + node: process.versions['node'], + }, +): boolean { + if (probe.bun) { + return compareVersions(probe.bun, MIN_BUN_VERSION) >= 0; + } + if (probe.node) { + return compareVersions(probe.node, MIN_NODE_VERSION) >= 0; + } + return false; +} + +/** + * Chooses the renderer for an interactive session. + * + * The decision is intentionally conservative: OpenTUI is selected only when + * the flag explicitly requests it AND the runtime can support it. Any other + * combination — unset flag, an unrecognized value, or an unsupported runtime — + * keeps ink, which remains the default renderer throughout the migration. + */ +export function selectTuiRenderer( + envValue: string | undefined = process.env[TUI_RENDERER_ENV_VAR], + probe?: RuntimeVersionProbe, +): RendererSelection { + const requested = envValue?.trim().toLowerCase(); + if (requested !== OPEN_TUI_RENDERER_VALUE) { + return { + renderer: 'ink', + reason: requested + ? `${TUI_RENDERER_ENV_VAR}=${envValue} is not "${OPEN_TUI_RENDERER_VALUE}"` + : `${TUI_RENDERER_ENV_VAR} is not set`, + }; + } + if (!isOpenTuiRuntimeSupported(probe)) { + return { + renderer: 'ink', + reason: `OpenTUI requested but the runtime cannot initialize its native FFI (needs Bun >= ${MIN_BUN_VERSION} or Node >= ${MIN_NODE_VERSION})`, + }; + } + return { + renderer: 'opentui', + reason: `${TUI_RENDERER_ENV_VAR}=${OPEN_TUI_RENDERER_VALUE} on a supported runtime`, + }; +} diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx new file mode 100644 index 00000000000..288b7f5eddb --- /dev/null +++ b/packages/cli/src/ui/opentui/start-opentui-ui.test.tsx @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Fallback-contract tests for the OpenTUI entry (Batch 6): startup must + * return `false` instead of crashing whenever the OpenTUI boot cannot + * complete — renderer creation, runtime sidecar I/O, or anything past it — + * so llm.tsx falls back to ink. The happy path pins the teardown-cleanup + * ordering the exit path relies on. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => { + const state = { + renderer: { + destroy: vi.fn(), + }, + root: { + render: vi.fn(), + unmount: vi.fn(), + }, + runtime: { + writeRuntimeSidecar: vi.fn(async () => {}), + startPressureMonitor: vi.fn(), + shutdown: vi.fn(async () => {}), + }, + sidecarRejects: false, + cleanups: [] as Array<() => void | Promise>, + stderrLines: [] as string[], + }; + 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', () => ({ + createCliRenderer: vi.fn(async () => mocks.state.renderer), + SyntaxStyle: { fromStyles: () => ({}) }, + MouseButton: { LEFT: 0 }, +})); +vi.mock('@opentui/react', () => ({ + createRoot: vi.fn(() => mocks.state.root), + useKeyboard: () => {}, + useTerminalDimensions: () => ({ width: 120, height: 40 }), +})); +vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime()); +vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime()); + +vi.mock('./opentui-runtime.js', () => ({ + OpenTuiRuntime: { + create: vi.fn(() => mocks.state.runtime), + }, +})); +vi.mock('./opentui-app-shell.js', () => ({ OpenTuiApp: () => null })); +vi.mock('./transcript-view.js', () => ({ + OpenTuiTranscriptView: () => null, +})); +vi.mock('./live-turn.js', () => ({ + useOpenTuiLiveTurn: () => ({ + items: [], + streaming: false, + waitingCalls: [], + queueLength: 0, + popQueue: () => null, + submit: () => {}, + interrupt: () => {}, + resetTranscript: () => {}, + applyEvent: () => {}, + settleWaitingCall: () => {}, + }), +})); +vi.mock('../handleAutoUpdate.js', () => ({ + setUpdateHandler: () => ({ cleanup: () => {} }), +})); +vi.mock('../hooks/useLogger.js', () => ({ useLogger: () => null })); +vi.mock('../../startup/startup-prefetch.js', () => ({ + startPostRenderPrefetches: () => {}, +})); +vi.mock('../../utils/version.js', () => ({ + getCliVersion: async () => '1.0.0', +})); +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStderrLine: (line: string) => { + mocks.state.stderrLines.push(line); + }, + writeStdoutLine: () => {}, + writeStderrLineSafe: () => {}, +})); +vi.mock('../../utils/cleanup.js', () => ({ + registerCleanup: (fn: () => void | Promise) => { + mocks.state.cleanups.push(fn); + return () => {}; + }, +})); +vi.mock('./exit-lifecycle.js', () => ({ + EXIT_CODE_INTERRUPT: 130, + exitSession: vi.fn(), +})); +vi.mock('./early-input.js', () => ({ + drainCapturedInputAsText: () => '', + injectCapturedInput: () => () => {}, +})); +vi.mock('./resume-session.js', () => ({ + resumeEventsFromConfig: () => null, +})); + +import { startOpenTuiUI } from './start-opentui-ui.js'; +import { createCliRenderer } from '@opentui/core'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import type { InitializationResult } from '../../core/initializer.js'; + +function buildConfig(): Config { + return { + getSessionId: () => 'test-session-id', + getTargetDir: () => '/tmp/project', + getApprovalMode: () => 'default', + getChatRecordingService: () => null, + isTelemetryInitializationDeferred: () => false, + getHookSystem: () => null, + getTranscriptPath: () => '/tmp/project/transcript.jsonl', + trackSessionRegistration: vi.fn(), + unregisterSessionRegistry: vi.fn(), + } as unknown as Config; +} + +const settings = { + merged: { ui: { hideWindowTitle: true } }, +} as unknown as LoadedSettings; + +describe('startOpenTuiUI fallback contract', () => { + beforeEach(() => { + mocks.state.sidecarRejects = false; + mocks.state.cleanups = []; + mocks.state.stderrLines = []; + mocks.state.renderer.destroy.mockClear(); + mocks.state.root.unmount.mockClear(); + mocks.state.runtime.shutdown.mockClear(); + }); + + it('returns false when the renderer cannot be created', async () => { + vi.mocked(createCliRenderer).mockRejectedValueOnce( + new Error('no native FFI'), + ); + const started = await startOpenTuiUI( + buildConfig(), + settings, + [], + '/tmp/project', + {} as InitializationResult, + ); + expect(started).toBe(false); + expect(mocks.state.stderrLines[0]).toContain('falling back to ink'); + expect(mocks.state.cleanups).toHaveLength(0); + }); + + it('tears the renderer down and returns false when the boot body throws', async () => { + mocks.state.sidecarRejects = true; + mocks.state.runtime.writeRuntimeSidecar.mockRejectedValueOnce( + new Error('disk exploded'), + ); + const started = await startOpenTuiUI( + buildConfig(), + settings, + [], + '/tmp/project', + {} as InitializationResult, + ); + expect(started).toBe(false); + expect(mocks.state.root.unmount).toHaveBeenCalled(); + expect(mocks.state.renderer.destroy).toHaveBeenCalled(); + expect(mocks.state.runtime.shutdown).toHaveBeenCalled(); + expect(mocks.state.stderrLines[0]).toContain('disk exploded'); + expect(mocks.state.cleanups).toHaveLength(0); + }); + + it('boots, arms the teardown cleanups, and returns true on success', async () => { + const config = buildConfig(); + const started = await startOpenTuiUI( + config, + settings, + [], + '/tmp/project', + {} as InitializationResult, + ); + expect(started).toBe(true); + expect(mocks.state.stderrLines).toHaveLength(0); + expect(mocks.state.cleanups.length).toBeGreaterThanOrEqual(3); + expect(config.trackSessionRegistration).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/opentui/start-opentui-ui.tsx b/packages/cli/src/ui/opentui/start-opentui-ui.tsx new file mode 100644 index 00000000000..beb6729ad90 --- /dev/null +++ b/packages/cli/src/ui/opentui/start-opentui-ui.tsx @@ -0,0 +1,434 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * OpenTUI entry wiring (Batch 6) — the opentui-side peer of + * `startInteractiveUI.tsx`. llm.tsx calls this only after + * `selectTuiRenderer()` picked opentui; returning `false` here (the renderer + * failed to initialize) falls back to ink, which stays the default renderer. + * + * Owns everything the shell names as entry seams: the native renderer + * lifecycle, the runtime sidecar, the live model turn, tool/shell/action + * confirmation delivery, the two-press exit guard, the update-notification + * wiring, startup-warning and resume replay, and the exit-echo cleanup chain + * (render-error echo + resume hint, ink parity). + */ + +import { stat } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + createCliRenderer, + type CliRenderer, + type KeyEvent, +} from '@opentui/core'; +import { createRoot, useKeyboard, useTerminalDimensions } from '@opentui/react'; +import type { PartListUnion } from '@google/genai'; +import { + createDebugLogger, + isDebugLogFileEnabled, + registerSession, + SessionEndReason, + ToolConfirmationOutcome, + type Config, +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { isValidSessionId } from '../../config/config.js'; +import type { InitializationResult } from '../../core/initializer.js'; +import type { ExtensionRefreshState } from '../../config/extension-refresh-state.js'; +import { registerCleanup } from '../../utils/cleanup.js'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { profileCheckpoint } from '../../utils/startupProfiler.js'; +import { startPostRenderPrefetches } from '../../startup/startup-prefetch.js'; +import { + computeWindowTitle, + writeTerminalTitle, +} from '../utils/windowTitle.js'; +import { getCliVersion } from '../../utils/version.js'; +import { sanitizeTerminalText } from '../utils/textUtils.js'; +import { t } from '../../i18n/index.js'; +import { + SessionStatsProvider, + useSessionStats, +} from '../contexts/SessionContext.js'; +import { MessageType, type HistoryItemWithoutId } from '../types.js'; +import { setUpdateHandler } from '../handleAutoUpdate.js'; +import { useLogger } from '../hooks/useLogger.js'; +import type { UpdateObject } from '../utils/updateCheck.js'; +import { OpenTuiApp } from './opentui-app-shell.js'; +import { OpenTuiRuntime } from './opentui-runtime.js'; +import { OpenTuiTranscriptView } from './transcript-view.js'; +import { useOpenTuiLiveTurn, type OpenTuiSubmitOptions } from './live-turn.js'; +import { consumeLastRenderError } from './opentui-error-boundary.js'; +import { createExitGuard, exitGuardHint } from './exit-guard.js'; +import { EXIT_CODE_INTERRUPT, exitSession } from './exit-lifecycle.js'; +import { resumeEventsFromConfig } from './resume-session.js'; +import { + drainCapturedInputAsText, + injectCapturedInput, +} from './early-input.js'; + +const debugLogger = createDebugLogger('OPEN_TUI_START'); + +export interface StartOpenTuiUIOptions { + postRenderConnectIde?: boolean; + postRenderInitializeTelemetry?: boolean; + extensionRefreshState?: ExtensionRefreshState; +} + +interface OpenTuiEntryAppProps { + config: Config; + settings: LoadedSettings; + runtime: OpenTuiRuntime; + startupWarnings: readonly string[]; + extensionRefreshState?: ExtensionRefreshState; + /** Decoded early-captured keystrokes; injected into the composer once. */ + capturedText: string; +} + +function OpenTuiEntryApp({ + config, + settings, + runtime, + startupWarnings, + extensionRefreshState, + capturedText, +}: OpenTuiEntryAppProps) { + const { width, height } = useTerminalDimensions(); + const { stats, startNewSession } = useSessionStats(); + const logger = useLogger(config.storage, config.getSessionId()); + const live = useOpenTuiLiveTurn({ config }); + const { applyEvent, submit, interrupt, resetTranscript, settleWaitingCall } = + live; + + const statsRef = useRef(stats); + useEffect(() => { + statsRef.current = stats; + }, [stats]); + const getSessionStats = useCallback(() => statsRef.current, []); + + // --- early-input injection (ink initialCapturedInput parity) ------------- + const composerHandle = useRef<{ + getText: () => string; + setText: (text: string) => void; + } | null>(null); + useEffect( + () => injectCapturedInput(() => composerHandle.current, capturedText), + [capturedText], + ); + + // --- startup warnings + resume replay -------------------------------------- + useEffect(() => { + const resumeEvents = resumeEventsFromConfig(config); + if (resumeEvents) resetTranscript(resumeEvents); + for (const warning of startupWarnings) { + applyEvent({ type: 'warning', text: warning }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // --- update-notification wiring (ink AppContainer parity) ----------------- + const [updateNotice, setUpdateNotice] = useState(null); + const isIdleRef = useRef(true); + useEffect(() => { + isIdleRef.current = !live.streaming; + }, [live.streaming]); + const addUpdateItem = useCallback( + (item: HistoryItemWithoutId) => { + if (item.type === MessageType.INFO) { + applyEvent({ type: 'info', text: item.text }); + } else if (item.type === MessageType.WARNING) { + applyEvent({ type: 'warning', text: item.text }); + } else if (item.type === MessageType.ERROR) { + applyEvent({ type: 'error', text: item.text }); + } + }, + [applyEvent], + ); + const setUpdateInfo = useCallback((info: UpdateObject | null) => { + setUpdateNotice(info?.message ?? null); + }, []); + useEffect(() => { + const { cleanup } = setUpdateHandler( + addUpdateItem, + setUpdateInfo, + isIdleRef, + ); + return cleanup; + }, [addUpdateItem, setUpdateInfo]); + + // --- two-press exit guard (ink useDoublePress parity) --------------------- + const [exitHint, setExitHint] = useState(null); + const exitGuard = useMemo( + () => createExitGuard({ onWindowExpired: () => setExitHint(null) }), + [], + ); + useEffect(() => () => exitGuard.dispose(), [exitGuard]); + const streamingRef = useRef(live.streaming); + useEffect(() => { + streamingRef.current = live.streaming; + }, [live.streaming]); + const waitingCallsRef = useRef(live.waitingCalls); + useEffect(() => { + waitingCallsRef.current = live.waitingCalls; + }, [live.waitingCalls]); + useKeyboard((key: KeyEvent) => { + if (!key.ctrl || (key.name !== 'c' && key.name !== 'd')) return; + // ink handleExit cascade parity: a parked confirmation closes first + // (settled as Cancel), then an in-flight turn interrupts, and only a + // fully idle session arms the two-press exit window. + const parked = waitingCallsRef.current[0]; + if (parked) { + void parked.confirmationDetails + .onConfirm(ToolConfirmationOutcome.Cancel) + .catch(() => {}); + settleWaitingCall(parked.callId); + return; + } + if (streamingRef.current) { + interrupt(); + return; + } + const guardKey = key.name === 'd' ? 'ctrl-d' : 'ctrl-c'; + if (exitGuard.press(guardKey) === 'exit') { + void exitSession(EXIT_CODE_INTERRUPT); + } else { + setExitHint(exitGuardHint(guardKey)); + } + }); + + // --- seams handed to the shell --------------------------------------------- + const renderMain = useCallback( + () => ( + + + {exitHint ? {exitHint} : null} + + ), + [live.items, width, height, exitHint], + ); + + const handleRenderError = useCallback((error: Error) => { + debugLogger.error( + `[FATAL_RENDER_ERROR] ${error.message}\n${error.stack ?? ''}`, + ); + // ink parity: the fallback unmounted the composer and Ctrl+C handling; + // schedule a graceful exit so the session cannot hang. + setTimeout(() => { + void exitSession(1); + }, 5000); + }, []); + + const handleStartNewSession = useCallback( + (sessionId: string) => startNewSession(sessionId), + [startNewSession], + ); + + const handleSubmitPrompt = useCallback( + ( + content: PartListUnion, + imagePaths?: readonly string[], + options?: OpenTuiSubmitOptions, + ) => submit(content, imagePaths, options), + [submit], + ); + + const handleQuit = useCallback(() => { + void exitSession(0); + }, []); + + return ( + + ); +} + +/** + * Boots the OpenTUI backend. Returns `false` when the OpenTUI boot cannot + * complete (renderer creation, runtime sidecar, or first render) so the + * caller falls back to ink — startup must never die here. + */ +export async function startOpenTuiUI( + config: Config, + settings: LoadedSettings, + startupWarnings: string[], + workspaceRoot: string = process.cwd(), + _initializationResult: InitializationResult, + options: StartOpenTuiUIOptions = {}, +): Promise { + let renderer: CliRenderer; + try { + renderer = await createCliRenderer({ exitOnCtrlC: false, useMouse: true }); + } catch (err) { + debugLogger.error('OpenTUI renderer initialization failed:', err); + writeStderrLine( + `Warning: OpenTUI renderer unavailable — ${err instanceof Error ? err.message : String(err)} (falling back to ink)`, + ); + return false; + } + + // Everything past renderer creation is also fallible (sidecar I/O, render, + // prefetch wiring); a rejection must tear the renderer down and fall back + // to ink instead of crashing the process (B2). + const root = createRoot(renderer); + let runtime: OpenTuiRuntime | null = null; + try { + const version = await getCliVersion(); + if ( + !settings.merged.ui?.hideWindowTitle && + settings.merged.ui?.showStatusInTitle !== false + ) { + writeTerminalTitle( + (value) => process.stdout.write(value), + computeWindowTitle(basename(workspaceRoot)), + ); + } + + runtime = OpenTuiRuntime.create({ config, version }); + await runtime.writeRuntimeSidecar(); + runtime.startPressureMonitor(); + + // Drain the early-captured input exactly once, before the renderer takes + // over stdin; the decoded text is injected into the composer after mount. + const capturedText = drainCapturedInputAsText(); + + root.render( + // children must sit in the props object: the provider declares it as a + // required prop, which createElement's rest-children overloads can't fill. + // eslint-disable-next-line react/no-children-prop + createElement(SessionStatsProvider, { + sessionId: config.getSessionId(), + children: ( + + ), + }), + ); + profileCheckpoint('first_paint'); + + startPostRenderPrefetches(config, settings, { + connectIde: options.postRenderConnectIde ?? false, + initializeTelemetry: + options.postRenderInitializeTelemetry ?? + config.isTelemetryInitializationDeferred(), + }); + + registerCleanup(async () => { + root.unmount(); + renderer.destroy(); + await runtime!.shutdown(); + // If the error boundary caught a render crash, echo it now that the + // renderer is torn down (ink startInteractiveUI parity). + const renderError = consumeLastRenderError(); + if (renderError) { + const loggedHint = isDebugLogFileEnabled() + ? ' (logged to debug file)' + : ''; + writeStderrLine( + `\nRendering error${loggedHint}: ${sanitizeTerminalText(renderError.message)}`, + ); + } + // The resume hint survives exit only on the main screen; the alt-screen + // transcript is discarded with the renderer. Mirrors the ink echo, + // including the sessionId-shape and non-empty-transcript gates. + try { + if (process.stdout.isTTY && config.getChatRecordingService()) { + const sessionId = config.getSessionId(); + const sessionFile = config.getTranscriptPath(); + if ( + isValidSessionId(sessionId) && + (await stat(sessionFile)).size > 0 + ) { + writeStdoutLine( + `\n${t('To continue this session, run')}\nqwen --resume ${sessionId}`, + ); + } + } + } catch { + // Best-effort: a hint must never block or break exit. + } + }); + + // SessionEnd hook parity (ink AppContainer registers the same cleanup): + // user-configured SessionEnd hooks must fire on exit from this entry too. + registerCleanup(async () => { + try { + await config + .getHookSystem() + ?.fireSessionEndEvent(SessionEndReason.PromptInputExit); + } catch (err) { + debugLogger.error(`SessionEnd hook failed: ${err}`); + } + }); + + // Announce this session only after the teardown cleanup above is armed + // (ink startInteractiveUI ordering). + config.trackSessionRegistration( + registerSession({ + sessionId: config.getSessionId(), + cwd: config.getTargetDir(), + qwenVersion: version, + }), + ); + registerCleanup(() => config.unregisterSessionRegistry()); + + return true; + } catch (err) { + debugLogger.error('OpenTUI startup failed; falling back to ink:', err); + writeStderrLine( + `Warning: OpenTUI startup failed — ${err instanceof Error ? err.message : String(err)} (falling back to ink)`, + ); + try { + root.unmount(); + renderer.destroy(); + if (runtime) await runtime.shutdown(); + } catch { + // Best-effort teardown; ink takes over from here. + } + return false; + } +} diff --git a/packages/cli/src/ui/opentui/transcript-view.tsx b/packages/cli/src/ui/opentui/transcript-view.tsx new file mode 100644 index 00000000000..e8ea808103f --- /dev/null +++ b/packages/cli/src/ui/opentui/transcript-view.tsx @@ -0,0 +1,516 @@ +/* eslint-disable react/no-unknown-property */ +/** @jsxImportSource @opentui/react */ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Transcript renderer for the OpenTUI backend (Batch 6): maps the folded + * {@link LiveHistoryItem} list onto screen rows, reusing the ink-parity + * helpers in messages.tsx (glyphs, colors, tail windows, todo/ansi rows) and + * the native `` renderable for assistant bodies. + * + * Every history kind renders something — a kind that fell through would be a + * silent no-op, which the composition-root contract forbids. + */ + +import { useEffect, useState } from 'react'; +import { C, SYNTAX } from './theme.js'; +import { + AnsiRows, + MESSAGE_ICON, + TodoRows, + assistantMessageMeta, + hiddenLinesLabel, + maxHistoryItemRows, + selectionProps, + STATUS_INDICATOR_WIDTH, + tailWindow, + thinkingMeta, + toolCardDescription, + toolCardName, + toolCardSummarySuffix, + toolStatusMeta, + truncateResultDisplayChars, + userMessageMeta, +} from './messages.js'; +import { + describeGoalCard, + describeLegacyGoalCard, + type GoalCardColor, + type LiveGoalLegacyData, + type LiveHistoryItem, + type LiveToolItem, +} from './live-session-model.js'; +import { renderDiffBody } from './diff-render.js'; +import { assistantMarkdownForRender } from './markdown-heal.js'; +import { sanitizeTerminalText } from '../utils/textUtils.js'; +import { getCompressionStatusText } from '../utils/compression-text.js'; +import { ICON } from '../constants.js'; + +const GOAL_COLOR: Record = { + secondary: C.dim, + accent: C.accent, + warning: C.yellow, + error: C.red, + success: C.green, +}; + +export interface TranscriptViewProps { + items: readonly LiveHistoryItem[]; + /** Width budget for ANSI grids / wrapping (defaults to a safe 80). */ + availableWidth?: number; + /** Terminal height; per-item row caps follow ink staticAreaMaxItemHeight. */ + availableTerminalHeight?: number; +} + +export function OpenTuiTranscriptView({ + items, + availableWidth = 80, + availableTerminalHeight = 24, +}: TranscriptViewProps) { + const maxRows = maxHistoryItemRows(availableTerminalHeight); + return ( + + {items.map((item) => ( + + ))} + + ); +} + +function TranscriptItem({ + item, + maxRows, + width, +}: { + item: LiveHistoryItem; + maxRows: number; + width: number; +}) { + switch (item.kind) { + case 'user': + return ; + case 'assistant': + return ; + case 'thinking': + return ; + case 'tool': + return ; + case 'task': + return ; + case 'image': + return ( + + {`[inline image: ${item.mimeType}]`} + + ); + case 'compaction': + return ; + case 'info': + return ( + + {`${MESSAGE_ICON.CIRCLE_FILLED} `} + + {sanitizeTerminalText(item.text)} + + + ); + case 'error': + return ; + case 'warning': + return ( + + {sanitizeTerminalText(item.text)} + + ); + case 'retry': + return ( + + ); + case 'stop-hook': + return ; + case 'goal': + return ; + default: { + const exhaustive: never = item; + return exhaustive; + } + } +} + +function UserRow({ text }: { text: string }) { + const meta = userMessageMeta(); + return ( + + {`${meta.glyph} `} + + {sanitizeTerminalText(text)} + + + ); +} + +function AssistantRow({ + text, + streaming, +}: { + text: string; + streaming: boolean; +}) { + const meta = assistantMessageMeta(); + const content = sanitizeTerminalText( + assistantMarkdownForRender(text, streaming), + ); + return ( + + {`${meta.glyph} `} + + + + + ); +} + +function ThinkingRow({ text, done }: { text: string; done: boolean }) { + const [expanded, setExpanded] = useState(false); + const meta = thinkingMeta(done, expanded, false); + return ( + { + if (done) setExpanded((v) => !v); + }} + > + + + {meta.icon} {meta.label} + {meta.hint ? ` ${meta.hint}` : ''} + + + {!meta.collapsed && text ? ( + + {sanitizeTerminalText(text)} + + ) : null} + + ); +} + +function ToolCard({ + item, + maxRows, + width, +}: { + item: LiveToolItem; + maxRows: number; + width: number; +}) { + const status = toolStatusMeta(item); + const name = toolCardName(item.tool); + const description = + item.description ?? toolCardDescription(item.tool, item.args); + const suffix = toolCardSummarySuffix(item.done, item.summary); + return ( + + + + + {status.glyph} + + + + {name} + + {description ? ( + + {` ${sanitizeTerminalText(description)}`} + + ) : null} + {suffix ? {sanitizeTerminalText(suffix)} : null} + + {item.confirm === 'pending' && !item.done ? ( + (awaiting approval) + ) : null} + + + ); +} + +function ToolCardBody({ + item, + maxRows, + width, +}: { + item: LiveToolItem; + maxRows: number; + width: number; +}) { + if (item.todos) { + return ( + + + + ); + } + if (item.ansi) { + return ( + + + + ); + } + if (item.diff) { + const lines = renderDiffBody(item.diff.fileDiff); + const window = tailWindow(lines, maxRows); + return ( + + {window.hiddenCount > 0 && ( + {hiddenLinesLabel(window.hiddenCount)} + )} + {window.visible.map((line, i) => ( + + {line.map((span, j) => ( + + {span.text} + + ))} + + ))} + + ); + } + const output = truncateResultDisplayChars(item.output); + if (!output) return null; + const lines = sanitizeTerminalText(output).split('\n'); + const window = tailWindow(lines, maxRows); + return ( + + {window.hiddenCount > 0 && ( + {hiddenLinesLabel(window.hiddenCount)} + )} + {window.visible.map((line, i) => ( + + {line} + + ))} + {item.visionBridgeNotice ? ( + {sanitizeTerminalText(item.visionBridgeNotice)} + ) : null} + + ); +} + +function TaskCard({ + item, +}: { + item: Extract; +}) { + return ( + + + + {item.done ? TOOL_GLYPH_DONE : TOOL_GLYPH_RUNNING} + + + {` ${sanitizeTerminalText(item.name)}`} + + {item.description ? ( + {sanitizeTerminalText(item.description)} + ) : null} + {item.stats ? {` · ${item.stats}`} : null} + + {item.progress.map((line, i) => ( + + {sanitizeTerminalText(line)} + + ))} + + ); +} + +const TOOL_GLYPH_DONE = ICON.CHECK; +const TOOL_GLYPH_RUNNING = ICON.CIRCLE_LEFT_HALF; + +function CompactionRow({ + compression, +}: { + compression: Extract['compression']; +}) { + const text = getCompressionStatusText({ + isPending: compression.isPending, + originalTokenCount: compression.originalTokenCount, + newTokenCount: compression.newTokenCount, + compressionStatus: compression.compressionStatus, + originalTokenCountIsEstimated: compression.originalTokenCountIsEstimated, + newTokenCountIsEstimated: compression.newTokenCountIsEstimated, + }); + const color = compression.isPending ? C.accent : C.green; + return ( + + + {compression.isPending ? '…' : ICON.DIAMOND} + + + {text} + + + ); +} + +function ErrorRow({ text, hint }: { text: string; hint?: string }) { + return ( + + + {`${ICON.CROSS} `} + + {sanitizeTerminalText(text)} + + + {hint ? ( + + {sanitizeTerminalText(hint)} + + ) : null} + + ); +} + +function RetryRows({ + message, + attempt, + maxRetries, + delayMs, + startedAt, +}: { + message?: string; + attempt: number; + maxRetries: number; + delayMs: number; + startedAt: number; +}) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + const remainingSec = Math.max( + 0, + Math.ceil((delayMs - (now - startedAt)) / 1000), + ); + return ( + + + {sanitizeTerminalText( + message ?? `Attempt ${attempt} of ${maxRetries} failed`, + )} + + + {`↻ Retrying in ${remainingSec}s… (attempt ${attempt} of ${maxRetries})`} + + + ); +} + +function StopHookRow({ message }: { message: string }) { + return ( + + {'⎿ Stop says:'} + + {` ${sanitizeTerminalText(message)}`} + + + ); +} + +function GoalCard({ + item, +}: { + item: Extract; +}) { + if (item.legacy) { + return ; + } + const view = describeGoalCard(item.snapshot, item.cause); + if (view.state === 'hidden') return null; + if (view.state === 'cleared') { + return Goal cleared; + } + const color = GOAL_COLOR[view.color]; + return ( + + + + {view.icon} {view.title} + + {view.subtitle ? {` · ${view.subtitle}`} : null} + + + {` ${sanitizeTerminalText(view.objective)}`} + + {view.reason ? ( + + {` ${sanitizeTerminalText(view.reason)}`} + + ) : null} + + ); +} + +function LegacyGoalCard({ legacy }: { legacy: LiveGoalLegacyData }) { + const view = describeLegacyGoalCard(legacy); + if (view.state === 'hidden') return null; + if (view.state === 'checking') { + return ( + + {view.title} + {` ${sanitizeTerminalText(view.condition)}`} + {view.judgeReason ? ( + {` ${sanitizeTerminalText(view.judgeReason)}`} + ) : null} + + ); + } + const color = GOAL_COLOR[view.color]; + return ( + + + + {view.icon} {view.title} + + {view.subtitle ? {` · ${view.subtitle}`} : null} + + {` ${sanitizeTerminalText(view.condition)}`} + {view.lastCheck ? ( + {` ${sanitizeTerminalText(view.lastCheck)}`} + ) : null} + + ); +}