|
| 1 | +# Architecture |
| 2 | + |
| 3 | +For contributors. This is the map of how OpenTab is put together; the ground rules |
| 4 | +for contributions (runtime constraints, tests, commit conventions) live in |
| 5 | +[CONTRIBUTING.md](../CONTRIBUTING.md). |
| 6 | + |
| 7 | +## Hard constraints |
| 8 | + |
| 9 | +- **Standard library only at runtime.** `curses` + `sqlite3` + stdlib. The only |
| 10 | + third-party runtime dependency is `windows-curses` (Windows-only, providing the |
| 11 | + missing stdlib `curses`); never add another. ruff/hatchling are dev/build tooling. |
| 12 | +- **Read-only on user data.** The stores open every source read-only and must not |
| 13 | + write to it. The only files OpenTab writes are its own — prefs, price cache, |
| 14 | + rollup cache, and the exports you explicitly ask for (see |
| 15 | + [Privacy](privacy.md#everything-it-writes)). |
| 16 | +- **Python 3.9+.** `MIN_PYTHON = (3, 9)`, `target-version = py39` — no newer syntax. |
| 17 | + |
| 18 | +## Package layout |
| 19 | + |
| 20 | +A src-layout package: PyPI distribution `opentab-ai`, import package and command |
| 21 | +both `opentab`. |
| 22 | + |
| 23 | +``` |
| 24 | +src/opentab/ |
| 25 | + __init__.py re-exports the public API (and a few stdlib modules) as opentab.* |
| 26 | + __main__.py python -m opentab |
| 27 | + cli.py parse_args + main (entry point: opentab = opentab.cli:main) |
| 28 | + models.py Workflow / DaySummary / MonthSummary / YearSummary / ProjectSummary |
| 29 | + formatting.py money/pct/tokens/cost_bar/short_path/iso_to_local + paint regexes |
| 30 | + heatmap.py heat_* / calendar_cells / week_key / month_range + HEAT_*/BAR_* consts |
| 31 | + pricing.py two-layer models.dev catalog (bundled + cache) + model_price/$ costing |
| 32 | + data/models.json the bundled models.dev snapshot (GENERATED by scripts/update_prices.py) |
| 33 | + demo.py demo_* anonymisation |
| 34 | + util.py clipboard/launchers/git_root/fuzzy/parse_range/tool_namespace |
| 35 | + sources.py make_store/resolve_source/available_sources/source_cycle + path routing |
| 36 | + state.py load_state/save_state/apply_state |
| 37 | + themes.py THEMES palettes (single source for the web browser + the TUI) |
| 38 | + stores/ opencode, claude, codex, hermes, csv_source, jsonl_source, |
| 39 | + copilot, vscode, pi, openclaw, zaly, combined, cached |
| 40 | + tui/ renderer (Renderer), app (App) |
| 41 | + web.py build_payload/session_extras + html_command/serve_command |
| 42 | + webpage.py render_html: the self-contained browser page (inline CSS/JS) |
| 43 | +``` |
| 44 | + |
| 45 | +**The import graph is acyclic, by layer**: leaves (`models`, `formatting`, |
| 46 | +`heatmap`, `pricing`, `demo`, `util`, `webpage`) → `stores/*` → `tui/*` → |
| 47 | +`sources`/`state`/`web` → `cli`. Stores never import the TUI; annotation-only |
| 48 | +back-references go under `if TYPE_CHECKING:`. |
| 49 | + |
| 50 | +## The three layers |
| 51 | + |
| 52 | +### Stores |
| 53 | + |
| 54 | +One store per data source (see [Data sources](sources.md) for what each reads). |
| 55 | +Every store implements the same four-method contract: |
| 56 | + |
| 57 | +| Method | Returns | |
| 58 | +|--------|---------| |
| 59 | +| `workflows()` | the fast per-root session rollup that paints the first frame | |
| 60 | +| `summary()` | app-wide totals | |
| 61 | +| `workflow_nodes(id)` | the recursive subagent cost tree for one session | |
| 62 | +| `model_breakdown()` | the heavy per-model scan over the whole corpus | |
| 63 | + |
| 64 | +plus `demo`/`demo_scale` for anonymisation. **That surface is the whole |
| 65 | +`App` ↔ store contract — keep `App`/`Renderer` backend-agnostic** (never reach into |
| 66 | +SQL or JSONL from the TUI). |
| 67 | + |
| 68 | +Two per-session opt-ins ride on top via `getattr`, each gated by |
| 69 | +`supports_*(workflow_id)` so unsupported tabs are hidden rather than shown empty: |
| 70 | +`tool_breakdown` (the Tools tab) and `message_timeline` (the Turns tab). Both are |
| 71 | +fetched **lazily on drill-in**, never as startup scans. |
| 72 | + |
| 73 | +Cost semantics are also part of the contract: a store's `records_cost` says whether |
| 74 | +it records real money. Token-only backends (Claude, Codex, Copilot, VS Code) report |
| 75 | +`cost=0` with tokens in the `unpriced_*` splits, and the normal `$` machinery turns |
| 76 | +that into the $0 / list-price-estimate behavior with no special-casing. Mixed |
| 77 | +backends (Hermes, pi, OpenClaw, zaly, CSV/JSONL) probe `records_cost` per instance. |
| 78 | + |
| 79 | +Notable store internals: |
| 80 | + |
| 81 | +- **`Store` (OpenCode)** is schema-adaptive: OpenCode's schema varies by version, so |
| 82 | + it probes columns and builds cost/token SQL dynamically — always go through those |
| 83 | + helpers, never hard-code column names. |
| 84 | +- **`CombinedStore`** wraps several backends for `--source all`: workflow ids are |
| 85 | + globally unique, so it routes the per-session methods by an `id → backend` map; |
| 86 | + `records_cost` is the AND of its backends; `combined=True` turns on the per-session |
| 87 | + origin markers. |
| 88 | +- **`CachedStore`** is the warm-start cache, a transparent wrapper `make_store()` |
| 89 | + puts around every leaf backend. It fingerprints the backend's `cache_inputs()` |
| 90 | + (`(path, size, mtime_ns)` per file) and, on a match, returns cached |
| 91 | + `workflows()`/`model_breakdown()` without parsing — the ~0.8s → ~50ms warm start. |
| 92 | + Only those two methods are intercepted; everything else delegates via |
| 93 | + `__getattr__` and parses lazily on first drill-in. Off under `--demo`/`--no-cache`; |
| 94 | + bump `CACHE_VERSION` to invalidate. |
| 95 | + |
| 96 | +### App |
| 97 | + |
| 98 | +`tui/app.py` — all state and the keyboard/mouse state machine; curses-free except |
| 99 | +the modal prompt line. `self.view` walks `"browse"` → `"zoom"` → `"session"`; zoom |
| 100 | +is lazygit-style (same split, roles swapped), the session view full-screen. Overlays |
| 101 | +(`trends`, `help`, `show_prices`) are booleans on top of any view, and the theme/ |
| 102 | +source pickers float above the overlays — `handle_key` checks the pickers first, |
| 103 | +which is what lets an open overlay act as the live preview behind them. |
| 104 | + |
| 105 | +`current_tabs()` is the source of truth for detail tabs (never index a class tab |
| 106 | +tuple directly): it appends Turns/Tools per the selected session's `supports_*` |
| 107 | +gates and injects Sources in the merged view, so `draw_detail` dispatches session |
| 108 | +tabs by name, not index. |
| 109 | + |
| 110 | +### Renderer |
| 111 | + |
| 112 | +`tui/renderer.py` — all drawing. `Renderer.__getattr__` delegates unknown |
| 113 | +attributes to its `App`, so renderer methods read app state as if it were `self`. |
| 114 | +Drawing methods return plain `list[str]`; `write_rich` re-colors money/token spans |
| 115 | +by regex at paint time. |
| 116 | + |
| 117 | +## Data flow |
| 118 | + |
| 119 | +- `App.__init__` loads `store.workflows()` so the first frame paints immediately. |
| 120 | + The per-model breakdown is the one heavy corpus scan and is **deferred**: `run()` |
| 121 | + loads it right after the first paint, before any key is handled. Don't move it |
| 122 | + into `__init__`. |
| 123 | +- The breakdown is computed once per root session (`_model_by_root`), then sliced |
| 124 | + per session/day/month — never re-queried per workflow. |
| 125 | +- The Turns and Tools tabs are the opposite trade-off: cheap per-session fetches on |
| 126 | + drill-in, memoized (`_turns_by_session`, `_tool_by_session`) and cleared on |
| 127 | + reload/source switch. Drilling into a session never freezes mid-draw — a |
| 128 | + "Loading session…" frame paints first and the prefetch tick does the blocking work. |
| 129 | +- `ranged_workflows` → `all_workflows` are cached properties; anything that mutates |
| 130 | + range/ignored state must call `_invalidate_workflow_cache()`. |
| 131 | +- Subagent costs are recursive: `workflow_nodes` walks the parent chain so a root |
| 132 | + session's cost includes its whole subtree. |
| 133 | + |
| 134 | +## The `$` what-if pricing model |
| 135 | + |
| 136 | +Every `Workflow` carries two cost snapshots: real recorded cost, and an |
| 137 | +API-equivalent (real spend + what `$0.00` subscription tokens would cost at list |
| 138 | +prices). `$` just swaps which snapshot every panel reads (`_apply_price_mode`) — |
| 139 | +nothing is recomputed at toggle time. |
| 140 | + |
| 141 | +Prices resolve across **two catalog layers with one schema**: the bundled models.dev |
| 142 | +snapshot (`src/opentab/data/models.json` — generated by |
| 143 | +`scripts/update_prices.py` each release; it's data, never hand-edit) and the |
| 144 | +optional user cache `~/.config/opentab/prices.json`, ordered newest-fetch-first. |
| 145 | +`model_price()` adds hand-kept family fallbacks for version/suffix churn, then a |
| 146 | +mid-range fallback. The cache is written only on the explicit refresh — the one |
| 147 | +place runtime OpenTab touches the network. |
| 148 | + |
| 149 | +The `P` overlay is described in [Pricing](pricing.md); internally, rows dedupe to a |
| 150 | +canonical model id (`canonical_model`: dots==dashes, date pins and effort suffixes |
| 151 | +stripped) and the `eff $/M` column blends list rates at the app-wide token mix. |
| 152 | + |
| 153 | +## Themes |
| 154 | + |
| 155 | +One source, two frontends: palettes live in `themes.py` (`THEMES` — role tokens + |
| 156 | +heat ramps per theme). The web reshapes them to CSS variables; the TUI maps them |
| 157 | +onto a fixed curses color-pair layout (true-color via `init_color`, nearest-256 |
| 158 | +otherwise), painting an explicit theme background on every cell so light themes |
| 159 | +actually render light. Adding a theme is one `THEMES` entry — both pickers, the |
| 160 | +`--theme` choices, and the web injection enumerate it. |
| 161 | + |
| 162 | +## The web frontend |
| 163 | + |
| 164 | +`web.py` builds a headless `App` (same rollups, prefs, cost snapshots) and |
| 165 | +serializes the visible dataset to JSON; `webpage.py` wraps it in one self-contained |
| 166 | +page that deliberately mirrors the TUI (same sidebar, tab tuples, keymap — see |
| 167 | +[the web browser](web.md)). Every cost travels twice (`real`/`api`) so the page's |
| 168 | +`$` toggle is a client-side field swap. The static export omits Turns/Tools; the |
| 169 | +server (`--serve`) exposes them as `/api/session/<id>` on drill-in. The server is |
| 170 | +deliberately single-threaded — the stores' sqlite connections are bound to their |
| 171 | +creating thread — and binds localhost by default. |
| 172 | + |
| 173 | +## Demo mode |
| 174 | + |
| 175 | +Stores transform rows in memory on load: deterministic fake titles/paths/models, |
| 176 | +synthetic prices for `$0.00` rows, and one hidden per-process factor scaling every |
| 177 | +cost/token so list-price math can't recover real dollars. Under `--source all` the |
| 178 | +`CombinedStore` forces one shared factor so cross-tool proportions stay truthful. |
| 179 | +Demo never persists state and disables clipboard/file-opener side effects. |
| 180 | + |
| 181 | +## Tests, lint, CI |
| 182 | + |
| 183 | +See [CONTRIBUTING.md](../CONTRIBUTING.md) — `test_opentab.py` is a custom runner |
| 184 | +(not pytest), ruff lints and formats, and `ruff.toml` deliberately ignores `E501` |
| 185 | +because the TUI f-strings build fixed-width columns. |
0 commit comments