IPython extension for backtick-triggered AI prompts.
This project is small. Nearly all runtime behavior lives in ipyclaude/core.py, so getting productive mainly means understanding that file and the tests in tests/test_core.py.
Install in editable mode:
pip install -e .[dev]
Run tests:
pytest
This repo is configured for fastship releases:
ship-changelog
ship-release
Implemented:
ast and sent as <note> blocks in contextstartup.ipynb (nbformat v4.5 with cell IDs)_tool_refs()_parse_frontmatter() shared helper for extracting YAML frontmatter from skills, notes, and tool resultsallowed-tools frontmatter key in skills and notes for declaring tool dependenciesallowed-tools or eval: true) contribute tools.agents/skills/ (CWD + parents) and ~/.config/agents/skills/load_skill tool added to user_ns at init time, resolved via normal tool mechanism (not special-cased)🔧 f(x=1) => 2 formcompletion_model with session context, shows as prompt_toolkit suggestion; partial accept via M-f preserves remaining suggestion)mistletoe markdown parser (not regex) for correctness. prompts and %%ipyclaude cells (patches IPythonPTLexer at class level)#| eval: true python code blocks in skills are executed via shell.run_cell when loadedsessions.remark, session resume via resume_session()prompt_toolkit.radiolist_dialog for ipyclaude -r%ipyclaude sessions command listing resumable sessions with last prompt previewipyclaude CLI entry point (console script) launching IPython with ipythonng + ipyclaude + output historySyntaxTB and inspect.getfile (guarded with once=True to coexist with ipykernel_helper)ipyclaude console script entry point — parses flags via ipythonng.cli.parse_flags, launches IPython with extensions and output historyipyclaude), and fastship configurationEach AI prompt is saved in an ai_prompts table inside IPython’s history SQLite database. Rows are keyed by the current IPython session_number and include:
promptresponsehistory_lineStored rows contain only the user prompt, full AI response, and the line where the code context for that prompt stops.
Example:
In [1]: import math
In [2]: .first prompt
In [3]: x = 1
In [4]: .second prompt
The stored rows are roughly:
history_line=1history_line=3So for the second prompt, ipyclaude knows:
x = 1, but not import mathFor each new prompt, ipyclaude reconstructs chat history as alternating user / assistant entries:
<context>...</context><user-request>...</user-request>The <context> block contains all non-ipyclaude code run since the previous AI prompt in the current session, plus Out[...] history when IPython has it. String-literal-only cells are sent as <note> instead of <code> (detected via ast). The XML is intentionally simple:
<context><code>a = 1</code><note>This is a note</note><code>a</code><output>1</output></context>
The extension lifecycle is:
%load_ext ipyclaude calls load_ipython_extension, which parses IPYTHONNG_FLAGS and delegates to create_extension.create_extension ensures the ai_prompts table exists, optionally resumes a session (or shows the interactive picker), creates the extension, stores CWD in sessions.remark, and registers the atexit handler.IPyAIExtension.__init__ loads config, system prompt, discovers skills, and loads the startup file.IPyAIExtension.load() registers %ipyclaude / %%ipyclaude, inserts a cleanup transform into IPython’s input_transformer_manager.cleanup_transforms, registers keybindings, and applies startup.ipynb if the session is still fresh.. is rewritten by transform_dots() into get_ipython().run_cell_magic('ipyclaude', '', prompt).AIMagics.ipyclaude() routes line input to handle_line() and cell input directly to the _run_prompt() coroutine (returned to the async run_cell_magic patch for awaiting)._run_prompt() reconstructs conversation history, resolves tools, adds skills tools/system prompt if skills were discovered, runs lisette.AsyncChat, streams the response, optionally writes an exact log entry, and stores the full response.At import time, ipyclaude also applies two small global IPython bugfixes (shared with ipykernel_helper, guarded with once=True so only the first loader applies them):
SyntaxTB.structured_traceback coerces non-string evalue.msg values to strinspect.getfile is wrapped to always return a stringThe period rewrite happens in cleanup_transforms, not in a later input transformer. That matters because IPython’s own parsing for help syntax and similar features can interfere with raw prompts if the rewrite happens too late.
This is the mechanism that makes these cases work correctly:
?The stored prompt text is not the exact user message sent to the model. The actual user entry is built dynamically with:
{context}<user-request>{prompt}</user-request>
context is empty when there has been no intervening code. Otherwise it is:
<context><code>...</code><note>...</note><output>...</output>...</context>
Important detail: only the raw prompt and raw response are stored in SQLite. Context is regenerated on each run from normal IPython history. That keeps the table small and avoids baking transient context into stored rows.
ipyclaude uses IPython’s existing history database connection at shell.history_manager.db.
Table schema:
CREATE TABLE IF NOT EXISTS ai_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session INTEGER NOT NULL,
prompt TEXT NOT NULL,
response TEXT NOT NULL,
history_line INTEGER NOT NULL DEFAULT 0
)
Notes:
session_numberhistory_line is used to decide which code cells belong in the next prompt’s generated <context> blockai_prompts does not match the expected schema, ipyclaude drops and recreates it instead of migrating it%ipyclaude reset deletes only current-session rows and sets a reset baseline in user_nsstartup.ipynb is stored as a Jupyter notebook (nbformat v4.5 with cell IDs) next to the other XDG files.
%ipyclaude save writes a merged event stream for the current session as notebook cells:
metadata.ipyclaude.kind="code")metadata.ipyclaude.source for round-trip replay)metadata.ipyclaude.prompt)On a fresh load:
run_cell(..., store_history=True)ai_prompts from metadataexecution_count is advanced for restored prompt events so later saves preserve orderingLegacy startup.json files (pre-notebook format) are still supported for loading.
ipyclaude stores the working directory in IPython’s sessions.remark column (an unused TEXT field) at extension load time. This enables per-directory session listing and resume.
Key functions:
_list_sessions(db, cwd) — queries sessions for the given directory, falls back to git repo root exact match; includes the last AI prompt per session via a subquery on ai_prompts_fmt_session() — formats a session row for display (shared by %ipyclaude sessions and the interactive picker)_pick_session(rows) — interactive radiolist_dialog picker from prompt_toolkitresume_session(shell, session_id) — deletes the fresh session row, restores session_number and execution_count, pads input_hist_parsed/input_hist_raw, reopens the old session (clears end timestamp)Resume is triggered by IPYTHONNG_FLAGS env var (set by the ipyclaude CLI when -r is passed). The _ng_parser (argparse) parses -r <id> or bare -r (const=-1 for interactive picker).
On exit, an atexit handler prints the session ID for easy resume.
Skills follow the Agent Skills specification. Discovery happens once at extension init time via _discover_skills():
.agents/skills/ in each~/.config/agents/skills/Each skill directory must contain a SKILL.md with YAML frontmatter (name, description). Frontmatter is parsed with PyYAML.
At runtime, if skills were discovered:
<skills> section listing all skill names, paths, and descriptionsload_skill tool is added to the tools list (reads SKILL.md and returns as FullResponse)user_ns (does not pollute the user’s namespace)The skills list is frozen at load time to prevent the LLM from creating and loading skills during a session.
code_context(start, stop) pulls normal IPython history with:
history_manager.get_range(session=0, start=start, stop=stop, raw=True, output=True)
Rules:
ipyclaude commands (starting with . or %ipyclaude) are skipped_is_note via ast.parse) become <note> tags containing the string value<code>...</code><output>...</output>Tool references are written in prompts as &name``.
Tools are discovered from multiple sources via _tool_refs():
&name`` in the current prompt and prior prompts in dialog historyallowed-tools frontmatter and &name`` mentions in skills&name`` mentions and allowed-tools frontmatter in notes (string-literal cells)allowed-tools or eval: trueShared helpers:
_parse_frontmatter(text) extracts YAML frontmatter from any text (reused by skills, notes, and tool results)_allowed_tools(text) combines frontmatter allowed-tools and &name`` mentions into a set of tool names_tool_results(response) scans stored response <details> blocks for qualifying tool resultsresolve_tools():
NameError/TypeError for missing or non-callable)_tool_refs()user_nsget_schema_nm(...) so the exposed tool name matches the namespace symbol instead of __call__ for callable objectslisette.AsyncChat(..., tools=...)The load_skill tool is added to user_ns at extension init time when skills are discovered. It is resolved through the normal tool mechanism (skills always contribute load_skill to the tool name set) rather than being special-cased in _run_prompt.
The tool lookup is intentionally live against the active namespace, so changing a function in the IPython session changes the tool used by subsequent prompts. Async callables are handled by lisette.AsyncChat, so tool results are awaited correctly.
Streaming and storage are deliberately separated.
astream_to_stdout():
lisette.AsyncStreamFormatter to iterate the response streamrich.live.Live view with Markdown(...) as chunks arriveDisplay processing (_display_text):
_strip_thinking removes 🧠emoji lines once actual content follows (shows them as a progress indicator during thinking, strips them from the final display)compact_tool_display rewrites lisette tool detail blocks to a short 🔧 f(x=1) => 2 formipyclaude wraps the streaming phase in a small guard that temporarily marks shell.display_pub._is_publishing = True. That keeps terminal-visible AI output out of IPython’s normal stdout capture and therefore out of output_history, while still allowing ipyclaude to store the full response in ai_prompts.
Registered via prompt_toolkit on shell.pt_app.key_bindings during load():
escape, . (Alt-.): AI inline completion — calls _ai_complete() as a background task, which builds a prompt from session context plus the current prefix/suffix and calls the configured completion_model. The result is set as buffer.suggestion (prompt_toolkit’s auto-suggest display), accepted with right-arrow or word-at-a-time with M-f. IPython’s existing auto-suggest get_suggestion is patched to remember the AI target text so partial accepts regenerate the remainder. Cancels safely if the buffer text changes before the response arrives.escape, up / escape, down (Alt-Up/Down): jump through complete history entries, bypassing line-by-line navigation in multiline inputs (calls buffer.history_backward() / history_forward())escape, W (Alt-Shift-W): insert all Python code blocks from _ai_last_responseescape, ! through escape, ( (Alt-Shift-1 through Alt-Shift-9): insert the Nth code blockescape, s-up / escape, s-down (Alt-Shift-Up/Down): cycle through code blocks one at a time, replacing the buffer contents; prompt_toolkit swaps A/B for modifier-4 (Alt+Shift) arrows, so the bindings are intentionally invertedCode blocks are extracted using mistletoe.Document and CodeFence — only blocks tagged python or py are included.
XDG-backed module globals are defined at import time:
CONFIG_PATH: model, think, search, Rich code theme, and the exact-log flagSYSP_PATH: system prompt passed as sp= to lisette.AsyncChatSTARTUP_PATH: saved startup snapshot (.ipynb format)LOG_PATH: optional raw prompt/response log outputCreation behavior:
model defaults from IPYAI_MODEL if present%ipyclaude model ... and similar commands change only the live extension object, not the config fileWhen log_exact is enabled, the log file contains the exact fully-expanded prompt passed to the model and the exact raw response returned from the stream.
To run ipyclaude in isolation (no user config, startup, or history), set these environment variables:
XDG_CONFIG_HOME — redirects ipyclaude’s config files (config.json, sysp.txt, startup.ipynb)IPYTHON_DIR — redirects IPython’s profile directory (prevents loading user ipython_config.py and startup scripts)--HistoryManager.hist_file=<path> — isolates the history databaseThe e2e test uses all three to create a fully isolated session via pexpect.
The test suite uses dummy shell, history, chat, formatter, console, and markdown objects.
Coverage currently focuses on:
<note> tags)_parse_frontmatter) and allowed-tools extractionload_skill, and eval blocks_run_promptWhen changing behavior in ipyclaude/core.py, update or add the narrowest possible test in tests/test_core.py.
If you want to change prompt parsing or magic routing:
is_dot_prompt(), prompt_from_lines(), or transform_dots()If you want to change the XML or history sent to the model:
_prompt_template, code_context(), format_prompt(), or dialog_history()If you want to change notes behavior:
_is_note(), _note_str(), and the note handling in code_context()If you want to change tool behavior:
_tool_names(), _tool_refs(), _parse_frontmatter(), _allowed_tools(), _tool_results(), or resolve_tools()If you want to change skills:
_parse_skill(), _discover_skills(), _skills_xml(), load_skill(), and the skills/tool collection in _run_prompt()If you want to change terminal rendering:
_display_text(), _strip_thinking(), compact_tool_display(), _astream_to_live_markdown(), _markdown_renderable(), or astream_to_stdout()If you want to change persistence:
ensure_prompt_table(), prompt_records(), save_prompt(), save_startup(), apply_startup(), and reset_session_history()If you want to change the startup notebook format:
_event_to_cell(), _cell_to_event(), _default_startup(), load_startup(), and save_startup()If you want to change keybindings:
_register_keybindings() and _extract_code_blocks()If you want to change AI inline completion:
_ai_complete(), _COMPLETION_SP, and the escape, . binding in _register_keybindings()If you want to change syntax highlighting:
_patch_lexer()If you want to change session persistence or resume:
_list_sessions(), _fmt_session(), _pick_session(), resume_session(), the sessions case in handle_line(), and the session handling in create_extension()