diff --git a/src/lib/components/console/HeadlessConsole.svelte b/src/lib/components/console/HeadlessConsole.svelte
index fffea055..d60d0007 100644
--- a/src/lib/components/console/HeadlessConsole.svelte
+++ b/src/lib/components/console/HeadlessConsole.svelte
@@ -1,11 +1,9 @@
diff --git a/src/python/console/console.d.ts b/src/python/console/console.d.ts
index 960e8463..18fa62bd 100644
--- a/src/python/console/console.d.ts
+++ b/src/python/console/console.d.ts
@@ -1,3 +1,4 @@
+import type { Item } from "$lib/components/console/HeadlessConsole.svelte";
import type { PyProxy } from "pyodide/ffi";
export class Result {
@@ -15,6 +16,7 @@ class EnhancedConsole {
export class ConsoleAPI extends PyProxy {
complete(source: string): [string[], number];
console: EnhancedConsole;
+ get_items(): Item[];
push(line: string): Result;
pop(): void;
}
diff --git a/src/python/console/console.py b/src/python/console/console.py
index 1f9de5eb..d69d2a99 100644
--- a/src/python/console/console.py
+++ b/src/python/console/console.py
@@ -1,8 +1,13 @@
+from __future__ import annotations
+
import builtins
+from asyncio import ensure_future
from functools import cached_property
+from operator import call
from pprint import pformat
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Callable
+from js import window
from pyodide.console import ConsoleFuture, PyodideConsole
from .bridge import js_api
@@ -37,6 +42,8 @@ def __init__(self, future: ConsoleFuture):
@property
def formatted_error(self):
+ if self.status == "syntax-error":
+ return f"Traceback (most recent call last):\n{self.future.formatted_error}"
return self.future.formatted_error
async def get_repr(self):
@@ -63,6 +70,12 @@ def runsource(self, source: str, filename=""):
class ConsoleAPI:
+ def __init__(self, sync: Callable[[]]):
+ self.sync = sync
+ self.console.stdin_callback = window.prompt # type: ignore
+ self.console.stdout_callback = lambda output: self.push_item({"type": "out", "text": output}) # type: ignore
+ self.console.stderr_callback = lambda output: self.push_item({"type": "err", "text": output, "is_traceback": False}) # type: ignore
+
@cached_property
def builtins(self):
return builtins.__dict__.copy()
@@ -75,17 +88,93 @@ def context(self):
def console(self):
return EnhancedConsole(self.context)
+ if TYPE_CHECKING:
+ from .item import Item
+
+ @cached_property
+ def items(self) -> list[Item]:
+ return []
+
@js_api
def complete(self, source: str):
return self.console.complete(source)
- def push(self, line: str):
+ @js_api
+ def get_items(self):
+ return self.items
+
+ @staticmethod
+ def can_merge(last: Item, this: Item):
+ if last["type"] != this["type"]:
+ return False
+ if last["type"] == "in" and last.get("incomplete", False): # incomplete input
+ return True
+ if last["type"] == "out": # both stdout
+ return True
+ if last["type"] == "err" and not last.get("is_traceback", False) and not this.get("is_traceback", False): # both stderr
+ return True
+
+ def push_item(self, item: Item, behind: Item | None = None):
+ if not self.items:
+ self.sync()
+ self.items.append(item)
+ return
+
+ last = self.items[-1]
+
+ if self.can_merge(last, item):
+ last["text"] += f"\n{item["text"]}" if item["type"] == "in" else item["text"]
+ if "incomplete" in item:
+ last["incomplete"] = item["incomplete"]
+ self.sync()
+ return last
+
+ elif behind is not None:
+ index = -1
+ for index, i in enumerate(self.items):
+ if i is behind:
+ break
+ assert index != -1, "not found"
+
+ if index != len(self.items) - 1 and self.items[index + 1]["type"] == "out":
+ index += 1 # repr follows stdout
+ self.items.insert(index + 1, item)
+
+ else:
+ self.items.append(item)
+
+ self.sync()
+
+ def console_push(self, line: str):
res = Result(future := self.console.push(line))
@future.add_done_callback
def _(_):
if future.syntax_check == "complete" and future.exception() is None:
- self.builtins["_"] = future.result()
+ if (result := future.result()) is not None:
+ self.builtins["_"] = result
+
+ return res
+
+ def push(self, line: str):
+ res = self.console_push(line)
+ input_item = self.push_item(ii := {"type": "in", "text": line, "incomplete": res.status == "incomplete"}) or ii
+ self.sync()
+
+ @ensure_future
+ @call
+ async def _():
+ match res.status:
+ case "syntax-error":
+ assert res.formatted_error
+ self.push_item({"type": "err", "text": res.formatted_error, "is_traceback": True}, behind=input_item)
+ case "complete":
+ try:
+ if text := await res.get_repr():
+ self.push_item({"type": "repr", "text": text}, behind=input_item)
+ except Exception as e:
+ stderr = res.formatted_error or self.console.formattraceback(e)
+ self.push_item({"type": "err", "text": stderr, "is_traceback": True}, behind=input_item)
return res
diff --git a/src/python/console/item.pyi b/src/python/console/item.pyi
new file mode 100644
index 00000000..e86c70aa
--- /dev/null
+++ b/src/python/console/item.pyi
@@ -0,0 +1,7 @@
+from typing import Literal, NotRequired, TypedDict
+
+class Item(TypedDict):
+ type: Literal["out", "err", "in", "repr"]
+ text: str
+ incomplete: NotRequired[bool]
+ is_traceback: NotRequired[bool]
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 451c2828..068f8244 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -44,7 +44,7 @@
focusedError = { traceback, code };
}
- let push: (source: string) => Promise;
+ let push: (source: string) => Promise;
onMount(async () => {
history.unshift(...(JSON.parse(localStorage.getItem("console-history") || "[]") as string[]));