tuieval 0.2.0.dev96__tar.gz → 0.2.0.dev98__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.dev96 → tuieval-0.2.0.dev98}/CHANGELOG.md +1 -0
  2. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/PKG-INFO +1 -1
  3. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/_version.py +2 -2
  4. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/tui.py +57 -8
  5. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/tests/test_tuieval.py +45 -0
  6. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/.gitignore +0 -0
  7. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/LICENSE +0 -0
  8. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/README.md +0 -0
  9. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/RELEASING.md +0 -0
  10. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/README.md +0 -0
  11. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/favicon.ico +0 -0
  12. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/readme-header.png +0 -0
  13. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/social-preview.png +0 -0
  14. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-1024.png +0 -0
  15. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-128.png +0 -0
  16. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-16.png +0 -0
  17. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-16.svg +0 -0
  18. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-256.png +0 -0
  19. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-32.png +0 -0
  20. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-48.png +0 -0
  21. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-512.png +0 -0
  22. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-64.png +0 -0
  23. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon-animated.svg +0 -0
  24. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/brand/tuieval-icon.svg +0 -0
  25. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/tui-answer-reasoning.png +0 -0
  26. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/tui-run-reasoning.png +0 -0
  27. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/images/tui-setup.png +0 -0
  28. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/models.md +0 -0
  29. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/docs/writing-packs.md +0 -0
  30. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/pyproject.toml +0 -0
  31. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/__init__.py +0 -0
  32. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/__main__.py +0 -0
  33. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/builtin.toml +0 -0
  34. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/cli.py +0 -0
  35. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/client.py +0 -0
  36. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/compare.py +0 -0
  37. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/doctor.py +0 -0
  38. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/engine.py +0 -0
  39. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/export.py +0 -0
  40. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/graders/__init__.py +0 -0
  41. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/graders/answer.py +0 -0
  42. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/graders/code.py +0 -0
  43. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/graders/rag.py +0 -0
  44. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/graders/reply.py +0 -0
  45. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/graders/tool_call.py +0 -0
  46. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/insights.py +0 -0
  47. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/logs.py +0 -0
  48. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/machines.py +0 -0
  49. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/onboard.py +0 -0
  50. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/packs.py +0 -0
  51. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/profiles.py +0 -0
  52. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/pta.py +0 -0
  53. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/remove.py +0 -0
  54. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/run_evals.py +0 -0
  55. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/scaffold.py +0 -0
  56. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/selftest.py +0 -0
  57. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/models.toml +0 -0
  58. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/answer/pack.toml +0 -0
  59. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/answer/system.txt +0 -0
  60. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/answer/tests.yaml +0 -0
  61. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/code/pack.toml +0 -0
  62. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/code/system.txt +0 -0
  63. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/code/tests.yaml +0 -0
  64. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/rag/pack.toml +0 -0
  65. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/rag/system.txt +0 -0
  66. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/rag/tests.yaml +0 -0
  67. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/reply/pack.toml +0 -0
  68. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/reply/system.txt +0 -0
  69. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/reply/tests.yaml +0 -0
  70. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/tool_call/pack.toml +0 -0
  71. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/tool_call/system.txt +0 -0
  72. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/tool_call/tests.yaml +0 -0
  73. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/templates/packs/tool_call/tools.yaml +0 -0
  74. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/tune.py +0 -0
  75. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/verdict.py +0 -0
  76. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/watch_proxy.py +0 -0
  77. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/workspace.py +0 -0
  78. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/src/tuieval/yamlout.py +0 -0
  79. {tuieval-0.2.0.dev96 → tuieval-0.2.0.dev98}/tests/mock_server.py +0 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Results, Per question: the model summary shows every picked model without scrolling, and fits the window: shorter column names, and long model names cut in the middle (both ends kept). The PTA index table fits the same way.
5
6
  - PTA index: the accuracy bar shows answers cut off at max_tokens in orange, with `✂ n%` beside it and a **cut off** column in the table, so overthinking stands apart from wrong answers.
6
7
  - Results → Insights (also `tuieval insights`): charts with what to do next: every model against a reference (95% CI), the best model per use case, fine-tunes against their base (`base` in models.toml), why models fail, accuracy against tokens or time, question health, and the Certify runs that would settle the strongest models' verdicts. `[defaults] reference` sets the default reference.
7
8
  - A server that stops sending tokens mid-answer for 5 minutes (`stall_timeout_ms`) is a server error: retried, never counted against the model. Keep-alive pings no longer stretch a request past `request_timeout_ms`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tuieval
3
- Version: 0.2.0.dev96
3
+ Version: 0.2.0.dev98
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
@@ -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.dev96'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'dev96')
21
+ __version__ = version = '0.2.0.dev98'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'dev98')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -355,6 +355,38 @@ def rich_escape(text):
355
355
  return text.replace("[", r"\[")
356
356
 
357
357
 
358
+ def mid_trim(text, width, head=None):
359
+ """text cut to width cells with … in the middle, so both ends of a model name show (its family and
360
+ its server suffix, e.g. -llama / -vllm). head: how many leading characters to keep (default: half)."""
361
+ if cell_len(text) <= width or width < 5:
362
+ return text
363
+ head = width - 1 - width // 2 if head is None else min(head, width - 2)
364
+ return text[:head] + "…" + text[head - width + 1:]
365
+
366
+
367
+ def trim_names(names, width):
368
+ """names cut to width (mid_trim), still telling them apart: when cutting the middle would make two
369
+ alike, the cut moves into the start they all share; if that's not enough either, names stay whole."""
370
+ trimmed = [mid_trim(n, width) for n in names]
371
+ if len(set(trimmed)) == len(set(names)):
372
+ return trimmed
373
+ shared = len(os.path.commonprefix(list(names)))
374
+ head = width - 1 - max(len(n) - shared for n in names) # keep every name's end from where they differ
375
+ if head < 1:
376
+ return list(names)
377
+ trimmed = [mid_trim(n, width, head=head) for n in names]
378
+ return trimmed if len(set(trimmed)) == len(set(names)) else list(names)
379
+
380
+
381
+ def fit_first_column(header, rows, room, floor=24):
382
+ """rows with the first cell trimmed in the middle (mid_trim) so a table of these columns fits room
383
+ cells across, but never below floor cells. Cells are Rich markup; each column adds 2 cells of padding."""
384
+ rest = sum(2 + max(cell_len(h), *(Text.from_markup(r[i]).cell_len for r in rows))
385
+ for i, h in enumerate(header) if i)
386
+ names = trim_names([r[0] for r in rows], max(floor, room - rest - 2))
387
+ return [[n] + r[1:] for n, r in zip(names, rows)]
388
+
389
+
358
390
  class BlockDumper(yaml.SafeDumper):
359
391
  """Multi-line strings (reference code, expected text) as readable | blocks, not escaped \\n."""
360
392
 
@@ -2981,6 +3013,9 @@ class ResultsScreen(Screen):
2981
3013
  self.query_one("#pta-left-out", Static).update(
2982
3014
  "\n".join(f"[dim]{rich_escape(line)}[/dim]" for line in pta.left_out_lines(result)))
2983
3015
  header, table = pta.table(result)
3016
+ self.pta_shown = bool(table) # to refit on resize
3017
+ if table:
3018
+ table = fit_first_column(header, table, (self.size.width or self.app.size.width) - 3)
2984
3019
  for i, h in enumerate(header): # sized to the contents: added before the rows, a column keeps its header's width
2985
3020
  t.add_column(h, width=max(len(h), *(len(r[i]) for r in table)) if table else None)
2986
3021
  t.add_rows(table)
@@ -3065,6 +3100,14 @@ class ResultsScreen(Screen):
3065
3100
  f"{os.path.basename(os.path.dirname(p))} · {os.path.basename(p)[:-5]}" for p in sorted(self.superseded))
3066
3101
  + ". Only the new run's answers (◐) are shown for these.[/yellow]" if self.superseded else ""))
3067
3102
 
3103
+ def on_resize(self, event):
3104
+ """Refit the tables whose model names are trimmed to the window's width."""
3105
+ if getattr(self, "pq_summary_args", None):
3106
+ self.fill_pq_summary(*self.pq_summary_args)
3107
+ self.sort_table(self.query_one("#pq-summary", DataTable))
3108
+ if getattr(self, "pta_shown", False):
3109
+ self.fill_pta()
3110
+
3068
3111
  def fill_pq_summary(self, tests, models):
3069
3112
  """One row per shown model over the questions they ALL answered, so totals compare fairly;
3070
3113
  with two models, a head-to-head line."""
@@ -3081,9 +3124,11 @@ class ResultsScreen(Screen):
3081
3124
  title.update("[yellow]These models have no question in common (with the filters above).[/yellow]")
3082
3125
  table.display = False
3083
3126
  return
3127
+ self.pq_summary_args = (tests, models) # to refit on resize
3084
3128
  rows = compare.compare_models(shared, models)
3085
- table.add_columns("Model", "Total time", "Passed", "Median tokens", "Total tokens", "Tokens per ✓", "Median s",
3086
- "Avg prompt tok/s", "Avg gen tok/s", "Fewest tokens", "Fastest", "Cut off")
3129
+ # short headings so every column fits; the line above the table says what each one is
3130
+ header = ("Model", "Time", "Passed", "Med tok", "Tokens", "Tok/✓", "Med s", "Prompt t/s", "Gen t/s",
3131
+ "Fewest", "Fastest", "Cut off")
3087
3132
  best = {k: max((r[k] for r in rows if r[k] is not None), default=None)
3088
3133
  for k in ("passed", "fewest", "fastest", "avg_pp_tps", "avg_gen_tps")}
3089
3134
  low = {k: min(r[k] for r in rows if r[k] is not None) if any(r[k] is not None for r in rows) else None
@@ -3092,9 +3137,10 @@ class ResultsScreen(Screen):
3092
3137
  def hi(v, text, key, good):
3093
3138
  ref = best.get(key) if good == "max" else low.get(key)
3094
3139
  return f"[bold green]{text}[/bold green]" if v is not None and v == ref and len(rows) > 1 else text
3140
+ cells = []
3095
3141
  for r in rows:
3096
3142
  tt = r["total_secs"]
3097
- table.add_row(r["model"],
3143
+ cells.append([r["model"],
3098
3144
  hi(tt, ("-" if not tt else f"{tt:.1f}s" if tt < 60 else fmt_secs(tt)), "total_secs", "min"),
3099
3145
  hi(r["passed"], f"{r['passed']}/{r['n']}", "passed", "max"),
3100
3146
  hi(r["med_tokens"], f"{r['med_tokens']:,.0f}" if r["med_tokens"] else "-", "med_tokens", "min"),
@@ -3107,7 +3153,9 @@ class ResultsScreen(Screen):
3107
3153
  "max"),
3108
3154
  hi(r["fewest"], str(r["fewest"]), "fewest", "max"),
3109
3155
  hi(r["fastest"], str(r["fastest"]), "fastest", "max"),
3110
- f"[red]{r['cut']}[/red]" if r["cut"] else "0")
3156
+ f"[red]{r['cut']}[/red]" if r["cut"] else "0"])
3157
+ table.add_columns(*header)
3158
+ table.add_rows(fit_first_column(header, cells, (self.size.width or self.app.size.width) - 3))
3111
3159
  line = f"[b]Comparison[/b] over the {len(shared)} questions all {len(models)} answered"
3112
3160
  if len(models) == 2:
3113
3161
  h = compare.head_to_head(shared, *models)
@@ -3121,9 +3169,10 @@ class ResultsScreen(Screen):
3121
3169
  if shared_speed:
3122
3170
  line += (f"\n[yellow]Some answers of {', '.join(shared_speed)} ran alongside other models, which "
3123
3171
  "shared the machine's speed: compare their times with care.[/yellow]")
3124
- title.update(line + " [dim]Fewest tokens / Fastest = questions won; ties count for both. Total tokens / "
3125
- "Total time add up each question's median. Avg tok/s = all tokens over all the time they took "
3126
- "(cached prompts left out).[/dim]")
3172
+ title.update(line + " [dim]Time / Tokens: each question's median, added up. Med tok / Med s: median "
3173
+ "per answer. Tok/✓: tokens per answer right. Prompt t/s / Gen t/s: all tokens over all the time "
3174
+ "they took (cached prompts left out). Fewest / Fastest: questions won; ties count for both. "
3175
+ "Cut off: answers that hit max_tokens.[/dim]")
3127
3176
 
3128
3177
  @staticmethod
3129
3178
  def pq_cell(c, best, here):
@@ -3277,7 +3326,7 @@ class EvalsApp(App):
3277
3326
  #pq-controls Select { width: 22; margin-right: 1; }
3278
3327
  #pq-models { margin-right: 1; min-width: 20; }
3279
3328
  #pq-compare-title { padding: 0 1; height: auto; }
3280
- #pq-summary { height: auto; max-height: 10; }
3329
+ #pq-summary { height: auto; } /* every picked model, no scrolling: the tab scrolls instead */
3281
3330
  #pq-legend { padding: 0 1; height: auto; }
3282
3331
  /* Tabs must read as tabs, not as a line of text: each tab is a button-like chip on its own band,
3283
3332
  the active one filled with the accent colour. (Textual's default: dimmed words on the background.) */
@@ -1660,6 +1660,51 @@ class FirstRun(unittest.TestCase):
1660
1660
  env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60).stdout.strip()
1661
1661
  self.assertEqual(out, "['first'] ['my-model'] smoke False") # tuning shows once something has run
1662
1662
 
1663
+ def test_trimmed_names_keep_both_ends(self):
1664
+ from tuieval.tui import fit_first_column, mid_trim, trim_names
1665
+ name = "some-author--some-long-model-name-q4_k_m-vllm"
1666
+ self.assertEqual(mid_trim(name, 20), "some-auth…4_k_m-vllm")
1667
+ self.assertEqual(mid_trim("short", 20), "short")
1668
+ rows = [[name, "[green]1/2[/green]", "10"], ["b", "2/2", "1,000"]]
1669
+ fitted = fit_first_column(["Model", "Passed", "Tokens"], rows, 45) # 2 + 8 + 8 cells for the rest
1670
+ self.assertEqual([len(r[0]) for r in fitted], [27, 1])
1671
+ self.assertEqual(fitted[0][1:], rows[0][1:])
1672
+ self.assertEqual(len(fit_first_column(["Model", "Passed"], rows, 10)[0][0]), 24) # never below the floor
1673
+ twins = [f"author--model-number-{i}-and-a-long-tail" for i in range(3)] # alike but in the middle
1674
+ self.assertEqual(trim_names(twins, 20), ["au…0-and-a-long-tail", "au…1-and-a-long-tail",
1675
+ "au…2-and-a-long-tail"])
1676
+ self.assertEqual(trim_names(twins, 10), twins) # can't keep them apart that short: whole
1677
+
1678
+ def test_per_question_summary_fits_every_model(self):
1679
+ tuieval(self.ws, "add", "my-model", "--server", "local")
1680
+ code = textwrap.dedent("""
1681
+ import asyncio
1682
+ from tuieval import compare
1683
+ from tuieval.tui import EvalsApp, ResultsScreen
1684
+ from textual.widgets import DataTable, TabbedContent
1685
+ models = [f"some-author--a-rather-long-model-name-number-{i:02d}-q4_k_m-vllm" for i in range(12)]
1686
+ rows = [{"model": m, "suite": "p", "test": f"p: q{q}", "ok": q % 2 == 0, "latency": 1000, "tokens": 10,
1687
+ "truncated": False, "gen_tps": 20.0, "prompt_tps": None, "repeat": 0} for m in models for q in range(3)]
1688
+ async def go():
1689
+ app = EvalsApp({})
1690
+ async with app.run_test(size=(120, 40)) as pilot:
1691
+ await pilot.pause()
1692
+ app.push_screen(ResultsScreen())
1693
+ await pilot.pause(0.5)
1694
+ app.screen.query_one(TabbedContent).active = "tab-per-question"
1695
+ await pilot.pause(0.3)
1696
+ app.screen.fill_pq_summary(compare.per_question(rows), models)
1697
+ await pilot.pause(0.3)
1698
+ t = app.screen.query_one("#pq-summary", DataTable)
1699
+ names = [str(t.get_row_at(i)[0]) for i in range(t.row_count)]
1700
+ print(t.row_count, t.region.height, t.virtual_size.width <= t.region.width,
1701
+ all(n.endswith("-q4_k_m-vllm") and "…" in n for n in names), len(set(names)))
1702
+ asyncio.run(go())
1703
+ """)
1704
+ out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True,
1705
+ env=dict(os.environ, TUIEVAL_HOME=self.ws, TUIEVAL_DETECT_PORTS=""), timeout=60)
1706
+ self.assertEqual(out.stdout.strip(), "12 13 True True 12", out.stderr[-2000:]) # every row, no scroll
1707
+
1663
1708
  def test_results_open_on_pta_index(self):
1664
1709
  tuieval(self.ws, "add", "my-model", "--server", "local")
1665
1710
  code = textwrap.dedent("""
File without changes
File without changes
File without changes