diff --git a/docs/architecture.md b/docs/architecture.md
index 2051851..75e3527 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -32,6 +32,7 @@ package and installed command are both `opentab`.
| `conversation.py` | Shared conversation input validation, bounded text windows, anchors and snapshot-bound cursors |
| `conversation_search.py` | Explicit private SQLite/FTS5 text index, source-bound root replacement and grouped lexical candidates; service owns visibility and live verification |
| `models.py` | Workflow, qualified session identity and summary records |
+| `tools.py` | Numeric per-call projection of recorded usage rows; ordered repeated calls and proportional attribution |
| `stores/` | Harness readers, combined views, portable summaries and warm caches |
| `remote_content.py` | Opt-in keyed SSH traces, snapshot/live identity validation, bounded transport and cancelable jobs |
| `tui/app.py` | Application state, accounting projections, keyboard/mouse navigation |
diff --git a/docs/keys.md b/docs/keys.md
index 5ddba0d..5ef616c 100644
--- a/docs/keys.md
+++ b/docs/keys.md
@@ -97,6 +97,17 @@ Expansion is temporary and is released when you leave the turn. Recorded tool
errors are labeled explicitly. Sources that do not support content have no turn
detail; real content is also unavailable in demo mode.
+On **Tools**, `j`/`k` select a tool or server/namespace ranking and `Enter` or
+double-click opens its contribution, per-call averages, token composition and exact
+model attribution. The chronological call list uses the same keys; `Enter` opens
+the owning prompt's turn list with the relevant turn selected. Another `Enter`
+opens the existing trace reader where supported. `Esc` returns through each level,
+restoring the originating tool call and ranking. `g`/`G` select first/last; page keys
+and the mouse wheel scroll the details. `$` updates attribution without changing
+the selected tool. The rankings remain useful for aggregate-only sources, which
+explicitly report that individual calls are unavailable. Tools CSV export remains
+the session's aggregate tool/model attribution, not the selected call ledger.
+
The overview chart shows **total cost per prompt**, matching the prompt table.
Inside a prompt, the charts show **cost per turn and context growth** for that
prompt only, with the same session-wide turn numbers as its table. Each peak is
diff --git a/docs/tui.md b/docs/tui.md
index 93a8f7f..883fd6d 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -237,6 +237,22 @@ missing or uniform per-call rates fall back to area. Small entries fold into
pairs while the browser measures its responsive container. Neither chart needs
another store query.
+The Tools explorer adds selectable tool and namespace rankings beneath this chart.
+Opening one shows contribution, averages, a token-composition band, token categories,
+per-model attribution and individual calls recovered from the existing timeline.
+`tools.tool_calls_from_turns` preserves original turn indices and repeated calls;
+it does not read raw content. Aggregate and recovered-ledger coverage are compared
+separately for calls, tokens and cost. A turn timestamp is not tool start time,
+and attributed model output is not the returned result's size.
+
+The App retains one numeric Tools projection and the renderer one layout, keyed by
+source-row snapshots, pricing revision/mode and layout inputs. Cached layouts restore
+their paint metadata, while cursors and viewport scrolling stay independent. A call
+opens the owning prompt's Turns list with that turn selected; trace content still
+requires a further explicit action. Back unwinds trace, owning Turns, Tools detail,
+then rankings. Manual tab/scope changes discard the cross-tab return context; reload
+clears the projection and drill rather than reusing call ordinals from an old snapshot.
+
## Coordinates and Encoding
The outer app frame is a viewport boundary. `draw()` paints it in screen
diff --git a/docs/web.md b/docs/web.md
index 391fb40..0ecb1b3 100644
--- a/docs/web.md
+++ b/docs/web.md
@@ -131,6 +131,32 @@ an older browser tab before requesting more prompts.
## Served live
+### Tools explorer
+
+The live **Tools** tab pairs the spend treemap with tool and server/namespace
+rankings. Select a row or treemap tile to inspect contribution, per-call averages,
+a token-composition band, exact token categories, model attribution and a
+chronological individual-call ledger. Tables sort on their headers; repeated
+calls remain separate, even within one turn. The ledger shows its aggregate
+coverage instead of assuming both accounting surfaces contain identical records.
+
+Select a call to open its owning prompt in **Turns**, with the exact turn
+highlighted and focused. `Esc`, browser Back or the visible Back button returns
+to that call in Tools; another Back returns to the ranking. Focused ranking/call
+rows support `j`/`k`, arrows and Enter, and `Tab` follows native browser focus.
+`$` changes the cost snapshot without losing the selected tool or call. Manual
+tab/scope changes and reload invalidate this transient navigation. Detail text
+wraps on small screens; tables scroll horizontally within their panels.
+
+Cost and token figures are **model usage attributed across a turn's tool calls**,
+not execution fees or tool-result size. Times are owning-turn timestamps, not tool
+start/end times. Status and duration are not inferred. Aggregate-only sources retain
+the rankings and breakdowns without inventing a call ledger. No arguments, tool
+results or raw content keys enter the live extras or static report; opening a call
+does not add a raw web trace reader. Static HTML still omits Tools entirely.
+
+### Running the server
+
`opentab web` serves the browser on `http://localhost:8321` (`--port` changes the
port) and opens it in your default browser. `opentab web --headless` serves without
launching a browser. Stop either with `Ctrl-C`.
diff --git a/src/opentab/tools.py b/src/opentab/tools.py
new file mode 100644
index 0000000..7fb2b24
--- /dev/null
+++ b/src/opentab/tools.py
@@ -0,0 +1,45 @@
+"""Shared projections for per-tool-call accounting."""
+
+from collections.abc import Iterable
+
+from opentab.util import tool_names, tool_namespace
+
+_ARITHMETIC_FIELDS = (
+ "cost",
+ "tokens_total",
+ "input",
+ "output",
+ "reasoning",
+ "cache_read",
+ "cache_write",
+ "cache_write_1h",
+)
+
+
+def tool_calls_from_turns(turns: Iterable[dict]) -> list[dict]:
+ """Flatten turns into chronological calls with evenly attributed usage."""
+ calls = []
+ for turn_index, turn in enumerate(turns):
+ tools = tool_names(turn.get("tools"))
+ if not tools:
+ continue
+ share_count = len(tools)
+ for call_index, tool in enumerate(tools):
+ call = {
+ "index": len(calls),
+ "turn_index": turn_index,
+ "call_index": call_index,
+ "tool": tool,
+ "namespace": tool_namespace(tool),
+ "time": turn.get("time"),
+ "agent": turn.get("agent"),
+ "depth": turn.get("depth"),
+ "model_name": turn.get("model_name"),
+ "effort": turn.get("effort"),
+ "prompt_id": turn.get("prompt_id"),
+ "prompt_title": turn.get("prompt_title"),
+ }
+ for field in _ARITHMETIC_FIELDS:
+ call[field] = (turn.get(field) or 0) / share_count
+ calls.append(call)
+ return calls
diff --git a/src/opentab/tui/app.py b/src/opentab/tui/app.py
index d46dba8..8a0b3eb 100644
--- a/src/opentab/tui/app.py
+++ b/src/opentab/tui/app.py
@@ -73,6 +73,7 @@
refresh_model_prices,
)
from opentab.sources import RESUME_COMMANDS, SOURCE_LABELS
+from opentab.tools import tool_calls_from_turns
from opentab.tui import bindings
from opentab.tui.renderer import Renderer
from opentab.util import (
@@ -86,6 +87,7 @@
open_path,
parse_range_text,
resolve_project_root,
+ tool_namespace,
workflow_fuzzy_score,
)
from opentab.whats_new import RELEASES_URL, load_release_history, should_announce
@@ -460,6 +462,14 @@ def __init__(
self._turn_drill_session: str | None = None
self._turn_cursor = 0
self._turn_follow = False
+ self._tool_drill_session: str | None = None
+ self.tool_drill: tuple[str, str] | None = None
+ self._tool_cursor = 0
+ self._tool_call_cursor = 0
+ self._tool_follow = False
+ self._tools_return: tuple | None = None
+ self._tool_projection_cache: tuple | None = None
+ self._model_price_revision = 0
self._subagent_snapshot = None
self._subagent_order: tuple[int, ...] = ()
self._subagent_selected: int | None = None
@@ -674,6 +684,7 @@ def _clear_zoom_drills(self) -> None:
def set_all_time(self) -> None:
# Capture first because clearing drills widens the list containing the selection.
+ self._tools_return = None
anchor = self.selection_anchor()
self._clear_zoom_drills()
self.custom_since = None
@@ -694,6 +705,7 @@ def range_input_value(self) -> str:
def set_range_from_text(self, raw: str) -> None:
# Capture first because clearing drills widens the list containing the selection.
+ self._tools_return = None
anchor = self.selection_anchor()
self._clear_zoom_drills()
days, months, since, until = parse_range_text(raw)
@@ -1041,6 +1053,7 @@ def select_machine_filter(self, name: str | None) -> None:
name = name or None
if name == self.machine_filter:
return
+ self._tools_return = None
anchor = self.selection_anchor()
self.machine_filter = name
self._invalidate_workflow_cache()
@@ -1109,6 +1122,7 @@ def select_harness_filter(self, name: str | None) -> None:
name = name or None
if name == self.harness_filter:
return
+ self._tools_return = None
anchor = self.selection_anchor()
self.harness_filter = name
self._invalidate_workflow_cache()
@@ -1248,6 +1262,7 @@ def toggle_ignored_projects_view(self) -> None:
if not (self.ignored_projects or self.ignored_sessions):
self.notify("no ignored items", "error")
return
+ self._tools_return = None
project = self.active_project_for_toggle()
project_dir = project.directory if project else None
session = self.session_ignore_target()
@@ -1271,9 +1286,11 @@ def toggle_ignored_projects_view(self) -> None:
def toggle_ignore(self) -> None:
if self.active_project_for_toggle() is not None:
+ self._tools_return = None
self.toggle_project_ignore()
return
if self.session_ignore_target() is not None:
+ self._tools_return = None
self.toggle_session_ignore()
return
self.notify("ignore: select a project or session first", "error")
@@ -1436,6 +1453,7 @@ def toggle_bookmarks_view(self) -> None:
"error",
)
return
+ self._tools_return = None
anchor = self.selection_anchor()
self.show_bookmarks_only = not self.show_bookmarks_only
self.restore_selection(anchor)
@@ -1760,6 +1778,200 @@ def session_tool_rows(self, workflow_id: str) -> list[dict]:
self._tool_by_session[workflow_id] = rows
return rows
+ def effective_tool_cost(self, row: dict) -> float:
+ cost = float(row.get("cost") or 0)
+ if self.show_api_prices and not self.store.demo and not cost:
+ return api_equivalent_cost(
+ str(row.get("model_name") or ""),
+ row.get("input", 0),
+ row.get("output", 0),
+ row.get("reasoning", 0),
+ row.get("cache_read", 0),
+ row.get("cache_write", 0),
+ row.get("cache_write_1h", 0),
+ )
+ return cost
+
+ @staticmethod
+ def _new_tool_total() -> dict:
+ return {
+ "calls": 0,
+ "cost": 0.0,
+ "tokens_total": 0,
+ "input": 0,
+ "output": 0,
+ "reasoning": 0,
+ "cache_read": 0,
+ "cache_write": 0,
+ "cache_write_1h": 0,
+ }
+
+ def tool_projection(self, workflow_id: str) -> dict:
+ aggregate = self.session_tool_rows(workflow_id)
+ turns = (
+ self.session_turn_rows(workflow_id) if self.session_supports_turns(workflow_id) else ()
+ )
+ key = (
+ workflow_id,
+ id(aggregate),
+ len(aggregate),
+ id(turns),
+ len(turns),
+ self.show_api_prices,
+ self._model_price_revision,
+ self.store.demo,
+ )
+ cached = self._tool_projection_cache
+ if cached is not None and cached[0] == key:
+ return cached[1]
+
+ buckets: dict[tuple[str, str], dict] = {}
+ models: dict[tuple[str, str], dict[str, dict]] = {}
+ for row in aggregate:
+ tool = str(row.get("tool") or "unknown")
+ row_cost = self.effective_tool_cost(row)
+ for kind, name in (("tool", tool), ("namespace", tool_namespace(tool))):
+ item = buckets.setdefault((kind, name), self._new_tool_total())
+ item.update({"kind": kind, "name": name})
+ item["calls"] += int(row.get("calls") or 0)
+ item["cost"] += row_cost
+ model = str(row.get("model_name") or "unknown")
+ model_item = models.setdefault((kind, name), {}).setdefault(
+ model, self._new_tool_total()
+ )
+ model_item["calls"] += int(row.get("calls") or 0)
+ model_item["cost"] += row_cost
+ for field in (
+ "tokens_total",
+ "input",
+ "output",
+ "reasoning",
+ "cache_read",
+ "cache_write",
+ "cache_write_1h",
+ ):
+ item[field] += row.get(field, 0) or 0
+ model_item[field] += row.get(field, 0) or 0
+ rankings = []
+ for kind in ("tool", "namespace"):
+ rows = [r for (row_kind, _name), r in buckets.items() if row_kind == kind]
+ rankings.extend(
+ sorted(
+ rows,
+ key=lambda r: (float(r["cost"]), float(r["tokens_total"]), r["name"]),
+ reverse=True,
+ )
+ )
+ calls = []
+ for row in tool_calls_from_turns(turns):
+ item = dict(row)
+ item["cost"] = self.effective_tool_cost(item)
+ calls.append(item)
+ projection = {"key": key, "rankings": rankings, "calls": calls, "models": models}
+ self._tool_projection_cache = (key, projection)
+ return projection
+
+ def tool_rankings(self, workflow_id: str) -> list[dict]:
+ return self.tool_projection(workflow_id)["rankings"]
+
+ def tool_calls(self, workflow_id: str) -> list[dict]:
+ return self.tool_projection(workflow_id)["calls"]
+
+ @property
+ def active_tool_drill(self) -> tuple[str, str] | None:
+ wf = self.current_session() if self.view == "session" else None
+ if (
+ wf is None
+ or self.active_tab_name() != "Tools"
+ or self.tool_drill is None
+ or self._tool_drill_session != wf.id
+ ):
+ return None
+ keys = {(r["kind"], r["name"]) for r in self.tool_rankings(wf.id)}
+ return self.tool_drill if self.tool_drill in keys else None
+
+ def selected_tool_ranking(self, workflow_id: str) -> dict | None:
+ rows = self.tool_rankings(workflow_id)
+ if not rows:
+ return None
+ self._tool_cursor = max(0, min(self._tool_cursor, len(rows) - 1))
+ return rows[self._tool_cursor]
+
+ def selected_tool_calls(self, workflow_id: str) -> list[dict]:
+ drill = self.active_tool_drill
+ if drill is None:
+ return []
+ kind, name = drill
+ return [
+ row
+ for row in self.tool_calls(workflow_id)
+ if (row.get("tool") if kind == "tool" else row.get("namespace")) == name
+ ]
+
+ def open_tool_drill(self, ordinal: int | None = None) -> bool:
+ wf = self.current_session()
+ if wf is None or self.active_tab_name() != "Tools":
+ return False
+ rows = self.tool_rankings(wf.id)
+ if not rows:
+ return False
+ if ordinal is not None:
+ self._tool_cursor = max(0, min(ordinal, len(rows) - 1))
+ row = rows[max(0, min(self._tool_cursor, len(rows) - 1))]
+ self.tool_drill = (row["kind"], row["name"])
+ self._tool_drill_session = wf.id
+ self._tool_call_cursor = 0
+ self.scroll = 0
+ return True
+
+ def close_tool_drill(self) -> bool:
+ if self.active_tool_drill is None:
+ return False
+ self.tool_drill = self._tool_drill_session = None
+ self._tool_call_cursor = 0
+ self._tool_follow = True
+ self.scroll = 0
+ return True
+
+ def open_tool_call_reader(self) -> bool:
+ wf = self.current_session()
+ calls = self.selected_tool_calls(wf.id) if wf else []
+ if not calls or wf is None or "Turns" not in self.current_tabs():
+ return False
+ self._tool_call_cursor = max(0, min(self._tool_call_cursor, len(calls) - 1))
+ turn_index = int(calls[self._tool_call_cursor]["turn_index"])
+ runs = self.turn_runs(wf.id)
+ group = next((i for i, run in enumerate(runs) if turn_index in run), None)
+ if group is None:
+ return False
+ self._tools_return = (
+ wf.id,
+ self.tool_drill,
+ self._tool_cursor,
+ self._tool_call_cursor,
+ self.scroll,
+ )
+ self.tab = self.current_tabs().index("Turns")
+ self.open_turn_drill(group)
+ self._trace_cursor = runs[group].index(turn_index)
+ self._turn_follow = True
+ return True
+
+ def return_to_tools(self) -> bool:
+ ret = self._tools_return
+ wf = self.current_session()
+ if ret is None or wf is None or wf.id != ret[0] or "Tools" not in self.current_tabs():
+ self._tools_return = None
+ return False
+ self._clear_trace_expansion()
+ self.trace_drill = self.turn_drill = self._turn_drill_session = None
+ self.tab = self.current_tabs().index("Tools")
+ _wid, self.tool_drill, self._tool_cursor, self._tool_call_cursor, self.scroll = ret
+ self._tool_drill_session = wf.id
+ self._tool_follow = True
+ self._tools_return = None
+ return True
+
def _scale_demo_tools(self, workflow_id: str, rows: list[dict]) -> list[dict]:
# Synthetic subscription prices keep demo useful; scaling hides all real magnitudes.
k = self.store.demo_scale
@@ -2990,7 +3202,7 @@ def _handle_whatif_filter_key(self, key: int | str, rows: list[tuple[str, int]])
self.whatif_menu_index = 0
return True
- def _reprice_in_place(self) -> None:
+ def _reprice_in_place(self, tool_key: tuple[str, str] | None = None) -> None:
# Repricing is a resort: on a measured corpus `$` moved 106/117 project rows.
# Re-anchor every cursor by value or Enter can open an unselected neighbor.
anchor = self.selection_anchor()
@@ -3003,9 +3215,13 @@ def _reprice_in_place(self) -> None:
]
zoom_project = None if self.browse_mode == "projects" else self.zoom_selected_project()
scroll = self.scroll
+ wf = self.current_session() if self.view == "session" else None
self._apply_price_mode()
self.restore_selection(anchor)
self.scroll = scroll
+ if wf is not None and tool_key is not None:
+ keys = [(r["kind"], r["name"]) for r in self.tool_rankings(wf.id)]
+ self._tool_cursor = keys.index(tool_key) if tool_key in keys else 0
if trend_key is not None:
keys = self.trend_ranked_keys()
self.trend_row_index = keys.index(trend_key) if trend_key in keys else 0
@@ -3034,8 +3250,13 @@ def toggle_api_prices(self) -> None:
self.notify("API-price view is for real data, not the demo", "error")
return
self._ensure_models()
+ wf = self.current_session() if self.view == "session" else None
+ tool_key = None
+ if wf is not None and self.active_tab_name() == "Tools":
+ selected = self.selected_tool_ranking(wf.id)
+ tool_key = (selected["kind"], selected["name"]) if selected else None
self.show_api_prices = not self.show_api_prices
- self._reprice_in_place()
+ self._reprice_in_place(tool_key)
self.notice = (
"what-if prices (what unpriced usage would cost at API list prices)"
if self.show_api_prices
@@ -3044,6 +3265,13 @@ def toggle_api_prices(self) -> None:
def refresh_prices_action(self) -> None:
self.notice = "fetching prices from models.dev…"
+ wf = self.current_session() if self.view == "session" else None
+ selected = (
+ self.selected_tool_ranking(wf.id)
+ if wf is not None and self.active_tab_name() == "Tools"
+ else None
+ )
+ tool_key = (selected["kind"], selected["name"]) if selected else None
try:
count, _ = refresh_model_prices()
except (OSError, ValueError) as exc:
@@ -3053,9 +3281,12 @@ def refresh_prices_action(self) -> None:
self.renderer._turn_layout_cache = None
self.renderer._trace_layout_cache = None
self._whatif_catalog_rows = None
+ self._model_price_revision += 1
+ self._tool_projection_cache = None
+ self.renderer._tool_layout_cache = None
self._ensure_models()
self._compute_api_costs()
- self._reprice_in_place()
+ self._reprice_in_place(tool_key)
# _ensure_models is already satisfied, so explicitly reject a now-unpriced target.
self._revalidate_whatif()
self.prices_scroll = 0
@@ -3178,6 +3409,10 @@ def reload(self) -> None:
self._resolve_project_roots()
notes_ok = self.refresh_notes()
self._tool_by_session.clear()
+ self._tool_projection_cache = None
+ self.renderer._tool_layout_cache = None
+ self.tool_drill = self._tool_drill_session = self._tools_return = None
+ self._tool_cursor = self._tool_call_cursor = 0
self._turns_by_session.clear()
self._turn_runs_cache = None
self.renderer._turn_layout_cache = None
@@ -3528,6 +3763,10 @@ def _reload_for_source(self, restore: dict | None = None) -> None:
self.refresh_notes()
self._models_loaded = False
self._tool_by_session.clear()
+ self._tool_projection_cache = None
+ self.renderer._tool_layout_cache = None
+ self.tool_drill = self._tool_drill_session = self._tools_return = None
+ self._tool_cursor = self._tool_call_cursor = 0
self._turns_by_session.clear()
self._turn_runs_cache = None
self.renderer._turn_layout_cache = None
@@ -5186,6 +5425,7 @@ def _carry_tab(self, name: str) -> None:
self.tab = tabs.index(name) if name in tabs else 0
def set_focus(self, name: str) -> None:
+ self._tools_return = None
active_tab = self.active_tab_name()
self.focus = name
self._carry_tab(active_tab)
@@ -5250,6 +5490,7 @@ def _return_to_browse(self) -> None:
# with it (those scope the detail pane we are leaving).
if self.view == "browse":
return
+ self._tools_return = None
self.view = "browse"
self._clear_zoom_drills()
self.scroll = 0
@@ -5318,6 +5559,7 @@ def _remember_mode_position(self) -> None:
def set_browse_mode(self, mode: str) -> None:
if mode == self.browse_mode:
return
+ self._tools_return = None
# Remember where we were in the mode we're leaving (session, tab, drills and all),
# then restore the target mode's remembered spot if we've been there -- otherwise
# open it fresh at the top. The snapshot is value-anchored, so it self-heals against
@@ -5485,6 +5727,7 @@ def _reanchor(self, cursor: str, value, keys: list) -> None:
def drill_out(self) -> None:
if self.view == "session":
+ self._tools_return = None
self._clear_trace_expansion()
self.view = "zoom"
tabs = self.current_tabs() # land back on the Sessions tab we came from
@@ -5601,6 +5844,23 @@ def _reopen_trends(self, ret: tuple) -> None:
def move(self, delta: int) -> None:
if self.view == "session":
with self.session_selection():
+ if self.active_tab_name() == "Tools":
+ wf = self.current_session()
+ rows = (
+ self.selected_tool_calls(wf.id)
+ if wf and self.active_tool_drill
+ else (self.tool_rankings(wf.id) if wf else [])
+ )
+ if rows:
+ attr = "_tool_call_cursor" if self.active_tool_drill else "_tool_cursor"
+ cur = max(0, min(getattr(self, attr), len(rows) - 1))
+ setattr(self, attr, cur)
+ moved = max(0, min(cur + delta, len(rows) - 1))
+ if moved != cur:
+ setattr(self, attr, moved)
+ self._tool_follow = True
+ return
+ self._tool_follow = False
if self._on_subagents_tab() and self._move_subagent_cursor(delta):
return
if self._on_turns_tab():
@@ -5786,7 +6046,7 @@ def _wheel(self, my: int, mx: int, delta: int) -> None:
n = len(self.zoom_machine_rows())
if n:
self.machine_pick_index = max(0, min(self.machine_pick_index + delta, n - 1))
- elif kind in ("detail", "turnline", "subagentline"):
+ elif kind in ("detail", "turnline", "subagentline", "toolline", "toolcallline"):
self.scroll = max(0, self.scroll + delta) # scroll the detail content
else:
self.move(delta) # a gap or the tab strip: the active pane, as before
@@ -5860,6 +6120,18 @@ def jump(self, to_end: bool, stdscr: curses.window | None = None) -> None:
self.machine_pick_index = len(rows) - 1 if to_end else 0
return
+ if self.view == "session" and self.active_tab_name() == "Tools":
+ wf = self.current_session()
+ rows = (
+ self.selected_tool_calls(wf.id)
+ if wf and self.active_tool_drill
+ else (self.tool_rankings(wf.id) if wf else [])
+ )
+ if rows:
+ attr = "_tool_call_cursor" if self.active_tool_drill else "_tool_cursor"
+ setattr(self, attr, len(rows) - 1 if to_end else 0)
+ self._tool_follow = True
+ return
if self._on_subagents_tab() and self.active_subagent_drill is None:
wf = self.current_session()
rows = self.subagent_rows(wf) if wf else []
@@ -7262,6 +7534,13 @@ def handle_key(self, stdscr: curses.window, key: int | str) -> bool:
if self._on_turns_tab():
self._toggle_turn_cursor()
return True
+ if self.active_tab_name() == "Tools":
+ if self.active_tool_drill is not None:
+ if not self.open_tool_call_reader():
+ self.notify("No recoverable owning turn for this call.", "warn")
+ else:
+ self.open_tool_drill()
+ return True
if self._on_subagents_tab() and self.open_subagent_drill():
return True
self.drill_in()
@@ -7277,7 +7556,13 @@ def handle_key(self, stdscr: curses.window, key: int | str) -> bool:
# before it starts popping the view stack -- but ONLY while that tab is the
# one on screen. Left ungated, Esc on Tools or Context silently tore down an
# invisible drill and was swallowed, so the key appeared to do nothing.
- if self._on_turns_tab() and (self.close_trace_drill() or self.close_turn_drill()):
+ if self._on_turns_tab() and self.close_trace_drill():
+ return True
+ if self._on_turns_tab() and self._tools_return is not None and self.return_to_tools():
+ return True
+ if self.active_tab_name() == "Tools" and self.close_tool_drill():
+ return True
+ if self._on_turns_tab() and self.close_turn_drill():
return True
if self.close_subagent_turns():
return True
@@ -7292,12 +7577,14 @@ def handle_key(self, stdscr: curses.window, key: int | str) -> bool:
self.drill_out()
return True
if act == "tab_prev":
+ self._tools_return = None
self._clear_subagent_prompt()
self._clear_trace_expansion()
self.tab = (self.tab - 1) % len(self.current_tabs())
self.scroll = 0
return True
if act == "tab_next":
+ self._tools_return = None
self._clear_subagent_prompt()
self._clear_trace_expansion()
self.tab = (self.tab + 1) % len(self.current_tabs())
@@ -7843,6 +8130,7 @@ def _apply_click(self, target: tuple[str, int], drill: bool) -> None:
# active and j/k keeps moving it instead.
self.drill_in()
if self.tab != value:
+ self._tools_return = None
self._clear_subagent_prompt()
self._clear_trace_expansion()
self.tab = value
@@ -7859,6 +8147,20 @@ def _apply_click(self, target: tuple[str, int], drill: bool) -> None:
if ordinal is not None and self._on_subagents_tab():
self.open_subagent_drill(ordinal)
return
+ if kind == "toolline":
+ ordinal = getattr(self.renderer, "_tool_header_at", {}).get(value)
+ if ordinal is not None and self.active_tab_name() == "Tools":
+ self._tool_cursor = ordinal
+ if drill:
+ self.open_tool_drill(ordinal)
+ return
+ if kind == "toolcallline":
+ ordinal = getattr(self.renderer, "_tool_call_at", {}).get(value)
+ if ordinal is not None and self.active_tool_drill is not None:
+ self._tool_call_cursor = ordinal
+ if drill:
+ self.open_tool_call_reader()
+ return
if kind == "turnline":
# A click on a Turns-tab prompt row drills into it (its full text + its
# turns); clicks on the ▼/❄ marker lines between rows are inert.
diff --git a/src/opentab/tui/keymap.py b/src/opentab/tui/keymap.py
index ea85678..c339dc1 100644
--- a/src/opentab/tui/keymap.py
+++ b/src/opentab/tui/keymap.py
@@ -242,6 +242,11 @@ def _enter_opens_something(app: App) -> bool:
)
if app._on_subagents_tab():
return app.active_subagent_drill is None or not app.subagent_turns_unavailable()
+ if app.active_tab_name() == "Tools":
+ wf = app.current_session()
+ if wf is None:
+ return False
+ return app.active_tool_drill is None or bool(app.selected_tool_calls(wf.id))
return False
@@ -274,6 +279,12 @@ def _enter_summary(app: App) -> str:
if app.active_subagent_drill is not None
else "inspect the selected execution"
)
+ if tab == "Tools":
+ return (
+ "open the owning turn"
+ if app.active_tool_drill is not None
+ else "inspect the selected tool or namespace"
+ )
return "its sessions, within this scope"
@@ -695,6 +706,10 @@ def _tab_focus_segments(app: App) -> list:
if app.active_subagent_turns
else "back to the executions"
if app._on_subagents_tab() and app.active_subagent_drill is not None
+ else "back to the Tools drill"
+ if app._on_turns_tab() and app._tools_return is not None
+ else "back to the Tools rankings"
+ if app.active_tab_name() == "Tools" and app.active_tool_drill is not None
else "back to the Trends session list"
if in_session(app) and app._trend_return is not None and app._trend_return[0] == "drill"
else "step back out — session → zoom → browse",
@@ -721,6 +736,10 @@ def _tab_focus_segments(app: App) -> list:
if _on_turns(app)
else "pick an execution"
if app._on_subagents_tab() and app.active_subagent_drill is None
+ else "pick a call"
+ if app.active_tab_name() == "Tools" and app.active_tool_drill is not None
+ else "pick a tool or namespace"
+ if app.active_tab_name() == "Tools"
else "move / scroll",
),
section="nav",
@@ -746,6 +765,10 @@ def _tab_focus_segments(app: App) -> list:
if _on_turns(app) and app.active_trace_drill is None
else "first / last execution"
if app._on_subagents_tab() and app.active_subagent_drill is None
+ else "first / last call"
+ if app.active_tab_name() == "Tools" and app.active_tool_drill is not None
+ else "first / last tool or namespace"
+ if app.active_tab_name() == "Tools"
else "top / bottom",
section="nav",
when=lambda app: not in_trends(app) or app.trend_drill is not None,
diff --git a/src/opentab/tui/renderer.py b/src/opentab/tui/renderer.py
index af84401..a42d231 100644
--- a/src/opentab/tui/renderer.py
+++ b/src/opentab/tui/renderer.py
@@ -94,7 +94,6 @@
tool_call_label,
tool_mix_label,
tool_names,
- tool_namespace,
unicode_screen,
)
from opentab.whats_new import RELEASES_URL
@@ -211,11 +210,15 @@ def __init__(self, app: App) -> None:
self._trend_rows_at: tuple[int, int, int] | None = None
self._turn_header_at: dict[int, int] = {}
self._turn_layout_cache: tuple | None = None
+ self._tool_layout_cache: tuple | None = None
self._trace_layout_cache: tuple | None = None
self._trace_tool_at: dict[int, int] = {}
self._trace_output_ends: list[tuple[int, int]] = []
# Selected prompt header line, recomputed each paint for scroll/highlight.
self._turn_cursor_line: int | None = None
+ self._tool_header_at: dict[int, int] = {}
+ self._tool_call_at: dict[int, int] = {}
+ self._tool_cursor_line: int | None = None
self._subagent_header_at: dict[int, int] = {}
self._subagent_cursor_line: int | None = None
# Logical header lines become screen-coordinate sort regions during paint.
@@ -1001,6 +1004,10 @@ def breadcrumb(self) -> str:
if self.app.active_turn_drill is not None:
segs.append(f"Prompt {self.app.active_turn_drill + 1}")
return sep.join(segs)
+ if self.view == "session" and tab_name == "Tools" and self.app.active_tool_drill:
+ kind, name = self.app.active_tool_drill
+ segs += ["Tools", f"{kind}: {shorten(name, 28)}"]
+ return sep.join(segs)
# Machine drills are mutually exclusive and need no additional crumb.
if self.browse_mode == "machines" and self.view != "session":
machine = self.selected_machine_summary
@@ -2645,6 +2652,9 @@ def draw_detail(self, stdscr: curses.window, y: int, x: int, h: int, w: int) ->
# Follow is one-shot and must run before the scroll clamp.
self._scroll_turn_cursor_into_view(visible)
self.app._turn_follow = False
+ if current == "Tools" and self.app._tool_follow:
+ self._scroll_line_into_view(self._tool_cursor_line, visible)
+ self.app._tool_follow = False
if current == "Subagents" and not turns and self.app._subagent_follow:
self._scroll_line_into_view(self._subagent_cursor_line, visible)
self.app._subagent_follow = False
@@ -2661,10 +2671,14 @@ def draw_detail(self, stdscr: curses.window, y: int, x: int, h: int, w: int) ->
target = self.trace_output_target() if tracing else None
for offset, line in enumerate(drawn):
attr = self.line_attr(line)
- if (turns and self.scroll + offset == self._turn_cursor_line) or (
- current == "Subagents"
- and not turns
- and self.scroll + offset == self._subagent_cursor_line
+ if (
+ (turns and self.scroll + offset == self._turn_cursor_line)
+ or (
+ current == "Subagents"
+ and not turns
+ and self.scroll + offset == self._subagent_cursor_line
+ )
+ or (current == "Tools" and self.scroll + offset == self._tool_cursor_line)
):
# Select by line index, not a display glyph. paint_cursor_row preserves
# gutters and prevents rich number colors from shredding the highlight.
@@ -2716,6 +2730,9 @@ def draw_detail(self, stdscr: curses.window, y: int, x: int, h: int, w: int) ->
self._add_rows_region("turnline", y + 3, x + 2, x + w - 3, self.scroll, len(drawn))
if current == "Subagents" and not turns:
self._add_rows_region("subagentline", y + 3, x + 2, x + w - 3, self.scroll, len(drawn))
+ if current == "Tools":
+ kind = "toolcallline" if self.app.active_tool_drill is not None else "toolline"
+ self._add_rows_region(kind, y + 3, x + 2, x + w - 3, self.scroll, len(drawn))
if not loading_content:
self._paint_scrollbar(
stdscr, y + 3, x + w - 1, len(lines) - body_start, visible, self.scroll
@@ -4432,90 +4449,358 @@ def rate_text(rate: float | None) -> str:
def detail_tools(
self, workflow: Workflow, width: int, treemap_height: int | None = None
) -> list[str]:
- # Attribute each assistant step across its invoked tools. These are turn costs,
- # not tool-output sizes; `$` reprices wholly unpriced rows at list rates.
+ self._tool_header_at = {}
+ self._tool_call_at = {}
+ self._tool_cursor_line = None
+ self._tool_tree_runs = {}
if not self.session_supports_tools(workflow.id):
- return [
- "# Tools",
- "This session's tool doesn't record per-tool attribution.",
- ]
- rows = self.session_tool_rows(workflow.id)
- if not rows:
+ return ["# Tools", "This session's tool doesn't record per-tool attribution."]
+ if not self.session_tool_rows(workflow.id):
return ["# Tools", "No tool calls recorded for this session."]
- api = self.show_api_prices and not self.store.demo
+ projection = self.app.tool_projection(workflow.id)
+ layout_key = (
+ projection["key"],
+ self.app.active_tool_drill,
+ width,
+ treemap_height,
+ self._key("main", "select"),
+ self._key("main", "back"),
+ unicode_screen(),
+ self._tool_heat_ok,
+ self._token_series_ok,
+ )
+ cached = self._tool_layout_cache
+ if cached is None or cached[0] != layout_key:
+ lines = self._build_detail_tools(workflow, width, treemap_height, projection)
+ cached = (
+ layout_key,
+ lines,
+ dict(self._tool_header_at),
+ dict(self._tool_call_at),
+ dict(self._tool_tree_runs),
+ set(self._box_headers),
+ dict(self._token_runs),
+ )
+ self._tool_layout_cache = cached
+ self._tool_header_at, self._tool_call_at, self._tool_tree_runs = cached[2:5]
+ self._box_headers.update(cached[5])
+ self._token_runs.update(cached[6])
+ mapping = (
+ self._tool_call_at if self.app.active_tool_drill is not None else self._tool_header_at
+ )
+ cursor = (
+ self.app._tool_call_cursor
+ if self.app.active_tool_drill is not None
+ else self.app._tool_cursor
+ )
+ self._tool_cursor_line = next(
+ (line for line, ordinal in mapping.items() if ordinal == cursor), None
+ )
+ return cached[1]
+
+ def _build_detail_tools(
+ self, workflow: Workflow, width: int, treemap_height: int | None, projection: dict
+ ) -> list[str]:
+ self._tool_header_at = {}
+ self._tool_call_at = {}
+ self._tool_cursor_line = None
+ if self.app.active_tool_drill is not None:
+ return self._tool_detail(workflow, width, projection)
+
+ rankings = projection["rankings"]
+ tools = [r for r in rankings if r["kind"] == "tool"]
+ namespaces = [r for r in rankings if r["kind"] == "namespace"]
+ by_tool = {
+ r["name"]: {
+ "calls": r["calls"],
+ "cost": r["cost"],
+ "tokens": r["tokens_total"],
+ }
+ for r in tools
+ }
+ calls = sum(r["calls"] for r in tools)
+ cost = sum(r["cost"] for r in tools)
+ overview = [
+ f"{calls:,} calls {len(tools)} tools {len(namespaces)} namespaces",
+ f"Attributed cost {money(cost)} {money(cost / calls) if calls else '-'} / call "
+ f"{human_tokens(int(sum(r['tokens_total'] for r in tools) / calls)) if calls else '-'} tokens / call",
+ ]
+ lines = self._tool_treemap_box(by_tool, width, treemap_height)
+ lines += self._sectioned_box(
+ "# Tool ledger", [self._subagent_wrap(overview, width - 4)], width, []
+ )
+ lines.append("")
+ lines += self._tool_ranking_box(tools, "# Tools — this session", width, 0, len(lines))
+ lines.append("")
+ lines += self._tool_ranking_box(
+ namespaces, "# By server / namespace", width, len(tools), len(lines)
+ )
+ lines += self._subagent_wrap(
+ [
+ "",
+ f"{self._key('main', 'select')} / double-click inspects a tool or namespace. Tokens and cost belong to the LLM turns that invoked calls, split across every call; they are not tool-result size.",
+ ],
+ width,
+ )
+ return lines
- def agg() -> dict[str, dict]:
- return defaultdict(
- lambda: {
- "calls": 0,
- "cost": 0.0,
- "tokens": 0,
- "cache_read": 0,
- "cache_write": 0,
- "output": 0,
+ def _tool_ranking_box(
+ self, rows: list[dict], title: str, width: int, ordinal: int, offset: int
+ ) -> list[str]:
+ display_rows = list(rows)
+ if len(rows) > 1:
+ display_rows.append(
+ {
+ "name": "TOTAL",
+ **{key: sum(r[key] for r in rows) for key in ("calls", "cost", "tokens_total")},
}
)
-
- by_tool, by_server = agg(), agg()
- for r in rows:
- # Preserve recorded cost; only wholly unpriced rows receive list-price estimates.
- cost = r["cost"]
- if api and not cost:
- cost = api_equivalent_cost(
- r["model_name"],
- r["input"],
- r["output"],
- r["reasoning"],
- r["cache_read"],
- r["cache_write"],
- r.get("cache_write_1h", 0),
+ inner = max(1, width - self.BOX_CHROME)
+ calls_w = max(5, len(f"{sum(r['calls'] for r in rows):,}"))
+ cost_w = max(4, max((len(money(float(r["cost"]))) for r in display_rows), default=0))
+ avg_w = max(
+ 6,
+ max(
+ (len(money(float(r["cost"]) / r["calls"])) for r in display_rows if r["calls"]),
+ default=0,
+ ),
+ )
+ token_w = max(
+ 6, max((len(human_tokens(int(r["tokens_total"]))) for r in display_rows), default=0)
+ )
+ show_tokens = inner >= calls_w + cost_w + token_w + 19
+ show_avg = inner >= calls_w + cost_w + token_w + avg_w + 20
+ tail = (
+ calls_w
+ + cost_w
+ + 2
+ + (token_w + 1 if show_tokens else 0)
+ + (avg_w + 1 if show_avg else 0)
+ )
+ name_w = max(4, inner - tail - 2)
+ header = f" {pad('Name', name_w)} {'Calls':>{calls_w}}"
+ if show_avg:
+ header += f" {'$/call':>{avg_w}}"
+ if show_tokens:
+ header += f" {'Tokens':>{token_w}}"
+ header += f" {'Cost':>{cost_w}}"
+ body = []
+ for row in display_rows:
+ avg = row["cost"] / row["calls"] if row["calls"] else 0
+ body.append(
+ f" {pad(shorten(str(row['name']), name_w), name_w)} {row['calls']:>{calls_w},}"
+ + (f"{money(avg):>{avg_w + 1}}" if show_avg else "")
+ + (
+ f"{human_tokens(int(row['tokens_total'])):>{token_w + 1}}"
+ if show_tokens
+ else ""
)
- for bucket, key in ((by_tool, r["tool"]), (by_server, tool_namespace(r["tool"]))):
- it = bucket[key]
- it["calls"] += r["calls"]
- it["cost"] += cost
- it["tokens"] += r["tokens_total"]
- it["cache_read"] += r["cache_read"]
- it["cache_write"] += r["cache_write"]
- it["output"] += r["output"]
-
- def table_rows(bucket: dict[str, dict]) -> list[tuple]:
- ordered = sorted(
- bucket.items(), key=lambda kv: (kv[1]["cost"], kv[1]["tokens"]), reverse=True
+ + f" {money(row['cost']):>{cost_w}}"
)
- return [
- (
- name,
- it["calls"],
- it["cost"],
- it["tokens"],
- it["cache_read"],
- it["cache_write"],
- it["output"],
+ total = body.pop() if len(display_rows) > len(rows) else None
+ box = self._ruled_box(title, header, body, total, [], width)
+ start = offset + (self._ruled_body_start or 0)
+ for i in range(len(rows)):
+ self._tool_header_at[start + i] = ordinal + i
+ selected = self.app._tool_cursor
+ if ordinal <= selected < ordinal + len(rows):
+ self._tool_cursor_line = start + selected - ordinal
+ return box
+
+ def _tool_detail(self, workflow: Workflow, width: int, projection: dict) -> list[str]:
+ drill = self.app.active_tool_drill
+ ranking = next(r for r in projection["rankings"] if (r["kind"], r["name"]) == drill)
+ calls = [
+ row
+ for row in projection["calls"]
+ if (row.get("tool") if drill[0] == "tool" else row.get("namespace")) == drill[1]
+ ]
+ all_tools = [r for r in projection["rankings"] if r["kind"] == "tool"]
+ total_cost = sum(r["cost"] for r in all_tools)
+ total_tokens = sum(r["tokens_total"] for r in all_tools)
+ back = self._key("main", "back")
+ lines = self._subagent_wrap(
+ [f"# {drill[0].capitalize()} · {drill[1]} {back}: back to rankings", ""], width
+ )
+ avg_cost = ranking["cost"] / ranking["calls"] if ranking["calls"] else 0
+ avg_tokens = ranking["tokens_total"] / ranking["calls"] if ranking["calls"] else 0
+ lines += self._sectioned_box(
+ "# Contribution",
+ [
+ self._subagent_wrap(
+ [
+ f"{ranking['calls']:,} calls cost {money(ranking['cost'])} ({pct(ranking['cost'], total_cost)}) tokens {human_tokens(int(ranking['tokens_total']))} ({pct(ranking['tokens_total'], total_tokens)})",
+ f"Per call: {money(avg_cost)} {human_tokens(int(avg_tokens))} attributed tokens",
+ ],
+ width - 4,
)
- for name, it in ordered
+ ],
+ width,
+ [],
+ )
+ categories = (
+ ("Uncached input", "input"),
+ ("Model output", "output"),
+ ("Reasoning", "reasoning"),
+ ("Cache read", "cache_read"),
+ ("Cache write", "cache_write"),
+ )
+ category_total = sum(float(ranking[key]) for _label, key in categories)
+ composition = []
+ if category_total > 0 and width >= 50:
+ slots = [(label, ranking[key], i) for i, (label, key) in enumerate(categories)]
+ composition = [
+ self._token_stack_line(slots, category_total, width - 4),
+ *self._token_legend_lines(
+ [(label, value, 0, slot) for label, value, slot in slots if value > 0],
+ width - 4,
+ ),
+ "",
]
+ token_rows = [
+ f"{label:<14} {int(ranking[key]):>16,} {pct(ranking[key], category_total):>6} avg {human_tokens(int(ranking[key] / ranking['calls'])) if ranking['calls'] else '-'}"
+ for label, key in categories
+ ]
+ if ranking["cache_write_1h"]:
+ token_rows.append(f" of cache writes, 1h: {int(ranking['cache_write_1h']):,} (subset)")
+ token_rows.append(f"Recorded total: {int(ranking['tokens_total']):,}")
+ lines += [""] + self._sectioned_box(
+ "# Attributed token categories",
+ [composition + self._subagent_wrap(token_rows, width - 4)],
+ width,
+ [],
+ )
- lines = self._tool_treemap_box(by_tool, width, treemap_height)
- lines += self._model_table(
- table_rows(by_tool), "# Tools — this session", width, "Tool", "Calls", price_split=False
+ models = projection["models"].get(drill, {})
+ model_rows = []
+ wide = width >= 116
+ inner = max(1, width - self.BOX_CHROME)
+ model_cost_w = max(
+ 4, max((len(money(float(item["cost"]))) for item in models.values()), default=0)
+ )
+ show_total_tokens = wide or inner >= model_cost_w + 30
+ name_w = max(
+ 4,
+ inner
+ - (70 + model_cost_w if wide else 9 + model_cost_w + (10 if show_total_tokens else 0)),
+ )
+ header = f" {pad('Model', name_w)} {'Calls':>5}"
+ if show_total_tokens:
+ header += f" {'Tokens':>9}"
+ if wide:
+ header += f" {'Input':>9} {'Model out':>9} {'Reason':>9} {'CacheR':>9} {'CacheW':>9}"
+ header += f" {'Cost':>{model_cost_w}}"
+ for model, item in sorted(
+ models.items(), key=lambda kv: (kv[1]["cost"], kv[1]["tokens_total"]), reverse=True
+ ):
+ line = f" {pad(shorten(model, name_w), name_w)} {item['calls']:>5}"
+ if show_total_tokens:
+ line += f" {human_tokens(int(item['tokens_total'])):>9}"
+ if wide:
+ line += " " + " ".join(
+ f"{human_tokens(int(item[key])):>9}"
+ for key in ("input", "output", "reasoning", "cache_read", "cache_write")
+ )
+ line += f" {money(item['cost']):>{model_cost_w}}"
+ model_rows.append(line)
+ lines += [""] + self._ruled_box(
+ "# Exact attributed usage by model", header, model_rows, None, [], width
+ )
+ if not wide:
+ for model, item in sorted(
+ models.items(), key=lambda kv: (kv[1]["cost"], kv[1]["tokens_total"]), reverse=True
+ ):
+ split = (
+ f" {shorten(model, 28)}: input {human_tokens(int(item['input']))} · "
+ f"model output {human_tokens(int(item['output']))} · "
+ f"reasoning {human_tokens(int(item['reasoning']))} · "
+ f"cache read {human_tokens(int(item['cache_read']))} · "
+ f"cache write {human_tokens(int(item['cache_write']))}"
+ )
+ if item["cache_write_1h"]:
+ split += f" (1h {human_tokens(int(item['cache_write_1h']))}, subset)"
+ lines += self._subagent_wrap([split], width)
+
+ aggregate_calls = int(ranking["calls"])
+ recovered_calls = len(calls)
+ recovered_tokens = sum(float(call.get("tokens_total") or 0) for call in calls)
+ recovered_cost = sum(float(call.get("cost") or 0) for call in calls)
+ call_state = "complete" if recovered_calls == aggregate_calls else "partial"
+ token_state = (
+ "complete" if abs(recovered_tokens - ranking["tokens_total"]) < 0.01 else "partial"
+ )
+ cost_state = (
+ "complete" if abs(recovered_cost - float(ranking["cost"])) < 0.00005 else "partial"
+ )
+ ledger = (
+ f"Ledger coverage — calls {call_state}: {recovered_calls}/{aggregate_calls}; "
+ f"tokens {token_state}: {human_tokens(round(recovered_tokens))}/{human_tokens(round(ranking['tokens_total']))}; "
+ f"cost {cost_state}: {money(recovered_cost)}/{money(float(ranking['cost']))}."
+ )
+ if not self.session_supports_turns(workflow.id):
+ ledger = f"Call ledger unavailable: aggregate source reports {aggregate_calls} calls but has no Turns timeline."
+ lines += self._subagent_wrap(
+ ["", ledger, "Timestamps below are owning-turn timestamps."], width
)
- lines.append("")
- lines.extend(
- self._model_table(
- table_rows(by_server),
- "# By server / namespace",
- width,
- "Server",
- "Calls",
- price_split=False,
+ if not calls:
+ return lines
+
+ inner = max(1, width - self.BOX_CHROME)
+ call_cost_w = max(4, max(len(money(float(call.get("cost") or 0))) for call in calls))
+ call_token_w = max(
+ 6, max(len(human_tokens(int(call.get("tokens_total") or 0))) for call in calls)
+ )
+ room = inner - (13 + call_token_w + call_cost_w)
+ model_w = min(24, max(0, room)) if room >= 11 else 0
+ room -= model_w + (1 if model_w else 0)
+ time_w = 8 if room >= 9 else 0
+ room -= time_w + (1 if time_w else 0)
+ agent_w = min(12, room - 1) if room >= 9 else 0
+ room -= agent_w + (1 if agent_w else 0)
+ tool_w = min(24, room - 1) if drill[0] == "namespace" and room >= 9 else 0
+ header = f" {'#':>3} {'Turn':>5}"
+ if time_w:
+ header += f" {'Time':<{time_w}}"
+ if tool_w:
+ header += f" {pad('Tool', tool_w)}"
+ if model_w:
+ header += f" {pad('Model', model_w)}"
+ if agent_w:
+ header += f" {pad('Agent', agent_w)}"
+ header += f" {'Tokens':>{call_token_w}} {'Cost':>{call_cost_w}}"
+ body = []
+ for i, call in enumerate(calls, start=1):
+ raw_time = str(call.get("time") or "")
+ time = (
+ raw_time[:19] if time_w == 19 else raw_time[11:19] if len(raw_time) >= 19 else "-"
)
+ agent = ("↳ " if call.get("depth") else "") + str(call.get("agent") or "-")
+ line = f" {i:>3} {int(call['turn_index']) + 1:>5}"
+ if time_w:
+ line += f" {time:<{time_w}}"
+ if tool_w:
+ line += f" {pad(shorten(short_tool_name(str(call['tool'])), tool_w), tool_w)}"
+ if model_w:
+ line += (
+ f" {pad(shorten(str(call.get('model_name') or 'unknown'), model_w), model_w)}"
+ )
+ if agent_w:
+ line += f" {pad(shorten(agent, agent_w), agent_w)}"
+ line += f" {human_tokens(int(call.get('tokens_total') or 0)):>{call_token_w}} {money(float(call.get('cost') or 0)):>{call_cost_w}}"
+ body.append(line)
+ offset = len(lines) + 1
+ box = self._ruled_box("# Calls — chronological", header, body, None, [], width)
+ start = offset + (self._ruled_body_start or 0)
+ self._tool_call_at = {start + i: i for i in range(len(calls))}
+ cur = max(0, min(self.app._tool_call_cursor, len(calls) - 1))
+ self._tool_cursor_line = start + cur
+ lines += [""] + box
+ lines += self._subagent_wrap(
+ [
+ f"{self._key('main', 'select')} / double-click opens the owning prompt's turn list; opening raw trace content remains a separate explicit {self._key('main', 'select')}. Model output is attributed LLM output, never tool-result bytes.",
+ ],
+ width,
)
- lines += [
- "",
- "· Tokens/cost are for the LLM turns that invoked each tool (split evenly across",
- "· a turn's tools), not the tool's own output size.",
- ]
return lines
def turn_costs(self, rows) -> list[float]:
diff --git a/src/opentab/web.py b/src/opentab/web.py
index 1769283..db57c16 100644
--- a/src/opentab/web.py
+++ b/src/opentab/web.py
@@ -29,6 +29,7 @@
model_price,
)
from opentab.themes import DEFAULT_THEME
+from opentab.tools import tool_calls_from_turns
from opentab.util import (
cached_share,
context_size,
@@ -301,10 +302,13 @@ def _machine_meta_payload(app: App) -> dict:
def session_extras(app: App, workflow_id: str) -> dict:
"""Return lazy drill-in data; empty capabilities remain hidden in the page."""
turns = []
+ turn_rows = []
+ curve = False
if app.session_supports_turns(workflow_id):
# Cumulative-delta backends cannot expose per-request context safely.
curve = app.session_supports_context_curve(workflow_id)
- for r in app.session_turn_rows(workflow_id):
+ turn_rows = app.session_turn_rows(workflow_id)
+ for r in turn_rows:
real = float(r.get("cost") or 0)
api = real or api_equivalent_cost(
r.get("model_name") or "",
@@ -340,7 +344,7 @@ def session_extras(app: App, workflow_id: str) -> dict:
# the causes the TUI renders; the client merely labels them.
expiries = []
if turns and curve:
- for m in cache_misses(app.session_turn_rows(workflow_id)):
+ for m in cache_misses(turn_rows):
if m.cause in ("waited", "reasoning"):
expiries.append(
{
@@ -354,7 +358,8 @@ def session_extras(app: App, workflow_id: str) -> dict:
}
)
tools = []
- if app.session_supports_tools(workflow_id):
+ supports_tools = app.session_supports_tools(workflow_id)
+ if supports_tools:
for r in app.session_tool_rows(workflow_id):
real = float(r.get("cost") or 0)
api = real or api_equivalent_cost(
@@ -374,16 +379,71 @@ def session_extras(app: App, workflow_id: str) -> dict:
"model": r.get("model_name") or "",
"real": _money6(real),
"api": _money6(api),
- "tokens": int(r.get("tokens_total") or 0),
+ "tokens": float(r.get("tokens_total") or 0),
+ "tok": [
+ float(r.get("input") or 0),
+ float(r.get("output") or 0),
+ float(r.get("reasoning") or 0),
+ float(r.get("cache_read") or 0),
+ float(r.get("cache_write") or 0),
+ float(r.get("cache_write_1h") or 0),
+ ],
}
)
+ tool_calls = []
+ calls = tool_calls_from_turns(turn_rows) if supports_tools else []
+ calls_per_turn: dict[int, int] = {}
+ for call in calls:
+ turn_index = int(call.get("turn_index") or 0)
+ calls_per_turn[turn_index] = calls_per_turn.get(turn_index, 0) + 1
+ for call in calls:
+ turn_index = int(call.get("turn_index") or 0)
+ source = turn_rows[turn_index]
+ count = calls_per_turn[turn_index]
+ turn_real = float(source.get("cost") or 0)
+ turn_api = turn_real or api_equivalent_cost(
+ source.get("model_name") or "",
+ source.get("input") or 0,
+ source.get("output") or 0,
+ source.get("reasoning") or 0,
+ source.get("cache_read") or 0,
+ source.get("cache_write") or 0,
+ source.get("cache_write_1h") or 0,
+ )
+ tool_calls.append(
+ {
+ "index": int(call.get("index") or 0),
+ "turnIndex": turn_index,
+ "callIndex": int(call.get("call_index") or 0),
+ "tool": str(call.get("tool") or "?"),
+ "ns": str(call.get("namespace") or "local"),
+ "time": str(call.get("time") or ""),
+ "agent": str(call.get("agent") or "-"),
+ "depth": int(call.get("depth") or 0),
+ "model": str(call.get("model_name") or ""),
+ "effort": str(call.get("effort") or ""),
+ "promptId": str(call.get("prompt_id") or ""),
+ "promptTitle": str(call.get("prompt_title") or ""),
+ "real": _money6(call.get("cost")),
+ "api": _money6(turn_api / count),
+ "tokens": float(call.get("tokens_total") or 0),
+ "tok": [
+ float(call.get("input") or 0),
+ float(call.get("output") or 0),
+ float(call.get("reasoning") or 0),
+ float(call.get("cache_read") or 0),
+ float(call.get("cache_write") or 0),
+ float(call.get("cache_write_1h") or 0),
+ ],
+ }
+ )
# Ship measurements; derive presentation-only context stats client-side.
context = None
if app.session_supports_context_curve(workflow_id):
points = []
windows = set()
model = ""
- for r in app.session_turn_rows(workflow_id):
+ for r in turn_rows:
if r.get("depth"):
continue
size = context_size(r)
@@ -414,7 +474,13 @@ def session_extras(app: App, workflow_id: str) -> dict:
"points": points,
"comp": comp,
}
- return {"turns": turns, "tools": tools, "context": context, "expiries": expiries}
+ return {
+ "turns": turns,
+ "tools": tools,
+ "toolCalls": tool_calls,
+ "context": context,
+ "expiries": expiries,
+ }
def html_command(app: App, args: argparse.Namespace) -> int:
diff --git a/src/opentab/webpage.py b/src/opentab/webpage.py
index d4294a8..6d58b11 100644
--- a/src/opentab/webpage.py
+++ b/src/opentab/webpage.py
@@ -253,7 +253,8 @@
border-radius:6px;background:var(--panel2)}
.tool-tile{position:absolute;border:2px solid var(--panel);background-clip:padding-box;
padding:9px 11px;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-start;
- line-height:1.25;container-type:size}
+ line-height:1.25;container-type:size;font:inherit;text-align:left;color:inherit}
+.tool-tile.click{cursor:pointer}.tool-tile.click:hover,.tool-tile.click:focus-visible{filter:brightness(1.12);outline:2px solid var(--accent);outline-offset:-3px}
.tool-tile .tn{font-size:clamp(12px,2.3cqw,19px);font-weight:800;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
.tool-tile .tv{font-size:clamp(10px,1.7cqw,14px);margin-top:3px;white-space:nowrap;opacity:.9}
@@ -261,6 +262,14 @@
font-weight:600}
.tool-tile.tiny{padding:2px}
.tool-table{margin-top:2px}
+.tool-ranks{display:grid;grid-template-columns:minmax(0,3fr) minmax(260px,2fr);gap:16px;margin-top:4px}
+.tool-ranks > .pane{min-width:0}
+.tool-detail-head{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px}
+.tool-detail-head .hbtn{margin-left:auto}.tool-detail-note{margin:8px 0 12px}
+.tool-call-table table{table-layout:auto}.tool-call-table td.grow{min-width:150px;white-space:normal;overflow-wrap:anywhere}
+.tool-owner{background:color-mix(in srgb,var(--accent) 16%,transparent);box-shadow:inset 3px 0 var(--accent)}
+.tool-owner:focus{outline:2px solid var(--accent);outline-offset:-2px}
+@media (max-width:760px){.tool-ranks{grid-template-columns:minmax(0,1fr)}.tool-detail-head .hbtn{margin-left:0}.tool-call-table td.grow{min-width:180px}}
@media (max-width:600px){.tool-map{height:170px}.tool-map-head{align-items:flex-start;flex-direction:column;gap:1px}}
tr.prompt-row td{color:var(--accent);padding-top:9px;font-weight:600}
@@ -644,9 +653,21 @@
// one exception being the hop straight back into the scope a drill was armed in, which
// both Esc and the browser's Back button make by restoring that exact hash.
function resetScopeState() {
+ if (typeof EXTRAS !== 'undefined' && EXTRAS.loading) {
+ const sc = curScope();
+ // popstate can start loading before hashchange resets the same destination.
+ if (sc.kind !== 's' || sc.id !== EXTRAS.id) {
+ EXTRAS_REQUEST += 1;
+ EXTRAS.id = null;
+ EXTRAS.loading = false;
+ }
+ }
+ if (typeof TOOL_NAV !== 'undefined') TOOL_NAV += '.';
FILTER = '';
EXPANDED.clear();
NODE_DRILL = null;
+ if (typeof TOOL_DRILL !== 'undefined') TOOL_DRILL = null;
+ if (typeof TOOL_TURN !== 'undefined') TOOL_TURN = null;
clearNodePrompt();
const back = !!RETURN && location.hash === RETURN.from;
MSUB = back ? RETURN.msub : null;
@@ -671,10 +692,17 @@
const VIEW = { calYear: null };
// Prompt ids may repeat, so a drill is an ordinal valid only for the loaded session.
let TURN_DRILL = null;
+// A tool/server drill is transient and identified by its exact payload key, not row order.
+let TOOL_DRILL = null;
+let TOOL_TURN = null;
+let TOOL_RETURN_FOCUS = null;
+// A per-page generation makes same-URL history entries from a reload fail closed.
+let TOOL_NAV = Math.random().toString(36);
// Payload indices, never titles or representative models, identify executions.
let NODE_DRILL = null;
let NODE_PROMPT = null;
-let EXTRAS = { id: null, loading: false, turns: [], tools: [], context: null, expiries: [] };
+let EXTRAS = { id: null, loading: false, turns: [], tools: [], toolCalls: [], context: null, expiries: [] };
+let EXTRAS_REQUEST = 0;
const TREND_TABS = ['Daily', 'Weekly', 'Monthly', 'Calendar', 'Models', 'Providers', 'Projects', 'Harnesses'].concat(META.machines ? ['Machines'] : []);
let TRENDS = { open: false, tab: 'Daily', monthIdx: 0, weekIdx: 0, yearIdx: 0, drill: null, drillTab: null, sort: 'cost', desc: true };
const PRICE_VIEWS = [['flat', 'flat list'], ['family', 'by vendor'], ['provider', 'by provider'], ['all', 'models.dev']];
@@ -721,6 +749,12 @@
const cost = w => MODE === 'api' ? w.api : w.real;
const rootCost = w => MODE === 'api' ? w.apiRoot : w.realRoot;
const mCost = r => MODE === 'api' ? r.api : r.real;
+function setCostMode(mode) {
+ const focus = toolFocusKey();
+ MODE = mode;
+ render(false);
+ focusToolTarget(focus, focus === 'owner-turn');
+}
const shortPath = p => META.home && p.startsWith(META.home) ? '~' + p.slice(META.home.length) : p;
const projName = p => { const parts = shortPath(p).split('/').filter(Boolean);
return parts.length ? parts[parts.length - 1] : (p || '(no project)'); };
@@ -1008,6 +1042,7 @@
if (opts.rowLabel && opts.onRow) {
row.setAttribute('tabindex', '0');
row.setAttribute('aria-label', opts.rowLabel(r));
+ if (opts.rowKey) row.setAttribute('data-tool-focus', opts.rowKey(r));
row.addEventListener('keydown', e => {
if (e.metaKey || e.ctrlKey || e.altKey || STARTUP_WARNINGS.length || WHATS_NEW_OPEN || THEMEPICK
|| WHATIF.open || PRICES.open || TRENDS.open || RANGE.pick) return;
@@ -1844,8 +1879,10 @@
let cum = 0;
const rows = g.indices.map(i => {
const t = turns[i];
+ const owner = TOOL_TURN && TOOL_TURN.turnIndex === i;
cum += mCost(t);
- return h('tr', null,
+ return h('tr', owner ? { class: 'tool-owner', tabindex: '-1', 'data-tool-focus': 'owner-turn',
+ 'aria-label': 'Owning turn ' + (i + 1) + ' for selected tool call' } : null,
h('td', { class: 'r dim' }, String(i + 1)),
h('td', { class: 'dim' }, t.time.slice(5, 19).replace('T', ' ')),
h('td', { class: 'grow' }, modelCell(t.model)),
@@ -1857,12 +1894,18 @@
h('td', { class: 'r' }, moneyCell(mCost(t))),
h('td', { class: 'r dim' }, money(cum)));
});
+ const back = TOOL_TURN
+ ? h('button', { class: 'hbtn', 'data-tool-focus': 'turn-back', onclick: closeToolTurn },
+ '← back to ' + (TOOL_DRILL.kind === 'tool' ? 'tool ' : 'namespace ') + TOOL_DRILL.value)
+ : h('a', { class: 'rowlink', onclick: () => { TURN_DRILL = null; render(false); } }, '← back to the prompts');
return h('div', null,
h('div', { class: 'hint' },
- h('a', { class: 'rowlink', onclick: () => { TURN_DRILL = null; render(false); } }, '← back to the prompts'),
+ back,
' · prompt ' + (n + 1) + ' of ' + groups.length + ' — ' + g.turns + ' turn'
+ (g.turns === 1 ? '' : 's') + ' · ' + hTok(g.tokens) + ' · ' + money(g.cost)
- + ' · cached ' + pct(g.cached)),
+ + ' · cached ' + pct(g.cached)
+ + (TOOL_TURN ? ' · selected call ' + (TOOL_TURN.callIndex + 1)
+ + ' owns highlighted turn ' + (TOOL_TURN.turnIndex + 1) : '')),
h('div', { class: 'prompt-full' }, g.full || '(no preceding prompt)'),
turnCostContextStrip(g.rows, { indices: g.indices }),
h('div', { class: 'scroll' }, h('table', null,
@@ -1877,14 +1920,87 @@
h('tbody', null, rows))));
}
-function toolsTable(toolRows) {
+const exactTok = v => Number(v || 0).toLocaleString('en-US', { maximumFractionDigits: 2 });
+function toolAgg(toolRows, keyFn, nameKey) {
+ const agg = new Map();
+ for (const r of toolRows) {
+ const key = keyFn(r);
+ let a = agg.get(key);
+ if (!a) { a = { tool: '', ns: '', calls: 0, real: 0, api: 0, tokens: 0, tok: [0,0,0,0,0,0] }; a[nameKey] = key; agg.set(key, a); }
+ a.calls += r.calls || 0; a.real += r.real || 0; a.api += r.api || 0; a.tokens += r.tokens || 0;
+ (r.tok || []).forEach((v, i) => { a.tok[i] += v || 0; });
+ }
+ return [...agg.values()];
+}
+function openToolDrill(kind, value) {
+ if (history.state && history.state.session === curScope().id) history.replaceState(null, '', location.hash);
+ TOOL_DRILL = { kind, value };
+ TOOL_TURN = null;
+ TOOL_RETURN_FOCUS = 'rank:' + kind + ':' + value;
+ history.pushState({ toolDrill: { kind, value }, rankFocus: TOOL_RETURN_FOCUS,
+ session: curScope().id, toolNav: TOOL_NAV }, '', location.hash);
+ render(false);
+ focusToolTarget('back');
+}
+function closeToolDrill() {
+ const state = history.state;
+ if (state && state.session === curScope().id && state.toolDrill && state.toolNav === TOOL_NAV) history.back();
+ else {
+ const target = TOOL_RETURN_FOCUS;
+ TOOL_DRILL = null; TOOL_TURN = null; render(false); focusToolTarget(target);
+ }
+}
+function toolFocusKey() {
+ const active = document.activeElement;
+ return active && active.getAttribute ? active.getAttribute('data-tool-focus') : null;
+}
+function focusToolTarget(key, scroll = false) {
+ if (!key) return;
+ const target = Array.from(document.querySelectorAll('[data-tool-focus]'))
+ .find(el => el.getAttribute('data-tool-focus') === key);
+ if (target) {
+ target.focus();
+ if (scroll && target.scrollIntoView) target.scrollIntoView({ block: 'center', inline: 'nearest' });
+ }
+}
+function abandonToolNavigation() {
+ if (!TOOL_DRILL && !TOOL_TURN) return;
+ TOOL_NAV += '.'; TOOL_DRILL = null; TOOL_TURN = null; TOOL_RETURN_FOCUS = null;
+ if (history.state && (history.state.toolDrill || history.state.toolTurn))
+ history.replaceState(null, '', location.hash);
+}
+function jumpToToolCall(call) {
+ const groups = turnGroupRows(EXTRAS.turns);
+ const group = groups.findIndex(g => g.indices.includes(call.turnIndex));
+ if (group < 0) return;
+ const sourceFocus = 'call:' + call.index;
+ if (history.state && history.state.toolDrill)
+ history.replaceState({ ...history.state, toolFocus: sourceFocus }, '', location.hash);
+ TOOL_TURN = { callIndex: call.index, turnIndex: call.turnIndex, group };
+ TAB = 'Turns'; TURN_DRILL = group;
+ history.pushState({ toolTurn: { ...TOOL_TURN }, toolDrill: { ...TOOL_DRILL },
+ rankFocus: TOOL_RETURN_FOCUS, session: curScope().id, toolNav: TOOL_NAV }, '', location.hash);
+ render(false);
+ focusToolTarget('owner-turn', true);
+}
+function closeToolTurn() {
+ const state = history.state;
+ if (state && state.toolTurn && state.toolNav === TOOL_NAV) history.back();
+ else {
+ TOOL_TURN = null; TAB = 'Tools'; TURN_DRILL = null; render(false); focusToolTarget('back');
+ }
+}
+function toolsTable(toolRows, calls) {
+ if (TOOL_DRILL) return toolDetail(toolRows, calls, TOOL_DRILL);
const agg = new Map();
for (const r of toolRows) {
let a = agg.get(r.tool);
- if (!a) { a = { tool: r.tool, ns: r.ns, calls: 0, real: 0, api: 0, tokens: 0 }; agg.set(r.tool, a); }
+ if (!a) { a = { tool: r.tool, ns: r.ns, calls: 0, real: 0, api: 0, tokens: 0, tok: [0,0,0,0,0,0] }; agg.set(r.tool, a); }
a.calls += r.calls || 0; a.real += r.real; a.api += r.api; a.tokens += r.tokens;
+ (r.tok || []).forEach((v, i) => { a.tok[i] += v || 0; });
}
const rows = [...agg.values()];
+ const namespaces = toolAgg(toolRows, r => r.ns || 'local', 'ns');
const peak = Math.max(...rows.map(mCost), 0);
const grid = table('t-s-tools', [
{ key: 'tool', label: 'Tool', asc: true, cls: 'grow' },
@@ -1893,13 +2009,135 @@
{ key: 'cost', label: 'Cost', align: 'r', sortVal: mCost, fmt: r => barCell(mCost(r), peak) },
{ key: 'tokens', label: 'Tokens', align: 'r', fmt: r => hTok(r.tokens) },
], rows, { defaultSort: { key: 'cost', desc: true },
+ rowLabel: r => 'Open tool ' + r.tool,
+ rowKey: r => 'rank:tool:' + r.tool,
+ onRow: r => openToolDrill('tool', r.tool),
totals: { tool: 'TOTAL', calls: sum(rows, r => r.calls),
cost: moneyCell(sum(rows, mCost)), tokens: hTok(sum(rows, r => r.tokens)) } });
- return h('div', null, toolTreemap(rows), h('div', { class: 'tool-table' }, grid),
+ const nsPeak = Math.max(...namespaces.map(mCost), 0);
+ const nsGrid = table('t-s-tool-ns', [
+ { key: 'ns', label: 'Namespace', asc: true, cls: 'grow' },
+ { key: 'calls', label: 'Calls', align: 'r' },
+ { key: 'cost', label: 'Cost', align: 'r', sortVal: mCost, fmt: r => barCell(mCost(r), nsPeak) },
+ { key: 'tokens', label: 'Tokens', align: 'r', fmt: r => hTok(r.tokens) },
+ ], namespaces, { defaultSort: { key: 'cost', desc: true },
+ rowLabel: r => 'Open namespace ' + r.ns,
+ rowKey: r => 'rank:ns:' + r.ns,
+ onRow: r => openToolDrill('ns', r.ns),
+ totals: { ns: 'TOTAL', calls: sum(namespaces, r => r.calls),
+ cost: moneyCell(sum(namespaces, mCost)), tokens: hTok(sum(namespaces, r => r.tokens)) } });
+ const totalCalls = sum(rows, r => r.calls), totalCost = sum(rows, mCost);
+ const ledgerCalls = (calls || []).length, ledgerCost = sum(calls || [], mCost);
+ const totalTokens = sum(rows, r => r.tokens || 0), ledgerTokens = sum(calls || [], r => r.tokens || 0);
+ const costTolerance = Math.max(0.000001, ledgerCalls * 0.0000006);
+ const coverage = ledgerCalls === totalCalls && Math.abs(ledgerCost - totalCost) <= costTolerance
+ && Math.abs(ledgerTokens - totalTokens) < 0.01
+ ? (ledgerCalls ? 'call ledger covers all aggregate calls and attributed cost' : 'aggregate attribution available; no individual call ledger retained')
+ : 'aggregate coverage differs from the call ledger: ' + totalCalls + ' aggregate calls / ' + ledgerCalls
+ + ' retained calls, ' + money(totalCost) + ' / ' + money(ledgerCost) + ' attributed cost, '
+ + exactTok(totalTokens) + ' / ' + exactTok(ledgerTokens) + ' attributed tokens';
+ return h('div', null,
+ tiles([
+ ['calls', exactTok(totalCalls), rows.length + ' tools'],
+ ['tools', String(rows.length), namespaces.length + ' namespaces'],
+ ['namespaces', String(namespaces.length), 'tool/server groups'],
+ ['cost / call', totalCalls ? money(totalCost / totalCalls) : '-', MODE === 'api' ? 'API-equivalent' : 'recorded', true],
+ ]),
+ toolTreemap(rows),
+ h('div', { class: 'tool-ranks' },
+ pane('Tools ranking', h('div', { class: 'tool-table' }, grid)),
+ pane('Namespaces ranking', h('div', { class: 'tool-table' }, nsGrid))),
+ h('div', { class: 'hint tool-detail-note' }, coverage),
h('div', { class: 'hint' }, 'cost and tokens belong to the LLM turns that invoked each tool; '
+ 'a multi-tool turn is split evenly'));
}
+function toolDetail(toolRows, calls, drill) {
+ const match = r => drill.kind === 'tool' ? r.tool === drill.value : r.ns === drill.value;
+ const aggregate = toolRows.filter(match), ledger = (calls || []).filter(match);
+ const total = toolAgg(aggregate, () => drill.value, drill.kind === 'tool' ? 'tool' : 'ns')[0]
+ || { calls: 0, real: 0, api: 0, tokens: 0, tok: [0,0,0,0,0,0] };
+ const allCost = sum(toolRows, mCost), allTokens = sum(toolRows, r => r.tokens || 0);
+ const models = toolAgg(aggregate, r => r.model || 'unknown', 'tool').map(r => ({ ...r, model: r.tool }));
+ const modelPeak = Math.max(...models.map(mCost), 0);
+ const categories = ['Uncached input', 'Model output', 'Reasoning', 'Cache read', 'Cache write'];
+ const categoryTotal = sum(total.tok.slice(0, 5), v => v);
+ const series = tokSeries();
+ const categoryRows = categories.map((name, i) => ({ name, color: series[i], tokens: total.tok[i] || 0,
+ share: categoryTotal ? (total.tok[i] || 0) / categoryTotal : 0 }));
+ const composition = categoryTotal > 0 ? h('div', { class: 'sbar' },
+ h('div', { class: 'lbl' }, 'Share of attributed token categories'),
+ h('div', { class: 'track' }, categoryRows.filter(r => r.tokens > 0).map(r => h('div', {
+ class: 'seg', style: 'flex:' + r.share + ' 0 0;background:' + r.color + ';color:' + inkOn(r.color),
+ title: r.name + ' · ' + exactTok(r.tokens) + ' · ' + fPct(r.share),
+ }, r.share > 0.075 ? fPct(r.share) : null)))) : null;
+ const modelGrid = table('t-s-tool-models', [
+ { key: 'model', label: 'Exact model', asc: true, cls: 'grow', fmt: r => modelCell(r.model) },
+ { key: 'calls', label: 'Calls', align: 'r', fmt: r => exactTok(r.calls) },
+ { key: 'cost', label: 'Cost', align: 'r', sortVal: mCost, fmt: r => barCell(mCost(r), modelPeak) },
+ { key: 'tokens', label: 'Attributed tokens', align: 'r', fmt: r => exactTok(r.tokens) },
+ ], models, { defaultSort: { key: 'cost', desc: true } });
+ const ledgerCost = sum(ledger, mCost), ledgerTokens = sum(ledger, r => r.tokens || 0);
+ const selectedTolerance = Math.max(0.000001, ledger.length * 0.0000006);
+ const selectedCovered = ledger.length === total.calls
+ && Math.abs(ledgerCost - mCost(total)) <= selectedTolerance
+ && Math.abs(ledgerTokens - total.tokens) < 0.01;
+ let selectedCoverage;
+ if (selectedCovered) selectedCoverage = 'Selected call ledger matches aggregate attribution: '
+ + exactTok(total.calls) + ' calls · ' + exactTok(total.tokens) + ' tokens · ' + money(mCost(total)) + '.';
+ else if (!ledger.length && (calls || []).length)
+ selectedCoverage = 'No retained individual calls match this selection; the session ledger contains '
+ + (calls || []).length + ' calls for other tools. Aggregate: ' + exactTok(total.calls) + ' calls · '
+ + exactTok(total.tokens) + ' tokens · ' + money(mCost(total)) + '.';
+ else if (!ledger.length)
+ selectedCoverage = 'This source retained aggregate attribution only for this selection: '
+ + exactTok(total.calls) + ' calls · ' + exactTok(total.tokens) + ' tokens · ' + money(mCost(total)) + '.';
+ else selectedCoverage = 'Selected aggregate / retained ledger differ: ' + exactTok(total.calls) + ' / '
+ + ledger.length + ' calls · ' + exactTok(total.tokens) + ' / ' + exactTok(ledgerTokens)
+ + ' tokens · ' + money(mCost(total)) + ' / ' + money(ledgerCost) + '.';
+ const callGrid = ledger.length ? table('t-s-tool-calls', [
+ { key: 'index', label: 'Call', asc: true, align: 'r', fmt: r => [String(r.index + 1),
+ h('div', { class: 'mut' }, 'turn ' + (r.turnIndex + 1) + ' · #' + (r.callIndex + 1))] },
+ { key: 'time', label: 'Owning-turn time', asc: true, fmt: r => dt(r.time) || '-' },
+ ...(drill.kind === 'ns' ? [{ key: 'tool', label: 'Tool', cls: 'grow' }] : []),
+ { key: 'promptTitle', label: 'Owning prompt', cls: 'grow', fmt: r => r.promptTitle || '(no preceding prompt)' },
+ { key: 'agent', label: 'Owner', cls: 'grow', fmt: r => [
+ h('div', null, r.depth ? '↳ ' + r.agent : r.agent), h('div', { class: 'mut' }, modelCell(r.model || '-'))] },
+ { key: 'tokens', label: 'Attributed tokens', align: 'r', fmt: r => exactTok(r.tokens) },
+ { key: 'cost', label: 'Attributed cost', align: 'r', sortVal: mCost, fmt: r => moneyCell(mCost(r)) },
+ ], ledger, { defaultSort: { key: 'index', desc: false },
+ rowLabel: r => 'Open owning turn ' + (r.turnIndex + 1) + ' for call ' + (r.index + 1),
+ rowKey: r => 'call:' + r.index,
+ onRow: r => jumpToToolCall(r) }) : h('div', { class: 'hint' },
+ 'No matching individual calls are retained; aggregate tool and model attribution remains available.');
+ const categoryGrid = h('div', { class: 'scroll' }, h('table', null,
+ h('thead', null, h('tr', null,
+ h('th', null, 'Category'), h('th', { class: 'r' }, 'Tokens'), h('th', { class: 'r' }, 'Share'))),
+ h('tbody', null, categoryRows.map(r => h('tr', null,
+ h('td', null, h('span', { class: 'lgd', style: 'background:' + r.color }), r.name), h('td', { class: 'r' }, exactTok(r.tokens)),
+ h('td', { class: 'r dim' }, fPct(r.share))))),
+ h('tfoot', null, h('tr', null,
+ h('td', null, '1h cache write'), h('td', { class: 'r' }, exactTok(total.tok[5] || 0)),
+ h('td', { class: 'r dim' }, 'subset of cache write')))));
+ return h('div', null,
+ h('div', { class: 'tool-detail-head' },
+ h('span', { class: 'hint' }, (drill.kind === 'tool' ? 'tool' : 'namespace') + ' detail'),
+ h('h2', { class: 'title' }, drill.value),
+ h('button', { class: 'hbtn', 'data-tool-focus': 'back', onclick: closeToolDrill }, 'esc back to rankings')),
+ tiles([
+ ['calls', exactTok(total.calls), ledger.length ? ledger.length + ' retained individually' : 'aggregate only'],
+ ['cost', money(mCost(total)), pct(mCost(total), allCost) + ' of tool-attributed cost', true],
+ ['tokens', exactTok(total.tokens), pct(total.tokens, allTokens) + ' of tool-attributed tokens'],
+ ['cost / call', total.calls ? money(mCost(total) / total.calls) : '-', MODE === 'api' ? 'API-equivalent' : 'recorded', true],
+ ['tokens / call', total.calls ? exactTok(total.tokens / total.calls) : '-', 'attributed average'],
+ ]),
+ pane('Attributed token categories', composition, categoryGrid),
+ pane('Exact model attribution', modelGrid),
+ h('div', { class: 'hint tool-detail-note' }, selectedCoverage),
+ pane('Chronological individual calls', h('div', { class: 'tool-call-table' }, callGrid)),
+ h('div', { class: 'hint' }, 'Times are owning-turn timestamps. Output is model output, not a tool result size. Click a call to open its owning prompt in Turns.'));
+}
+
function binaryTreemap(items, x, y, w, h, out) {
if (!items.length || w <= 0 || h <= 0) return out;
if (items.length === 1) { out.push({ ...items[0], x, y, w, h }); return out; }
@@ -1958,7 +2196,7 @@
: (r.value / r.calls) >= 0.01 ? money(r.value / r.calls) + '/call'
: (r.value / r.calls) < 0.0001 ? '<$0.0001/call'
: '$' + (r.value / r.calls).toFixed(4).replace(/0+$/, '') + '/call';
- const map = h('div', { class: 'tool-map', 'aria-hidden': 'true' });
+ const map = h('div', { class: 'tool-map', 'aria-label': 'Tool-attributed spend treemap' });
// Reflow only this chart; a global resize render would discard transient UI state.
let frame = 0;
const draw = () => {
@@ -1977,11 +2215,14 @@
const rateEl = (rate && r.w >= 110 && r.h >= 64)
? h('span', { class: 'tr' }, rate + ' · ' + r.calls + ' call' + (r.calls === 1 ? '' : 's'))
: null;
- return h('div', {
- class: 'tool-tile' + (roomy ? '' : ' tiny'),
+ const clickable = r.tool !== 'Other';
+ return h(clickable ? 'button' : 'div', {
+ class: 'tool-tile' + (roomy ? '' : ' tiny') + (clickable ? ' click' : ''),
style: 'left:' + r.x + 'px;top:' + r.y + 'px;width:' + r.w + 'px;height:' + r.h
+ 'px;background:' + fill + ';color:' + inkOn(fill),
title: r.tool + ' · ' + amount + (rate ? ' · ' + rate : ''),
+ 'data-tool-focus': clickable ? 'rank:tool:' + r.tool : null,
+ onclick: clickable ? () => openToolDrill('tool', r.tool) : null,
}, roomy ? h('span', { class: 'tn' }, r.tool) : null, details, rateEl);
});
map.replaceChildren(...tiles_);
@@ -2516,8 +2757,8 @@
if (tree) root.appendChild(h('div', { class: 'hint' }, 'Click an execution for detail; focus a row and use j/k or arrows, then Enter. Model labels are representative, not a full model mix.'));
} else if (TAB === 'Turns') root.appendChild(pane('Turns · cost over time',
EXTRAS.loading ? h('div', { class: 'hint' }, 'loading turns…') : turnsTable(EXTRAS.turns, EXTRAS.expiries)));
- else if (TAB === 'Tools') root.appendChild(pane('Tools',
- EXTRAS.loading ? h('div', { class: 'hint' }, 'loading tools…') : toolsTable(EXTRAS.tools)));
+ else if (TAB === 'Tools') root.appendChild(pane('Tools explorer',
+ EXTRAS.loading ? h('div', { class: 'hint' }, 'loading tools…') : toolsTable(EXTRAS.tools, EXTRAS.toolCalls)));
else if (TAB === 'Context') {
root.appendChild(pane('Context · window usage',
EXTRAS.loading ? h('div', { class: 'hint' }, 'loading context…') : contextPane(EXTRAS.context)));
@@ -2537,7 +2778,7 @@
const ld = loading && (t === 'Turns' || t === 'Tools' || t === 'Context');
const cls = (t === TAB ? 'on ' : '') + (ld ? 'ld' : '');
bar.appendChild(h('button', { class: cls.trim() || null,
- onclick: () => { TAB = t; render(false); } }, t + (ld ? ' ⋯' : '')));
+ onclick: () => { if (t !== TAB) abandonToolNavigation(); TAB = t; render(false); } }, t + (ld ? ' ⋯' : '')));
});
}
function renderCrumbs(sc) {
@@ -2580,8 +2821,8 @@
right.textContent = '';
if (!META.demo) {
right.appendChild(h('div', { class: 'seg' },
- h('button', { class: MODE === 'real' ? 'on' : null, onclick: () => { MODE = 'real'; render(false); } }, 'actual $'),
- h('button', { class: MODE === 'api' ? 'on' : null, onclick: () => { MODE = 'api'; render(false); } }, 'what-if $')));
+ h('button', { class: MODE === 'real' ? 'on' : null, onclick: () => setCostMode('real') }, 'actual $'),
+ h('button', { class: MODE === 'api' ? 'on' : null, onclick: () => setCostMode('api') }, 'what-if $')));
}
if (META.demo) { }
else if (MODE === 'api') right.appendChild(h('span', { class: 'badge est' }, 'estimated · list prices'));
@@ -2605,14 +2846,20 @@
function ensureExtras(sc) {
if (sc.kind !== 's' || !META.serve || EXTRAS.id === sc.id) return;
+ const request = ++EXTRAS_REQUEST, session = sc.id;
TURN_DRILL = null;
- EXTRAS = { id: sc.id, loading: true, turns: [], tools: [], context: null, expiries: [] };
+ abandonToolNavigation();
+ EXTRAS = { id: sc.id, loading: true, turns: [], tools: [], toolCalls: [], context: null, expiries: [] };
fetch('/api/session/' + encodeURIComponent(sc.id)).then(r => r.json()).then(x => {
- EXTRAS = { id: sc.id, loading: false, turns: x.turns || [], tools: x.tools || [], context: x.context || null, expiries: x.expiries || [] };
+ const current = curScope();
+ if (request !== EXTRAS_REQUEST || current.kind !== 's' || current.id !== session || EXTRAS.id !== session) return;
+ EXTRAS = { id: sc.id, loading: false, turns: x.turns || [], tools: x.tools || [], toolCalls: x.toolCalls || [], context: x.context || null, expiries: x.expiries || [] };
render(false);
}).catch(err => {
+ const current = curScope();
+ if (request !== EXTRAS_REQUEST || current.kind !== 's' || current.id !== session || EXTRAS.id !== session) return;
console.error('session extras failed:', err);
- EXTRAS = { id: sc.id, loading: false, turns: [], tools: [], context: null, expiries: [] };
+ EXTRAS = { id: sc.id, loading: false, turns: [], tools: [], toolCalls: [], context: null, expiries: [] };
render(false);
});
}
@@ -3198,7 +3445,7 @@
if (next !== list.index) list.rows[next].go();
e.preventDefault();
} else if (e.key === 'Tab') {
- if (sc.kind === 's' && TAB === 'Subagents') return; // Native focus reaches execution rows and Back.
+ if (sc.kind === 's' && (TAB === 'Subagents' || TAB === 'Tools')) return; // Native focus reaches drill rows and Back.
const order = focusOrder();
const cur = order.indexOf(FOCUS);
FOCUS = order[((cur < 0 ? 0 : cur) + (e.shiftKey ? -1 : 1) + order.length) % order.length];
@@ -3207,10 +3454,12 @@
} else if (e.key === 'h' || e.key === 'ArrowLeft' || e.key === 'l' || e.key === 'ArrowRight') {
const i = tabs.indexOf(TAB);
const step = (e.key === 'h' || e.key === 'ArrowLeft') ? -1 : 1;
- TAB = tabs[(i + step + tabs.length) % tabs.length];
+ abandonToolNavigation(); TAB = tabs[(i + step + tabs.length) % tabs.length];
render(false);
} else if (e.key === 'Escape') {
if (NODE_DRILL != null && TAB === 'Subagents') { closeExecution(); e.preventDefault(); return; }
+ if (typeof TOOL_TURN !== 'undefined' && TOOL_TURN != null && TAB === 'Turns') { closeToolTurn(); e.preventDefault(); return; }
+ if (typeof TOOL_DRILL !== 'undefined' && TOOL_DRILL != null && TAB === 'Tools') { closeToolDrill(); e.preventDefault(); return; }
// Escape leaves a visible prompt drill before popping the route scope.
if (TURN_DRILL != null && TAB === 'Turns') { TURN_DRILL = null; render(false); e.preventDefault(); return; }
if (MSUB) { clearMsub(); e.preventDefault(); return; }
@@ -3224,8 +3473,7 @@
else if (sc.kind === 'H') go('', '');
else if (sc.kind === 'y' || sc.kind === 'p' || sc.kind === 'M') go('', '');
} else if (e.key === '$' && !META.demo) {
- MODE = MODE === 'api' ? 'real' : 'api';
- render(false);
+ setCostMode(MODE === 'api' ? 'real' : 'api');
} else if (e.key === 'w') {
// Demo scaling hides absolute values but preserves the comparison ratio.
toggleWhatif();
@@ -3279,10 +3527,29 @@
// Same-URL history entries let browser Back close an execution without leaving its session.
window.addEventListener('popstate', e => {
const sc = curScope(), state = e.state;
+ const leavingTool = TOOL_DRILL, returnFocus = TOOL_RETURN_FOCUS;
NODE_DRILL = sc.kind === 's' && state && state.session === sc.id && Number.isInteger(state.execution)
? state.execution : null;
+ const validToolState = sc.kind === 's' && state && state.session === sc.id && state.toolNav === TOOL_NAV;
+ TOOL_DRILL = validToolState && state.toolDrill
+ && (state.toolDrill.kind === 'tool' || state.toolDrill.kind === 'ns')
+ ? { kind: state.toolDrill.kind, value: String(state.toolDrill.value) } : null;
+ TOOL_RETURN_FOCUS = TOOL_DRILL ? String(state.rankFocus || '') : null;
+ TOOL_TURN = validToolState && state.toolTurn && Number.isInteger(state.toolTurn.turnIndex)
+ && Number.isInteger(state.toolTurn.callIndex)
+ ? { turnIndex: state.toolTurn.turnIndex, callIndex: state.toolTurn.callIndex, group: state.toolTurn.group } : null;
+ if (TOOL_TURN && TOOL_DRILL) {
+ const groups = turnGroupRows(EXTRAS.turns);
+ const group = groups.findIndex(g => g.indices.includes(TOOL_TURN.turnIndex));
+ if (group >= 0) { TOOL_TURN.group = group; TURN_DRILL = group; TAB = 'Turns'; }
+ else { TOOL_TURN = null; TAB = 'Tools'; TURN_DRILL = null; }
+ } else if (TOOL_DRILL) { TAB = 'Tools'; TURN_DRILL = null; }
+ else if (leavingTool) { TAB = 'Tools'; TURN_DRILL = null; }
requestNodePrompt();
render(false);
+ if (TOOL_TURN) focusToolTarget('owner-turn', true);
+ else if (TOOL_DRILL) focusToolTarget(String(state.toolFocus || 'back'));
+ else if (leavingTool) focusToolTarget(returnFocus);
});
// Apply persisted or payload theme before charts render.
applyTheme((function () { try { return localStorage.getItem('opentab-theme'); } catch (e) { return null; } })() || META.theme || 'tokyo-night');
diff --git a/tests/test_tools.py b/tests/test_tools.py
new file mode 100644
index 0000000..f7b151c
--- /dev/null
+++ b/tests/test_tools.py
@@ -0,0 +1,133 @@
+from opentab.tools import tool_calls_from_turns
+
+
+def _turn(**changes):
+ turn = {
+ "time": "2026-09-12T12:00:00Z",
+ "agent": "build",
+ "depth": 1,
+ "model_name": "claude-sonnet-4-5",
+ "effort": "high",
+ "prompt_id": "prompt-1",
+ "prompt_title": "Implement it",
+ "tools": ["Bash"],
+ "cost": 1.0,
+ "tokens_total": 100,
+ "input": 40,
+ "output": 20,
+ "reasoning": 10,
+ "cache_read": 20,
+ "cache_write": 10,
+ "cache_write_1h": 4,
+ }
+ turn.update(changes)
+ return turn
+
+
+def test_tool_calls_preserve_duplicate_calls_and_chronological_indices():
+ calls = tool_calls_from_turns(
+ [
+ _turn(tools=["Bash", "Bash", "mcp__github__search"]),
+ _turn(tools=[]),
+ _turn(tools=["Read"], prompt_id="prompt-2"),
+ ]
+ )
+
+ assert [call["tool"] for call in calls] == [
+ "Bash",
+ "Bash",
+ "mcp__github__search",
+ "Read",
+ ]
+ assert [call["index"] for call in calls] == [0, 1, 2, 3]
+ assert [call["turn_index"] for call in calls] == [0, 0, 0, 2]
+ assert [call["call_index"] for call in calls] == [0, 1, 2, 0]
+ assert [call["namespace"] for call in calls] == [
+ "(built-in)",
+ "(built-in)",
+ "github",
+ "(built-in)",
+ ]
+
+
+def test_tool_calls_validate_shape_before_calculating_attribution():
+ assert tool_calls_from_turns([_turn(tools="Bash")]) == []
+ assert tool_calls_from_turns([_turn(tools={"tool": "Bash"})]) == []
+
+ calls = tool_calls_from_turns([_turn(tools=[["bad"], "", None, "Bash", 3])])
+ assert len(calls) == 1
+ assert calls[0]["tool"] == "Bash"
+ assert calls[0]["call_index"] == 0
+ assert calls[0]["tokens_total"] == 100
+ assert calls[0]["cost"] == 1.0
+
+
+def test_tool_calls_split_every_arithmetic_field_across_valid_calls():
+ calls = tool_calls_from_turns(
+ [
+ _turn(
+ tools=["Bash", None, "Read", "Bash"],
+ cost=1.0,
+ tokens_total=99,
+ input=42,
+ output=21,
+ reasoning=9,
+ cache_read=15,
+ cache_write=12,
+ cache_write_1h=3,
+ )
+ ]
+ )
+
+ expected = {
+ "cost": 1 / 3,
+ "tokens_total": 33,
+ "input": 14,
+ "output": 7,
+ "reasoning": 3,
+ "cache_read": 5,
+ "cache_write": 4,
+ "cache_write_1h": 1,
+ }
+ for call in calls:
+ for field, value in expected.items():
+ assert call[field] == value
+ assert call["cache_write_1h"] <= call["cache_write"]
+ for field, value in expected.items():
+ assert sum(call[field] for call in calls) == value * 3
+
+
+def test_tool_calls_copy_only_allowed_metadata_and_numeric_fields():
+ turn = _turn(
+ tools=["serena_find_symbol"],
+ content_key="secret-key",
+ prompt_full="full private prompt",
+ arguments={"path": "/private"},
+ result="private output",
+ status="completed",
+ duration_ms=123,
+ )
+
+ (call,) = tool_calls_from_turns([turn])
+ assert call == {
+ "index": 0,
+ "turn_index": 0,
+ "call_index": 0,
+ "tool": "serena_find_symbol",
+ "namespace": "serena",
+ "time": "2026-09-12T12:00:00Z",
+ "agent": "build",
+ "depth": 1,
+ "model_name": "claude-sonnet-4-5",
+ "effort": "high",
+ "prompt_id": "prompt-1",
+ "prompt_title": "Implement it",
+ "cost": 1.0,
+ "tokens_total": 100.0,
+ "input": 40.0,
+ "output": 20.0,
+ "reasoning": 10.0,
+ "cache_read": 20.0,
+ "cache_write": 10.0,
+ "cache_write_1h": 4.0,
+ }
diff --git a/tests/test_tui_detail.py b/tests/test_tui_detail.py
index 83a1778..215a068 100644
--- a/tests/test_tui_detail.py
+++ b/tests/test_tui_detail.py
@@ -2195,6 +2195,426 @@ def tool_breakdown(self, wid):
assert app.renderer.current_pager_lines(100) == table(wf, 96) # content = width - 4
+class _ToolsExplorerStore(FakeStore):
+ raw_reads = 0
+
+ def workflow_nodes(self, wid):
+ return []
+
+ def supports_turns(self, wid):
+ return True
+
+ def supports_tools(self, wid):
+ return True
+
+ def supports_turn_content(self, wid):
+ return True
+
+ def turn_content(self, wid, content_key=None):
+ self.raw_reads += 1
+ return {}
+
+ def message_timeline(self, wid):
+ return [
+ {
+ "time": "2026-06-01 12:00:01",
+ "agent": "main",
+ "depth": 0,
+ "model_name": "anthropic/claude-fable-5",
+ "cost": 3.0,
+ "tokens_total": 300,
+ "input": 120,
+ "output": 60,
+ "reasoning": 30,
+ "cache_read": 60,
+ "cache_write": 30,
+ "cache_write_1h": 15,
+ "tools": ["Read", "Read", "mcp__srv__read"],
+ "prompt_id": "p1",
+ "prompt_title": "inspect",
+ "content_key": "turn-1",
+ },
+ {
+ "time": "2026-06-01 12:01:00",
+ "agent": "explore",
+ "depth": 1,
+ "model_name": "anthropic/claude-fable-5",
+ "cost": 0.0,
+ "tokens_total": 1_000_000,
+ "input": 1_000_000,
+ "output": 0,
+ "reasoning": 0,
+ "cache_read": 0,
+ "cache_write": 0,
+ "cache_write_1h": 0,
+ "tools": ["Bash"],
+ "prompt_id": "p1",
+ "prompt_title": "inspect",
+ "content_key": "turn-2",
+ },
+ ]
+
+ def tool_breakdown(self, wid):
+ def row(tool, calls, cost, total, input_, output, reasoning, read, write, one_hour):
+ return {
+ "tool": tool,
+ "model_name": "anthropic/claude-fable-5",
+ "calls": calls,
+ "cost": cost,
+ "tokens_total": total,
+ "input": input_,
+ "output": output,
+ "reasoning": reasoning,
+ "cache_read": read,
+ "cache_write": write,
+ "cache_write_1h": one_hour,
+ }
+
+ return [
+ row("Read", 2, 2.0, 200, 80, 40, 20, 40, 20, 10),
+ row("mcp__srv__read", 1, 1.0, 100, 40, 20, 10, 20, 10, 5),
+ # Aggregate ledger retained one call the timeline no longer can recover.
+ row("Bash", 2, 0.0, 1_000_000, 1_000_000, 0, 0, 0, 0, 0),
+ ]
+
+
+def _tools_explorer_app(store_type=_ToolsExplorerStore):
+ args = type("Args", (), {"since": None, "until": None, "days": None})()
+ app = ot.App(store_type([workflow("s1", "2026-06-01 12:00:00")]), args)
+ app.view = "session"
+ app.tab = app.current_tabs().index("Tools")
+ return app
+
+
+def test_tools_explorer_drills_duplicate_calls_and_returns_without_raw_read():
+ app = _tools_explorer_app()
+ rows = app.tool_rankings("s1")
+ app._tool_cursor = next(
+ i for i, row in enumerate(rows) if row["kind"] == "tool" and row["name"] == "Read"
+ )
+ assert app.open_tool_drill()
+ calls = app.selected_tool_calls("s1")
+ assert len(calls) == 2
+ assert [call["call_index"] for call in calls] == [0, 1]
+ assert [call["cost"] for call in calls] == [1.0, 1.0]
+
+ app.keymap = ot.tui.bindings.Keymap({("main", "select"): ["v"]})
+ joined = "\n".join(app.renderer.detail_tools(app.current_session(), 120))
+ assert "Model output" in joined and "of cache writes, 1h: 10 (subset)" in joined
+ assert "Exact attributed usage by model" in joined
+ assert "calls complete: 2/2" in joined
+ assert "tokens complete: 200/200" in joined
+ assert "cost complete: $2.00/$2.00" in joined
+ assert "owning-turn timestamps" in joined
+ assert "v / double-click" in joined
+ assert not any(
+ "anthropic/claude-fable-5: input" in line
+ for line in app.renderer.detail_tools(app.current_session(), 120)
+ )
+
+ app.handle_key(None, ord("v"))
+ assert app.active_tab_name() == "Turns" and app.active_turn_drill == 0
+ assert app.active_trace_drill is None and app.store.raw_reads == 0
+ app.handle_key(None, 27)
+ assert app.active_tab_name() == "Tools" and app.active_tool_drill == ("tool", "Read")
+
+
+def test_tools_explorer_namespace_partial_aggregate_only_and_narrow_layout():
+ app = _tools_explorer_app()
+ rows = app.tool_rankings("s1")
+ app._tool_cursor = next(
+ i
+ for i, row in enumerate(rows)
+ if row["kind"] == "namespace" and row["name"] == "(built-in)"
+ )
+ app.open_tool_drill()
+ assert {call["tool"] for call in app.selected_tool_calls("s1")} == {"Read", "Bash"}
+ narrow = app.renderer.detail_tools(app.current_session(), 76)
+ assert all(len(line) <= 76 for line in narrow)
+ assert "calls partial: 3/4" in "\n".join(narrow)
+ assert "tokens complete: 1.0M/1.0M" in "\n".join(narrow)
+
+ class AggregateOnly(_ToolsExplorerStore):
+ def supports_turns(self, wid):
+ return False
+
+ aggregate = _tools_explorer_app(AggregateOnly)
+ aggregate.open_tool_drill()
+ text = "\n".join(aggregate.renderer.detail_tools(aggregate.current_session(), 80))
+ assert "Call ledger unavailable" in text and "no Turns\ntimeline" in text
+ aggregate.handle_key(None, 10)
+ assert aggregate.active_tab_name() == "Tools"
+
+
+def test_tools_explorer_repricing_preserves_selected_identity_and_reorders():
+ app = _tools_explorer_app()
+ app.show_api_prices = False
+ rows = app.tool_rankings("s1")
+ app._tool_cursor = next(
+ i for i, row in enumerate(rows) if row["kind"] == "tool" and row["name"] == "Bash"
+ )
+ before = app._tool_cursor
+ app.toggle_api_prices()
+ selected = app.selected_tool_ranking("s1")
+ assert selected["name"] == "Bash" and selected["cost"] > 0
+ assert app._tool_cursor != before
+
+
+def test_tools_explorer_mouse_navigation_and_reload_reset_without_raw_read():
+ app = _tools_explorer_app()
+ rankings = app.tool_rankings("s1")
+ read_ordinal = next(
+ i for i, row in enumerate(rankings) if row["kind"] == "tool" and row["name"] == "Read"
+ )
+ app.renderer.detail_tools(app.current_session(), 100)
+ read_line = next(
+ line for line, ordinal in app.renderer._tool_header_at.items() if ordinal == read_ordinal
+ )
+
+ app._apply_click(("toolline", read_line), False)
+ assert app._tool_cursor == read_ordinal and app.active_tool_drill is None
+ app._apply_click(("toolline", read_line), True)
+ assert app.active_tool_drill == ("tool", "Read")
+
+ app.renderer.detail_tools(app.current_session(), 100)
+ second_call_line = next(
+ line for line, ordinal in app.renderer._tool_call_at.items() if ordinal == 1
+ )
+ app._apply_click(("toolcallline", second_call_line), True)
+ assert app.active_tab_name() == "Turns" and app.active_trace_drill is None
+ assert app.store.raw_reads == 0
+ app.handle_key(None, 27)
+ assert app.active_tool_drill == ("tool", "Read")
+
+ app.reload()
+ assert app.active_tool_drill is None and app._tool_cursor == app._tool_call_cursor == 0
+
+
+def test_tools_explorer_draw_paints_follows_and_routes_real_mouse_regions():
+ app = _tools_explorer_app()
+ app.prefetch_session_data("s1")
+ rows = app.tool_rankings("s1")
+ read_ordinal = next(
+ i for i, row in enumerate(rows) if row["kind"] == "tool" and row["name"] == "Read"
+ )
+ app._tool_cursor = read_ordinal
+ app._tool_follow = True
+ app.scroll = 10_000
+ screen = AttrScreen(60, 100)
+ with patch.object(ot.curses, "color_pair", side_effect=lambda n: n << 8):
+ app.renderer.regions = []
+ app.renderer.draw_detail(screen, 0, 0, 60, 100)
+ logical = next(
+ line for line, ordinal in app.renderer._tool_header_at.items() if ordinal == read_ordinal
+ )
+ screen_y = 3 + logical - app.scroll
+ assert 3 <= screen_y < 59 and not app._tool_follow
+ assert any(
+ attr & ot.curses.A_REVERSE for (row, _col), attr in screen.attrs.items() if row == screen_y
+ )
+ assert app.renderer.hit(screen_y, 8) == ("toolline", logical)
+
+ original = ot.curses.getmouse
+ try:
+ ot.curses.getmouse = lambda: (0, 8, screen_y, 0, ot.curses.BUTTON1_DOUBLE_CLICKED)
+ app.handle_key(screen, ot.curses.KEY_MOUSE)
+ finally:
+ ot.curses.getmouse = original
+ assert app.active_tool_drill == ("tool", "Read")
+
+ call_screen = AttrScreen(80, 100)
+ with patch.object(ot.curses, "color_pair", side_effect=lambda n: n << 8):
+ app.renderer.regions = []
+ app.renderer.draw_detail(call_screen, 0, 0, 80, 100)
+ call_line = next(line for line, ordinal in app.renderer._tool_call_at.items() if ordinal == 1)
+ call_y = 3 + call_line - app.scroll
+ assert app.renderer.hit(call_y, 8) == ("toolcallline", call_line)
+ before = app.scroll
+ app._wheel_down = getattr(ot.curses, "BUTTON5_PRESSED", 0) or ot.curses.REPORT_MOUSE_POSITION
+ try:
+ ot.curses.getmouse = lambda: (0, 8, call_y, 0, app._wheel_down)
+ app.handle_key(call_screen, ot.curses.KEY_MOUSE)
+ finally:
+ ot.curses.getmouse = original
+ assert app.scroll == before + 3
+
+
+def test_tools_explorer_cursor_bounds_hand_keys_to_the_scrollable_pane():
+ class ManyCalls(_ToolsExplorerStore):
+ def message_timeline(self, wid):
+ rows = super().message_timeline(wid)
+ rows[0]["tools"] = ["Read"] * 24 + ["mcp__srv__read"]
+ return rows
+
+ def tool_breakdown(self, wid):
+ rows = super().tool_breakdown(wid)
+ rows[0]["calls"] = 24
+ return rows
+
+ def draw(app):
+ screen = AttrScreen(12, 100)
+ with patch.object(ot.curses, "color_pair", side_effect=lambda n: n << 8):
+ app.renderer.draw_detail(screen, 0, 0, 12, 100)
+ return screen
+
+ def exercise_bounds(app, cursor_attr, summaries):
+ rows = (
+ app.selected_tool_calls(app.current_session().id)
+ if app.active_tool_drill
+ else app.tool_rankings(app.current_session().id)
+ )
+ assert len(rows) > 1
+
+ app.scroll = 10_000
+ app._tool_follow = True
+ draw(app)
+ assert app.scroll > 0 and getattr(app, cursor_attr) == 0
+
+ seen = ""
+ for key in (ord("k"), ot.curses.KEY_UP) * 50:
+ app.handle_key(None, key)
+ assert not app._tool_follow
+ screen = draw(app)
+ seen += screen_text(screen)
+ if app.scroll == 0:
+ break
+ assert app.scroll == 0
+ assert all(summary in seen for summary in summaries), seen
+ assert getattr(app, cursor_attr) == 0
+
+ app.handle_key(None, ord("j"))
+ assert getattr(app, cursor_attr) == 1 and app._tool_follow
+ draw(app)
+ assert not app._tool_follow
+
+ app.jump(to_end=True)
+ draw(app)
+ last = len(rows) - 1
+ assert getattr(app, cursor_attr) == last
+ bottom_start = app.scroll
+ for key in (ord("j"), ot.curses.KEY_DOWN) * 50:
+ before = app.scroll
+ app.handle_key(None, key)
+ assert not app._tool_follow
+ draw(app)
+ if app.scroll == before:
+ break
+ assert app.scroll > bottom_start and getattr(app, cursor_attr) == last
+
+ app.handle_key(None, ord("k"))
+ assert getattr(app, cursor_attr) == last - 1 and app._tool_follow
+ draw(app)
+ assert not app._tool_follow
+
+ app = _tools_explorer_app(ManyCalls)
+ app.prefetch_session_data("s1")
+ exercise_bounds(app, "_tool_cursor", ("Tool ledger",))
+
+ app._tool_cursor = next(
+ i
+ for i, row in enumerate(app.tool_rankings("s1"))
+ if row["kind"] == "tool" and row["name"] == "Read"
+ )
+ assert app.open_tool_drill()
+ exercise_bounds(app, "_tool_call_cursor", ("Contribution", "Attributed token categories"))
+
+
+def test_tools_explorer_warm_draw_reuses_projection_pricing_and_layout():
+ app = _tools_explorer_app()
+ app.prefetch_session_data("s1")
+ app.effective_tool_cost = Mock(wraps=app.effective_tool_cost)
+ screen = FakeScreen(30, 100)
+ with patch.object(ot.curses, "color_pair", side_effect=lambda n: n << 8):
+ app.renderer.draw_detail(screen, 0, 0, 30, 100)
+ priced = app.effective_tool_cost.call_count
+ layout = app.renderer._tool_layout_cache
+ projection = app._tool_projection_cache
+ app.scroll += 1
+ app.renderer.draw_detail(FakeScreen(30, 100), 0, 0, 30, 100)
+ assert app.effective_tool_cost.call_count == priced
+ assert app.renderer._tool_layout_cache is layout
+ assert app._tool_projection_cache is projection
+ app._model_price_revision += 1
+ with patch.object(ot.curses, "color_pair", side_effect=lambda n: n << 8):
+ app.renderer.draw_detail(FakeScreen(30, 100), 0, 0, 30, 100)
+ assert app.effective_tool_cost.call_count > priced
+
+
+def test_tools_explorer_return_lifecycle_and_trace_back_stack():
+ def owning_turn_app():
+ app = _tools_explorer_app()
+ app.open_tool_drill()
+ assert app.open_tool_call_reader() and app._tools_return is not None
+ return app
+
+ app = owning_turn_app()
+ app.open_trace_drill()
+ assert app.active_trace_drill is not None
+ app.handle_key(None, 27)
+ assert app.active_tab_name() == "Turns" and app.active_trace_drill is None
+ assert app._tools_return is not None
+ app.handle_key(None, 27)
+ assert app.active_tab_name() == "Tools" and app.active_tool_drill is not None
+
+ app = owning_turn_app()
+ app._apply_click(("tab", 0), False)
+ assert app._tools_return is None
+ app = owning_turn_app()
+ app.handle_key(None, ord("1"))
+ assert app.view == "browse" and app._tools_return is None
+ app = owning_turn_app()
+ app.set_browse_mode("projects")
+ assert app._tools_return is None
+ app = owning_turn_app()
+ app._reload_for_source()
+ assert app._tools_return is None
+
+
+def test_tools_explorer_narrow_tables_keep_cost_at_the_right_edge():
+ app = _tools_explorer_app()
+ for width in (40, 80, 136):
+ ranking = app.renderer.detail_tools(app.current_session(), width)
+ assert all(len(line) <= width for line in ranking)
+ ranking_rows = [row for row in _cells(ranking) if re.match(r"\s*(Bash|Read)\s+\d", row)]
+ assert ranking_rows and all(re.search(r"\$[\d,.]+\d\s*$", row) for row in ranking_rows)
+ app.open_tool_drill()
+ calls = app.renderer.detail_tools(app.current_session(), 40)
+ assert all(len(line) <= 40 for line in calls)
+ call_rows = [row for row in _cells(calls) if re.match(r"\s*\d+\s+\d+", row)]
+ assert call_rows and all("$" in row for row in call_rows)
+
+
+def test_tools_explorer_cached_paint_metadata_stays_scoped_and_fractional_coverage_closes():
+ app = _tools_explorer_app()
+ renderer = app.renderer
+ renderer.detail_tools(app.current_session(), 100)
+ headers = set(renderer._box_headers)
+ renderer._box_headers.clear()
+ renderer.detail_tools(app.current_session(), 100)
+ assert headers <= renderer._box_headers
+ assert renderer._tool_tree_runs
+
+ app.open_tool_drill(1)
+ lines = renderer.detail_tools(app.current_session(), 100)
+ assert renderer._tool_tree_runs == {}
+ runs = dict(renderer._token_runs)
+ assert runs
+ renderer._token_runs.clear()
+ assert renderer.detail_tools(app.current_session(), 100) is lines
+ assert renderer._token_runs == runs
+
+ # Thirds remain fractional until display; truncating each call falsely loses tokens.
+ app.tool_drill = ("tool", "Read")
+ turns = [dict(row) for row in app.session_turn_rows("s1")]
+ turns[0]["tokens_total"] = 302
+ app._turns_by_session["s1"] = turns
+ aggregate = [dict(row) for row in app.session_tool_rows("s1")]
+ aggregate[0]["tokens_total"] = 302 * 2 / 3
+ app._tool_by_session["s1"] = aggregate
+ joined = "\n".join(renderer.detail_tools(app.current_session(), 100))
+ assert "tokens complete:" in joined
+
+
def test_subagent_nodes_memoized_per_session():
def node(workflow_id, depth, agent, title):
return {
diff --git a/tests/test_web.py b/tests/test_web.py
index b2ba993..8ebbc94 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -89,6 +89,55 @@ def message_timeline(self, workflow_id):
]
+class ToolsExplorerFakeStore(TurnsFakeStore):
+ def supports_tools(self, workflow_id):
+ return True
+
+ def message_timeline(self, workflow_id):
+ rows = super().message_timeline(workflow_id)
+ rows[0].update(
+ tools=["Bash", "Bash", "mcp__github__search"],
+ cost=0.9,
+ tokens_total=99,
+ input=42,
+ output=21,
+ reasoning=9,
+ cache_read=15,
+ cache_write=12,
+ cache_write_1h=3,
+ content_key="never-ship-this-key",
+ arguments={"secret": "never-ship-this-argument"},
+ result="never-ship-this-result",
+ )
+ rows[1].update(tools=["Read"], cost=0.0)
+ return rows
+
+ def tool_breakdown(self, workflow_id):
+ from opentab.tools import tool_calls_from_turns
+
+ return [
+ {
+ "tool": call["tool"],
+ "calls": 1,
+ "model_name": call["model_name"],
+ **{
+ key: call[key]
+ for key in (
+ "cost",
+ "tokens_total",
+ "input",
+ "output",
+ "reasoning",
+ "cache_read",
+ "cache_write",
+ "cache_write_1h",
+ )
+ },
+ }
+ for call in tool_calls_from_turns(self.message_timeline(workflow_id))
+ ]
+
+
def test_web_payload_carries_both_cost_snapshots():
app = app_with(
[
@@ -199,6 +248,61 @@ def test_web_session_extras_reports_turns_with_both_costs():
assert ctx["mixedWindows"] is False and ctx["comp"] == []
+def test_web_tools_explorer_payload_is_allowlisted_fractional_and_reprices_whole_turns():
+ w = workflow("w1", "2026-05-01 10:00:00", cost=0.9)
+ args = type("Args", (), {"since": None, "until": None, "days": None})()
+ extras = ot.session_extras(ot.App(ToolsExplorerFakeStore([w]), args), "w1")
+
+ assert set(extras) == {"turns", "tools", "toolCalls", "context", "expiries"}
+ assert all(
+ set(row) == {"tool", "ns", "calls", "model", "real", "api", "tokens", "tok"}
+ for row in extras["tools"]
+ )
+ allowed = {
+ "index",
+ "turnIndex",
+ "callIndex",
+ "tool",
+ "ns",
+ "time",
+ "agent",
+ "depth",
+ "model",
+ "effort",
+ "promptId",
+ "promptTitle",
+ "real",
+ "api",
+ "tokens",
+ "tok",
+ }
+ assert all(set(call) == allowed for call in extras["toolCalls"])
+ bash1, bash2, github, read = extras["toolCalls"]
+ assert [call["index"] for call in extras["toolCalls"]] == [0, 1, 2, 3]
+ assert [call["callIndex"] for call in extras["toolCalls"]] == [0, 1, 2, 0]
+ assert [bash1["tool"], bash2["tool"], github["ns"], read["turnIndex"]] == [
+ "Bash",
+ "Bash",
+ "github",
+ 1,
+ ]
+ assert bash1["tokens"] == bash2["tokens"] == github["tokens"] == 33.0
+ assert bash1["tok"] == [14.0, 7.0, 3.0, 5.0, 4.0, 1.0]
+ assert bash1["real"] == bash1["api"] == 0.3
+ assert read["real"] == 0 and read["api"] > 0
+ assert round(sum(call["api"] for call in extras["toolCalls"][:3]), 6) == 0.9
+ text = json.dumps(extras)
+ assert "never-ship-this" not in text
+ assert all(word not in text for word in ("content_key", "arguments", "result"))
+ assert "<\\/script>" in ot.render_html({"meta": {}, "toolCalls": extras["toolCalls"]})
+
+ # Static reports retain the lazy boundary: building one must neither query nor embed
+ # per-session tool calls, prompts, or the deliberately hostile fixture data above.
+ static_app = ot.App(ToolsExplorerFakeStore([w]), args)
+ static = json.dumps(ot.build_payload(static_app))
+ assert "toolCalls" not in static and "never-ship-this" not in static
+
+
def test_web_never_fetches_or_serializes_remote_trace_content():
from unittest.mock import patch
@@ -543,11 +647,13 @@ class Node {
appendChild(n) { this.children.push(n); n.parent = this; return n; }
append(...nodes) { nodes.forEach(n => this.appendChild(n)); }
setAttribute(k, v) { this.attrs[k] = v; }
+ getAttribute(k) { return this.attrs[k]; }
addEventListener(k, fn) { this.events[k] = fn; }
set textContent(t) { this.children = []; this.text = t; }
get textContent() { return this.text + this.children.map(n => n.textContent).join(''); }
querySelectorAll() { return []; }
focus() { document.activeElement = this; }
+ scrollIntoView(options) { this.scrolled = options; }
get nextElementSibling() { return this.parent.children[this.parent.children.indexOf(this) + 1]; }
get previousElementSibling() { return this.parent.children[this.parent.children.indexOf(this) - 1]; }
}
@@ -557,6 +663,14 @@ class Node {
createElement: tag => new Node(tag), createElementNS: (_, tag) => new Node(tag),
createTextNode: text => new Node('#text', text),
getElementById(id) { if (!elements.has(id)) elements.set(id, new Node('div')); return elements.get(id); },
+ querySelectorAll(selector) {
+ const out = [], visit = node => {
+ if (selector === 'tr[aria-label]' && node.tag === 'tr' && node.attrs['aria-label']) out.push(node);
+ if (selector === '[data-tool-focus]' && node.attrs['data-tool-focus']) out.push(node);
+ node.children.forEach(visit);
+ };
+ elements.forEach(visit); return out;
+ },
addEventListener(k, fn) { listeners[k] = fn; }
};
const window = {addEventListener(k, fn) { listeners[k] = fn; }};
@@ -1272,7 +1386,13 @@ def test_web_report_server_serves_page_extras_and_404():
assert '"serve":true' in page # the served page knows the extras exist
extras = json.loads(urllib.request.urlopen(base + "/api/session/w1").read().decode("utf-8"))
# FakeStore: no turns/tools support, and no turns means no context curve
- assert extras == {"turns": [], "tools": [], "context": None, "expiries": []}
+ assert extras == {
+ "turns": [],
+ "tools": [],
+ "toolCalls": [],
+ "context": None,
+ "expiries": [],
+ }
try:
urllib.request.urlopen(base + "/nope")
raise AssertionError("expected a 404")
@@ -1846,12 +1966,14 @@ def test_web_overview_closes_with_the_models_table():
assert "modelsTable('t-ov-models'" in body[body.rindex("root.appendChild(") :]
-def test_web_tools_treemap_is_passive_themed_and_precedes_the_table():
+def test_web_tools_treemap_is_clickable_themed_and_precedes_the_rankings():
js = _js_source()
tools = js.split("function toolsTable(", 1)[1].split("\nfunction binaryTreemap", 1)[0]
tree = js.split("function toolTreemap(", 1)[1].split("\n}", 1)[0]
- assert tools.index("toolTreemap(rows)") < tools.index("class: 'tool-table'")
- assert "onclick" not in tools and "onclick" not in tree # passive: no hidden interaction mode
+ assert tools.index("toolTreemap(rows)") < tools.index("class: 'tool-ranks'")
+ assert "openToolDrill('tool', r.tool)" in tools
+ assert "openToolDrill('ns', r.ns)" in tools
+ assert "openToolDrill('tool', r.tool)" in tree
assert "TH.heat[level(r)]" in tree and "inkOn(fill)" in tree
assert "dollars ? mCost(r) : r.tokens" in tree # $0 subscription fallback
assert "Math.min(8, all.length)" in tree and "tool: 'Other'" in tree
@@ -1893,7 +2015,7 @@ def test_web_tools_treemap_is_passive_themed_and_precedes_the_table():
# The exact table below has to be able to state the figure the shade encodes.
assert "{ key: 'calls', label: 'Calls', align: 'r' }," in tools
assert "calls: sum(rows, r => r.calls)" in tools
- assert "'aria-hidden': 'true'" in tree # exact accessible table follows immediately
+ assert "aria-label': 'Tool-attributed spend treemap'" in tree
assert "new ResizeObserver(" in tree # reflow only the chart, never global page state
assert "render(false)" not in tree
assert "function binaryTreemap(" in js
@@ -1905,6 +2027,235 @@ def test_web_tools_treemap_is_passive_themed_and_precedes_the_table():
assert ".tool-tile .tr{" in page # the rate rides its own line, gated on its own
+def test_web_tools_drill_executes_navigation_back_price_and_turn_jump():
+ node = shutil.which("node")
+ if node is None:
+ print("SKIP JavaScript Tools explorer check: Node.js is not installed (required in CI)")
+ return
+ source = _js_source()
+ shipped = source[: source.index("document.getElementById('trends').addEventListener")]
+ shipped += re.search(r"window.addEventListener\('popstate', e => \{.*?\n\}\);", source, re.S)[0]
+ result = subprocess.run(
+ [node, "-"],
+ input=_WEB_DOM_JS
+ + r"""
+const payload = {meta:{source:'test', serve:true, startApi:false, demo:false, recordsCost:true},
+ warnings:[], workflows:[{id:'w1', title:'session', date:'2026-09-12T10:00:00Z', tokens:100,
+ real:1, api:5, realRoot:1, apiRoot:5, project:'/tmp/x', source:'test', subagents:0}],
+ models:{}, nodes:{}, whatif:{rates:{}, models:[], catalog:[]}, prices:{}, machineMeta:{}};
+global.requestAnimationFrame = () => 1; global.cancelAnimationFrame = () => {};
+document.getElementById('opentab-data').textContent = JSON.stringify(payload);
+"""
+ + shipped
+ + r"""
+function render() { renderDetail(curScope(), []); }
+const view = document.getElementById('view');
+function all(el, tag) { return [...(el.tag === tag ? [el] : []), ...el.children.flatMap(n => all(n, tag))]; }
+function key(key) { const e = {key, preventDefault(){this.prevented=true}, stopPropagation(){}}; listeners.keydown(e); return e; }
+EXTRAS = {id:'w1', loading:false, context:null, expiries:[],
+ turns:[{time:'2026-09-12T10:00:00Z', agent:'builder', depth:0, model:'vendor/model', effort:'high',
+ real:1, api:5, tokens:100, ctx:0, cached:null, tools:['Bash'], promptId:'p1',
+ promptTitle:'safe
', promptFull:'full prompt'}],
+ tools:[{tool:'Bash',ns:'(built-in)',calls:1,model:'vendor/model',real:1,api:5,tokens:100,tok:[40,20,10,20,10,4]}],
+ toolCalls:[{index:0,turnIndex:0,callIndex:0,tool:'Bash',ns:'(built-in)',time:'2026-09-12T10:00:00Z',
+ agent:'builder',depth:0,model:'vendor/model',effort:'high',promptId:'p1',
+ promptTitle:'safe
',real:1,api:5,tokens:100,tok:[40,20,10,20,10,4]}]};
+TAB = 'Tools';
+openToolDrill('tool', 'Bash');
+assert.deepEqual(TOOL_DRILL, {kind:'tool', value:'Bash'});
+assert.deepEqual(history.state.toolDrill, {kind:'tool', value:'Bash'});
+assert.equal(document.activeElement.attrs['data-tool-focus'], 'back');
+assert.ok(view.textContent.includes('Model output20'));
+assert.ok(view.textContent.includes('1h cache write4subset of cache write'));
+assert.ok(view.textContent.includes('Owning-turn time'));
+assert.ok(view.textContent.includes('Selected call ledger matches aggregate attribution'));
+assert.equal(all(view, 'img').length, 0);
+assert.ok(view.textContent.includes('$1.00'));
+key('$'); assert.deepEqual(TOOL_DRILL, {kind:'tool', value:'Bash'});
+assert.ok(view.textContent.includes('$5.00'));
+assert.equal(key('Escape').prevented, true); assert.equal(TOOL_DRILL, null);
+assert.equal(location.hash, '#/s/w1');
+assert.equal(document.activeElement.attrs['aria-label'], 'Open tool Bash');
+openToolDrill('ns', '(built-in)'); history.back(); assert.equal(TOOL_DRILL, null);
+openToolDrill('tool', 'Bash');
+const callRow = all(view, 'tr').find(r => String(r.attrs['aria-label'] || '').startsWith('Open owning turn'));
+assert.ok(callRow); callRow.events.click();
+assert.deepEqual(TOOL_DRILL, {kind:'tool', value:'Bash'}); assert.equal(TAB, 'Turns'); assert.equal(TURN_DRILL, 0);
+assert.deepEqual(TOOL_TURN, {callIndex:0, turnIndex:0, group:0});
+assert.equal(document.activeElement.attrs['data-tool-focus'], 'owner-turn');
+assert.ok(document.activeElement.className.includes('tool-owner'));
+assert.deepEqual(document.activeElement.scrolled, {block:'center', inline:'nearest'});
+assert.ok(view.textContent.includes('selected call 1 owns highlighted turn 1'));
+key('$'); assert.equal(document.activeElement.attrs['data-tool-focus'], 'owner-turn');
+const turnBack = all(view, 'button').find(r => r.attrs['data-tool-focus'] === 'turn-back');
+assert.ok(turnBack); turnBack.events.click();
+assert.equal(TAB, 'Tools'); assert.equal(TOOL_TURN, null);
+assert.deepEqual(TOOL_DRILL, {kind:'tool', value:'Bash'});
+assert.equal(document.activeElement.attrs['data-tool-focus'], 'call:0');
+key('$'); assert.equal(document.activeElement.attrs['data-tool-focus'], 'call:0');
+all(view, 'tr').find(r => r.attrs['data-tool-focus'] === 'call:0').events.click();
+assert.equal(key('Escape').prevented, true); assert.equal(TAB, 'Tools');
+assert.equal(document.activeElement.attrs['data-tool-focus'], 'call:0');
+assert.equal(key('Escape').prevented, true); assert.equal(TOOL_DRILL, null);
+assert.equal(document.activeElement.attrs['data-tool-focus'], 'rank:tool:Bash');
+openToolDrill('tool', 'Bash');
+all(view, 'tr').find(r => r.attrs['data-tool-focus'] === 'call:0').events.click();
+history.back(); assert.equal(TAB, 'Tools'); assert.deepEqual(TOOL_DRILL, {kind:'tool', value:'Bash'});
+history.back(); assert.equal(TOOL_DRILL, null);
+openToolDrill('tool', 'Bash'); const staleEpoch = TOOL_NAV;
+abandonToolNavigation(); TAB = 'Overview'; render(false); history.back();
+assert.notEqual(TOOL_NAV, staleEpoch); assert.equal(TOOL_DRILL, null); assert.equal(TOOL_TURN, null);
+TAB = 'Tools'; TOOL_DRILL = {kind:'tool', value:'Bash'};
+EXTRAS.toolCalls = [{...EXTRAS.toolCalls[0], index:1, tool:'Read', real:.5, api:.5, tokens:50}];
+render(false);
+assert.ok(view.textContent.includes('session ledger contains 1 calls for other tools'));
+assert.ok(!view.textContent.includes('This source retained aggregate attribution only for this selection'));
+EXTRAS.toolCalls = [{...EXTRAS.toolCalls[0], index:0, tool:'Bash', real:.5, api:.5, tokens:50}];
+render(false);
+assert.ok(view.textContent.includes('Selected aggregate / retained ledger differ'));
+""",
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_web_session_extras_discards_a_stale_tool_ledger_response():
+ node = shutil.which("node")
+ if node is None:
+ print("SKIP JavaScript extras race check: Node.js is not installed (required in CI)")
+ return
+ source = _js_source()
+ shipped = source[: source.index("document.getElementById('trends').addEventListener")]
+ result = subprocess.run(
+ [node, "-"],
+ input=_WEB_DOM_JS
+ + r"""
+const payload = {meta:{source:'test', serve:true, startApi:false, demo:false}, warnings:[],
+ workflows:[{id:'w1',title:'one',date:'2026-09-12',project:''},{id:'w2',title:'two',date:'2026-09-12',project:''}],
+ models:{},nodes:{},whatif:{rates:{},models:[],catalog:[]},prices:{},machineMeta:{}};
+document.getElementById('opentab-data').textContent = JSON.stringify(payload);
+"""
+ + shipped
+ + r"""
+function render() {}
+const tick = () => new Promise(resolve => setImmediate(resolve));
+(async () => {
+ ensureExtras(curScope());
+ assert.equal(requests.length, 1); assert.ok(requests[0].url.endsWith('/w1'));
+ location.hash = '#/s/w2'; resetScopeState(); ensureExtras(curScope());
+ assert.equal(requests.length, 2); assert.equal(EXTRAS.id, 'w2');
+ requests[0].resolve({json:async () => ({turns:[{model:'stale'}],tools:[],toolCalls:[{tool:'stale'}]})});
+ await tick(); assert.equal(EXTRAS.id, 'w2'); assert.equal(EXTRAS.loading, true);
+ assert.deepEqual(EXTRAS.toolCalls, []);
+ requests[1].resolve({json:async () => ({turns:[],tools:[{tool:'Read'}],toolCalls:[{tool:'fresh'}]})});
+ await tick(); assert.equal(EXTRAS.id, 'w2'); assert.equal(EXTRAS.loading, false);
+ assert.deepEqual(EXTRAS.toolCalls, [{tool:'fresh'}]);
+})().catch(e => { console.error(e); process.exitCode = 1; });
+""",
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ assert result.returncode == 0, result.stderr
+
+
+def test_web_session_extras_survive_navigation_events_and_restart_after_abandonment():
+ node = shutil.which("node")
+ if node is None:
+ print(
+ "SKIP JavaScript extras navigation race check: Node.js is not installed (required in CI)"
+ )
+ return
+ source = _js_source()
+ shipped = source[: source.index("document.getElementById('trends').addEventListener")]
+ shipped += (
+ next(line for line in source.splitlines() if "window.addEventListener('hashchange'" in line)
+ + "\n"
+ )
+ shipped += re.search(r"window.addEventListener\('popstate', e => \{.*?\n\}\);", source, re.S)[0]
+ result = subprocess.run(
+ [node, "-"],
+ input=_WEB_DOM_JS
+ + r"""
+const payload = {meta:{source:'test', serve:true, startApi:false, demo:false, recordsCost:true}, warnings:[],
+ workflows:[{id:'w1',title:'one',date:'2026-09-12',project:'',tokens:10,real:1,api:1,
+ realRoot:1,apiRoot:1,source:'test',subagents:0}],
+ models:{},nodes:{},whatif:{rates:{},models:[],catalog:[]},prices:{},machineMeta:{}};
+global.requestAnimationFrame = () => 1; global.cancelAnimationFrame = () => {};
+document.getElementById('opentab-data').textContent = JSON.stringify(payload);
+"""
+ + shipped
+ + r"""
+function render() {
+ const sc = curScope();
+ ensureExtras(sc);
+ const tabs = tabsFor(sc);
+ if (!tabs.includes(TAB)) TAB = tabs[0];
+ renderTabs(sc, tabs);
+ renderDetail(sc, scopeWorkflows(sc));
+}
+const tick = () => new Promise(resolve => setImmediate(resolve));
+const tabText = () => document.getElementById('tabbar').textContent;
+const viewText = () => document.getElementById('view').textContent;
+const complete = {turns:[{time:'2026-09-12',agent:'main',depth:0,model:'test',real:1,api:1,
+ tokens:10,ctx:5,cached:0,tools:['Read'],promptId:'p1',promptTitle:'one',promptFull:'one'}],
+ tools:[{tool:'Read',ns:'local',calls:1,model:'test',real:1,api:1,tokens:10,tok:[5,1,0,4,0,0]}],
+ toolCalls:[{index:0,turnIndex:0,callIndex:0,tool:'Read',ns:'local',time:'2026-09-12',
+ agent:'main',depth:0,model:'test',effort:'',promptId:'p1',promptTitle:'one',real:1,api:1,
+ tokens:10,tok:[5,1,0,4,0,0]}],
+ context:{model:'test',window:100,points:[{t:'09-12',v:5,w:100}],comp:[]},expiries:[]};
+(async () => {
+ // Normal hash navigation dispatches popstate before hashchange in the live browser.
+ // The second event must not invalidate the same session's pending request.
+ location.hash = '#/'; TAB = 'Turns'; openSession('w1');
+ listeners.popstate({state:null});
+ assert.equal(requests.length, 1); assert.ok(requests[0].url.endsWith('/w1'));
+ listeners.hashchange({});
+ assert.equal(requests.length, 1); assert.ok(tabText().includes('Turns ⋯'));
+ requests[0].resolve({json:async () => complete}); await tick();
+ assert.equal(EXTRAS.loading, false);
+ assert.ok(!tabText().includes('⋯'));
+ for (const tab of ['Turns', 'Tools', 'Context']) {
+ TAB = tab; render(false); assert.ok(!viewText().includes('loading ' + tab.toLowerCase()));
+ }
+
+ // A completed cache survives another same-session event pair without refetching.
+ listeners.popstate({state:null}); listeners.hashchange({});
+ assert.equal(requests.length, 1); assert.equal(EXTRAS.loading, false);
+
+ // Leaving while a request is pending clears its identity. Returning to A starts a
+ // fresh request, and an old success arriving before it cannot populate the page.
+ EXTRAS = {id:null,loading:false,turns:[],tools:[],toolCalls:[],context:null,expiries:[]};
+ TAB = 'Turns'; render(false); const oldSuccess = requests[1];
+ location.hash = '#/d/2026-09-12'; listeners.hashchange({});
+ location.hash = '#/s/w1'; listeners.hashchange({}); const freshAfterSuccess = requests[2];
+ assert.equal(requests.length, 3); assert.equal(EXTRAS.id, 'w1'); assert.equal(EXTRAS.loading, true);
+ oldSuccess.resolve({json:async () => ({...complete, turns:[{model:'STALE'}]})}); await tick();
+ assert.equal(EXTRAS.loading, true); assert.deepEqual(EXTRAS.turns, []);
+ freshAfterSuccess.resolve({json:async () => complete}); await tick();
+ assert.equal(EXTRAS.loading, false); assert.equal(EXTRAS.turns[0].model, 'test');
+
+ // The inverse ordering is safe too: an old failure after the fresh success must not
+ // clear the completed extras or put all three tabs back into loading/empty state.
+ EXTRAS = {id:null,loading:false,turns:[],tools:[],toolCalls:[],context:null,expiries:[]};
+ render(false); const oldFailure = requests[3];
+ location.hash = '#/d/2026-09-12'; listeners.hashchange({});
+ location.hash = '#/s/w1'; listeners.hashchange({}); const freshBeforeFailure = requests[4];
+ freshBeforeFailure.resolve({json:async () => complete}); await tick();
+ oldFailure.reject(new Error('stale failure')); await tick();
+ assert.equal(EXTRAS.loading, false); assert.equal(EXTRAS.turns[0].model, 'test');
+ assert.equal(EXTRAS.tools[0].tool, 'Read'); assert.equal(EXTRAS.context.model, 'test');
+})().catch(e => { console.error(e); process.exitCode = 1; });
+""",
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ assert result.returncode == 0, result.stderr
+
+
def test_web_flamegraph_divides_the_same_node_costs_as_the_tui():
with tempfile.TemporaryDirectory() as tmp:
app = _whatif_db(tmp) # a subscription session: root + one Docs subagent, $0