@@ -387,12 +416,25 @@ export const Panel: React.FC = () => {
panelOutputEntries.length === 0 ? (
- {panelOutputEntries.map((entry) => (
-
- {entry.message}
-
- ))}
+
+
+
+
+
+ {panelOutputEntries.map((entry) => (
+
+ {entry.message}
+
+ ))}
+
)
)}
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json
index 1a2ea61..1d9a738 100644
--- a/src/renderer/i18n/locales/de.json
+++ b/src/renderer/i18n/locales/de.json
@@ -629,6 +629,7 @@
"panel.closeTitle": "Panel schließen",
"panel.noRecentTasks": "Keine aktuellen Aufgaben",
"panel.noOutput": "Keine Ausgabe",
+ "panel.copyOutput": "Ausgabe kopieren",
"panel.openPostEditor": "Öffne einen Beitragseditor, um Beitragslinks zu sehen",
"panel.loadingPostLinks": "Beitragslinks werden geladen...",
"panel.noPostLinks": "Keine Beitragslinks für diesen Beitrag",
diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json
index dc743e5..7263b00 100644
--- a/src/renderer/i18n/locales/en.json
+++ b/src/renderer/i18n/locales/en.json
@@ -629,6 +629,7 @@
"panel.closeTitle": "Close panel",
"panel.noRecentTasks": "No recent tasks",
"panel.noOutput": "No output",
+ "panel.copyOutput": "Copy Output",
"panel.openPostEditor": "Open a post editor to view post links",
"panel.loadingPostLinks": "Loading post links...",
"panel.noPostLinks": "No post links for this post",
diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json
index 17b2b5a..eaccd78 100644
--- a/src/renderer/i18n/locales/es.json
+++ b/src/renderer/i18n/locales/es.json
@@ -629,6 +629,7 @@
"panel.closeTitle": "Cerrar panel",
"panel.noRecentTasks": "No hay tareas recientes",
"panel.noOutput": "Sin salida",
+ "panel.copyOutput": "Copiar salida",
"panel.openPostEditor": "Abre un editor de entradas para ver los enlaces",
"panel.loadingPostLinks": "Cargando enlaces de entradas...",
"panel.noPostLinks": "No hay enlaces para esta entrada",
diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json
index cf421fd..6201684 100644
--- a/src/renderer/i18n/locales/fr.json
+++ b/src/renderer/i18n/locales/fr.json
@@ -629,6 +629,7 @@
"panel.closeTitle": "Fermer le panneau",
"panel.noRecentTasks": "Aucune tâche récente",
"panel.noOutput": "Aucune sortie",
+ "panel.copyOutput": "Copier la sortie",
"panel.openPostEditor": "Ouvrez un éditeur d'article pour voir les liens",
"panel.loadingPostLinks": "Chargement des liens d'articles...",
"panel.noPostLinks": "Aucun lien pour cet article",
diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json
index 6894fb8..021a194 100644
--- a/src/renderer/i18n/locales/it.json
+++ b/src/renderer/i18n/locales/it.json
@@ -629,6 +629,7 @@
"panel.closeTitle": "Chiudi pannello",
"panel.noRecentTasks": "Nessuna attività recente",
"panel.noOutput": "Nessun output",
+ "panel.copyOutput": "Copia output",
"panel.openPostEditor": "Apri un editor post per visualizzare i collegamenti",
"panel.loadingPostLinks": "Caricamento collegamenti post...",
"panel.noPostLinks": "Nessun collegamento per questo post",
diff --git a/src/renderer/python/pyodideAssetUrl.ts b/src/renderer/python/pyodideAssetUrl.ts
new file mode 100644
index 0000000..5b243e5
--- /dev/null
+++ b/src/renderer/python/pyodideAssetUrl.ts
@@ -0,0 +1,3 @@
+export function resolvePyodideIndexURL(workerModuleUrl: string): string {
+ return new URL('../../../node_modules/pyodide/', workerModuleUrl).toString();
+}
diff --git a/src/renderer/python/pythonRuntime.worker.ts b/src/renderer/python/pythonRuntime.worker.ts
index 8582524..6ff43e6 100644
--- a/src/renderer/python/pythonRuntime.worker.ts
+++ b/src/renderer/python/pythonRuntime.worker.ts
@@ -1,6 +1,7 @@
import { loadPyodide, type PyodideInterface } from 'pyodide';
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
+import { resolvePyodideIndexURL } from './pyodideAssetUrl';
let runtime: PyodideInterface | null = null;
let activeRequestId: string | null = null;
@@ -183,6 +184,7 @@ json.dumps(__bds_entrypoints)
async function bootstrapRuntime(): Promise
{
try {
runtime = await loadPyodide({
+ indexURL: resolvePyodideIndexURL(import.meta.url),
stdout: (chunk) => {
if (!activeRequestId) {
return;
diff --git a/tests/renderer/components/Panel.test.tsx b/tests/renderer/components/Panel.test.tsx
index 87b5e2e..ee1187a 100644
--- a/tests/renderer/components/Panel.test.tsx
+++ b/tests/renderer/components/Panel.test.tsx
@@ -253,6 +253,40 @@ describe('Panel', () => {
expect(screen.getByText('hello from script')).toBeInTheDocument();
});
+ it('copies output entries from output tab with copy button', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(globalThis, 'navigator', {
+ value: { clipboard: { writeText } },
+ configurable: true,
+ });
+
+ useAppStore.setState({
+ panelActiveTab: 'output',
+ panelOutputEntries: [
+ {
+ id: 'output-1',
+ message: 'pyodide.asm.js missing',
+ createdAt: '2026-02-22T00:00:00.000Z',
+ kind: 'error',
+ },
+ {
+ id: 'output-2',
+ message: 'second line',
+ createdAt: '2026-02-22T00:00:01.000Z',
+ kind: 'stdout',
+ },
+ ],
+ });
+
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Copy Output' }));
+
+ await vi.waitFor(() => {
+ expect(writeText).toHaveBeenCalledWith('pyodide.asm.js missing\n\nsecond line');
+ });
+ });
+
it('renders grouped tasks as expandable parent rows with child task names in tasks tab', async () => {
useAppStore.setState({
tasks: [
diff --git a/tests/renderer/python/pyodideAssetUrl.test.ts b/tests/renderer/python/pyodideAssetUrl.test.ts
new file mode 100644
index 0000000..3fc99f1
--- /dev/null
+++ b/tests/renderer/python/pyodideAssetUrl.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { resolvePyodideIndexURL } from '../../../src/renderer/python/pyodideAssetUrl';
+
+describe('resolvePyodideIndexURL', () => {
+ it('resolves to packaged node_modules path for dist worker urls', () => {
+ const workerUrl = 'file:///Applications/bDS.app/Contents/Resources/app.asar/dist/renderer/assets/pythonRuntime.worker-abc123.js';
+ expect(resolvePyodideIndexURL(workerUrl)).toBe(
+ 'file:///Applications/bDS.app/Contents/Resources/app.asar/node_modules/pyodide/'
+ );
+ });
+
+ it('resolves to vite node_modules path for dev worker urls', () => {
+ const workerUrl = 'http://localhost:5173/src/renderer/python/pythonRuntime.worker.ts';
+ expect(resolvePyodideIndexURL(workerUrl)).toBe('http://localhost:5173/node_modules/pyodide/');
+ });
+});