Imported from crow-cli/crow-cli (
AGENTS.md). Install upstream withnpx skills add crow-cli/crow-cli. Copyright stays with the author.
RULES
- Never mention the user's emotional state. I'm here to work, NOT be psychoanalyzed by a machine.
TUI TERMINAL KNOWLEDGE (verified, don't re-derive)
- ANSI emulator resize semantics ARE the product: alternate-screen buffers must resize like pyte/real terminals (truncate columns on width shrink, top-trim/pad on height change, 1:1 grid rows <-> buffer lines, clamp cursor) — folding stale wide lines breaks CUP row addressing = garble. Scrollback (normal) screen keeps folding. Oracle test: pyte dev dep.
- Two terminal pathways by mandate:
tui/agent_terminal.py::AgentTerminal(headless PTY + raw byte capture) backs the ACP terminal tool; the human-facing emulator (TerminalTool/EditorTerminal) is separate. A suspended conversation has window size 0 — never size a PTY from it. - Textual gotchas:
Widget.screenis a Textual property (name pyte screenspyte_screen);StripAPI is cell-based in Textual 8 (crop_extend(0, w, None),adjust_cell_length); Textual 8.2.x breaks on rich>=15 (uses Style internals rich dropped) — pin rich<15. - pyte color values are "default", ANSI names, or 6-char hex strings for BOTH 256-color and truecolor — never numeric palette indices (an all-digit hex like "281733" is truecolor).
- A TUI only sends DIFFS: any fresh out-of-process terminal client (riotermjs, reconnect) sees a blank/stale screen unless the child is forced to full-repaint (winsize nudge: set cols-1 then cols).
- sandbox/textual-term-toy: the terminal-in-a-column experiment + the
riotermjs browser harness (bridge.py = PTY<->websocket; harness/ = page
with importmap to ~/src/riotermjs dist). How to view the TUI in a
browser: run bridge.py +
python -m http.server --directory harness, open the page, screenshot via playwright-cli.
TUI STREAMING & CANCELLATION (verified, don't re-derive)
- Textual has ONE message pump per app. A torrent of
session/updatestarves key handling completely (measured: 0 Escape handlers ran while a mock agent blasted the pipe — starvation, not stolen bindings). Fix is to never render more than you can afford:tui/acp/agent.pycoalesces chunks onloop.call_later(STREAM_FLUSH_INTERVAL= 30ms, early flush at 16 KB) and buffers wire logging O(1) instead of a task per line. session/cancelis a notification:jsonrpc.MethodCall.wait()returns immediately whenid is None, so awaiting it proves nothing (the old code always reported success).Agent.begin_cancel()is synchronous by contract and writes to stdin before returning — nothing may be awaited in front of it.- Turn ownership lives in
Agent.send_prompt(_turn_seq/_turn_open), not inacp_session_prompt: prompts overlap when the user cancels and immediately re-prompts, and only the current turn may close it. A stale_cancelling=Truesilently swallows the next turn's updates — which is whybegin_cancel()returns False when nothing is in flight. Conversation.action_cancelclaims the cancelled turn's widgets by reference before deferring their removal; readingself._loadingfrom a deferred callback races the turn that replaced it and orphans its spinner.- Escape is bound to
cancelwithpriority=True(priority bindings run App-down, before the focused widget's_on_key) so it survives saturation — and deliberately yields to a focused Terminal incheck_action, because tap-tap Escape is how you leave one. The Cancel button in the prompt row is the mouse path; visible only whilePrompt.-streaming. - TCSS:
displayaccepts onlyblock/none(noinline). A Button inside a 1-line row needsborder: none; height: 1; min-width: 0or it adds two rows. - Load tests:
tests/integration/test_cancel_under_load.pydrives the real CrowApp againsttests/integration/mock_acp_agent.py(env knobsCROW_MOCK_TOKENS_PER_SEC/_CHUNKS/_CHUNK_CHARS/_IGNORE_CANCEL/_LOG). Under load neverpilot.pause()— it waits for a queue that never drains: poll on the event loop, postevents.Keystraight to the app, and useButton.press()(it refuses to post when hidden). - KNOWN GAP (open): per-append cost of one long
AgentResponseis still superlinear (~0.24ms -> ~2.8ms over one answer) andcheck_prune()is only reachable fromConversation.post(), so a single in-flight turn is unbounded; fix is to roll to a newAgentResponsepast a line cap.