Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) |
Expand Down
11 changes: 11 additions & 0 deletions cheetahclaws/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions cheetahclaws/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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-..."
Expand Down
108 changes: 101 additions & 7 deletions cheetahclaws/ui/input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -234,12 +321,19 @@ 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:
_SESSION = None
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()
147 changes: 147 additions & 0 deletions cheetahclaws/ui/suggest.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading