diff --git a/README.md b/README.md index c2b68f3..d40cfa7 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Not on zsh? The Python CLIs (`shellllm-comma`, `shellllm-ask`, `shellllm-recall` | Command | What it does | Example | |---|---|---| | `, ` | Propose 3–5 shell commands, pick one in fzf, drop on prompt. Never executes. | `, the five largest files here` | -| `,, [prompt]` | Same, but with terminal context attached. Bare `,,` repairs the previous command. | `,,` after a typo'd command | +| `,,` | Repair the previous command. Top fix lands on your prompt line — no picker. `,, ` for "I meant X"; `,, --pick` to see alternatives. | `,,` after a typo | | `? ` | Ask the model. Streams markdown, keeps a per-pane conversation. Reads piped input. | `? what does git stash do` | | `???` | Memory and recall. Bare query searches the archive; flags pin long-term facts. | `??? --add I prefer ripgrep` | | `??` | Start, stop, or check the local `llama-server`. | `?? --start fast` | diff --git a/bash/shellllm.bash b/bash/shellllm.bash index 144ea17..8b86f60 100644 --- a/bash/shellllm.bash +++ b/bash/shellllm.bash @@ -43,6 +43,9 @@ _shellllm_with_ctx() { llmc() { local s=$?; "$SHELLLM_COMMA" "$@"; } llmcc() { local s=$?; _shellllm_with_ctx "$s" "$SHELLLM_COMMA" --ctx "$@"; } -llmf() { local s=$?; _shellllm_with_ctx "$s" "$SHELLLM_COMMA" --fix; } +# Bare `llmf` repairs; `llmf ` repairs with your stated intent; +# `llmf --pick [intent]` shows the picker. Top suggestion is printed to +# stdout — the user pastes / runs it, never the function. +llmf() { local s=$?; _shellllm_with_ctx "$s" "$SHELLLM_COMMA" --fix "$@"; } llma() { local s=$?; _shellllm_with_ctx "$s" "$SHELLLM_ASK" "$@"; } llmm() { "$SHELLLM_RECALL" "$@"; } diff --git a/demo.gif b/demo.gif index 4546cb1..43c3ddb 100644 Binary files a/demo.gif and b/demo.gif differ diff --git a/demo.tape b/demo.tape index 6aa3b92..0113fe0 100644 --- a/demo.tape +++ b/demo.tape @@ -123,10 +123,12 @@ Sleep 1s Enter Sleep 3s -# ── Beat 2 — `,,` diagnoses then fixes the previous command ───────────── +# ── Beat 2 — `,,` diagnoses, then drops the fix on the prompt line ────── # A real flag typo on `find`. BSD and GNU find both accept `-type`, so -# the corrected command works on every platform — `,,` diagnoses the -# typo in one stderr line and proposes the corrected command first. +# the corrected command works everywhere. `,,` prints the diagnosis on +# stderr and drops the model's top fix directly on the next prompt +# line — no picker. You read both, then Enter to run, or edit / Ctrl-C. +# `,, --pick` would show the full picker when alternatives matter. Type "find . -tpye f -name '*.py'" Sleep 500ms Enter @@ -134,12 +136,11 @@ Sleep 1500ms Type ",," Sleep 800ms Enter -Wait+Screen@30s /enter: drop on prompt/ -Sleep 800ms +# Wait for the diagnosis line and the dropped fix; give the audience +# time to read both before pressing Enter to run. +Sleep 9s Enter -Sleep 1s -Enter -Sleep 4s +Sleep 3s Type "clear" Enter Sleep 400ms diff --git a/src/shellllm/comma.py b/src/shellllm/comma.py index 79de7b7..a830cd9 100644 --- a/src/shellllm/comma.py +++ b/src/shellllm/comma.py @@ -171,15 +171,20 @@ def _fix_system_prompt() -> str: def _print_usage(*, to: Any = None) -> None: out = to or sys.stdout out.write( - "usage: , propose commands (no terminal context)\n" - " ,, same, with terminal context (, --ctx)\n" - " ,, fix the previous command (, --fix [hint])\n" - " , --new start a fresh session\n" + "usage: , propose commands via fzf picker\n" + " ,, fix the previous command (top fix → prompt line)\n" + " ,, fix using your stated intent\n" + " ,, --pick [intent] same, but show the picker (see alternatives)\n" + " , --ctx propose with terminal context as background\n" + " , --new start a fresh session\n" " , --reset drop current session\n" " , --history print session transcript\n" " , --fast|--balanced|--smart … route this call to a tier (zsh)\n" " , --help show this message\n" "\n" + "`,,` never executes anything — the suggestion lands on your prompt;\n" + "you confirm with Enter, edit it, or cancel with Ctrl-C.\n" + "\n" "For facts and cross-session recall, see `??? --help`.\n" ) @@ -400,10 +405,15 @@ def _consume_flag(flag: str) -> bool: if _consume_flag("--new"): session.archive_and_reset(archive=archive, embed_fn=_safe_embed) - # `,` is the context-free verb; `--ctx` (the zsh `,, `) brings - # the terminal context along, and `--fix` (bare `,,`) implies it. + # `,` is the context-free verb; `, --ctx ` brings the + # terminal context along (the zsh `,,` glyph routes here when run + # with a prompt that the user wants treated as a refinement, not a + # repair); `, --fix` (the zsh bare `,,`) repairs the previous + # command. Fix-mode drops the model's top suggestion straight on + # the prompt — pass `--pick` to see the full picker instead. ctx_mode = _consume_flag("--ctx") fix_mode = _consume_flag("--fix") + pick_mode = _consume_flag("--pick") shell_ctx = ctx_mode or fix_mode prompt = " ".join(argv).strip() @@ -459,9 +469,18 @@ def _consume_flag(flag: str) -> bool: return 1 content, items = result - chosen = _pick(items) - if not chosen: - return 1 + # In fix mode (the typical `,,` flow) we trust the model's top + # suggestion and drop it straight on the user's prompt line — the + # diagnose-then-suggest design already surfaces *why* the model + # thinks this is the fix above. The picker is one keystroke too + # many for obvious typos. `,, --pick` (or `, --fix --pick`) opts + # back into the picker when alternatives matter. + if fix_mode and not pick_mode: + chosen = items[0]["command"] + else: + chosen = _pick(items) + if not chosen: + return 1 # Persist the turn for the next refinement. We store the raw JSON # the model produced so it sees its own prior list verbatim. diff --git a/tests/test_comma_session.py b/tests/test_comma_session.py index 4ba4d5a..347b7b8 100644 --- a/tests/test_comma_session.py +++ b/tests/test_comma_session.py @@ -260,6 +260,62 @@ def test_fix_appends_user_hint(monkeypatch, capsys, isolated, fake_model, auto_p assert "I meant the dev branch" in user_msg +def test_fix_default_skips_picker_and_prints_top(monkeypatch, capsys, isolated, fake_model): + """Bare `,,` must drop the model's top suggestion to stdout without invoking the picker.""" + _enable_shell_ctx(monkeypatch) + pick_called = {"hit": False} + + def fail_pick(items): + pick_called["hit"] = True + return None + + monkeypatch.setattr(comma, "_pick", fail_pick) + assert _run(["--fix"], monkeypatch) == 0 + out = capsys.readouterr().out + # `fake_model` fixture returns commands=[{ls -lh, "list with sizes"}, ...] + # so the top is "ls -lh" and it must reach stdout untouched. + assert out.strip() == "ls -lh" + assert pick_called["hit"] is False + + +def test_fix_pick_flag_opts_back_into_picker(monkeypatch, capsys, isolated, fake_model): + """`,, --pick` must restore the picker for cases where alternatives matter.""" + _enable_shell_ctx(monkeypatch) + pick_calls: list = [] + + def record_pick(items): + pick_calls.append(items) + return items[1]["command"] # pick the second one to prove we routed through + + monkeypatch.setattr(comma, "_pick", record_pick) + assert _run(["--fix", "--pick"], monkeypatch) == 0 + assert len(pick_calls) == 1 + assert capsys.readouterr().out.strip() == "ls -la" + + +def test_fix_pick_with_intent(monkeypatch, capsys, isolated, fake_model, auto_pick): + """`--pick` must coexist with a free-form intent — neither swallows the other.""" + _enable_shell_ctx(monkeypatch) + assert _run(["--fix", "--pick", "use", "ripgrep"], monkeypatch) == 0 + user_msg = next(m["content"] for m in fake_model[0] if m["role"] == "user") + assert "use ripgrep" in user_msg + + +def test_plain_comma_unchanged_still_uses_picker( + monkeypatch, capsys, isolated, fake_model, auto_pick +): + """No-picker behavior is fix-mode-only; plain `,` keeps the fzf flow.""" + pick_calls: list = [] + + def record_pick(items): + pick_calls.append(items) + return items[0]["command"] + + monkeypatch.setattr(comma, "_pick", record_pick) + assert _run(["list", "files"], monkeypatch) == 0 + assert len(pick_calls) == 1, "plain , must go through the picker" + + def test_fix_uses_fix_schema(monkeypatch, capsys, isolated, fake_model, auto_pick): """Fix mode must request the diagnose-then-suggest schema, not the plain one.""" _enable_shell_ctx(monkeypatch) diff --git a/zsh/shellllm.zsh b/zsh/shellllm.zsh index f94d3ce..4d93a72 100644 --- a/zsh/shellllm.zsh +++ b/zsh/shellllm.zsh @@ -205,17 +205,21 @@ function ,() { # default `cmd`) controls how much context rides along. function ,,() { local __last_status=$_SHELLLM_PREV_STATUS - # A leading tier flag (`,, --fast …`) is routing, not the prompt — - # pop it so the fix-vs-ctx switch only sees real arguments. - local -a tier + # `,,` is always fix mode. A leading tier flag is routing, not the + # prompt; `--pick` (anywhere) opts into the picker; everything else + # becomes "intent" — a hint the model uses to narrow the repair. + local -a tier pick rest if [[ "${1:-}" == --fast || "${1:-}" == --balanced || "${1:-}" == --smart ]]; then tier=("$1"); shift fi - if (( $# )); then - _shellllm_comma_run $__last_status ${tier[@]} --ctx "$@" - else - _shellllm_comma_run $__last_status ${tier[@]} --fix - fi + local a + for a in "$@"; do + case "$a" in + --pick) pick=(--pick) ;; + *) rest+=("$a") ;; + esac + done + _shellllm_comma_run $__last_status ${tier[@]} --fix ${pick[@]} ${rest[@]} } # ─── `?` — answer. `noglob` is required because `?` is a zsh glob char.