oppx chat frontend: modules, Aider hooks, context manager, sessions, prefix cache
Part of the developer notes; the index is in CLAUDE.md.
-
Chat frontend (
client/oppx/frontend/: theoppx_chatpackage plusrun.py, embedded withinclude_str!viaagent::FRONTEND): -
Modules, lowest layer first; each imports only from the ones above it (the map is in
oppx_chat/__init__.py):config(environment from oppx),term(output, spinner, Esc),render(streamed answers, diffs),context(ContextManager),mcp(connectors: the MCP client and its tools; no UI imports),agent(the agent engine; no UI imports),oppx_io(Aider’s IO, prompt, keys),routing(ask/edit check),cache(prefix-cache patches),commands(slash commands, memory),app(Aider patches, turn loop,main). A new module must also be listed inagent::FRONTEND; a unit test checks this. -
Engines (
oppx --engine agent|aider,OPPX_ENGINE; defaultagent):- The
agentengine is in theoppx-agentskill, its permission rules inoppx-permissions, and the app-building eval inagent-app-eval. aider: Aider’s own loop, with everything below (context manager, cache patches, ask/edit routing). Slash commands,/undo, sessions, the status bar and Esc work in both.- Measured (RTX 3090, this repo at v0.4.1): a typical Aider request was ~25k tokens, ~60% whole files the model had mentioned and ~30% repo map, with 8–19 s to the first answer and a 42% cache hit rate. The agent’s questions use 2–14k-token requests with 76–93% served from cache, answering in 2–6 s. Edits (
scripts/eval_edits.py, 6 tasks checked by compiling and testing, 3 runs): Qwen2.5-Coder-14B-AWQ agent 18/18, Aider 15/18 (Aider can’t do the multi-file rename); gpt-oss-20b agent 5/6 three times, Aider 4/6. Split Qwen3.6-27B-AWQ (3090 + 4090 laptop, v0.5.0):eval_edits.pyagent 6/6 (24–52 s a task); a 10-turnbench_agent.pysession that fills the window went from 3 failed turns and a 61% cache hit rate to none and 89% with the fixes above.
- The
-
How it runs:
oppxwrites the package to~/.cache/oppx/frontend-<version>-<hash>/and runsrun.pywith the Python of the Aider venv (thepythonnext to the real launcher, or its shebang), soaider,prompt_toolkitandrichare importable. -
How it hooks into Aider: it sets
aider.main.InputOutputto its ownOppxIO, andbase_coder.WaitingSpinnerto itsThinkingspinner. It callsaider.main.main(argv, return_coder=True), then loops overcoder.run_one()itself, handlingSwitchCoderthe way Aider’s main loop does. -
What it changes on screen:
- It hides Aider’s announcements, URLs and
Tokens:lines. - Edit blocks are hidden while they stream, and each turn ends with a Claude-style diff (
⏺ Update(file)plus colored lines). - Edit-retry noise collapses to one line.
- Routine confirmations are auto-accepted. Commands the model proposes, and going over the context window, are asked.
- It hides Aider’s announcements, URLs and
-
Aider version: it relies on Aider internals, so it’s tested with aider-chat 0.86.2 and warns on other versions; installs are pinned to that version. Crashes go to
~/.local/state/oppx/last-crash.txtwith a hint to use--classic. -
Claude Code parity:
- Keys: Ctrl-C clears the line, twice on an empty line exits; Ctrl-D exits; Esc (or Ctrl-C) interrupts the model; Shift+Tab toggles plan mode (
/ask, no edits);\+Enter or Esc-Enter adds a new line;?shows shortcuts. - Input prefixes:
!cmdbecomes/run,@fileadds the file,# noteappends toOPENPHALANX.md. Shell commands run withPAGER/GIT_PAGER=catand without the Esc watcher, so!git logdoesn’t openlessand a command that prompts gets the keys (before, both hung the chat). - Commands:
/clear /compact /cost /context /status /model /init /memory /review /export /doctor /vim /resume /undo /permissions /exit, (/undorestores the files the last editing turn changed, once; nothing is committed), plus Aider’s/add /drop /run /test /lint /web, our/search, and/mcp(connectors: theconnectors-and-browsertopic). Other Claude-only commands report “not available”.
- Keys: Ctrl-C clears the line, twice on an empty line exits; Ctrl-D exits; Esc (or Ctrl-C) interrupts the model; Shift+Tab toggles plan mode (
-
Esc interrupt:
EscWatcherholds the terminal in cbreak mode during a turn and turns a lone Esc into SIGINT. It pauses whenever a question needs an answer. Keystrokes typed during a turn are kept: finished lines are queued as the next messages, and a partial line is pre-filled. -
Typing during a turn is shown (
term.ahead,announce_queued): the terminal doesn’t echo while a turn runs (cbreak mode), so type-ahead worked and showed nothing, which read as “nothing can be typed”. The spinner’s line now ends with what is being typed and a cursor block (✻ Thinking… (2s · esc to interrupt) > and which one is pub▌), cut from the left to fit the width since the line is redrawn in place; with nothing typed it says “type to queue a message”, and with lines waiting “2 queued”. Enter prints “Queued for when this is done: …” into the transcript (at the spinner’s next tick, never into a streaming answer) and the line is sent as the next message when the turn ends. Backspace, Ctrl-U (the line) and Ctrl-W (the last word) work; a pasted character split across two reads is kept. Not shown while the final answer streams (the spinner is off then): what is typed there is still kept and comes back in the next prompt. Seen in a real chat (tmux): the typed text on the spinner line, the queued note, the queued message run next, and a half-typed line pre-filled at the prompt after it. -
Interrupts: Aider’s
keyboard_interrupt(which exits on a double press) is replaced. A mid-stream interrupt is detected from the “I see that you interrupted…” note Aider records; check the coder returned afterSwitchCodertoo. -
Ask/edit routing: each normal message goes through a one-word
ask/editdecision by the server’s model through the proxy (routing.ask_choice). In the agent engine the message is read with the end of the assistant’s last reply and any change still owed (routing.intent_input), and a second one-word check asks whether a reply announced a change (routing.announces_change); see theoppx-agenttopic. It’s one regex-constrained token on regular models (about 40 ms, 16/16 on probes, including text-only requests such as essays). On reasoning models (/v1/inforeasoning, passed asOPPX_REASONING), SGLang applies the regex only after the reasoning, so it’s a short free answer at low effort (chat_template_kwargsreasoning_effort: low,enable_thinking: false) instead, and the first allowed word counts. That scores 16/16 on gpt-oss-20b in about 260 ms. These calls carryoppx_utility: true, so the gateway never routes or searches them. Questions run as/ask, so they can’t edit; a file named in an answer is added to the chat but doesn’t trigger Aider’s “I added these files” follow-up request (it made the model answer twice). Without it, Qwen-14B in diff mode deletedmulwhen asked “what does mul return?”. The gateway skips the web-search router for requests that carryregexorresponse_format. -
Context manager (
oppx_chat/context.py):-
Hooks: it wraps
Coder.format_messages, so every request Aider builds is fitted first. -
Budget:
(context − 4096 reserved for the answer) / factor. The factor covers the gap between Aider’s generic token count and the model’s real tokenizer: 1.10 until calibrated (26,065 tokens at 32k), then the measured ratio × 1.03. After each answer,ContextManager.calibratecounts the request it just sent with the server’s tokenizer (POST /v1/tokenizevia the proxy, in a background thread) and keeps a running average. On Qwen2.5-Coder the ratio is about 1.01, giving 27,541 tokens. -
Making room, cheapest loss first:
- summarize the conversation (
summarizer.summarize_all); - set aside files the model requested that are least recently used;
- halve the repo map down to 1,024 tokens;
- set aside older files you added or that were edited.
Files used in the current turn are never set aside. Set-aside files stay visible as signatures in the repo map.
- summarize the conversation (
-
Repo map: capped at 8,192 tokens (
map_mul_no_files=1). Aider alone lets it grow to about 28k when no files are in the chat, which caused a 46k-token request on this repo. -
File requests: the model’s requests go through
confirm_ask. A single file over 60% of the budget is refused with a reason. -
Calibrating early: an uncalibrated request above 60% of the budget is measured with
/v1/tokenizebefore it’s sent. The 1.10 guess was 20% short on Qwen3.6, so its first large request failed every time and nothing ever calibrated. -
Server refusals: SGLang’s “Input length (N tokens) exceeds the maximum allowed length (M tokens)” (and “longer than the model’s context length”) is classified as a context overflow (
_get_ex_info), not a connection error, which Aider retried for about a minute with nothing on screen. The numbers set the ratio and the server’s limit, andrun_turnrefits and resends once.--allow-auto-truncateis deliberately not used: SGLang would cut the prompt’s start (system prompt, repo map) without telling the agent. -
Final guard:
check_tokensis replaced, so a request is never sent over the limit, and Aider’s “proceed anyway / providers won’t charge” text is suppressed. -
Visibility:
/contextshows the breakdown, and the status bar shows the percentage used.
-
-
Progress display: one
TurnSpinnerruns from Enter to the end of the turn. It’s paused by any output (out()and the spinner shareOUT_LOCK) and restarted on each wait; Aider’sWaitingSpinneris mapped onto it.Coder.show_prettyis forced to True. Aider disables streaming and its spinner whenever it picks a non-```fence (any Markdown file with code blocks in the chat), which looked like a freeze. The edit-block hider understands every fence Aider may pick:```, four backticks, and<source>,<code>and<pre>tags. -
Editing safeguards:
- Aider’s
--no-suggest-shell-commandsis always set. Aider otherwise offers to run anybashblock in a reply, even quoted file content. - A change request that yields no edit gets one model-agnostic retry in
wholeformat (_whole_file_retry; small files only, output hidden, diff shown), then a “No changes were made” notice. - The content before an edit comes from
OppxIO.write_text.
- Aider’s
-
Questions on strict models: questions reuse the edit prompts (for the cache), so
cache.QUESTION_RULEadds a question exception to the system prompt and reminder. gpt-oss ranks system rules over the note in the message and otherwise answered questions with edit blocks. Aider’s few-shot examples go in the system prompt (examples_as_sys_msg); sent as turns, gpt-oss took them for the real conversation. -
Server errors (
oppx_io.server_problem): litellm’s errors and Aider’s provider hints (“Check your API key”) become one plain message per turn: revoked (pair again), starting up or busy, unreachable, or certificate changed. A refused turn skips the whole-file retry, and Aider’s “Retrying in N s” lines are hidden. -
Model info:
oppxreadsGET /v1/infoat start-up and passesOPPX_MODEL_ID(shown in the welcome box, status bar and/status) and a--model-settings-filewith the server’sedit_formatanduse_repo_map: true(Aider otherwise assumeswholeand no repo map for our model name). Reasoning output is hidden byhide_reasoning, and the spinner keeps going while the model reasons. That covers<think>spans, a lone</think>, Aider’s “► THINKING / ► ANSWER” markers, and Aider’s internal<thinking-content-…>tag: with SGLang’s separatereasoning_content, Aider 0.86 opens with that tag but closes with the configured one.OppxIO.ai_outputstrips reasoning from the session file too, so-c/-rdon’t resend it. -
Memory:
OPENPHALANX.md(andOPPX.mdandAGENTS.mdif present, matched in any letter case, soOPPX.MDworks) in the repo root is loaded read-only every turn./initasks the model to write it. -
Sessions: a message is written to the session file once: the prompt writes what is typed (as Aider does), and the agent turn writes only what didn’t come from the prompt (
-p, lines typed during a turn;OppxIO.input_logged). Both used to write, so every typed message was in the file twice, and-c/-rrestored it twice. One file per conversation in~/.local/state/oppx/history/<repo>-<id>/<YYYYmmdd-HHMMSS>.md, with a sharedinput.history. The old single per-repo file is migrated as session00000000-000000.-cand-rpass--restore-chat-history. -
Prefix-cache friendliness (
oppx_chat/cache.py; measured withscripts/bench_session.py). SGLang only reuses an unchanged prompt prefix, and two things used to change near the start of every request, giving 3–7% cache hits over 10 turns:- Repo map: Aider re-ranked it around the chat files and the names each message mentions. It’s now ranked once for the whole repo and cached per (file list, map size). The context manager’s map size only shrinks within a session (reset by
/clear). - System prompt: questions ran in Aider’s ask mode, which has its own prompts.
AskCodernow gets the editing coder’s prompts, and the question carriesASK_NOTE; ask mode never applies edits. The map heading’s{other}word is fixed too.
Result: 50% of prompt tokens served from cache over the same 10 turns, and follow-up questions start answering in 0.6 s instead of 4.8 s. The rest is inherent: new or edited files, files moving into the history after the first edit, and compaction.
OPPX_DUMP_REQUESTS=<file>writes every request as JSON lines to find where consecutive requests diverge. - Repo map: Aider re-ranked it around the chat files and the names each message mentions. It’s now ranked once for the whole repo and cached per (file list, map size). The context manager’s map size only shrinks within a session (reset by
-
Testing: drive it in a pty (Python
pty.fork, 120×40 viaTIOCSWINSZ). The driver must answer cursor-position requests (ESC[6n→ESC[30;1R), or prompt_toolkit never draws the status bar. Render the raw bytes withpyteto see the real screen.