From 4a119a17244e5d473e4c43602fd0be197c688d02 Mon Sep 17 00:00:00 2001 From: Mohamed Hamidi Date: Sat, 12 Sep 2026 19:12:57 +0200 Subject: [PATCH] feat(tools): add interactive tool and call explorer --- docs/architecture.md | 1 + docs/keys.md | 11 + docs/tui.md | 16 ++ docs/web.md | 26 +++ src/opentab/tools.py | 45 ++++ src/opentab/tui/app.py | 312 ++++++++++++++++++++++++- src/opentab/tui/keymap.py | 23 ++ src/opentab/tui/renderer.py | 437 +++++++++++++++++++++++++++++------- src/opentab/web.py | 78 ++++++- src/opentab/webpage.py | 313 ++++++++++++++++++++++++-- tests/test_tools.py | 133 +++++++++++ tests/test_tui_detail.py | 420 ++++++++++++++++++++++++++++++++++ tests/test_web.py | 361 ++++++++++++++++++++++++++++- 13 files changed, 2061 insertions(+), 115 deletions(-) create mode 100644 src/opentab/tools.py create mode 100644 tests/test_tools.py 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