Skip to content

Commit babf736

Browse files
committed
Add interactive prompts, command completion, and model filtering
- Implemented SlashCompleter for command completion based on user input. - Added interactive choice prompts for selecting providers and models. - Introduced model filtering logic to ensure only valid chat models are presented. - Created tests for new features including command completion and model listing. - Established suite-wide guards to prevent real HTTP requests during tests. - Enhanced user experience by echoing typed responses in prompts.
1 parent 07994ee commit babf736

14 files changed

Lines changed: 1359 additions & 62 deletions

CLAUDE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ turn's `.messages` back in as `history` — that is the *only* behavioural diffe
7272
`history` is copied, not mutated, so an interrupted turn cannot leave the caller with
7373
a transcript containing unanswered tool calls (which the API rejects).
7474

75+
Everything that reads a keypress lives in `agent/prompts.py` (pickers, confirm, secret
76+
entry) and `agent/completion.py` (slash completion). `agent/models.py` asks a provider
77+
what it will actually accept, filtering `/models` down to ids that can drive a
78+
tool-calling loop — an embedding id in the picker is a 400 waiting to happen.
79+
7580
**Rendering stays in `agent/ui.py`.** The loop emits events and never prints, so the
7681
benchmark adapter runs it with no console attached. Anything that makes `agent_loop`
7782
aware of a terminal breaks that.
@@ -190,6 +195,28 @@ These encode failures already hit; changing them will silently break runs.
190195
prints, and `ui.glyph()` falls back to ASCII when the encoding is narrow. The same
191196
applies to rich's box-drawing: `banner()` picks `box.ASCII` from `ascii_only()`
192197
rather than trusting rich's terminal detection.
198+
- **Tool schemas are narrowed per provider on the way out** (`tools_for`). The union
199+
types (`["integer","string"]`) exist because Groq validates the model's arguments
200+
server-side; Gemini's schema layer is OpenAPI-derived and cannot express a union at
201+
all. So `TOOLS` stays canonical and each provider gets its own shape. `_rebuild_client`
202+
re-narrows on `/provider`, or the next turn 400s on a schema built for the provider
203+
just left.
204+
- **Anything the user types at a prompt must be echoed by prompt_toolkit, not
205+
`input()`.** `input()` writes straight to the terminal while rich is still repainting
206+
a spinner over the same line, so the keystrokes vanish and the user answers the
207+
permission gate blind. `ui._read_answer` and `prompts.confirm` both own the line.
208+
- **Enter approves only what is safe to approve by reflex.** The permission prompt
209+
defaults to yes, because approving reads is most of what it does — but not for
210+
`Risk.DANGEROUS` or anything `outside_root`.
211+
- **A picker with no terminal cancels; it never guesses.** `prompts.choose` returns
212+
`None` when stdin is not a tty. Returning the first option would silently change a
213+
provider or model in a scripted run.
214+
- **The suite makes no network calls, and `tests/conftest.py` enforces it.** A test that
215+
called `main()` loaded the developer's `.env`, found a real key and spent tokens on a
216+
live request. httpx is blocked; the Docker tests use requests and still work.
217+
- **`load_dotenv` must be given `find_dotenv(usecwd=True)`.** The default searches
218+
upward from the calling *file*, which inside a pipx install is site-packages — so an
219+
installed `dietcode` never saw the `.env` in the directory the user was standing in.
193220

194221
## Constraints from the plan
195222

agent/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "0.4.2"
1+
__version__ = "0.5.0"
22

33
from .loop import AgentResult, agent_loop, make_client
44
from .sandbox import DockerExecutor, Executor, LocalExecutor, SandboxError, ShellResult

agent/cli.py

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
with_project_context,
3636
)
3737
from .permissions import PermissionGate, Policy, deny_all
38+
from .prompts import confirm
3839
from .repl import Session
3940
from .sandbox import (
4041
DEFAULT_CPUS,
@@ -46,7 +47,7 @@
4647
SandboxError,
4748
)
4849
from .subagent import SPAWN_TOOL, make_spawn_handler
49-
from .tools import TOOLS
50+
from .tools import tools_for
5051
from .ui import FAIL, Renderer, make_approver, turn_footer, use_utf8_stdout
5152

5253
SUBCOMMANDS = {"login", "logout", "auth", "doctor"}
@@ -195,7 +196,9 @@ def wants_sandbox(args: argparse.Namespace) -> bool:
195196
return bool(args.sandbox or args.container or args.mount or args.no_network)
196197

197198

198-
def make_executor(args: argparse.Namespace, console: Console) -> tuple[Any, list]:
199+
def make_executor(
200+
args: argparse.Namespace, console: Console, renderer: Renderer | None = None
201+
) -> tuple[Any, list]:
199202
if not wants_sandbox(args):
200203
root = Path(args.workdir).resolve()
201204
inner = LocalExecutor(root)
@@ -209,7 +212,7 @@ def make_executor(args: argparse.Namespace, console: Console) -> tuple[Any, list
209212
approver = deny_all # unreachable while yes_to_everything is set
210213
elif sys.stdin.isatty():
211214
policy = Policy()
212-
approver = make_approver(console)
215+
approver = make_approver(console, renderer)
213216
else:
214217
# Nothing can be asked, so nothing destructive may happen. Silently
215218
# approving here is how an automated run rewrites someone's files.
@@ -246,6 +249,10 @@ def build_agent_extras(
246249
"""The optional bits: project instructions and sub-agent delegation."""
247250
extras: dict[str, Any] = {}
248251

252+
# Always set, because the schemas one provider requires are the ones
253+
# another rejects. Whichever provider the user picked has to work.
254+
extras["tools"] = tools_for(getattr(args, "provider", None) or default_provider())
255+
249256
if getattr(args, "context", True):
250257
context, source = load_project_context(
251258
"." if wants_sandbox(args) else args.workdir
@@ -255,7 +262,7 @@ def build_agent_extras(
255262
console.print(f"[dim]using project instructions from {source}[/dim]")
256263

257264
if getattr(args, "subagents", False):
258-
extras["tools"] = [*TOOLS, SPAWN_TOOL]
265+
extras["tools"] = [*extras["tools"], SPAWN_TOOL]
259266
extras["extra_tool_handlers"] = {
260267
"spawn_subagent": make_spawn_handler(
261268
executor, client, model, context_budget=args.context_budget
@@ -264,9 +271,14 @@ def build_agent_extras(
264271
return extras
265272

266273

267-
def run_once(args: argparse.Namespace, executor: Any, client: Any, model: str) -> int:
268-
console = Console(quiet=args.quiet and not args.json)
269-
renderer = Renderer(console, show_steps=args.steps)
274+
def run_once(
275+
args: argparse.Namespace,
276+
executor: Any,
277+
client: Any,
278+
model: str,
279+
console: Console,
280+
renderer: Renderer,
281+
) -> int:
270282
started = time.monotonic()
271283
try:
272284
result = agent_loop(
@@ -333,9 +345,12 @@ def main(argv: list[str] | None = None) -> int:
333345
# .env is a convenience for running from a checkout; installed users have a
334346
# saved login instead. Optional so the package does not hard-depend on it.
335347
try:
336-
from dotenv import load_dotenv
348+
from dotenv import find_dotenv, load_dotenv
337349

338-
load_dotenv()
350+
# usecwd, because the default searches upward from *this file*. Inside
351+
# a pipx install that is site-packages, so an installed dietcode never
352+
# saw the .env sitting in the directory the user was standing in.
353+
load_dotenv(find_dotenv(usecwd=True))
339354
except ImportError:
340355
pass
341356

@@ -356,8 +371,27 @@ def main(argv: list[str] | None = None) -> int:
356371

357372
try:
358373
api_key, base_url, model = resolve_model_config(args)
374+
except AuthError as exc:
375+
# Dead-ending on "no credentials" makes the user go read the help, come
376+
# back, and run a different command. Offer to fix it right here instead.
377+
if not sys.stdin.isatty():
378+
console.print(f"[red]{exc}[/red]")
379+
return 2
380+
console.print(f"[orange3]{exc}[/orange3]\n")
381+
if not confirm(console, "Set one up now?"):
382+
return 2
383+
if login(console, args.provider, None) != 0:
384+
return 2
385+
try:
386+
api_key, base_url, model = resolve_model_config(args)
387+
except AuthError as retry_exc:
388+
console.print(f"[red]{retry_exc}[/red]")
389+
return 2
390+
console.print()
391+
392+
try:
359393
client = make_client(api_key=api_key, base_url=base_url)
360-
except (AuthError, RuntimeError) as exc:
394+
except RuntimeError as exc:
361395
console.print(f"[red]{exc}[/red]")
362396
return 2
363397

@@ -368,8 +402,9 @@ def main(argv: list[str] | None = None) -> int:
368402
if stale:
369403
console.print(f"[dim]cleaned up {stale} orphaned container(s)[/dim]")
370404

405+
renderer = Renderer(console, show_steps=args.steps)
371406
try:
372-
executor, mounts = make_executor(args, console)
407+
executor, mounts = make_executor(args, console, renderer)
373408
except SandboxError as exc:
374409
# Docker is optional now that --here exists, so a missing daemon should
375410
# be a signpost rather than a dead end.
@@ -385,7 +420,7 @@ def main(argv: list[str] | None = None) -> int:
385420

386421
try:
387422
if args.task:
388-
return run_once(args, executor, client, model)
423+
return run_once(args, executor, client, model, console, renderer)
389424
return Session(
390425
executor,
391426
client,
@@ -399,6 +434,7 @@ def main(argv: list[str] | None = None) -> int:
399434
max_total_tokens=args.max_tokens,
400435
provider=args.provider or default_provider(),
401436
extras=build_agent_extras(args, executor, client, model, console),
437+
renderer=renderer,
402438
).run()
403439
finally:
404440
executor.close()

agent/commands.py

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,37 +25,30 @@
2525
set_default_provider,
2626
store_key,
2727
)
28+
from .prompts import Choice, ask_secret, choose
2829
from .ui import BRAND, DETAIL, FAIL, MUTED, NOTE, OK, TOOL, WARN, glyph
2930

3031

31-
def _prompt_secret(console: Console, label: str) -> str:
32-
"""Read a key without echoing it."""
33-
import getpass
34-
35-
if not sys.stdin.isatty():
36-
# Piped input: read a line so `echo $KEY | dietcode login` works.
37-
return sys.stdin.readline().strip()
38-
try:
39-
return getpass.getpass(f"{label}: ")
40-
except (EOFError, KeyboardInterrupt):
41-
console.print()
42-
return ""
43-
44-
4532
def login(console: Console, provider: str | None, api_key: str | None) -> int:
4633
"""Save an API key for a provider."""
4734
if provider is None:
48-
console.print(f"[{TOOL}]Which provider?[/{TOOL}]")
49-
for spec in PROVIDERS.values():
35+
provider = choose(
36+
console,
37+
"Which provider?",
38+
[
39+
Choice(spec.name, spec.label, f"free tier {glyph('dot')} {spec.signup_url}"
40+
if spec.name in ("groq", "gemini") else spec.signup_url)
41+
for spec in PROVIDERS.values()
42+
],
43+
)
44+
if provider is None:
45+
# Also the path a piped run takes: there is nobody to ask, so say
46+
# which flag would have answered the question.
5047
console.print(
51-
f" [{NOTE}]{spec.name:<8}[/{NOTE}] [{MUTED}]{spec.label} "
52-
f"{glyph('dot')} keys at {spec.signup_url}[/{MUTED}]"
48+
f"[{MUTED}]cancelled[/{MUTED}] "
49+
f"[{MUTED}]{glyph('dash')} or name one: "
50+
f"dietcode login --provider {'|'.join(PROVIDERS)}[/{MUTED}]"
5351
)
54-
console.print()
55-
try:
56-
provider = input(f"provider [{list(PROVIDERS)[0]}]: ").strip() or list(PROVIDERS)[0]
57-
except (EOFError, KeyboardInterrupt):
58-
console.print()
5952
return 130
6053

6154
try:
@@ -69,7 +62,7 @@ def login(console: Console, provider: str | None, api_key: str | None) -> int:
6962
f"[{MUTED}]Get a key at {spec.signup_url} "
7063
f"{glyph('dot')} input is hidden[/{MUTED}]"
7164
)
72-
api_key = _prompt_secret(console, f"{spec.label} API key")
65+
api_key = ask_secret(f"{spec.label} API key")
7366

7467
api_key = clean_key(api_key or "")
7568
if not api_key:
@@ -172,12 +165,13 @@ def report(good: bool, label: str, detail: str, fix: str = "") -> None:
172165

173166
docker = shutil.which("docker")
174167
if docker is None:
168+
# Not a failure: Docker is only needed for --sandbox, so reporting it
169+
# as broken would send people chasing an install they do not need.
175170
report(
176-
False,
171+
True,
177172
"docker",
178173
"not installed",
179-
"optional: without it use `dietcode --here` to work in the current "
180-
"directory instead of a container",
174+
"optional -- only needed for `dietcode --sandbox`",
181175
)
182176
else:
183177
try:
@@ -196,7 +190,7 @@ def report(good: bool, label: str, detail: str, fix: str = "") -> None:
196190
running,
197191
"docker",
198192
detail,
199-
"start Docker Desktop, or use `dietcode --here` to skip the sandbox",
193+
"only needed for --sandbox; start Docker Desktop if you want it",
200194
)
201195

202196
configured = [spec.name for spec, masked, _ in credential_status() if masked]
@@ -209,7 +203,7 @@ def report(good: bool, label: str, detail: str, fix: str = "") -> None:
209203

210204
console.print()
211205
if ok:
212-
console.print(f"[{OK}]{glyph('tick')} ready[/{OK}] [{MUTED}]try: dietcode --here[/{MUTED}]")
206+
console.print(f"[{OK}]{glyph('tick')} ready[/{OK}] [{MUTED}]try: dietcode[/{MUTED}]")
213207
return 0
214208
console.print(f"[{WARN}]fix the items above, then run `dietcode doctor` again[/{WARN}]")
215209
return 1

agent/completion.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Slash-command completion.
2+
3+
WordCompleter offers its candidates for whatever word is under the cursor, so
4+
typing an ordinary task and pressing space popped the command list up mid
5+
sentence. This only completes when the *whole line* is a single token starting
6+
with "/" -- which is the only moment a command could be what you meant.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from collections.abc import Iterable, Mapping
12+
13+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
14+
from prompt_toolkit.document import Document
15+
16+
17+
class SlashCompleter(Completer):
18+
def __init__(self, commands: Mapping[str, str]):
19+
self._commands = dict(commands)
20+
21+
def get_completions(
22+
self, document: Document, complete_event: CompleteEvent
23+
) -> Iterable[Completion]:
24+
text = document.text_before_cursor
25+
26+
# Only at the very start of the line, and only while still typing the
27+
# command itself. "/model gemini" is past the command, and "fix /tmp"
28+
# is a path, not a command.
29+
if not text.startswith("/") or " " in text:
30+
return
31+
32+
for name, description in self._commands.items():
33+
if name.startswith(text):
34+
yield Completion(
35+
name,
36+
start_position=-len(text),
37+
display=name,
38+
display_meta=description,
39+
)

0 commit comments

Comments
 (0)