Skip to content

Commit 11e1e4f

Browse files
committed
docs: split the README's deep detail into a docs/ folder
1 parent f550f5d commit 11e1e4f

11 files changed

Lines changed: 858 additions & 332 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ conventions keep it that way.
1313
`~/.config/opentab/` and the `opentab-*.csv` you ask for with `e`.
1414
- **Python 3.9+.** Don't reach for newer syntax (`target-version = py39`).
1515

16-
See [`CLAUDE.md`](CLAUDE.md) for the full architecture, layering rules, and the backend
17-
contract before making larger changes.
16+
See [`docs/architecture.md`](docs/architecture.md) for the architecture, layering rules, and the backend contract before making larger changes.
1817

1918
## Setup
2019

README.md

Lines changed: 40 additions & 329 deletions
Large diffs are not rendered by default.

docs/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# OpenTab documentation
2+
3+
The [top-level README](../README.md) covers installation and a tour of the features.
4+
These pages hold the full detail:
5+
6+
| Page | What's in it |
7+
|------|--------------|
8+
| [Data sources](sources.md) | Every supported tool — where its records live, how cost is derived, quirks, the CSV/JSONL schema, and the merged `all` view |
9+
| [Keys & navigation](keys.md) | The complete keymap, the browse → zoom → session model, overlays, custom launcher hooks, and what persists between runs |
10+
| [Pricing & the `$` view](pricing.md) | How costs are attributed, the `$` what-if estimate, the `P` price table and models.dev catalog, pinning, and refreshing rates |
11+
| [The web browser](web.md) | `--html`, `--serve`, and `--web` — the self-contained page, the live server, deep links, and security notes |
12+
| [Windows & WSL](windows.md) | Running natively on Windows, and reading Windows-side data from WSL |
13+
| [Privacy — what it touches](privacy.md) | Everything OpenTab reads, writes, and runs; network policy; demo mode |
14+
| [Architecture](architecture.md) | For contributors — the package layout, the store contract, data flow, and the pricing model internals |
15+
16+
Everything is also discoverable in-app: press **`?`** for the full keymap with every
17+
panel and overlay documented.

docs/architecture.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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.

docs/keys.md

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Keys & navigation
2+
3+
OpenTab opens on a stacked **Months / Days** (or Projects) sidebar, lazygit-style:
4+
drill from a month or day into its detail tabs, from the Sessions tab into a single
5+
session — cost split, model mix, subagent tree — and step back out with `Esc`.
6+
7+
Everything below is also in the app itself — press **`?`** for the built-in help.
8+
9+
## How the views nest
10+
11+
Three levels: **browse****zoom****session**. `Enter` (or `+`) drills in, `Esc`
12+
steps back out. Zoom is not full-screen: the detail pane takes focus *beside* the
13+
sidebar, which stays clickable to re-scope in place; `+` maximizes/restores the
14+
detail pane (remembered between runs). The session view is full-screen.
15+
16+
Detail tabs per scope: years/months get Overview · Models · Projects · Sessions;
17+
days drop Models. A session adds **Turns** (per-turn cost over time, every source
18+
that records per-step usage) and **Tools** (per-tool / MCP spend) when its source
19+
supports them, and **Sources** joins in the merged `all` view.
20+
21+
## Move around
22+
23+
| Key | Action |
24+
|-----|--------|
25+
| `p` / `t` | Switch to the Projects / Time browse mode |
26+
| `Tab` / `Shift-Tab` | Cycle focus Years → Months → Days (Time mode); Shift-Tab at the top steps back out |
27+
| `Enter` / `+` | Drill into the selection; on a Sessions / Projects / Sources tab, open it in this scope |
28+
| `Esc` | Step back out — session → zoom → browse |
29+
| `h` / `l` | Switch detail tabs |
30+
| `j` / `k` | Move in the list (``/`` too), or scroll the detail pane |
31+
| `PgDn` / `PgUp` | Half a page (`Ctrl-D` / `Ctrl-U` too) |
32+
| `g` / `G` | Jump to the top / bottom |
33+
| Mouse | Wheel scrolls · click selects (anywhere in the preview pane focuses it) · double-click drills · click a tab, or a column header to sort (again to reverse) |
34+
35+
On the Turns tab, `z` (or clicking a `` header) unfolds the whole prompt text.
36+
37+
## Scope & filter
38+
39+
| Key | Action |
40+
|-----|--------|
41+
| `R` | Set the date range — `all` · `30d` (or `30`) · `2m` · `1y` · `2026` · `2026-05` · `start..end` |
42+
| `a` | Back to all time, keeping the current selection where possible |
43+
| `s` | Sort picker for the visible list (`j`/`k` move · `Enter` · `Esc`) |
44+
| `f` or `/` | Live filter — fuzzy (fzf-style) over sessions (title/project/id), projects, and Models; substring over Prices. While filtering: ``/`` select · `Enter` keep · `Esc` cancel · `Ctrl-U` clear |
45+
| `x` | Clear the filter |
46+
47+
## Sessions & projects
48+
49+
| Key | Action |
50+
|-----|--------|
51+
| `i` / `I` | Ignore / unignore the selection; `I` reveals hidden rows so they can be unignored |
52+
| `b` / `B` | Bookmark ★ the selected session (remembered between runs); `B` shows only bookmarks, within the active range |
53+
| `o` | Open the selected session's / project's directory |
54+
| `L` | Launch the session in its own tool — `opencode --session` / `claude --resume` / `codex resume`. Then `w` window · `s` split · `v` vsplit · `p` popup · `y` copy the command (`w`/`s`/`v`/`p` need tmux or a [launcher hook](#custom-launchers); `y` copies anywhere) |
55+
| `e` | Export the current list to a CSV in the working directory |
56+
57+
## Views & overlays
58+
59+
| Key | Action |
60+
|-----|--------|
61+
| `T` | Trends — Daily · Weekly · Monthly · Calendar · Models · Providers · Sources. `h`/`l` tabs · `j`/`k` page months/weeks/years. On the charts and Calendar: `Enter` focuses, arrows pick a bar/day, `Enter` drills in, `Esc` back. On Models/Providers/Sources: `j`/`k` pick a row · `Enter` its sessions · `Enter` again opens one |
62+
| `P` | Model prices — the table behind the `$` estimate; see [Pricing](pricing.md) for the views, sorting, and pinning |
63+
| `$` | Toggle what-if prices — what unpriced usage would cost at API list rates |
64+
| `c` | Data-source picker (`j`/`k` move · `Enter` switch · `Esc` cancel) |
65+
| `C` | Colour-theme picker — `j`/`k` live-preview · `Enter` keep · `Esc` revert (themes are shared with the web browser) |
66+
| `D` | Toggle real / demo data (demo anonymizes titles and paths) |
67+
| `r` / `q` / `?` | Reload the data · quit · help |
68+
69+
The global toggles stay live *inside* the overlays: `?`, `C`, `c`, and `D` work from
70+
anywhere, Trends and Prices included.
71+
72+
## What persists between runs
73+
74+
The active **source, range, sort, ignored projects, bookmarks, pinned price rows,
75+
theme, and `$` what-if view are remembered between runs**, stored in
76+
`~/.config/opentab/state.json`. Pass `--no-state` to disable; `--demo` never
77+
persists.
78+
79+
Two formatting rules worth knowing: sub-cent costs render as `<$0.01` so they aren't
80+
confused with a red `$0.00`, which specifically means *unpriced* (tokens with no
81+
local price); and git worktrees fold into their main repo (`--no-worktrees` keeps
82+
them split).
83+
84+
## Custom launchers
85+
86+
If an executable exists at `~/.config/opentab/launcher` (or `$OPENTAB_LAUNCHER`
87+
points at one), every `L`-menu launch is handed to it instead of the built-in
88+
tmux commands — git-hooks style. It's called as
89+
90+
```sh
91+
launcher <kind> <directory> <command>
92+
# kind ∈ window | hsplit | vsplit | popup
93+
# e.g. launcher window /repo/myproj 'claude --resume abc123'
94+
```
95+
96+
and a nonzero exit shows its stderr as the launch error. The footer reads
97+
"launch via launcher hook" when one is active.
98+
99+
**Example hook** — route launches through zellij (or kitty, or your own popup
100+
manager):
101+
102+
```sh
103+
#!/bin/sh
104+
# ~/.config/opentab/launcher — example: zellij instead of tmux
105+
kind=$1 dir=$2 cmd=$3
106+
case $kind in
107+
window) exec zellij action new-tab --cwd "$dir" -- sh -c "$cmd" ;;
108+
popup) exec zellij run --floating --cwd "$dir" -- sh -c "$cmd" ;;
109+
*) exec zellij run --cwd "$dir" -- sh -c "$cmd" ;;
110+
esac
111+
```

0 commit comments

Comments
 (0)