codebind 0.6.15__tar.gz → 0.6.17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {codebind-0.6.15 → codebind-0.6.17}/PKG-INFO +8 -3
  2. {codebind-0.6.15 → codebind-0.6.17}/README.md +6 -2
  3. {codebind-0.6.15 → codebind-0.6.17}/pyproject.toml +2 -1
  4. {codebind-0.6.15 → codebind-0.6.17}/pyproject.toml.orig +2 -1
  5. codebind-0.6.17/src/codebind/async_runtime.py +20 -0
  6. codebind-0.6.17/src/codebind/context.md +1 -0
  7. codebind-0.6.17/src/codebind/context.py +135 -0
  8. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/extension.py +30 -2
  9. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/session.py +12 -8
  10. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/terminal.py +38 -3
  11. {codebind-0.6.15 → codebind-0.6.17}/LICENSE +0 -0
  12. {codebind-0.6.15 → codebind-0.6.17}/data/share/jupyter/labextensions/codebind-jupyterlab/install.json +0 -0
  13. {codebind-0.6.15 → codebind-0.6.17}/data/share/jupyter/labextensions/codebind-jupyterlab/package.json +0 -0
  14. {codebind-0.6.15 → codebind-0.6.17}/data/share/jupyter/labextensions/codebind-jupyterlab/static/590.98c719a309e23852.js +0 -0
  15. {codebind-0.6.15 → codebind-0.6.17}/data/share/jupyter/labextensions/codebind-jupyterlab/static/remoteEntry.24258c9500a0850a.js +0 -0
  16. {codebind-0.6.15 → codebind-0.6.17}/data/share/jupyter/labextensions/codebind-jupyterlab/static/style.js +0 -0
  17. {codebind-0.6.15 → codebind-0.6.17}/data/share/jupyter/labextensions/codebind-jupyterlab/static/third-party-licenses.json +0 -0
  18. {codebind-0.6.15 → codebind-0.6.17}/jupyterlab/.yarnrc.yml +0 -0
  19. {codebind-0.6.15 → codebind-0.6.17}/jupyterlab/install.json +0 -0
  20. {codebind-0.6.15 → codebind-0.6.17}/jupyterlab/package.json +0 -0
  21. {codebind-0.6.15 → codebind-0.6.17}/jupyterlab/src/index.ts +0 -0
  22. {codebind-0.6.15 → codebind-0.6.17}/jupyterlab/tsconfig.json +0 -0
  23. {codebind-0.6.15 → codebind-0.6.17}/jupyterlab/yarn.lock +0 -0
  24. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/__init__.py +0 -0
  25. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/cli.py +0 -0
  26. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/configuration.py +0 -0
  27. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/conversation.py +0 -0
  28. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/display.py +0 -0
  29. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/execution.py +0 -0
  30. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/images.py +0 -0
  31. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/instructions.md +0 -0
  32. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/jupyter.py +0 -0
  33. {codebind-0.6.15 → codebind-0.6.17}/src/codebind/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebind
3
- Version: 0.6.15
3
+ Version: 0.6.17
4
4
  Summary: A frontend-neutral model loop for persistent IPython sessions.
5
5
  Keywords: ai,ipython,llm,repl,agents
6
6
  Author: Giovanni Gravili
@@ -18,6 +18,7 @@ Classifier: Typing :: Typed
18
18
  Requires-Dist: ipython>=9.17
19
19
  Requires-Dist: langchain-core>=1.0
20
20
  Requires-Dist: models-provider>=0.1.6
21
+ Requires-Dist: mistune>=3.3,<4
21
22
  Requires-Dist: pillow>=11
22
23
  Requires-Dist: resvg-py>=0.5
23
24
  Requires-Dist: rich>=14.1
@@ -65,7 +66,9 @@ Start a new conversation:
65
66
  uvx codebind
66
67
  ```
67
68
 
68
- Press `Shift+Tab` to switch the prompt into Question mode; the prompt turns purple. Type a question and press `Enter` to send it. Press `Shift+Tab` again to return to Python mode. Ordinary `Tab` completion is unchanged.
69
+ Press `Shift+Tab` to switch the prompt into Question mode; the prompt turns purple. Type a question and press `Enter` to send it. Press `Shift+Tab` again to return to Python mode. Ordinary `Tab` completion is unchanged. Codebind leaves terminal mouse reporting off so scrollback works while editing. Move the cursor with the keyboard or a terminal-native cursor-position shortcut, if your terminal supports one.
70
+
71
+ `%context` displays a Markdown breakdown of provider-reported model calls, including input, output, cached input (as tokens and a percentage of input), and reasoning tokens. It estimates context remaining after the last call; future inputs and tool output cannot be counted exactly until the provider responds.
69
72
 
70
73
  The `%question` and `%%question` magics remain available:
71
74
 
@@ -74,8 +77,10 @@ The `%question` and `%%question` magics remain available:
74
77
  Inspect this project and tell me what to implement first.
75
78
  ```
76
79
 
80
+ Codebind keeps its terminal prompt and internal bookkeeping usable when a tool cell leaves an asyncio loop active on the terminal thread. This also supports sync clients that manage their own loop across tool cells, without changing IPython’s normal top-level `await` behavior.
81
+
77
82
  Terminal conversations remain live for the current IPython process. Previous terminal inputs and outputs, including Python cells you ran yourself, enter the model's context when you ask a question. Durable automatic resumption belongs to notebooks, where the notebook file provides an unambiguous conversation identity.
78
- Model-executed cells appear with IPython's native input counter, syntax highlighting, output prompts, streams, and tracebacks. Assistant Markdown appears before the following tool cell, and tool output appears as it is written. To keep terminal scrollback usable, Codebind previews at most three gray output lines, shortening each line after 60 characters with `...`. Further lines appear as `… N more output lines.` The complete output remains available to the model and in IPython history; `%output 8` opens the full text of cell 8 in IPython's pager. User-entered Python cells keep IPython's normal display behavior. Assistant Markdown is rendered through IPython's terminal MIME renderer. A terminal is a stream rather than a notebook document, so these displays are not editable notebook cells.
83
+ Model-executed cells appear with IPython's native input counter, syntax highlighting, output prompts, streams, and tracebacks. Assistant Markdown appears before the following tool cell, and tool output appears as it is written. To keep terminal scrollback usable, Codebind previews at most three gray output lines, shortening each line after 80 characters with `...`. Further lines appear as `… N more output lines.` The complete output remains available to the model and in IPython history; `%output 8` opens the full text of cell 8 in IPython's pager. User-entered Python cells keep IPython's normal display behavior. Assistant Markdown is rendered through IPython's terminal MIME renderer. A terminal is a stream rather than a notebook document, so these displays are not editable notebook cells.
79
84
 
80
85
  ## JupyterLab
81
86
 
@@ -37,7 +37,9 @@ Start a new conversation:
37
37
  uvx codebind
38
38
  ```
39
39
 
40
- Press `Shift+Tab` to switch the prompt into Question mode; the prompt turns purple. Type a question and press `Enter` to send it. Press `Shift+Tab` again to return to Python mode. Ordinary `Tab` completion is unchanged.
40
+ Press `Shift+Tab` to switch the prompt into Question mode; the prompt turns purple. Type a question and press `Enter` to send it. Press `Shift+Tab` again to return to Python mode. Ordinary `Tab` completion is unchanged. Codebind leaves terminal mouse reporting off so scrollback works while editing. Move the cursor with the keyboard or a terminal-native cursor-position shortcut, if your terminal supports one.
41
+
42
+ `%context` displays a Markdown breakdown of provider-reported model calls, including input, output, cached input (as tokens and a percentage of input), and reasoning tokens. It estimates context remaining after the last call; future inputs and tool output cannot be counted exactly until the provider responds.
41
43
 
42
44
  The `%question` and `%%question` magics remain available:
43
45
 
@@ -46,8 +48,10 @@ The `%question` and `%%question` magics remain available:
46
48
  Inspect this project and tell me what to implement first.
47
49
  ```
48
50
 
51
+ Codebind keeps its terminal prompt and internal bookkeeping usable when a tool cell leaves an asyncio loop active on the terminal thread. This also supports sync clients that manage their own loop across tool cells, without changing IPython’s normal top-level `await` behavior.
52
+
49
53
  Terminal conversations remain live for the current IPython process. Previous terminal inputs and outputs, including Python cells you ran yourself, enter the model's context when you ask a question. Durable automatic resumption belongs to notebooks, where the notebook file provides an unambiguous conversation identity.
50
- Model-executed cells appear with IPython's native input counter, syntax highlighting, output prompts, streams, and tracebacks. Assistant Markdown appears before the following tool cell, and tool output appears as it is written. To keep terminal scrollback usable, Codebind previews at most three gray output lines, shortening each line after 60 characters with `...`. Further lines appear as `… N more output lines.` The complete output remains available to the model and in IPython history; `%output 8` opens the full text of cell 8 in IPython's pager. User-entered Python cells keep IPython's normal display behavior. Assistant Markdown is rendered through IPython's terminal MIME renderer. A terminal is a stream rather than a notebook document, so these displays are not editable notebook cells.
54
+ Model-executed cells appear with IPython's native input counter, syntax highlighting, output prompts, streams, and tracebacks. Assistant Markdown appears before the following tool cell, and tool output appears as it is written. To keep terminal scrollback usable, Codebind previews at most three gray output lines, shortening each line after 80 characters with `...`. Further lines appear as `… N more output lines.` The complete output remains available to the model and in IPython history; `%output 8` opens the full text of cell 8 in IPython's pager. User-entered Python cells keep IPython's normal display behavior. Assistant Markdown is rendered through IPython's terminal MIME renderer. A terminal is a stream rather than a notebook document, so these displays are not editable notebook cells.
51
55
 
52
56
  ## JupyterLab
53
57
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.6.15"
3
+ version = "0.6.17"
4
4
  description = "A frontend-neutral model loop for persistent IPython sessions."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -27,6 +27,7 @@ dependencies = [
27
27
  "ipython>=9.17",
28
28
  "langchain-core>=1.0",
29
29
  "models-provider>=0.1.6",
30
+ "mistune>=3.3,<4",
30
31
  "pillow>=11",
31
32
  "resvg-py>=0.5",
32
33
  "rich>=14.1",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.6.15"
3
+ version = "0.6.17"
4
4
  description = "A frontend-neutral model loop for persistent IPython sessions."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "ipython>=9.17",
25
25
  "langchain-core>=1.0",
26
26
  "models-provider>=0.1.6",
27
+ "mistune>=3.3,<4",
27
28
  "pillow>=11",
28
29
  "resvg-py>=0.5",
29
30
  "rich>=14.1",
@@ -0,0 +1,20 @@
1
+ """Bridge synchronous Codebind operations around foreign event loops."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from typing import TypeVar
9
+
10
+ _T = TypeVar("_T")
11
+
12
+
13
+ def run_sync(operation: Awaitable[_T]) -> _T:
14
+ """Run a coroutine without nesting asyncio.run in a loop owned by another API."""
15
+ try:
16
+ asyncio.get_running_loop()
17
+ except RuntimeError:
18
+ return asyncio.run(operation)
19
+ with ThreadPoolExecutor(max_workers=1, thread_name_prefix="codebind-async") as worker:
20
+ return worker.submit(asyncio.run, operation).result()
@@ -0,0 +1 @@
1
+ Below you can find the context window information for `$model`.
@@ -0,0 +1,135 @@
1
+ """Provider-reported context usage for the active conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from importlib.resources import files
7
+ from string import Template
8
+ from typing import Any
9
+
10
+ import mistune
11
+ from langchain_core.language_models import BaseChatModel
12
+ from langchain_core.messages import AIMessage, BaseMessage, ToolMessage
13
+ from mistune.renderers.markdown import MarkdownRenderer
14
+
15
+
16
+ def _count(value: Any) -> int:
17
+ return value if isinstance(value, int) and value >= 0 else 0
18
+
19
+
20
+ def _text(value: str) -> dict[str, Any]:
21
+ return {"type": "text", "raw": value}
22
+
23
+
24
+ def _paragraph(value: str) -> dict[str, Any]:
25
+ return {"type": "paragraph", "children": [_text(value)]}
26
+
27
+
28
+ def _table(headers: tuple[str, ...], rows: list[tuple[str, ...]]) -> dict[str, Any]:
29
+ def cells(values: tuple[str, ...], *, head: bool) -> list[dict[str, Any]]:
30
+ return [
31
+ {
32
+ "type": "table_cell",
33
+ "attrs": {"align": "left" if index == 0 else "right", "head": head},
34
+ "children": [_text(value)],
35
+ }
36
+ for index, value in enumerate(values)
37
+ ]
38
+
39
+ return {
40
+ "type": "table",
41
+ "children": [
42
+ {"type": "table_head", "children": cells(headers, head=True)},
43
+ {
44
+ "type": "table_body",
45
+ "children": [
46
+ {"type": "table_row", "children": cells(row, head=False)} for row in rows
47
+ ],
48
+ },
49
+ ],
50
+ }
51
+
52
+
53
+ def format_context(messages: Sequence[BaseMessage], model: BaseChatModel, name: str) -> str:
54
+ """Render reported calls as Markdown; next-turn context remains an estimate."""
55
+ context_window = getattr(model, "context_window", None)
56
+ capacity = _count(context_window() if callable(context_window) else getattr(model, "context_length", 0))
57
+ calls = [message for message in messages if isinstance(message, AIMessage)]
58
+ measured = [message for message in calls if isinstance(message.usage_metadata, dict)]
59
+ results = [message for message in messages if isinstance(message, ToolMessage)]
60
+
61
+ def totals(group: list[AIMessage]) -> tuple[str, ...]:
62
+ counts = [0, 0, 0, 0]
63
+ for message in group:
64
+ usage = message.usage_metadata or {}
65
+ counts[0] += _count(usage.get("input_tokens"))
66
+ counts[1] += _count(usage.get("output_tokens"))
67
+ counts[2] += _count((usage.get("input_token_details") or {}).get("cache_read"))
68
+ counts[3] += _count((usage.get("output_token_details") or {}).get("reasoning"))
69
+ input_tokens, output_tokens, cached_tokens, reasoning_tokens = counts
70
+ cached_share = f"{cached_tokens / input_tokens:.2%}" if input_tokens else "—"
71
+ return (
72
+ f"{len(group):,}",
73
+ f"{input_tokens:,}",
74
+ f"{output_tokens:,}",
75
+ f"{cached_tokens:,} ({cached_share})",
76
+ f"{reasoning_tokens:,}",
77
+ )
78
+
79
+ last_reported = bool(calls and isinstance(calls[-1].usage_metadata, dict))
80
+ latest = calls[-1].usage_metadata if last_reported else {}
81
+ last_input = _count((latest or {}).get("input_tokens"))
82
+ last_output = _count((latest or {}).get("output_tokens"))
83
+ last_cached = _count(((latest or {}).get("input_token_details") or {}).get("cache_read"))
84
+ remaining = max(capacity - last_input - last_output, 0) if capacity and last_reported else None
85
+
86
+ def percent(value: int) -> str:
87
+ return f"{value / capacity:.2%}" if capacity and last_reported else "—"
88
+
89
+ tool_chars = sum(
90
+ len(block["text"])
91
+ for message in results
92
+ for block in message.content_blocks
93
+ if block.get("type") == "text" and isinstance(block.get("text"), str)
94
+ )
95
+ images = sum(1 for message in results for block in message.content_blocks if block.get("type") == "image")
96
+ missing = len(calls) - len(measured)
97
+ request_rows = [
98
+ ("Tool calls", *totals([message for message in measured if message.tool_calls])),
99
+ ("Responses", *totals([message for message in measured if not message.tool_calls])),
100
+ ("Reported total", *totals(measured)),
101
+ ]
102
+ if missing:
103
+ request_rows.append(("Unreported", f"{missing:,}", "—", "—", "—", "—"))
104
+ preset = Template(files("codebind").joinpath("context.md").read_text()).substitute(model=name)
105
+ parser = mistune.create_markdown(renderer="ast", plugins=["table"])
106
+ heading, *notes = parser(preset)
107
+ blocks = [
108
+ heading,
109
+ _paragraph(
110
+ "Provider-reported usage from completed calls."
111
+ if last_reported
112
+ else "Latest model call has no reported usage; remaining context cannot be measured."
113
+ if calls
114
+ else "No provider-reported model usage yet; remaining context cannot be measured."
115
+ ),
116
+ _table(
117
+ ("Last completed model call", "Tokens", "Share"),
118
+ [
119
+ ("Input (includes cached input)", f"{last_input:,}" if last_reported else "—", f"{percent(last_input)} of window" if capacity and last_reported else "—"),
120
+ ("Cached input", f"{last_cached:,}" if last_reported else "—", f"{last_cached / last_input:.2%} of input" if last_input else "—"),
121
+ ("Output (includes reasoning)", f"{last_output:,}" if last_reported else "—", f"{percent(last_output)} of window" if capacity and last_reported else "—"),
122
+ ("Estimated remaining", f"{remaining:,}" if remaining is not None else "—", f"{percent(remaining or 0)} of window" if remaining is not None else "—"),
123
+ ],
124
+ ),
125
+ _table(
126
+ ("Model calls", "Calls", "Input", "Output", "Cached input (%)", "Reasoning output"),
127
+ request_rows,
128
+ ),
129
+ _table(
130
+ ("Tool results", "Count", "Text characters", "Images"),
131
+ [("Recorded", f"{len(results):,}", f"{tool_chars:,}", f"{images:,}")],
132
+ ),
133
+ *notes,
134
+ ]
135
+ return MarkdownRenderer()(blocks, mistune.BlockState())
@@ -12,7 +12,10 @@ from IPython.core.page import page
12
12
  from IPython.terminal.interactiveshell import TerminalInteractiveShell
13
13
  from langchain_core.language_models import BaseChatModel
14
14
 
15
+ from .async_runtime import run_sync
15
16
  from .configuration import load_configuration, load_models
17
+ from .context import format_context
18
+ from .display import display_assistant
16
19
  from .conversation import MemoryConversationStore, NotebookConversationStore
17
20
  from .jupyter import JupyterLabBridge, TARGET_NAME
18
21
  from .session import Session
@@ -26,6 +29,9 @@ def _close_state(state: dict[str, Any]) -> None:
26
29
  restore_control_interrupt = state.get("restore_control_interrupt")
27
30
  if callable(restore_control_interrupt):
28
31
  restore_control_interrupt()
32
+ restore_context_magic = state.get("restore_context_magic")
33
+ if callable(restore_context_magic):
34
+ restore_context_magic()
29
35
  restore_output_magic = state.get("restore_output_magic")
30
36
  if callable(restore_output_magic):
31
37
  restore_output_magic()
@@ -53,7 +59,7 @@ def _close_state(state: dict[str, Any]) -> None:
53
59
  try:
54
60
  asyncio.get_running_loop().create_task(discard_model())
55
61
  except RuntimeError:
56
- asyncio.run(discard_model())
62
+ run_sync(discard_model())
57
63
 
58
64
 
59
65
  def load_ipython_extension(ipython: InteractiveShell) -> None:
@@ -99,9 +105,31 @@ def load_ipython_extension(ipython: InteractiveShell) -> None:
99
105
  "restore_terminal_question_mode": None,
100
106
  "terminal_history": None,
101
107
  "restore_output_magic": None,
108
+ "restore_context_magic": None,
102
109
  "restore_control_interrupt": None,
103
110
  }
104
111
 
112
+ line_magics = ipython.magics_manager.magics["line"]
113
+ previous_context_magic = line_magics.get("context")
114
+
115
+ def context_magic(line: str) -> None:
116
+ if line.strip():
117
+ raise ValueError("Usage: %context")
118
+ session = state.get("session")
119
+ messages = session.messages if isinstance(session, Session) else ()
120
+ display_assistant(format_context(messages, get_model(), configuration.model))
121
+
122
+ ipython.register_magic_function(context_magic, "line", "context")
123
+
124
+ def restore_context_magic() -> None:
125
+ if line_magics.get("context") is context_magic:
126
+ if previous_context_magic is None:
127
+ line_magics.pop("context", None)
128
+ else:
129
+ line_magics["context"] = previous_context_magic
130
+
131
+ state["restore_context_magic"] = restore_context_magic
132
+
105
133
  if manager is None:
106
134
  state["session"] = Session(shell=ipython, store=MemoryConversationStore())
107
135
  if isinstance(ipython, TerminalInteractiveShell):
@@ -236,7 +264,7 @@ def load_ipython_extension(ipython: InteractiveShell) -> None:
236
264
  try:
237
265
  session.send(cell if cell is not None else line, selected, notebook=history)
238
266
  except BaseException:
239
- asyncio.run(discard_model())
267
+ run_sync(discard_model())
240
268
  raise
241
269
 
242
270
  ipython.register_magic_function(question_magic, "line_cell", "question")
@@ -27,6 +27,7 @@ from langchain_core.messages.ai import add_ai_message_chunks
27
27
  from langchain_core.runnables import Runnable
28
28
  from models_provider import TransientProviderError
29
29
 
30
+ from .async_runtime import run_sync
30
31
  from .display import display_assistant
31
32
  from .conversation import Conversation, ConversationStore, MemoryConversationStore
32
33
  from .execution import ExecutionReport, IPythonExecutor
@@ -146,7 +147,7 @@ class Session:
146
147
  await self._ensure_loaded()
147
148
 
148
149
  def _ensure_loaded_sync(self) -> None:
149
- asyncio.run(self._ensure_loaded())
150
+ run_sync(self._ensure_loaded())
150
151
 
151
152
  @staticmethod
152
153
  async def _await_durable(operation: Awaitable[None]) -> None:
@@ -192,17 +193,17 @@ class Session:
192
193
  await self._commit(lambda conversation: conversation.append_notebook(notebook, turn_id))
193
194
 
194
195
  def _append_event_sync(self, event_type: str, **data: Any) -> None:
195
- asyncio.run(self._append_event(event_type, **data))
196
+ run_sync(self._append_event(event_type, **data))
196
197
 
197
198
  def _append_message_sync(self, message: BaseMessage, turn_id: str) -> None:
198
- asyncio.run(self._append_message(message, turn_id))
199
+ run_sync(self._append_message(message, turn_id))
199
200
 
200
201
  def _append_notebook_sync(
201
202
  self,
202
203
  notebook: list[dict[str, Any]] | None,
203
204
  turn_id: str,
204
205
  ) -> None:
205
- asyncio.run(self._append_notebook(notebook, turn_id))
206
+ run_sync(self._append_notebook(notebook, turn_id))
206
207
 
207
208
  def _model_kwargs(self, model: BaseChatModel) -> dict[str, Any]:
208
209
  if self.conversation is None:
@@ -316,7 +317,7 @@ class Session:
316
317
  except TransientProviderError as error:
317
318
  if retry == len(_MODEL_RETRY_DELAYS):
318
319
  raise
319
- asyncio.run(self._reset_model_connection(model))
320
+ run_sync(self._reset_model_connection(model))
320
321
  time.sleep(max(0.0, error.retry_after or _MODEL_RETRY_DELAYS[retry]))
321
322
  raise AssertionError("unreachable")
322
323
 
@@ -434,7 +435,7 @@ class Session:
434
435
  try:
435
436
  asyncio.get_running_loop()
436
437
  except RuntimeError:
437
- asyncio.run(self.aclear())
438
+ run_sync(self.aclear())
438
439
  return
439
440
  raise RuntimeError("clear() cannot run inside an active event loop; use await aclear().")
440
441
 
@@ -459,11 +460,14 @@ class Session:
459
460
  ) -> None:
460
461
  """Send one user message synchronously."""
461
462
  try:
462
- asyncio.get_running_loop()
463
+ loop = asyncio.get_running_loop()
463
464
  except RuntimeError:
464
465
  pass
465
466
  else:
466
- raise RuntimeError("send() cannot run inside an active event loop; use await asend().")
467
+ # Greenlet-backed sync APIs can expose their loop on this thread
468
+ # without this call actually running inside an asyncio task.
469
+ if asyncio.current_task(loop) is not None:
470
+ raise RuntimeError("send() cannot run inside an active event loop; use await asend().")
467
471
 
468
472
  text = prompt.strip()
469
473
  if not text:
@@ -3,8 +3,10 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import ast
6
+ import asyncio
6
7
  import sys
7
8
  from collections.abc import Callable, Iterator
9
+ from concurrent.futures import ThreadPoolExecutor
8
10
  from contextlib import contextmanager
9
11
  from copy import deepcopy
10
12
  from unittest.mock import patch
@@ -86,7 +88,7 @@ def _history_cell(
86
88
  magic = _magic_input(transformed)
87
89
  if magic is not None and magic[0] == "question":
88
90
  return {"id": identifier, "type": "markdown", "source": magic[1]}
89
- if magic is not None and magic[0] == "output":
91
+ if magic is not None and magic[0] in {"output", "context"}:
90
92
  return {"id": identifier, "type": "raw", "source": source}
91
93
 
92
94
  outputs: list[dict[str, object]] = []
@@ -313,6 +315,11 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
313
315
  if prompt_session is None:
314
316
  return lambda: None
315
317
 
318
+ original_shell_mouse = shell.mouse_support
319
+ original_prompt_mouse = prompt_session.mouse_support
320
+ # Terminal mouse reporting intercepts scrollback, even if the prompt is idle.
321
+ shell.mouse_support = False
322
+ prompt_session.mouse_support = False
316
323
  original_style = prompt_session.style
317
324
  original_prompt_for_code = shell.prompt_for_code
318
325
  had_prompt_override = "prompt_for_code" in vars(shell)
@@ -321,12 +328,36 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
321
328
  question_mode = False
322
329
  active_prompt = False
323
330
  pending_question = False
331
+ prompt_worker: ThreadPoolExecutor | None = None
324
332
 
325
333
  def prompt_for_code() -> str:
326
- nonlocal active_prompt, pending_question
334
+ nonlocal active_prompt, pending_question, prompt_worker
327
335
  active_prompt = True
328
336
  try:
329
- text = original_prompt_for_code()
337
+ try:
338
+ asyncio.get_running_loop()
339
+ except RuntimeError:
340
+ text = original_prompt_for_code()
341
+ else:
342
+ if prompt_worker is None:
343
+ prompt_worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="codebind-prompt")
344
+ future = prompt_worker.submit(original_prompt_for_code)
345
+ try:
346
+ text = future.result()
347
+ except BaseException:
348
+ if not future.done():
349
+ app = prompt_session.app
350
+ if app.is_running and app.loop is not None:
351
+ def abort_prompt() -> None:
352
+ if app.is_running:
353
+ app.exit(exception=KeyboardInterrupt())
354
+
355
+ app.loop.call_soon_threadsafe(abort_prompt)
356
+ try:
357
+ future.result(timeout=3)
358
+ except BaseException:
359
+ pass
360
+ raise
330
361
  finally:
331
362
  active_prompt = False
332
363
  pending_question = question_mode and bool(text.strip())
@@ -384,6 +415,10 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
384
415
  else:
385
416
  del shell.prompt_for_code
386
417
  prompt_session.style = original_style
418
+ prompt_session.mouse_support = original_prompt_mouse
419
+ shell.mouse_support = original_shell_mouse
420
+ if prompt_worker is not None:
421
+ prompt_worker.shutdown(wait=False, cancel_futures=True)
387
422
 
388
423
  return restore
389
424
 
File without changes
File without changes