From 805d7ad9391264bd59010c489b5681e1a4809d9f Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Sat, 11 Jul 2026 14:58:27 -0400 Subject: [PATCH 1/4] properly pass image display objs back over MCP --- clikernel/base.py | 25 +++++++++++++++++++------ clikernel/cli.py | 13 ++++++++----- clikernel/skill.py | 2 +- tests/test_cli.py | 23 +++++++++++++++++++++-- tests/test_mcp.py | 13 +++++++++++++ 5 files changed, 62 insertions(+), 14 deletions(-) diff --git a/clikernel/base.py b/clikernel/base.py index 6765d48..8bc4290 100644 --- a/clikernel/base.py +++ b/clikernel/base.py @@ -128,8 +128,8 @@ def serve_stream( class _Worker: - def __init__(self, argv=None): - self.argv = argv or [sys.executable, "-m", "clikernel.cli"] + def __init__(self, argv=None, media=False): + self.argv = argv or [sys.executable, "-m", "clikernel.cli"] + (["--media"] if media else []) self.proc,self.delim,self.started,self.busy,self.desynced = None,None,False,False,False self.startup_info = "" self.lock = asyncio.Lock() @@ -189,6 +189,17 @@ async def run(self, code): def _errbox(): return "\n" + traceback.format_exc() + "" +_MEDIA_RE = re.compile(r'\n?\n(.*?)\n', re.S) +_IMG_MIMES = {'image/png','image/jpeg','image/gif','image/webp'} + +def _split_media(body): + "Split `` elements out of a worker response into `(text, content blocks)`; images only, the rest is dropped" + from mcp.types import ImageContent + parts = _MEDIA_RE.findall(body) + blocks = [ImageContent(type='image', data=d, mimeType=m) for m,d in parts if m in _IMG_MIMES] + return (_MEDIA_RE.sub('', body) if parts else body), blocks + + def _kill_worker(w, grace=3): "Reap the worker child so it can never outlive the supervisor (signal-handler safe: no await); SIGTERM first so it can clean up, SIGKILL if it lingers" p = w.proc @@ -230,7 +241,7 @@ async def _serve(w, name, docs, instructions=None, eager=False): died = docs['died'] async def execute(code:str # Code to run in the persistent session - )->str: # Rendered outputs (stdout, display data, last-expression result, errors) + ): # Rendered outputs (stdout, display data, last-expression result, errors), plus any rich media blocks try: async with w.lock: note = "" @@ -245,7 +256,8 @@ async def execute(code:str # Code to run in the persistent session acked, body = await w.run(code) if body is None: return note + "\nkernel process died while executing this request; a fresh kernel will be started on the next call, with all session state lost\n" - return note + body + text, blocks = _split_media(note + body) + return [text, *blocks] if blocks else text except Exception: w.desynced = True return _errbox() @@ -275,9 +287,10 @@ def run_mcp( name='clikernel', # MCP server name docs=None, # Overrides for `TOOL_DOCS` entries (tool descriptions and the state-lost note) instructions=None, # Static MCP `instructions` text, for when the worker isn't started eagerly - eager=False # Start the worker at server startup, forwarding its banner (with startup output) as `instructions`? + eager=False, # Start the worker at server startup, forwarding its banner (with startup output) as `instructions`? + media=True, # Forward rich display outputs (images) from the worker as MCP content blocks? ): "Run an MCP server supervising a persistent stream-protocol worker subprocess." - w = _Worker(argv) + w = _Worker(argv, media=media) _install_signal_guards(w) asyncio.run(_serve(w, name, {**TOOL_DOCS, **(docs or {})}, instructions, eager)) diff --git a/clikernel/cli.py b/clikernel/cli.py index 7364ac0..2d8e3fe 100644 --- a/clikernel/cli.py +++ b/clikernel/cli.py @@ -2,7 +2,7 @@ from pathlib import Path from fastcore.xdg import xdg_config_home from clikernel import INSTRUCTIONS -from clikernel.base import fmt_error,init_worker,run_startup,serve_stream +from clikernel.base import _IMG_MIMES,fmt_error,init_worker,run_startup,serve_stream def _state_root(): if d := os.environ.get("CLIKERNEL_STATE_DIR"): return Path(d).expanduser() @@ -59,11 +59,14 @@ def _inspect(shell, inspectors, code): return "".join(note for f in inspectors if (note := _call_inspector(f, tree, code))) -def _execute(shell, inspectors, code): - from fastcore.nbio import render_text +def _execute(shell, inspectors, code, media=False): + from fastcore.nbio import render_text,render_media try: note = _inspect(shell, inspectors, code) except Exception as e: return fmt_error("blocked", f"{type(e).__name__}: {e}") - return note + render_text(shell.run(code)) + res = shell.run(code) + out = note + render_text(res) + if media: out += ''.join(f'\n\n{d}\n' for m,d in render_media(res) if m in _IMG_MIMES) + return out def _request_exit(shell): shell._clikernel_exit = True @@ -108,7 +111,7 @@ def main(): shell = _make_shell() block = _startup_block(shell) inspectors = _load_inspectors() - serve_stream(lambda code: _execute(shell, inspectors, code), + serve_stream(lambda code: _execute(shell, inspectors, code, media="--media" in sys.argv[1:]), INSTRUCTIONS + ("\n\n" + block if block else ""), should_exit=lambda: _should_exit(shell)) diff --git a/clikernel/skill.py b/clikernel/skill.py index 77a17e6..24003fa 100644 --- a/clikernel/skill.py +++ b/clikernel/skill.py @@ -73,7 +73,7 @@ def f(x): 42 -`display_data`/`execute_result` prefer a non-image, markdown-over-HTML representation; images are ignored. Exceptions come back as a single clean `` traceback -- no color codes, not duplicated. +`display_data`/`execute_result` prefer a non-image, markdown-over-HTML representation; image display outputs are forwarded to MCP clients as media content blocks (text-only for other consumers, unless the worker runs with `--media`). Exceptions come back as a single clean `` traceback -- no color codes, not duplicated. # Interaction rules diff --git a/tests/test_cli.py b/tests/test_cli.py index a5e188a..596de4d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -67,12 +67,12 @@ def _env(tmp_path): return env -def start_kernel(tmp_path, extra_env=None): +def start_kernel(tmp_path, extra_env=None, args=()): cmd = shutil.which("clikernel") assert cmd, "clikernel console script is not on PATH" env = _env(tmp_path) if extra_env: env.update(extra_env) - proc = subprocess.Popen([cmd], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + proc = subprocess.Popen([cmd, *args], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) proc._stdout_buffer = bytearray() return proc @@ -369,3 +369,22 @@ def test_cli_profile(tmp_path): assert send(proc, "prof_ran\n")[0] == "7\n" assert send(proc, "ck_ext\n")[0] == "1\n" finally: stop_kernel(proc) + + +PNG1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==' +SHOW_IMG = f"import base64; from IPython.display import Image, display; display(Image(base64.b64decode('{PNG1}'))); 'done'" + +def test_media_flag(tmp_path): + "Rich outputs stay text-only by default (existing consumers see no change); --media appends elements after the rendered text." + proc = start_kernel(tmp_path) + try: + read_until_ready(proc) + out, _ = send(proc, SHOW_IMG + "\n") + assert "done" in out and "\n{PNG1}\n' in out + finally: stop_kernel(proc) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1d77a34..0cf3f64 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -161,3 +161,16 @@ async def test_graceful_kill(tmp_path): _kill_worker(w) # sync path (signal handler, atexit) await w.proc.wait() assert m2.read_text() == "cleaned" + + +PNG1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==' + +async def test_mcp_media(): + "Rich display outputs come back as ImageContent blocks after the text part; text-only results are unchanged." + async with stdio_client(_server_params()) as (r, w), ClientSession(r, w) as s: + await s.initialize() + res = await s.call_tool("execute", {"code": f"import base64; from IPython.display import Image, display; display(Image(base64.b64decode('{PNG1}'))); 'done'"}) + assert res.content[0].type == "text" and "done" in res.content[0].text and " Date: Sun, 12 Jul 2026 07:04:00 +1000 Subject: [PATCH 2/4] fixes #17 --- README.md | 3 +-- clikernel/cli.py | 7 ++++--- clikernel/dojo.py | 9 +++++---- clikernel/skill.py | 3 ++- tests/test_cli.py | 5 +++-- tests/test_rules.py | 12 ++++++------ 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d693d23..79d466f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Outputs are rendered with `fastcore.nbio.render_text`. A single non-empty output ## Startup file -On startup, after creating the shell and before the session delimiter, `clikernel` runs `$XDG_CONFIG_HOME/clikernel/startup.py` (usually `~/.config/clikernel/startup.py`) if it exists. It runs in the persistent session, so any imports, variables, and helpers it defines are available to every later request. The loading banner then carries a `` element with a `` child holding the file's whole source and, when the file prints anything, an `` child holding its captured stdout; the `clikernel-mcp` server forwards this (after its own instructions) as the MCP `instructions` field. A broken `startup.py` is reported on stderr but does not stop the kernel starting. +On startup, after creating the shell and before the session delimiter, `clikernel` runs `$XDG_CONFIG_HOME/clikernel/startup.py` (usually `~/.config/clikernel/startup.py`) if it exists. It runs via IPython's `%run -i`, so `__file__` is the startup path and any imports, variables, and helpers it defines remain available to every later request. The loading banner then carries a `` element with a `` child holding the file's whole source and, when the file prints anything, an `` child holding its captured stdout; the `clikernel-mcp` server forwards this (after its own instructions) as the MCP `instructions` field. A broken `startup.py` is reported on stderr but does not stop the kernel starting. ## Inspectors @@ -114,4 +114,3 @@ ship-gh ship-pypi ship-bump # dev release always later than prod release ``` - diff --git a/clikernel/cli.py b/clikernel/cli.py index 2d8e3fe..e63204d 100644 --- a/clikernel/cli.py +++ b/clikernel/cli.py @@ -38,12 +38,13 @@ def _stream_text(outputs): def _startup_block(shell): "Run `$XDG_CONFIG_HOME/clikernel/startup.py` in the persistent session (so its imports and names are available to later requests), returning its `` banner element via `run_startup`." - def runner(src): - out = _stream_text(shell.run(src)) + path = xdg_config_home()/"clikernel"/"startup.py" + def runner(_src): + out = _stream_text(shell.run(f"%run -i {shlex.quote(str(path))}")) if not shell.exc: return out, None exc = shell.exc return out, "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) - return run_startup(xdg_config_home()/"clikernel"/"startup.py", runner) + return run_startup(path, runner) def _call_inspector(f, tree, code): diff --git a/clikernel/dojo.py b/clikernel/dojo.py index c75d3a6..6002c81 100644 --- a/clikernel/dojo.py +++ b/clikernel/dojo.py @@ -13,7 +13,7 @@ def _version(): from clikernel import __version__ - return __version__ + return f"{__version__}:2" def _complete_file(): "Completion record path: a sibling of the swept run root, so the sweep never touches it" @@ -73,10 +73,10 @@ def _chk_nb(d): KATAS = [ dict(name='orient', par=2, files=['01_api.ipynb'], check=_chk_orient, ro=True, - route='find_cells/summary_nb for the module story; nbrg for the httpx calls; answer from the bare results', + route='find_cells(summary=True)/summary_nb for the module story; nbrg for the httpx calls; answer from the bare results', prompt='What does the weather module do, and which notebook cells call httpx? Answer in prose, naming cells by id, and pass it via dojo_score(orient="...").'), dict(name='edit set', par=2, files=['core.py'], check=_chk_core, - route='lnhashview_file, then ONE exhash_file with all commands, worked bottom-to-top', + route='lnhashview_file, then ONE exhash_file with each command tuple as a positional argument, worked bottom-to-top', prompt="In core.py: change the default units to 'metric', and rename cfg to config everywhere in code (docstring unchanged)."), dict(name='hostile replace', par=2, files=['tmpl.py'], check=_chk_tmpl, route='lnhashview_file, then one %%exhash with a range-c address; payload verbatim, no quoting. (% c would replace the whole file: too much here)', @@ -251,7 +251,8 @@ def dojo_score(bash_calls=0, orient=''): _rm_run(d) _RUN.clear() cid = uuid.uuid4().hex[:4] - recs = _completions(); recs[cid] = dict(t=time.time(), v=_version()) + recs = _completions() + recs[cid] = dict(t=time.time(), v=_version()) _complete_file().write_text(json.dumps(recs)) print(f"Clean round. Run dir removed. Completion id: {cid} - keep this id, including through compaction: passing dojo_start({cid!r}) in a future session skips the round.") else: diff --git a/clikernel/skill.py b/clikernel/skill.py index 24003fa..5beb06b 100644 --- a/clikernel/skill.py +++ b/clikernel/skill.py @@ -4,7 +4,7 @@ `clikernel` exposes one long-lived IPython process (wrapping `execnb.shell.CaptureShell`) that runs Python/IPython code and keeps state across the whole conversation: imports, live objects, monkeypatches, cached results, API clients, small experiments. Treat it as a notebook-style workbench, not a one-shot script runner. -Prefer it over one-off Python scripts (`python -c`, shell heredocs) whenever you need to inspect runtime behavior, test an idea, call a Python API, examine package state, run a live probe, or iterate on an implementation detail. Shell commands are still the right tool for file search, git, project test/build commands, and non-Python tools. +Prefer it over one-off Python scripts (`python -c`, shell heredocs) whenever you need to inspect runtime behavior, test an idea, call a Python API, examine package state, run a live probe, or iterate on an implementation detail. Prefer in-kernel tools over shell equivalents when they exist: file search goes through the `rgapi` pyskill (`rg()`/`fd()`), and GitHub work (PRs, issues, CI status) through the `ghapi` pyskill, when those are installed. Shell commands remain the right tool for local git operations, project test/build commands, and non-Python tools. There are two ways to drive it, depending on what the host supports: @@ -89,6 +89,7 @@ def f(x): - `importlib.reload`ing a module is not always enough to pick up a change: other already-imported modules that did `from x import *`, or that monkeypatched one of its classes (e.g. via fastcore's `@patch`), hold stale references that a targeted reload won't fix. If you hit a stale-class symptom (a class missing a method you know it has, `isinstance` mysteriously failing), you need a fresh interpreter process: the MCP `restart` tool, or in CLI mode, exit and start a new process. - Everything a cell outputs lands in the conversation and stays there. Be surgical: inspect only what's needed, and don't dump large values -- `print(len(v))` first, then decide whether to print in full or filter down. - The kernel is scoped to one client session and shared by any subagents within it. If the server restarts or exits, in-memory state is gone -- redo imports and setup. +- If the host conversation survived a kernel restart and the relevant `doc()` output is still visible, call `doced(name1, name2, ...)` to restore doc state without reprinting it. After context compaction, call `forget_doced()` and read the docs again. # Pyskills diff --git a/tests/test_cli.py b/tests/test_cli.py index 596de4d..b71a7cd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -322,11 +322,11 @@ def test_cli_inspectors(tmp_path): finally: stop_kernel(proc) -STARTUP_SRC = "from functools import reduce # SRC-ONLY-TOKEN\nprint('STARTUP-STDOUT-MARKER')\nGREETING = 'hi from startup'\n" +STARTUP_SRC = "from functools import reduce # SRC-ONLY-TOKEN\nprint('STARTUP-STDOUT-MARKER')\nGREETING = 'hi from startup'\nSTARTUP_FILE = __file__\n" def test_cli_startup(tmp_path): "startup.py from XDG runs in the persistent session (imports/names available to later requests); the banner carries a element with a child always and an child only when it prints; a broken startup.py is reported but doesn't stop the kernel." - xdg = tmp_path/"xdg" + xdg = tmp_path/"xdg config" (xdg/"clikernel").mkdir(parents=True) sp = xdg/"clikernel"/"startup.py" sp.write_text(STARTUP_SRC) @@ -338,6 +338,7 @@ def test_cli_startup(tmp_path): assert "" in body and "STARTUP-STDOUT-MARKER" in body # stdout child present assert send(proc, "GREETING\n")[0] == "'hi from startup'\n" # name defined at startup persists assert send(proc, "reduce(lambda a,b:a+b, [1,2,3])\n")[0] == "6\n" # import from startup persists + assert send(proc, "STARTUP_FILE\n")[0] == repr(str(sp)) + "\n" # startup runs as a file, with its path available finally: stop_kernel(proc) (xdg/"clikernel"/"startup.py").write_text("SILENT = 1\n") # imports/defs but prints nothing diff --git a/tests/test_rules.py b/tests/test_rules.py index 766b37a..34ef5b2 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -50,12 +50,12 @@ def test_rules(): assert not fires("%nbrun abc", "run_magic") # a real magic is the blessed route # tuple a/i/c payloads -> %%exhash magic (short quote-free payloads tolerated) - assert fires("exhash_file(p, [(a, 'c', 'a longer replacement line here')])", "tuple_payload") - assert fires('exhash_file(p, [(a, "a", "it\'s")])', "tuple_payload") # quote in payload - assert fires("exhash_cell(p, cid, [(a, 'i', 'x = \\\\n')])", "tuple_payload") # backslash in payload - assert not fires("exhash_file(p, [(a, 'c', 'metric')])", "tuple_payload") # short one-worder: fine - assert not fires("exhash_file(p, [(a, 's', 'longer than twenty chars ok')])", "tuple_payload") # s is not a payload command - assert not fires("exhash_file(p, [(a, 'c', body)])", "tuple_payload") # variables unknowable: stay quiet + assert fires("exhash_file(p, (a, 'c', 'a longer replacement line here'))", "tuple_payload") + assert fires('exhash_file(p, (a, "a", "it\'s"))', "tuple_payload") # quote in payload + assert fires("exhash_cell(p, cid, (a, 'i', 'x = \\\\n'))", "tuple_payload") # backslash in payload + assert not fires("exhash_file(p, (a, 'c', 'metric'))", "tuple_payload") # short one-worder: fine + assert not fires("exhash_file(p, (a, 's', 'longer than twenty chars ok'))", "tuple_payload") # s is not a payload command + assert not fires("exhash_file(p, (a, 'c', body))", "tuple_payload") # variables unknowable: stay quiet # blockers assert fires("import subprocess", "shell_escape") From 6a3bd4c1a084728a2da3a52154f82a6d69d60189 Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Thu, 16 Jul 2026 11:17:33 -0400 Subject: [PATCH 3/4] fix dup images --- clikernel/base.py | 2 +- clikernel/cli.py | 4 +++- tests/test_cli.py | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/clikernel/base.py b/clikernel/base.py index e36e7ab..8f25536 100644 --- a/clikernel/base.py +++ b/clikernel/base.py @@ -194,7 +194,7 @@ def _errbox(): return "\n" + traceback.format_exc() + "\n(.*?)\n', re.S) -_IMG_MIMES = {'image/png','image/jpeg','image/gif','image/webp'} +_IMG_MIMES = ('image/png','image/jpeg','image/gif','image/webp') # preference order for choosing one image per output def _split_media(body): "Split `` elements out of a worker response into `(text, content blocks)`; images only, the rest is dropped" diff --git a/clikernel/cli.py b/clikernel/cli.py index d91a58a..e02253d 100644 --- a/clikernel/cli.py +++ b/clikernel/cli.py @@ -68,7 +68,9 @@ def _execute(shell, inspectors, code, media=False): except Exception as e: note = f"\ninspector crashed, check skipped ({type(e).__name__}: {e})\n\n" res = shell.run(code) out = note + render_text(res) - if media: out += ''.join(f'\n\n{d}\n' for m,d in render_media(res) if m in _IMG_MIMES) + if media: + for ms in (dict(render_media([o])) for o in res): + if (m := next((m for m in _IMG_MIMES if m in ms), None)): out += f'\n\n{ms[m]}\n' return out diff --git a/tests/test_cli.py b/tests/test_cli.py index fb930f1..3a2df25 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -381,6 +381,7 @@ def test_cli_profile(tmp_path): PNG1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==' SHOW_IMG = f"import base64; from IPython.display import Image, display; display(Image(base64.b64decode('{PNG1}'))); 'done'" +MULTI_IMG = f"display({{'image/png': base64.b64decode('{PNG1}'), 'image/jpeg': base64.b64decode('{PNG1}')}}, raw=True); 'done'" def test_media_flag(tmp_path): "Rich outputs stay text-only by default (existing consumers see no change); --media appends elements after the rendered text." @@ -395,4 +396,6 @@ def test_media_flag(tmp_path): read_until_ready(proc) out, _ = send(proc, SHOW_IMG + "\n") assert "done" in out and f'\n{PNG1}\n' in out + out, _ = send(proc, MULTI_IMG + "\n") + assert out.count("' in out # one image per output: preferred mime wins finally: stop_kernel(proc) From 8daa5575ff0935fc093468836c86c62ca4a08017 Mon Sep 17 00:00:00 2001 From: Nathan Cooper Date: Mon, 20 Jul 2026 14:50:09 -0400 Subject: [PATCH 4/4] image supp --- clikernel/base.py | 10 +++++----- clikernel/cli.py | 11 +++-------- tests/test_cli.py | 8 ++++---- tests/test_mcp.py | 2 +- 4 files changed, 13 insertions(+), 18 deletions(-) diff --git a/clikernel/base.py b/clikernel/base.py index 8f25536..412aa62 100644 --- a/clikernel/base.py +++ b/clikernel/base.py @@ -193,14 +193,14 @@ async def run(self, code): def _errbox(): return "\n" + traceback.format_exc() + "" -_MEDIA_RE = re.compile(r'\n?\n(.*?)\n', re.S) -_IMG_MIMES = ('image/png','image/jpeg','image/gif','image/webp') # preference order for choosing one image per output +_IMG_MIMES = ('image/png','image/jpeg') # mimes forwarded as MCP image blocks; others (e.g. svg) stay in the text +_MEDIA_RE = re.compile(r'\n?<(display_data|execute_result) mime="(%s)">\n(.*?)\n' % '|'.join(_IMG_MIMES), re.S) def _split_media(body): - "Split `` elements out of a worker response into `(text, content blocks)`; images only, the rest is dropped" + "Split image output elements out of a worker response into `(text, content blocks)`" from mcp.types import ImageContent parts = _MEDIA_RE.findall(body) - blocks = [ImageContent(type='image', data=d, mimeType=m) for m,d in parts if m in _IMG_MIMES] + blocks = [ImageContent(type='image', data=d, mimeType=m) for _,m,d in parts] return (_MEDIA_RE.sub('', body) if parts else body), blocks @@ -261,7 +261,7 @@ async def execute(code:str # Code to run in the persistent session if body is None: return note + "\nkernel process died while executing this request; a fresh kernel will be started on the next call, with all session state lost\n" text, blocks = _split_media(note + body) - return [text, *blocks] if blocks else text + return [*([text] if text.strip() else []), *blocks] if blocks else text except Exception: w.desynced = True return _errbox() diff --git a/clikernel/cli.py b/clikernel/cli.py index e02253d..13e33ef 100644 --- a/clikernel/cli.py +++ b/clikernel/cli.py @@ -2,7 +2,7 @@ from pathlib import Path from fastcore.xdg import xdg_config_home from clikernel import INSTRUCTIONS -from clikernel.base import _IMG_MIMES,RuleBlock,fmt_error,init_worker,run_startup,serve_stream +from clikernel.base import RuleBlock,fmt_error,init_worker,run_startup,serve_stream def _state_root(): if d := os.environ.get("CLIKERNEL_STATE_DIR"): return Path(d).expanduser() @@ -62,16 +62,11 @@ def _inspect(shell, inspectors, code): def _execute(shell, inspectors, code, media=False): - from fastcore.nbio import render_text,render_media + from fastcore.nbio import render_text try: note = _inspect(shell, inspectors, code) except RuleBlock as e: return fmt_error("blocked", str(e)) except Exception as e: note = f"\ninspector crashed, check skipped ({type(e).__name__}: {e})\n\n" - res = shell.run(code) - out = note + render_text(res) - if media: - for ms in (dict(render_media([o])) for o in res): - if (m := next((m for m in _IMG_MIMES if m in ms), None)): out += f'\n\n{ms[m]}\n' - return out + return note + render_text(shell.run(code), include_imgs=media) def _request_exit(shell): shell._clikernel_exit = True diff --git a/tests/test_cli.py b/tests/test_cli.py index 3a2df25..96e19b5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -384,18 +384,18 @@ def test_cli_profile(tmp_path): MULTI_IMG = f"display({{'image/png': base64.b64decode('{PNG1}'), 'image/jpeg': base64.b64decode('{PNG1}')}}, raw=True); 'done'" def test_media_flag(tmp_path): - "Rich outputs stay text-only by default (existing consumers see no change); --media appends elements after the rendered text." + "Rich outputs stay text-only by default (existing consumers see no change); --media renders image outputs inline with a `mime` attribute." proc = start_kernel(tmp_path) try: read_until_ready(proc) out, _ = send(proc, SHOW_IMG + "\n") - assert "done" in out and "\n{PNG1}\n' in out + assert "done" in out and f'\n{PNG1}\n' in out out, _ = send(proc, MULTI_IMG + "\n") - assert out.count("' in out # one image per output: preferred mime wins + assert out.count('mime=') == 1 and 'mime="image/jpeg"' in out # one image per output: fastcore's preferred mime wins finally: stop_kernel(proc) diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c19ed65..9c5bc9d 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -170,7 +170,7 @@ async def test_mcp_media(): async with stdio_client(_server_params()) as (r, w), ClientSession(r, w) as s: await s.initialize() res = await s.call_tool("execute", {"code": f"import base64; from IPython.display import Image, display; display(Image(base64.b64decode('{PNG1}'))); 'done'"}) - assert res.content[0].type == "text" and "done" in res.content[0].text and "