tuieval 0.2.0.dev108__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.dev108 → tuieval-0.2.0.dev112}/CHANGELOG.md +2 -0
  2. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/PKG-INFO +2 -2
  3. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/README.md +1 -1
  4. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/_version.py +2 -2
  5. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/pta.py +9 -4
  6. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/tui.py +106 -16
  7. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/tests/test_tuieval.py +86 -0
  8. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/.gitignore +0 -0
  9. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/LICENSE +0 -0
  10. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/RELEASING.md +0 -0
  11. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/README.md +0 -0
  12. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/favicon.ico +0 -0
  13. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/readme-header.png +0 -0
  14. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/social-preview.png +0 -0
  15. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-1024.png +0 -0
  16. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-128.png +0 -0
  17. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-16.png +0 -0
  18. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-16.svg +0 -0
  19. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-256.png +0 -0
  20. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-32.png +0 -0
  21. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-48.png +0 -0
  22. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-512.png +0 -0
  23. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-64.png +0 -0
  24. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  25. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/brand/tuieval-icon.svg +0 -0
  26. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/tui-answer-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/tui-run-reasoning.png +0 -0
  28. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/images/tui-setup.png +0 -0
  29. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/models.md +0 -0
  30. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/docs/writing-packs.md +0 -0
  31. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/pyproject.toml +0 -0
  32. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/__init__.py +0 -0
  33. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/__main__.py +0 -0
  34. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/builtin.toml +0 -0
  35. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/cli.py +0 -0
  36. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/client.py +0 -0
  37. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/compare.py +0 -0
  38. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/doctor.py +0 -0
  39. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/engine.py +0 -0
  40. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/export.py +0 -0
  41. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/__init__.py +0 -0
  42. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/answer.py +0 -0
  43. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/code.py +0 -0
  44. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/rag.py +0 -0
  45. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/reply.py +0 -0
  46. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/graders/tool_call.py +0 -0
  47. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/insights.py +0 -0
  48. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/logs.py +0 -0
  49. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/machines.py +0 -0
  50. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/onboard.py +0 -0
  51. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/packs.py +0 -0
  52. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/profiles.py +0 -0
  53. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/run_evals.py +0 -0
  55. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/src/tuieval/yamlout.py +0 -0
  79. {tuieval-0.2.0.dev108 → tuieval-0.2.0.dev112}/tests/mock_server.py +0 -0
@@ -2,6 +2,8 @@
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.
5
7
  - The Models heading shows how many are selected, naming any the filter hides; Start asks first when some are hidden.
6
8
  - Results → Failures: a Models button filters the list (the same pick as the other tabs), with the number of failures shown.
7
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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev108
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.dev108'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev108')
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:
@@ -2163,6 +2203,7 @@ class RunScreen(Screen):
2163
2203
  Binding("c", "cancel_all", "Cancel all", priority=True),
2164
2204
  Binding("f", "toggle_follow", "Pause scroll", priority=True),
2165
2205
  Binding("v", "watch_next", "Next model", priority=True),
2206
+ Binding("y", "copy_reasoning", "Copy reasoning", priority=True),
2166
2207
  Binding("n", "new_run", "More evals", priority=True),
2167
2208
  Binding("r", "results", "Results", priority=True),
2168
2209
  Binding("w", "runs", "Runs", priority=True),
@@ -2182,6 +2223,8 @@ class RunScreen(Screen):
2182
2223
  self.follow = True
2183
2224
  self.buf = {"reasoning": [], "answer": []}
2184
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)
2185
2228
  self.req_live = {} # model label -> its request now: phase, ~tokens, started, last token
2186
2229
  self.started = time.time()
2187
2230
  self.req_started = None
@@ -2344,6 +2387,7 @@ class RunScreen(Screen):
2344
2387
  v.clear()
2345
2388
  for stream, text in parts.items():
2346
2389
  if text:
2390
+ self.streamed[stream].append(text)
2347
2391
  self.query_one(f"#{stream}", StreamView).append(text, self.follow)
2348
2392
  self.req_tokens += max(1, len(text) // 4)
2349
2393
  if self.req_first is None and self.req_started:
@@ -2364,6 +2408,7 @@ class RunScreen(Screen):
2364
2408
  self.flush_streams()
2365
2409
  self.query_one("#reasoning", StreamView).clear()
2366
2410
  self.query_one("#answer", StreamView).clear()
2411
+ self.streamed, self.streamed_whole = {"reasoning": [], "answer": []}, True
2367
2412
  self.req_started, self.req_tokens, self.req_first = time.time(), 0, None
2368
2413
  job = d["job"]
2369
2414
  rep_note = f" · repeat {d['repeat'] + 1}/{job.repeat}" if job.repeat > 1 else ""
@@ -2731,6 +2776,7 @@ class RunScreen(Screen):
2731
2776
  v.clear()
2732
2777
  self.query_one("#reasoning", StreamView).clear()
2733
2778
  self.query_one("#answer", StreamView).clear()
2779
+ self.streamed, self.streamed_whole = {"reasoning": [], "answer": []}, False
2734
2780
  self.req_started = None
2735
2781
  self.current = f"Streaming [b]{self.watch}[/b] from its next answer…"
2736
2782
  self.refresh_current()
@@ -2748,6 +2794,12 @@ class RunScreen(Screen):
2748
2794
  self.follow = not self.follow
2749
2795
  self.update_overall()
2750
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
+
2751
2803
  @on(DataTable.RowSelected, "#recent")
2752
2804
  def recent_selected(self, event):
2753
2805
  keys = [k for k in event.data_table.rows if k in self.recent_entries]
@@ -3032,22 +3084,51 @@ class ResultsScreen(Screen):
3032
3084
  models = sorted(counts, key=lambda m: (-counts[m], m))
3033
3085
 
3034
3086
  def picked(chosen):
3035
- if chosen is None:
3036
- return
3037
- first = chosen and not self.app.pq_models
3038
- self.app.pq_models = chosen # kept for this session, so reopening Results keeps it
3039
- if first: # a fair comparison needs the same questions: turn it on (you can untick it)
3040
- with self.query_one("#pq-shared", Checkbox).prevent(Checkbox.Changed):
3041
- self.query_one("#pq-shared", Checkbox).value = True
3042
- # after the picker has closed: a table rebuilt under a modal keeps its header-only widths
3043
- self.call_after_refresh(self.fill_per_question)
3044
- self.call_after_refresh(self.fill_pta)
3045
- self.call_after_refresh(self.fill_failures)
3046
- self.insights_stale = True
3047
- if self.query_one(TabbedContent).active == "tab-insights":
3048
- self.call_after_refresh(self.fill_insights)
3087
+ if chosen is not None:
3088
+ self.set_models(chosen)
3049
3089
  self.app.push_screen(ModelPickScreen(models, counts, self.app.pq_models), picked)
3050
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
+
3051
3132
  # -- insights: charts with what to do next (insights.py); built when the tab is first shown
3052
3133
  @on(TabbedContent.TabActivated)
3053
3134
  def tab_activated(self, event):
@@ -3127,7 +3208,16 @@ class ResultsScreen(Screen):
3127
3208
  # none picked: every model with enough answers (pta.index leaves out the rest, and says so)
3128
3209
  result = pta.index(rows, sorted(chosen) or None, pta.memory_gb(self.infos))
3129
3210
  self.query_one("#pta-note", Static).update(pta.scope_note(result, sorted(present)))
3130
- 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 "")
3131
3221
  self.query_one("#pta-left-out", Static).update(
3132
3222
  "\n".join(f"[dim]{rich_escape(line)}[/dim]" for line in pta.left_out_lines(result)))
3133
3223
  header, table = pta.table(result)
@@ -1130,6 +1130,70 @@ 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
+
1133
1197
  def test_tui_counts_ticked_models_and_names_filtered_ones_before_start(self):
1134
1198
  code = textwrap.dedent("""
1135
1199
  import asyncio, json
@@ -1609,6 +1673,23 @@ class PTAIndex(unittest.TestCase):
1609
1673
  app.screen.fill_pta() # rebuilt: still sorted
1610
1674
  await pilot.pause(0.2)
1611
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
1612
1693
  app.screen.query_one(TabbedContent).active = "tab-failures"
1613
1694
  await pilot.pause(0.3)
1614
1695
  f = app.screen.query_one("#failures", DataTable)
@@ -1647,6 +1728,11 @@ class PTAIndex(unittest.TestCase):
1647
1728
  s = got["sorts"] # a click on a column name sorts by it, a second click reverses
1648
1729
  self.assertEqual((s["up"], s["down"], s["kept"]), (["hosted", "good"], ["good", "hosted"], ["good", "hosted"]))
1649
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])
1650
1736
  self.assertEqual((s["tests"][0], s["opened"]), ("apps: d", "d"))
1651
1737
  fails = got["fails"]
1652
1738
  self.assertEqual(fails["all"], [4, "4 failures"])
File without changes