Skip to content

Commit 2c4ee20

Browse files
committed
feat(cli): cohesive terminal UX — shared tables, empty states, error hygiene
1 parent 6de509e commit 2c4ee20

8 files changed

Lines changed: 828 additions & 64 deletions

File tree

effgen/cli/_main.py

Lines changed: 316 additions & 58 deletions
Large diffs are not rendered by default.

effgen/cli/chat.py

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import json
1919
import logging
2020
import os
21+
import sys
2122
import time
2223
from datetime import datetime
2324
from pathlib import Path
@@ -64,6 +65,13 @@ def __init__(self, cli: Any, args: Any):
6465
self.quiet = getattr(args, "quiet", False)
6566
self.verbose = getattr(args, "verbose", False)
6667
self.animate = cli._animate(args)
68+
# A piped session (``echo hi | effgen chat``) has no terminal to show a
69+
# transient "Thinking…" indicator on, so the placeholder is suppressed
70+
# there and only the labeled answer and footer are emitted.
71+
try:
72+
self.interactive = sys.stdin.isatty() and sys.stdout.isatty()
73+
except (ValueError, OSError): # pragma: no cover - closed std streams
74+
self.interactive = False
6775

6876
self.model_id = getattr(args, "model", None) or self.DEFAULT_MODEL
6977
self.preset = getattr(args, "preset", None)
@@ -105,6 +113,30 @@ def __init__(self, cli: Any, args: Any):
105113
self._preset_system_prompt = _preset_cfg.system_prompt
106114
self._preset_temperature = _preset_cfg.temperature
107115

116+
# Tools named with --tools/-t start attached (in addition to any the
117+
# preset brought), matching `effgen run --tools`. An unknown name is
118+
# reported with a close-match hint rather than silently ignored.
119+
requested_tools = getattr(args, "tools", None) or []
120+
if requested_tools:
121+
try:
122+
registry = self.cli.tool_registry
123+
registry.discover_builtin_tools()
124+
available = set(registry.list_tools())
125+
except Exception: # noqa: BLE001 - never let tool discovery sink startup
126+
available = set()
127+
for name in requested_tools:
128+
if available and name not in available:
129+
import difflib
130+
131+
hint = difflib.get_close_matches(name, sorted(available), n=1)
132+
suffix = f" Did you mean: {hint[0]}?" if hint else ""
133+
self.cli.print_warning(
134+
f"No tool named '{name}'.{suffix} See: effgen tools list"
135+
)
136+
continue
137+
if name not in self.tool_names:
138+
self.tool_names.append(name)
139+
108140
self.agent: Any = None
109141
self.session_tokens = 0
110142
self.session_cost = 0.0
@@ -514,13 +546,14 @@ def _stream_plain(self, user_input: str) -> str:
514546
first = tok
515547
break
516548
else:
517-
if not self.quiet:
549+
show_placeholder = not self.quiet and self.interactive
550+
if show_placeholder:
518551
print("Thinking…", end="", flush=True)
519552
for tok in gen:
520553
if tok:
521554
first = tok
522555
break
523-
if not self.quiet:
556+
if show_placeholder:
524557
print("\r" + " " * 10 + "\r", end="", flush=True)
525558

526559
if self.console:
@@ -552,7 +585,7 @@ def _run_with_tools(self, user_input: str) -> str:
552585
):
553586
response = self.agent.run(user_input, mode=AgentMode.AUTO)
554587
else:
555-
if not self.quiet:
588+
if not self.quiet and self.interactive:
556589
print("Thinking…")
557590
response = self.agent.run(user_input, mode=AgentMode.AUTO)
558591

effgen/cli/progress.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from __future__ import annotations
2323

24+
import json
2425
import os
2526
import sys
2627
import time
@@ -374,6 +375,42 @@ def _truncate(value: Any, limit: int) -> str:
374375
return text if len(text) <= limit else text[: limit - 1] + "…"
375376

376377

378+
def format_tool_call(name: str, tool_input: Any, limit: int = 72) -> str:
379+
"""Render a tool call as ``name(key="value", …)`` on one balanced line.
380+
381+
``tool_input`` may be a dict of arguments or a JSON string of one; either
382+
is shown as compact ``key="value"`` pairs. Anything else is shown as a
383+
single truncated argument. The parentheses always close — a call trimmed to
384+
fit ends in ``…)`` rather than a dangling ``,`` or an unbalanced brace.
385+
"""
386+
args: Any = tool_input
387+
if isinstance(args, str):
388+
text = args.strip()
389+
if text[:1] in ("{", "["):
390+
try:
391+
args = json.loads(text)
392+
except (ValueError, TypeError):
393+
args = text
394+
else:
395+
args = text
396+
397+
if isinstance(args, dict):
398+
parts = []
399+
for key, val in args.items():
400+
if isinstance(val, str):
401+
rendered = f'{key}="{val}"'
402+
else:
403+
rendered = f"{key}={val}"
404+
parts.append(rendered.replace("\n", " "))
405+
inner = ", ".join(parts)
406+
else:
407+
inner = str(args).replace("\n", " ")
408+
409+
if len(inner) > limit:
410+
inner = inner[: limit - 1].rstrip(", ") + "…"
411+
return f"{name}({inner})"
412+
413+
377414
def execution_trace_lines(trace: list[dict[str, Any]] | None) -> list[tuple[str, str]]:
378415
"""Turn an :class:`ExecutionTracker` event trace into readable ``(style, text)``.
379416
@@ -394,9 +431,10 @@ def execution_trace_lines(trace: list[dict[str, Any]] | None) -> list[tuple[str,
394431
elif etype == "tool_call_start":
395432
name = data.get("tool_name", "tool")
396433
tool_input = data.get("tool_input", data.get("input", ""))
397-
detail = f"🔧 {name}"
398434
if tool_input:
399-
detail += f"({_truncate(tool_input, 80)})"
435+
detail = f"🔧 {format_tool_call(name, tool_input)}"
436+
else:
437+
detail = f"🔧 {name}"
400438
out.append(("green", detail))
401439
elif etype == "tool_call_complete":
402440
result = data.get("result", data.get("output", ""))

effgen/core/batch.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@
3434
# works without ``--query-field``.
3535
_QUERY_ALIASES = ("query", "input", "prompt", "question", "text")
3636

37+
# File extensions ``write_results`` can serialize a batch to. The CLI checks a
38+
# requested ``--output`` against this set up front so an unsupported extension
39+
# fails before any billed call rather than after the whole run.
40+
SUPPORTED_OUTPUT_FORMATS = (".jsonl", ".json", ".csv")
41+
3742

3843
def _resolve_row_query(obj: dict[str, Any], query_field: str) -> str:
3944
"""Return a row's query text, trying *query_field* then the common aliases.
@@ -317,7 +322,10 @@ def write_results(
317322
for row in rows:
318323
writer.writerow(BatchRunner._csv_flatten(row))
319324
else:
320-
raise ValueError(f"Unsupported output format: {suffix}")
325+
raise ValueError(
326+
f"Unsupported output format: {suffix}. "
327+
f"Use one of: {', '.join(SUPPORTED_OUTPUT_FORMATS)}."
328+
)
321329

322330
logger.info("Wrote %d results to %s", len(rows), path)
323331

effgen/ui/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,15 @@
2121
response_show,
2222
response_trace,
2323
)
24+
from .tables import console_is_interactive, render_table
2425
from .theme import EFFGEN_THEME, color_enabled, get_console, rich_available
2526

2627
__all__ = [
2728
"EFFGEN_THEME",
2829
"color_enabled",
30+
"console_is_interactive",
2931
"get_console",
32+
"render_table",
3033
"rich_available",
3134
"answer_renderable",
3235
"format_cost",

effgen/ui/tables.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Aligned table rendering shared by the CLI listing/results commands.
2+
3+
Renders a Rich table on an interactive terminal and a clean, aligned
4+
plain-text table when output is piped or Rich is unavailable, so the results
5+
commands (``eval``, ``compare``, ``cost``, ``sessions list``) read as one
6+
family without adding box-drawing or color to piped/``--json`` output.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from collections.abc import Sequence
12+
from typing import Any
13+
14+
from .theme import rich_available
15+
16+
17+
def console_is_interactive(console: Any) -> bool:
18+
"""True when *console* writes to a real terminal (not a pipe or file)."""
19+
return bool(console is not None and getattr(console, "is_terminal", False))
20+
21+
22+
def render_table(
23+
*,
24+
columns: Sequence[str],
25+
rows: Sequence[Sequence[Any]],
26+
console: Any = None,
27+
title: str | None = None,
28+
justify: Sequence[str] | None = None,
29+
styles: Sequence[str | None] | None = None,
30+
footer: Sequence[str] | None = None,
31+
caption: str | None = None,
32+
file: Any = None,
33+
) -> None:
34+
"""Print *rows* under *columns* as a table.
35+
36+
A Rich table (with the shared theme) is used on an interactive terminal;
37+
output that is piped or redirected gets an aligned plain-text table with no
38+
box-drawing characters or color, so a ``| command | grep`` pipeline stays
39+
readable. ``justify`` is a per-column ``"left"``/``"right"``/``"center"``
40+
list; ``styles`` a per-column Rich style (ignored in the plain path);
41+
``footer`` a per-column footer row; ``caption`` a trailing note.
42+
43+
``file`` forces the plain-text path to a specific stream (e.g. ``stderr``);
44+
a caller routing human output away from stdout under ``--json`` passes it so
45+
the table never lands on the JSON stream.
46+
"""
47+
cell_rows = [["" if c is None else str(c) for c in row] for row in rows]
48+
if file is None and rich_available() and console_is_interactive(console):
49+
_render_rich(console, columns, cell_rows, title, justify, styles, footer, caption)
50+
else:
51+
_render_plain(columns, cell_rows, title, justify, footer, caption, file)
52+
53+
54+
def _render_rich(
55+
console: Any,
56+
columns: Sequence[str],
57+
rows: Sequence[Sequence[str]],
58+
title: str | None,
59+
justify: Sequence[str] | None,
60+
styles: Sequence[str | None] | None,
61+
footer: Sequence[str] | None,
62+
caption: str | None,
63+
) -> None:
64+
from rich.table import Table
65+
66+
table = Table(title=title, caption=caption, show_footer=footer is not None)
67+
for i, col in enumerate(columns):
68+
table.add_column(
69+
col,
70+
justify=(justify[i] if justify and i < len(justify) else "left"),
71+
style=(styles[i] if styles and i < len(styles) else None),
72+
footer=(footer[i] if footer and i < len(footer) else ""),
73+
overflow="fold",
74+
)
75+
for row in rows:
76+
table.add_row(*row)
77+
console.print(table)
78+
79+
80+
def _render_plain(
81+
columns: Sequence[str],
82+
rows: Sequence[Sequence[str]],
83+
title: str | None,
84+
justify: Sequence[str] | None,
85+
footer: Sequence[str] | None,
86+
caption: str | None,
87+
file: Any = None,
88+
) -> None:
89+
n = len(columns)
90+
body = list(rows) + ([list(footer)] if footer else [])
91+
widths = [len(str(columns[i])) for i in range(n)]
92+
for row in body:
93+
for i in range(n):
94+
cell = str(row[i]) if i < len(row) else ""
95+
widths[i] = max(widths[i], len(cell))
96+
97+
def _fmt(row: Sequence[str]) -> str:
98+
cells = []
99+
for i in range(n):
100+
cell = str(row[i]) if i < len(row) else ""
101+
right = bool(justify and i < len(justify) and justify[i] == "right")
102+
cells.append(cell.rjust(widths[i]) if right else cell.ljust(widths[i]))
103+
return " ".join(cells).rstrip()
104+
105+
if title:
106+
print(title, file=file)
107+
print(_fmt(list(columns)), file=file)
108+
print(" ".join("-" * w for w in widths), file=file)
109+
for row in rows:
110+
print(_fmt(row), file=file)
111+
if footer:
112+
print(" ".join("-" * w for w in widths), file=file)
113+
print(_fmt(list(footer)), file=file)
114+
if caption:
115+
print(caption, file=file)

0 commit comments

Comments
 (0)