codebind 0.6.19__tar.gz → 0.6.21__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.21}/PKG-INFO +4 -3
  2. {codebind-0.6.19 → codebind-0.6.21}/README.md +3 -2
  3. {codebind-0.6.19 → codebind-0.6.21}/pyproject.toml +1 -1
  4. {codebind-0.6.19 → codebind-0.6.21}/pyproject.toml.orig +1 -1
  5. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/execution.py +8 -1
  6. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/extension.py +14 -1
  7. codebind-0.6.21/src/codebind/interrupts.py +9 -0
  8. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/jupyter.py +3 -1
  9. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/session.py +3 -2
  10. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/terminal.py +164 -19
  11. {codebind-0.6.19 → codebind-0.6.21}/LICENSE +0 -0
  12. {codebind-0.6.19 → codebind-0.6.21}/data/share/jupyter/labextensions/codebind-jupyterlab/install.json +0 -0
  13. {codebind-0.6.19 → codebind-0.6.21}/data/share/jupyter/labextensions/codebind-jupyterlab/package.json +0 -0
  14. {codebind-0.6.19 → codebind-0.6.21}/data/share/jupyter/labextensions/codebind-jupyterlab/static/590.0b7494e278aa27b9.js +0 -0
  15. {codebind-0.6.19 → codebind-0.6.21}/data/share/jupyter/labextensions/codebind-jupyterlab/static/remoteEntry.8f763ad3faac0f5a.js +0 -0
  16. {codebind-0.6.19 → codebind-0.6.21}/data/share/jupyter/labextensions/codebind-jupyterlab/static/style.js +0 -0
  17. {codebind-0.6.19 → codebind-0.6.21}/data/share/jupyter/labextensions/codebind-jupyterlab/static/third-party-licenses.json +0 -0
  18. {codebind-0.6.19 → codebind-0.6.21}/jupyterlab/.yarnrc.yml +0 -0
  19. {codebind-0.6.19 → codebind-0.6.21}/jupyterlab/install.json +0 -0
  20. {codebind-0.6.19 → codebind-0.6.21}/jupyterlab/package.json +0 -0
  21. {codebind-0.6.19 → codebind-0.6.21}/jupyterlab/src/index.ts +0 -0
  22. {codebind-0.6.19 → codebind-0.6.21}/jupyterlab/tsconfig.json +0 -0
  23. {codebind-0.6.19 → codebind-0.6.21}/jupyterlab/yarn.lock +0 -0
  24. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/__init__.py +0 -0
  25. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/async_runtime.py +0 -0
  26. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/cli.py +0 -0
  27. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/configuration.py +0 -0
  28. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/context.md +0 -0
  29. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/context.py +0 -0
  30. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/conversation.py +0 -0
  31. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/display.py +0 -0
  32. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/images.py +0 -0
  33. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/instructions.md +0 -0
  34. {codebind-0.6.19 → codebind-0.6.21}/src/codebind/model_text.py +0 -0
  35. {codebind-0.6.19 → codebind-0.6.21}/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.21
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,8 @@ 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
+
84
+ `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
85
 
85
86
  ## JupyterLab
86
87
 
@@ -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,8 @@ 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
+
55
+ `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
56
 
56
57
  ## JupyterLab
57
58
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codebind"
3
- version = "0.6.19"
3
+ version = "0.6.21"
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.21"
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,52 @@ 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
+ "active_prompt": False,
46
+ "interrupt_reported": False,
47
+ "question_text": None,
48
+ }
49
+ setattr(shell, _MODE_ATTRIBUTE, mode)
50
+ return mode
51
+
52
+
53
+ def set_question_running(shell: TerminalInteractiveShell, running: bool) -> None:
54
+ """Mark that a question is executing its tool cells."""
55
+ terminal_mode(shell)["running"] = running
56
+
57
+
58
+ def remember_question(shell: TerminalInteractiveShell, question: str) -> None:
59
+ """Record the exact question text for the cell IPython is about to run."""
60
+ terminal_mode(shell)["question_text"] = question
61
+
62
+
63
+ def take_question(shell: TerminalInteractiveShell) -> str | None:
64
+ """Consume the recorded question text, if one is waiting."""
65
+ mode = getattr(shell, _MODE_ATTRIBUTE, None)
66
+ if not isinstance(mode, dict):
67
+ return None
68
+ question = mode.get("question_text")
69
+ mode["question_text"] = None
70
+ return question if isinstance(question, str) else None
29
71
 
30
72
 
31
73
  def _plain_ansi(value: str) -> str:
@@ -87,7 +129,8 @@ def _history_cell(
87
129
  identifier = f"terminal-{number}"
88
130
  magic = _magic_input(transformed)
89
131
  if magic is not None and magic[0] == "question":
90
- return {"id": identifier, "type": "markdown", "source": magic[1]}
132
+ recorded = take_question(shell)
133
+ return {"id": identifier, "type": "markdown", "source": recorded or magic[1]}
91
134
  if magic is not None and magic[0] in {"output", "context"}:
92
135
  return {"id": identifier, "type": "raw", "source": source}
93
136
 
@@ -301,20 +344,40 @@ def preview_tool_output(
301
344
  preview_stderr = _PreviewStream(stderr, preview)
302
345
  sys.stdout = preview_stdout
303
346
  sys.stderr = preview_stderr
347
+ _REAL_STDOUT["stream"] = stdout
304
348
  try:
305
349
  yield preview
306
350
  finally:
351
+ _REAL_STDOUT.pop("stream", None)
307
352
  preview_stdout.flush()
308
353
  preview_stderr.flush()
309
354
  sys.stdout, sys.stderr = stdout, stderr
310
355
 
311
356
 
357
+ def _interrupt_report(shell: TerminalInteractiveShell, value: BaseException) -> str:
358
+ """Report a user interrupt the way IPython reports an interrupted cell.
359
+
360
+ Only the exception line is printed. The frames below it belong to the model
361
+ client, the provider, or the standard library, so naming a cell number would
362
+ be misleading: the interrupted cell never completed.
363
+ """
364
+ if isinstance(value, AgentTurnInterrupted):
365
+ value = KeyboardInterrupt()
366
+ return shell.get_exception_only((KeyboardInterrupt, value, None))
367
+
368
+
312
369
  def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]:
313
- """Let Shift+Tab switch the terminal prompt between Python and questions."""
370
+ """Switch the terminal prompt between Python and Markdown questions.
371
+
372
+ Shift+Tab toggles Question mode. While it is on, Enter submits a one-line
373
+ question, Shift+Enter starts a new Markdown line, and Ctrl+C returns to
374
+ Python mode instead of interrupting the shell.
375
+ """
314
376
  prompt_session = shell.pt_app
315
377
  if prompt_session is None:
316
378
  return lambda: None
317
379
 
380
+ mode = terminal_mode(shell)
318
381
  original_shell_mouse = shell.mouse_support
319
382
  original_prompt_mouse = prompt_session.mouse_support
320
383
  # Terminal mouse reporting intercepts scrollback, even if the prompt is idle.
@@ -325,14 +388,15 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
325
388
  had_prompt_override = "prompt_for_code" in vars(shell)
326
389
  bindings = prompt_session.key_bindings
327
390
  transforms = shell.input_transformer_manager.cleanup_transforms
328
- question_mode = False
329
- active_prompt = False
330
- pending_question = False
331
391
  prompt_worker: ThreadPoolExecutor | None = None
332
392
 
333
393
  def prompt_for_code() -> str:
334
- nonlocal active_prompt, pending_question, prompt_worker
335
- active_prompt = True
394
+ nonlocal prompt_worker
395
+ mode["active_prompt"] = True
396
+ mode["question_asked"] = False
397
+ mode["answered_only"] = False
398
+ mode["running"] = False
399
+ mode["interrupt_reported"] = False
336
400
  try:
337
401
  try:
338
402
  asyncio.get_running_loop()
@@ -359,28 +423,99 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
359
423
  pass
360
424
  raise
361
425
  finally:
362
- active_prompt = False
363
- pending_question = question_mode and bool(text.strip())
426
+ mode["active_prompt"] = False
427
+ mode["running"] = False
428
+ if mode["question"] and mode["question_asked"]:
429
+ mode["answered_only"] = not text.strip()
430
+ else:
431
+ mode["answered_only"] = False
364
432
  return text
365
433
 
366
434
  def transform_question(lines: list[str]) -> list[str]:
367
- nonlocal pending_question
368
- if not pending_question:
435
+ """Wrap the cell the user just answered, and only that cell."""
436
+ if not mode["question_asked"] or mode["answered_only"]:
369
437
  return lines
370
- pending_question = False
438
+ mode["question_asked"] = False
371
439
  return ["%%question\n", *lines]
372
440
 
373
441
  def toggle_question(event) -> None:
374
- nonlocal question_mode
375
- question_mode = not question_mode
442
+ mode["question"] = not mode["question"]
376
443
  event.app.invalidate()
377
444
 
378
- def submit_question(event) -> None:
445
+ def enter_question(event) -> None:
446
+ """Enter submits; the prompt session stays open for the next question."""
447
+ mode["question_asked"] = True
379
448
  event.current_buffer.validate_and_handle()
380
449
 
381
- question_active = Condition(lambda: active_prompt and question_mode)
450
+ def newline_question(event) -> None:
451
+ """Shift+Enter or Ctrl+J adds a Markdown line, as in a notebook cell."""
452
+ event.current_buffer.insert_text("\n")
453
+ event.app.invalidate()
454
+
455
+ def read_keys(_source=None):
456
+ """Read Shift+Enter as a newline in terminals that report it.
457
+
458
+ prompt_toolkit maps that sequence to ControlM, exactly like a plain
459
+ Enter, so the raw ``KeyPress.data`` is the only way to tell them apart.
460
+ Apple Terminal does not report Shift+Enter at all, so Ctrl+J is the
461
+ newline key there; terminals that do report it get the expected behaviour.
462
+ """
463
+ keys = original_read_keys()
464
+ if not mode["question"]:
465
+ return keys
466
+ rewritten = []
467
+ for key in keys:
468
+ data = getattr(key, "data", None)
469
+ if isinstance(data, str) and data == _SHIFT_ENTER_SEQUENCE:
470
+ rewritten.append(KeyPress("\n"))
471
+ else:
472
+ rewritten.append(key)
473
+ return rewritten
474
+
475
+ def interrupt_question(event) -> None:
476
+ """Ctrl+C discards the question and returns to Python mode."""
477
+ buffer = event.current_buffer
478
+ if buffer.complete_state:
479
+ buffer.cancel_completion()
480
+ else:
481
+ buffer.reset()
482
+ mode["question"] = False
483
+ event.app.invalidate()
484
+
485
+ question_active = Condition(lambda: mode["question"])
486
+ original_read_keys = prompt_session.input.read_keys
487
+ original_showtraceback = shell.showtraceback
488
+
489
+ def showtraceback(
490
+ exc_tuple=None,
491
+ filename=None,
492
+ tb_offset=None,
493
+ exception_only=False,
494
+ running_compiled_code=False,
495
+ ) -> None:
496
+ if mode["running"] and not exception_only:
497
+ _etype, value, _traceback = sys.exc_info() if exc_tuple is None else exc_tuple
498
+ if isinstance(value, (KeyboardInterrupt, AgentTurnInterrupted)):
499
+ # One interrupt can surface twice: once as the interrupted cell's
500
+ # KeyboardInterrupt and once as the signal that stopped the turn.
501
+ if not mode["interrupt_reported"]:
502
+ mode["interrupt_reported"] = True
503
+ stream = _REAL_STDOUT.get("stream") or sys.stdout
504
+ stream.write("\n" + _interrupt_report(shell, value))
505
+ return
506
+ original_showtraceback(
507
+ exc_tuple,
508
+ filename,
509
+ tb_offset,
510
+ exception_only,
511
+ running_compiled_code,
512
+ )
513
+
514
+ shell.showtraceback = showtraceback
382
515
  bindings.add("s-tab", filter=has_focus(DEFAULT_BUFFER))(toggle_question)
383
- bindings.add("enter", filter=has_focus(DEFAULT_BUFFER) & question_active)(submit_question)
516
+ bindings.add("enter", filter=has_focus(DEFAULT_BUFFER) & question_active)(enter_question)
517
+ bindings.add("c-j", filter=has_focus(DEFAULT_BUFFER) & question_active)(newline_question)
518
+ bindings.add("c-c", filter=has_focus(DEFAULT_BUFFER) & question_active)(interrupt_question)
384
519
  shell.prompt_for_code = prompt_for_code
385
520
  question_style = merge_styles(
386
521
  [
@@ -402,14 +537,19 @@ def install_question_mode(shell: TerminalInteractiveShell) -> Callable[[], None]
402
537
  ]
403
538
  )
404
539
  prompt_session.style = DynamicStyle(
405
- lambda: question_style if active_prompt and question_mode else original_style
540
+ lambda: question_style if mode["question"] and mode["active_prompt"] else original_style
406
541
  )
407
542
  transforms.insert(0, transform_question)
543
+ prompt_session.input.read_keys = read_keys
408
544
 
409
545
  def restore() -> None:
410
546
  bindings.remove(toggle_question)
411
- bindings.remove(submit_question)
547
+ bindings.remove(enter_question)
548
+ bindings.remove(newline_question)
549
+ bindings.remove(interrupt_question)
412
550
  transforms.remove(transform_question)
551
+ shell.showtraceback = original_showtraceback
552
+ prompt_session.input.read_keys = original_read_keys
413
553
  if had_prompt_override:
414
554
  shell.prompt_for_code = original_prompt_for_code
415
555
  else:
@@ -502,10 +642,15 @@ def record_tool_output(shell: TerminalInteractiveShell) -> Iterator[None]:
502
642
 
503
643
 
504
644
  __all__ = [
645
+ "AgentTurnInterrupted",
505
646
  "TerminalCellHistory",
506
647
  "install_markdown_renderer",
507
648
  "install_question_mode",
508
649
  "preview_tool_output",
509
650
  "record_tool_output",
651
+ "remember_question",
652
+ "set_question_running",
510
653
  "show_input",
654
+ "take_question",
655
+ "terminal_mode",
511
656
  ]
File without changes
File without changes