tuieval 0.2.0.dev107__tar.gz → 0.2.0.dev112__tar.gz

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 (79) hide show
  1. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/CHANGELOG.md +3 -0
  2. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/PKG-INFO +2 -2
  3. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/README.md +1 -1
  4. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/pta.py +9 -4
  6. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/tui.py +140 -23
  7. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/tests/test_tuieval.py +127 -0
  8. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/RELEASING.md +0 -0
  11. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/README.md +0 -0
  12. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/favicon.ico +0 -0
  13. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/readme-header.png +0 -0
  14. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/social-preview.png +0 -0
  15. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-1024.png +0 -0
  16. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-128.png +0 -0
  17. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-16.png +0 -0
  18. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-16.svg +0 -0
  19. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-256.png +0 -0
  20. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-32.png +0 -0
  21. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-48.png +0 -0
  22. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-512.png +0 -0
  23. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-64.png +0 -0
  24. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  25. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon.svg +0 -0
  26. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/tui-answer-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/tui-run-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/images/tui-setup.png +0 -0
  29. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/models.md +0 -0
  30. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/docs/writing-packs.md +0 -0
  31. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/pyproject.toml +0 -0
  32. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/__init__.py +0 -0
  33. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/__main__.py +0 -0
  34. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/builtin.toml +0 -0
  35. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/cli.py +0 -0
  36. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/client.py +0 -0
  37. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/compare.py +0 -0
  38. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/doctor.py +0 -0
  39. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/engine.py +0 -0
  40. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/export.py +0 -0
  41. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/graders/__init__.py +0 -0
  42. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/graders/answer.py +0 -0
  43. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/graders/code.py +0 -0
  44. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/graders/rag.py +0 -0
  45. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/graders/reply.py +0 -0
  46. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/graders/tool_call.py +0 -0
  47. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/insights.py +0 -0
  48. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/logs.py +0 -0
  49. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/machines.py +0 -0
  50. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/onboard.py +0 -0
  51. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/packs.py +0 -0
  52. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/profiles.py +0 -0
  53. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/run_evals.py +0 -0
  55. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/src/tuieval/yamlout.py +0 -0
  79. {tuieval-0.2.0.dev107 → tuieval-0.2.0.dev112}/tests/mock_server.py +0 -0
@@ -2,6 +2,9 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Copy reasoning: `y` in the answer view copies its full reasoning, `a` the answer; `y` on the run screen copies the reasoning streamed so far. Uses pbcopy, wl-copy, xclip, xsel or clip, else the terminal (OSC 52).
6
+ - PTA index: click a model's name to remove it from view (every Results tab follows); click it under the bars to bring it back, or show all.
7
+ - The Models heading shows how many are selected, naming any the filter hides; Start asks first when some are hidden.
5
8
  - Results → Failures: a Models button filters the list (the same pick as the other tabs), with the number of failures shown.
6
9
  - Fix: an answer followed by a stray `</think>` (or a stray code fence after it) was graded as empty; the answer before the tag is graded now. The code grader also reads a doubled ```` ```python ```` fence as one.
7
10
  - Run screen: `x` removes the highlighted pack from the run going if it hasn't started (the reverse of adding it); a model left with nothing isn't started.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev107
3
+ Version: 0.2.0.dev112
4
4
  Summary: Evaluate local and frontier LLMs on your own questions: accuracy, speed, tokens and PASS/FAIL verdicts, in the terminal.
5
5
  Project-URL: Homepage, https://github.com/ashe-wb/tuieval
6
6
  Project-URL: Issues, https://github.com/ashe-wb/tuieval/issues
@@ -66,7 +66,7 @@ While a run goes, you watch each question's reasoning and answer stream in:
66
66
 
67
67
  ![tuieval's run screen with a model's reasoning streaming in for a coding question](https://raw.githubusercontent.com/ashe-wb/tuieval/main/docs/images/tui-run-reasoning.png)
68
68
 
69
- Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others:
69
+ Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others, `y` copies the reasoning and `a` the answer (on the run screen, `y` copies the reasoning streamed so far):
70
70
 
71
71
  ![one answer in full: the grade and its checks, the model's reasoning and its code](https://raw.githubusercontent.com/ashe-wb/tuieval/main/docs/images/tui-answer-reasoning.png)
72
72
 
@@ -40,7 +40,7 @@ While a run goes, you watch each question's reasoning and answer stream in:
40
40
 
41
41
  ![tuieval's run screen with a model's reasoning streaming in for a coding question](https://raw.githubusercontent.com/ashe-wb/tuieval/main/docs/images/tui-run-reasoning.png)
42
42
 
43
- Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others:
43
+ Afterwards, every answer keeps its reasoning. Enter on a result opens it with the grade and each check, the full reasoning and the answer; `[` and `]` step through the others, `y` copies the reasoning and `a` the answer (on the run screen, `y` copies the reasoning streamed so far):
44
44
 
45
45
  ![one answer in full: the grade and its checks, the model's reasoning and its code](https://raw.githubusercontent.com/ashe-wb/tuieval/main/docs/images/tui-answer-reasoning.png)
46
46
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0.dev107'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev107')
21
+ __version__ = version = '0.2.0.dev112'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev112')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -117,12 +117,13 @@ def _blocks(width, v, most):
117
117
  return max(1, min(width, round(width * v / most)))
118
118
 
119
119
 
120
- def bars(result, width=20, markup=True):
120
+ def bars(result, width=20, markup=True, click=None):
121
121
  """Lines with a row per model and a bar each for P, T and A: the at-a-glance view. The P and T bars
122
122
  are proportional to tokens and time (the longest is the model that used most; shorter is better),
123
123
  with how many times the leanest / fastest model's beside them; the A bar is the share of answers
124
124
  right (longer is better, full only when every answer passed), then the share cut off at max_tokens in
125
- orange (▓ in plain text) and "(n% truncated)" beside it. markup=False gives plain text."""
125
+ orange (▓ in plain text) and "(n% truncated)" beside it. markup=False gives plain text; click (an
126
+ action, e.g. "screen.remove") makes each model's name a link calling it with the row's index."""
126
127
  if not result["models"]:
127
128
  return []
128
129
  name_w = max(len(x["model"]) for x in result["models"])
@@ -133,7 +134,7 @@ def bars(result, width=20, markup=True):
133
134
  sub = pad + "".join(f"{better:<{cell_w}}" for _, _, better, *_ in LETTERS)
134
135
  lines = [f"[b]{head.rstrip()}[/b]" if markup else head.rstrip(), f"[dim]{sub.rstrip()}[/dim]" if markup else sub.rstrip()]
135
136
  most = {m: max((x.get(m) or 0 for x in result["models"]), default=0) for _, _, _, m, _, _ in LETTERS}
136
- for x in result["models"]:
137
+ for row, x in enumerate(result["models"]):
137
138
  cells = []
138
139
  for k, _, _, measure, shown, color in LETTERS:
139
140
  if k == "A":
@@ -155,7 +156,11 @@ def bars(result, width=20, markup=True):
155
156
  if markup else bar + cut_bar + rest)
156
157
  cells.append(f"{bar} {num:>6}" + " " * max(0, cell_w - width - 1 - max(6, len(num))))
157
158
  name = f"{x['model']:<{name_w}}"
158
- lines.append((f"{rich_escape(name) if markup else name} " + "".join(cells)).rstrip())
159
+ if markup:
160
+ pad_w = name_w - len(x["model"])
161
+ name = (f"[@click={click}({row})]{rich_escape(x['model'])}[/]" + " " * pad_w if click
162
+ else rich_escape(name))
163
+ lines.append((f"{name} " + "".join(cells)).rstrip())
159
164
  return lines
160
165
 
161
166
 
@@ -24,6 +24,8 @@ import glob
24
24
  import json
25
25
  import os
26
26
  import re
27
+ import shutil
28
+ import subprocess
27
29
  import threading
28
30
  import time
29
31
 
@@ -289,6 +291,37 @@ class StreamView(TextArea):
289
291
  self.call_after_refresh(lambda: self.scroll_to(y=max(0, self.max_scroll_y - from_end), animate=False))
290
292
 
291
293
 
294
+ CLIPBOARD_TOOLS = (["pbcopy"], ["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"],
295
+ ["clip"])
296
+
297
+
298
+ def copy_text(app, text):
299
+ """Put text on the system clipboard with the first tool here that takes it (pbcopy, wl-copy, xclip, xsel,
300
+ clip). Without one, the terminal is asked to (OSC 52), which not every terminal allows. True if a tool did."""
301
+ for cmd in CLIPBOARD_TOOLS:
302
+ if not shutil.which(cmd[0]):
303
+ continue
304
+ try:
305
+ subprocess.run(cmd, input=text.encode("utf-16" if cmd[0] == "clip" else "utf-8"),
306
+ capture_output=True, check=True, timeout=10)
307
+ return True
308
+ except (OSError, subprocess.SubprocessError):
309
+ continue # e.g. xclip without a display: try the next
310
+ app.copy_to_clipboard(text)
311
+ return False
312
+
313
+
314
+ def notify_copied(app, what, text, note=""):
315
+ if not text:
316
+ app.notify(f"No {what} to copy.", severity="warning")
317
+ elif copy_text(app, text):
318
+ app.notify(f"Copied {what}{note}: {len(text):,} chars.")
319
+ else:
320
+ app.notify(f"Sent {what}{note} ({len(text):,} chars) to the terminal's clipboard. If nothing pastes, "
321
+ "the terminal doesn't allow it: install a clipboard tool (wl-copy, xclip or xsel).",
322
+ severity="warning", timeout=10)
323
+
324
+
292
325
  # ------------------------------------------------------------------ answer detail
293
326
  def answer_entry(row):
294
327
  """An answer for AnswerScreen from a compare row (None when the row has no stored answer)."""
@@ -451,7 +484,7 @@ with the model's reasoning and answer as they stream, then recent results with t
451
484
  PTA index parsimony (tokens), time and accuracy per model, a bar each: tokens and time
452
485
  as long as they took (shorter is better; 2.4× = 2.4 times the best), accuracy
453
486
  longer is better. Per-answer numbers, tok/s, TTFT and memory in the table.
454
- Results open here.
487
+ Results open here. Click a model's name to remove it from view (every tab).
455
488
  Production readiness one verdict per model and use case; the table says why and what to run next
456
489
  Per question every question, model by model (filters: pack, level, Models disagree)
457
490
  Failures every wrong answer with the grader's reason (filter: Models); enter opens it
@@ -511,11 +544,14 @@ class AnswerScreen(ModalScreen):
511
544
  Binding("q", "close", "Close", show=False, priority=True),
512
545
  Binding("left_square_bracket", "step(-1)", "Previous", priority=True),
513
546
  Binding("right_square_bracket", "step(1)", "Next", priority=True),
547
+ Binding("y", "copy('reasoning')", "Copy reasoning", priority=True),
548
+ Binding("a", "copy('answer')", "Copy answer", priority=True),
514
549
  ]
515
550
 
516
551
  def __init__(self, entries, index=0, what="answer"):
517
552
  super().__init__()
518
553
  self.entries, self.index, self.what = entries, index, what
554
+ self.texts = {} # what's shown: "reasoning", "answer" -> text, for the copy keys
519
555
 
520
556
  def check_action(self, action, parameters):
521
557
  return len(self.entries) > 1 if action == "step" else True
@@ -550,6 +586,9 @@ class AnswerScreen(ModalScreen):
550
586
  self.index = (self.index + delta) % len(self.entries)
551
587
  self.show()
552
588
 
589
+ def action_copy(self, what):
590
+ notify_copied(self.app, what, self.texts.get(what, ""))
591
+
553
592
  def show(self):
554
593
  e = self.app.engine
555
594
  entry = self.entries[self.index]
@@ -608,6 +647,7 @@ class AnswerScreen(ModalScreen):
608
647
  answer += "\n\n=== TOOL CALLS ===\n" + json.dumps(rec["tool_calls"], indent=2)
609
648
  for wid, text in (("#answer-reasoning", reasoning), ("#answer-answer", answer or "(empty)")):
610
649
  self.query_one(wid, TextArea).load_text(text)
650
+ self.texts = {"reasoning": reasoning, "answer": answer}
611
651
 
612
652
  question, checked = "(test not found)", "(test not found)"
613
653
  if test:
@@ -776,10 +816,7 @@ class SetupScreen(Screen):
776
816
  self.verdicts = {}
777
817
  self.app.log(f"verdicts failed: {err!r}")
778
818
  codes = group_codes(e)
779
- self.query_one("#models-legend", Label).update(
780
- "[b]Models[/b] [dim][green]✓[/green] pass [red]✗[/red] fail [yellow]?[/yellow] inconclusive "
781
- "grey = earlier · x hides[/dim]\n[dim]"
782
- + " ".join(f"{c} {g}" for g, c in codes.items()) + "[/dim]")
819
+ self.legend_codes = codes
783
820
  n_hidden = sum(m["label"] in hidden for m in e.cfg["models"])
784
821
  self.query_one("#show-hidden", Checkbox).label = f"Show hidden ({n_hidden})"
785
822
  sections = {}
@@ -819,8 +856,27 @@ class SetupScreen(Screen):
819
856
  at = values.index(keep) if keep in values else min(keep_at or 0, models.option_count - 1)
820
857
  models.highlighted = min((i for i in enabled if i >= at), default=enabled[-1])
821
858
  self.show_detail()
859
+ self.update_legend()
822
860
  self.update_estimate()
823
861
 
862
+ def unseen_models(self):
863
+ """Ticked models the list doesn't show right now (the filter leaves them out)."""
864
+ models = self.query_one("#models", SelectionList)
865
+ visible = {models.get_option_at_index(i).value for i in range(models.option_count)}
866
+ return [m["label"] for m in self.app.engine.cfg["models"]
867
+ if m["label"] in self.selected_models and m["label"] not in visible]
868
+
869
+ def update_legend(self):
870
+ """The Models heading: how many are ticked (and which ones the filter hides), then the key."""
871
+ n, unseen = len(self.selected_models), self.unseen_models()
872
+ count = f"[b]{n}[/b] selected" if n else "[dim]none selected[/dim]"
873
+ if unseen:
874
+ count += f" [yellow]({len(unseen)} not shown: {rich_escape(', '.join(unseen))})[/yellow]"
875
+ self.query_one("#models-legend", Label).update(
876
+ f"[b]Models[/b] {count} [dim][green]✓[/green] pass [red]✗[/red] fail [yellow]?[/yellow] "
877
+ "inconclusive grey = earlier · x hides[/dim]\n[dim]"
878
+ + " ".join(f"{c} {g}" for g, c in getattr(self, "legend_codes", {}).items()) + "[/dim]")
879
+
824
880
  @on(SelectionList.SelectionHighlighted, "#models")
825
881
  def show_detail(self, *_):
826
882
  """Everything about the highlighted model: verdicts in full, serving, per-pack results."""
@@ -894,6 +950,7 @@ class SetupScreen(Screen):
894
950
  return
895
951
  visible = {models.get_option_at_index(i).value for i in range(models.option_count)}
896
952
  self.selected_models = (self.selected_models - visible) | set(models.selected)
953
+ self.update_legend()
897
954
  self.update_estimate()
898
955
 
899
956
  def tier(self):
@@ -1110,10 +1167,20 @@ class SetupScreen(Screen):
1110
1167
  self.notify(f"Nothing to run: {item.problem}.", severity="warning")
1111
1168
  return
1112
1169
  self.app.start_or_queue(item)
1113
- if rows:
1114
- self.app.push_screen(RetestScreen(rows, force, rerun), go)
1170
+ def asked(yes=True):
1171
+ if not yes:
1172
+ return
1173
+ if rows:
1174
+ self.app.push_screen(RetestScreen(rows, force, rerun), go)
1175
+ else:
1176
+ go({})
1177
+ unseen = self.unseen_models()
1178
+ if unseen: # ticks the filter hides are easy to forget: name them before anything starts
1179
+ self.app.push_screen(ConfirmScreen(
1180
+ f"Start {len(labels)} model(s)? {len(unseen)} of them the filter hides:\n "
1181
+ + rich_escape("\n ".join(unseen) + f"\n\nAll selected: {', '.join(labels)}")), asked)
1115
1182
  else:
1116
- go({})
1183
+ asked()
1117
1184
 
1118
1185
  def action_tune(self):
1119
1186
  e = self.app.engine
@@ -2136,6 +2203,7 @@ class RunScreen(Screen):
2136
2203
  Binding("c", "cancel_all", "Cancel all", priority=True),
2137
2204
  Binding("f", "toggle_follow", "Pause scroll", priority=True),
2138
2205
  Binding("v", "watch_next", "Next model", priority=True),
2206
+ Binding("y", "copy_reasoning", "Copy reasoning", priority=True),
2139
2207
  Binding("n", "new_run", "More evals", priority=True),
2140
2208
  Binding("r", "results", "Results", priority=True),
2141
2209
  Binding("w", "runs", "Runs", priority=True),
@@ -2155,6 +2223,8 @@ class RunScreen(Screen):
2155
2223
  self.follow = True
2156
2224
  self.buf = {"reasoning": [], "answer": []}
2157
2225
  self.buf_lock = threading.Lock()
2226
+ self.streamed = {"reasoning": [], "answer": []} # the watched answer so far, untrimmed (for y)
2227
+ self.streamed_whole = False # from its start (not joined midway with v)
2158
2228
  self.req_live = {} # model label -> its request now: phase, ~tokens, started, last token
2159
2229
  self.started = time.time()
2160
2230
  self.req_started = None
@@ -2317,6 +2387,7 @@ class RunScreen(Screen):
2317
2387
  v.clear()
2318
2388
  for stream, text in parts.items():
2319
2389
  if text:
2390
+ self.streamed[stream].append(text)
2320
2391
  self.query_one(f"#{stream}", StreamView).append(text, self.follow)
2321
2392
  self.req_tokens += max(1, len(text) // 4)
2322
2393
  if self.req_first is None and self.req_started:
@@ -2337,6 +2408,7 @@ class RunScreen(Screen):
2337
2408
  self.flush_streams()
2338
2409
  self.query_one("#reasoning", StreamView).clear()
2339
2410
  self.query_one("#answer", StreamView).clear()
2411
+ self.streamed, self.streamed_whole = {"reasoning": [], "answer": []}, True
2340
2412
  self.req_started, self.req_tokens, self.req_first = time.time(), 0, None
2341
2413
  job = d["job"]
2342
2414
  rep_note = f" · repeat {d['repeat'] + 1}/{job.repeat}" if job.repeat > 1 else ""
@@ -2704,6 +2776,7 @@ class RunScreen(Screen):
2704
2776
  v.clear()
2705
2777
  self.query_one("#reasoning", StreamView).clear()
2706
2778
  self.query_one("#answer", StreamView).clear()
2779
+ self.streamed, self.streamed_whole = {"reasoning": [], "answer": []}, False
2707
2780
  self.req_started = None
2708
2781
  self.current = f"Streaming [b]{self.watch}[/b] from its next answer…"
2709
2782
  self.refresh_current()
@@ -2721,6 +2794,12 @@ class RunScreen(Screen):
2721
2794
  self.follow = not self.follow
2722
2795
  self.update_overall()
2723
2796
 
2797
+ def action_copy_reasoning(self):
2798
+ """The streaming answer's reasoning so far, all of it (the pane drops the oldest text when it's long)."""
2799
+ self.flush_streams()
2800
+ notify_copied(self.app, "reasoning", "".join(self.streamed["reasoning"]),
2801
+ "" if self.streamed_whole else " (from midway: streaming joined after it began)")
2802
+
2724
2803
  @on(DataTable.RowSelected, "#recent")
2725
2804
  def recent_selected(self, event):
2726
2805
  keys = [k for k in event.data_table.rows if k in self.recent_entries]
@@ -3005,22 +3084,51 @@ class ResultsScreen(Screen):
3005
3084
  models = sorted(counts, key=lambda m: (-counts[m], m))
3006
3085
 
3007
3086
  def picked(chosen):
3008
- if chosen is None:
3009
- return
3010
- first = chosen and not self.app.pq_models
3011
- self.app.pq_models = chosen # kept for this session, so reopening Results keeps it
3012
- if first: # a fair comparison needs the same questions: turn it on (you can untick it)
3013
- with self.query_one("#pq-shared", Checkbox).prevent(Checkbox.Changed):
3014
- self.query_one("#pq-shared", Checkbox).value = True
3015
- # after the picker has closed: a table rebuilt under a modal keeps its header-only widths
3016
- self.call_after_refresh(self.fill_per_question)
3017
- self.call_after_refresh(self.fill_pta)
3018
- self.call_after_refresh(self.fill_failures)
3019
- self.insights_stale = True
3020
- if self.query_one(TabbedContent).active == "tab-insights":
3021
- self.call_after_refresh(self.fill_insights)
3087
+ if chosen is not None:
3088
+ self.set_models(chosen)
3022
3089
  self.app.push_screen(ModelPickScreen(models, counts, self.app.pq_models), picked)
3023
3090
 
3091
+ def set_models(self, chosen):
3092
+ """The models every Results tab compares (none: all of them)."""
3093
+ first = chosen and not self.app.pq_models
3094
+ self.app.pq_models = chosen # kept for this session, so reopening Results keeps it
3095
+ if first: # a fair comparison needs the same questions: turn it on (you can untick it)
3096
+ with self.query_one("#pq-shared", Checkbox).prevent(Checkbox.Changed):
3097
+ self.query_one("#pq-shared", Checkbox).value = True
3098
+ # after a picker has closed: a table rebuilt under a modal keeps its header-only widths
3099
+ self.call_after_refresh(self.fill_per_question)
3100
+ self.call_after_refresh(self.fill_pta)
3101
+ self.call_after_refresh(self.fill_failures)
3102
+ self.insights_stale = True
3103
+ if self.query_one(TabbedContent).active == "tab-insights":
3104
+ self.call_after_refresh(self.fill_insights)
3105
+
3106
+ def action_pta_remove(self, row):
3107
+ """A click on a model's name in the PTA bars: leave it out of every tab (click it again below to bring it back)."""
3108
+ shown = [x["model"] for x in getattr(self, "pta_result", {}).get("models", [])]
3109
+ if not 0 <= row < len(shown):
3110
+ return
3111
+ if len(shown) == 1:
3112
+ self.notify("That's the last model shown.", severity="warning")
3113
+ return
3114
+ model = shown[row]
3115
+ self.app.pta_removed = [m for m in getattr(self.app, "pta_removed", []) if m != model] + [model]
3116
+ self.set_models((self.app.pq_models or set(shown)) - {model})
3117
+
3118
+ def action_pta_restore(self, row):
3119
+ removed = self.pta_removed_now()
3120
+ if 0 <= row < len(removed):
3121
+ self.app.pta_removed.remove(removed[row])
3122
+ self.set_models(self.app.pq_models | {removed[row]})
3123
+
3124
+ def action_pta_restore_all(self):
3125
+ self.app.pta_removed = []
3126
+ self.set_models(set())
3127
+
3128
+ def pta_removed_now(self):
3129
+ """Models removed by a click that are still left out (the Models button may have put them back)."""
3130
+ return [m for m in getattr(self.app, "pta_removed", []) if self.app.pq_models and m not in self.app.pq_models]
3131
+
3024
3132
  # -- insights: charts with what to do next (insights.py); built when the tab is first shown
3025
3133
  @on(TabbedContent.TabActivated)
3026
3134
  def tab_activated(self, event):
@@ -3100,7 +3208,16 @@ class ResultsScreen(Screen):
3100
3208
  # none picked: every model with enough answers (pta.index leaves out the rest, and says so)
3101
3209
  result = pta.index(rows, sorted(chosen) or None, pta.memory_gb(self.infos))
3102
3210
  self.query_one("#pta-note", Static).update(pta.scope_note(result, sorted(present)))
3103
- self.query_one("#pta-bars", Static).update("\n".join(pta.bars(result)) if result["questions"] else "")
3211
+ self.pta_result = result
3212
+ removed = [m for m in self.pta_removed_now() if m in present]
3213
+ self.query_one("#pta-bars", Static).update(
3214
+ "\n".join(pta.bars(result, click="screen.pta_remove")
3215
+ + ([f"\n[dim]Click a name to remove it. Removed:[/dim] "
3216
+ + " ".join(f"[@click=screen.pta_restore({self.pta_removed_now().index(m)})]{rich_escape(m)}[/]"
3217
+ for m in removed)
3218
+ + " [dim]·[/dim] [@click=screen.pta_restore_all]show all[/]"] if removed
3219
+ else ["\n[dim]Click a name to remove it from view.[/dim]"]))
3220
+ if result["questions"] else "")
3104
3221
  self.query_one("#pta-left-out", Static).update(
3105
3222
  "\n".join(f"[dim]{rich_escape(line)}[/dim]" for line in pta.left_out_lines(result)))
3106
3223
  header, table = pta.table(result)
@@ -1130,6 +1130,111 @@ class AddToRun(unittest.TestCase):
1130
1130
  self.assertEqual(fewer, 4) # its 4 questions are no longer counted
1131
1131
  self.assertEqual(out["running"], [False, True])
1132
1132
 
1133
+ def test_tui_copies_reasoning_and_answer(self):
1134
+ path = os.path.join(self.ws, "models.toml")
1135
+ write(path, read(path).replace('"--delay", "0.2"', '"--delay", "1.5"')) # reasoning first, then a wait
1136
+ fake = os.path.join(self.tmp.name, "bin")
1137
+ os.makedirs(fake)
1138
+ write(os.path.join(fake, "pbcopy"), '#!/bin/sh\ncat > "$CLIP_OUT"\n')
1139
+ os.chmod(os.path.join(fake, "pbcopy"), 0o755)
1140
+ clip = os.path.join(self.tmp.name, "clip.txt")
1141
+ code = textwrap.dedent("""
1142
+ import asyncio, json, os, time
1143
+ from tuieval.tui import AnswerScreen, EvalsApp, RunScreen
1144
+ from textual.widgets import SelectionList
1145
+ def pasted():
1146
+ with open(os.environ["CLIP_OUT"]) as f:
1147
+ text = f.read()
1148
+ os.remove(os.environ["CLIP_OUT"])
1149
+ return text
1150
+ async def go():
1151
+ app = EvalsApp({})
1152
+ async with app.run_test(size=(160, 50)) as pilot:
1153
+ await pilot.pause()
1154
+ s = app.screen
1155
+ s.query_one("#suites", SelectionList).select("apps")
1156
+ s.selected_models = {"a"}
1157
+ s.refresh_models()
1158
+ s.query_one("#tier-certify").value = True
1159
+ await pilot.pause()
1160
+ s.action_start()
1161
+ deadline = time.time() + 180 # CI machines can take long to start servers
1162
+ while time.time() < deadline:
1163
+ await pilot.pause(0.1)
1164
+ if isinstance(app.screen, RunScreen) and app.screen.req_started:
1165
+ break
1166
+ run, out = app.screen, {}
1167
+ await pilot.pause(0.5) # the reasoning has streamed; the answer waits 1.5 s
1168
+ await pilot.press("y")
1169
+ await pilot.pause()
1170
+ out["live"] = pasted()
1171
+ deadline = time.time() + 120
1172
+ while run.running and time.time() < deadline:
1173
+ await pilot.pause(0.2)
1174
+ app.push_screen(AnswerScreen(list(run.recent_entries.values())))
1175
+ await pilot.pause()
1176
+ for key in "ya":
1177
+ await pilot.press(key)
1178
+ await pilot.pause()
1179
+ out[key] = pasted()
1180
+ print(json.dumps(out))
1181
+ asyncio.run(go())
1182
+ """)
1183
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
1184
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS="", CLIP_OUT=clip,
1185
+ PATH=fake + os.pathsep + os.environ["PATH"]))
1186
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
1187
+ self.assertEqual(out, {"live": "Thinking it over. ", "y": "Thinking it over. ", "a": "ANSWER: 1"}, out)
1188
+
1189
+ def test_copy_falls_back_to_the_terminal(self):
1190
+ from unittest import mock
1191
+ from tuieval.tui import copy_text
1192
+ app = mock.Mock()
1193
+ with mock.patch.dict(os.environ, {"PATH": self.tmp.name}): # no clipboard tool here
1194
+ self.assertFalse(copy_text(app, "some reasoning"))
1195
+ app.copy_to_clipboard.assert_called_once_with("some reasoning")
1196
+
1197
+ def test_tui_counts_ticked_models_and_names_filtered_ones_before_start(self):
1198
+ code = textwrap.dedent("""
1199
+ import asyncio, json
1200
+ from tuieval.tui import EvalsApp, ConfirmScreen
1201
+ from textual.widgets import SelectionList, Input, Label
1202
+ async def go():
1203
+ app = EvalsApp({})
1204
+ async with app.run_test(size=(160, 50)) as pilot:
1205
+ await pilot.pause()
1206
+ s, out = app.screen, {}
1207
+ legend = lambda: str(s.query_one("#models-legend", Label).render())
1208
+ out["none"] = legend()
1209
+ s.query_one("#suites", SelectionList).select("apps")
1210
+ models = s.query_one("#models", SelectionList)
1211
+ models.select("a")
1212
+ models.select("b")
1213
+ await pilot.pause()
1214
+ out["two"] = legend()
1215
+ s.query_one("#filter", Input).value = "zzz-nothing"
1216
+ await pilot.pause()
1217
+ out["filtered"] = legend()
1218
+ s.action_start()
1219
+ await pilot.pause()
1220
+ out["asked"] = isinstance(app.screen, ConfirmScreen) and str(app.screen.question)
1221
+ await pilot.press("n")
1222
+ await pilot.pause()
1223
+ out["cancelled"] = app.screen is s and not app.is_busy()
1224
+ print(json.dumps(out))
1225
+ asyncio.run(go())
1226
+ """)
1227
+ p = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600,
1228
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""))
1229
+ out = json.loads(p.stdout.strip().splitlines()[-1]) if p.stdout.strip() else p.stderr
1230
+ self.assertIsInstance(out, dict, out)
1231
+ self.assertIn("none selected", out["none"])
1232
+ self.assertIn("2 selected", out["two"])
1233
+ self.assertNotIn("not shown", out["two"])
1234
+ self.assertIn("2 not shown: a, b", out["filtered"])
1235
+ self.assertIn("All selected: a, b", out["asked"])
1236
+ self.assertTrue(out["cancelled"])
1237
+
1133
1238
  def test_tui_asks_before_skipping_a_model(self):
1134
1239
  path = os.path.join(self.ws, "models.toml")
1135
1240
  write(path, read(path).replace('"--delay", "0.2"', '"--delay", "3"')) # still running while it's asked
@@ -1568,6 +1673,23 @@ class PTAIndex(unittest.TestCase):
1568
1673
  app.screen.fill_pta() # rebuilt: still sorted
1569
1674
  await pilot.pause(0.2)
1570
1675
  sorts["kept"] = [str(pta_t.get_row_at(r)[0]) for r in range(pta_t.row_count)]
1676
+ bars_w = app.screen.query_one("#pta-bars", Static)
1677
+ shown = [x["model"] for x in app.screen.pta_result["models"]]
1678
+ await pilot.click(bars_w, offset=(1, 1 + 2 + shown.index("hosted"))) # a click on its name
1679
+ await pilot.pause(0.3)
1680
+ clicks = {"removed": [sorted(app.pq_models), [x["model"] for x in app.screen.pta_result["models"]],
1681
+ "Removed: hosted" in str(bars_w.render())]}
1682
+ await pilot.click(bars_w, offset=(1, 1 + 2)) # the last one stays
1683
+ await pilot.pause(0.3)
1684
+ clicks["last"] = sorted(app.pq_models)
1685
+ app.screen.action_pta_restore(0)
1686
+ await pilot.pause(0.3)
1687
+ clicks["restored"] = [sorted(app.pq_models), len(app.screen.pta_result["models"])]
1688
+ app.screen.action_pta_remove(shown.index("hosted"))
1689
+ app.screen.action_pta_restore_all()
1690
+ await pilot.pause(0.3)
1691
+ clicks["all"] = [sorted(app.pq_models), "Removed" in str(bars_w.render())]
1692
+ sorts["clicks"] = clicks
1571
1693
  app.screen.query_one(TabbedContent).active = "tab-failures"
1572
1694
  await pilot.pause(0.3)
1573
1695
  f = app.screen.query_one("#failures", DataTable)
@@ -1606,6 +1728,11 @@ class PTAIndex(unittest.TestCase):
1606
1728
  s = got["sorts"] # a click on a column name sorts by it, a second click reverses
1607
1729
  self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
1608
1730
  self.assertIn("answers right ▼", s["labels"])
1731
+ c = s["clicks"] # a click on a name in the bars leaves it out of every tab; the last one stays
1732
+ self.assertEqual(c["removed"], [["good"], ["good"], True])
1733
+ self.assertEqual(c["last"], ["good"])
1734
+ self.assertEqual(c["restored"], [["good", "hosted"], 2])
1735
+ self.assertEqual(c["all"], [[], False])
1609
1736
  self.assertEqual((s["tests"][0], s["opened"]), ("apps: d", "d"))
1610
1737
  fails = got["fails"]
1611
1738
  self.assertEqual(fails["all"], [4, "4 failures"])
File without changes