from teleprint.testing import EmuTty as _EmuTtycli
Two small fixes to teleprint are applied when this module loads. Each checks whether the installed teleprint already has the fix and does nothing if so; FRAME_PATCHED and INK_PATCHED record whether the patch was needed.
The first concerns rows already written to the scrollback. Teleprint keeps a mark, _ws, of how many rows of the document it has committed there, and each frame writes only the rows past the mark. Those rows are history in the terminal and cannot be taken back. When a tool result folds, the document gets shorter, and teleprint used to lower the mark to match, so the next frame wrote earlier rows a second time. A session with many folded calls ended up with many copies of its startup banner. The patch keeps the mark from ever going down.
The second adds a hook, Compositor.on_frame, which runs after each paint. Pictures need it: a picture is placed on the screen after the text is drawn, as the section on pictures below explains. The hook is installed before the mark fix, so _frame.__wrapped__ is the frame without the mark fix, and the test below uses it to show the duplicated rows.
async def _marks_over_a_fold(frame):
"The ink mark after growth, after a fold, and after growing again, under `frame`."
tty = _EmuTty(40, 8)
comp = Compositor(tty); comp._register_signals = lambda: None
await comp.start()
comp.print_block(Text('BANNER'), tag='note', collapse_at=None, source='BANNER')
tall = comp.print_block(Text('\n'.join(f'line {i}' for i in range(20))), tag='tool',
collapse_at=None, source='x')
out = []
frame(comp); out.append(comp._ws)
tall.collapsed, tall.collapse_at = True, 1
comp._dirty(tall); frame(comp); out.append(comp._ws)
comp.print_block(Text('\n'.join(f'again {i}' for i in range(20))), tag='tool',
collapse_at=None, source='y')
frame(comp); out.append(comp._ws)
tty.close()
return out
_marks = await _marks_over_a_fold(Compositor._frame)
assert _marks[0] > 0, 'the document has to outgrow the window for anything to be inked at all'
test_eq(_marks, sorted(_marks))
if INK_PATCHED:
_bare = await _marks_over_a_fold(Compositor._frame.__wrapped__)
test_eq(_bare[1], 0)
assert _bare != sorted(_bare)_h_tty = _EmuTty(30, 6)
_h_comp = Compositor(_h_tty); _h_comp._register_signals = lambda: None
await _h_comp.start()
_fired = []
_h_comp.on_frame = lambda: _fired.append(_h_comp._cursor)
_h_comp.print_block(Text('one'), tag='note')
test_eq(len(_fired), 1)
_h_comp.paused = True
_h_comp.print_block(Text('two'), tag='note')
test_eq(len(_fired), 1)
_h_tty.close()Ramabana gives shift+enter its own meaning: during a turn it queues the line as the next turn, where plain enter steers the running one. By default a terminal sends the same byte for both keys, so the program has to ask the terminal for richer key reports. There are two protocols for this:
- Kitty’s keyboard protocol, with flag 1 (“disambiguate”), reports a modified key as
CSI code;mods u. Ghostty and kitty support it. - Xterm’s modifyOtherKeys, at level 1, reports it as
CSI 27;mods;code ~. Tmux sends this form when itsextended-keysoption is on, and the kitty form whenextended-keys-formatiscsi-u.
The compositor asks for both, and each terminal answers with the one it supports. ext_key turns either report into the same Key the old byte parser would have produced. Once kitty’s flag is on, esc, control letters and alt letters also arrive in the new form, so the decoder has to cover every key, and it follows these rules:
- A key release is dropped.
- Keypad keys decode to the same characters and names the old bytes gave.
ctrl+i,ctrl+jandctrl+mstaytabandenter.- Kitty’s other private-use keys, such as bare modifiers and media keys, are dropped rather than typed.
- Caps lock and num lock are ignored, since they are states rather than key chords.
- Chords with super, hyper or meta have no names here and are dropped.
ext_keys_ok decides whether to ask at all: only when both stdin and stdout are a real terminal, so tests and pipes never see the escape codes. The compositor turns the modes off wherever it hands the terminal back: at stop, at release when another program borrows the terminal, before a fatal signal, and while ctrl+z has the process suspended. A ctrl+z from the transcript view also leaves the alternate screen while suspended and comes back on fg. Kitty keeps a separate flag stack for the alternate screen, so the transcript view sets the flag again there and clears it before leaving.
ext_key
def ext_key(
code, mods:int=1, event:int=1
):The Key for a kitty or modifyOtherKeys report, or None.
def _ks(*chunks):
p = Parser()
return [e.name for c in chunks for e in p.feed(c)]
test_eq(_ks('\x1b[13;2u', '\x1b[27;2;13~', '\x1b\r', '\r'), ['shift+enter', 'shift+enter', 'alt+enter', 'enter'])
test_eq(_ks('\x1b[9;2u', '\x1b[27;2;9~', '\x1b[Z'), ['shift+tab'] * 3)
test_eq(_ks('\x1b[27u', '\x1b[99;5u', '\x03', '\x1b[49;3u', '\x1b1'), ['escape', 'ctrl+c', 'ctrl+c', 'alt+1', 'alt+1'])
test_eq(_ks('\x1b[13;2:3u', '\x1b[57428u'), [])
test_eq(_ks('\x1b[13;2', ':1u'), ['shift+enter'])
test_eq(Parser().feed('\x1b[97u'), [Key('a', 'a')])
test_eq(_ks('\x1b[57414u', '\x1b[106;5u', '\x1b[127;2u', '\x1b[57441u'), ['enter', 'enter', 'backspace'])
test_eq(ext_keys_ok(_EmuTty(10, 4)), False)Blocks and keys
Every block has a kind, and each kind has its own gutter, the mark in the left margin. GUTTERS maps the nine kinds to their marks: user for your prompts (▌), step for the agent’s narration (┆), tool for a tool call (│), reply for the answer, ask for an approval question (◆), plan for the plan checklist (▸), note for a message from Ramabana itself (·), error (×), and media for a picture.
A turn is one prompt and everything the agent does to answer it. In the transcript it reads as a timeline in the order the work happened: a step for each stretch of narration, a tool block for each call that narration introduced, and one reply with the answer at the end. Results fold to one row and narration stays visible, so the answer always sits below the narration that led to it.
The cell below also holds the palettes. DARK, GRUVBOX and the other dicts name the colours of each theme, set_theme picks the active one, and code_theme, hl_theme and code_bg give the matching colours for highlighted code. plan_text draws the plan checklist in the active palette.
set_theme
def set_theme(
name:str='github-dark'
):Select the active palette; auto means the default.
hl_theme
def hl_theme(
name:NoneType=None
):The Lumis theme hl colours code with under the named palette.
code_theme
def code_theme(
name:NoneType=None
):The pygments style to highlight code with under the named palette.
print(HELP)print(guide_text(GUIDE))Attachments
A prompt can carry files with it: pictures, sound, or text. In the terminal you attach one in three ways. You can drop the file on the window, paste its path, or write @path in the prompt. All three end up as the same attachment. The path parser accepts quoted paths, paths in brackets, file:// URIs and backslash-escaped spaces, and a plain sentence that merely mentions a path is left alone, as the tests below check.
A picture goes to the model as an image content part, the form a model API takes images in. Every attachment also carries its absolute path, and the path still works when the model cannot take the content part. A model without audio support, for example, can still open a .wav through the file tools.
The attachment helpers themselves, media_paths, Attachment, media_parts and the rest, live in ramabana.tools and are imported here so the terminal and the other frontends share one parser.
Pictures, drawn where the terminal can draw
A picture can reach the screen in two ways. The model can return one in Resp.media, or a tool can write an image file during the turn and report it through on_media. On a terminal without kitty graphics, the picture is saved and its path printed. kitty_graphics decides which kind of terminal this is, from the environment variables and $TERM names in KITTY_ENV and KITTY_TERM.
On a terminal with kitty graphics the picture is drawn in place. Kitty positions an image by screen cell, separately from the text. The compositor redraws its document with absolute cursor moves, so an image cannot ride along in the text the way prose does. A Picture handles this in two steps. Picture.send transfers the image bytes to the terminal once. The block itself holds only the blank rows the picture will cover, sized by img_cells from png_size. Then Picture.place writes a short placement command at the end of every frame, at the current position of those blank rows. Each placement reuses the picture’s image id, which replaces the previous placement, so repaints never stack copies. If the terminal clears the cells under an image, the next frame places it again.
picture builds a Picture for a file, or returns None when this terminal or this file cannot be drawn. draw_png is for code that has no frames to hook into: it sends and places a picture at the cursor in one go. media_line is the transcript line for a saved picture.
Kitty also offers Unicode placeholders, where the image is anchored to special characters in the text. They are not used, because Kaku and at least one other terminal ignore the U=1 flag, draw at the cursor, and print U+10EEEE as a missing glyph, which leaves a block of boxes behind the picture.
draw_png
def draw_png(
path, cols:int=24
):Send and place path at the cursor, or '', for callers without frames.
picture
def picture(
path, cols:int=24, rows:int=12
):A Picture for path, or None where this terminal or this file cannot be drawn.
Picture
def Picture(
path, cols:int=24, rows:int=12
):One picture on screen: bytes sent once, then re-placed at the end of every frame.
img_cells
def img_cells(
path, cols, rows:int=12
):Cell width and height for path in a cols by rows box, keeping aspect.
kitty_graphics
def kitty_graphics():Whether this terminal speaks the kitty graphics protocol.
import os as _osr, struct as _struct, zlib as _zlib
def _a_png(w, h):
"A real noise PNG, big enough to need several APC chunks."
def chunk(tag, data):
return (_struct.pack('>I', len(data)) + tag + data
+ _struct.pack('>I', _zlib.crc32(tag + data) & 0xffffffff))
raw = b''.join(b'\x00' + _osr.urandom(3 * w) for _ in range(h))
return (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', _struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
+ chunk(b'IDAT', _zlib.compress(raw)) + chunk(b'IEND', b''))import os as _os
_seen = {k: _os.environ.pop(k, None) for k in (*KITTY_ENV, 'TERM', 'TERM_PROGRAM')}
try:
test_eq(kitty_graphics(), False)
test_eq(picture('/tmp/nope.png'), None)
test_eq(draw_png('/tmp/nope.png'), '')
assert media_line('/tmp/a.png').startswith('saved ')
import tempfile
with tempfile.TemporaryDirectory() as d:
png = b'\x89PNG\r\n\x1a\n' + b'x'
p = save_media({'mime': 'image/png', 'data': png}, d)
test_eq(p.name, 'image-1.png')
test_eq(p.read_bytes(), png)
test_eq(p.parent.name, 'media')
test_eq(save_media({'mime': 'image/png', 'data': png}, d).name, 'image-2.png')
test_eq(save_media({'mime': 'image/avif', 'data': png}, d).suffix, '.avif')
_os.environ['RAMABANA_KITTY'] = '1'
with tempfile.TemporaryDirectory() as d:
p = Path(d)/'a.png'
p.write_bytes(_a_png(1200, 600))
pic = picture(p, 24)
test_eq((pic.cols, pic.rows), (24, 6))
assert len(pic.send()) > 2 * APC_CHUNK
test_eq(pic.send().count('m=0'), 1)
assert 'a=t' in pic.send() and 'a=p' not in pic.send()
test_eq(pic.place(), f'\x1b_Ga=p,i={pic.id},p=1,c=24,r=6,C=1,q=2\x1b\\')
test_eq(len(pic.gap().plain.split('\n')), 6)
test_eq(pic.gap().plain.split('\n')[0], 'a.png')
test_eq(media_line(p), str(p))
finally:
_os.environ.pop('RAMABANA_KITTY', None)
for k, v in _seen.items():
if v is not None: _os.environ[k] = vText files can be attached too, up to MAX_FILE_ATTACH characters each. A FileAttachment captures the file’s text when the prompt is submitted, and file_note adds the text of every attached file to the prompt, each tagged with its exact path, so the model reads the file as it was at that moment. file_refs finds the @path references in a prompt. steer_note covers a line sent during a turn, which reaches the model inside a tool result; a tool result cannot carry a picture, so the note lists each attachment by path for the model to open with view_file.
The cell after this one makes a few files to attach.
file_note
def file_note(
atts
):The grounded text from selected workspace files and folder listings, with their exact paths.
FileAttachment
def FileAttachment(
path, text, folder:bool=False
):One text or notebook source attachment, or a folder’s listing, captured when the prompt is submitted.
file_refs
def file_refs(
text
):Paths named as @path or @"path with spaces" in a prompt, sentence punctuation removed from a bare one.
steer_note
def steer_note(
atts
):What a steered line says about its attachments: a tool result carries no picture, so each goes by path.
media_dir = Path(tempfile.mkdtemp())
(media_dir / 'shot.png').write_bytes(b'\x89PNG\r\n\x1a\n' + b'0' * 64)
(media_dir / 'My Photo.png').write_bytes(b'\x89PNG\r\n\x1a\n')
(media_dir / 'note.wav').write_bytes(b'RIFF' + b'0' * 40)
shot, spaced, snd_path = media_dir / 'shot.png', media_dir / 'My Photo.png', media_dir / 'note.wav'
sorted(p.name for p in media_dir.iterdir())The Ui methods that attach files, attach, paste and attach_file, are defined after Ui further down. The tests here check the path parser on its own.
test_eq(media_paths(str(shot)), [shot])
test_eq(media_paths(f'"{shot}"'), [shot])
test_eq(media_paths(f"'{shot}'"), [shot])
test_eq(media_paths(f'[{shot}]'), [shot])
test_eq(media_paths(f'file://{shot}'), [shot])
test_eq(media_paths(f' {shot} '), [shot])
test_eq(media_paths(str(spaced)), [spaced])
test_eq(media_paths(str(spaced).replace(' ', '\\ ')), [spaced])
test_eq(media_paths(f'{shot} {snd_path}'), [shot, snd_path])from unittest.mock import patch as mock_patchtest_eq(media_paths('where is the threshold?'), [])
test_eq(media_paths(f'have a look at {shot} when you get a chance'), [])
test_eq(media_paths(str(media_dir / 'gone.png')), [])
_real_expanduser = Path.expanduser
def _expand_without_home(path):
if str(path).startswith('~'): raise RuntimeError('Could not determine home directory.')
return _real_expanduser(path)
with mock_patch.object(Path, 'expanduser', _expand_without_home):
test_eq(media_paths('keep ~literal text'), [])
test_eq(attach_refs(f'compare @{shot} against the spec'), [shot])
test_eq(attach_refs(f'what is in @{shot}?'), [shot])
test_eq(attach_refs('email me @ the address on file'), [])img, snd = Attachment(shot), Attachment(snd_path)
test_eq((img.kind, img.mime), ('image', 'image/png'))
test_eq((snd.kind, snd.mime), ('audio', 'audio/wav'))
test_eq(media_note([]), '')
hears, deaf = resolve('gemini/gemini-2.5-flash'), resolve('anthropic/claude-opus-4-5')
test_eq(media_parts([img, snd], hears), [img.data, snd.data])
test_eq(media_parts([img, snd], deaf), [img.data])
assert 'The audio above is attached' in media_note([img, snd], hears)
assert 'does not accept audio input' in media_note([img, snd], deaf)
test_eq(media_parts([img, snd]), [img.data, snd.data])
note = media_note([img, snd])
assert str(img.path) in note and str(snd.path) in note
print(note)The turn
A turn starts when you press enter on a prompt. run_turn runs one turn and streams it into the transcript. The agent’s agent.stream is a blocking generator that runs on a separate model thread, so run_turn reads its chunks from a queue. That keeps the event loop free to handle keystrokes, approval questions and activity updates while the model works.
Before the turn starts, some prompts get a row of choices. A request such as “refactor the runtime” can mean “make the change”, “propose a plan”, or “say what is in scope”, and learning which one the model picked is costly. options_for checks the typed line against the trigger words in MENUS and returns a title and a list of Options, or None. ChoiceMenu draws the row above the input line and handles the keys that move through it and pick one. Each Option carries the instruction it appends to the prompt (its suffix), so the choice reaches the model as part of what you asked for. The REFACTOR options are the only menu at present. The examples of the menu in use come later, under the surface.
close_done_shells closes the tmux panes of run_shell_bg commands that have finished; their output stays readable through cmd_output.
close_done_shells
def close_done_shells(
host
):Close the tmux panes of run_shell_bg commands that have exited; cmd_output keeps what they printed.
options_for
def options_for(
text
):(title, options) for a prompt worth asking how* about, or None.*
Option
def Option(
key:str, label:str, note:str='', suffix:str=''
)->None:One choice on the options row: key, label, note and prompt suffix.
Tool results and the files a turn changed are drawn in colour. hl_text highlights a piece of code in a given language, and diff_rich colours a diff, both through hl in the active palette and its Lumis theme from hl_theme. In a unified diff, each line gets the colour of an addition or a removal, and the code inside is highlighted for the file’s language. A diffstat gets green and red bars, and any other text stays gray. changed_table lists the files a turn wrote, largest change first, with a total row at the end.
Whether a finished call stays open is decided by opens: a file write stays open, and so does any unified diff, while a shell or Python run folds. An open diff is wrapped in OpenDiff, which shows the first DIFF_OPEN lines with a hint until you open the block.
OpenDiff
def OpenDiff(
ui, blk, text
):A result shown DIFF_OPEN lines deep, with a hint, until the reader opens it.
opens
def opens(
act
):Whether a finished call stays open: a write other than a shell or python run, or any unified diff.
changed_table
def changed_table(
rows, cap:int=12
):The turn’s changed files, largest cap first, then a total over every one.
hl_text
def hl_text(
code, lang
):code coloured by fastpylight in the active palette’s theme.
d = diff_rich('--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-x = 1\n+x = 2')
test_eq(d.plain.splitlines()[-1], '+x = 2')
assert any(GRUVBOX['green'] in str(s.style) for s in d.spans)
test_eq(diff_rich('3 passed').spans, [])
t = hl_text('a\r\nb = 1', 'python')
test_eq(t.plain, 'a\nb = 1')
assert any(t.plain[s.start:s.end] == '1' for s in t.spans), 'colours stay put after a CRLF'
from rich.console import Console
_con = Console(width=60)
with _con.capture() as cap: _con.print(changed_table([('app/[id]/page.tsx', 3, 1, True)]))
assert 'app/[id]/page.tsx' in cap.get() and '1 file' in cap.get()The surface
Ui is the whole terminal surface, and the class the rest of this page builds on. It has three jobs. It paints the tail, the status bar and input line at the bottom of the screen. It turns tool calls and approval requests into blocks. And it decides what each keystroke means: on_key returns a coroutine for the caller to start (a turn, for example), the string 'quit', or None when the key was handled.
Every method on Ui is synchronous and does no terminal I/O of its own, so the examples below drive it against an emulated terminal with no mocks. The one care it takes is with threads. Activity and approval callbacks arrive on the model’s worker thread, and only the event loop thread may touch the compositor, so those callbacks are handed to the loop through _post.
The class is long, and later cells add to it with fastcore’s @patch. The methods a caller is most likely to use are submit for the typed line, say to print a block, note for a one-line message, stream for reply text as it arrives, and paint to redraw.
Ui
def Ui(
comp, agent, loop:NoneType=None
):The terminal surface: a transcript of blocks, a status bar, and one line to type in.
A line starting with /name that is not one of the agent’s slash commands is looked up as a skill or a command file. Command files are markdown files in <cfg>/commands/*.md, plus a root’s .agents/commands/ once the project_extensions option is turned on. So /exhash fix the loop sends the prompt “fix the loop” with the exhash skill attached, instead of reporting an unknown command. In a command file, $ARGUMENTS is replaced by everything after the name, $1 to $n by its separate words, and each @path by that file when it lies inside the open folders. A command file with subtask: true in its frontmatter is sent as a task for delegate_async, which runs it in a sub-agent.
The approval keys come next. While an approval question waits, y approves, n refuses, and lowercase a approves this request and everything else for the rest of the session. Capital A approves and also adds a rule that always allows requests like this one, through always_allow, when the approval gate can hold rules; ask_pattern is what that rule matches. Without rules, A behaves like a. parse_answer reads a typed answer such as y, no or n: put it in docs/.
A waiting question rings the terminal bell with ring, but only once nobody has typed for BELL_IDLE seconds. nudge shows the question again every REASK_EVERY seconds while it waits, and reask does the showing. #note TEXT at the prompt calls note_memory, which keeps the line for the next session’s memory.
Ui.always_allow
def always_allow():Always allow the pending request’s pattern and approve it; False if none.
Ui.nudge
def nudge():Ring and re-show a question after REASK_EVERY seconds; True if re-shown.
Ui.reask
def reask(
body:str=''
):Show the pending question with body, ring, and restart the nudge wait.
parse_answer
def parse_answer(
text
):(ok, note) for a typed approval answer (y, n, n: reason), or None.
from types import SimpleNamespace
class _QueueWork:
def __init__(self): self.closed = False
def close(self): self.closed = True
class _QueueTask:
def add_done_callback(self, callback): self.callback = callback
def done(self): return False
class _QueueComp:
def __init__(self): self.blocks, self.started = {}, None
def remove_block(self, block): self.blocks.pop(block.id)
def spawn(self, work, name):
self.started = work
return _QueueTask()
class _QueueUi:
agent = SimpleNamespace(leftovers=lambda: [])
_echo, _retract, _replay = Ui._echo, Ui._retract, Ui._replay
_turn_over, start_turn, _next_queued, take_back = Ui._turn_over, Ui.start_turn, Ui._next_queued, Ui.take_back
_queued_prompt = Ui._queued_prompt
flash, mark_read = Ui.flash, Ui.mark_read
def __init__(self, turn=None):
self.comp, self.turn = _QueueComp(), turn
self._queued, self._queued_echo, self._echoed = None, [], []
self._steered, self._queued_typed = [], ''
self._queued_text, self._typed, self._prompt, self._flash = [], '', None, None
self.buf = SimpleNamespace(text='', cursor=0)
self.notes, self.painted, self.attachments = [], False, []
def say(self, body, kind='reply', **kw):
block = type('Block', (), {'id': len(self.comp.blocks) + 1, 'body': str(body), 'tag': kind})()
self.comp.blocks[block.id] = block
return block
def note(self, text, kind='note'):
self.notes.append((text, kind))
return self.say(text, kind)
def paint(self): self.painted = True
u = _QueueUi()
u._echo('now')
now = _QueueWork()
assert u.start_turn(now)
test_eq([(b.body, b.tag) for b in u.comp.blocks.values()], [('now', 'user')])
u = _QueueUi('running')
u._echo('second')
second = _QueueWork()
assert not u.start_turn(second)
test_eq([(b.body, b.tag) for b in u.comp.blocks.values()],
[('queued · it runs when this turn ends · alt+up takes it back · ctrl+c stops this turn', 'note')])
u._echo('third')
third = _QueueWork()
assert not u.start_turn(third)
assert third.closed
assert all(b.body != 'third' for b in u.comp.blocks.values())
assert u._queued is second
u.turn = None
u._next_queued()
assert u.comp.started is second
assert u._queued is None
assert list(u.comp.blocks.values())[-1].body == 'second'
u = _QueueUi('running')
u._echo('/clear me')
u._typed = '/clear me' # what Enter took from the composer
clear = _QueueWork()
u.start_turn(clear)
assert u.take_back()
assert clear.closed and u._queued is None and u.buf.text == '/clear me', 'a queued command goes back as it was typed'
assert all(b.body != '/clear me' for b in u.comp.blocks.values())
u = _QueueUi('running')
u._echo('retry me')
retry = _QueueWork()
u.start_turn(retry)
u.turn = None
def fail_spawn(work, name): raise RuntimeError('busy')
u.comp.spawn = fail_spawn
u._next_queued()
assert u._queued is retry
assert all(b.body != 'retry me' for b in u.comp.blocks.values())
assert u.notes[-1] == ('the waiting message did not start: RuntimeError: busy', 'error')class _SteerAgent:
def __init__(self, listening=True): self.listening, self.told, self.left = listening, [], []
def steer(self, text):
if self.listening: self.told.append(text)
return 'told' if self.listening else ''
def leftovers(self): out, self.left = self.left, []; return out
_QueueUi.steer = Ui.steer
u = _QueueUi('running'); u.agent = _SteerAgent()
u._echo('look at b')
assert u.steer('look at b')
test_eq(u.agent.told, ['look at b'])
test_eq([b.body for b in u.comp.blocks.values()], ['↪ look at b'])
test_eq(u._flash[0], 'sent · read after the current call')
assert not _QueueUi(None).steer('no turn of ours')
u = _QueueUi('running'); u.agent = _SteerAgent(listening=False)
assert not u.steer('nobody listening') and u.agent.told == []
u = _QueueUi('running'); u.agent = _SteerAgent()
held = _QueueWork()
u.start_turn(held, prompt='queued line')
u.agent.left, u.turn = ['unread steer'], None
u._next_queued()
assert held.closed and u._queued is None
test_eq(u.comp.started.cr_frame.f_locals['prompt'], 'unread steer\n\nqueued line')
u.comp.started.close()Ui.place_pics
def place_pics():Put every picture back where its block sits, after each frame.
Ui.on_media
def on_media(
paths
):Show pictures a tool wrote, mid-turn, from the model’s thread.
Ui.show_media
def show_media(
media, session:str=''
):Save the pictures the model itself returned and show each one.
class _Comp:
cols, rows, paused, _cursor, _screen, _spans = 80, 24, False, (0, 0), [], {}
blocks = {}
class tty:
wrote = []
@classmethod
def write(cls, s): cls.wrote.append(s)
class _Say:
def __init__(self): self.said, self.comp, self.agent, self.pics, self.drawn = [], _Comp(), None, {}, 0
def say(self, body, kind='reply', **kw):
self.said.append((str(body), kind))
return type('B', (), {'id': len(self.said)})
def paint(self): pass
show_pic, place_pics = Ui.show_pic, Ui.place_pics
u = _Say(); Ui.show_media(u, [])
test_eq(u.said, [])
import tempfile
with tempfile.TemporaryDirectory() as d:
u = _Say()
Ui.show_media(u, [{'mime': 'image/png', 'data': b'\x89PNG\r\n\x1a\nx'}], session=d)
assert u.said and (Path(d)/'media'/'image-1.png').exists()
test_eq([k for _, k in u.said], ['note'])
test_eq(u.pics, {})
from ramabana.testing import FullHost
h, away, cwd = FullHost(), Path(tempfile.mkdtemp()), os.getcwd()
u = _Say(); u.agent = type('A', (), {'host': h})()
os.chdir(away) # no session: the agent's host folder, never the working directory
try: Ui.show_media(u, [{'mime': 'image/png', 'data': b'\x89PNG\r\n\x1a\nx'}])
finally: os.chdir(cwd)
assert (h.root/'media'/'image-1.png').exists() and not list(away.iterdir())There are two ways to change the approval mode, one for each direction. tighten_approve, bound to ctrl+g, makes approvals one step stricter, from auto to edits to ask to off, and never looser, so pressing it by accident is harmless. Loosening takes a typed command: approve_mode handles /approve MODE, which moves in either direction, and /approve alone prints the current mode.
Ui.chip
def chip():Plan mode or the approval mode as one short token, or None when nothing is gated.
Ui.cycle_approve
def cycle_approve():Cycle ask, edits, auto, then plan mode, then ask again; from off, go to ask.
Ui.approve_mode
def approve_mode(
want:str=''
):/approve [off|ask|edits|auto], or the current mode when want is empty.
cycle_approve, bound to shift+tab, goes round ask, edits and auto, then into plan mode. In plan mode every tool that acts on the world is withheld and the model proposes a plan instead. The next press leaves plan mode and goes back to ask. off is not on the cycle; from off, the next press goes to ask.
chip is the short label for the current mode, which always shows at the left of the row under the status bar. Every one of these controls also answers a question that was already waiting, so changing the mode to auto approves the request in front of you.
from ramabana.agent import Approvals
from ramabana.tools import WRITE_TOOLS
class _Planner:
"Enough of an `Agent` for the approval controls: approvals, and plan mode."
plan_mode = False
def __init__(self, approvals): self.approvals = approvals
def set_plan_mode(self, on): self.plan_mode = on; return on
class _Holder:
"Enough of a `Ui` for the approval controls."
def __init__(self, approvals): self.agent = _Planner(approvals)
tighten_approve, approve_mode, cycle_approve, chip = Ui.tighten_approve, Ui.approve_mode, Ui.cycle_approve, Ui.chip
u = _Holder(Approvals(tools=WRITE_TOOLS, mode='auto'))
test_eq(u.tighten_approve(), 'approvals: auto -> edits')
test_eq(u.tighten_approve(), 'approvals: edits -> ask')
test_eq(u.tighten_approve(), 'approvals: ask -> off')
assert 'off' in u.tighten_approve()
test_eq(u.agent.approvals.mode, 'off')
test_eq(u.approve_mode(), 'approvals: off')
test_eq(u.approve_mode('ask'), 'approvals: off -> ask')
test_eq(u.approve_mode('yolo'), 'usage: /approve [off|ask|edits|auto]')
test_eq(u.agent.approvals.mode, 'ask')
test_eq(_Holder(None).tighten_approve(), 'this session runs without approvals')
said = [u.cycle_approve() for _ in range(3)]
test_eq(said[:2], ['approvals: ask -> edits', 'approvals: edits -> auto'])
assert said[2].startswith('plan mode') and u.agent.plan_mode and u.chip().plain == '⏸ plan'
test_eq(u.cycle_approve(), 'approvals: auto -> ask') # leaving plan mode re-enters at ask
assert not u.agent.plan_mode
u.agent.approvals.mode = 'off'
test_eq(u.chip().plain, '⊘ off')
test_eq(u.cycle_approve(), 'approvals: off -> ask')
test_eq(_Holder(None).chip(), None)compact_md
def compact_md(
text
):Markdown with blank rows between plain paragraphs made hard breaks.
Reply
def Reply(
markup:str, code_theme:str='monokai', justify:JustifyMethod | None=None, style:str | Style='none',
hyperlinks:bool=True, inline_code_lexer:str | None=None, inline_code_theme:str | None=None
)->None:Rich’s Markdown with our code block.
ThemedCode
def ThemedCode(
lexer_name:str, theme:str
)->None:A fenced block on the palette’s background: a diff by diff_rich, known code by fastpylight, else pygments.
fence_lang
def fence_lang(
name
):fastpylight’s name for a fence’s language: ‘diff’ for a patch, None when it has no grammar.
Ui.paste
def paste(
text
):Insert pasted text, or attach the files when the paste is nothing but paths.
Ui.on_key
def on_key(
k
):Give transcript navigation priority; Ctrl-P/Ctrl-N recall prompts.
Ui.on_mouse
def on_mouse(
ev
):Route a mouse event to the browsing view, or here when /mouse is on.
Ui.set_mouse
def set_mouse(
want:str=''
):Take or release the mouse on the main screen; off by default.
Ui.leave_transcript
def leave_transcript():Close the browsing view and return the mouse unless /mouse is on.
Ui.enter_transcript
def enter_transcript():Open the browsing view, borrowing the mouse while it is up.
Ui.recall
def recall(
step
):Move through submitted prompts, preserving the draft beyond the newest entry.
Ui.tail
def tail():The live-tail description shared with Teleprint’s transcript view. The plan checklist heads it rather than covering the transcript.
Ui.model_row
def model_row():The row under the bar: the next turn’s model, from the routing table.
Python mode
Python mode gives the same prompt a second meaning. /python starts a Jupyter kernel that belongs to you, and from then on a line you type runs as Python in that kernel. /agent hands the line back to the model and leaves the kernel running; only /quit or the end of the process shuts it down. Everything else stays the same: the transcript, folding, approvals, attachments and slash commands. Only what enter does with the line changes.
The agent follows you into the kernel. enter_python points the agent’s Python tools at a Dhrishti overlay on the new kernel. An overlay is a layer over your namespace: the agent can read every name you defined and writes its own names into its layer, and it cannot rebind a name you made. The host stays the same object. enter_python then calls Agent.refresh, which drops the cached skills, registry and tools and gives the running model backend a fresh briefing with the new tools.
Related methods: run_code runs one cell you typed and prints its outputs as blocks, complete_python handles tab completion from the kernel, attach_session points the agent at someone else’s live Dhrishti session for --attach, and enable_agent_proxy makes this session’s agent callable from the Python prompt for --agent-proxy. /promote NAME copies one of the agent’s variables into your namespace.
pyrepl holds the parts that judge Python: the kernel, the overlay host, whether a line compiles, and how to colour it. It is imported only when Python mode starts, so a plain ramabana session needs no kernel. The tests that need a live kernel are on that page.
Ui.complete_python
async def complete_python():Tab: complete as far as the candidates agree, and leave the menu up to cycle.
Ui.run_code
async def run_code(
code
):Run one owner-typed Python cell, streaming its outputs into blocks.
Ui.attach_session
async def attach_session(
name
):Point the agent’s Python at someone else’s dhrishti session.
Ui.enable_agent_proxy
async def enable_agent_proxy():Bind this session’s agent at the Python prompt and in the agent’s layer.
Ui.enter_python
async def enter_python():Take the line for Python, starting the kernel once and pointing the agent at it.
Ui.use_host
def use_host(
host
):Move the agent onto host, re-briefing the running turn backend with its tools.
Ui.log_cell
def log_cell(
source, outputs:NoneType=None, cell_type:str='code'
):Write one cell to the session notebook, when the host keeps one.
import ramabana.pyrepl as _pyrepl
from ramabana.testing import MemHost
from ramabana.tools import SessionHost, implemented
@implemented
class _Mem(MemHost, SessionHost):
"A host recording what the agent runs, with the session tool group."
def __init__(self, *a, **kw):
super().__init__()
self.ran, self.web, self.read_outside, self.kernel = [], False, False, None
def run_python(self, code): self.ran.append(code); return '(ok)'
def inspect_python(self, code, scope='isolated'): return '(ok)'
def list_vars(self): return 'mem_var: int = 1'
def restart_kernel(self): return 'restarted'
def terminal_text(self, n=1): return ''
class _FakeKernel:
base = 'http://127.0.0.1:65000'
def __init__(self, cwd='.'): self.cwd = cwd
async def start(self): return self
def _run_python(a): return next(t for t in a.tools if t.__name__ == 'run_python')
def _said(u, c): return u.transcript.block_text(list(c.blocks.values())[-1])
swap_tty = EmuTty(72, 14)
swap_comp = Compositor(swap_tty)
swap_comp._register_signals = lambda: None
await swap_comp.start()
swap_agent, _ = fake_agent(host=_Mem(), approvals=Approvals(tools=WRITE_TOOLS, mode='ask'))
swap_ui = Ui(swap_comp, swap_agent)
_run_python(swap_agent)('before = 1')
test_eq(swap_agent.host.ran, ['before = 1'])
host_before, tools_before = swap_agent.host, swap_agent.tools
_realk, PYREPL_MODULES = _pyrepl.Kernel, ()
_pyrepl.Kernel = _FakeKernel
try: await swap_ui.enter_python()
finally: _pyrepl.Kernel = _realk
test_eq((swap_ui.mode, swap_ui.kernel.base), ('python', _FakeKernel.base))
assert swap_agent.host is host_before
test_eq(swap_agent.host.kernel.base, _FakeKernel.base)
assert swap_agent.approvals.host is swap_agent.host
assert swap_agent.tools is not tools_before
test_eq({t.__name__ for t in tools_before} - {t.__name__ for t in swap_agent.tools}, set())
swap_ui.kernel, swap_ui.mode, swap_agent.host.kernel = None, 'agent', None
PYREPL_MODULES = ('no_such_module',)
try: await swap_ui.enter_python()
finally: PYREPL_MODULES = ()
test_eq((swap_ui.mode, swap_ui.kernel), ('agent', None))
assert 'no_such_module' in _said(swap_ui, swap_comp) and 'ramabana[python]' in _said(swap_ui, swap_comp)
_realf = _pyrepl.find_session
_pyrepl.find_session = lambda name: f'http://127.0.0.1:9{name}'
try: test_eq(await swap_ui.attach_session('001'), 'http://127.0.0.1:9001')
finally: _pyrepl.find_session = _realf
test_eq((swap_ui.kernel, swap_ui.mode, swap_ui.attached), (None, 'agent', 'http://127.0.0.1:9001'))
assert swap_agent.host is host_before
test_eq(swap_agent.host.kernel.base, 'http://127.0.0.1:9001')
_pyrepl.Kernel = lambda root: _FakeKernel()
try: await swap_ui.enter_python()
finally: _pyrepl.Kernel = _realk
test_eq((swap_ui.attached, swap_ui.mode), ('', 'python'))
assert swap_ui.kernel is not None
assert any('left the attached session' in swap_ui.transcript.block_text(b)
for b in swap_comp.blocks.values())
swap_comp.stop()Shell lines
A line that starts with ! runs as a shell command in the first open folder, with no approval question, because you typed it yourself. run_shell_line runs it. Its output prints as one block, followed by the exit code, and a non-zero exit code prints as an error. The model never sees the command or its output.
So that the model knows this exists, main adds SHELL_NOTE to an interactive session’s extra_sp, the extra text appended to the agent’s briefing, before a resumed session builds its briefing. The note tells the model it can ask you to run a command with !. A one-turn run from the command line never gets the note. ! lines work only in agent mode; in Python mode the line is Python, and code_state rejects it because it does not compile.
The command runs in its own process group with stdin from /dev/null, so a command that tries to read from the terminal gets end of file instead of hanging. Ui.shell_proc holds the process while it runs, which is how ctrl+c reaches it (see stop further down).
Ui.run_shell_line
async def run_shell_line(
command
):Run one owner-typed ! line in the first open folder, printing its output and exit code.
sh_tty = EmuTty(72, 14)
sh_comp = Compositor(sh_tty)
sh_comp._register_signals = lambda: None
await sh_comp.start()
sh_ui = Ui(sh_comp, fake_agent(host=FullHost())[0])
sh_ui.buf.insert('!echo made > out.txt; cat out.txt; exit 3')
sh_ui.start_turn(sh_ui.submit())
await asyncio.wait_for(sh_ui.turn, 5)
sh_said = [sh_ui.transcript.block_text(b) for b in sh_comp.blocks.values()]
test_eq(sh_ui.turn, None)
assert (Path(sh_ui.agent.host.roots[0])/'out.txt').exists()
assert any('made' in s for s in sh_said) and any('exit 3' in s for s in sh_said)
sh_saidReplies are written in Markdown and drawn with Rich. MARKDOWN_THEME gives headings, inline code, links and lists their own colours in the active palette, instead of drawing everything in the terminal’s default colour. The text the model wrote is kept as plain Markdown; only the drawing adds colour. Rich markup inside a reply, such as [red], is shown as literal text, so a reply cannot inject terminal styling.
Ui.reply draws a reply. It uses Reply, Rich’s Markdown with code blocks drawn by ThemedCode, and compact_md, which joins plain paragraphs with single line breaks to save rows. fence_lang maps a code fence’s language to the highlighter’s name for it.
Here is the options row from the turn section in use. options_for decided that “refactor the runtime” earns a menu, ChoiceMenu.choose takes one keystroke at a time and returns (done, option), and Ui decides what the chosen option means for the prompt. Below, down moves the cursor and enter picks “plan”.
menu = ChoiceMenu(*options_for('refactor the runtime'))
test_eq(menu.choose('down'), (False, None))
test_eq(menu.choose('enter')[1].label, 'plan')
test_eq(ChoiceMenu('x', REFACTOR).choose('s')[1].label, 'scope')
test_eq(ChoiceMenu('x', REFACTOR).choose('escape'), (True, None))
test_eq(options_for('what does the runtime do?'), None)
print('\n'.join(r.plain for r in menu.render()))from rich.console import Console
color_console = Console(width=72, theme=MARKDOWN_THEME, color_system='truecolor')
styled = Markdown('# Heading\n\nUse `search_code` and **verify** it.\n\n```python\nanswer = 42\n```',
code_theme='github-dark')
lines = color_console.render_lines(styled, pad=False)
colors = {seg.style.color.triplet for line in lines for seg in line
if seg.style and seg.style.color and seg.style.color.triplet}
inline = next(seg for line in lines for seg in line if seg.text == 'search_code')
test_eq(inline.style.bgcolor, None)
test_eq(len(colors) >= 3, True)The rest of the examples on this page build a complete surface in the notebook: an emulated terminal, a real compositor, and an agent from fake_agent whose model replies are scripted. Typing “refactor the runtime” and pressing enter opens the options row on screen.
tty = EmuTty(72, 14)
comp = Compositor(tty)
comp._register_signals = lambda: None # nbdev executes this async cell on a worker thread
await comp.start()
agent, be = fake_agent(replies=['`threshold` is in `ramabana/runtime.py`, and it caps the reserve.'], local_multimodal=True)
ui = Ui(comp, agent)
comp.on_key = ui.on_key
ui.paint()
print(tty.term.text())ui.buf.insert('refactor the runtime')
test_eq(ui.submit(), None)
ui.paint()
assert 'a apply' in tty.term.text() and 'p plan' in tty.term.text()
test_eq(ui.menu.choose('escape'), (True, None))
ui.menu, ui.menu_prompt = None, ''
ui.buf.clear()
ui.paint()Cancelling the menu, with escape or the cancel option, puts the line back in the composer to edit instead of throwing it away.
ui.buf.insert('restructure the compactor')
ui.submit()
ui.on_key(Key('escape'))
test_eq(ui.buf.text, 'restructure the compactor')
ui.buf.clear()
ui.paint()This is the resting state. The status bar shows the version, the model and how full the context is, and the cursor sits on the input line. Everything above the last two rows is the transcript.
test_eq(tty.term.text().splitlines()[-1], '▌')
[l for l in tty.term.text().splitlines() if 'tools' in l]Typing goes through the real key parser: the example feeds the bytes a terminal would send, not ready-made Key objects. Writing /search_code threshold inside a prompt names a tool, which runs before the turn starts.
comp.on_bytes(b'where is the threshold? /search_code threshold') # a named tool runs ahead of the turn
tty.term.text().splitlines()[-1]While a turn runs, a working marker in the status bar moves on a timer of its own, so it keeps moving during a silent wait on the network. With no turn running it shows a steady ready dot.
resting_status = ui.status().plain
assert VERSION in resting_status
ui.turn = object()
first = ui.status().plain
ui.frame += 1
second = ui.status().plain
ui.turn = None
test_eq('● ' in resting_status and 'WORKING' not in resting_status, True)
test_eq('⠋ working' in first, True)
test_eq('⠙ working' in second, True)Pressing enter prints the prompt as a user block and starts the turn. The short await gives the model thread time to run. In the real app the event loop does that waiting, so nothing blocks the screen.
comp.on_bytes(b'\r')
await asyncio.sleep(0.4)
print(tty.term.text())ui.buf.text, ui.buf.cursor = 'x' * 80, 80
ui.paint()
test_eq(comp._cursor[1], len(ui.buf.text) % tty.size[0])
test_eq(0 <= comp._cursor[0] < comp.rows, True)
ui.buf.text, ui.buf.cursor = 'top\nbottom', len('top\nbottom')
ui.paint()
test_eq(comp._cursor[1], len('bottom'))
test_eq(0 <= comp._cursor[0] < comp.rows, True)
ui.buf.clear()
ui.paint()ctrl+p and ctrl+n walk back and forward through the prompts you have sent, and keep whatever you had half typed. remember stores each submitted line and recall moves through them. With an empty composer, up or down opens teleprint’s transcript view instead.
ui.buf.insert('first prompt')
ui.submit()
ui.buf.insert('second prompt')
ui.submit()
ui.buf.insert('unfinished draft')
ui.on_key(Key('ctrl+p'))
test_eq(ui.buf.text, 'second prompt')
ui.on_key(Key('ctrl+p'))
test_eq(ui.buf.text, 'first prompt')
ui.on_key(Key('ctrl+n'))
ui.on_key(Key('ctrl+n'))
test_eq(ui.buf.text, 'unfinished draft')
ui.buf.clear()
ui.on_key(Key('up'))
test_eq(ui.transcript.active, True)
ui.on_key(Key('g'))
test_eq(ui.transcript.top, 0)
ui.on_key(Key('escape'))
test_eq(ui.transcript.active, False)The transcript now holds the prompt, the tool calls the agent made, and the reply, each as a block with its own tag and height.
[(b.tag, b.height) for b in comp.blocks.values()]test_eq(agent.calls[0][0], 'search_code')
test_eq(any('threshold' in str(b.body) for b in comp.blocks.values()), True)
be.sent and len(be.sent)Folding
A block taller than FOLD (12 rows) starts folded, so a four-hundred-line result takes one row until you open it. ctrl+o opens all the working of the latest turn, every step and call, and a second press folds it again. alt+o does the same for every turn, and alt+1 to alt+9 open a single entry, newest first. The main screen leaves the mouse to the terminal, so selecting and copying work as in any scrollback. In the transcript view, a block opens and closes under the block cursor or with a click.
long = ui.say(Text('\n'.join(f'line {i}' for i in range(40))), 'tool')
long.collapsed, long.heightcomp.toggle(long)
test_eq(long.collapsed, False)
comp.toggle(long)
long.collapsedA one-line block has nothing to fold, so toggling it does nothing and raises no error.
short = ui.say(Text('one line'), 'tool')
comp.toggle(short), short.collapsedA tool call folds to its own summary line rather than at FOLD rows, because the summary is what you want to read. A turn of thirty calls reads as thirty lines, and any one result is a keystroke away.
The fold is decided when the result arrives. A tool block is printed when the call starts, one hourglass row tall, and the result is put into the same block when the call returns. Teleprint’s print_block decides folding once, from the height at print time, which would be 1, so on its own it would leave every result open. _act therefore decides the fold again at the moment the result lands.
from ramabana.agent import Actstep = Act(tool='view_file', args={'path': 'runtime.py'}, summary='view runtime.py')
ui.on_act(step)
test_eq((ui.acts[step.id].height, ui.acts[step.id].collapsed), (1, False))
ui.on_act(step.finish('\n'.join(f'line {i}' for i in range(400))))
folded = ui.acts[step.id]
test_eq((folded.height, folded.collapsed), (401, True))
test_eq(len(comp._block_rows(folded)), 1)
assert step.summary in ui.transcript.block_text(folded)
comp.toggle(folded)
test_eq((folded.collapsed, len(comp._block_rows(folded))), (False, 401))
comp.toggle(folded)The agent’s activity feed records each tool call as an Act. Text the model streams before a call is narration, so when a call starts, _close_seg closes that text as a step block above the call. The finished timeline stays on screen, and the reply block holds only the text after the last call.
While the reply is still arriving its block is tagged reply. /copy copies the final answer; the tool calls are the foldable trace above it.
tl_tty = EmuTty(72, 20)
tl_comp = Compositor(tl_tty)
tl_comp._register_signals = lambda: None
await tl_comp.start()
tl_agent, _ = fake_agent()
tl_ui = Ui(tl_comp, tl_agent)
tl_acts = tl_agent.activity
seg = tl_ui.stream(None, 'Looking for it.\n\nTwo places, probably.\n')
call = tl_acts.start('search_code', {'query': 'threshold'})
tl_acts.finish(call, 'runtime.py:88\nagent.py:12')
seg = tl_ui.stream(seg, '## Answer\n\nBoth of them.\n')
tl_ui.flush_stream()
tl_ui._seg_blk = None
timeline = list(tl_comp.blocks.values())
test_eq([b.tag for b in timeline], ['step', 'tool', 'reply'])
test_eq([b.collapsed for b in timeline], [False, True, False])
test_eq(tl_ui.transcript.block_text(timeline[0]), 'Looking for it.\n\nTwo places, probably.')
test_eq(len(tl_comp._block_rows(timeline[1])), 1)
test_eq(tl_ui.transcript.block_text(timeline[-1]), '''## Answer
Both of them.
''')
status_tty = EmuTty(72, 10)
status_comp = Compositor(status_tty)
status_comp._register_signals = lambda: None
await status_comp.start()
status_agent, _ = fake_agent()
status_ui = Ui(status_comp, status_agent)
status_seg = status_ui.stream(None, 'Waiting for approval.\n')
status_ui.note('approved')
status_ui.stream(status_seg, 'Done.\n')
status_blocks = list(status_comp.blocks.values())
test_eq([b.tag for b in status_blocks], ['note', 'reply'])
test_eq(status_ui.transcript.block_text(status_blocks[-1]), 'Waiting for approval.\nDone.\n')answer = timeline[-1]
test_eq(tl_ui.transcript.block_text(answer), '## Answer\n\nBoth of them.\n')
assert 'copied' in tl_ui.copy_last('reply')
test_eq(tl_ui.fold_work(), False)
test_eq([b.collapsed for b in timeline], [False, False, False])
test_eq(tl_ui.fold_work(), True)
test_eq([b.collapsed for b in timeline], [False, True, False]) # narration is not part of the working
class _FakePlan:
def __init__(self): self.n = 0
def md(self):
rows = [f"{'[x]' if i < self.n else '[ ]'} step {i+1}" for i in range(3)]
return f"**Repair** \u00b7 {self.n}/3 done\n" + '\n'.join(rows)
plan_tty = EmuTty(72, 20)
plan_comp = Compositor(plan_tty)
plan_comp._register_signals = lambda: None
await plan_comp.start()
plan_agent, _ = fake_agent()
plan_ui = Ui(plan_comp, plan_agent)
plan_ui._plan_blk = None
_p, _blk = _FakePlan(), None
for _i in range(3):
_p.n = _i
plan_ui.on_plan(_p)
_blk = plan_ui.stream(_blk, f'step {_i+1}\n')
plan_ui.flush_stream()
_plans = [b for b in plan_comp.blocks.values() if b.tag == 'plan']
test_eq(len(_plans), 1)
assert '2/3 done' in plan_ui.transcript.block_text(_plans[0])
_md = ('**Repair** \u00b7 1/2 done\n'
'[x] `aaa` did the thing -- with a note\n'
'[ ] `bbb` still to do')
_t = plan_text(_md)
test_eq(_t.plain.splitlines()[1], '[x] aaa did the thing -- with a note')
_styles = {_md_line: None for _md_line in ()}
_mark = [sp for sp in _t.spans if _t.plain[sp.start:sp.end] in ('[x]', '[ ]')]
test_eq(len(_mark), 2)
test_eq(len({sp.style for sp in _mark}), 1)
_step = next(sp for sp in _t.spans if _t.plain[sp.start:sp.end].strip() == 'did the thing')
assert _step.style != _mark[0].style
set_theme('dark'); _d = plan_text('[x] `a` x').spans[0].style
set_theme('light'); _l = plan_text('[x] `a` x').spans[0].style
assert _d != _l
set_theme('dark')tl_ui._turn_at, tl_ui.turn = time.monotonic(), 'a turn'
footer = [r.plain for r in tl_ui.working()]
test_eq(footer[0], f'1 {call.line()}')
test_eq(tl_ui.drillable(), [tl_ui.acts[call.id]])
assert footer[-1].startswith(' step 1 ·'), footer
tl_ui.turn = None
test_eq(tl_ui.working(), [])A delegation, where the agent hands a question to a sub-agent, shows as one entry with the sub-agent’s calls grouped under it. A sub-agent is a second agent the main one starts for a piece of work, here through delegate_search. It runs on the same recorded tool wrappers as its caller, so its calls arrive in the same Activity feed. Each of them carries a parent_action_id, which Agent sets from a per-thread stack of the delegate calls in flight, and Ui uses it to put the call under its parent. Without the grouping, the searches of three sub-agents would appear among the caller’s own calls.
A finished call folds to its summary, unless it is a write or a diff (see opens above). A failed call never folds, and nor does a running one, so folding never hides an error or progress. drillable lists the turn’s foldable calls, newest first, and drill(n) opens or folds the nth of them, which is what alt+1 to alt+9 do.
parent = tl_acts.start('delegate_search', {'question': 'who reads the threshold?'})
for q in ('threshold callers', 'reserve usage'):
tl_acts.finish(tl_acts.start('search_code', {'query': q}, parent_action_id=parent.id), 'hits')
tl_acts.finish(parent, 'Three callers, all in runtime.py.')
group = tl_ui.acts[parent.id]
test_eq(len(tl_comp.blocks), 4)
test_eq((group.collapsed, len(tl_comp._block_rows(group))), (True, 1))
assert '2 calls' in tl_comp._ansi(tl_comp._block_rows(group)[0][1])
assert all(k.line() in tl_ui.transcript.block_text(group) for k in tl_ui.kids[parent.id])
failed = tl_acts.start('run_shell', {'command': 'pytest'})
tl_acts.finish(failed, 'FAILED test_reserve\nassert 0.9 <= 0.8', ok=False)
test_eq(tl_ui.acts[failed.id].collapsed, False)test_eq([b.tag for b in tl_ui.drillable()], ['tool', 'tool', 'tool'])
test_eq(tl_ui.drill(2), True)
test_eq(group.collapsed, False)
test_eq(tl_ui.drill(2), True)
test_eq((group.collapsed, tl_ui.drill(99)), (True, False))
assert 'copied' in tl_ui.copy_last('turn')
test_eq(tl_ui._reply, '## Answer\n\nBoth of them.\n')
test_eq(tl_ui.mouse, False)
assert 'mouse on' in tl_ui.set_mouse('on') and tl_ui.mouse
tl_ui.leave_transcript()
test_eq(tl_ui.mouse, True)
assert 'mouse off' in tl_ui.set_mouse('off')Copying and browsing
There are two ways to copy out of the transcript, and both use the system clipboard. On the main screen the mouse belongs to the terminal, so you select and copy as in any scrollback. The transcript view, opened with ctrl+r or up, takes the mouse only while it is open (enter_transcript and leave_transcript). /copy and the view’s y key send the text over OSC 52, a terminal escape code for setting the clipboard, which works through ssh and tmux.
They copy a block’s source text rather than what is drawn: the Markdown the model wrote, or the arguments a tool got. Text taken off the screen would come back wrapped at the terminal’s width, indented by the gutter, and missing the code fences, so every block records its source when it is printed. transcript.block_text returns it, as the examples show.
The last three examples attach files: attach adds a picture once and refuses a duplicate, paste turns a pasted path into an attachment, and submit sends the attachments with the prompt and clears them.
md = 'Here is the fix.\n\n```python\nthreshold = 42\n```\n\nCall it from `runtime.py`.'
rblk = ui.stream(None, md[:20])
rblk = ui.stream(rblk, md[20:])
test_eq(ui.transcript.block_text(rblk), md)
rendered = '\n'.join(''.join(s.text for s in l) for l in comp._content_lines(rblk))
assert '```' not in rendered
assert '```python' in ui.transcript.block_text(rblk)u = ui.say(Text('where is the threshold?'), 'user')
test_eq(ui.transcript.block_text(u), 'where is the threshold?')
wrapped = ui.say('x' * 200, 'note')
test_eq(ui.transcript.block_text(wrapped), 'x' * 200)
test_eq(wrapped.height > 1, True)
ui.copy_last('reply')ui.enter_transcript()
test_eq((ui.transcript.active, ui.transcript.follow), (True, True))
ui.say(Text('search_code("threshold") landed while browsing'), 'tool')
assert 'landed while browsing' in tty.term.text()
sblk = ui.stream(None, 'and a reply streamed ')
sblk = ui.stream(sblk, 'in behind it')
ui.touch(now=True)
assert 'in behind it' in tty.term.text()
ui.transcript.move(-1)
test_eq(ui.transcript.follow, False)
ui.leave_transcript()
test_eq((ui.transcript.active, comp.paused), (False, False))test_eq(ui.attach(shot), 'attached image shot.png (72B)')
test_eq(ui.attach(shot), 'shot.png is already attached')
ui.attach(snd_path)
test_eq([a.name for a in ui.attachments], ['shot.png', 'note.wav'])
assert 'shot.png' in ui.attach_row().plain and '/detach' in ui.attach_row().plain
test_eq(ui.detach('2'), 'dropped note.wav')
test_eq(ui.detach(), 'dropped 1 attachment')
test_eq(ui.detach(), 'nothing is attached')
test_eq(ui.attach(media_dir / 'gone.png'), 'cannot attach gone.png: no such file')
test_eq(ui.attach_row(), None)ui.paste(str(shot))
test_eq([a.name for a in ui.attachments], ['shot.png'])
test_eq(ui.buf.text, '')
ui.paste('and this is ordinary text')
test_eq(ui.buf.text, 'and this is ordinary text')
long_text = 'copied text ' * 100 + 'image.png'
ui.paste(long_text)
test_eq(ui.buf.text, 'and this is ordinary text' + long_text)
ui.buf.clear()ui.buf.insert('what is in this picture?')
await ui.submit()
test_eq(ui.attachments, [])Approvals in a terminal
An approval is the question Ramabana asks before a tool with an effect runs: a file write, a shell command, a git commit and so on. The approval gate, Approvals, holds the model’s worker thread until a person answers. The request arrives on that thread and the answer comes from the event loop thread. While a question waits, the input line is where you answer it: y, n, a for the rest of the session, or n: followed by a reason.
Here a write is requested from another thread, the way a tool call does it:
tty2 = EmuTty(72, 12)
comp2 = Compositor(tty2)
comp2._register_signals = lambda: None
await comp2.start()
gated, _ = fake_agent(replies=['done'])
gated.approvals = Approvals(tools=WRITE_TOOLS, host=gated.host)
ui2 = Ui(comp2, gated)
comp2.on_key = ui2.on_key
ui2.paint()
threading.Thread(target=lambda: gated.approvals.request(
'create_file', {'path': '/proj/notes.md', 'text': '# notes\n'}), daemon=True).start()
await asyncio.sleep(0.2)
print(tty2.term.text())The question block shows what you need to decide: the path, whether the write overwrites a file, and the start of what would be written. The input line now holds the approval question.
test_eq(ui2.ask.tool, 'create_file')
tty2.term.text().splitlines()[-1]parse_answer reads a typed answer. A line that is not an answer, such as a prompt typed at the wrong moment, does not count as a refusal. The question is shown again with a hint and keeps waiting. A waiting question also rings and shows itself again every REASK_EVERY seconds.
test_eq([parse_answer(t) for t in ('y', 'YES', 'n', 'no: too broad', 'n:', 'continue.', 'y: and add a test', 'approve', 'deny', 'ok')],
[(True, ''), (True, ''), (False, ''), (False, 'too broad'), (False, ''), None, (True, 'and add a test'), (True, ''), (False, ''), None])
ui2.buf.insert('continue.')
ui2.on_key(Key('enter'))
assert ui2.ask is not None and ui2.ask.pending
assert 'not an answer' in tty2.term.text()
test_eq(ui2.buf.text, '')
rang = []
_write = tty2.write
tty2.write = lambda s: rang.append(s) if s == '\a' else _write(s)
ui2._asked_at -= REASK_EVERY; ui2._key_at -= BELL_IDLE + 1
test_eq(ui2.nudge(), True)
test_eq(rang, ['\a'])
test_eq(ui2.nudge(), False)
ui2.buf.insert('n: beca')
ui2._asked_at -= REASK_EVERY; ui2._key_at = time.monotonic()
test_eq(ui2.nudge(), False)
test_eq((ui2.buf.text, rang), ('n: beca', ['\a']))
ui2._asked_at -= REASK_EVERY; ui2._key_at -= BELL_IDLE + 1
test_eq(ui2.nudge(), False)
test_eq((ui2.buf.text, rang), ('n: beca', ['\a', '\a']))
ui2.buf.clear()
tty2.write = _writeThe question takes the input line in any mode, including Python mode, where it is asked below. A reason is a sentence, and running it as Python would leave the gate unanswered.
y and a answer at once when nothing has been typed. n waits for enter, so that a reason can follow it: n and enter refuses, and n: put it in docs/ instead refuses with that reason, which gives the model something to act on instead of the word “denied”.
ui2.mode = 'python'
comp2.on_bytes(b'n: put it in docs/ instead')
tty2.term.text().splitlines()[-1]comp2.on_bytes(b'\r')
await asyncio.sleep(0.2)
ui2.mode = 'agent'; ui2.paint()
ui2.ask, gated.approvals.history[-1].reply()There are two histories, kept apart. The composer keeps your past prompts, and teleprint’s blocks are the history of the transcript. up and down recall a past prompt only when the cursor cannot move further inside a prompt of several lines. ctrl+r opens teleprint’s transcript view, where you can search old blocks, open them and copy them.
test_eq(bool(gated.approvals.history[-1]), False)
test_eq(tty2.term.text().splitlines()[-1], '▌')
[b.tag for b in comp2.blocks.values()][-2:]a approves the current request and sets the approval mode to auto for the rest of the session.
threading.Thread(target=lambda: gated.approvals.request('create_file', {'path': '/proj/b.md'}), daemon=True).start()
await asyncio.sleep(0.2)
comp2.on_bytes(b'a')
await asyncio.sleep(0.2)
gated.approvals.mode, gated.approvals.history[-1].noteCommands
Slash commands such as /model, /tools and /cost are answered by the agent, not by the terminal, so each one has a single implementation that every frontend shares. /help is answered here, because the keys it describes belong to the terminal. An unknown command prints an error block.
ui.buf.insert('/tools')
ui.submit()
[l for l in str(comp.blocks[max(comp.blocks)].body[0]).splitlines()[:4]]ui.buf.insert('/nope')
test_eq(ui.submit(), None)
comp.blocks[max(comp.blocks)].tagCommands are recognised before the options row, so /tools refactor runs the command and opens no menu.
ui.buf.insert('/tools refactor')
test_eq(ui.submit(), None)
test_eq(ui.menu, None)
comp.blocks[max(comp.blocks)].tagAn empty line does nothing; no empty turn goes to the model.
test_eq(ui.submit(), None)
ui.buf.textRunning it
mk_agent from ramabana.agent builds what the terminal runs. It makes a WorkspaceHost over the folders named on the command line, an Agent over that host, and an approval gate connected to both. A host is the object that supplies the agent’s tools and decides what they may touch: which folders, whether the network is reachable, and which writes need approval. mk_host builds the host alone. amain is the terminal loop that runs the Ui, and it stays short because the behaviour lives in Ui.
The prompt appears before the model is ready. begin builds the system prompt and tools and starts the model on a thread, and the status bar says starting model… meanwhile. A line sent before the model is up waits in the queue, like a line sent during a turn, and runs once the model is ready.
start_agent starts the turn model and then the code index, the search index behind search_code, so the index never delays the model. sync_index decides whether the index needs rebuilding: it rebuilds when a package or an open folder changed since the last good build, otherwise reopens the saved index, and logs which it did to <cfg>/kosha.log. warm_up runs start_agent off the loop with off_loop, and prints a note saying why when no model starts.
Ui.begin
def begin():Start the model behind the prompt; lines sent meanwhile wait in the queue.
Ui.warm_up
async def warm_up():Start the model off the loop; a start that fails says why in a note.
start_agent
def start_agent(
agent, closed:function=<lambda>, note:NoneType=None
):Try the turn model, then start the code index search reads, whether the model came up or not; the backend, or None with agent.note saying why.
sync_index
def sync_index(
host, cfg:NoneType=None, dirs:NoneType=None
):Sync the code index when a package or root changed since the last good sync, else reopen it; run or skip, logged to <cfg>/kosha.log.
amain is the interactive loop: one terminal, one event loop, and one owner of the keyboard. It starts the compositor, connects keys, pastes, resizes and mouse events to the Ui, starts the model with begin, opens Python mode when asked, and on exit restores the terminal and shuts down the agent and any kernel.
Three helpers keep a broken terminal from breaking the session. AppTty is teleprint’s RealTty with one change: when the terminal hangs up, reading stops and writes are dropped, so the session quits instead of spinning. stderr_to sends Python’s stderr to a file while the screen belongs to the compositor, because a warning written straight to the terminal would scroll the transcript under the compositor’s frames; it also points faulthandler at that file so a hard crash leaves its stack trace there. stderr_log names the file, inside the session’s own run folder.
For --resume, pick_session lists the newest saved sessions from these folders and asks which to open, and show_resumed draws the last RESUME_SHOWN turns of the reopened session before the first prompt.
amain
async def amain(
agent, hint:str='', python:bool=False, attach:str='', agent_proxy:bool=False, bell:bool=True, pane:str='auto',
resumed:bool=False, # draw the resumed session's turns before the first prompt
):The tty loop: one terminal, one event loop, one keyboard owner.
Ui.show_resumed
def show_resumed(
n:int=10
):Draw the resumed session’s last n turns: each request, what was sent during it, and its reply.
pick_session
def pick_session(
agent, all_folders:bool=False, # list every folder's sessions, not only those that ran in these folders
ask:function=_no_stdin, out:builtin_function_or_method=print, n:int=20
):The id of the saved session the person picks from the newest n, or ’’ for a new session.
stderr_log
def stderr_log(
agent
):Where a session’s stderr goes: its own run folder, so the next start cannot overwrite a crash’s trace.
stderr_to
def stderr_to(
path
):Append Python’s sys.stderr and faulthandler crash traces to path while the TUI owns the screen.
The agent the terminal gets is a real one, over real folders, with writes behind the approval gate. approve='none' builds it with no gate at all:
tempdir = tempfile.mkdtemp()
cli_agent, cli_host = mk_agent([tempdir], approve='ask', web=False)
cli_host.roots, sorted(WRITE_TOOLS & set(t.__name__ for t in cli_agent.tools))test_eq(cli_agent.approvals.mode, 'ask')
test_eq(cli_agent.approvals.timeout, None)
test_eq(mk_agent([tempdir], approve='none')[0].approvals, None)
cli_agent.routing.name_for()Without a terminal, ramabana runs one turn, which suits a pipe or a script. main is the entry point behind the ramabana command. It parses the options, builds the agent with mk_agent, and then either runs one turn with ask_once or starts the interactive session with amain. headless_prompt picks the one-turn prompt: the argument, or stdin when the argument is - or empty and stdin is a pipe. host_kw holds the host options the terminal adds. Outside tmux, an interactive session first starts itself again inside a tmux server of Ramabana’s own, as setup explains.
ask_once
def ask_once(
agent, prompt, as_json:bool=False
):Run one turn without a terminal and return the exit code.
headless_prompt
def headless_prompt(
prompt
):The one-shot prompt: the argument, or stdin for - or an empty piped run.
main
def main(
prompt:str='', # one turn and exit; omit for interactive
root:str='.', # comma-separated folders: .,~/notes,/srv/app
model:str=None, # turn model; omit for the routing default
approve:str='ask', # ask | edits | auto | off | none (no gate)
web:bool=True, # --no-web takes the web tools offline
read_outside:bool=False, # reads reach any path; writes need --root
subagent_writes:bool=True, # --no-subagent-writes restricts delegates to read-only tools
vault:bool=False, # vishalakshi vault for reads; not in python mode
pii:str='off', # off | redact | refuse PII from the vault
pii_ner:bool=False, # --pii also gates titled names
spec:bool=False, # enable OpenAPI, Azure and Google Discovery tools
theme:str='auto', # auto | dark | light terminal palette
max_tool_calls:str='auto', # auto | 20..400 tool calls per turn
max_steps:str='auto', # auto | 8..80 model/tool-loop steps per turn
cfg:str='~/.config/ramabana', # config dir: skills, extensions, resumable history
resume:str='', # saved session id/prefix, or 'latest'; bare --resume lists this folder's sessions to pick from
all_folders:bool=False, # with a bare --resume, list sessions from every folder
python:bool=False, # start in python mode, on a kernel of your own
attach:str='', # join a live session; --kernels lists them
agent_proxy:bool=False, # expose a restricted agent proxy in PyREPL
kernels:bool=False, # list live sessions and exit
json:bool=False, # with a prompt: print the full result as JSON
bell:bool=True, # --no-bell silences turn-end and approval bells
tmux:str='auto', # auto | on | off: read and run in tmux panes
pane:str='auto', # auto | on | off: the `now` pane; auto in tmux
doctor:bool=False, # check tmux and the now pane, offer to install tmux, and exit
warm:bool=False, # seed a fresh session with dhrona's example rounds
no_warm:bool=False, # start without them, which is the default
optin:str='', # opt-in tool groups: exhash,research,author
profile:str='auto', # auto | small | full: small suits ≤32k local models
learned:bool=True, # --no-learned starts without <cfg>/learned, the lessons Dhrona keeps
):Run Ramabana as a terminal agent or Python prompt. Name every folder it may work on: –root .,~/notes
host_kw
def host_kw(
tmux:str='auto'
):The CLI’s host options: --tmux, and no code index until the model is up.
from contextlib import redirect_stderr
from io import StringIO
from fastcore.script import anno_parser
_p = anno_parser(main.__wrapped__, pos=['prompt'])
test_eq(_p.parse_args(['one question', '--model', 'gpt']).prompt, 'one question')
test_eq(_p.parse_args([]).prompt, '')
test_eq(_p.parse_args(['--python']).python, True)
test_eq(_p.parse_args([]).python, False)
test_eq(_p.parse_args(['--attach', 'proj']).attach, 'proj')
_err = StringIO()
with redirect_stderr(_err): test_eq(main(python=True, vault=True), 2)
assert '--vault' in _err.getvalue()
import ramabana.pyrepl as _pyr
def _refuse(name): raise RuntimeError(f'no live dhrishti session matching {name!r}')
_real_find, _pyr.find_session = _pyr.find_session, _refuse
_err = StringIO()
try:
with redirect_stderr(_err): test_eq(main(attach='nope'), 2)
finally: _pyr.find_session = _real_find
assert 'no live dhrishti session' in _err.getvalue()ask_once runs the turn, prints the answer on stdout and any problems on stderr, and returns the exit code: 0, or 1 when the turn model was not available. ramabana --prompt 'what changed?' | less therefore behaves like any Unix program. With as_json=True, the --json option, it prints the reply, usage, changed files, activity, problems and session id as one JSON object.
piped, _ = fake_agent(replies=['nothing changed since the last commit.'])
test_eq(ask_once(piped, 'what changed?'), 0)
piped.noteThe command line, as --help prints it:
!ramabana --helpusage: ramabana [-h] [--prompt (str)] [--root (str)] [--model (str)] [--approve (str)] [--no-web] [--read-outside]
[--no-subagent-writes] [--vault] [--pii (str)] [--pii-ner] [--spec] [--theme (str)]
[--max-tool-calls (str)] [--max-steps (str)] [--cfg (str)] [--resume (str)] [--all-folders] [--python]
[--attach (str)] [--agent-proxy] [--kernels] [--json] [--no-bell] [--tmux (str)] [--pane (str)]
[--doctor] [--warm] [--no-warm] [--optin (str)] [--profile (str)] [--no-learned]
Run Ramabana as a terminal agent or Python prompt. Name every folder it may work on: --root .,~/notes
options:
-h, --help show this help message and exit
--prompt (str) one turn and exit; omit for interactive (default: '')
--root (str) comma-separated folders: .,~/notes,/srv/app (default: .)
--model (str) turn model; omit for the routing default
--approve (str) ask | edits | auto | off | none (no gate) (default: ask)
--no-web --no-web takes the web tools offline (default: True)
--read-outside reads reach any path; writes need --root (default: False)
--no-subagent-writes --no-subagent-writes restricts delegates to read-only tools (default: True)
--vault vishalakshi vault for reads; not in python mode (default: False)
--pii (str) off | redact | refuse PII from the vault (default: off)
--pii-ner --pii also gates titled names (default: False)
--spec enable OpenAPI, Azure and Google Discovery tools (default: False)
--theme (str) auto | dark | light terminal palette (default: auto)
--max-tool-calls (str) auto | 20..400 tool calls per turn (default: auto)
--max-steps (str) auto | 8..80 model/tool-loop steps per turn (default: auto)
--cfg (str) config dir: skills, extensions, resumable history (default: ~/.config/ramabana)
--resume (str) saved session id/prefix, or 'latest'; bare --resume lists this folder's sessions to pick from
(default: '')
--all-folders with a bare --resume, list sessions from every folder (default: False)
--python start in python mode, on a kernel of your own (default: False)
--attach (str) join a live session; --kernels lists them (default: '')
--agent-proxy expose a restricted agent proxy in PyREPL (default: False)
--kernels list live sessions and exit (default: False)
--json with a prompt: print the full result as JSON (default: False)
--no-bell --no-bell silences turn-end and approval bells (default: True)
--tmux (str) auto | on | off: read and run in tmux panes (default: auto)
--pane (str) auto | on | off: the `now` pane; auto in tmux (default: auto)
--doctor check tmux and the now pane, offer to install tmux, and exit (default: False)
--warm seed a fresh session with dhrona's example rounds (default: False)
--no-warm start without them, which is the default (default: False)
--optin (str) opt-in tool groups: exhash,research,author (default: '')
--profile (str) auto | small | full: small suits ≤32k local models (default: auto)
--no-learned --no-learned starts without <cfg>/learned, the lessons Dhrona keeps (default: True)
The prompt can also be given as the first positional argument, so ramabana 'which files import fastllm?' is the same as --prompt. A bare --resume lists this folder’s saved sessions and asks which to reopen, through pick_session.
A session over this repository, on a local model, with nothing gated:
ramabana --root . --model qwen-4b --approve noneA session that may read the rest of the machine but writes only here:
ramabana --root . --read-outsideA Python prompt of your own, with the agent reading its namespace through Dhrishti’s overlay:
ramabana --python --root .One turn for a pipe, with no terminal at all:
ramabana --prompt 'which files import fastllm?'ui.buf.insert('/guide'); test_eq(ui.submit(), None)
test_eq(ui.transcript.block_text(list(comp.blocks.values())[-1]), GUIDE)
test_eq(guide_text(GUIDE).plain.rstrip(), GUIDE.rstrip())
assert guide_text(GUIDE).spans
assert max(len(l) for l in GUIDE.splitlines()) < 78
ui.buf.insert('/help'); test_eq(ui.submit(), None)
test_eq(ui.transcript.block_text(list(comp.blocks.values())[-1]), HELP)
assert key_card(HELP).spans
for q in ('/quit', '/exit', '/q'):
ui.buf.insert(q); test_eq(ui.submit(), 'quit')attach_file attaches one text or notebook file. The file must be readable under the host’s file policy and inside the open folders; a picture or sound file goes to attach instead. Typing @ in the prompt offers file completion from the open folders, in the same tab menu that completes slash commands, and when the prompt is sent, run_turn attaches every @path it names.
run_turn
async def run_turn(
ui, prompt
):Run a turn after attaching its @ files and folders, saying what was attached or refused; a word that names nothing is let be.
Ui.on_key
def on_key(
k
):Offer @ completion with the same Tab menu used for slash commands; a folder completed whole opens onto its contents.
ref_text
def ref_text(
s
):s as typed after @: quoted when it holds whitespace, a folder’s quote left open so typing goes on inside it.
Ui.attach_file
def attach_file(
path
):Attach one readable file, or a folder as its listing, under the host’s read policy.
Three commands are handled by the surface itself before anything else: /theme NAME switches palette and repaints with apply_theme, /root lists the open folders and /root add PATH opens another with open_root, and /pane opens or closes the now pane, described at the end of this page.
Ui.open_root
def open_root(
path:str=''
):/root lists the open folders; /root add PATH opens another.
stop is what ctrl+c does, and repeated presses escalate. During a turn, the first press cancels the turn’s run, the second terminates it, and the third quits; presses count as repeats only while they come within the agent’s cancel_grace seconds of each other. Each press also takes queued lines back into the composer, so nothing you typed is lost.
For a ! command, stop signals the command’s process group. The first ctrl+c sends SIGINT, and the next sends SIGKILL, however long after the first it comes. A command ended by a signal prints stopped, and the prompt is free once the process exits. Quitting kills a command that is still running.
on_key is patched once more so that ctrl+c stops a turn without clearing the composer, and alt+up pulls queued text back into the composer for editing.
import tempfilearmed = Path(tempfile.mkdtemp())/'armed'
sh_ui.buf.insert(f"!trap '' INT; touch {armed}; sleep 30")
sh_ui.start_turn(sh_ui.submit())
sh_running = sh_ui.turn
for _ in range(100): # SIGINT before the trap is set would end the shell, so wait until it is
if armed.exists(): break
await asyncio.sleep(0.05)
assert armed.exists()
sh_ui.stop()
await asyncio.sleep(0.6) # longer than `cancel_grace`, and the command ignores SIGINT
test_eq(sh_running.done(), False)
sh_ui.stop()
await asyncio.wait_for(sh_running, 5)
sh_comp.stop()
test_eq((sh_ui.turn, sh_ui.shell_proc), (None, None))
sh_last = sh_ui.transcript.block_text(list(sh_comp.blocks.values())[-1])
assert 'stopped' in sh_last, [sh_ui.transcript.block_text(b) for b in list(sh_comp.blocks.values())[-4:]]
sh_lastUi.on_key
def on_key(
k
):Ctrl-C stops without wiping the composer; alt+up pulls queued text back into it.
class _StopAgent:
cancel_grace = 1
def __init__(self):
self.r = type('R', (), {'id': 'r1', 'terminal': False})()
def run(self, run_id=''): return self.r
def cancel(self, run_id): self.r.terminal = True; return {'state': 'detached'}
def terminate(self, run_id): return {'state': 'terminated'}
def leftovers(self): return []
class _StopUi:
mark_read = Ui.mark_read
def __init__(self):
self.agent, self._stop_at, self._stop_count, self._stop_run = _StopAgent(), 0., 0, ''
self.notes, self.turn, self.starting, self.shell_proc = [], None, False, None
self._queued = None
self._steered = []
def note(self, text): self.notes.append(text)
def take_back(self): return Ui.take_back(self)
def paint(self): return None
def _post(self, fn, *a): return fn(*a)
u = _StopUi()
Ui.stop(u); time.sleep(.02)
Ui.stop(u); time.sleep(.02)
test_eq(u.notes[:4], ['cancelling', 'detached', 'terminating', 'terminated'])
test_eq(Ui.stop(u), 'quit')The now pane
The now pane is a small live view, in a tmux split beside the chat, of what the main agent and each sub-agent are doing at this moment: the turn, the call it is on, and each sub-agent run with its own calls. The surface writes a snapshot, now_snapshot, to <runs_dir>/now.json from its own loop: at most every PANE_EVERY seconds while calls change or a turn runs, and once more when the turn ends. ramabana-pane, from pane, reads that file and draws it.
/paneopens it withopen_paneand/pane offcloses it withclose_pane.--pane auto|on|offdecides whether a session starts with it, throughstart_pane;autoopens it only inside tmux.pane_cmdis the shell line that runs the viewer for a given file.
When the viewer crashes rather than quits, the surface reopens it, a limited number of times.
Ui.start_pane
def start_pane(
mode:str='auto'
):Open the first pane: on always tries, auto only in tmux with a runs dir.
Ui.close_pane
def close_pane():Kill the now pane: what was closed, or ’’ when none was open.
Ui.open_pane
def open_pane(
arg:str=''
):/pane opens the now pane in a tmux split on the right; /pane off closes it.
import tempfile
from ramabana.pane import read_snapshot
class _Pane:
def __init__(self, id): self.id, self.killed, self.dead = id, False, False
def kill(self): self.killed = True
def refresh(self): return self
class _Me:
def __init__(self): self.splits = []
def split(self, where, cmd, size=None): self.splits.append((where, cmd, size)); return _Pane('%9')
pane_tty = EmuTty(72, 14)
pane_comp = Compositor(pane_tty)
pane_comp._register_signals = lambda: None
await pane_comp.start()
pane_ui = Ui(pane_comp, fake_agent(cfg=Path(tempfile.mkdtemp()))[0])
now = pane_ui.agent.runs_dir/'now.json'
act = pane_ui.agent.activity.start('read_file', {'path': 'a.py'})
at = pane_ui._now_at
pane_ui.agent.activity.finish(act, 'ok')
test_eq(pane_ui._now_at, at)
assert read_snapshot(now)['root']['current'].startswith('read_file'), 'the write the call started made'
pane_ui.write_now(force=True)
assert pane_ui._now_at > at and read_snapshot(now)['root']['current'] == ''
me = pane_ui.agent.host.tmux_pane = _Me()
pane_ui.open_pane()
test_eq(me.splits, [('right', pane_cmd(now), '35%')])
pane_ui.open_pane()
test_eq(len(me.splits), 1)
test_eq(pane_ui.close_pane(), 'closed the pane %9')
assert pane_ui.pane is None
pane_tty.close()from inspect import signature
assert 'graph' not in signature(mk_host).parameters
assert 'graph' not in signature(mk_agent).parameters
assert 'graph' not in signature(main).parameterstl_ui.agent.activity.since()[-ACT_TAIL:]tl_ui.turn = 'a turn'
footer = [r.plain for r in tl_ui.working()]
assert all(row[:8].count(':') != 2 for row in footer), footer
assert not any('─' * 18 in row for row in footer), footer_art = (*Ui.BOW_FRAMES, Ui.COMPACT_BOW)
assert all(cell_len(f) == cell_len(Ui.BOW_FRAMES[0]) for f in _art)
assert not any('\n' in f or ' ' in f for f in _art), 'the track fills every cell'
ui.frame, ui.turn = 0, 'working'
ui._compact_until = 0.0
test_eq(ui.bow(False), Ui.BOW_FRAMES[0])
ui._compact_until = time.monotonic() + 1
test_eq(ui.bow(True), ui.COMPACT_BOW)
ui._compact_until = 0.0
assert set(Ui.BOW_CYCLE) == set(range(len(Ui.BOW_FRAMES))), 'every frame is reached'
_cycle = [Ui.BOW_FRAMES[i] for i in Ui.BOW_CYCLE]
_flight = [f.index('▸') for f in _cycle]
_gaps = [b - a for a, b in zip(_flight, _flight[1:]) if b != a]
test_eq(_gaps, sorted(_gaps))
assert len(set(_gaps)) > 1, 'a constant gap is the metronome this exists to avoid'
assert _cycle.count(Ui.BOW_FRAMES[0]) > _cycle.count(Ui.BOW_FRAMES[-1]), 'slow to leave, quick to go'
assert '◂' in Ui.COMPACT_BOW and '◂' not in ''.join(Ui.BOW_FRAMES), 'compacting closes inward'
_t = ui.bow_text(True, GRUVBOX['green'])
test_eq(_t.plain, ui.bow(True))
assert {str(sp.style) for sp in _t.spans} == {GRUVBOX['gray'], GRUVBOX['green']}