# pair_range: a lone cell is its own (i,i) range; a Prompt immediately followed by its Assistant
# reply is a (prompt_i, assistant_i) pair, addressable from either side; a Prompt with no reply yet
# (or not immediately followed by one) is just a lone cell.
nb.reset()
raw = nb.add('raw', 'r')
p = nb.add('prompt', 'q'); a = nb.add('assistant', 'ans')
lone_prompt = nb.add('prompt', 'q2') # no assistant reply -- not a pair
assert nb.pair_range(raw.id) == (nb.index(raw.id), nb.index(raw.id))
assert nb.pair_range(p.id) == (nb.index(p.id), nb.index(a.id)), "pair addressed from the prompt side"
assert nb.pair_range(a.id) == (nb.index(p.id), nb.index(a.id)), "pair addressed from the assistant side"
i = nb.index(lone_prompt.id)
assert nb.pair_range(lone_prompt.id) == (i, i), "a prompt with no reply yet is not a pair"
assert nb.pair_range(99999) is None, "an id that doesn't exist returns None"
nb.reset()
# move: a Prompt+Assistant pair moves as one block; boundary moves (past the start/end) are no-ops.
nb.reset()
first = nb.add('code', '1')
p = nb.add('prompt', 'q'); a = nb.add('assistant', 'ans')
last = nb.add('code', '2')
nb.move(p.id, -1) # move the pair up, swapping with `first`
assert [c.ctype for c in nb.cells] == ['prompt', 'assistant', 'code', 'code']
assert nb.cells[0].id == p.id and nb.cells[1].id == a.id, "the pair moved as one block, in order"
nb.move(nb.cells[0].id, -1) # already first -- no-op
assert [c.ctype for c in nb.cells] == ['prompt', 'assistant', 'code', 'code']
nb.move(nb.cells[-1].id, 1) # already last -- no-op
assert [c.ctype for c in nb.cells] == ['prompt', 'assistant', 'code', 'code']
nb.move(p.id, 1) # move the pair back down past `first`
assert [c.ctype for c in nb.cells] == ['code', 'prompt', 'assistant', 'code']
nb.reset()
print('pair_range + move: lone cells, pairs, and boundary no-ops all verified')notebook
Cell, the Notebook that holds a stack of them (with selection, clipboard, and app-level UI state), and the single running nb instance. Pure data – no rendering, no HTTP, no kernel. cells.py (rendering/routes) and serialize.py (the .ipynb boundary) both build on this; it depends on nothing of theirs.
Cells
CTYPES is the set of cell types you can author (code/note/prompt/raw; assistant cells are generated, not authored). Cell is one cell – its type, source, output, and a few UI/export flags – a plain data-holder with no behavior of its own.
Cell
def Cell(
id:int, ctype:str, source:str, output:Any=None, visible:bool=True, model:str | None=None, nb_id:str | None=None,
export:bool=False, details:str | None=None
):One notebook cell: its type, source text, any output, and a few UI/export-related flags.
The notebook
Notebook holds the stack of cells plus selection, clipboard, and app-level UI state, along with the operations the editor drives (move, copy/cut/paste, remove – all respecting the Prompt+Assistant pairing rule). nb is the single running instance the rest of the app imports and mutates in place.
Notebook
def Notebook():The whole in-memory notebook: its cells, selection, clipboard, and app-level UI state.
Tests
Assertion-based checks of the trickier Notebook logic – the pair-range/move/clipboard/remove pairing rules – run by nbdev-test. Not exported.
# copy/cut/paste: copy leaves the original untouched; cut removes it; paste creates fresh cells
# (new ids) rather than reusing the originals, and inserts them after the given anchor (or at the
# end if no anchor is given).
nb.reset()
a = nb.add('code', 'a'); b = nb.add('code', 'b'); c = nb.add('code', 'c')
nb.copy_range(b.id)
assert len(nb.cells) == 3, "copy must not remove anything"
assert nb.clipboard[0]['source'] == 'b'
pasted = nb.paste_after(a.id)
assert [x.source for x in nb.cells] == ['a', 'b', 'b', 'c'], "pasted copy lands right after the anchor"
assert pasted[0].id != b.id, "a pasted cell is a fresh copy, not the original cell object/id"
nb.reset()
a = nb.add('code', 'a'); b = nb.add('code', 'b'); c = nb.add('code', 'c')
nb.cut_range(b.id)
assert [x.source for x in nb.cells] == ['a', 'c'], "cut removes the cell"
assert nb.clipboard[0]['source'] == 'b', "...but keeps it on the clipboard"
assert nb.selected == nb.cells[min(1, len(nb.cells)-1)].id, "selection lands on a sensible neighbor after cutting"
pasted = nb.paste_after(None) # no anchor -- appends at the end
assert [x.source for x in nb.cells] == ['a', 'c', 'b']
# cut/paste also carries a Prompt+Assistant pair as one unit.
nb.reset()
lead = nb.add('code', 'lead')
p = nb.add('prompt', 'q'); asst = nb.add('assistant', 'ans')
nb.cut_range(p.id)
assert [x.ctype for x in nb.cells] == ['code'], "cutting a pair removes both cells"
assert len(nb.clipboard) == 2 and nb.clipboard[0]['ctype'] == 'prompt' and nb.clipboard[1]['ctype'] == 'assistant'
nb.paste_after(lead.id)
assert [x.ctype for x in nb.cells] == ['code', 'prompt', 'assistant'], "pasting restores the pair intact, in order"
nb.reset()
print('copy/cut/paste verified, including Prompt+Assistant pairs moving as one unit')# remove() deletes a lone cell, or a whole Prompt+Assistant pair when the id addresses one --
# same pairing rule as pair_range/move/cut, just without going through the clipboard.
nb.reset()
a = nb.add('code', 'a'); b = nb.add('code', 'b')
nb.remove(a.id)
assert [x.source for x in nb.cells] == ['b']
nb.reset()
lead = nb.add('code', 'lead')
p = nb.add('prompt', 'q'); asst = nb.add('assistant', 'ans')
trail = nb.add('code', 'trail')
nb.remove(asst.id) # addressed from the assistant side -- should still take the whole pair
assert [x.ctype for x in nb.cells] == ['code', 'code'], "removing via the assistant side removes its paired prompt too"
nb.reset()
print('remove() verified for lone cells and Prompt+Assistant pairs')# Cell.source normalizes line endings on assignment, however the text arrives. Browsers
# CRLF-normalize form-submitted <textarea> values, so without this the server's copy of a cell
# drifted one character per line break away from the caret offsets the client reports -- and
# split_cell(), which slices c.source at a client-supplied `pos`, cut in the wrong place.
nb.reset()
c = Cell(1, 'note', 'a\r\nb\r\n\r\nc')
assert c.source == 'a\nb\n\nc', "CRLF normalized at construction"
c.source = 'x\r\ny'
assert c.source == 'x\ny', "...and on later assignment"
c.source = 'old\rmac'
assert c.source == 'old\nmac', "...including lone CR"
assert '\r' not in nb.add('note', 'p\r\nq').source, "...and via Notebook.add/insert_at"
# The payoff: a caret offset measured against the '\n'-only text the editor shows now slices the
# stored source at exactly that same point. Pre-fix, `pos` landed 8 characters early here (one per
# line break above it), orphaning all of 'line 8' into the lower cell.
nb.reset()
text = '\n'.join(f'line {i}' for i in range(1,13))
c = nb.add('code', text.replace('\n','\r\n')) # as a browser form-save would deliver it
pos = text.index('line 9') # caret at the start of line 9
head, tail = c.source[:pos], c.source[pos:]
assert head.rstrip('\n').endswith('line 8'), "everything above the caret stays above"
assert tail.lstrip('\n').startswith('line 9'), "and the new cell starts exactly at the caret"
nb.reset()
print('line-ending normalization verified -- caret offsets and stored source now agree')