diff --git a/package.json b/package.json index 3d824d902..c6c00140e 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,9 @@ "baseline-browser-mapping": "^2.9.19", "normalize-package-data": "^7.0.1", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0" }, "bin": { "qwen": "dist/cli.js" diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d6cbfd82e..6ad68705f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -591,19 +591,25 @@ export const AppContainer = (props: AppContainerProps) => { // Keep the static header in sync with model changes without polling. // Ink's output is append-only, so model changes must explicitly // clear and remount the static region to redraw the banner at the top. + // + // refreshStatic() is fired from an effect (not inside the setState updater) + // so React.StrictMode's double-invoke of state updaters doesn't translate + // into two clearTerminal writes per model change. Side-effects belong in + // effects; the updater stays pure. useEffect(() => { const unsubscribe = config.onModelChange((model) => { - setCurrentModel((prev) => { - if (prev === model) { - return prev; - } - refreshStatic(); - return model; - }); + setCurrentModel(model); }); - return unsubscribe; - }, [config, refreshStatic]); + }, [config]); + + const prevCurrentModelRef = useRef(currentModel); + useEffect(() => { + if (prevCurrentModelRef.current !== currentModel) { + prevCurrentModelRef.current = currentModel; + refreshStatic(); + } + }, [currentModel, refreshStatic]); const { isThemeDialogOpen,