benchkit-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. benchkit/__init__.py +1 -0
  2. benchkit/_pi_proxy.py +219 -0
  3. benchkit/answer_key_guard.ts +49 -0
  4. benchkit/artifacts.py +87 -0
  5. benchkit/benchmarks/__init__.py +105 -0
  6. benchkit/benchmarks/aider_polyglot.py +405 -0
  7. benchkit/benchmarks/arc.py +52 -0
  8. benchkit/benchmarks/banking77.py +149 -0
  9. benchkit/benchmarks/base.py +19 -0
  10. benchkit/benchmarks/boolq.py +49 -0
  11. benchkit/benchmarks/chartqa.py +133 -0
  12. benchkit/benchmarks/code_verifier.py +77 -0
  13. benchkit/benchmarks/downloads.py +166 -0
  14. benchkit/benchmarks/evalplus.py +288 -0
  15. benchkit/benchmarks/git_surgery.py +485 -0
  16. benchkit/benchmarks/gpqa.py +56 -0
  17. benchkit/benchmarks/gsm8k.py +97 -0
  18. benchkit/benchmarks/hellaswag.py +49 -0
  19. benchkit/benchmarks/humaneval.py +103 -0
  20. benchkit/benchmarks/ifeval.py +56 -0
  21. benchkit/benchmarks/ifeval_instructions.py +477 -0
  22. benchkit/benchmarks/mbpp.py +60 -0
  23. benchkit/benchmarks/mc_arena.py +479 -0
  24. benchkit/benchmarks/mcq.py +94 -0
  25. benchkit/benchmarks/medfailbench.py +537 -0
  26. benchkit/benchmarks/mmlu.py +54 -0
  27. benchkit/benchmarks/mmlu_pro.py +62 -0
  28. benchkit/benchmarks/mmvp.py +115 -0
  29. benchkit/benchmarks/ocrbench.py +96 -0
  30. benchkit/benchmarks/openbookqa.py +52 -0
  31. benchkit/benchmarks/patcheval.py +929 -0
  32. benchkit/benchmarks/piqa.py +52 -0
  33. benchkit/benchmarks/ruler.py +172 -0
  34. benchkit/benchmarks/ruler_generator.py +535 -0
  35. benchkit/benchmarks/sanity.py +174 -0
  36. benchkit/benchmarks/treejs_arena.py +268 -0
  37. benchkit/benchmarks/truthfulqa.py +52 -0
  38. benchkit/benchmarks/utils.py +25 -0
  39. benchkit/benchmarks/vision.py +230 -0
  40. benchkit/benchmarks/vstar.py +108 -0
  41. benchkit/benchmarks/winogrande.py +52 -0
  42. benchkit/benchmarks/xstest.py +116 -0
  43. benchkit/browser.py +429 -0
  44. benchkit/catalog.py +121 -0
  45. benchkit/cli.py +1000 -0
  46. benchkit/client.py +1413 -0
  47. benchkit/controls.py +87 -0
  48. benchkit/datasets/MMLU_LICENSE.txt +21 -0
  49. benchkit/datasets/README.md +142 -0
  50. benchkit/datasets/arc_challenge.jsonl +1172 -0
  51. benchkit/datasets/banking77.jsonl +3080 -0
  52. benchkit/datasets/banking77_intents.json +79 -0
  53. benchkit/datasets/boolq.jsonl +3270 -0
  54. benchkit/datasets/code_verifier.jsonl +500 -0
  55. benchkit/datasets/gpqa_diamond.jsonl +198 -0
  56. benchkit/datasets/gsm8k.jsonl +1319 -0
  57. benchkit/datasets/hellaswag.jsonl +1000 -0
  58. benchkit/datasets/humaneval.jsonl +164 -0
  59. benchkit/datasets/ifeval.jsonl +541 -0
  60. benchkit/datasets/mbpp.jsonl +500 -0
  61. benchkit/datasets/mc_blocks_1_20_1.jsonl +1003 -0
  62. benchkit/datasets/medfailbench_hard30.jsonl +30 -0
  63. benchkit/datasets/medfailbench_hard30.md +13 -0
  64. benchkit/datasets/openbookqa.jsonl +500 -0
  65. benchkit/datasets/piqa.jsonl +1838 -0
  66. benchkit/datasets/sanity_code.jsonl +6 -0
  67. benchkit/datasets/truthfulqa.jsonl +817 -0
  68. benchkit/datasets/winogrande.jsonl +1267 -0
  69. benchkit/datasets/xstest.csv +451 -0
  70. benchkit/decision.py +194 -0
  71. benchkit/demo.py +451 -0
  72. benchkit/dockerfiles.py +247 -0
  73. benchkit/engine.py +1270 -0
  74. benchkit/evaluation.py +195 -0
  75. benchkit/events.py +141 -0
  76. benchkit/executor.py +96 -0
  77. benchkit/git_surgery/backport-release-stack/setup.sh +328 -0
  78. benchkit/git_surgery/backport-release-stack/verify.sh +119 -0
  79. benchkit/git_surgery/bisect-the-regression/setup.sh +49 -0
  80. benchkit/git_surgery/bisect-the-regression/verify.sh +34 -0
  81. benchkit/git_surgery/rebase-conflict-chain/setup.sh +65 -0
  82. benchkit/git_surgery/rebase-conflict-chain/verify.sh +43 -0
  83. benchkit/git_surgery/recover-complex-stash/setup.sh +152 -0
  84. benchkit/git_surgery/recover-complex-stash/verify.sh +95 -0
  85. benchkit/git_surgery/recover-lost-work/setup.sh +40 -0
  86. benchkit/git_surgery/recover-lost-work/verify.sh +36 -0
  87. benchkit/git_surgery/repair-force-pushed-remote/setup.sh +171 -0
  88. benchkit/git_surgery/repair-force-pushed-remote/verify.sh +83 -0
  89. benchkit/git_surgery/revert-merge-with-followups/setup.sh +198 -0
  90. benchkit/git_surgery/revert-merge-with-followups/verify.sh +98 -0
  91. benchkit/git_surgery/secret-in-history/README.md +27 -0
  92. benchkit/git_surgery/secret-in-history/setup.sh +107 -0
  93. benchkit/git_surgery/secret-in-history/verify.sh +106 -0
  94. benchkit/git_surgery/split-the-mega-commit/setup.sh +50 -0
  95. benchkit/git_surgery/split-the-mega-commit/verify.sh +48 -0
  96. benchkit/git_surgery/untangle-nested-submodules/setup.sh +193 -0
  97. benchkit/git_surgery/untangle-nested-submodules/verify.sh +90 -0
  98. benchkit/history.py +474 -0
  99. benchkit/jobs.py +332 -0
  100. benchkit/leakage.py +51 -0
  101. benchkit/looping.py +873 -0
  102. benchkit/mc_render.py +274 -0
  103. benchkit/mc_viewer/dist/atlas.png +0 -0
  104. benchkit/mc_viewer/dist/blockStates.json.gz +0 -0
  105. benchkit/mc_viewer/dist/version.json +4 -0
  106. benchkit/mc_viewer/dist/viewer.html +23 -0
  107. benchkit/mc_viewer/dist/viewer.js +2 -0
  108. benchkit/mc_viewer/dist/viewer.js.LICENSE.txt +25 -0
  109. benchkit/mc_viewer/dist/worker.js +2 -0
  110. benchkit/mc_viewer/dist/worker.js.LICENSE.txt +19 -0
  111. benchkit/metrics.py +100 -0
  112. benchkit/ollama.py +60 -0
  113. benchkit/perf.py +353 -0
  114. benchkit/perf_report.py +207 -0
  115. benchkit/perturbations.py +208 -0
  116. benchkit/pi_agent.py +476 -0
  117. benchkit/pi_package/package-lock.json +1827 -0
  118. benchkit/pi_package/package.json +8 -0
  119. benchkit/report.py +142 -0
  120. benchkit/report_markdown.py +645 -0
  121. benchkit/results.py +688 -0
  122. benchkit/retry.py +163 -0
  123. benchkit/runner.py +769 -0
  124. benchkit/sandbox.py +1167 -0
  125. benchkit/template_check.py +291 -0
  126. benchkit/templates/arena.html +504 -0
  127. benchkit/templates/history.html +58 -0
  128. benchkit/templates/perf.html +509 -0
  129. benchkit/templates/report.html +769 -0
  130. benchkit/tui/__init__.py +5 -0
  131. benchkit/tui/app.py +87 -0
  132. benchkit/tui/app.tcss +512 -0
  133. benchkit/tui/formatting.py +84 -0
  134. benchkit/tui/screens/__init__.py +1 -0
  135. benchkit/tui/screens/connect.py +191 -0
  136. benchkit/tui/screens/detail.py +288 -0
  137. benchkit/tui/screens/help.py +84 -0
  138. benchkit/tui/screens/modals.py +544 -0
  139. benchkit/tui/screens/results.py +361 -0
  140. benchkit/tui/screens/run.py +988 -0
  141. benchkit/tui/screens/setup.py +733 -0
  142. benchkit/tui/theme.py +135 -0
  143. benchkit/tui/widgets.py +120 -0
  144. benchkit_cli-0.1.0.dist-info/METADATA +294 -0
  145. benchkit_cli-0.1.0.dist-info/RECORD +149 -0
  146. benchkit_cli-0.1.0.dist-info/WHEEL +5 -0
  147. benchkit_cli-0.1.0.dist-info/entry_points.txt +3 -0
  148. benchkit_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
  149. benchkit_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,191 @@
1
+ """Connection screen: pick a host/provider and discover models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from textual import events, work
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding
10
+ from textual.containers import Horizontal, Vertical, VerticalScroll
11
+ from textual.screen import Screen
12
+ from textual.widgets import Button, Footer, Header, Input, Label, Select, Static
13
+
14
+ from benchkit.client import DEFAULT_HOST, InferenceClient
15
+ from benchkit.demo import DemoClient
16
+ from benchkit.tui.widgets import Banner
17
+
18
+ PROVIDERS = [
19
+ ("auto detect", "auto"),
20
+ ("OpenAI compatible", "openai"),
21
+ ("Ollama", "ollama"),
22
+ ]
23
+
24
+ HINT = (
25
+ "Point BenchKit at any OpenAI-compatible server (llama.cpp, llama-swap, vLLM, "
26
+ "LM Studio) or a native Ollama host."
27
+ )
28
+
29
+
30
+ class ConnectScreen(Screen[None]):
31
+ """First screen: configure the endpoint and connect."""
32
+
33
+ BINDINGS = [
34
+ Binding("ctrl+r", "connect", "Connect"),
35
+ Binding("f5", "connect", "Connect", show=False),
36
+ Binding("f3", "demo", "Demo mode"),
37
+ Binding("escape", "app.quit", "Quit", show=False),
38
+ ]
39
+
40
+ def compose(self) -> ComposeResult:
41
+ yield Header(show_clock=True)
42
+ with VerticalScroll(id="connect-root"):
43
+ yield Banner(id="banner")
44
+ yield Static(
45
+ "Benchmark your local LLMs — not vibes, actual scores.", id="tagline"
46
+ )
47
+ with Vertical(id="connect-card"):
48
+ yield Static("Connection", classes="card-title")
49
+ with Vertical(id="connect-form"):
50
+ with Horizontal(classes="field"):
51
+ yield Label("Host", classes="field-label")
52
+ yield Input(placeholder=DEFAULT_HOST, id="host", compact=True)
53
+ with Horizontal(classes="field"):
54
+ yield Label("Provider", classes="field-label")
55
+ yield Select(
56
+ PROVIDERS,
57
+ value="auto",
58
+ allow_blank=False,
59
+ id="provider",
60
+ compact=True,
61
+ )
62
+ with Horizontal(classes="field"):
63
+ yield Label("API key", classes="field-label")
64
+ yield Input(
65
+ placeholder="optional",
66
+ password=True,
67
+ id="api-key",
68
+ compact=True,
69
+ )
70
+ with Horizontal(classes="field"):
71
+ yield Label("Task timeout", classes="field-label")
72
+ yield Input(placeholder="300", id="timeout", compact=True)
73
+ with Horizontal(id="connect-actions"):
74
+ yield Button("Connect", id="connect", variant="primary")
75
+ yield Button("Quit", id="quit", variant="error")
76
+ yield Static(HINT, id="connect-status")
77
+ yield Footer()
78
+
79
+ def on_resize(self, event: events.Resize) -> None:
80
+ # The wordmark is 62 cells wide, so it goes first on a small terminal.
81
+ size = event.size
82
+ self.set_class(size.height < 30 or size.width < 66, "compact")
83
+
84
+ def on_mount(self) -> None:
85
+ defaults = _env_defaults()
86
+ host = self.app.host_override or defaults["host"]
87
+ self.query_one("#host", Input).value = host
88
+ self.query_one("#provider", Select).value = defaults["provider"]
89
+ self.query_one("#api-key", Input).value = defaults["api_key"]
90
+ self.query_one("#timeout", Input).value = defaults["timeout"]
91
+
92
+ if self.app.demo:
93
+ self.set_timer(0.1, self.action_demo)
94
+ else:
95
+ self.set_timer(0.1, self.action_connect)
96
+
97
+ # Actions ----------------------------------------------------------
98
+
99
+ def action_connect(self) -> None:
100
+ host = self.query_one("#host", Input).value.strip() or DEFAULT_HOST
101
+ provider = self.query_one("#provider", Select).value
102
+ api_key = self.query_one("#api-key", Input).value.strip() or None
103
+ raw_timeout = self.query_one("#timeout", Input).value.strip() or "300"
104
+ try:
105
+ timeout = float(raw_timeout)
106
+ except ValueError:
107
+ self._status(f"Timeout must be a number, got {raw_timeout!r}", "error")
108
+ return
109
+
110
+ self._busy(True)
111
+ self._status(f"Connecting to {host} …", "working")
112
+ self._connect(host.rstrip("/"), str(provider), api_key, timeout)
113
+
114
+ def action_demo(self) -> None:
115
+ client = DemoClient()
116
+ self._status("Demo mode — offline models, no server required.", "ok")
117
+ self.app.show_setup(client, client.list_models())
118
+
119
+ def on_button_pressed(self, event: Button.Pressed) -> None:
120
+ if event.button.id == "connect":
121
+ self.action_connect()
122
+ elif event.button.id == "quit":
123
+ self.app.exit()
124
+
125
+ def on_input_submitted(self) -> None:
126
+ self.action_connect()
127
+
128
+ # Worker -----------------------------------------------------------
129
+
130
+ @work(thread=True, exclusive=True, group="connect")
131
+ def _connect(
132
+ self, host: str, provider: str, api_key: str | None, timeout: float
133
+ ) -> None:
134
+ try:
135
+ client = InferenceClient(host, provider, api_key, timeout)
136
+ models = client.list_models()
137
+ except Exception as exc:
138
+ self.app.call_from_thread(self._failed, f"{type(exc).__name__}: {exc}")
139
+ return
140
+ self.app.call_from_thread(self._connected, client, models)
141
+
142
+ def _connected(self, client: InferenceClient, models: list[dict]) -> None:
143
+ self._busy(False)
144
+ if not models:
145
+ self._status(
146
+ f"Connected to {client.host} but the server reports no models.", "error"
147
+ )
148
+ return
149
+ self._status(f"{len(models)} model(s) via {client.label}", "ok")
150
+ self.app.show_setup(client, models)
151
+
152
+ def _failed(self, message: str) -> None:
153
+ self._busy(False)
154
+ self._status(
155
+ f"{message}\nCheck the host and that the server is running, "
156
+ "or press F3 for demo mode.",
157
+ "error",
158
+ )
159
+
160
+ # Helpers ----------------------------------------------------------
161
+
162
+ def _busy(self, busy: bool) -> None:
163
+ self.query_one("#connect", Button).disabled = busy
164
+ self.query_one("#connect-card").set_class(busy, "busy")
165
+
166
+ def _status(self, message: str, kind: str = "") -> None:
167
+ status = self.query_one("#connect-status", Static)
168
+ status.set_classes(f"status {kind}" if kind else "status")
169
+ status.update(message)
170
+ status.scroll_visible()
171
+
172
+
173
+ def _env_defaults() -> dict[str, str]:
174
+ provider = os.environ.get("BENCHKIT_PROVIDER", "auto").lower()
175
+ if provider not in {"auto", "openai", "ollama"}:
176
+ provider = "auto"
177
+ host = (
178
+ os.environ.get("BENCHKIT_HOST")
179
+ or os.environ.get("OPENAI_BASE_URL")
180
+ or os.environ.get("OLLAMA_HOST")
181
+ or DEFAULT_HOST
182
+ )
183
+ api_key = (
184
+ os.environ.get("BENCHKIT_API_KEY") or os.environ.get("OPENAI_API_KEY") or ""
185
+ )
186
+ return {
187
+ "host": host.rstrip("/"),
188
+ "provider": provider,
189
+ "api_key": api_key,
190
+ "timeout": os.environ.get("BENCHKIT_TIMEOUT", "300"),
191
+ }
@@ -0,0 +1,288 @@
1
+ """Per-run drill-down: every task of a single model x benchmark."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.text import Text
6
+ from textual import events
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding
9
+ from textual.containers import Horizontal, Vertical
10
+ from textual.screen import Screen
11
+ from textual.widgets import DataTable, Footer, Header, Input
12
+
13
+ from benchkit.engine import slice_label
14
+ from benchkit.metrics import (
15
+ aggregate_tok_s,
16
+ decision_speed,
17
+ effective_concurrency,
18
+ is_decision,
19
+ latency_text,
20
+ stream_tok_s,
21
+ )
22
+ from benchkit.tui.formatting import (
23
+ fmt_count,
24
+ fmt_duration,
25
+ result_color,
26
+ score_color,
27
+ throughput_stat,
28
+ )
29
+ from benchkit.tui.screens.modals import TaskDetailScreen
30
+ from benchkit.tui.widgets import SectionTitle, StatCard, apply_compact
31
+
32
+ FILTERS = ["all", "failed", "passed"]
33
+
34
+
35
+ class JobDetailScreen(Screen[None]):
36
+ """Task-level view of one benchmark run."""
37
+
38
+ BINDINGS = [
39
+ Binding("f", "cycle_filter", "Filter"),
40
+ Binding("slash", "focus_search", "Search"),
41
+ Binding("escape", "close", "Back"),
42
+ ]
43
+
44
+ def __init__(self, result: dict) -> None:
45
+ super().__init__()
46
+ self.result = result
47
+ self.filter_index = 0
48
+
49
+ def compose(self) -> ComposeResult:
50
+ yield Header(show_clock=True)
51
+ with Vertical(id="detail-root"):
52
+ with Horizontal(id="stat-row"):
53
+ yield StatCard("Score", "--", id="stat-score")
54
+ yield StatCard("Passed", "--", id="stat-passed")
55
+ yield StatCard("Loop rate", "--", id="stat-loops")
56
+ yield StatCard("Throughput", "--", id="stat-speed")
57
+ yield StatCard("Avg latency", "--", id="stat-latency")
58
+ yield StatCard("Duration", "--", id="stat-duration")
59
+ with Vertical(classes="pane", id="detail-pane"):
60
+ yield SectionTitle("Tasks", "enter to open", id="detail-title")
61
+ yield Input(placeholder="Search task id or response…", id="task-search")
62
+ yield DataTable(id="task-table", cursor_type="row", zebra_stripes=True)
63
+ yield Footer()
64
+
65
+ def on_mount(self) -> None:
66
+ result = self.result
67
+ benchmark_label = result.get("benchmark_label", result["benchmark"])
68
+ harness = result.get("harness_label", "Direct")
69
+ self.title = f"{benchmark_label} · {result['model']} · {harness}"
70
+ self.sub_title = f"{result['passed']}/{result['total']} passed"
71
+
72
+ table = self.query_one("#task-table", DataTable)
73
+ table.add_column("#", key="index", width=5)
74
+ table.add_column("Task", key="task")
75
+ table.add_column("Result", key="result", width=8)
76
+ table.add_column("Loop", key="loop", width=9)
77
+ table.add_column("Think", key="thinking", width=8)
78
+ table.add_column("Latency", key="latency", width=9)
79
+ table.add_column("Stream tok/s", key="tok_s", width=12)
80
+
81
+ score = result["score"]
82
+ score_hint = slice_label(result.get("slice"))
83
+ if result.get("score_delta_pp") is not None:
84
+ score_hint = (
85
+ f"{score_hint} · Δ {result['score_delta_pp']:+.1f} pp · "
86
+ f"{result.get('regressions', 0)} regression(s) · "
87
+ f"seed {result.get('perturbation_seed', 42)}"
88
+ )
89
+ if result.get("harness_score_delta_pp") is not None:
90
+ score_hint += (
91
+ f" · harness score Δ {result['harness_score_delta_pp']:+.1f} pp"
92
+ f" · loop-kill Δ {result.get('loop_kill_delta_pp', 0):+.1f} pp"
93
+ + (
94
+ f" (initial {result.get('harness_first_score_delta_pp', 0):+.1f})"
95
+ if result.get("repair_attempts")
96
+ else ""
97
+ )
98
+ + " · "
99
+ f"{result.get('harness_gains', 0)} gain(s) · "
100
+ f"{result.get('harness_regressions', 0)} regression(s)"
101
+ )
102
+ if result.get("repair_attempts"):
103
+ score_hint += (
104
+ f" · repair Δ {result.get('repair_delta_pp', 0):+.1f} pp · "
105
+ f"{result.get('repair_successes', 0)}/"
106
+ f"{result.get('repair_attempted', 0)} fixed"
107
+ )
108
+ self.query_one("#stat-score", StatCard).set_state(f"{score:.1f}%", score_hint)
109
+ self.query_one("#stat-passed", StatCard).set_state(
110
+ f"{result['passed']}/{result['total']}",
111
+ f"{result.get('errors', 0)} error(s)",
112
+ )
113
+ self.query_one("#stat-loops", StatCard).set_state(
114
+ f"{result.get('loop_rate', 0):.1f}%",
115
+ f"{result.get('loops', 0)} loop · "
116
+ f"{result.get('suspected_loops', 0)} suspect · "
117
+ f"{result.get('recovered_loops', 0)} recovered · "
118
+ f"{result.get('loop_kills', 0)} killed",
119
+ )
120
+ speed = self.query_one("#stat-speed", StatCard)
121
+ speed.set_state(
122
+ *(
123
+ decision_speed(result)
124
+ if is_decision(result)
125
+ else throughput_stat(
126
+ concurrency=result.get("concurrency", 1),
127
+ aggregate=aggregate_tok_s(result),
128
+ stream=stream_tok_s(result),
129
+ effective=effective_concurrency(result),
130
+ )
131
+ )
132
+ )
133
+ self.query_one("#stat-latency", StatCard).set_state(latency_text(result))
134
+ self.query_one("#stat-duration", StatCard).set_state(
135
+ fmt_duration(result["total_time"])
136
+ )
137
+ self._restyle_score()
138
+
139
+ self._fill()
140
+ self.watch(self.app, "theme", self._theme_changed, init=False)
141
+ table.focus()
142
+
143
+ def _restyle_score(self) -> None:
144
+ color = score_color(self.result["score"], self.app.current_theme.dark)
145
+ self.query_one("#stat-score", StatCard).styles.color = color
146
+
147
+ def _theme_changed(self, _theme: str) -> None:
148
+ self._restyle_score()
149
+ self._fill()
150
+
151
+ def on_resize(self, event: events.Resize) -> None:
152
+ apply_compact(self, event.size.height)
153
+
154
+ # Rendering --------------------------------------------------------
155
+
156
+ def _visible_tasks(self) -> list[tuple[int, dict]]:
157
+ mode = FILTERS[self.filter_index]
158
+ needle = self.query_one("#task-search", Input).value.strip().lower()
159
+ rows = []
160
+ for index, task in enumerate(self.result["tasks"]):
161
+ if mode == "failed" and task["passed"]:
162
+ continue
163
+ if mode == "passed" and not task["passed"]:
164
+ continue
165
+ if (
166
+ needle
167
+ and needle not in task["task_id"].lower()
168
+ and needle not in (task.get("response", "").lower())
169
+ and needle not in (task.get("thinking", "").lower())
170
+ ):
171
+ continue
172
+ rows.append((index, task))
173
+ return rows
174
+
175
+ def _fill(self) -> None:
176
+ table = self.query_one("#task-table", DataTable)
177
+ table.clear()
178
+ rows = self._visible_tasks()
179
+ for index, task in rows:
180
+ entry = task.get("entry_point")
181
+ label = f"{task['task_id']} ({entry})" if entry else task["task_id"]
182
+ table.add_row(
183
+ str(index + 1),
184
+ label,
185
+ _result_cell(task, self.app.current_theme.dark),
186
+ _loop_cell(task),
187
+ (
188
+ fmt_duration(task.get("thinking_time_s", 0))
189
+ if task.get("thinking")
190
+ else "—"
191
+ ),
192
+ fmt_duration(task.get("response_time_s", 0)),
193
+ f"{task.get('tok_s', 0):.1f}",
194
+ key=str(index),
195
+ )
196
+ self.query_one("#detail-title", SectionTitle).set_detail(
197
+ f"{FILTERS[self.filter_index]} · {fmt_count(len(rows))} shown · enter to open"
198
+ )
199
+
200
+ # Events -----------------------------------------------------------
201
+
202
+ def on_input_changed(self) -> None:
203
+ self._fill()
204
+
205
+ def on_input_submitted(self) -> None:
206
+ self.query_one("#task-table", DataTable).focus()
207
+
208
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
209
+ index = int(str(event.row_key.value))
210
+ task = self.result["tasks"][index]
211
+ benchmark_label = self.result.get("benchmark_label", self.result["benchmark"])
212
+ title = (
213
+ f"{benchmark_label} · {self.result['model']} · "
214
+ f"{self.result.get('harness_label', 'Direct')}"
215
+ )
216
+ self.app.push_screen(TaskDetailScreen(title, task))
217
+
218
+ # Actions ----------------------------------------------------------
219
+
220
+ def action_cycle_filter(self) -> None:
221
+ self.filter_index = (self.filter_index + 1) % len(FILTERS)
222
+ self._fill()
223
+
224
+ def action_focus_search(self) -> None:
225
+ self.query_one("#task-search", Input).focus()
226
+
227
+ def action_close(self) -> None:
228
+ self.app.pop_screen()
229
+
230
+
231
+ def _result_cell(task: dict, dark: bool = True) -> Text:
232
+ error = bool(task.get("error"))
233
+ passed = bool(task.get("passed"))
234
+ label = (
235
+ "LOOPKILL"
236
+ if task.get("loop_killed")
237
+ else "TIMEOUT"
238
+ if task.get("timed_out")
239
+ else "LENGTH"
240
+ if task.get("length_exceeded")
241
+ else "ERROR"
242
+ if error
243
+ else "FIXED"
244
+ if task.get("repaired")
245
+ else "PASS"
246
+ if passed
247
+ else f"{float(task.get('score', 0)):.0f}%"
248
+ if float(task.get("score", 0)) > 0
249
+ else "FAIL"
250
+ )
251
+ color = (
252
+ score_color(float(task.get("score", 0)), dark)
253
+ if not passed and float(task.get("score", 0)) > 0 and not error
254
+ else result_color(passed, error, dark)
255
+ )
256
+ return Text(label, style=f"bold {color}")
257
+
258
+
259
+ def _loop_cell(task: dict) -> Text:
260
+ state = task.get("loop_state", "unavailable")
261
+ source = task.get("loop_source", "none")
262
+ label = (
263
+ "RECOVERED"
264
+ if task.get("recovered_cycle")
265
+ else "OUTPUT"
266
+ if state == "looping" and source == "answer"
267
+ else "LOOPING"
268
+ if state == "looping"
269
+ else "SUSPECT"
270
+ if state == "suspected"
271
+ else "NO TRACE"
272
+ if state == "clear" and source == "answer"
273
+ else "CLEAR"
274
+ if state == "clear"
275
+ else "NO TRACE"
276
+ )
277
+ style = (
278
+ "bold yellow"
279
+ if task.get("recovered_cycle")
280
+ else "bold red"
281
+ if state == "looping"
282
+ else "bold yellow"
283
+ if state == "suspected"
284
+ else "green"
285
+ if state == "clear" and source != "answer"
286
+ else "dim"
287
+ )
288
+ return Text(label, style=style)
@@ -0,0 +1,84 @@
1
+ """Keyboard reference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.binding import Binding
7
+ from textual.containers import VerticalScroll
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Footer, Markdown, Static
10
+
11
+ HELP = """
12
+ ### Everywhere
13
+
14
+ | Key | Action |
15
+ | --- | --- |
16
+ | `?` / `F1` | This help |
17
+ | `Ctrl+P` | Command palette |
18
+ | `F2` | Dogi light / dark theme |
19
+ | `Ctrl+Q` | Quit |
20
+ | `Tab` / `Shift+Tab` | Move between panes |
21
+
22
+ ### Connect
23
+
24
+ | Key | Action |
25
+ | --- | --- |
26
+ | `Ctrl+R` | Connect / retry |
27
+ | `F3` | Demo mode (no server needed) |
28
+ | `Enter` | Connect from any field |
29
+
30
+ ### Setup
31
+
32
+ | Key | Action |
33
+ | --- | --- |
34
+ | `Space` / `Enter` | Toggle the highlighted row |
35
+ | `a` / `n` / `i` | Select all / clear / invert the focused list |
36
+ | `l` | Task limit for the highlighted benchmark |
37
+ | `p` | Toggle the paired choice-order run |
38
+ | `v` | Check chat templates (selected models, or all) |
39
+ | `d` | Datasets: see what is installed and install what is missing |
40
+ | `/` | Jump to the filter box |
41
+ | `s` / `F5` | Start the run |
42
+ | `Esc` | Back to the connection screen |
43
+
44
+ Limits accept `20` (first 20), `-20` (last 20) and `40-80` (a range).
45
+ Choice-order runs each selected task clean and with deterministically shuffled options.
46
+
47
+ ### Run
48
+
49
+ | Key | Action |
50
+ | --- | --- |
51
+ | `p` | Pause / resume between tasks |
52
+ | `k` | Skip the current model × benchmark |
53
+ | `x` | Stop the run and keep what finished |
54
+ | `f` | Show failures only |
55
+ | `t` | Follow the newest task (auto-scroll) |
56
+ | `Enter` | Inspect the highlighted task |
57
+
58
+ ### Results
59
+
60
+ | Key | Action |
61
+ | --- | --- |
62
+ | `Enter` | Drill into a run's tasks |
63
+ | `s` / `m` / `t` | Sort by score / model / duration |
64
+ | `c` | Copy the results folder path |
65
+ | `r` | Configure another run |
66
+ | `Esc` | Back to setup |
67
+ """
68
+
69
+
70
+ class HelpScreen(ModalScreen[None]):
71
+ BINDINGS = [
72
+ Binding("escape", "close", "Close"),
73
+ Binding("question_mark", "close", "Close", show=False),
74
+ Binding("q", "close", "Close", show=False),
75
+ ]
76
+
77
+ def compose(self) -> ComposeResult:
78
+ with VerticalScroll(id="help-dialog", classes="dialog"):
79
+ yield Static("Keyboard reference", classes="card-title")
80
+ yield Markdown(HELP)
81
+ yield Footer()
82
+
83
+ def action_close(self) -> None:
84
+ self.dismiss(None)