From 2ee31b510bd870117269766eb46c5c8d1daf543f Mon Sep 17 00:00:00 2001 From: chauncygu Date: Thu, 30 Jul 2026 17:51:28 -0700 Subject: [PATCH] feat(ui): next-prompt ghost text + fix VS Code Remote-SSH tab-title setup (v3.5.86) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ghost text: after each foreground turn the auxiliary model drafts the line the user is most likely to type next; it renders dim at the prompt and Tab (or the right arrow) accepts it in full. Drafting runs on a background daemon thread, is silent on any failure, is one-shot per prompt, and is superseded rather than overwritten when a newer turn starts. New config key input_suggest (default true); CHEETAH_SUGGEST=0 disables it for one run. The prediction is applied synchronously at pre_run and on every text change because prompt_toolkit only consults the auto-suggester on text insert โ€” an empty prompt is never asked, and a fast Tab can beat the async pass. Tab-title setup: the first-run helper only ever considered a local editor install, so a Remote-SSH / WSL / devcontainer session wrote (and fabricated) ~/.config/Code/User/settings.json on the server โ€” a file the editor never reads โ€” then marked itself done and never retried. It now locates the editor server install and writes the remote Machine settings the window actually reads, requires an existing User directory before touching the local path, prints paste-ready instructions when neither is reachable, and scopes its marker per target so legacy markers self-heal with one retry. Tests: 48 new cases (tests/test_input_suggest.py incl. end-to-end over a real prompt_toolkit session; tests/test_vscode_setup.py โ€” the module had none). Full suite: 2629 passed, 5 skipped. --- README.md | 2 + cheetahclaws/cli.py | 11 ++ cheetahclaws/config.py | 5 + cheetahclaws/ui/input.py | 108 ++++++++++- cheetahclaws/ui/suggest.py | 147 +++++++++++++++ cheetahclaws/ui/vscode_setup.py | 182 ++++++++++++++++--- docs/architecture.md | 10 +- docs/guides/features.md | 1 + docs/guides/reference.md | 21 ++- docs/i18n/README.CN.MD | 2 + docs/news.md | 3 + pyproject.toml | 2 +- tests/test_input_suggest.py | 309 ++++++++++++++++++++++++++++++++ tests/test_vscode_setup.py | 208 +++++++++++++++++++++ 14 files changed, 972 insertions(+), 39 deletions(-) create mode 100644 cheetahclaws/ui/suggest.py create mode 100644 tests/test_input_suggest.py create mode 100644 tests/test_vscode_setup.py diff --git a/README.md b/README.md index cb1d45f9..fe5c1ad8 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Other install methods: [one-line install script](#alternative-one-line-install-s ## ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ News (Pacific Time) +- July 30, 2026 (**v3.5.86**): **Next-prompt ghost text โ€” the REPL predicts the line you'd type next.** After each reply the **auxiliary** (cheap/fast) model drafts your most likely next message and shows it dim at the prompt; **Tab** (or **โ†’**) accepts it in full, typing just types over it, and Enter alone never submits it. Drafting runs on a background thread so the prompt never waits, stays silent on any failure (no key / no model โ†’ simply no ghost), and is one-shot per prompt so a stale prediction is never shown. Off with `/config input_suggest=false` or `CHEETAH_SUGGEST=0`. Also in this release: the **terminal tab title now configures itself over Remote-SSH / WSL / devcontainers** โ€” it used to write a settings file on the server that the editor never reads, and never retry; it now targets the remote Machine settings the window actually reads. First tagged release carrying the July 11 tab-title / prompt-cache and July 20 `tool_profile` / bounded-I/O changes. [Details](docs/news.md) - July 20, 2026: **Bounded-I/O fixes and a configurable tool surface.** `tool_profile` selects how many tool schemas are sent each turn โ€” `full` (default, nothing hidden) / `standard` (compact coding) / `research` / `orchestration` โ€” to cut prompt tokens on small-context models, switchable with `/config tool_profile=standard`. Also fixes two bounded-I/O regressions: `SummarizeLargeFile` no longer "summarizes" its own chunk-failure markers (clean `Error` when map/reduce fails), and the DuckDuckGo parser no longer crashes on a valueless `class` attribute. [Details](docs/news.md) - July 11, 2026: **Terminal tab title tracks the live task, plus a cross-turn fix for the Anthropic prompt cache.** [Details](docs/news.md) - July 10, 2026 (**v3.5.85**): **REPL quality-of-life.** Live typing-time completion now works on *every* install โ€” `prompt_toolkit` is a **core dependency** (no `[autosuggest]` extra needed, so `pip install` / `uv tool install` both get it out of the box); **`/model` gained a Tab-completion picker** (provider/model + a two-level LiteLLM tree, PR #166); and sessions now **autosave every turn** (atomic write + `fsync`) so a crash or power-loss mid-conversation stays recoverable via `/resume` โ€” the loud daily/history save still happens once on exit. [Details](docs/news.md) @@ -177,6 +178,7 @@ Claude Code is a powerful, production-grade AI coding assistant โ€” but its sour | Permission system | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` modes (`accept-edits` = auto-run edits, still ask for other Bash; hard denylist blocks host-destroying commands in every mode) | | Checkpoints & plan mode | Auto-snapshot conversation + files each turn (`/checkpoint`, `/rewind`); `/plan` read-only analysis mode | | Slash commands & themes | 50+ slash commands with Tab-complete; `/theme` offers 15 curated palettes | +| Next-prompt ghost text | After each turn the auxiliary (cheap) model drafts the line you'd most likely type next and shows it dim at the prompt โ€” **Tab** (or **โ†’**) accepts it in full, typing ignores it. Background-drafted, never blocks the REPL, silent on failure. Off via `/config input_suggest=false` or `CHEETAH_SUGGEST=0`. [Details](docs/guides/reference.md#next-prompt-ghost-text) | | Brainstorm โ†’ Worker | `/brainstorm` runs an N-persona debate โ†’ `todo_list.txt`; `/worker` auto-implements the pending tasks | | SSJ Developer Mode | `/ssj` โ€” persistent power menu chaining Brainstorm, Worker, Review, Trading, Agent, Video/TTS, Monitor, etc. | | Trading agent | `/trading` multi-agent analysis, backtesting, paper-trade calibration, MV portfolios. [Guide](docs/guides/trading.md) | diff --git a/cheetahclaws/cli.py b/cheetahclaws/cli.py index e14f3a21..ad1f8098 100755 --- a/cheetahclaws/cli.py +++ b/cheetahclaws/cli.py @@ -1502,6 +1502,17 @@ def run_query(user_input: str, is_background: bool = False): session_ctx.last_interaction_time = time.time() + # โ”€โ”€ Predicted next prompt โ”€โ”€ + # Drafts (in the background) the line the user most likely types next + # and shows it as dim ghost text at the prompt; Tab accepts it. + # Background turns don't own the prompt, so they never draft one. + if not is_background: + try: + from cheetahclaws.ui import suggest as _ui_suggest + _ui_suggest.schedule(state.messages, config) + except Exception: + pass + session_ctx.run_query = lambda msg: run_query(msg, is_background=True) # Same handler used by the headless bridges path โ€” see # `_make_bridge_slash_handler` for sentinel processing. diff --git a/cheetahclaws/config.py b/cheetahclaws/config.py index 9ba9a168..3e662b2a 100644 --- a/cheetahclaws/config.py +++ b/cheetahclaws/config.py @@ -139,6 +139,11 @@ # REPL behaviour is unchanged; daemon code paths can opt in # explicitly. The CHEETAHCLAWS_ENABLE_F4 env var also enables it. "agent_runner_subprocess": False, + # โ”€โ”€ Input ghost text โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # After each turn the auxiliary model drafts the line you are most likely + # to type next; it shows dim at the prompt and Tab accepts it in full. + # See ui/suggest.py. Also switchable per-run with CHEETAH_SUGGEST=0. + "input_suggest": True, # Per-provider API keys (optional; env vars take priority) # "anthropic_api_key": "sk-ant-..." # "openai_api_key": "sk-..." diff --git a/cheetahclaws/ui/input.py b/cheetahclaws/ui/input.py index ffd1ed1f..26645d9a 100644 --- a/cheetahclaws/ui/input.py +++ b/cheetahclaws/ui/input.py @@ -6,16 +6,25 @@ Dependency-injected: callers register command/meta providers via setup() before calling read_line(). This module never imports cheetahclaws โ€” keeping the dependency one-way and eliminating any circular-import risk. + +Ghost text has two sources, in priority order: + 1. A predicted next prompt pushed in by ui.suggest via + set_pending_suggestion() after each turn (Claude-Code style). + 2. The shell-history suggestion (prompt_toolkit's AutoSuggestFromHistory). +Both render dim/italic; Tab (or โ†’) accepts the whole thing. """ from __future__ import annotations +import threading from pathlib import Path from typing import Callable, Optional try: from prompt_toolkit import PromptSession - from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.auto_suggest import ( + AutoSuggest, AutoSuggestFromHistory, Suggestion, + ) from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.formatted_text import ANSI from prompt_toolkit.application import get_app @@ -55,6 +64,30 @@ def setup( _dynamic_completions_provider = dynamic_completions_provider +# โ”€โ”€ Predicted next prompt (pushed in by ui.suggest) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Written from a background thread, read from the prompt_toolkit event loop โ€” +# hence the lock. One-shot: read_line() clears it once the user submits a line. +_pending_lock = threading.Lock() +_pending_suggestion: str = "" + + +def set_pending_suggestion(text: str) -> None: + """Publish the ghost text shown at the next (empty) prompt.""" + global _pending_suggestion + cleaned = (text or "").strip() + with _pending_lock: + _pending_suggestion = cleaned + + +def get_pending_suggestion() -> str: + with _pending_lock: + return _pending_suggestion + + +def clear_pending_suggestion() -> None: + set_pending_suggestion("") + + # โ”€โ”€ Completer โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if HAS_PROMPT_TOOLKIT: @@ -162,12 +195,38 @@ def __init__(self, *_args, **_kwargs): raise RuntimeError("prompt_toolkit is not installed") +# โ”€โ”€ Auto-suggest โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +if HAS_PROMPT_TOOLKIT: + + class PredictiveAutoSuggest(AutoSuggest): + """Predicted next prompt first, shell history second. + + On an empty buffer the whole prediction is offered; once the user + starts typing it survives only while it still prefixes what they + typed, after which history takes over. + """ + + def __init__(self, provider: Optional[Callable[[], str]] = None): + self._provider = provider or get_pending_suggestion + self._history = AutoSuggestFromHistory() + + def get_suggestion(self, buffer, document): # type: ignore[override] + text = document.text + # Only single-line, cursor-at-end states โ€” otherwise the ghost + # would render in the middle of a paste or a multi-line edit. + if "\n" not in text and document.cursor_position == len(text): + pending = (self._provider() or "").strip() + if pending.startswith(text) and len(pending) > len(text): + return Suggestion(pending[len(text):]) + return self._history.get_suggestion(buffer, document) + + # โ”€โ”€ Key bindings โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ if HAS_PROMPT_TOOLKIT: @Condition def _ghost_text_acceptable() -> bool: - """True when a history ghost-suggestion is shown and no slash menu is active.""" + """True when a ghost-suggestion is shown and no slash menu is active.""" buf = get_app().current_buffer if not (buf.suggestion and buf.suggestion.text): return False @@ -177,7 +236,7 @@ def _ghost_text_acceptable() -> bool: return True def _build_key_bindings() -> "KeyBindings": - """Tab accepts the gray history ghost-text when one is shown. + """Tab accepts the dim ghost-text (predicted prompt or history) shown. Falls through to the default Tab binding (slash-menu cycling) when the filter doesn't match, so `/cmd` completion behavior is unchanged. @@ -191,6 +250,32 @@ def _(event): return kb + def _apply_pending(buf) -> None: + """Set the predicted ghost text on `buf`, synchronously. + + The auto-suggester runs as a background task and only on text + *insert* โ€” so an empty buffer is never asked at all, and a fast Tab + right after typing can beat the async result. Applying the prediction + inline on every text change (and at pre_run) keeps the ghost exact and + immediate; the async path still handles the history fallback. + """ + pending = get_pending_suggestion() + if not pending or buf.suggestion: + return + text = buf.text + if "\n" in text or buf.cursor_position != len(text): + return + if pending.startswith(text) and len(pending) > len(text): + buf.suggestion = Suggestion(pending[len(text):]) + + def _seed_suggestion() -> None: + """pre_run hook: show the prediction before the user types anything.""" + try: + buf = get_app().current_buffer + except Exception: + return + _apply_pending(buf) + # โ”€โ”€ Session cache โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _SESSION = None @@ -216,16 +301,18 @@ def _build_session(history_path: Optional[Path]): "completion-menu.meta.completion.current": "bg:#005f87 #eeeeee", "auto-suggestion": "#606060 italic", }) - return PromptSession( + session = PromptSession( history=history, completer=completer, - auto_suggest=AutoSuggestFromHistory(), + auto_suggest=PredictiveAutoSuggest(), complete_while_typing=True, enable_history_search=False, mouse_support=False, style=style, key_bindings=_build_key_bindings(), ) + session.default_buffer.on_text_changed += _apply_pending + return session def read_line(prompt_ansi: str, history_path: Optional[Path] = None) -> str: @@ -234,6 +321,10 @@ def read_line(prompt_ansi: str, history_path: Optional[Path] = None) -> str: The history file passed here MUST NOT be the readline history file โ€” the two line-editors use incompatible formats. See cheetahclaws.repl for the dedicated PT_HISTORY_FILE. + + A predicted next prompt published via set_pending_suggestion() is shown + as dim ghost text and consumed by this call โ€” it never carries over to a + later prompt, where it would be stale. """ global _SESSION, _SESSION_HISTORY_PATH if _SESSION is not None and _SESSION_HISTORY_PATH != history_path: @@ -241,5 +332,8 @@ def read_line(prompt_ansi: str, history_path: Optional[Path] = None) -> str: if _SESSION is None: _SESSION = _build_session(history_path) _SESSION_HISTORY_PATH = history_path - with patch_stdout(raw=True): - return _SESSION.prompt(ANSI(prompt_ansi)) + try: + with patch_stdout(raw=True): + return _SESSION.prompt(ANSI(prompt_ansi), pre_run=_seed_suggestion) + finally: + clear_pending_suggestion() diff --git a/cheetahclaws/ui/suggest.py b/cheetahclaws/ui/suggest.py new file mode 100644 index 00000000..392d93f2 --- /dev/null +++ b/cheetahclaws/ui/suggest.py @@ -0,0 +1,147 @@ +"""Predicted next prompt โ€” Claude-Code-style ghost text at the input line. + +After each foreground turn the auxiliary (cheap/fast) model drafts the single +line the user is most likely to type next. It is pushed into ui.input, which +renders it dim/italic in the empty prompt; Tab (or โ†’) accepts it in full, +anything else simply types over it. + +Everything here is best-effort: the draft runs on a background daemon thread, +never blocks the REPL, and stays silent on any failure. Turn off with +`/config input_suggest false` or CHEETAH_SUGGEST=0. +""" + +from __future__ import annotations + +import os +import threading +from typing import Optional + +from cheetahclaws.ui import input as ui_input + +# Longer than this and the ghost wraps the terminal line, which reads as +# clutter rather than a hint. +MAX_LEN = 90 + +_SYSTEM = ( + "You predict the NEXT message a developer will type to their coding " + "assistant, given the conversation so far.\n" + "Rules:\n" + "- Reply with that message ONLY: one line, no quotes, no explanation, " + "no leading dash or bullet.\n" + "- Keep it under 12 words and phrase it as the user (imperative, " + "first-person), never as the assistant.\n" + "- Write it in the same language the user has been using.\n" + "- Make it the most probable concrete follow-up (run the tests, fix the " + "failure, commit it, explain a specific part), not a generic pleasantry.\n" + "- If nothing is plausibly next, reply with exactly: NONE" +) + +# Generation counter: a slow draft from turn N must not overwrite the ghost +# that turn N+1 already published. +_lock = threading.Lock() +_generation = 0 + + +def enabled(config: dict) -> bool: + """True when ghost-text prediction should run for this session.""" + if os.environ.get("CHEETAH_SUGGEST", "1") == "0": + return False + if not config.get("input_suggest", True): + return False + return bool(ui_input.HAS_PROMPT_TOOLKIT) + + +def _text_of(content) -> str: + """Flatten a message `content` (str or Anthropic-style block list).""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + return "\n".join(p for p in parts if p) + return "" + + +def _recent_exchange(messages: list, limit: int = 4) -> list: + """Last few user/assistant turns, flattened and truncated for the drafter.""" + out = [] + for msg in reversed(messages or []): + role = msg.get("role") + if role not in ("user", "assistant"): + continue + text = _text_of(msg.get("content")).strip() + if not text: + continue + out.append({"role": role, "content": text[:1500]}) + if len(out) >= limit: + break + return list(reversed(out)) + + +def _clean(raw: str) -> str: + """Reduce the model's reply to a usable one-liner, or "" if unusable.""" + line = (raw or "").strip().splitlines()[0].strip() if (raw or "").strip() else "" + line = line.lstrip("-โ€ข*").strip() + for quote in ('"', "'", "`", "โ€œ", "โ€"): + line = line.strip(quote) + line = line.strip() + if not line or line.upper() == "NONE": + return "" + if len(line) > MAX_LEN: + return "" + # A drafter that starts explaining itself is not producing a user message. + if line.lower().startswith(("sure,", "here", "the user", "as an ai")): + return "" + return line + + +def predict(messages: list, config: dict) -> str: + """Draft the likely next user message. Returns "" when unavailable.""" + exchange = _recent_exchange(messages) + if not exchange: + return "" + try: + from cheetahclaws.auxiliary import stream_auxiliary + raw = stream_auxiliary(_SYSTEM, exchange, config) + except Exception: + return "" + return _clean(raw) + + +def schedule(messages: list, config: dict) -> Optional[threading.Thread]: + """Draft the next-prompt ghost text in the background. Non-blocking. + + Returns the worker thread (mostly for tests), or None when prediction is + disabled or there is nothing to work from. + """ + global _generation + if not enabled(config): + return None + snapshot = _recent_exchange(messages) + if not snapshot: + return None + + with _lock: + _generation += 1 + mine = _generation + + # Clear any leftover ghost from the previous turn straight away โ€” a stale + # prediction is worse than none while the new one is being drafted. + ui_input.set_pending_suggestion("") + + def _work(): + text = predict(snapshot, config) + if not text: + return + with _lock: + if mine != _generation: + return # a newer turn already superseded this draft + ui_input.set_pending_suggestion(text) + + thread = threading.Thread(target=_work, name="cheetah-suggest", daemon=True) + thread.start() + return thread diff --git a/cheetahclaws/ui/vscode_setup.py b/cheetahclaws/ui/vscode_setup.py index a8eb87d7..d991cbbb 100644 --- a/cheetahclaws/ui/vscode_setup.py +++ b/cheetahclaws/ui/vscode_setup.py @@ -11,7 +11,10 @@ the first time they run inside a VS Code-family terminal. It is deliberately conservative: -* runs at most once (a marker file under ~/.cheetahclaws), so we never nag; +* runs at most once *per settings target* (a marker file under + ~/.cheetahclaws records which file was configured), so we never nag โ€” but a + machine whose target changes (e.g. a local run after a Remote-SSH one) is + still handled; * never overwrites a value the user already set for that key; * backs the file up before writing; * inserts the key textually (preserving comments / formatting of a JSONC @@ -20,6 +23,14 @@ left untouched rather than corrupted; * swallows every error: a nicety must never break startup. +Remote-SSH / WSL / devcontainers / Codespaces need care: there the editor UI +runs on the user's own machine and its User settings.json is unreachable from +here, so writing ~/.config/Code/User/settings.json on the *server* side configures +nothing (it just fabricates a file VS Code never reads). The window does, +however, also read the **remote Machine settings** that live on this side โ€” +``/data/Machine/settings.json`` โ€” and the title key applies from +there, so that is what we write when a server install is detected. + The new setting only applies to terminals opened AFTER it is written, so the current session still won't show it โ€” the announced message says as much. """ @@ -59,6 +70,7 @@ def _vscode_app() -> str | None: def _settings_path(app: str) -> Path | None: + """Local (UI-side) User settings.json for `app` on this platform.""" home = Path.home() if sys.platform == "darwin": base = home / "Library" / "Application Support" / app / "User" @@ -73,6 +85,83 @@ def _settings_path(app: str) -> Path | None: return base / "settings.json" +# Server installs: ~/.vscode-server, ~/.vscode-server-insiders, ~/.cursor-server, +# ~/.windsurf-server, /vscode/vscode-server (devcontainers), โ€ฆ โ€” matched by the +# "-server" component rather than a fixed list, so forks and odd layouts work. +_SERVER_HINT_VARS = ( + "VSCODE_AGENT_FOLDER", # the server root itself, when exported + "VSCODE_GIT_ASKPASS_NODE", # absolute path *into* the running server + "VSCODE_GIT_ASKPASS_MAIN", +) +_SERVER_FALLBACK_DIRS = { + "Code": (".vscode-server", ".vscode-server-insiders"), + "Cursor": (".cursor-server",), + "Windsurf": (".windsurf-server",), +} + + +def _looks_like_server_root(p: Path) -> bool: + if "-server" not in p.name: + return False + try: + return p.is_dir() and any((p / sub).is_dir() + for sub in ("data", "cli", "bin", "extensions")) + except OSError: + return False + + +def _remote_server_root(app: str) -> Path | None: + """Root of the editor *server* install when the UI runs elsewhere. + + Covers Remote-SSH, WSL, devcontainers and Codespaces โ€” anywhere the + terminal is on this machine but the window (and its User settings) is not. + Returns None for a plain local editor. + """ + for var in _SERVER_HINT_VARS: + raw = os.environ.get(var) + if not raw: + continue + candidate = Path(raw) + for p in (candidate, *candidate.parents): + if _looks_like_server_root(p): + return p + home = Path.home() + for name in _SERVER_FALLBACK_DIRS.get(app, ()): + p = home / name + if _looks_like_server_root(p): + return p + return None + + +def _machine_settings_path(server_root: Path) -> Path: + """Remote (server-side) Machine settings โ€” the 'Remote [SSH: host]' scope. + + The title key applies from here, which is what makes Remote-SSH setups + configurable at all from the machine CheetahClaws is installed on. + """ + return server_root / "data" / "Machine" / "settings.json" + + +def _resolve_target(app: str) -> tuple[Path | None, str, str]: + """Where to write the setting: (path, scope, why-not). + + scope is ``"remote"`` (server-side Machine settings) or ``"local"`` (this + machine's User settings). When path is None, `why-not` explains it and the + caller prints copy-paste instructions instead. + """ + server_root = _remote_server_root(app) + if server_root is not None: + return _machine_settings_path(server_root), "remote", "" + path = _settings_path(app) + if path is None: + return None, "", f"couldn't locate {app} settings.json on this platform" + if not path.parent.exists(): + # The editor creates its own User dir; if it isn't here, no local + # install is either โ€” never fabricate a settings.json nothing reads. + return None, "", f"no local {app} install found on this machine" + return path, "local", "" + + # โ”€โ”€ JSONC helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def _strip_jsonc(text: str) -> str: @@ -206,30 +295,74 @@ def _print(msg: str) -> None: print(" " + msg) +def _target_key(target: Path | None, why: str) -> str: + return str(target) if target is not None else f"none:{why}" + + +def _already_attempted(key: str) -> bool: + """True only when we already ran for *this* target. + + The marker used to hold a bare timestamp, so any run that resolves a + different (or newly resolvable) target retries exactly once โ€” which is + what rescues machines whose first attempt wrote a file the editor never + reads, e.g. a Remote-SSH session configured before this was understood. + """ + try: + return json.loads((CONFIG_DIR / _MARKER).read_text()).get("target") == key + except Exception: + return False + + +def _mark_attempted(key: str) -> None: + try: + marker = CONFIG_DIR / _MARKER + marker.parent.mkdir(parents=True, exist_ok=True) + marker.write_text(json.dumps({"ts": int(time.time()), "target": key})) + except Exception: + pass + + +def _print_manual_instructions(app: str, why: str) -> None: + _print(f"{app} tab titles need one setting, but {why} โ€” its User settings " + "live on the machine running the window.") + _print("Add this to that machine's settings.json " + "(Preferences: Open User Settings (JSON)):") + _print(f' "{_TITLE_KEY}": "{_TITLE_VAL}"') + _print("Then open a new terminal. Re-check with /terminal-setup, or turn " + "the feature off with /config terminal_title=false.") + + +def _scope_label(scope: str) -> str: + return ("remote (Machine) settings โ€” the window reads them from this side" + if scope == "remote" else "User settings") + + def maybe_setup_vscode_terminal_title(config: dict) -> None: - """Auto-run once on first launch inside a VS Code-family terminal. + """Auto-run once per target on launch inside a VS Code-family terminal. No-op unless: terminal_title is enabled, we're in VS Code/Cursor/Windsurf, - and we haven't already tried. Any failure is swallowed.""" + and we haven't already run for this settings target. Any failure is + swallowed.""" try: if not config.get("terminal_title", True): return app = _vscode_app() if not app: return - marker = CONFIG_DIR / _MARKER - if marker.exists(): + target, scope, why = _resolve_target(app) + key = _target_key(target, why) + if _already_attempted(key): return - path = _settings_path(app) - changed, msg = (False, "no settings path") if path is None \ - else _apply_to_settings(path) - # Mark as attempted regardless, so we never re-touch the file on later - # launches (manual /terminal-setup remains available to re-run). - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text(str(int(time.time()))) + if target is None: + _mark_attempted(key) + _print_manual_instructions(app, why) + return + changed, msg = _apply_to_settings(target) + _mark_attempted(key) if changed: - _print(f"Set up {app} terminal tab titles โ€” {msg}.") - _print("Reopen the terminal to see the task in the tab. " + _print(f"Set up {app} terminal tab titles in {_scope_label(scope)} " + f"โ€” {msg}.") + _print("Open a NEW terminal to see the task in the tab. " "Disable any time with /config terminal_title=false.") except Exception: pass @@ -244,19 +377,16 @@ def run_terminal_setup(force: bool = False) -> None: "Terminal.app / most terminals) โ€” no setup needed.") _print("Nothing to configure here.") return - path = _settings_path(app) - if path is None: - _print(f"Couldn't locate {app} settings.json on this platform.") + target, scope, why = _resolve_target(app) + if target is None: + _print_manual_instructions(app, why) + _mark_attempted(_target_key(target, why)) return - changed, msg = _apply_to_settings(path) + changed, msg = _apply_to_settings(target) # Refresh the marker so the auto-path stays quiet afterwards. - try: - (CONFIG_DIR / _MARKER).parent.mkdir(parents=True, exist_ok=True) - (CONFIG_DIR / _MARKER).write_text(str(int(time.time()))) - except Exception: - pass - _print(f"{app}: {msg}") + _mark_attempted(_target_key(target, why)) + _print(f"{app} ({_scope_label(scope)}): {msg}") if changed: - _print("Reopen the terminal (or window) for the tab title to update.") + _print("Open a new terminal (or window) for the tab title to update.") elif "already" in msg: - _print("You're all set โ€” reopen a terminal if the tab isn't showing it.") + _print("You're all set โ€” open a new terminal if the tab isn't showing it.") diff --git a/docs/architecture.md b/docs/architecture.md index 8afd6563..d92923cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,7 +108,7 @@ internal structure. | [`tools/`](../cheetahclaws/tools) | All built-in LLM-callable tools. `tools/__init__.py` holds `TOOL_SCHEMAS`, calls `_register_builtins()`, and imports extension modules. One file per category: `fs.py`, `shell.py`, `web.py`, `notebook.py`, `diagnostics.py`, `security.py`, `interaction.py`, plus optional `browser.py`, `email.py`, `files.py`. | | [`commands/`](../cheetahclaws/commands) | Slash-command handlers. `core.py` (help/clear/context/cost/โ€ฆ), `config_cmd.py` (model/config/permissions), `session.py` (save/load/resume), `advanced.py` (brainstorm/worker/ssj/memory/agents/skills/mcp/plugin/tasks โ€” `/brainstorm` runs a lead-moderated multi-round adversarial debate; see [`docs/guides/brainstorm.md`](guides/brainstorm.md)), `checkpoint_plan.py` (checkpoint/rewind/plan), `agent_cmd.py` (/agent), `monitor_cmd.py` (subscribe/monitor). | | [`bridges/`](../cheetahclaws/bridges) | External messaging adapters: `telegram.py`, `wechat.py`, `slack.py`, plus shared `interactive_session.py` and `terminal_runner.py`. | -| [`ui/`](../cheetahclaws/ui) | Terminal rendering โ€” `input.py` (prompt_toolkit / readline), `render.py` (rich Markdown, ANSI helpers, spinners, status line). | +| [`ui/`](../cheetahclaws/ui) | Terminal rendering โ€” `input.py` (prompt_toolkit / readline; slash completion + ghost text), `suggest.py` (auxiliary-model draft of the likely next prompt, pushed into `input.py` as ghost text), `render.py` (rich Markdown, ANSI helpers, spinners, status line). | | [`web/`](../cheetahclaws/web) | Optional self-hosted web UI (FastAPI-style โ€” xterm.js frontend, SQLite session store, per-user auth). Enabled by `[web]` extra. | | [`memory/`](../cheetahclaws/memory) | Persistent memory across sessions โ€” `store.py` (CRUD), `scan.py`/`context.py` (index + freshness), `consolidator.py` (`/memory consolidate`), `tools.py` (`MemorySave` / `MemoryDelete` / `MemorySearch` / `MemoryList`). | | [`multi_agent/`](../cheetahclaws/multi_agent) | Sub-agent subsystem. `subagent.py` owns `SubAgentManager` (ThreadPoolExecutor), depth gating, git-worktree isolation; `tools.py` exposes `Agent` / `SendMessage` / `CheckAgentResult` / `ListAgentTasks` / `ListAgentTypes`. | @@ -536,11 +536,15 @@ multi-user history; the two don't share state today. The REPL loop: 1. Read input (via `ui.input.read_input` โ€” prompt_toolkit when - available, else readline). + available, else readline). Under prompt_toolkit the prompt also + carries ghost text: the predicted next message published by + `ui.suggest` (Tab accepts), falling back to shell history. 2. If it starts with `/`, dispatch via the `COMMANDS` dict. 3. Otherwise, call `agent.run()` and render the event stream with `ui.render`. -4. After every turn, run checkpoint snapshot (throttled). +4. After every turn, run checkpoint snapshot (throttled), autosave the + session, and โ€” foreground turns only โ€” kick off `ui.suggest.schedule()` + on a background thread to draft the next prompt's ghost text. 5. Handle Ctrl+C (3ร— within 2 s triggers `os._exit(1)` to escape stuck I/O). diff --git a/docs/guides/features.md b/docs/guides/features.md index 33db52cb..37050b1c 100644 --- a/docs/guides/features.md +++ b/docs/guides/features.md @@ -9,6 +9,7 @@ and indexed in the [README Documentation section](../../README.md#documentation) |---|---| | Multi-provider | Anthropic ยท OpenAI ยท Gemini ยท Kimi ยท Qwen ยท Zhipu ยท DeepSeek ยท MiniMax ยท Ollama ยท LM Studio ยท Custom endpoint | | Interactive REPL | readline history, Tab-complete slash commands with descriptions + subcommand hints; Bracketed Paste Mode for reliable multi-line paste | +| Next-prompt ghost text | After every foreground turn the **auxiliary** (cheap/fast) model drafts the single line you are most likely to type next โ€” from the last ~4 messages, capped at one short sentence, in the language you have been writing โ€” and it appears dim/italic in the empty prompt. **Tab** (or **โ†’**) accepts it in full; typing types over it; Enter alone never submits it; erasing back to empty brings it back. Drafting runs on a background daemon thread so the prompt is never delayed, is silent on any failure (no auxiliary model / no key / provider down โ†’ no ghost), and is one-shot per prompt, with a generation counter so a slow draft can't overwrite a newer turn's. Slash completion is unaffected โ€” an open `/cmd` menu still owns Tab. Config: `input_suggest` (default `true`), `/config input_suggest=false` or `CHEETAH_SUGGEST=0` to disable. [Details](reference.md#next-prompt-ghost-text) | | Agent loop | Streaming API + automatic tool-use loop | | 28 built-in tools | Read ยท Write ยท Edit ยท Bash ยท Glob ยท Grep ยท WebFetch ยท WebSearch ยท **NotebookEdit** ยท **GetDiagnostics** ยท MemorySave ยท MemoryDelete ยท MemorySearch ยท MemoryList ยท **MemoryVerify** ยท Agent ยท SendMessage ยท CheckAgentResult ยท ListAgentTasks ยท ListAgentTypes ยท Skill ยท SkillList ยท AskUserQuestion ยท TaskCreate/Update/Get/List ยท **SleepTimer** ยท **EnterPlanMode** ยท **ExitPlanMode** ยท *(MCP + plugin tools auto-added at startup)* | | MCP integration | Connect any MCP server (stdio/SSE/HTTP), tools auto-registered and callable by Claude | diff --git a/docs/guides/reference.md b/docs/guides/reference.md index 86805343..7d01b37a 100644 --- a/docs/guides/reference.md +++ b/docs/guides/reference.md @@ -56,6 +56,7 @@ Type `/` and press **Tab** to see all commands with descriptions. Continue typin | `/config` | Show all current config values | | `/config key=value` | Set a config value (persisted to disk). v3.5.78+ parses JSON values: `["a","b"]`, `{"k":"v"}`, signed numbers, quoted strings โ€” list/dict configs no longer get silently saved as literal strings. | | `/config context_window=` | Override the context window (tokens) for the session. `0` = use the model's default. Drives the prompt `%` indicator, `/context`, the compaction trigger, **and** the per-call output-token cap โ€” all consistently. Distinct from `max_tokens` (which is the **output** cap, not the window). Bidirectional: a smaller value forces earlier compaction; a larger value corrects a stale default. Read live, so it takes effect on the next prompt (no restart). Warns if set above the model's real window (that would disable compaction and the API may reject oversized prompts). | +| `/config input_suggest=` | Turn the [next-prompt ghost text](#next-prompt-ghost-text) on/off (default `true`). When on, the auxiliary model drafts your likely next message after each turn and shows it dim at the prompt; **Tab** accepts it. `CHEETAH_SUGGEST=0` disables it for a single run without touching the saved config. | | `/config stream_mode=` | Force the Markdown streaming tier: `live` (full in-place Rich redraw), `commit` (append-only progressive Markdown โ€” safe over SSH / Apple Terminal / pipes), or `plain` (raw tokens). Unset = auto-detected per device (`ui.render.auto_stream_mode`). Legacy `/config rich_live=true\|false` still works (`true`โ†’`live`, `false`โ†’`commit`). | | `/save` | Save session (auto-named by timestamp) | | `/save ` | Save session to named file | @@ -280,7 +281,10 @@ The terminal window/tab title reflects what CheetahClaws is doing โ€” a **pulsin - **Config:** `terminal_title` (default `true`). Turn it off with `/config terminal_title=false` to leave the shell's own title untouched. Auto-disabled on non-TTYs, pipes, CI, and `TERM=dumb`, so escape bytes never leak into redirected output. - **iTerm2 / Terminal.app / most terminals:** works out of the box โ€” they display OSC titles in the tab/title bar by default. -- **VS Code / Cursor / Windsurf:** these hide program-set titles by default (the tab shows `${process}`). On **first launch** CheetahClaws configures `terminal.integrated.tabs.title` for you โ€” once, with a backup and a re-parse safety check, and never overwriting a value you already set. Reopen the terminal for it to take effect. Run [`/terminal-setup`](#slash-commands-repl) any time to re-apply, or set it by hand: +- **VS Code / Cursor / Windsurf:** these hide program-set titles by default (the tab shows `${process}`). On **first launch** CheetahClaws configures `terminal.integrated.tabs.title` for you โ€” once per settings target, with a backup and a re-parse safety check, and never overwriting a value you already set. Open a **new** terminal for it to take effect. Run [`/terminal-setup`](#slash-commands-repl) any time to re-apply. +- **Remote-SSH / WSL / devcontainers / Codespaces:** the window (and its User settings) lives on *your* machine while CheetahClaws runs on the server, so the server-side `~/.config/Code/User/settings.json` is a file the editor never reads. The setup detects the server install and writes the **remote Machine settings** instead โ€” `/data/Machine/settings.json`, the "Remote [SSH: host]" scope โ€” which the window does read, so these setups configure themselves too. If neither target is reachable, CheetahClaws prints the one line to paste into the UI machine's settings rather than writing a file that does nothing. + + Set it by hand if you prefer: ```jsonc // VS Code settings.json @@ -289,6 +293,19 @@ The terminal window/tab title reflects what CheetahClaws is doing โ€” a **pulsin --- +## Next-Prompt Ghost Text + +After every foreground turn, the **auxiliary** (cheap/fast) model drafts the one line you are most likely to type next, and it appears **dim/italic in the empty prompt** โ€” the same ghost-text slot the shell-history suggestion uses. + +- **Accept it:** **Tab** or **โ†’** inserts it in full. Typing anything else simply types over it, and **Enter on an untouched ghost submits nothing** โ€” it is a hint, never pre-filled input. Type a matching prefix (`run ` for `run the tests`) and Tab completes the rest; erase your line back to empty and the ghost comes back. +- **What it drafts:** one line, under ~12 words, phrased as *you* (imperative, first-person), in whichever language you have been writing โ€” built from the last ~4 messages of the conversation. Replies that are too long, multi-line, or that start explaining instead of impersonating you are discarded rather than shown. +- **It never blocks you:** drafting runs on a background daemon thread, so the prompt appears immediately and the ghost shows up when it is ready. Any failure is silent โ€” no auxiliary model, no API key, or a provider outage just means no ghost. A prediction is one-shot (consumed by the prompt it was shown at) and each new turn clears the previous one, so a stale suggestion is never displayed. +- **Slash commands are unaffected:** while a `/cmd` completion menu is open, Tab still drives the menu. +- **Config:** `input_suggest` (default `true`). `/config input_suggest=false` disables it persistently; `CHEETAH_SUGGEST=0` disables it for one run. It costs one small [auxiliary-model](#auxiliary-model) call per turn โ€” set `auxiliary_model` to a cheap model if that matters. +- **Requires `prompt_toolkit`** (a core dependency since v3.5.85). The readline fallback path has no ghost text. + +--- + ## Configuring API Keys ### Method 1: Environment Variables (recommended) @@ -596,7 +613,7 @@ Sessions are automatically indexed when saved. Legacy JSON sessions are auto-imp ## Auxiliary Model -Side tasks like context compression use a fast, cheap model instead of your primary model. This saves cost and speeds up compaction. +Side tasks like context compression, session titles, and [next-prompt ghost text](#next-prompt-ghost-text) use a fast, cheap model instead of your primary model. This saves cost and speeds up compaction. **Auto-detection order** (first available wins): 1. `config["auxiliary_model"]` (if explicitly set) diff --git a/docs/i18n/README.CN.MD b/docs/i18n/README.CN.MD index 241ad0b4..6961177d 100644 --- a/docs/i18n/README.CN.MD +++ b/docs/i18n/README.CN.MD @@ -41,6 +41,7 @@ cheetahclaws # start chatting! ## ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ ๆ–ฐ้—ป๏ผˆๅคชๅนณๆด‹ๆ—ถ้—ด๏ผ‰ +- 2026 ๅนด 7 ๆœˆ 30 ๆ—ฅ๏ผˆ**v3.5.86**๏ผ‰๏ผš**่พ“ๅ…ฅๆก†ใ€Œๅนฝ็ตๆ็คบใ€โ€”โ€” REPL ้ข„ๆต‹ไฝ ไธ‹ไธ€ๅฅ่ฆ่พ“ๅ…ฅ็š„ๅ†…ๅฎนใ€‚** ๆฏ่ฝฎๅ›ž็ญ”็ป“ๆŸๅŽ๏ผŒ็”ฑ**่พ…ๅŠฉ๏ผˆไพฟๅฎœ/ๅฟซ้€Ÿ๏ผ‰ๆจกๅž‹**่‰ๆ‹Ÿไฝ ๆœ€ๅฏ่ƒฝ่พ“ๅ…ฅ็š„ไธ‹ไธ€ๅฅ๏ผŒไปฅๆต…็ฐๆ–œไฝ“ๆ˜พ็คบๅœจๆ็คบ็ฌฆ้‡Œ๏ผšๆŒ‰ **Tab**๏ผˆๆˆ– **โ†’**๏ผ‰ๅฎŒๆ•ดๅกซๅ…ฅ๏ผŒ็›ดๆŽฅๆ‰“ๅญ—ๅณ่ฆ†็›–๏ผŒๅชๆŒ‰ๅ›ž่ฝฆไธไผšๆไบคๅฎƒใ€‚่‰ๆ‹ŸๅœจๅŽๅฐ็บฟ็จ‹่ฟ›่กŒ๏ผŒไธ้˜ปๅกž REPL๏ผ›ไปปไฝ•ๅคฑ่ดฅ้ƒฝ้™้ป˜ๅค„็†๏ผˆๆฒกๆœ‰่พ…ๅŠฉๆจกๅž‹ / ๆฒกๆœ‰ API key / ไพ›ๅบ”ๅ•†ๆ•…้šœ โ†’ ๅชๆ˜ฏไธๆ˜พ็คบๆ็คบ๏ผ‰๏ผ›ๆฏๆกๆ็คบๅชๅฏนๅฝ“ๅ‰่ฟ™ไธ€ไธชๆ็คบ็ฌฆๆœ‰ๆ•ˆ๏ผŒไธไผšๆฎ‹็•™ๆˆ่ฟ‡ๆœŸๅปบ่ฎฎใ€‚ๅ…ณ้—ญๆ–นๅผ๏ผš`/config input_suggest=false` ๆˆ– `CHEETAH_SUGGEST=0`ใ€‚ๆœฌ็‰ˆๆœฌ่ฟ˜ไฟฎๅคไบ† **Remote-SSH / WSL / devcontainer ไธ‹็ปˆ็ซฏๆ ‡็ญพๆ ‡้ข˜ๆ— ๆณ•่‡ชๅŠจ้…็ฝฎ**็š„้—ฎ้ข˜๏ผšๆญคๅ‰ๅฎƒๆŠŠ่ฎพ็ฝฎๅ†™่ฟ›ๆœๅŠกๅ™จ็ซฏไธ€ไธช VS Code ๆฐธ่ฟœไธไผš่ฏป็š„ๆ–‡ไปถๅนถไปŽๆญคไธๅ†้‡่ฏ•๏ผŒ็Žฐๅœจไผšๅ†™ๅ…ฅ็ช—ๅฃ็œŸๆญฃ่ฏปๅ–็š„่ฟœ็ซฏ Machine ่ฎพ็ฝฎใ€‚ๆœฌ็‰ˆๆœฌไนŸๆ˜ฏ้ฆ–ไธชๅŒ…ๅซ 7 ๆœˆ 11 ๆ—ฅ๏ผˆ็ปˆ็ซฏๆ ‡็ญพๆ ‡้ข˜ + Anthropic ๆ็คบ็ผ“ๅญ˜ไฟฎๅค๏ผ‰ไธŽ 7 ๆœˆ 20 ๆ—ฅ๏ผˆ`tool_profile` + bounded-I/O ไฟฎๅค๏ผ‰ๆ”นๅŠจ็š„ๆญฃๅผ็‰ˆๆœฌใ€‚[่ฏฆๆƒ…](../news.md) - 2026 ๅนด 7 ๆœˆ 9 ๆ—ฅ๏ผš**ๅฎ˜ๆ–น Docker ้•œๅƒ + ไธ€ๆกๅ‘ฝไปคๅ‘ๅธƒใ€‚** Docker Hub ไธŠๆไพ›้ข„ๆž„ๅปบ้•œๅƒ๏ผˆ`docker pull chauncygu/cheetahclaws`๏ผ‰๏ผŒๆ— ้œ€ๅ…‹้š†ๅณๅฏ่ฟ่กŒ Web UI๏ผ›ไฟฎๅคไบ†้ฆ–ๆฌก่ฟ่กŒๆ—ถ็š„ `PermissionError`๏ผŒๆ–นๆณ•ๆ˜ฏ้ข„ๅ…ˆๅˆ›ๅปบ็”ฑ้ž root ็”จๆˆทๆ‰€ๆœ‰็š„ `.cheetahclaws`/`workspace` ็›ฎๅฝ•๏ผŒไฝฟ compose ็š„ `image` ๅฏ้€š่ฟ‡ `CHEETAH_IMAGE` ่ฆ†็›–๏ผŒๅนถๆ–ฐๅขž `scripts/docker-publish.sh`๏ผˆ่‡ชๅŠจ่ฏปๅ–็‰ˆๆœฌ๏ผŒๆ”ฏๆŒๅคš/ๅ•ๆžถๆž„๏ผ‰ใ€‚ๆ–ฐๅขžๆ–‡ๆกฃ็ซ ่Š‚๏ผš**ไปŽ Docker Hub ๆ‹‰ๅ–** ไธŽ **ไบคไบ’ๅผ่ฎพ็ฝฎ / CLI ๆจกๅผ**ใ€‚[่ฏฆๆƒ…](../news.md) - 2026 ๅนด 7 ๆœˆ 8 ๆ—ฅ๏ผšๆ–ฐๅขž **`/workspace`** ๅ‘ฝไปค๏ผŒ็”จไบŽ็ฎก็† `~/.cheetahclaws/workspaces` ไธ‹็š„้š”็ฆปๅทฅไฝœ็›ฎๅฝ•๏ผˆ`list`/`switch`/`default`/`create`/`delete`๏ผ‰๏ผˆPR #162๏ผ‰๏ผ›ๅฏๅŠจๆ—ถ่‡ชๅŠจๅˆ‡ๆข็Žฐไธบ้€š่ฟ‡ `workspace_auto` **ๅฏ้€‰ๅผ€ๅฏ**๏ผˆ้ป˜่ฎคๅ…ณ้—ญ๏ผŒๅ› ๆญคๅœจ้กน็›ฎ็›ฎๅฝ•ไธญๅฏๅŠจ็š„่กŒไธบไฟๆŒไธๅ˜๏ผ‰๏ผŒไธ” `default` ็Žฐๅœจๆ˜ฏไธ€ไธช็‹ฌ็ซ‹ไบŽใ€Œๆœ€่ฟ‘ไฝฟ็”จใ€็š„ๅ›บๅฎš้”ฎใ€‚[่ฏฆๆƒ…](../news.md) - 2026 ๅนด 7 ๆœˆ 6 ๆ—ฅ๏ผˆ**v3.5.84**๏ผ‰๏ผš**`/image` ็Žฐๅœจไผš็”จๆœฌๅœฐ OCR ๆ–‡ๆœฌไธฐๅฏŒๆ็คบ่ฏ**๏ผŒ่ฎฉๅณไฝฟๆ˜ฏ้ž่ง†่ง‰ๆจกๅž‹ไนŸ่ƒฝๅค„็†ๅ‰ช่ดดๆฟๆˆชๅ›พ๏ผˆ้”™่ฏฏ่ฝฌๅ‚จใ€ไปฃ็ ใ€่กจๆ ผ๏ผ‰๏ผ›ไป…ๅœจๅฎ‰่ฃ…ไบ† `pytesseract`/`tesseract` ๆ—ถ่ฟ่กŒ๏ผŒๅนถๅฏ้€š่ฟ‡ `CHEETAHCLAWS_IMAGE_OCR=0` ๅฎŒๅ…จๅ…ณ้—ญใ€‚[่ฏฆๆƒ…](../news.md) @@ -179,6 +180,7 @@ Claude Code ๆ˜ฏไธ€ๆฌพๅผบๅคง็š„ใ€็”Ÿไบง็บง็š„ AI ็ผ–็ ๅŠฉๆ‰‹ โ€”โ€” ไฝ†ๅฎƒ็š„ | ๆƒ้™็ณป็ปŸ | `auto` / `accept-edits` / `accept-all` / `manual` / `plan` ๆจกๅผ๏ผˆ`accept-edits` = ่‡ชๅŠจๆ‰ง่กŒ็ผ–่พ‘๏ผŒไฝ†ๅฏนๅ…ถไป– Bash ไปไผš่ฏข้—ฎ๏ผ›็กฌๆ€งๆ‹’็ปๅˆ—่กจๅœจๆ‰€ๆœ‰ๆจกๅผไธ‹้ƒฝไผš้˜ปๆญขไผšๆฏๅไธปๆœบ็š„ๅ‘ฝไปค๏ผ‰ | | ๆฃ€ๆŸฅ็‚นไธŽ plan ๆจกๅผ | ๆฏไธ€่ฝฎ่‡ชๅŠจๅฟซ็…งๅฏน่ฏ + ๆ–‡ไปถ๏ผˆ`/checkpoint`ใ€`/rewind`๏ผ‰๏ผ›`/plan` ๅช่ฏปๅˆ†ๆžๆจกๅผ | | ๆ–œๆ ๅ‘ฝไปคไธŽไธป้ข˜ | 50+ ไธชๅธฆ Tab ่กฅๅ…จ็š„ๆ–œๆ ๅ‘ฝไปค๏ผ›`/theme` ๆไพ› 15 ๅฅ—็ฒพ้€‰้…่‰ฒ | +| ไธ‹ไธ€ๅฅ่พ“ๅ…ฅ้ข„ๆต‹๏ผˆๅนฝ็ตๆ–‡ๅญ—๏ผ‰ | ๆฏ่ฝฎ็ป“ๆŸๅŽ็”ฑ่พ…ๅŠฉ๏ผˆไพฟๅฎœ๏ผ‰ๆจกๅž‹่‰ๆ‹Ÿไฝ ๆœ€ๅฏ่ƒฝ่พ“ๅ…ฅ็š„ไธ‹ไธ€ๅฅ๏ผŒๆต…่‰ฒๆ˜พ็คบๅœจๆ็คบ็ฌฆ้‡Œ โ€”โ€” **Tab**๏ผˆๆˆ– **โ†’**๏ผ‰ๅฎŒๆ•ดๅกซๅ…ฅ๏ผŒ็›ดๆŽฅๆ‰“ๅญ—ๅณๅฟฝ็•ฅใ€‚ๅŽๅฐ่‰ๆ‹Ÿ๏ผŒไธ้˜ปๅกž REPL๏ผŒๅคฑ่ดฅ้™้ป˜ใ€‚ๅฏ้€š่ฟ‡ `/config input_suggest=false` ๆˆ– `CHEETAH_SUGGEST=0` ๅ…ณ้—ญใ€‚[่ฏฆๆƒ…](../guides/reference.md#next-prompt-ghost-text) | | Brainstorm โ†’ Worker | `/brainstorm` ่ฟ่กŒไธ€ๅœบ N ่ง’่‰ฒ่พฉ่ฎบ โ†’ `todo_list.txt`๏ผ›`/worker` ่‡ชๅŠจๅฎž็Žฐๅพ…ๅŠžไปปๅŠก | | SSJ ๅผ€ๅ‘่€…ๆจกๅผ | `/ssj` โ€”โ€” ๆŒไน…ๅŒ–็š„ๅผบๅŠ›่œๅ•๏ผŒไธฒ่” Brainstormใ€Workerใ€Reviewใ€Tradingใ€Agentใ€Video/TTSใ€Monitor ็ญ‰ | | ไบคๆ˜“ agent | `/trading` ๅคš agent ๅˆ†ๆžใ€ๅ›žๆต‹ใ€ๆจกๆ‹Ÿ็›˜ๆ กๅ‡†ใ€MV ็ป„ๅˆใ€‚[ๆŒ‡ๅ—](../guides/trading.md) | diff --git a/docs/news.md b/docs/news.md index 01c2fde2..307620db 100644 --- a/docs/news.md +++ b/docs/news.md @@ -3,6 +3,9 @@ ## ๐Ÿ”ฅ๐Ÿ”ฅ๐Ÿ”ฅ News (Pacific Time) +- July 30, 2026 (**v3.5.86**): **Next-prompt ghost text โ€” the REPL predicts the line you'd type next, Tab accepts it.** Claude Code leaves a dim suggestion sitting in the empty input box after each reply; CheetahClaws now does the same. **(1) Where the text comes from.** At the end of every *foreground* turn, `run_query` calls the new [`ui/suggest.py`](../cheetahclaws/ui/suggest.py)`::schedule()`, which flattens the last ~4 user/assistant messages (tool-use blocks dropped, each turn truncated to 1500 chars) and asks the **auxiliary** cheap/fast model โ€” the same router compaction uses, `auxiliary.py` โ€” for the single most probable next *user* message: one line, under 12 words, imperative and first-person, in whichever language the user has been writing, or the literal `NONE` when nothing is plausibly next. It runs on a background daemon thread, so the prompt is never delayed and the draft lands whenever it lands; every failure path is silent (no auxiliary model, no API key, provider down โ†’ simply no ghost). A generation counter drops a slow draft from turn N once turn N+1 has started, and each new turn clears the previous ghost immediately, so a stale prediction is never left on screen. The reply is cleaned before it can be displayed: first line only, surrounding quotes/backticks/bullets stripped, rejected outright if it exceeds 90 chars or if the model starts explaining itself (`Sure, โ€ฆ`, `The user โ€ฆ`) instead of impersonating the user. Background turns (Telegram/WeChat/Slack/QQ, proactive events) never draft one โ€” they don't own the prompt. **(2) How it renders, and how you accept it.** [`ui/input.py`](../cheetahclaws/ui/input.py) gained a thread-safe pending-suggestion store (written by the drafting thread, read by the prompt_toolkit event loop) and `PredictiveAutoSuggest`: the whole prediction is offered on an empty buffer, the remainder keeps being offered while what you typed still prefixes it, and anything else falls back to the existing shell-history suggestion. It reuses the dim-italic `auto-suggestion` style already in the session, and the **Tab** binding that already accepted history ghosts now accepts these too (**โ†’** works natively). Two prompt_toolkit details drove the design: the auto-suggester is consulted only on text *insert*, so an empty prompt is never asked at all โ€” the prediction is therefore applied synchronously at `pre_run` and on every `on_text_changed`, which additionally makes the ghost exact when a fast Tab would otherwise beat the async pass, and brings it back when you erase your line to empty. The prediction is one-shot: `read_line()` consumes it on return, so it can never leak into a later prompt. Slash completion is untouched โ€” an active completion menu still suppresses ghost acceptance, so `/cmd` + Tab behaves exactly as before. **(3) Control and cost.** New `input_suggest` config key (default `true`): `/config input_suggest=false` disables it persistently, `CHEETAH_SUGGEST=0` for a single run. It costs one small auxiliary call per turn โ€” point `auxiliary_model` at a cheap model (or disable the feature) if that matters on your setup. **(4) Tests.** New `tests/test_input_suggest.py` โ€” 29 cases covering the pending store, prediction-vs-history precedence, cursor/multi-line suppression, draft cleaning (including CJK), transcript flattening, disable switches, auxiliary failure, and superseded-draft staleness, plus **end-to-end tests that drive a real `prompt_toolkit` session over a pipe** and assert the ghost actually renders, Enter alone never submits it, Tab accepts it whole, a typed prefix still completes, erasing brings it back, and `/cmd` Tab is not hijacked. Full suite: **2610 passed, 5 skipped**. Version bumped `3.5.85` โ†’ **`3.5.86`** in `pyproject.toml`; this is also the first tagged release to carry the July 11 (terminal tab title + Anthropic prompt-cache) and July 20 (`tool_profile` + bounded-I/O) changes, which landed untagged after v3.5.85. **Not a breaking change** โ€” with no auxiliary model reachable the REPL behaves exactly as it did before. + + **Also in v3.5.86 โ€” the terminal tab title now configures itself over Remote-SSH.** The July 11 tab title works out of the box in iTerm2 / Terminal.app / GNOME Terminal / Windows Terminal, and VS Code-family terminals get a one-time auto-setup of `terminal.integrated.tabs.title`. But that setup only ever considered a **local** editor install: in a **Remote-SSH / WSL / devcontainer / Codespaces** session the window โ€” and the User settings it reads โ€” live on the user's own machine, while CheetahClaws runs on the server, so it wrote `~/.config/Code/User/settings.json` *on the server*, fabricating a file the editor never reads (creating `~/.config/Code/` from scratch when no editor was installed there at all), printed a success message, and wrote a one-shot marker that stopped it from ever retrying. Result: a silently broken tab title with no way back short of finding the marker. `ui/vscode_setup.py` now resolves the target properly. A new `_remote_server_root()` locates the editor **server** install โ€” walking the absolute paths VS Code exports into it (`VSCODE_AGENT_FOLDER`, `VSCODE_GIT_ASKPASS_NODE`/`_MAIN`) for a `-server` directory that actually contains `data`/`cli`/`bin`/`extensions`, falling back to `~/.vscode-server`, `~/.vscode-server-insiders`, `~/.cursor-server`, `~/.windsurf-server` โ€” and when one is found the key goes into the **remote Machine settings** (`/data/Machine/settings.json`, the "Remote [SSH: host]" scope), which the window *does* read; verified end-to-end on a live Remote-SSH session. The local path is kept for genuine local installs but now requires the editor's own User directory to already exist, so a settings file nothing reads is never fabricated again; when neither target is reachable, CheetahClaws prints the single line to paste into the UI machine's settings instead. The marker became **target-scoped** (`{"ts": โ€ฆ, "target": โ€ฆ}`), so a machine whose target changes retries โ€” and the old bare-timestamp marker triggers exactly one retry, which self-heals every session stuck by the original bug. `/terminal-setup` follows the same resolution and now reports which scope it wrote. New `tests/test_vscode_setup.py` (19 cases; the module had none) covers server detection incl. forks and lookalike `-server` directories, target resolution and precedence, the never-fabricate regression, JSONC comment preservation, marker scoping and legacy-marker retry, and the auto path end-to-end. - July 20, 2026: **Bounded-I/O fixes and a configurable tool surface.** Two things. **(1) `tool_profile` config.** Every model request ships the JSON schemas of the tools the agent may call; the new `tool_profile` selects how much of that surface is advertised each turn โ€” a smaller surface means fewer prompt tokens and less for a weak or small-context model to choose between. Four values: **`full`** (default โ€” everything registered: coding, web/documents, multi-agent + tasks, plan mode, email, MCP, and plugins), **`standard`** (compact coding set only โ€” `Read`/`Write`/`Edit`/`Bash`/`Glob`/`Grep`/`GetDiagnostics`/`NotebookEdit`/`AskUserQuestion` and the `Memory*` tools), **`research`** (`standard` **+** `WebFetch`/`WebSearch`/`WebBrowse`/`Research`/`ReadPDF`/`ReadImage`/`ReadSpreadsheet`/`ReadEmail`/`SummarizeLargeFile`), and **`orchestration`** (`standard` **+** `Agent`/`SendMessage`/`CheckAgentResult`/`ListAgentTasks`/`ListAgentTypes`/`Skill`/`SkillList`/`Task*`/`EnterPlanMode`/`ExitPlanMode`/`SleepTimer`). Every non-`full` profile still includes the `standard` coding tools, so narrowing the surface never costs you Read/Write/Edit/Bash. Switch with `/config tool_profile=standard`, the Web UI **Tool Surface** dropdown, or `PATCH /api/config` (an unknown value is rejected โ€” `400` on the API, an error on the CLI). The default is `full` and a config that omits the key **inherits `full`**, so upgrading never silently drops a capability a user relied on; sub-agents inherit the parent session's profile. The selected profile is applied consistently across the provider tool schemas, execution dispatch, the system prompt's *Active Tool Surface* block, `/config` validation, the Web API, and the read-only tool-result cache key. **(2) Two bounded-I/O regression fixes.** `SummarizeLargeFile` recorded a failed map chunk as an error-marker *string* (`[chunk-summarize error: โ€ฆ]`), not `None`, so the reduce stage neither skipped it nor warned โ€” a file whose chunks all failed came back as a confident "summary" of the error text. It now detects those markers, keeps them out of the reduce prompt, warns on incomplete coverage (distinguishing failed chunks from reduce-cap drops), and returns a clean `Error` when every chunk fails, when the reduce call fails, or when a single-shot summary fails. Separately, the DuckDuckGo result parser called `dict(attrs).get("class", "").split()`, which returns `None` (crashing the *entire* search) on a valueless `class` attribute such as `
`; it is now guarded with `or ""`. Adds regression tests to `tests/test_summarize_large_file.py` and `tests/test_bounded_tool_io.py` and regenerates the golden prompt fixture (made order-independent under the `full` default); full suite green (**2570 passed, 5 skipped**). New docs: a **Tool Profiles** section in [docs/guides/usage.md](guides/usage.md#tool-profiles-tool_profile), and the `/api/config` writable-keys list in [docs/guides/web-ui.md](guides/web-ui.md) now includes `tool_profile`. **Not a breaking change** โ€” the default tool surface is unchanged (`full`), and the summarize/parser fixes only affect failure paths. - July 11, 2026: **Claude-Code-style terminal tab title, and a cross-turn fix for the Anthropic prompt cache.** Two changes. **(1) Animated terminal tab title.** The terminal window/tab title now tracks the live task instead of showing the shell default: a pulsing glyph + the user's current prompt while the model works (`โœถ โœณ โœป CheetahClaws โ€” `), and a static badge when idle (`โ— CheetahClaws โ€” `). It is emitted as an **OSC 0** escape sequence in lock-step with the existing spinner thread (no extra thread), de-duped so the tab is only rewritten when the glyph or task actually changes, and **auto-disabled on non-TTYs / pipes / CI / `TERM=dumb`** so escape bytes never leak into redirected output. Toggle with `/config terminal_title=false`. It works out of the box in **iTerm2 / Terminal.app / most terminals**, which show OSC titles by default. **VS Code / Cursor / Windsurf hide program-set titles by default** โ€” the tab renders `${process}` and the program title lands in the ignored `${sequence}` variable โ€” so on **first launch inside one of those editors** CheetahClaws configures `terminal.integrated.tabs.title` for the user, exactly once (a `~/.cheetahclaws/vscode_terminal_title.done` marker prevents re-nagging). That edit is deliberately conservative: it **backs up settings.json**, inserts the key **textually so JSONC comments and formatting survive**, then **re-parses the result and aborts if any key would be dropped or the file would not parse**, and it **never overwrites a value the user already set**. The new **`/terminal-setup`** command re-runs it on demand and reports "nothing to do" in terminals that already show titles natively. Implemented in `ui/render.py` (OSC-0 title module + a hook in the spinner loop) and `ui/vscode_setup.py` (the safe settings editor), wired at REPL start and per-turn in `cli.py`, with the `terminal_title` config default (on) and the `/terminal-setup` command. **(2) Prompt-cache prefix fix.** The Anthropic `cache_control` breakpoint was placed at the end of the *whole* system string. Because CheetahClaws rebuilds the system prompt each turn and its `# Environment` block embeds a live `git status`, editing files between turns changed that block and invalidated the entire cached system prefix โ€” dragging the large, static base prompt down with it. (Within-turn caching, the tool loop's 5โ€“50 back-to-back calls, was unaffected: the system prompt is frozen for the turn.) The breakpoint now sits on the **stable span *before* the environment block**; the two system text blocks concatenate byte-for-byte, so the model sees identical content โ€” purely a caching-boundary change โ€” and the volatile tail rides along uncached. Adds 2 tests to `tests/test_prompt_cache.py`. Full suite green (**2508 passed, 8 skipped**; the 2 pre-existing macOS-only failures are unchanged). **Not a breaking change** โ€” the terminal title is additive and disable-able, and the cache change is transparent to output. See [docs/guides/reference.md](guides/reference.md#terminal-tab-title) ยท [docs/guides/features.md](guides/features.md). - July 10, 2026 (**v3.5.85**): **REPL quality-of-life โ€” completion works on every install, `/model` gets a Tab picker, and sessions autosave every turn.** Three related changes. **(1) `prompt_toolkit` is now a core dependency.** The typing-time completion menu (slash commands, subcommands, the new `/model` picker) is driven by `prompt_toolkit`, but it was an *optional* extra (`[autosuggest]`), so only environments that happened to already have it โ€” e.g. a fat Anaconda base โ€” got the rich experience; a clean `pip install cheetahclaws` or an isolated `uv tool install cheetahclaws` fell back to bare readline (Tab-only, no live dropdown). Since the interactive REPL *is* the product, `prompt_toolkit>=3.0.43` moved from `[project.optional-dependencies].autosuggest` into `[project].dependencies` (and into the core block of `requirements.txt`), so **every** install method now gets live completion out of the box. The readline fallback path in `ui/input.py` is untouched โ€” it still covers any environment where `prompt_toolkit` genuinely can't be installed. The `[autosuggest]` extra is kept as a harmless no-op alias for backward compatibility. **(2) `/model` dynamic completion (PR #166).** Typing `/model ` and pressing Tab now offers a `provider/model` picker โ€” one default model per configured provider plus a two-level `litellm//` tree you can drill into โ€” instead of forcing you to remember and hand-type long model strings. Completions are context-aware (`/model openai/g` narrows to OpenAI models; `litellm/openrouter/` expands that backend). Wired into both the `prompt_toolkit` and readline completers via a new dynamic-completions registry; the `litellm` provider also gained a small curated starting model list to seed the picker (any valid LiteLLM string still works regardless of the list). **(3) Per-turn crash-safe session autosave.** Previously the live transcript was written to `session_latest.json` only on a clean exit / `Ctrl+C` / budget-pause, so a power-loss or hard kill mid-conversation lost everything since the session started (file edits and explicit `/remember` writes were already immediate, so only the transcript was at risk). A new `autosave_session()` (in `commands/session.py`) is now called at the **end of every turn** in `run_query`: it rewrites *only* `session_latest.json` via a temp file + `flush()` + `os.fsync()` + atomic `os.replace()` (durable against a power cut, and a crash can never leave a half-written file), stays silent (no console spam), reuses one stable `session_id` so each turn overwrites the same file, and is best-effort (never raises into the REPL). It deliberately does **not** write a `daily/` copy, append to `history.json`, or touch SQLite โ€” those remain exit-time finalization steps in `save_latest()`, which still prints the loud `Session saved โ†’ โ€ฆ` paths on quit. Net effect: `/resume` now recovers a conversation after a crash, not just after a clean exit. See [docs/PR/resume_Feature.md](PR/resume_Feature.md) ยท [docs/guides/reference.md](guides/reference.md) (`/model`, `/resume`) ยท [docs/guides/features.md](guides/features.md) (Session persistence). Version bumped `3.5.84` โ†’ **`3.5.85`** in `pyproject.toml`. **Not a breaking change** โ€” no runtime behavior changes for existing installs beyond the always-on completion and autosave; publishing the new release (git tag + PyPI) is what lets users pull the `prompt_toolkit` core-dependency change via `pip install -U` / `uv tool upgrade`. diff --git a/pyproject.toml b/pyproject.toml index 47ca0ec8..eb4cd361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "cheetahclaws" -version = "3.5.85" +version = "3.5.86" description = "CheetahClaws: Agent Harness Infrastructure for Long-Horizon, Multi-Model, and Tool-Using AI Systems" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_input_suggest.py b/tests/test_input_suggest.py new file mode 100644 index 00000000..d851cbd7 --- /dev/null +++ b/tests/test_input_suggest.py @@ -0,0 +1,309 @@ +"""Unit tests for the predicted-next-prompt ghost text. + +Covers the ui.input pending-suggestion store + PredictiveAutoSuggest, and the +ui.suggest drafting/cleanup/staleness logic (with the auxiliary model stubbed). +""" + +from __future__ import annotations + +import pytest + +from cheetahclaws.ui.input import HAS_PROMPT_TOOLKIT + +if not HAS_PROMPT_TOOLKIT: + pytest.skip("prompt_toolkit not installed", allow_module_level=True) + +from prompt_toolkit.document import Document + +import cheetahclaws.ui.input as ui_input +import cheetahclaws.ui.suggest as ui_suggest + + +@pytest.fixture(autouse=True) +def _clean_pending(): + ui_input.clear_pending_suggestion() + yield + ui_input.clear_pending_suggestion() + + +def _suggest(text: str, pending: str): + completer = ui_input.PredictiveAutoSuggest(provider=lambda: pending) + doc = Document(text, cursor_position=len(text)) + return completer.get_suggestion(_FakeBuffer(), doc) + + +class _FakeBuffer: + """Minimal stand-in โ€” AutoSuggestFromHistory only reads `.history`.""" + + class _History: + def get_strings(self): + return ["run the tests", "git commit -m fix"] + + history = _History() + document = None + + +# โ”€โ”€ ui.input: pending store โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_pending_suggestion_roundtrip_and_strip(): + ui_input.set_pending_suggestion(" run the tests ") + assert ui_input.get_pending_suggestion() == "run the tests" + ui_input.clear_pending_suggestion() + assert ui_input.get_pending_suggestion() == "" + + +# โ”€โ”€ ui.input: PredictiveAutoSuggest โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_empty_buffer_offers_whole_prediction(): + s = _suggest("", "add a test for the parser") + assert s is not None and s.text == "add a test for the parser" + + +def test_typed_prefix_offers_the_remainder(): + s = _suggest("add a ", "add a test for the parser") + assert s is not None and s.text == "test for the parser" + + +def test_divergent_typing_falls_back_to_history(): + s = _suggest("git c", "add a test for the parser") + assert s is not None and s.text == "ommit -m fix" # from _FakeBuffer history + + +def test_fully_typed_prediction_yields_no_ghost(): + """Prediction exhausted and no history match โ†’ nothing dangling.""" + s = _suggest("add a test for the parser", "add a test for the parser") + assert s is None + + +def test_no_pending_leaves_history_behavior_untouched(): + s = _suggest("run the", "") + assert s is not None and s.text == " tests" + + +def test_cursor_not_at_end_suppresses_prediction(): + """Mid-line editing gets history behavior only โ€” no predicted remainder. + + (prompt_toolkit's renderer hides any ghost while the cursor is not at the + end, so the history fallback here is invisible either way.) + """ + completer = ui_input.PredictiveAutoSuggest(provider=lambda: "run the parser suite") + doc = Document("run", cursor_position=1) + s = completer.get_suggestion(_FakeBuffer(), doc) + assert s is not None and s.text == " the tests" # history, not the prediction + + +def test_multiline_buffer_suppresses_prediction(): + text = "run\nthe" + completer = ui_input.PredictiveAutoSuggest(provider=lambda: "run\nthe tests") + doc = Document(text, cursor_position=len(text)) + assert completer.get_suggestion(_FakeBuffer(), doc) is None + + +# โ”€โ”€ ui.suggest: cleaning โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +@pytest.mark.parametrize("raw, expected", [ + ("run the tests", "run the tests"), + (' "run the tests" ', "run the tests"), + ("- run the tests", "run the tests"), + ("`run the tests`", "run the tests"), + ("run the tests\nthen commit", "run the tests"), + ("็ป™่ฟ™ไธชๅ‡ฝๆ•ฐๅŠ ไธชๆต‹่ฏ•", "็ป™่ฟ™ไธชๅ‡ฝๆ•ฐๅŠ ไธชๆต‹่ฏ•"), + ("NONE", ""), + ("", ""), + (" ", ""), + ("Sure, here is what they'd type", ""), + ("x" * (ui_suggest.MAX_LEN + 1), ""), +]) +def test_clean(raw, expected): + assert ui_suggest._clean(raw) == expected + + +# โ”€โ”€ ui.suggest: transcript extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_recent_exchange_flattens_blocks_and_keeps_order(): + messages = [ + {"role": "user", "content": "fix the parser"}, + {"role": "assistant", "content": [ + {"type": "text", "text": "Fixed it."}, + {"type": "tool_use", "name": "Edit"}, + ]}, + ] + assert ui_suggest._recent_exchange(messages) == [ + {"role": "user", "content": "fix the parser"}, + {"role": "assistant", "content": "Fixed it."}, + ] + + +def test_recent_exchange_skips_empty_and_non_chat_roles(): + messages = [ + {"role": "system", "content": "ignore me"}, + {"role": "user", "content": ""}, + {"role": "user", "content": "hello"}, + ] + assert ui_suggest._recent_exchange(messages) == [ + {"role": "user", "content": "hello"}, + ] + + +# โ”€โ”€ ui.suggest: scheduling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _stub_auxiliary(monkeypatch, reply): + import cheetahclaws.auxiliary as aux + monkeypatch.setattr(aux, "stream_auxiliary", + lambda system, messages, config: reply) + + +MESSAGES = [ + {"role": "user", "content": "fix the parser"}, + {"role": "assistant", "content": "Fixed it."}, +] + + +def test_schedule_publishes_prediction(monkeypatch): + _stub_auxiliary(monkeypatch, "run the tests") + thread = ui_suggest.schedule(MESSAGES, {}) + assert thread is not None + thread.join(timeout=5) + assert ui_input.get_pending_suggestion() == "run the tests" + + +def test_schedule_disabled_by_config(monkeypatch): + _stub_auxiliary(monkeypatch, "run the tests") + assert ui_suggest.schedule(MESSAGES, {"input_suggest": False}) is None + assert ui_input.get_pending_suggestion() == "" + + +def test_schedule_disabled_by_env(monkeypatch): + _stub_auxiliary(monkeypatch, "run the tests") + monkeypatch.setenv("CHEETAH_SUGGEST", "0") + assert ui_suggest.schedule(MESSAGES, {}) is None + + +def test_schedule_without_history_is_a_noop(monkeypatch): + _stub_auxiliary(monkeypatch, "run the tests") + assert ui_suggest.schedule([], {}) is None + + +def test_schedule_clears_the_previous_ghost_immediately(monkeypatch): + """A stale prediction must not survive into the next turn's draft window.""" + ui_input.set_pending_suggestion("stale suggestion") + _stub_auxiliary(monkeypatch, "NONE") # nothing usable this turn + thread = ui_suggest.schedule(MESSAGES, {}) + thread.join(timeout=5) + assert ui_input.get_pending_suggestion() == "" + + +def test_auxiliary_failure_is_silent(monkeypatch): + import cheetahclaws.auxiliary as aux + + def _boom(system, messages, config): + raise RuntimeError("provider down") + + monkeypatch.setattr(aux, "stream_auxiliary", _boom) + assert ui_suggest.predict(MESSAGES, {}) == "" + + +def test_stale_draft_does_not_overwrite_newer_one(monkeypatch): + """Turn N finishing after turn N+1 must not clobber the newer ghost.""" + import cheetahclaws.auxiliary as aux + monkeypatch.setattr(aux, "stream_auxiliary", + lambda system, messages, config: "old prediction") + ui_suggest._generation += 1 # simulate a newer turn already queued + thread = ui_suggest.schedule(MESSAGES, {}) + thread.join(timeout=5) + published = ui_input.get_pending_suggestion() + ui_suggest._generation += 1 + assert published == "old prediction" # this draft IS the newest one + + +# โ”€โ”€ End-to-end through a real prompt_toolkit session โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def _drive(keys: str): + """Run read_line() against a piped input; return (submitted, rendered).""" + import io + from prompt_toolkit.application import create_app_session + from prompt_toolkit.input import create_pipe_input + from prompt_toolkit.output.plain_text import PlainTextOutput + + screen = io.StringIO() + ui_input.reset_session() + try: + with create_pipe_input() as pipe: + pipe.send_text(keys) + with create_app_session(input=pipe, output=PlainTextOutput(screen)): + return ui_input.read_line("ยป "), screen.getvalue() + finally: + ui_input.reset_session() + + +def test_e2e_ghost_renders_on_empty_prompt_and_enter_ignores_it(): + ui_input.set_pending_suggestion("run the tests") + submitted, screen = _drive("\r") + assert "run the tests" in screen # shown as ghost textโ€ฆ + assert submitted == "" # โ€ฆbut never submitted on its own + + +def test_e2e_prediction_is_consumed_by_the_prompt_it_was_shown_at(): + ui_input.set_pending_suggestion("run the tests") + _drive("\r") + assert ui_input.get_pending_suggestion() == "" + + +def test_e2e_tab_accepts_the_whole_prediction(): + ui_input.set_pending_suggestion("run the tests") + submitted, _ = _drive("\t\r") + assert submitted == "run the tests" + + +def test_e2e_tab_completes_after_a_matching_prefix(): + """Regression: the ghost must be exact even when Tab beats the async pass.""" + ui_input.set_pending_suggestion("run the tests") + submitted, _ = _drive("run \t\r") + assert submitted == "run the tests" + + +def test_e2e_typing_something_else_types_over_the_ghost(): + ui_input.set_pending_suggestion("run the tests") + submitted, _ = _drive("hello\r") + assert submitted == "hello" + + +def test_e2e_ghost_returns_after_erasing_back_to_empty(): + ui_input.set_pending_suggestion("run the tests") + submitted, _ = _drive("x\x7f\t\r") + assert submitted == "run the tests" + + +def test_e2e_ghost_does_not_hijack_tab_for_slash_commands(): + ui_input.setup(lambda: {"help": True}, lambda: {"help": ("Show help", [])}) + try: + ui_input.set_pending_suggestion("run the tests") + submitted, _ = _drive("/hel\t\r") + assert submitted.startswith("/hel") # slash menu, never the ghost + assert submitted != "run the tests" + finally: + ui_input.setup(lambda: {}, lambda: {}) + + +def test_superseded_draft_is_dropped(monkeypatch): + import cheetahclaws.auxiliary as aux + started = __import__("threading").Event() + release = __import__("threading").Event() + + def _slow(system, messages, config): + started.set() + release.wait(timeout=5) + return "first prediction" + + monkeypatch.setattr(aux, "stream_auxiliary", _slow) + first = ui_suggest.schedule(MESSAGES, {}) + assert started.wait(timeout=5) + + monkeypatch.setattr(aux, "stream_auxiliary", + lambda system, messages, config: "second prediction") + second = ui_suggest.schedule(MESSAGES, {}) + second.join(timeout=5) + + release.set() + first.join(timeout=5) + assert ui_input.get_pending_suggestion() == "second prediction" diff --git a/tests/test_vscode_setup.py b/tests/test_vscode_setup.py new file mode 100644 index 00000000..f60b24d2 --- /dev/null +++ b/tests/test_vscode_setup.py @@ -0,0 +1,208 @@ +"""Unit tests for the VS Code terminal-title auto-setup. + +The behavior that matters: pick the settings file the *editor actually reads* +โ€” server-side Machine settings under Remote-SSH / WSL / devcontainers, local +User settings for a plain local install, and neither (instructions instead) +when the UI lives on a machine we can't touch. +""" + +from __future__ import annotations + +import json + +import pytest + +from cheetahclaws.ui import vscode_setup as vs + + +@pytest.fixture(autouse=True) +def _isolated_env(monkeypatch, tmp_path): + """Strip every VS Code marker and point HOME/CONFIG_DIR at a temp tree.""" + for var in ("TERM_PROGRAM", "VSCODE_AGENT_FOLDER", "VSCODE_GIT_ASKPASS_NODE", + "VSCODE_GIT_ASKPASS_MAIN", "VSCODE_IPC_HOOK_CLI", + "XDG_CONFIG_HOME", "APPDATA"): + monkeypatch.delenv(var, raising=False) + home = tmp_path / "home" + home.mkdir() + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(vs.Path, "home", classmethod(lambda cls: home)) + monkeypatch.setattr(vs, "CONFIG_DIR", tmp_path / "cheetah") + (tmp_path / "cheetah").mkdir() + return home + + +def _make_server(home, name=".vscode-server"): + root = home / name + (root / "cli" / "servers" / "Stable-abc" / "server").mkdir(parents=True) + (root / "data").mkdir() + return root + + +# โ”€โ”€ app detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_no_vscode_env_means_no_app(monkeypatch): + assert vs._vscode_app() is None + + +def test_cursor_is_distinguished_from_code(monkeypatch): + monkeypatch.setenv("TERM_PROGRAM", "vscode") + monkeypatch.setenv("CURSOR_TRACE_ID", "x") + assert vs._vscode_app() == "Cursor" + + +# โ”€โ”€ remote server-root detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_server_root_found_via_askpass_path(monkeypatch, _isolated_env): + root = _make_server(_isolated_env) + monkeypatch.setenv( + "VSCODE_GIT_ASKPASS_NODE", + str(root / "cli" / "servers" / "Stable-abc" / "server" / "node"), + ) + assert vs._remote_server_root("Code") == root + + +def test_server_root_found_via_home_fallback(_isolated_env): + root = _make_server(_isolated_env) + assert vs._remote_server_root("Code") == root + + +def test_server_root_matches_forks(monkeypatch, _isolated_env): + root = _make_server(_isolated_env, ".cursor-server") + assert vs._remote_server_root("Cursor") == root + assert vs._remote_server_root("Code") is None # wrong fork's fallback + + +def test_server_root_ignores_unrelated_dirs_named_server(monkeypatch, _isolated_env): + stray = _isolated_env / "my-server" + stray.mkdir() # no data/cli/bin/extensions inside + monkeypatch.setenv("VSCODE_GIT_ASKPASS_NODE", str(stray / "node")) + assert vs._remote_server_root("Code") is None + + +def test_local_install_is_not_mistaken_for_a_server(_isolated_env): + (_isolated_env / ".config" / "Code" / "User").mkdir(parents=True) + assert vs._remote_server_root("Code") is None + + +# โ”€โ”€ target resolution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_remote_target_is_machine_settings(_isolated_env): + root = _make_server(_isolated_env) + target, scope, why = vs._resolve_target("Code") + assert scope == "remote" and why == "" + assert target == root / "data" / "Machine" / "settings.json" + + +def test_local_target_is_user_settings_when_the_editor_dir_exists(_isolated_env): + user_dir = _isolated_env / ".config" / "Code" / "User" + user_dir.mkdir(parents=True) + target, scope, why = vs._resolve_target("Code") + assert scope == "local" and target == user_dir / "settings.json" + + +def test_no_target_when_no_local_install_and_no_server(_isolated_env): + """Regression: never fabricate ~/.config/Code/User/settings.json. + + That file is what a Remote-SSH session used to write โ€” a path the editor + never reads, leaving the user with a silently broken tab title. + """ + target, scope, why = vs._resolve_target("Code") + assert target is None and scope == "" + assert "no local Code install" in why + assert not (_isolated_env / ".config" / "Code").exists() + + +def test_server_wins_over_a_local_settings_dir(_isolated_env): + """Server-side run: the local User dir here is not the UI's settings.""" + root = _make_server(_isolated_env) + (_isolated_env / ".config" / "Code" / "User").mkdir(parents=True) + target, scope, _ = vs._resolve_target("Code") + assert scope == "remote" and target.is_relative_to(root) + + +# โ”€โ”€ marker: one attempt per target โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_marker_is_target_scoped(_isolated_env): + vs._mark_attempted("/a/settings.json") + assert vs._already_attempted("/a/settings.json") + assert not vs._already_attempted("/b/settings.json") + + +def test_legacy_timestamp_marker_triggers_exactly_one_retry(_isolated_env): + """Machines stuck by the old bare-timestamp marker must retry once.""" + (vs.CONFIG_DIR / vs._MARKER).write_text("1753034346") + assert not vs._already_attempted("/a/settings.json") + vs._mark_attempted("/a/settings.json") + assert vs._already_attempted("/a/settings.json") + + +# โ”€โ”€ end-to-end through the auto path โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def test_auto_setup_writes_remote_machine_settings(monkeypatch, _isolated_env, capsys): + root = _make_server(_isolated_env) + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + vs.maybe_setup_vscode_terminal_title({}) + + written = json.loads((root / "data" / "Machine" / "settings.json").read_text()) + assert written[vs._TITLE_KEY] == vs._TITLE_VAL + out = capsys.readouterr().out + assert "NEW terminal" in out + # Second launch is silent and does not rewrite anything. + before = (root / "data" / "Machine" / "settings.json").read_text() + vs.maybe_setup_vscode_terminal_title({}) + assert (root / "data" / "Machine" / "settings.json").read_text() == before + assert capsys.readouterr().out == "" + + +def test_auto_setup_preserves_existing_machine_settings(monkeypatch, _isolated_env): + root = _make_server(_isolated_env) + machine = root / "data" / "Machine" + machine.mkdir(parents=True) + (machine / "settings.json").write_text( + '{\n // keep me\n "files.autoSave": "off"\n}\n') + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + vs.maybe_setup_vscode_terminal_title({}) + + raw = (machine / "settings.json").read_text() + assert "// keep me" in raw # JSONC comments survive + parsed = json.loads(vs._strip_jsonc(raw)) + assert parsed["files.autoSave"] == "off" + assert parsed[vs._TITLE_KEY] == vs._TITLE_VAL + + +def test_auto_setup_prints_instructions_when_it_cannot_write(monkeypatch, _isolated_env, capsys): + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + vs.maybe_setup_vscode_terminal_title({}) + + out = capsys.readouterr().out + assert vs._TITLE_KEY in out and vs._TITLE_VAL in out + assert "/terminal-setup" in out + assert not (_isolated_env / ".config").exists() # nothing fabricated + + +def test_auto_setup_respects_terminal_title_false(monkeypatch, _isolated_env, capsys): + root = _make_server(_isolated_env) + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + vs.maybe_setup_vscode_terminal_title({"terminal_title": False}) + + assert not (root / "data" / "Machine" / "settings.json").exists() + assert capsys.readouterr().out == "" + + +def test_terminal_setup_command_reports_remote_scope(monkeypatch, _isolated_env, capsys): + _make_server(_isolated_env) + monkeypatch.setenv("TERM_PROGRAM", "vscode") + + vs.run_terminal_setup() + + out = capsys.readouterr().out + assert "Machine" in out or "remote" in out + + +def test_terminal_setup_command_outside_vscode_says_nothing_to_do(_isolated_env, capsys): + vs.run_terminal_setup() + assert "no setup needed" in capsys.readouterr().out