now_snapshot
def now_snapshot(
agent
):The foreground turn, its plan, sub-agents, writes and background work, as a JSON-able dict.
While a turn runs, the chat shows the answer as it streams and one line per tool call. It does not show, at a glance, which step the turn is on, how far the plan has got, what each sub-agent is doing, or which files have changed so far. The now pane shows all of that in a narrow column beside the chat. It is called the now pane because it shows only the present: what is running at this moment and what this turn has done.
The pane runs as a separate program, ramabana-pane, in a tmux split. tmux is a terminal multiplexer: it divides one terminal window into panes, each running its own program. Because the pane is a separate process, the terminal session hands it information through a file. Several times a second the session writes a snapshot, a JSON file called now.json in the session’s run folder, and the pane reads that file and draws it. This page covers the three parts in order: taking a snapshot, laying it out as rows of text, and the viewer that draws those rows and answers keys and clicks.
now_snapshot(agent) returns a plain dict that json.dumps can write. Its keys are:
root, the foreground turn: how many steps it has taken, how long it has run, its state, the call it is waiting on (current), and its status, the last line the model wrote before calling a tool.plan, the agent’s checklist, with each item’s text and status.subs, one row per sub-agent. A sub-agent is a second agent the turn starts to do part of the work. The list holds every delegation this turn started, and every background delegation that is running or has finished since the turn began. A row carries the sub-agent’s status, its last CALLS_KEPT calls with their results cut short, and its answer, cut to ANSWER_CHARS, once it has finished. A finished sub-agent stays listed until the next turn starts.files, the files this turn wrote: path, lines added and removed, and the diff cut to DIFF_LINES lines.notes, what the model wrote before each of this turn’s calls, in full and with a timestamp.background, the work running behind the turn: background delegations, commands started with run_shell_bg, and folder watches. Each row has a kind, an id, a label, a state and the seconds it has been running. A finished shell command or a watch has no clock, so its elapsed is None.The session writes a snapshot up to five times a second, so taking one has to be cheap when nothing has changed. The list of files is worked out again only after a call finishes. The state of a background shell command is asked of the host at most once every SHELL_EVERY seconds while it runs, and never again once it has ended.
Each recorded call (an Act) names the run whose model made it, so a sub-agent’s step count is the number of calls with its run id. A step of the main turn is counted the way the terminal counts it: a call whose parent_action_id does not name another call in the activity log. A sub-agent’s calls name the delegate call that started it, so they never count as steps of the main turn.
The foreground turn, its plan, sub-agents, writes and background work, as a JSON-able dict.
The example builds an agent with fake_agent and a scripted model, Root. The model reads a file, then asks a sub-agent one question with delegate_search, and the sub-agent reads the same file before it answers. The activity log’s on_change hook takes a snapshot when the sub-agent’s read starts, which is the moment the pane would show both agents working. The main turn has taken two steps and the one sub-agent is running with one step:
from ramabana.runtime import current_run
from ramabana.testing import ScriptedBackend, Step, fake_agent
READ = ('view_file', {'path': '/proj/a.py'})
a, _ = fake_agent()
seen = []
a.activity.on_change = lambda act: seen.append(now_snapshot(a)) if not act.done and current_run().kind != 'root' else None
class Root(ScriptedBackend):
def spawn(self, sp='', tools=(), **kw):
return ScriptedBackend(self.spec, steps=[Step(tool=READ), Step('a() is defined')], token_delay=0, tools=tools)
be = Root(steps=[Step(tool=READ), Step(tool=('delegate_search', {'questions': ['what does a.py define?']})), Step('done')],
token_delay=0, tools=a.tools, sp=a.system_prompt())
a._be = a._be_or_none = lambda job='turn': be
list(a.stream('go'))
mid = seen[0]
test_eq((mid['busy'], mid['root']['steps'], [(s['state'], s['steps']) for s in mid['subs']]), (True, 2, [('running', 1)]))
mid{'at': 1792474688.9890528,
'busy': True,
'root': {'turn_elapsed': 0.0,
'steps': 2,
'state': 'running',
'status': '',
'current': 'Delegate: what does a.py define?'},
'plan': [],
'subs': [{'id': 'run_5837d1bea07d',
'question': 'what does a.py define?',
'elapsed': 0.0,
'state': 'running',
'steps': 1,
'status': '',
'current': 'View /proj/a.py',
'calls': [{'line': 'View /proj/a.py',
'ok': True,
'done': False,
'out': ''}],
'answer': ''}],
'files': [],
'background': [],
'notes': []}
Once the turn ends, the sub-agent stays listed as completed. The next turn clears it:
The plan and the files this turn wrote are part of the snapshot too, each file with its diff. Here the example sets a two-item plan and changes a.py by hand:
a.plan.set('ship', ['read a.py', 'fix it']); a.plan.update(a.plan.todos[0].id, status='active')
a.before['/proj/a.py'] = 'def a(): pass\n'; a.host.files['/proj/a.py'] = 'def a(): return 1\n'
snap = now_snapshot(a)
test_eq(snap['plan'], [{'text': 'read a.py', 'status': 'active'}, {'text': 'fix it', 'status': 'pending'}])
test_eq([(f['path'], f['added'], f['removed']) for f in snap['files']], [('a.py', 1, 1)])
print(snap['files'][0]['diff'])write_snapshot writes the snapshot to a file and read_snapshot reads it back. The writer goes through a temporary file and renames it into place, so the pane never reads half a file. The reader returns None when the file is missing or does not yet hold valid JSON, and the pane then keeps showing what it had. The example checks both cases, and that the temporary file is gone afterwards:
The snapshot at path, or None when it is missing or unreadable.
Write now_snapshot(agent) to path atomically.
board(snap, width) lays a snapshot out as rows for a pane width cells wide, one row per screen line. Each row is a (key, Text) pair, where Text is a rich styled string. Long lines are cut at the width, not wrapped. Use board when you need the rows themselves, as the viewer does to know which row a click landed on. Use render when you only want something to print: it returns the same rows as one rich renderable.
From the top, the board shows:
main, its step number, its clock and its status line. The chat already shows the call the main agent is waiting on, so the header leaves it out. An idle agent gets one line.▶ running, ✓ completed, ✗ stopped or failed. An open row, marked ▾ in place of ▸, adds the question, each recent call with its result, and the answer once there is one.hl.diff_rich with the code coloured for the file’s language and the added and removed lines tinted.SHELL_LINES lines of output, a watch’s pattern and last review, or a background sub-agent’s detail.▸ row with its time and first line, and opens to the whole note wrapped to the pane. The newest note starts open.Empty sections are left out. A row that can open carries a key: ('sub', id), ('file', path), ('bg', id) or ('note', at). The caller passes the keys of the open rows in open, so a row stays open when the next snapshot arrives. A running clock adds the time since the snapshot was written, so a long call does not look frozen between writes. The colours are the terminal session’s default github-dark palette, copied into PALETTE here because ramabana.cli imports this module and this module cannot import it back.
The pane for snap as a rich renderable, width cells wide, with the rows keyed in open opened.
The pane for snap as (key, row) pairs, one per screen line; a row with a key opens and closes.
A snapshot of a busy turn, written five seconds before now, with every row closed. The turn’s clock reads 47s, its 42 seconds plus the five since the write:
call = lambda line, ok=True, done=True, out='': {'line': line, 'ok': ok, 'done': done, 'out': out}
now = time.time()
busy = {'at': now - 5, 'busy': True,
'root': {'turn_elapsed': 42, 'steps': 7, 'state': 'running', 'status': 'Checking the pane tests', 'current': 'Run shell: pytest'},
'plan': [{'text': 'Read the viewer', 'status': 'done'}, {'text': 'Rewrite render', 'status': 'active'},
{'text': 'Wire the CLI', 'status': 'pending'}, {'text': 'Poll the file', 'status': 'cancelled'}],
'subs': [{'id': 'run_aaa', 'question': 'which files import fastllm?', 'elapsed': 12, 'state': 'running', 'steps': 2,
'status': 'Searching for imports', 'current': 'Search code: fastllm',
'calls': [call('Search code: import fastllm', out='agent.py:12: import fastllm'), call('Search code: fastllm', None, False)],
'answer': ''},
{'id': 'run_bbb', 'question': 'is the pane tested?', 'elapsed': 8, 'state': 'completed', 'steps': 1, 'status': '',
'current': '', 'calls': [call('Read tests/test_pane.py')], 'answer': 'Yes, in tests/test_pane.py.'}],
'files': [{'path': 'ramabana/pane.py', 'added': 1, 'removed': 1, 'diff': '@@ -1 +1 @@\n-from rich.live import Live\n+from teleprint.keys import Parser'}],
'background': [{'kind': 'shell', 'id': 'cmd_1234abcd', 'label': 'pytest -x tests', 'state': 'running', 'elapsed': 30}]}
def show(snap, open=()):
con = Console(record=True, width=40, color_system=None)
con.print(render(snap, 40, now=now, open=open))
return con.export_text()
out = show(busy)
assert '47s' in out and 'Run shell' not in out and '▸ run_aaa' in out and 'import fastllm' not in out
assert all(len(l) <= 40 for l in out.splitlines())
print(out)Passing the keys of the running sub-agent and the file in open shows the sub-agent’s calls and the diff:
In an open file the diff is drawn by hl.diff_rich in the pane’s palette. The code in each hunk is coloured by the file’s language, and added and removed lines get a tinted background. The cell checks that an added line carries more than two styles, so the code is highlighted and not drawn in one colour:
py = {**busy, 'files': [{'path': 'a.py', 'added': 1, 'removed': 1, 'diff': diff_text('x = 1\n', 'def f(): return 2\n', 'a.py')}]}
rows = [t for _, t in board(py, 40, now=now, open={('file', 'a.py')})]
added = next(t for t in rows if 'def f' in t.plain)
test_eq(added.plain, ' +def f(): return 2')
assert len({str(s.style) for s in added.spans}) > 2, 'the code is highlighted, not one colour'
addedViewer(path, tty) is the program that runs in the pane. It reads the snapshot at path and draws the board on tty. A tty is any object with read(timeout), write, size and restore, as teleprint’s RealTty has. PaneTty is RealTty with a read that returns None at the end of input, so the viewer can tell that its terminal has gone.
The viewer switches the terminal to the alternate screen, the separate screen full-screen programs use so that the shell’s scrollback comes back unchanged when they exit, and asks the terminal to report mouse clicks. Each tick waits up to every seconds for input and acts on it. The controls are:
j and k, or the arrow keys, move the cursor over the rows that open. Past the first or last such row they scroll, so a board taller than the pane can be read to its end.g and G jump to the top and the bottom.p on a background shell command opens a tmux pane below this one that follows the command’s log with less +F. q closes it.q or ctrl+c quits.The last line of the pane is always HINT, so the main keys stay on screen however far the board scrolls.
The viewer reads the snapshot again only when its file has changed. It lays out the board on every tick but writes only the lines that differ from what is on screen, so a clock that moves once a second rewrites one line a second and a board that is not changing writes nothing. When the terminal changes size, the viewer clears it and draws everything again.
The viewer must keep running whatever a single frame does. If drawing fails, it keeps the last good snapshot and shows the error as a row until a good snapshot arrives. However it exits, it gives the terminal back its normal screen and modes. When the user quits, it leaves a file named by quit_mark beside the snapshot, now.closed. The terminal session checks for it, so it can tell a pane the user closed, which it leaves closed, from one that crashed. A hangup (tmux closing the pane), SIGTERM and the end of input count as quitting too.
The board for the snapshot at path, drawn on tty: the last good snapshot, the open rows, the cursor and the scroll.
RealTty whose read returns None at the end of input, where RealTty.read would spin.
The file a viewer the user quit leaves beside the snapshot at path, so the CLI keeps it closed.
The example drives a Viewer with a test terminal, Tty, that replays scripted input and keeps everything written to it. A click on the running sub-agent opens it and puts the cursor there. A tick with nothing new writes nothing. Then j moves the cursor down, space opens run_bbb, and q quits:
class Tty:
"A terminal for tests: scripted input chunks, a size, and every write kept."
def __init__(self, *input, size=(40, 30)): self.input, self.size, self.writes = list(input), size, []
def read(self, timeout=0): return self.input.pop(0).encode() if self.input else b''
def write(self, s): self.writes.append(s)
def restore(self): pass
with tempfile.TemporaryDirectory() as d:
(p := Path(d)/'now.json').write_text(json.dumps(busy))
v = Viewer(p, Tty()); v.tick(now=now)
y = next(i for i, (k, _) in enumerate(v.rows) if k == ('sub', 'run_aaa'))
v.tty.input.append(f'\x1b[<0;3;{y + 1}M'); v.tick(now=now)
test_eq((v.open, v.cursor), ({('sub', 'run_aaa')}, ('sub', 'run_aaa')))
n = len(v.tty.writes); v.tick(now=now)
test_eq(len(v.tty.writes), n)
v.tty.input += ['j', ' ', 'q']
for _ in range(3): v.tick(now=now)
test_eq((v.cursor, v.open, v.done), (('sub', 'run_bbb'), {('sub', 'run_aaa'), ('sub', 'run_bbb')}, True))main is the viewer’s command line: ramabana-pane PATH runs the viewer on the terminal it was started in. The terminal session starts it in a tmux split when you type /pane, and prints the command when there is no tmux, so you can run it in another terminal yourself. --every sets how long each tick waits for input.
Show the now snapshot at path as a board until q or ctrl+c.