codebind 0.6.19__tar.gz → 0.6.20__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 (35) hide show
  1. {codebind-0.6.19 → codebind-0.6.20}/PKG-INFO +3 -3
  2. {codebind-0.6.19 → codebind-0.6.20}/README.md +2 -2
  3. {codebind-0.6.19 → codebind-0.6.20}/pyproject.toml +1 -1
  4. {codebind-0.6.19 → codebind-0.6.20}/pyproject.toml.orig +1 -1
  5. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/execution.py +8 -1
  6. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/extension.py +14 -1
  7. codebind-0.6.20/src/codebind/interrupts.py +9 -0
  8. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/jupyter.py +3 -1
  9. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/session.py +3 -2
  10. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/terminal.py +198 -19
  11. {codebind-0.6.19 → codebind-0.6.20}/LICENSE +0 -0
  12. {codebind-0.6.19 → codebind-0.6.20}/data/share/jupyter/labextensions/codebind-jupyterlab/install.json +0 -0
  13. {codebind-0.6.19 → codebind-0.6.20}/data/share/jupyter/labextensions/codebind-jupyterlab/package.json +0 -0
  14. {codebind-0.6.19 → codebind-0.6.20}/data/share/jupyter/labextensions/codebind-jupyterlab/static/590.0b7494e278aa27b9.js +0 -0
  15. {codebind-0.6.19 → codebind-0.6.20}/data/share/jupyter/labextensions/codebind-jupyterlab/static/remoteEntry.8f763ad3faac0f5a.js +0 -0
  16. {codebind-0.6.19 → codebind-0.6.20}/data/share/jupyter/labextensions/codebind-jupyterlab/static/style.js +0 -0
  17. {codebind-0.6.19 → codebind-0.6.20}/data/share/jupyter/labextensions/codebind-jupyterlab/static/third-party-licenses.json +0 -0
  18. {codebind-0.6.19 → codebind-0.6.20}/jupyterlab/.yarnrc.yml +0 -0
  19. {codebind-0.6.19 → codebind-0.6.20}/jupyterlab/install.json +0 -0
  20. {codebind-0.6.19 → codebind-0.6.20}/jupyterlab/package.json +0 -0
  21. {codebind-0.6.19 → codebind-0.6.20}/jupyterlab/src/index.ts +0 -0
  22. {codebind-0.6.19 → codebind-0.6.20}/jupyterlab/tsconfig.json +0 -0
  23. {codebind-0.6.19 → codebind-0.6.20}/jupyterlab/yarn.lock +0 -0
  24. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/__init__.py +0 -0
  25. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/async_runtime.py +0 -0
  26. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/cli.py +0 -0
  27. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/configuration.py +0 -0
  28. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/context.md +0 -0
  29. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/context.py +0 -0
  30. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/conversation.py +0 -0
  31. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/display.py +0 -0
  32. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/images.py +0 -0
  33. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/instructions.md +0 -0
  34. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/model_text.py +0 -0
  35. {codebind-0.6.19 → codebind-0.6.20}/src/codebind/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebind
3
- Version: 0.6.19
3
+ Version: 0.6.20
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
@@ -66,7 +66,7 @@ Start a new conversation:
66
66
  uvx codebind
67
67
  ```
68
68
 
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.
69
+ Press `Shift+Tab` to switch the prompt into Question mode; the prompt turns purple. Type a question and press `Enter` to send it. `Ctrl+J` starts a new Markdown line, and so does `Shift+Enter` in terminals that report the key with modifiers (macOS Terminal does not; iTerm2, Ghostty, WezTerm, and Kitty do). `Ctrl+C` while typing returns to Python mode without interrupting the kernel. Submitting a question also returns to Python mode automatically; press `Shift+Tab` again for another question. 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
70
 
71
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.
72
72
 
@@ -80,7 +80,7 @@ Inspect this project and tell me what to implement first.
80
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
81
 
82
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.
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.` Full output remains in IPython history, while model-facing text is bounded and displayed images stay available as image blocks; `%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
+ While a question runs, its cell shows `In [*]` and its tools show `In [N]`, as a notebook does. `Ctrl+C` during a question stops the whole turn: it prints a plain `KeyboardInterrupt` and returns to the prompt, whether the interrupt lands in the model stream or in a running tool cell. 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.` Full output remains in IPython history, while model-facing text is bounded and displayed images stay available as image blocks; `%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.
84
84
 
85
85
  ## JupyterLab
86
86
 
@@ -37,7 +37,7 @@ 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. 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.
40
+ Press `Shift+Tab` to switch the prompt into Question mode; the prompt turns purple. Type a question and press `Enter` to send it. `Ctrl+J` starts a new Markdown line, and so does `Shift+Enter` in terminals that report the key with modifiers (macOS Terminal does not; iTerm2, Ghostty, WezTerm, and Kitty do). `Ctrl+C` while typing returns to Python mode without interrupting the kernel. Submitting a question also returns to Python mode automatically; press `Shift+Tab` again for another question. 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
41
 
42
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.
43
43
 
@@ -51,7 +51,7 @@ Inspect this project and tell me what to implement first.
51
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
52
 
53
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.
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.` Full output remains in IPython history, while model-facing text is bounded and displayed images stay available as image blocks; `%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
+ While a question runs, its cell shows `In [*]` and its tools show `In [N]`, as a notebook does. `Ctrl+C` during a question stops the whole turn: it prints a plain `KeyboardInterrupt` and returns to the prompt, whether the interrupt lands in the model stream or in a running tool cell. 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.` Full output remains in IPython history, while model-facing text is bounded and displayed images stay available as image blocks; `%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.
55
55
 
56
56
  ## JupyterLab
57
57
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.6.19"
3
+ version = "0.6.20"
4
4
  description = "A frontend-neutral model loop for persistent IPython sessions."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.6.19"
3
+ version = "0.6.20"
4
4
  description = "A frontend-neutral model loop for persistent IPython sessions."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -17,12 +17,16 @@ from IPython.utils.capture import capture_output
17
17
  from .display import display_cell
18
18
  from .images import PreparedImage, prepare_mime_image
19
19
  from .model_text import bounded_text
20
+ from .interrupts import AgentTurnInterrupted
20
21
  from .jupyter import JupyterLabBridge
21
22
  from .terminal import preview_tool_output, record_tool_output, show_input
22
23
 
23
24
 
24
25
  _MAXIMUM_IMAGES_PER_REPORT = 32
25
26
 
27
+ # Re-exported so the model loop can recognise a user interrupt raised here.
28
+ __all__ = ["AgentTurnInterrupted", "ExecutionReport", "IPythonExecutor"]
29
+
26
30
 
27
31
  @dataclass(frozen=True, slots=True)
28
32
  class ExecutionReport:
@@ -223,6 +227,9 @@ class IPythonExecutor:
223
227
  show_input(self.shell, cell)
224
228
  with preview_tool_output() as preview, record_tool_output(self.shell):
225
229
  result = self.shell.run_cell(cell, store_history=True)
230
+ # A user interrupt is not a failing tool result: it stops the turn.
231
+ if isinstance(result.error_before_exec or result.error_in_exec, KeyboardInterrupt):
232
+ raise AgentTurnInterrupted
226
233
  report = self._native_report(result)
227
234
  preview.show_notice(report.error)
228
235
  return report
@@ -308,7 +315,7 @@ class IPythonExecutor:
308
315
  if asyncio.current_task() and asyncio.current_task().cancelling():
309
316
  raise asyncio.CancelledError
310
317
  if isinstance(result.error_before_exec or result.error_in_exec, KeyboardInterrupt):
311
- raise KeyboardInterrupt
318
+ raise AgentTurnInterrupted
312
319
  if bridged:
313
320
  assert bridge is not None and cell_id is not None
314
321
  bridge.finish_code_cell(
@@ -19,7 +19,13 @@ from .display import display_assistant
19
19
  from .conversation import MemoryConversationStore, NotebookConversationStore
20
20
  from .jupyter import JupyterLabBridge, TARGET_NAME
21
21
  from .session import Session
22
- from .terminal import TerminalCellHistory, install_markdown_renderer, install_question_mode
22
+ from .terminal import (
23
+ TerminalCellHistory,
24
+ install_markdown_renderer,
25
+ install_question_mode,
26
+ remember_question,
27
+ set_question_running,
28
+ )
23
29
 
24
30
 
25
31
  _STATE_ATTRIBUTE = "_codebind_extension_state"
@@ -256,11 +262,18 @@ def load_ipython_extension(ipython: InteractiveShell) -> None:
256
262
  state["session"] = session
257
263
  selected = get_model()
258
264
  terminal_history = state.get("terminal_history")
265
+ is_question_cell = cell is not None
266
+ if is_question_cell:
267
+ remember_question(ipython, cell)
259
268
  history = (
260
269
  terminal_history.snapshot()
261
270
  if isinstance(terminal_history, TerminalCellHistory)
262
271
  else None
263
272
  )
273
+ if isinstance(ipython, TerminalInteractiveShell):
274
+ # Keep the running mark until the next prompt: IPython prints an
275
+ # interrupted question's traceback after this magic returns.
276
+ set_question_running(ipython, True)
264
277
  try:
265
278
  session.send(cell if cell is not None else line, selected, notebook=history)
266
279
  except BaseException:
@@ -0,0 +1,9 @@
1
+ """Signals shared by the frontends that run the model loop."""
2
+
3
+
4
+ class AgentTurnInterrupted(BaseException):
5
+ """The user pressed Ctrl+C during a question, so the whole turn must stop.
6
+
7
+ This derives from :class:`BaseException` on purpose: an interrupted question
8
+ is not a failing tool result, and the model loop must not turn it into one.
9
+ """
@@ -7,6 +7,8 @@ from collections.abc import Awaitable, Callable
7
7
  from typing import Any
8
8
  from uuid import uuid4
9
9
 
10
+ from .interrupts import AgentTurnInterrupted
11
+
10
12
  TARGET_NAME = "codebind"
11
13
  _Notebook = list[dict[str, Any]]
12
14
  _QuestionHandler = Callable[[str, _Notebook], Awaitable[None]]
@@ -270,7 +272,7 @@ class JupyterLabBridge:
270
272
  if self._question_handler is None:
271
273
  raise RuntimeError("Codebind is not ready to receive questions.")
272
274
  await self._question_handler(question, notebook)
273
- except (asyncio.CancelledError, KeyboardInterrupt):
275
+ except (asyncio.CancelledError, KeyboardInterrupt, AgentTurnInterrupted):
274
276
  cancelled = True
275
277
  except Exception as exception:
276
278
  error = {"type": type(exception).__name__, "message": str(exception)}
@@ -31,6 +31,7 @@ from .async_runtime import run_sync
31
31
  from .display import display_assistant
32
32
  from .conversation import Conversation, ConversationStore, MemoryConversationStore
33
33
  from .execution import ExecutionReport, IPythonExecutor
34
+ from .interrupts import AgentTurnInterrupted
34
35
  from .jupyter import JupyterLabBridge
35
36
 
36
37
 
@@ -498,7 +499,7 @@ class Session:
498
499
  self._tool_message(report, identifier),
499
500
  turn_id,
500
501
  )
501
- except KeyboardInterrupt:
502
+ except (KeyboardInterrupt, AgentTurnInterrupted):
502
503
  self._cancel_assistant_stream(assistant_cell_id)
503
504
  self._seal_interrupted_calls_sync(turn_id)
504
505
  self._append_event_sync("turn_finished", turn_id=turn_id, status="cancelled")
@@ -546,7 +547,7 @@ class Session:
546
547
  self._tool_message(report, identifier),
547
548
  turn_id,
548
549
  )
549
- except (asyncio.CancelledError, KeyboardInterrupt):
550
+ except (asyncio.CancelledError, KeyboardInterrupt, AgentTurnInterrupted):
550
551
  self._cancel_assistant_stream(assistant_cell_id)
551
552
  await self._seal_interrupted_calls(turn_id)
552
553
  await self._append_event("turn_finished", turn_id=turn_id, status="cancelled")
@@ -13,6 +13,7 @@ from unittest.mock import patch
13
13
 
14
14
  from IPython.terminal.interactiveshell import IPythonPTLexer, TerminalInteractiveShell
15
15
  from prompt_toolkit.enums import DEFAULT_BUFFER
16
+ from prompt_toolkit.key_binding.key_processor import KeyPress
16
17
  from prompt_toolkit.filters import Condition, has_focus
17
18
  from prompt_toolkit.document import Document
18
19
  from prompt_toolkit.formatted_text import ANSI, FormattedText, PygmentsTokens, to_formatted_text
@@ -21,11 +22,54 @@ from prompt_toolkit.styles import DynamicStyle, Style, merge_styles
21
22
  from rich.console import Console
22
23
  from rich.markdown import Markdown
23
24
 
25
+ from .interrupts import AgentTurnInterrupted
26
+
24
27
 
25
28
  _MISSING = object()
26
29
  _IMAGE_FORMATS = ("image/png", "image/jpeg", "image/svg+xml")
27
30
  _MAXIMUM_VISIBLE_OUTPUT_LINES = 3
28
31
  _MAXIMUM_VISIBLE_OUTPUT_CHARACTERS_PER_LINE = 80
32
+ _MODE_ATTRIBUTE = "_codebind_terminal_mode"
33
+ # The real stdout, while tool output is being previewed.
34
+ _REAL_STDOUT: dict[str, object] = {}
35
+ _SHIFT_ENTER_SEQUENCE = "\x1b[27;2;13~"
36
+
37
+
38
+ def terminal_mode(shell: TerminalInteractiveShell) -> dict[str, bool]:
39
+ """Share one prompt's mode flags with the rest of the terminal session."""
40
+ mode = getattr(shell, _MODE_ATTRIBUTE, None)
41
+ if not isinstance(mode, dict):
42
+ mode = {
43
+ "question": False,
44
+ "running": False,
45
+ "submitted": False,
46
+ "submitted_count": None,
47
+ "active_prompt": False,
48
+ "interrupt_reported": False,
49
+ "question_text": None,
50
+ }
51
+ setattr(shell, _MODE_ATTRIBUTE, mode)
52
+ return mode
53
+
54
+
55
+ def set_question_running(shell: TerminalInteractiveShell, running: bool) -> None:
56
+ """Mark that a question is executing its tool cells."""
57
+ terminal_mode(shell)["running"] = running
58
+
59
+
60
+ def remember_question(shell: TerminalInteractiveShell, question: str) -> None:
61
+ """Record the exact question text for the cell IPython is about to run."""
62
+ terminal_mode(shell)["question_text"] = question
63
+
64
+
65
+ def take_question(shell: TerminalInteractiveShell) -> str | None:
66
+ """Consume the recorded question text, if one is waiting."""
67
+ mode = getattr(shell, _MODE_ATTRIBUTE, None)
68
+ if not isinstance(mode, dict):
69
+ return None
70
+ question = mode.get("question_text")
71
+ mode["question_text"] = None
72
+ return question if isinstance(question, str) else None
29
73
 
30
74
 
31
75
  def _plain_ansi(value: str) -> str:
@@ -87,7 +131,8 @@ def _history_cell(
87
131
  identifier = f"terminal-{number}"
88
132
  magic = _magic_input(transformed)
89
133
  if magic is not None and magic[0] == "question":
90
- return {"id": identifier, "type": "markdown", "source": magic[1]}
134
+ recorded = take_question(shell)
135
+ return {"id": identifier, "type": "markdown", "source": recorded or magic[1]}
91
136
  if magic is not None and magic[0] in {"output", "context"}:
92
137
  return {"id": identifier, "type": "raw", "source": source}
93
138
 
@@ -301,20 +346,40 @@ def preview_tool_output(
301
346
  preview_stderr = _PreviewStream(stderr, preview)
302
347
  sys.stdout = preview_stdout
303
348
  sys.stderr = preview_stderr
349
+ _REAL_STDOUT["stream"] = stdout
304
350
  try:
305
351
  yield preview
306
352
  finally:
353
+ _REAL_STDOUT.pop("stream", None)
307
354
  preview_stdout.flush()
308
355
  preview_stderr.flush()
309
356
  sys.stdout, sys.stderr = stdout, stderr
310
357
 
311
358
 
359
+ def _interrupt_report(shell: TerminalInteractiveShell, value: BaseException) -> str:
360
+ """Report a user interrupt the way IPython reports an interrupted cell.
361
+
362
+ Only the exception line is printed. The frames below it belong to the model
363
+ client, the provider, or the standard library, so naming a cell number would
364
+ be misleading: the interrupted cell never completed.
365
+ """
366
+ if isinstance(value, AgentTurnInterrupted):
367
+ value = KeyboardInterrupt()
368
+ return shell.get_exception_only((KeyboardInterrupt, value, None))
369
+
370
+
312
371
  def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]:
313
- """Let Shift+Tab switch the terminal prompt between Python and questions."""
372
+ """Switch the terminal prompt between Python and Markdown questions.
373
+
374
+ Shift+Tab toggles Question mode. While it is on, Enter submits a one-line
375
+ question, Shift+Enter starts a new Markdown line, and Ctrl+C returns to
376
+ Python mode instead of interrupting the shell.
377
+ """
314
378
  prompt_session = shell.pt_app
315
379
  if prompt_session is None:
316
380
  return lambda: None
317
381
 
382
+ mode = terminal_mode(shell)
318
383
  original_shell_mouse = shell.mouse_support
319
384
  original_prompt_mouse = prompt_session.mouse_support
320
385
  # Terminal mouse reporting intercepts scrollback, even if the prompt is idle.
@@ -325,14 +390,16 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
325
390
  had_prompt_override = "prompt_for_code" in vars(shell)
326
391
  bindings = prompt_session.key_bindings
327
392
  transforms = shell.input_transformer_manager.cleanup_transforms
328
- question_mode = False
329
- active_prompt = False
330
- pending_question = False
331
393
  prompt_worker: ThreadPoolExecutor | None = None
332
394
 
333
395
  def prompt_for_code() -> str:
334
- nonlocal active_prompt, pending_question, prompt_worker
335
- active_prompt = True
396
+ nonlocal prompt_worker
397
+ mode["active_prompt"] = True
398
+ mode["question_asked"] = False
399
+ mode["answered_only"] = False
400
+ mode["running"] = False
401
+ mode["submitted"] = False
402
+ mode["interrupt_reported"] = False
336
403
  try:
337
404
  try:
338
405
  asyncio.get_running_loop()
@@ -359,28 +426,129 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
359
426
  pass
360
427
  raise
361
428
  finally:
362
- active_prompt = False
363
- pending_question = question_mode and bool(text.strip())
429
+ mode["active_prompt"] = False
430
+ mode["running"] = False
431
+ if mode["question"] and mode["question_asked"]:
432
+ mode["answered_only"] = not text.strip()
433
+ else:
434
+ mode["answered_only"] = False
364
435
  return text
365
436
 
366
437
  def transform_question(lines: list[str]) -> list[str]:
367
- nonlocal pending_question
368
- if not pending_question:
438
+ """Wrap the cell the user just answered, and only that cell."""
439
+ if not mode["question_asked"] or mode["answered_only"]:
369
440
  return lines
370
- pending_question = False
441
+ mode["question_asked"] = False
371
442
  return ["%%question\n", *lines]
372
443
 
373
444
  def toggle_question(event) -> None:
374
- nonlocal question_mode
375
- question_mode = not question_mode
445
+ mode["question"] = not mode["question"]
376
446
  event.app.invalidate()
377
447
 
378
- def submit_question(event) -> None:
448
+ def enter_question(event) -> None:
449
+ """Enter submits; the prompt session stays open for the next question."""
450
+ mode["question_asked"] = True
451
+ # Set before the prompt closes: the accepted line is drawn once more
452
+ # while this flag is still set, which is where In [N] becomes In [*].
453
+ # The cell number is latched, because execution_count does not change
454
+ # until the cell runs, so the next prompt would otherwise look marked too.
455
+ mode["submitted"] = True
456
+ mode["submitted_count"] = str(shell.execution_count)
379
457
  event.current_buffer.validate_and_handle()
380
458
 
381
- question_active = Condition(lambda: active_prompt and question_mode)
459
+ def newline_question(event) -> None:
460
+ """Shift+Enter or Ctrl+J adds a Markdown line, as in a notebook cell."""
461
+ event.current_buffer.insert_text("\n")
462
+ event.app.invalidate()
463
+
464
+ def read_keys(_source=None):
465
+ """Read Shift+Enter as a newline in terminals that report it.
466
+
467
+ prompt_toolkit maps that sequence to ControlM, exactly like a plain
468
+ Enter, so the raw ``KeyPress.data`` is the only way to tell them apart.
469
+ Apple Terminal does not report Shift+Enter at all, so Ctrl+J is the
470
+ newline key there; terminals that do report it get the expected behaviour.
471
+ """
472
+ keys = original_read_keys()
473
+ if not mode["question"]:
474
+ return keys
475
+ rewritten = []
476
+ for key in keys:
477
+ data = getattr(key, "data", None)
478
+ if isinstance(data, str) and data == _SHIFT_ENTER_SEQUENCE:
479
+ rewritten.append(KeyPress("\n"))
480
+ else:
481
+ rewritten.append(key)
482
+ return rewritten
483
+
484
+ def interrupt_question(event) -> None:
485
+ """Ctrl+C discards the question and returns to Python mode."""
486
+ buffer = event.current_buffer
487
+ if buffer.complete_state:
488
+ buffer.cancel_completion()
489
+ else:
490
+ buffer.reset()
491
+ mode["question"] = False
492
+ mode["submitted"] = False
493
+ event.app.invalidate()
494
+
495
+ question_active = Condition(lambda: mode["question"])
496
+ original_prompt_options = shell._extra_prompt_options
497
+
498
+ def prompt_options():
499
+ """Let a question's own line show the running marker, In [*].
500
+
501
+ The message stays callable so the marker follows the current mode: it is
502
+ read when the prompt is drawn, and toggle_question redraws it.
503
+ """
504
+ options = original_prompt_options()
505
+ prompt_tokens = shell.prompts.in_prompt_tokens
506
+
507
+ def message():
508
+ tokens = prompt_tokens()
509
+ counter = str(shell.execution_count)
510
+ if counter != mode["submitted_count"]:
511
+ return PygmentsTokens(tokens)
512
+ marked = [(token, "*" if text == counter else text) for token, text in tokens]
513
+ return PygmentsTokens(marked)
514
+
515
+ options["message"] = message
516
+ return options
517
+
518
+ shell._extra_prompt_options = prompt_options
519
+ original_read_keys = prompt_session.input.read_keys
520
+ original_showtraceback = shell.showtraceback
521
+
522
+ def showtraceback(
523
+ exc_tuple=None,
524
+ filename=None,
525
+ tb_offset=None,
526
+ exception_only=False,
527
+ running_compiled_code=False,
528
+ ) -> None:
529
+ if mode["running"] and not exception_only:
530
+ _etype, value, _traceback = sys.exc_info() if exc_tuple is None else exc_tuple
531
+ if isinstance(value, (KeyboardInterrupt, AgentTurnInterrupted)):
532
+ # One interrupt can surface twice: once as the interrupted cell's
533
+ # KeyboardInterrupt and once as the signal that stopped the turn.
534
+ if not mode["interrupt_reported"]:
535
+ mode["interrupt_reported"] = True
536
+ stream = _REAL_STDOUT.get("stream") or sys.stdout
537
+ stream.write("\n" + _interrupt_report(shell, value))
538
+ return
539
+ original_showtraceback(
540
+ exc_tuple,
541
+ filename,
542
+ tb_offset,
543
+ exception_only,
544
+ running_compiled_code,
545
+ )
546
+
547
+ shell.showtraceback = showtraceback
382
548
  bindings.add("s-tab", filter=has_focus(DEFAULT_BUFFER))(toggle_question)
383
- bindings.add("enter", filter=has_focus(DEFAULT_BUFFER) & question_active)(submit_question)
549
+ bindings.add("enter", filter=has_focus(DEFAULT_BUFFER) & question_active)(enter_question)
550
+ bindings.add("c-j", filter=has_focus(DEFAULT_BUFFER) & question_active)(newline_question)
551
+ bindings.add("c-c", filter=has_focus(DEFAULT_BUFFER) & question_active)(interrupt_question)
384
552
  shell.prompt_for_code = prompt_for_code
385
553
  question_style = merge_styles(
386
554
  [
@@ -402,14 +570,20 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
402
570
  ]
403
571
  )
404
572
  prompt_session.style = DynamicStyle(
405
- lambda: question_style if active_prompt and question_mode else original_style
573
+ lambda: question_style if mode["question"] and mode["active_prompt"] else original_style
406
574
  )
407
575
  transforms.insert(0, transform_question)
576
+ prompt_session.input.read_keys = read_keys
408
577
 
409
578
  def restore() -> None:
410
579
  bindings.remove(toggle_question)
411
- bindings.remove(submit_question)
580
+ bindings.remove(enter_question)
581
+ bindings.remove(newline_question)
582
+ bindings.remove(interrupt_question)
412
583
  transforms.remove(transform_question)
584
+ shell._extra_prompt_options = original_prompt_options
585
+ shell.showtraceback = original_showtraceback
586
+ prompt_session.input.read_keys = original_read_keys
413
587
  if had_prompt_override:
414
588
  shell.prompt_for_code = original_prompt_for_code
415
589
  else:
@@ -502,10 +676,15 @@ def record_tool_output(shell: TerminalInteractiveShell) -> Iterator[None]:
502
676
 
503
677
 
504
678
  __all__ = [
679
+ "AgentTurnInterrupted",
505
680
  "TerminalCellHistory",
506
681
  "install_markdown_renderer",
507
682
  "install_question_mode",
508
683
  "preview_tool_output",
509
684
  "record_tool_output",
685
+ "remember_question",
686
+ "set_question_running",
510
687
  "show_input",
688
+ "take_question",
689
+ "terminal_mode",
511
690
  ]
File without changes
File without changes