From 8d0924ce536d095fa722cd9877b63663f82dbd98 Mon Sep 17 00:00:00 2001 From: johnny4young Date: Wed, 14 Oct 2026 12:40:53 -0500 Subject: [PATCH 1/5] fix(python): show printed strings without repr quotes - The print hook serialized each argument with the scratchpad value serializer, which uses repr, so print("sum =", 55) appeared in the console as 'sum =' 55. - Top-level string arguments are now primitive string payloads holding the printed text; strings nested in containers keep repr, as print itself does. - Run the real bootstrap under CPython in a new test of both cases. Changelog: none --- src/renderer/workers/python-worker-sources.ts | 4 ++ tests/workers/pythonPrintPayloads.test.ts | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/workers/pythonPrintPayloads.test.ts diff --git a/src/renderer/workers/python-worker-sources.ts b/src/renderer/workers/python-worker-sources.ts index 1fcc5a424..8c64b0cca 100644 --- a/src/renderer/workers/python-worker-sources.ts +++ b/src/renderer/workers/python-worker-sources.ts @@ -286,6 +286,10 @@ def __lingua_print(*args, sep=None, end=None, file=None, flush=False): # own payload entry aligned with the joined text. payloads = [] for arg in args: + # print() shows str(arg); a repr payload would add quotes to strings. + if isinstance(arg, str): + payloads.append({"kind": "primitive", "type": "string", "repr": arg}) + continue payload = __lingua_console_serialize(arg) if payload is None: payload = {"kind": "rawText", "text": __lingua_repr_safe(arg)} diff --git a/tests/workers/pythonPrintPayloads.test.ts b/tests/workers/pythonPrintPayloads.test.ts new file mode 100644 index 000000000..4af3d7e07 --- /dev/null +++ b/tests/workers/pythonPrintPayloads.test.ts @@ -0,0 +1,43 @@ +import { spawnSync } from 'node:child_process'; +import { describe, expect, it } from 'vitest'; +import { buildPythonExecutionBootstrapSource } from '../../src/renderer/workers/python-worker-sources'; + +const hasPython = spawnSync('python3', ['--version']).status === 0; + +/** Run the real bootstrap under CPython and return the captured print entries. */ +function printEntries(userCode: string): Array<{ text: string; payloads: unknown[] }> { + const bootstrap = buildPythonExecutionBootstrapSource({ + richConsoleEnabled: true, + sourceMappingEnabled: false, + }); + const dump = '\nimport json\nsys.__stdout__.write(json.dumps(__lingua_print_entries))\n'; + const result = spawnSync('python3', ['-c', `${bootstrap}\n${userCode}${dump}`], { + encoding: 'utf8', + }); + if (result.status !== 0) throw new Error(result.stderr); + return JSON.parse(result.stdout); +} + +describe.skipIf(!hasPython)('Python print payloads', () => { + it('shows top-level string arguments as print does, without repr quotes', () => { + const [entry] = printEntries('print("sum =", 55)\n'); + expect(entry?.text).toBe('sum = 55\n'); + expect(entry?.payloads).toEqual([ + { kind: 'primitive', type: 'string', repr: 'sum =' }, + { kind: 'primitive', type: 'number', repr: '55' }, + ]); + }); + + it('keeps repr for strings nested in containers, matching print', () => { + const [entry] = printEntries('print(["a"], "b")\n'); + expect(entry?.text).toBe("['a'] b\n"); + expect(entry?.payloads).toEqual([ + { + kind: 'array', + length: 1, + entries: [{ index: 0, value: { kind: 'primitive', type: 'string', repr: "'a'" } }], + }, + { kind: 'primitive', type: 'string', repr: 'b' }, + ]); + }); +}); From acfe71e766885e1d31432627c11392d85ab2f55e Mon Sep 17 00:00:00 2001 From: johnny4young Date: Wed, 14 Oct 2026 12:40:53 -0500 Subject: [PATCH 2/5] fix(editor): unbind a recipe dropped by a language-changing Save As - A recipe bound while a language-changing Save As was writing was dropped from the tab by the live-state merge but stayed in the recipe store. - Track whether the merge dropped a live binding and unbind it alongside the existing original-binding case. Changelog: none --- src/renderer/stores/editorDocumentSave.ts | 9 +++++- tests/stores/editorSaveInputRetention.test.ts | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/renderer/stores/editorDocumentSave.ts b/src/renderer/stores/editorDocumentSave.ts index 69d61695f..d10972632 100644 --- a/src/renderer/stores/editorDocumentSave.ts +++ b/src/renderer/stores/editorDocumentSave.ts @@ -69,6 +69,7 @@ export function createDocumentSaveAction(set: EditorSet, get: EditorGet) { return false; } + let droppedLiveBinding = false; set(state => ({ tabs: state.tabs.map(t => { if (t.id !== id) return t; @@ -91,6 +92,8 @@ export function createDocumentSaveAction(set: EditorSet, get: EditorGet) { }; } const committed = withLiveTabState(savedTab, t, tab); + droppedLiveBinding = + t.recipeBindingId !== undefined && committed.recipeBindingId === undefined; if (t.content !== tab.content) { return { ...committed, content: t.content, isDirty: true }; } @@ -112,7 +115,11 @@ export function createDocumentSaveAction(set: EditorSet, get: EditorGet) { if (savedTab.language !== previousLanguage) { useDependencyDetectionStore.getState().evictTab(id); } - if (tab.recipeBindingId !== undefined && savedTab.recipeBindingId === undefined) { + // Includes a binding made while a language-changing save was writing. + if ( + droppedLiveBinding || + (tab.recipeBindingId !== undefined && savedTab.recipeBindingId === undefined) + ) { useRecipeStore.getState().unbindRecipe(id); } diff --git a/tests/stores/editorSaveInputRetention.test.ts b/tests/stores/editorSaveInputRetention.test.ts index a92f517ab..a3bf85862 100644 --- a/tests/stores/editorSaveInputRetention.test.ts +++ b/tests/stores/editorSaveInputRetention.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import * as editorPersistence from '@/stores/editorPersistence'; import { createDefaultTab, useEditorStore } from '@/stores/editorStore'; +import { useRecipeStore } from '@/stores/recipeStore'; import { useSettingsStore } from '@/stores/settingsStore'; const bridge = Object.getOwnPropertyDescriptor(window, 'lingua'); @@ -107,3 +109,29 @@ it('drops a recipe binding from the tab when the save drops it', async () => { const saved = useEditorStore.getState().tabs.find(t => t.id === tab.id)!; expect(saved.recipeBindingId).toBeUndefined(); }); + +it('unbinds a recipe bound while a language-changing Save As was writing', async () => { + const tab = { ...createDefaultTab('javascript'), name: 'demo.js' }; + useEditorStore.getState().addTab(tab); + vi.spyOn(editorPersistence, 'persistTab').mockImplementation(async original => { + // The user binds a recipe while the write is in flight. + useEditorStore.setState(state => ({ + tabs: state.tabs.map(t => (t.id === tab.id ? { ...t, recipeBindingId: 'recipe-1' } : t)), + })); + useRecipeStore.getState().bindRecipeToTab(tab.id, 'recipe-1'); + return { + ...original, + name: 'demo.py', + language: 'python', + filePath: '/tmp/demo.py', + rootId: 'root-demo', + relativePath: 'demo.py', + }; + }); + const unbind = vi.spyOn(useRecipeStore.getState(), 'unbindRecipe'); + expect(await useEditorStore.getState().saveTabById(tab.id, true)).toBe(true); + const saved = useEditorStore.getState().tabs.find(t => t.id === tab.id)!; + expect(saved.language).toBe('python'); + expect(saved.recipeBindingId).toBeUndefined(); + expect(unbind).toHaveBeenCalledWith(tab.id); +}); From 894f7a8a31297ebfbe1153bce62d33c88ce34d20 Mon Sep 17 00:00:00 2001 From: johnny4young Date: Wed, 14 Oct 2026 12:40:53 -0500 Subject: [PATCH 3/5] fix(tour): open the console only when the tour actually starts - startTour opened the bottom console before awaiting editor readiness, so a start interrupted by another dialog still changed the layout. - Open it in the same branch that activates the first step; the empty-workspace sample tab is still created up front because the tour waits for an editor. Changelog: none --- src/renderer/components/GuidedTour/GuidedTourRuntime.tsx | 3 ++- tests/components/GuidedTourFocus.test.tsx | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/GuidedTour/GuidedTourRuntime.tsx b/src/renderer/components/GuidedTour/GuidedTourRuntime.tsx index 547e967d9..070014984 100644 --- a/src/renderer/components/GuidedTour/GuidedTourRuntime.tsx +++ b/src/renderer/components/GuidedTour/GuidedTourRuntime.tsx @@ -336,11 +336,12 @@ export function GuidedTourRuntime({ if (tabs.length === 0) { addTab(createDefaultTab('javascript')); } - useUIStore.getState().openBottomPanel('console'); await waitForGuidedTourSelector(GUIDED_TOUR_SELECTORS.editor); if (generation === startGenerationRef.current && !hasActiveOverlayRef.current) { + // Open the console only for a tour that actually starts. + useUIStore.getState().openBottomPanel('console'); activeStepIndexRef.current = 0; setActiveStepIndex(0); } diff --git a/tests/components/GuidedTourFocus.test.tsx b/tests/components/GuidedTourFocus.test.tsx index 69bb6171c..2cae38100 100644 --- a/tests/components/GuidedTourFocus.test.tsx +++ b/tests/components/GuidedTourFocus.test.tsx @@ -249,6 +249,7 @@ describe('GuidedTour focus management (accessibility pass)', () => { }) ); const { rerender } = renderTour(); + uiState.openBottomPanel.mockClear(); fireEvent.click(screen.getByTestId('trigger')); await waitFor(() => expect(wait).toHaveBeenCalled()); @@ -259,8 +260,10 @@ describe('GuidedTour focus management (accessibility pass)', () => { await act(async () => ready()); expect(screen.queryByRole('dialog')).toBeNull(); + expect(uiState.openBottomPanel).not.toHaveBeenCalled(); fireEvent.click(screen.getByTestId('trigger')); expect(await screen.findByRole('dialog')).toBeTruthy(); + expect(uiState.openBottomPanel).toHaveBeenCalledWith('console'); }); it('does not restore the old trigger over a newer overlay focus target', async () => { const { rerender } = renderTour(); From 1fb05ccae1650e7910a5e555165345040c8f5eec Mon Sep 17 00:00:00 2001 From: johnny4young Date: Wed, 14 Oct 2026 12:40:53 -0500 Subject: [PATCH 4/5] docs(changelog): note the renderer review follow-ups --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4543f582f..645fe1af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ The format follows Keep a Changelog and groups changes by release. ### Fixed - Lua `string.format` with a zero `%g` precision, such as `%.0g`, now formats like standard Lua instead of failing the run with a meaningless error. - `lingua capsule verify` and the regression-suite export name the engine mismatch for Python, Go and web Ruby recordings. A real recording with per-line results or a failed run used to report a generic incomplete baseline instead. +- Python `print` with several arguments shows strings in the console as `print` does, without repr quotes (`sum = 55`, not `'sum =' 55`). Strings inside lists and other containers keep their quotes. +- A language-changing Save As no longer leaves a recipe bound in the background when the binding was made while the file was still writing. +- Starting the guided tour opens the console only once the tour actually begins; a start interrupted by another dialog leaves the bottom panel as it was. ## [1.6.0] — 2026-10-13 From 4811be4e40e248e6da8ce3f92a2c27a4d4f402ef Mon Sep 17 00:00:00 2001 From: johnny4young Date: Wed, 14 Oct 2026 23:10:39 -0500 Subject: [PATCH 5/5] test(e2e): find the tour console row without relying on repr quotes - The website tour spec told the console row from the inline pill by the repr quote that print used to add; printed strings now match print, so scope the assertion to the console row instead. Changelog: none --- tests/e2e/tourVisual.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/e2e/tourVisual.spec.ts b/tests/e2e/tourVisual.spec.ts index 4764eaed9..fc122e147 100644 --- a/tests/e2e/tourVisual.spec.ts +++ b/tests/e2e/tourVisual.spec.ts @@ -133,7 +133,11 @@ test.describe('website tour evidence', () => { await replaceEditorText(page, PYTHON_SOURCE); await expect(page.getByText('slowest path: db').first()).toBeVisible({ timeout: 60_000 }); await openConsole(page); - await expect(page.getByText(/slowest path: db'/).first()).toBeVisible(); + // The console row prints the str() text, so scope it to the console + // rather than relying on repr quotes to tell it from the inline pill. + await expect( + page.getByTestId('console-entry-row').filter({ hasText: 'slowest path: db' }).first() + ).toBeVisible(); await capture(page, `workspace-python-${language}.png`); // Command palette filtered to sql.