From 0334499785760fc5307795942c08b9450171b04b Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Thu, 30 Apr 2026 16:55:25 +0200 Subject: [PATCH] feat(ui,memory): add interactive REPL with cancellation, OAuth support, and llmwiki memory plugin - Add interactive REPL with prompt toolkit for rich terminal UI - Implement agent cancellation via ESC key during execution - Add OAuth token acquisition and caching for MCP HTTP transport - Integrate MCP servers during bootstrap phase - Add context compaction progress spinner feedback - Implement shared state coordination between input and agent threads - Add llmwiki-plugin example (WikiRead/Write/Append/Search/List/Status tools) - Add docs/guides/llmwiki.md with step-by-step setup tutorial - Fix tilde expansion in /plugin install for local paths (plugin/store.py) - Add .env and .llmwiki.yaml to .gitignore --- .gitignore | 4 +- README.md | 1 + agent.py | 12 +- bootstrap.py | 7 +- cc_mcp/client.py | 94 ++++++-- cc_mcp/oauth.py | 272 ++++++++++++++++++++++ cc_mcp/types.py | 17 +- cheetahclaws.py | 84 ++++++- commands/advanced.py | 2 +- commands/core.py | 12 +- docs/guides/llmwiki.md | 334 ++++++++++++++++++++++++++++ examples/llmwiki-plugin/README.md | 45 ++++ examples/llmwiki-plugin/plugin.json | 10 + examples/llmwiki-plugin/tools.py | 195 ++++++++++++++++ plugin/store.py | 4 +- providers.py | 8 +- tools/__init__.py | 2 +- tools/interaction.py | 38 +++- ui/agent_state.py | 26 +++ ui/btw_overlay.py | 92 ++++++++ ui/input.py | 79 ++++++- ui/render.py | 74 +++--- 22 files changed, 1332 insertions(+), 80 deletions(-) create mode 100644 cc_mcp/oauth.py create mode 100644 docs/guides/llmwiki.md create mode 100644 examples/llmwiki-plugin/README.md create mode 100644 examples/llmwiki-plugin/plugin.json create mode 100644 examples/llmwiki-plugin/tools.py create mode 100644 ui/agent_state.py create mode 100644 ui/btw_overlay.py diff --git a/.gitignore b/.gitignore index 69f1b88..07f1f73 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ build/ dist/ debug_payload.json brainstorm_outputs/ -reference/ \ No newline at end of file +reference/ +.env +.llmwiki.yaml diff --git a/README.md b/README.md index 88c66b9..5e904be 100644 --- a/README.md +++ b/README.md @@ -980,6 +980,7 @@ Detailed guides have been moved to [`docs/guides/`](docs/guides/) to keep this R | [**Advanced**](docs/guides/advanced.md) | Brainstorm, SSJ Developer Mode, Tmux, Proactive monitoring, Checkpoints, Plan mode, Session management, Cloud sync | | [**Recipes**](docs/guides/recipes.md) | 12 step-by-step examples: code review, Telegram remote control, autonomous research, bug fix, brainstorm, session search, browse web pages, email, PDF/Excel analysis, and more | | [**Plugin Authoring**](docs/guides/plugin-authoring.md) | Build your own plugin: tools, commands, skills, MCP servers, publishing checklist | +| [**llmwiki Memory Plugin**](docs/guides/llmwiki.md) | Persistent memory via llmwiki-py: install, configure, update, and use WikiRead/Write/Search/Append | | [**Example Plugin**](examples/example-plugin/) | Copy-and-edit starter template with working tools, commands, and skills | | [**Contributing**](CONTRIBUTING.md) | Project structure, architecture guide, PR checklist | diff --git a/agent.py b/agent.py index 033100d..15ec779 100644 --- a/agent.py +++ b/agent.py @@ -102,7 +102,13 @@ def run( # Compact context if approaching window limit try: - maybe_compact(state, config) + from ui.render import _start_tool_spinner, _stop_tool_spinner, set_spinner_phrase + set_spinner_phrase("compacting context…") + _start_tool_spinner() + try: + maybe_compact(state, config) + finally: + _stop_tool_spinner() except Exception as _compact_err: _log.warn("compact_failed", error=str(_compact_err)) @@ -136,6 +142,8 @@ def run( tool_schemas=get_tool_schemas(), config=config, ): + if cancel_check and cancel_check(): + return if isinstance(event, (TextChunk, ThinkingChunk)): yield event elif isinstance(event, AssistantTurn): @@ -242,6 +250,8 @@ def _exec_one(tc): """Execute a single tool call, return (tc, result, permitted).""" tid = tc["id"] permitted = permissions[tid] + if cancel_check and cancel_check(): + return tc, "[Interrupted by user]", False if not permitted: if config.get("permission_mode") == "plan": plan_file = runtime.get_ctx(config).plan_file or "" diff --git a/bootstrap.py b/bootstrap.py index 6eae678..9126e40 100644 --- a/bootstrap.py +++ b/bootstrap.py @@ -39,7 +39,12 @@ def bootstrap(config: dict) -> None: import tools as _tools # noqa: F401 _log.debug("bootstrap_tools_ready") - # ── 3. Health-check HTTP server ──────────────────────────────────────── + # ── 3. MCP servers ───────────────────────────────────────────────────── + # Importing mcp.tools triggers background connection + tool registration. + import cc_mcp.tools as _mcp_tools # noqa: F401 + _log.debug("bootstrap_mcp_connecting") + + # ── 5. Health-check HTTP server ──────────────────────────────────────── port = config.get("health_check_port") if port: try: diff --git a/cc_mcp/client.py b/cc_mcp/client.py index 910c1f1..afcfecf 100644 --- a/cc_mcp/client.py +++ b/cc_mcp/client.py @@ -8,6 +8,7 @@ import time from typing import Any, Dict, List, Optional +from .oauth import acquire_token, get_cached_token from .types import ( MCPServerConfig, MCPServerState, MCPTool, MCPTransport, INIT_PARAMS, make_notification, make_request, @@ -148,21 +149,48 @@ def __init__(self, config: MCPServerConfig): self._sse_pending: Dict[int, dict] = {} self._running = False - def _get_client(self): - if self._client is None: - try: - import httpx - self._client = httpx.Client( - headers=self._config.headers, - timeout=self._config.timeout, - follow_redirects=True, - ) - except ImportError: - raise RuntimeError("httpx is required for HTTP/SSE MCP transport: pip install httpx") + def _get_client(self, oauth_token: Optional[str] = None): + try: + import httpx + except ImportError: + raise RuntimeError("httpx is required for HTTP/SSE MCP transport: pip install httpx") + headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + **self._config.headers, + } + # Inject cached OAuth token if no Authorization header already configured + if oauth_token and "Authorization" not in self._config.headers: + headers["Authorization"] = f"Bearer {oauth_token}" + if self._client is None or oauth_token: + if self._client: + try: + self._client.close() + except Exception: + pass + self._client = httpx.Client( + headers=headers, + timeout=self._config.timeout, + follow_redirects=True, + ) return self._client + def _needs_oauth(self) -> bool: + """True if this server has no static auth header configured.""" + return "Authorization" not in self._config.headers + + def _try_oauth(self, www_auth_header: str) -> None: + """Run OAuth flow and rebuild the HTTP client with the new token.""" + token = acquire_token(self._config.url, www_auth_header) + self._get_client(oauth_token=token) + def start(self) -> None: """For SSE transport: connect to the /sse endpoint and get session URL.""" + # Inject a cached OAuth token before first connection if available + if self._needs_oauth(): + cached = get_cached_token(self._config.url) + if cached: + self._get_client(oauth_token=cached) if self._config.transport == MCPTransport.SSE: self._start_sse() else: @@ -241,8 +269,24 @@ def request(self, method: str, params: Optional[dict] = None, timeout: Optional[ else: # For HTTP: POST and get response directly resp = client.post(self._session_url or self._config.url, json=msg, timeout=wait_secs) + + # OAuth: on 401 with no static auth, run browser flow and retry once + if resp.status_code == 401 and self._needs_oauth(): + www_auth = resp.headers.get("www-authenticate", "") + self._try_oauth(www_auth) + resp = self._client.post(self._session_url or self._config.url, json=msg, timeout=wait_secs) + resp.raise_for_status() - result = resp.json() + ct = resp.headers.get("content-type", "") + if "text/event-stream" in ct: + # Server returned SSE envelope — extract the data: line + result = None + for line in resp.text.splitlines(): + if line.startswith("data:"): + result = json.loads(line[5:].strip()) + break + else: + result = resp.json() if result is None: raise TimeoutError(f"MCP server '{self._config.name}' timed out on '{method}'") @@ -307,6 +351,21 @@ def connect(self) -> None: self._transport.start() self._handshake() self.state = MCPServerState.CONNECTED + except RuntimeError as e: + # If handshake failed due to OAuth being triggered mid-connect, retry once + if "401" in str(e) or "Unauthorized" in str(e): + try: + self._transport.stop() + self._transport = self._make_transport() + self._transport.start() + self._handshake() + self.state = MCPServerState.CONNECTED + return + except Exception: + pass + self.state = MCPServerState.ERROR + self._error = str(e) + raise except Exception as e: self.state = MCPServerState.ERROR self._error = str(e) @@ -492,16 +551,19 @@ def all_tools(self) -> List[MCPTool]: def call_tool(self, qualified_name: str, arguments: dict) -> str: """Dispatch a tool call by qualified name (mcp__server__tool).""" - # Parse server and tool name from qualified name parts = qualified_name.split("__", 2) if len(parts) != 3 or parts[0] != "mcp": raise ValueError(f"Invalid MCP tool name: {qualified_name}") - server_name = parts[1] + server_name_sanitized = parts[1] tool_name = parts[2] - client = self._clients.get(server_name) + client = next( + (c for c in self._clients.values() + if "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in c.config.name) == server_name_sanitized), + None, + ) if client is None: - raise RuntimeError(f"MCP server '{server_name}' not configured") + raise RuntimeError(f"MCP server '{server_name_sanitized}' not configured") # Auto-reconnect if dropped if not client.alive: diff --git a/cc_mcp/oauth.py b/cc_mcp/oauth.py new file mode 100644 index 0000000..3ea8730 --- /dev/null +++ b/cc_mcp/oauth.py @@ -0,0 +1,272 @@ +"""OAuth 2.0 + PKCE flow for MCP HTTP servers. + +Handles: + - Dynamic client registration (RFC 7591) + - Authorization Code + PKCE (S256) + - Token refresh + - Token persistence in ~/.cheetahclaws/mcp_oauth_tokens.json +""" +from __future__ import annotations + +import base64 +import hashlib +import http.server +import json +import re +import secrets +import threading +import time +import urllib.parse +import webbrowser +from pathlib import Path +from typing import Optional + +TOKEN_STORE = Path.home() / ".cheetahclaws" / "mcp_oauth_tokens.json" +REDIRECT_PORT = 54321 +REDIRECT_URI = f"http://localhost:{REDIRECT_PORT}/callback" + + +def _http(): + try: + import httpx + return httpx + except ImportError: + raise RuntimeError("httpx is required for MCP OAuth: pip install httpx") + + +# ── Token persistence ───────────────────────────────────────────────────────── + +def _load_tokens() -> dict: + if TOKEN_STORE.exists(): + try: + return json.loads(TOKEN_STORE.read_text()) + except Exception: + pass + return {} + + +def _save_tokens(data: dict) -> None: + TOKEN_STORE.parent.mkdir(parents=True, exist_ok=True) + TOKEN_STORE.write_text(json.dumps(data, indent=2)) + + +def get_cached_token(server_url: str) -> Optional[str]: + data = _load_tokens() + entry = data.get(server_url) + if not entry: + return None + if entry.get("expires_at", 0) < time.time() + 60: + refreshed = _try_refresh(server_url, entry) + if refreshed: + return refreshed + return None + return entry.get("access_token") + + +def _try_refresh(server_url: str, entry: dict) -> Optional[str]: + refresh_token = entry.get("refresh_token") + token_uri = entry.get("token_uri") + client_id = entry.get("client_id") + if not (refresh_token and token_uri and client_id): + return None + try: + httpx = _http() + resp = httpx.post(token_uri, data={ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + }, timeout=15) + resp.raise_for_status() + token_data = resp.json() + _cache_token(server_url, token_data, token_uri, client_id) + return token_data.get("access_token") + except Exception: + return None + + +def _cache_token(server_url: str, token_data: dict, token_uri: str, client_id: str) -> None: + data = _load_tokens() + expires_in = token_data.get("expires_in", 3600) + data[server_url] = { + "access_token": token_data["access_token"], + "refresh_token": token_data.get("refresh_token"), + "expires_at": time.time() + expires_in, + "token_uri": token_uri, + "client_id": client_id, + } + _save_tokens(data) + + +# ── OAuth metadata discovery ────────────────────────────────────────────────── + +def _parse_www_authenticate(header: str) -> dict: + result = {} + for match in re.finditer(r'(\w+)="([^"]*)"', header): + result[match.group(1)] = match.group(2) + return result + + +def _fetch_json(url: str) -> dict: + try: + httpx = _http() + resp = httpx.get(url, timeout=10) + resp.raise_for_status() + return resp.json() + except Exception: + return {} + + +def _discover_endpoints(www_auth_header: str) -> tuple[str, str]: + params = _parse_www_authenticate(www_auth_header) + auth_uri = params.get("authorization_uri", "") + token_uri = params.get("token_uri", "") + + if not auth_uri or not token_uri: + metadata_url = params.get("resource_metadata", "") + if metadata_url: + resource_meta = _fetch_json(metadata_url) + for as_url in resource_meta.get("authorization_servers", []): + as_meta = _fetch_json(f"{as_url.rstrip('/')}/.well-known/oauth-authorization-server") + if as_meta: + auth_uri = auth_uri or as_meta.get("authorization_endpoint", "") + token_uri = token_uri or as_meta.get("token_endpoint", "") + break + + return auth_uri, token_uri + + +# ── Dynamic client registration ─────────────────────────────────────────────── + +def _register_client(registration_endpoint: str) -> str: + httpx = _http() + resp = httpx.post(registration_endpoint, json={ + "client_name": "cheetahclaws", + "redirect_uris": [REDIRECT_URI], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + }, timeout=15) + resp.raise_for_status() + return resp.json()["client_id"] + + +def _get_or_register_client(server_url: str, token_uri: str) -> str: + data = _load_tokens() + reg_key = f"__client__{server_url}" + if reg_key in data: + return data[reg_key]["client_id"] + + base = token_uri.rsplit("/token", 1)[0] + registration_endpoint = f"{base}/register" + try: + client_id = _register_client(registration_endpoint) + except Exception as e: + raise RuntimeError( + f"Dynamic client registration failed for {server_url}: {e}\n" + "Add a static 'Authorization' header to this server's entry in mcp.json." + ) + + data[reg_key] = {"client_id": client_id} + _save_tokens(data) + return client_id + + +# ── PKCE helpers ────────────────────────────────────────────────────────────── + +def _pkce_pair() -> tuple[str, str]: + verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + challenge = base64.urlsafe_b64encode( + hashlib.sha256(verifier.encode()).digest() + ).rstrip(b"=").decode() + return verifier, challenge + + +# ── Local callback server ───────────────────────────────────────────────────── + +class _CallbackHandler(http.server.BaseHTTPRequestHandler): + code: Optional[str] = None + error: Optional[str] = None + done = threading.Event() + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + params = dict(urllib.parse.parse_qsl(parsed.query)) + if "code" in params: + _CallbackHandler.code = params["code"] + else: + _CallbackHandler.error = params.get("error", "unknown error") + _CallbackHandler.done.set() + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(b"

Authentication complete. You can close this tab.

") + + def log_message(self, *_): + pass + + +def _run_callback_server() -> http.server.HTTPServer: + _CallbackHandler.code = None + _CallbackHandler.error = None + _CallbackHandler.done.clear() + server = http.server.HTTPServer(("localhost", REDIRECT_PORT), _CallbackHandler) + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + return server + + +# ── Main OAuth flow ─────────────────────────────────────────────────────────── + +def acquire_token(server_url: str, www_auth_header: str) -> str: + """Run the full OAuth 2.0 + PKCE browser flow and return an access token.""" + auth_uri, token_uri = _discover_endpoints(www_auth_header) + if not auth_uri or not token_uri: + raise RuntimeError( + f"Cannot discover OAuth endpoints for {server_url}. " + "Set an Authorization header manually in mcp.json." + ) + + client_id = _get_or_register_client(server_url, token_uri) + verifier, challenge = _pkce_pair() + state = secrets.token_urlsafe(16) + + auth_url = auth_uri + "?" + urllib.parse.urlencode({ + "response_type": "code", + "client_id": client_id, + "redirect_uri": REDIRECT_URI, + "scope": "mcp", + "state": state, + "code_challenge": challenge, + "code_challenge_method": "S256", + }) + + callback_server = _run_callback_server() + print(f"\n[MCP OAuth] Opening browser for {server_url} ...") + print(f"[MCP OAuth] If the browser doesn't open, visit:\n {auth_url}\n") + webbrowser.open(auth_url) + + if not _CallbackHandler.done.wait(timeout=120): + callback_server.shutdown() + raise RuntimeError(f"OAuth timeout waiting for callback from {server_url}") + callback_server.shutdown() + + if _CallbackHandler.error: + raise RuntimeError(f"OAuth error from {server_url}: {_CallbackHandler.error}") + + httpx = _http() + resp = httpx.post(token_uri, data={ + "grant_type": "authorization_code", + "code": _CallbackHandler.code, + "redirect_uri": REDIRECT_URI, + "client_id": client_id, + "code_verifier": verifier, + }, timeout=15) + resp.raise_for_status() + token_data = resp.json() + + if "access_token" not in token_data: + raise RuntimeError(f"Token exchange failed for {server_url}: {token_data}") + + _cache_token(server_url, token_data, token_uri, client_id) + print(f"[MCP OAuth] Authenticated successfully with {server_url}\n") + return token_data["access_token"] diff --git a/cc_mcp/types.py b/cc_mcp/types.py index 8a4451e..5f18fd5 100644 --- a/cc_mcp/types.py +++ b/cc_mcp/types.py @@ -1,11 +1,18 @@ """MCP type definitions: server configs, tool descriptors, connection state.""" from __future__ import annotations +import os +import re from dataclasses import dataclass, field from enum import Enum from typing import Any, Dict, List, Optional +def _expand(value: str) -> str: + """Expand ${VAR} and $VAR references using os.environ.""" + return re.sub(r'\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)', lambda m: os.environ.get(m.group(1) or m.group(2), m.group(0)), value) + + # ── Server config ───────────────────────────────────────────────────────────── class MCPTransport(str, Enum): @@ -51,11 +58,11 @@ def from_dict(cls, name: str, d: dict) -> "MCPServerConfig": return cls( name=name, transport=transport, - command=d.get("command", ""), - args=d.get("args", []), - env=d.get("env", {}), - url=d.get("url", ""), - headers=d.get("headers", {}), + command=_expand(d.get("command", "")), + args=[_expand(a) for a in d.get("args", [])], + env={k: _expand(v) for k, v in d.get("env", {}).items()}, + url=_expand(d.get("url", "")), + headers={k: _expand(v) for k, v in d.get("headers", {}).items()}, timeout=int(d.get("timeout", 30)), disabled=bool(d.get("disabled", False)), ) diff --git a/cheetahclaws.py b/cheetahclaws.py index 07e3823..6180fcb 100755 --- a/cheetahclaws.py +++ b/cheetahclaws.py @@ -113,6 +113,20 @@ # ── Standard library ─────────────────────────────────────────────────────── import os + +# Load .env from the cheetahclaws directory if present (before any other imports read os.environ) +def _load_env() -> None: + env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") + if not os.path.exists(env_path): + return + with open(env_path) as _f: + for _line in _f: + _line = _line.strip() + if not _line or _line.startswith("#") or "=" not in _line: + continue + _k, _, _v = _line.partition("=") + os.environ.setdefault(_k.strip(), _v.strip()) +_load_env() import re import sys import uuid @@ -521,6 +535,7 @@ def handle_slash(line: str, state, config) -> Union[bool, tuple]: "circuit": ("Show / reset per-provider circuit breakers", ["status", "reset"]), "web": ("Start the web terminal / chat UI in background", ["status", "--no-auth", "--host"]), "setup": ("Run interactive setup wizard", []), + "btw": ("Ask a quick question while agent runs (side-session overlay)", []), "exit": ("Exit cheetahclaws", []), "quit": ("Exit (alias for /exit)", []), "resume": ("Resume last session", []), @@ -722,6 +737,9 @@ def repl(config: dict, initial_prompt: str = None): query_lock = threading.RLock() + # ── Concurrency primitives for interactive ESC / queue / /btw ───────── + from ui.agent_state import _cancel_event, _input_queue, _agent_running + # Apply rich_live config: disable in-place Live streaming if terminal has issues. # Auto-detect environments where ANSI cursor-up / live-rewrite doesn't work: # - SSH sessions (cursor-up fails across network PTY) @@ -733,7 +751,11 @@ def repl(config: dict, initial_prompt: str = None): _is_dumb = (console is not None and getattr(console, "is_dumb_terminal", False)) _is_macos_terminal = (_plat.system() == "Darwin" and _os.environ.get("TERM_PROGRAM", "") in ("Apple_Terminal", "")) - _rich_live_default = not _in_ssh and not _is_dumb and not _is_macos_terminal + # Disable Rich Live when prompt_toolkit is active: patch_stdout + Rich Live + # conflict in a background-thread setup, causing every streaming update to + # re-print the full accumulated text (visible duplication on scroll-up). + from ui.input import HAS_PROMPT_TOOLKIT as _HAS_PT + _rich_live_default = (not _in_ssh and not _is_dumb and not _is_macos_terminal) set_rich_live(config.get("rich_live", _rich_live_default)) # Initialize proactive polling state via RuntimeContext (defaults already set) @@ -744,6 +766,15 @@ def repl(config: dict, initial_prompt: str = None): t.start() def run_query(user_input: str, is_background: bool = False): + # Clear any leftover cancel signal from a previous turn + _cancel_event.clear() + _agent_running.set() + try: + _run_query_inner(user_input, is_background) + finally: + _agent_running.clear() + + def _run_query_inner(user_input: str, is_background: bool = False): nonlocal verbose with query_lock: @@ -768,7 +799,8 @@ def run_query(user_input: str, is_background: bool = False): _duplicate_suppressed = False try: - for event in run(user_input, state, config, system_prompt): + for event in run(user_input, state, config, system_prompt, + cancel_check=lambda: _cancel_event.is_set()): # Stop spinner only when visible output arrives if spinner_shown: show_thinking = isinstance(event, ThinkingChunk) and verbose @@ -1131,6 +1163,27 @@ def _read_input(prompt: str) -> str: return first + def _run_query_in_thread(user_input: str) -> None: + """Start the agent on a background thread and return immediately. + + The main thread stays in _read_input / _SESSION.prompt() so the » + prompt bar is visible at the bottom (via patch_stdout) while the + agent prints output above it — exactly as Claude Code behaves. + + Queued messages are drained by the agent thread itself, not the main + thread, so the main loop keeps re-entering the prompt each time. + """ + def _run_and_drain(msg: str) -> None: + run_query(msg) + while _input_queue: + next_msg = _input_queue.popleft() + run_query(next_msg) + + t = threading.Thread(target=_run_and_drain, args=(user_input,), daemon=True) + t.start() + # Return immediately — do NOT join. The main loop will call _read_input + # right away, showing the prompt bar while the agent thread runs. + while True: # Show notifications for background agents that finished _print_background_notifications() @@ -1152,6 +1205,12 @@ def _read_input(prompt: str) -> str: except Exception: pass prompt = clr(f"\n[{cwd_short}]", "dim") + ctx_hint + clr(" ", "dim") + clr("» ", "cyan", "bold") + try: + import shutil as _shutil + _w = _shutil.get_terminal_size((80, 24)).columns + print(clr("─" * _w, "dim"), flush=True) + except Exception: + pass user_input = _read_input(prompt) except (EOFError, KeyboardInterrupt): print() @@ -1422,12 +1481,17 @@ def _spin_and_query(phrase, prompt): if result: continue - try: - run_query(user_input) - except KeyboardInterrupt: - _track_ctrl_c() - print(clr("\n (interrupted)", "yellow")) - # Keep conversation history up to the interruption + # ── /btw overlay: quick side-session while agent runs ────────────── + if user_input.startswith("/btw ") or user_input == "/btw": + question = user_input[5:].strip() if user_input.startswith("/btw ") else "" + if not question: + warn("/btw requires a question, e.g. /btw what does this function do?") + continue + from ui.btw_overlay import run_btw_overlay + run_btw_overlay(question, config.get("model", "claude-sonnet-4-6"), config) + continue + + _run_query_in_thread(user_input) # ── Entry point ──────────────────────────────────────────────────────────── @@ -1447,6 +1511,8 @@ def main(): help="Never ask permission (accept all operations)") parser.add_argument("--verbose", action="store_true", help="Show thinking + token counts") + parser.add_argument("--no-live", action="store_true", + help="Disable Rich Live streaming (plain text output, no duplication on scroll)") parser.add_argument("--thinking", action="store_true", help="Enable extended thinking") parser.add_argument("--version", action="store_true", help="Print version") @@ -1501,6 +1567,8 @@ def main(): config["permission_mode"] = "accept-all" if args.verbose: config["verbose"] = True + if args.no_live: + config["rich_live"] = False if args.thinking: config["thinking"] = True diff --git a/commands/advanced.py b/commands/advanced.py index e65514f..53bb79b 100644 --- a/commands/advanced.py +++ b/commands/advanced.py @@ -897,7 +897,7 @@ def cmd_mcp(args: str, _state, config) -> bool: }.get(client.state.value, "dim") print(f" {clr(client.status_line(), status_color)}") for tool in client._tools: - print(f" {clr(tool.qualified_name, 'cyan')} {tool.description[:60]}") + print(f" {clr(tool.qualified_name, 'cyan')} {tool.description}") total_tools += 1 if total_tools: diff --git a/commands/core.py b/commands/core.py index acbe1d9..f9d397d 100644 --- a/commands/core.py +++ b/commands/core.py @@ -107,12 +107,14 @@ def cmd_cost(_args: str, state, config) -> bool: def cmd_compact(args: str, state, config) -> bool: """Manually compact conversation history.""" from compaction import manual_compact + from ui.render import _start_tool_spinner, _stop_tool_spinner, set_spinner_phrase focus = args.strip() - if focus: - info(f"Compacting with focus: {focus}") - else: - info("Compacting conversation...") - success, msg = manual_compact(state, config, focus=focus) + set_spinner_phrase(f"compacting conversation{' [' + focus + ']' if focus else ''}…") + _start_tool_spinner() + try: + success, msg = manual_compact(state, config, focus=focus) + finally: + _stop_tool_spinner() if success: info(msg) else: diff --git a/docs/guides/llmwiki.md b/docs/guides/llmwiki.md new file mode 100644 index 0000000..e3bee7d --- /dev/null +++ b/docs/guides/llmwiki.md @@ -0,0 +1,334 @@ +# Memory Management with llmwiki-py — Setup Tutorial + +This guide walks you through every step: cloning the repo, initialising a wiki, wiring credentials, installing the CheetahClaws plugin, and running your first memory operation. + +Estimated time: ~10 minutes. + +--- + +## Prerequisites + +- Python 3.11+ +- `pip` on your `PATH` +- CheetahClaws installed and working (`cheetahclaws` launches the REPL) +- Git (for cloning and for the optional git-backend) + +--- + +## Step 1 — Install llmwiki-py + +Install directly from GitHub — no manual clone needed: + +```bash +pip install "git+https://github.com/yamaceay/llmwiki-py.git#egg=llmwiki" +``` + +Verify the CLI landed on your PATH: + +```bash +wiki --help +``` + +Expected first line: `Usage: wiki [OPTIONS] COMMAND [ARGS]...` + +> **Python mismatch warning.** If you run CheetahClaws with a specific interpreter (e.g. `python3.11 cheetahclaws.py`), install with the same one: +> ```bash +> python3.11 -m pip install "git+https://github.com/yamaceay/llmwiki-py.git#egg=llmwiki" +> ``` + +> **Want a local editable copy instead?** If you prefer to clone and hack on the source: +> ```bash +> git clone https://github.com/yamaceay/llmwiki-py +> pip install -e ./llmwiki-py +> ``` + +--- + +## Step 3 — Create and initialise a wiki + +### 3a. Choose a directory + +The wiki root is just a folder on disk. Create it wherever you like: + +```bash +mkdir -p ~/my-wiki +``` + +### 3b. Set the active wiki + +llmwiki-py resolves the active wiki through three mechanisms in order: + +1. `--wiki ` CLI flag (per-command override) +2. `.llmwiki.yaml` found by walking up from the current directory +3. Registry default (`~/.config/llmwiki/registry.yaml`) + +`wiki init` registers the wiki automatically. Make it the default: + +```bash +wiki use openclaude-memory +``` + +> **Note on `LLMWIKI_WIKI_ROOT`:** this env var is only read when calling the Python API directly (`Wiki(root=None)`). The `wiki` CLI ignores it — registry default is what controls the CLI. + +### 3c. Initialise + +```bash +wiki init "$LLMWIKI_WIKI_ROOT" --name "my-wiki" --domain general +``` + +This creates `~/my-wiki/.llmwiki.yaml` — the wiki's config file. Check it: + +```bash +cat ~/my-wiki/.llmwiki.yaml +``` + +Expected output: + +```yaml +backend: filesystem +created: '2026-04-30' +domain: general +name: my-wiki +paths: + raw: raw + schema: SCHEMA.md + wiki: wiki +``` + +The `wiki/` subdirectory (at `~/my-wiki/wiki/`) is where all pages live. The `raw/` subdirectory holds unprocessed source material. + +--- + +## Step 4 — Credentials (git backend only) + +If you chose `--backend filesystem` (the default above) **skip this step entirely** — no credentials needed. + +If you want the wiki backed by a GitHub repo so it survives across machines: + +### 4a. Create a GitHub Personal Access Token + +1. Go to **GitHub → Settings → Developer settings → Personal access tokens → Fine-grained tokens** +2. Click **Generate new token** +3. Scopes needed: `Contents: Read and Write` on the target repo +4. Copy the token — you will not see it again + +### 4b. Store the token + +Never put a token in `.llmwiki.yaml` — it would end up in git history. Use an env var instead. llmwiki-py checks these in order: + +| Env var | Notes | +|---|---| +| `LLMWIKI_GIT_TOKEN` | llmwiki-specific, takes highest priority | +| `GITHUB_TOKEN` | standard CI variable, works if already set | +| `GIT_TOKEN` | generic fallback | + +Add to your shell profile: + +```bash +echo 'export LLMWIKI_GIT_TOKEN="ghp_xxxxxxxxxxxxxxxxxxxx"' >> ~/.zshrc +``` + +Replace `ghp_xxxx…` with your actual token. Then reload: + +```bash +source ~/.zshrc +``` + +### 4c. Re-initialise with git backend + +```bash +wiki init "$LLMWIKI_WIKI_ROOT" \ + --name "my-wiki" \ + --domain general \ + --backend git \ + --git-repo "yourusername/your-wiki-repo" +``` + +The token is read from the env var at runtime — do **not** pass `--git-token` on the command line (it would appear in shell history). + +--- + +## Step 5 — Verify the wiki works + +Run a quick smoke test before involving CheetahClaws: + +```bash +# Check status +wiki status + +# Write a test page +echo "# Hello\n\nFirst wiki page." | wiki write concepts/hello.md + +# Read it back +wiki read concepts/hello.md + +# List all pages +wiki list --tree + +# Search +wiki search "hello" +``` + +All five commands should succeed without errors. If `wiki status` complains about a missing wiki, double-check that `LLMWIKI_WIKI_ROOT` is set in the current shell session. + +--- + +## Step 6 — Install the CheetahClaws plugin + +### 6a. Copy the plugin manifest + +```bash +cp -r ~/aicore/openclaude/examples/llmwiki-plugin \ + ~/.cheetahclaws/plugins/llmwiki +``` + +The plugin directory must be named `llmwiki`. Verify: + +```bash +ls ~/.cheetahclaws/plugins/llmwiki/ +# plugin.json tools.py README.md +``` + +### 6b. Enable the plugin + +Launch CheetahClaws and run: + +``` +/plugin enable llmwiki +``` + +Then confirm it's registered: + +``` +/plugin +``` + +Expected line: `llmwiki [user] enabled Persistent memory management via llmwiki-py` + +Restart CheetahClaws if the tools don't appear immediately. + +--- + +## Step 7 — First memory operation + +Inside the CheetahClaws REPL, tell the AI to write something: + +``` +remember that the auth service uses JWT with a 24h expiry +``` + +The AI will call `WikiWrite` to persist this. Then verify it was stored: + +``` +show me everything you know about auth +``` + +The AI will call `WikiSearch` and `WikiRead` and surface the note. + +You can also check the file directly: + +```bash +wiki list --tree +wiki read +``` + +--- + +## Step 8 — Update llmwiki-py + +```bash +pip install --upgrade "git+https://github.com/yamaceay/llmwiki-py.git#egg=llmwiki" +``` + +If you installed from a local clone instead: + +```bash +cd ~/aicore/llmwiki-py +git pull +``` + +No CheetahClaws restart needed after updating the package. + +To pick up a newer plugin manifest from openclaude (e.g. after a `git pull` in openclaude): + +```bash +cp -r ~/aicore/openclaude/examples/llmwiki-plugin \ + ~/.cheetahclaws/plugins/llmwiki +``` + +Then restart CheetahClaws. + +--- + +## Setting environment variables via openclaude + +You do not need to set variables in your shell profile. openclaude loads a `.env` file from its own directory at startup — before any plugin code runs — so variables defined there are available to every tool, including the wiki CLI subprocess. + +Create the file (it is already in `.gitignore`, so it will never be committed): + +```bash +# ~/aicore/openclaude/.env +LLMWIKI_WIKI_ROOT=/Users/you/my-wiki +LLMWIKI_GIT_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx # only needed for git backend +``` + +Rules: +- One `KEY=VALUE` per line, no quotes needed around values +- Lines starting with `#` are comments +- If the variable is already set in your shell, the shell value takes precedence (`.env` is a fallback, not an override) + +After editing `.env`, restart CheetahClaws once for the values to take effect. + +--- + +## Environment variable reference + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `LLMWIKI_WIKI_ROOT` | Yes (simplest path) | — | Points directly at a wiki directory | +| `LLMWIKI_WIKI` | No | registry default | Selects a named wiki from the registry | +| `LLMWIKI_CONFIG_DIR` | No | `~/.config/llmwiki` | Location of `registry.yaml` | +| `LLMWIKI_GIT_TOKEN` | Only for git backend | — | GitHub PAT for push/pull | +| `GITHUB_TOKEN` | No | — | Fallback git token (standard CI var) | +| `GIT_TOKEN` | No | — | Second fallback git token | +| `LLMWIKI_GITHUB_API_URL` | No | `https://api.github.com` | Override for GitHub Enterprise | + +All variables go in your shell profile (`~/.zshrc` or `~/.bashrc`) and take effect after `source ~/.zshrc` or opening a new terminal. + +--- + +## Troubleshooting + +**`wiki: command not found`** + +```bash +# Find where pip installed it +python -m llmwiki.cli.main --help +# Add its bin dir to PATH, or use the module form: +alias wiki="python -m llmwiki.cli.main" +``` + +**`Error: no wiki found. Run 'wiki init' first.`** + +`LLMWIKI_WIKI_ROOT` is not set in the current shell. Run `echo $LLMWIKI_WIKI_ROOT` to check, then `source ~/.zshrc` to reload your profile. + +**`WikiRead` / `WikiWrite` return `"wiki command not found"` inside CheetahClaws** + +CheetahClaws launched in a shell environment where `wiki` is not on `PATH`. Fix by using an absolute path in the plugin's `tools.py`, or ensure your shell profile exports `PATH` correctly and CheetahClaws inherits it. + +**Tools disappear after a restart** + +The plugin is enabled but CheetahClaws is losing the state. Check `~/.cheetahclaws/plugins.json` — the `llmwiki` entry should have `"enabled": true`. + +**Git backend: authentication failed** + +`LLMWIKI_GIT_TOKEN` is not reaching the process. Verify with `printenv LLMWIKI_GIT_TOKEN`. If empty, re-source your profile in the same terminal where you launch CheetahClaws. + +--- + +## Reference + +- [Plugin authoring guide](./plugin-authoring.md) — how the plugin system works +- [llmwiki-py repo](https://github.com/yamaceay/llmwiki-py) — source and issue tracker +- Plugin manifest: `examples/llmwiki-plugin/plugin.json` +- Plugin tools: `examples/llmwiki-plugin/tools.py` diff --git a/examples/llmwiki-plugin/README.md b/examples/llmwiki-plugin/README.md new file mode 100644 index 0000000..59eb071 --- /dev/null +++ b/examples/llmwiki-plugin/README.md @@ -0,0 +1,45 @@ +# llmwiki plugin + +Memory management for CheetahClaws via [llmwiki-py](https://github.com/yamaceay/llmwiki-py). + +## Quick install + +```bash +# 1. Install llmwiki-py (once, from its source directory) +cd /path/to/llmwiki-py +pip install -e . + +# 2. Create and initialise a wiki +export LLMWIKI_WIKI_ROOT="$HOME/my-wiki" +wiki init + +# 3. Copy this plugin into CheetahClaws +cp -r /path/to/openclaude/examples/llmwiki-plugin ~/.cheetahclaws/plugins/llmwiki + +# 4. Enable it +cheetahclaws +/plugin enable llmwiki +``` + +## Tools registered + +| Tool | What it does | +|---|---| +| `WikiRead` | Read a page by path | +| `WikiWrite` | Create or overwrite a page | +| `WikiAppend` | Append to an existing page | +| `WikiSearch` | Full-text search with snippets | +| `WikiList` | Directory tree of all pages | +| `WikiStatus` | Wiki health check | + +## Updating llmwiki-py + +```bash +cd /path/to/llmwiki-py +git pull +pip install -e . # only if dependencies changed +``` + +## Full documentation + +See [docs/guides/llmwiki.md](../../docs/guides/llmwiki.md). diff --git a/examples/llmwiki-plugin/plugin.json b/examples/llmwiki-plugin/plugin.json new file mode 100644 index 0000000..f19a05d --- /dev/null +++ b/examples/llmwiki-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "llmwiki", + "version": "0.1.0", + "description": "Persistent memory management via llmwiki-py (read/write/search/append a local wiki)", + "author": "openclaude", + "tags": ["memory", "wiki", "knowledge-base"], + "tools": ["tools"], + "dependencies": [], + "homepage": "https://github.com/yamaceay/llmwiki-py" +} diff --git a/examples/llmwiki-plugin/tools.py b/examples/llmwiki-plugin/tools.py new file mode 100644 index 0000000..42d34e8 --- /dev/null +++ b/examples/llmwiki-plugin/tools.py @@ -0,0 +1,195 @@ +"""llmwiki plugin tools — wraps the `wiki` CLI for AI tool use.""" +from __future__ import annotations + +import os +import subprocess +from tool_registry import ToolDef + + +def _run(args: list[str], stdin: str | None = None) -> tuple[int, str]: + """Run the wiki CLI and return (returncode, stdout+stderr).""" + try: + import shutil + wiki_bin = shutil.which("wiki") or "wiki" + result = subprocess.run( + [wiki_bin, *args], + input=stdin, + capture_output=True, + text=True, + env={**os.environ}, + ) + output = result.stdout + if result.returncode != 0 and result.stderr: + output = result.stderr.strip() + return result.returncode, output + except FileNotFoundError: + return 1, ( + "The `wiki` command was not found. Install llmwiki-py with:\n" + " pip install \"git+https://github.com/yamaceay/llmwiki-py.git#egg=llmwiki\"" + ) + + +def _wiki_read(params: dict, config: dict) -> str: + _, out = _run(["read", params["path"]]) + return out or "(empty page)" + + +def _wiki_write(params: dict, config: dict) -> str: + _, out = _run(["write", params["path"]], stdin=params["content"]) + return out + + +def _wiki_append(params: dict, config: dict) -> str: + _, out = _run(["append", params["path"]], stdin=params["content"]) + return out + + +def _wiki_search(params: dict, config: dict) -> str: + args = ["search", params["query"]] + if "limit" in params: + args += ["--limit", str(params["limit"])] + _, out = _run(args) + return out or "No results." + + +def _wiki_list(params: dict, config: dict) -> str: + args = ["list", "--tree"] + if params.get("dir"): + args.append(params["dir"]) + _, out = _run(args) + return out or "(wiki is empty)" + + +def _wiki_status(params: dict, config: dict) -> str: + _, out = _run(["status"]) + return out + + +TOOL_DEFS = [ + ToolDef( + name="WikiRead", + schema={ + "name": "WikiRead", + "description": "Read a page from the wiki knowledge base.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Wiki page path, e.g. 'concepts/auth.md'", + } + }, + "required": ["path"], + }, + }, + func=_wiki_read, + read_only=True, + concurrent_safe=True, + ), + ToolDef( + name="WikiWrite", + schema={ + "name": "WikiWrite", + "description": "Write (create or overwrite) a wiki page with new content.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Wiki page path, e.g. 'concepts/auth.md'", + }, + "content": { + "type": "string", + "description": "Full Markdown content to write", + }, + }, + "required": ["path", "content"], + }, + }, + func=_wiki_write, + read_only=False, + concurrent_safe=False, + ), + ToolDef( + name="WikiAppend", + schema={ + "name": "WikiAppend", + "description": "Append content to an existing wiki page without overwriting it.", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Wiki page path", + }, + "content": { + "type": "string", + "description": "Markdown content to append", + }, + }, + "required": ["path", "content"], + }, + }, + func=_wiki_append, + read_only=False, + concurrent_safe=False, + ), + ToolDef( + name="WikiSearch", + schema={ + "name": "WikiSearch", + "description": "Full-text search across all wiki pages. Returns matching pages with snippets.", + "input_schema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query", + }, + "limit": { + "type": "integer", + "description": "Max results to return (default: 10)", + "default": 10, + }, + }, + "required": ["query"], + }, + }, + func=_wiki_search, + read_only=True, + concurrent_safe=True, + ), + ToolDef( + name="WikiList", + schema={ + "name": "WikiList", + "description": "List all wiki pages as a directory tree.", + "input_schema": { + "type": "object", + "properties": { + "dir": { + "type": "string", + "description": "Subdirectory to list (optional, defaults to root)", + } + }, + }, + }, + func=_wiki_list, + read_only=True, + concurrent_safe=True, + ), + ToolDef( + name="WikiStatus", + schema={ + "name": "WikiStatus", + "description": "Show wiki health: page count, index status, git backend status.", + "input_schema": { + "type": "object", + "properties": {}, + }, + }, + func=_wiki_status, + read_only=True, + concurrent_safe=True, + ), +] diff --git a/plugin/store.py b/plugin/store.py index 729c337..8cec063 100644 --- a/plugin/store.py +++ b/plugin/store.py @@ -170,7 +170,7 @@ def install_plugin( try: if source is None: # No source → treat name as a local path if it exists, else error - local = Path(name) + local = Path(name).expanduser() if local.exists() and local.is_dir(): source = str(local.resolve()) else: @@ -188,7 +188,7 @@ def install_plugin( if not ok: return False, msg else: - local_src = Path(source) + local_src = Path(source).expanduser() if not local_src.exists(): return False, f"Local path not found: {source}" shutil.copytree(str(local_src), str(plugin_dir)) diff --git a/providers.py b/providers.py index 867f8d4..c628e06 100644 --- a/providers.py +++ b/providers.py @@ -512,7 +512,13 @@ def stream_anthropic( ) -> Generator: """Stream from Anthropic API. Yields TextChunk/ThinkingChunk, then AssistantTurn.""" import anthropic as _ant - client = _ant.Anthropic(api_key=api_key) + import os as _os + _base_url = (config.get("anthropic_base_url") + or _os.environ.get("ANTHROPIC_BASE_URL", "")) + _client_kwargs = {"api_key": api_key} + if _base_url: + _client_kwargs["base_url"] = _base_url + client = _ant.Anthropic(**_client_kwargs) _mt = resolve_max_tokens(config, "anthropic", model) or 8192 kwargs = { diff --git a/tools/__init__.py b/tools/__init__.py index 5bcaf74..75b31c2 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -616,7 +616,7 @@ def _register_builtins() -> None: "memory.tools", "multi_agent.tools", "skill.tools", - "cc_mcp.tools", + "mcp.tools", "task.tools", ] diff --git a/tools/interaction.py b/tools/interaction.py index 6457ff4..47a50bb 100644 --- a/tools/interaction.py +++ b/tools/interaction.py @@ -180,12 +180,40 @@ def ask_input_interactive(prompt: str, config: dict, return text # ── Terminal ──────────────────────────────────────────────────────────── + # The agent runs in a background thread; prompt_toolkit owns stdin on the + # main thread. Signal the question via shared state so the main thread's + # Enter binding collects the answer and unblocks us — no stdin fighting. try: - rl_prompt = _re.sub(r'(\x1b\[[0-9;]*m)', r'\001\1\002', prompt) - return input(rl_prompt) - except (KeyboardInterrupt, EOFError): - print() - return "" + from ui.agent_state import ( + _pending_question as _pq_mod, # noqa — we mutate the module + _answer_event, + _answer_value, + ) + import ui.agent_state as _st + + clean_prompt = _re.sub(r'(\x1b\[[0-9;]*m)', '', prompt).strip() + + # Print the question above the prompt bar (goes via patch_stdout proxy) + import sys as _sys + _sys.stdout.write(f"\n\033[1;35m❯ {clean_prompt}\033[0m\n") + _sys.stdout.flush() + + # Signal the Enter binding + _st._answer_event.clear() + _st._answer_value = "" + _st._pending_question = clean_prompt + + if not _st._answer_event.wait(timeout=_INPUT_WAIT_TIMEOUT): + _st._pending_question = "" + return "(timeout)" + + return _st._answer_value + + except Exception: + try: + return input(_re.sub(r'(\x1b\[[0-9;]*m)', r'\001\1\002', prompt)) + except (KeyboardInterrupt, EOFError): + return "" # ── SleepTimer ──────────────────────────────────────────────────────────── diff --git a/ui/agent_state.py b/ui/agent_state.py new file mode 100644 index 0000000..6460c38 --- /dev/null +++ b/ui/agent_state.py @@ -0,0 +1,26 @@ +"""ui/agent_state.py — Shared concurrency primitives for the interactive REPL. + +Three module-level singletons coordinate the main (input) thread and the +agent (run_query) thread: + + _cancel_event — ESC sets this; agent loop checks it to stop early. + _input_queue — typed-ahead messages buffered while agent is running. + _agent_running — set while a run_query() turn is in progress. + +Question routing (AskUserQuestion): + _pending_question — non-empty string while agent is waiting for an answer. + _answer_event — agent thread blocks on this; Enter binding sets it. + _answer_value — the user's answer, written by Enter binding before set(). +""" +from __future__ import annotations + +import collections +import threading + +_cancel_event: threading.Event = threading.Event() +_input_queue: collections.deque = collections.deque() +_agent_running: threading.Event = threading.Event() + +_pending_question: str = "" +_answer_event: threading.Event = threading.Event() +_answer_value: str = "" diff --git a/ui/btw_overlay.py b/ui/btw_overlay.py new file mode 100644 index 0000000..dbfc963 --- /dev/null +++ b/ui/btw_overlay.py @@ -0,0 +1,92 @@ +"""ui/btw_overlay.py — /btw Rich overlay: side-session Q&A while agent runs. + +Opens a three-column layout: + left — dim padding (agent output scrolls behind via patch_stdout) + center — focused white-on-dark panel; streams the answer + right — dim padding (mirror) + +The question is answered by a fresh no-tools API call so it never +touches the main conversation state. +""" +from __future__ import annotations + +import sys +from typing import Iterator + + +def _side_stream(question: str, model: str, config: dict) -> Iterator[str]: + """Stream a quick no-tools answer for the /btw question.""" + from providers import stream as _stream, AssistantTurn, TextChunk + messages = [{"role": "user", "content": question}] + system = "Answer concisely and helpfully. No markdown headers needed." + for event in _stream( + model=model, + system=system, + messages=messages, + tool_schemas=[], + config=config, + ): + if isinstance(event, TextChunk): + yield event.text + + +def run_btw_overlay(question: str, model: str, config: dict) -> None: + """Render the /btw overlay and stream an answer into it.""" + try: + from rich.console import Console + from rich.layout import Layout + from rich.live import Live + from rich.panel import Panel + from rich.markdown import Markdown + from rich.text import Text + except ImportError: + # Rich not available — plain fallback + print(f"\n\033[35m[/btw]\033[0m {question}") + for chunk in _side_stream(question, model, config): + sys.stdout.write(chunk) + sys.stdout.flush() + print() + return + + console = Console() + layout = Layout() + layout.split_row( + Layout(name="left", ratio=1), + Layout(name="center", ratio=3), + Layout(name="right", ratio=1), + ) + dim_panel = Panel(Text(""), border_style="dim") + layout["left"].update(dim_panel) + layout["right"].update(dim_panel) + layout["center"].update( + Panel(Text("…", style="dim"), title="[cyan]/btw[/cyan]", border_style="cyan") + ) + + buf: list[str] = [] + + def _render_center(): + text = "".join(buf) + try: + renderable = Markdown(text) if any(c in text for c in ("#", "*", "`", "_", "[")) else text + except Exception: + renderable = text + layout["center"].update( + Panel(renderable, title="[cyan]/btw[/cyan]", border_style="cyan") + ) + + with Live(layout, console=console, auto_refresh=True, refresh_per_second=12, + vertical_overflow="visible"): + try: + for chunk in _side_stream(question, model, config): + buf.append(chunk) + _render_center() + except KeyboardInterrupt: + pass + + # Leave a static copy so the user can scroll back + final = "".join(buf).strip() + if final: + console.print( + Panel(Markdown(final) if any(c in final for c in ("#", "*", "`", "_", "[")) else final, + title="[cyan]/btw[/cyan]", border_style="dim") + ) diff --git a/ui/input.py b/ui/input.py index ed726ae..d4420d3 100644 --- a/ui/input.py +++ b/ui/input.py @@ -153,10 +153,12 @@ def _ghost_text_acceptable() -> bool: return True def _build_key_bindings() -> "KeyBindings": - """Tab accepts the gray history ghost-text when one is shown. + """Key bindings for the REPL input line. - Falls through to the default Tab binding (slash-menu cycling) when the - filter doesn't match, so `/cmd` completion behavior is unchanged. + Tab — accept gray history ghost-text (falls through to slash-menu cycling). + Escape — hard-stop the running agent turn (sets _cancel_event). + Enter — if agent is running, queue the typed message instead of submitting. + /btw submits immediately as an overlay request. """ kb = KeyBindings() @@ -165,6 +167,48 @@ def _(event): buf = event.current_buffer buf.insert_text(buf.suggestion.text) + @kb.add("escape") + def _esc(event): + from ui.agent_state import _cancel_event, _agent_running + if _agent_running.is_set(): + _cancel_event.set() + import sys + sys.stdout.write("\n\033[33m (interrupted — finishing current operation…)\033[0m\n") + sys.stdout.flush() + event.current_buffer.reset() + + @kb.add("enter") + def _enter(event): + import ui.agent_state as _st + buf = event.current_buffer + text = buf.text.strip() + + # ── Answer mode: agent is waiting for a reply ────────────────── + if _st._pending_question: + _st._answer_value = text + _st._pending_question = "" + _st._answer_event.set() + buf.reset() + return + + if not text: + event.current_buffer.validate_and_handle() + return + # /btw goes straight to overlay (handled by read_line caller) + if text.startswith("/btw ") or text == "/btw": + event.app.exit(result=text) + return + if _st._agent_running.is_set(): + # Queue the message for after the current turn finishes + _st._input_queue.append(text) + buf.reset() + import sys + short = text[:60] + ("…" if len(text) > 60 else "") + sys.stdout.write(f"\n\033[2m queued: {short}\033[0m\n") + sys.stdout.flush() + else: + event.app.exit(result=text) + return kb @@ -192,6 +236,33 @@ def _build_session(history_path: Optional[Path]): "completion-menu.meta.completion.current": "bg:#005f87 #eeeeee", "auto-suggestion": "#606060 italic", }) + # Disable CPR (Cursor Position Report): prompt_toolkit sends ESC[6n to + # detect cursor position, and the terminal's response leaks back into stdin + # as garbage characters (e.g. "7;1R"). Disabling CPR prevents this entirely. + try: + import sys as _sys + import io as _io + from prompt_toolkit.output.vt100 import Vt100_Output + from prompt_toolkit.output.color_depth import ColorDepth + + def _get_size(): + from prompt_toolkit.utils import get_cwidth as _ # noqa + try: + from prompt_toolkit.output.vt100 import _get_size as _gs + rows, cols = _gs(_sys.stdout.fileno()) + except Exception: + rows, cols = 24, 80 + from prompt_toolkit.data_structures import Size + return Size(rows=rows or 24, columns=cols or 80) + + _output = Vt100_Output( + _sys.stdout, + _get_size, + enable_cpr=False, + ) + except Exception: + _output = None + return PromptSession( history=history, completer=completer, @@ -201,6 +272,7 @@ def _build_session(history_path: Optional[Path]): mouse_support=False, style=style, key_bindings=_build_key_bindings(), + output=_output, ) @@ -217,5 +289,6 @@ 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)) diff --git a/ui/render.py b/ui/render.py index c776ced..5bab8cc 100644 --- a/ui/render.py +++ b/ui/render.py @@ -105,11 +105,10 @@ def _start_live() -> None: def stream_text(chunk: str) -> None: - """Buffer chunk; update Live in-place when Rich available, else print directly. + """Buffer chunk; render formatted output at flush_response() time. - Safety: if accumulated text exceeds _LIVE_LINE_LIMIT lines, auto-switch - from Rich Live to plain streaming to prevent terminal re-render duplication - on terminals that can't handle large Live areas (macOS Terminal, etc.). + When Rich Live is enabled: update in-place on each chunk. + When disabled (patch_stdout active): buffer silently, render once at flush. """ global _current_live _accumulated_text.append(chunk) @@ -122,8 +121,6 @@ def stream_text(chunk: str) -> None: if _current_live is not None and line_count > _LIVE_LINE_LIMIT: _current_live.stop() _current_live = None - # Print the full text once (Live already displayed partial content, - # but stopping Live clears it — so we re-print cleanly) console.print(_make_renderable(full)) return @@ -132,10 +129,8 @@ def stream_text(chunk: str) -> None: _start_live() _current_live.update(_make_renderable(full), refresh=True) else: - # Already past limit, no Live — just append new chunk print(chunk, end="", flush=True) - else: - print(chunk, end="", flush=True) + # Non-Live: buffer only — flush_response() renders the complete text once def stream_thinking(chunk: str, verbose: bool): if verbose: @@ -144,17 +139,18 @@ def stream_thinking(chunk: str, verbose: bool): print(f"{C['dim']}{clean_chunk}", end="", flush=True) def flush_response() -> None: - """Commit buffered text to screen: stop Live (freezes rendered Markdown in place).""" + """Commit buffered text to screen with Rich Markdown formatting.""" global _current_live full = "".join(_accumulated_text) _accumulated_text.clear() if _current_live is not None: _current_live.stop() _current_live = None - elif _RICH and _RICH_LIVE and full.strip(): + elif _RICH and full.strip(): console.print(_make_renderable(full)) - else: - print() # ensure newline after plain-text stream + elif full: + print(full, end="", flush=True) + print() # ── Spinner ──────────────────────────────────────────────────────────────── @@ -276,25 +272,43 @@ def _tool_desc(name: str, inputs: dict) -> str: def print_tool_start(name: str, inputs: dict, verbose: bool): - """Show tool invocation.""" - desc = _tool_desc(name, inputs) - print(clr(f" ⚙ {desc}", "dim", "cyan"), flush=True) if verbose: + desc = _tool_desc(name, inputs) + print(clr(f" ⚙ {desc}", "dim", "cyan"), flush=True) print(clr(f" inputs: {json.dumps(inputs, ensure_ascii=False)[:200]}", "dim")) + return + # Non-verbose: overwrite the spinner line with the tool name, no newline + desc = _tool_desc(name, inputs) + sys.stdout.write(f"\r \033[2m⚙ {desc[:80]}\033[0m" + " " * 10 + "\r") + sys.stdout.flush() def print_tool_end(name: str, result: str, verbose: bool): - lines = result.count("\n") + 1 - size = len(result) - summary = f"→ {lines} lines ({size} chars)" - if not result.startswith("Error") and not result.startswith("Denied"): - print(clr(f" ✓ {summary}", "dim", "green"), flush=True) - if name in ("Edit", "Write") and _has_diff(result): - parts = result.split("\n\n", 1) - if len(parts) == 2: - print(clr(f" {parts[0]}", "dim")) - render_diff(parts[1]) - else: + if verbose: + lines = result.count("\n") + 1 + size = len(result) + summary = f"→ {lines} lines ({size} chars)" + if not result.startswith("Error") and not result.startswith("Denied"): + print(clr(f" ✓ {summary}", "dim", "green"), flush=True) + if name in ("Edit", "Write") and _has_diff(result): + parts = result.split("\n\n", 1) + if len(parts) == 2: + print(clr(f" {parts[0]}", "dim")) + render_diff(parts[1]) + preview = result[:500] + ("…" if len(result) > 500 else "") + print(clr(f" {preview.replace(chr(10), chr(10)+' ')}", "dim")) + else: + print(clr(f" ✗ {result[:120]}", "dim", "red"), flush=True) + return + # Non-verbose: show diffs for Edit/Write (permanent output), errors; erase all else + if name in ("Edit", "Write") and _has_diff(result): + sys.stdout.write("\r" + " " * 60 + "\r") + sys.stdout.flush() + parts = result.split("\n\n", 1) + if len(parts) == 2: + print(clr(f" {parts[0]}", "dim")) + render_diff(parts[1]) + elif result.startswith("Error") or result.startswith("Denied"): + sys.stdout.write("\r" + " " * 60 + "\r") + sys.stdout.flush() print(clr(f" ✗ {result[:120]}", "dim", "red"), flush=True) - if verbose and not result.startswith("Denied"): - preview = result[:500] + ("…" if len(result) > 500 else "") - print(clr(f" {preview.replace(chr(10), chr(10)+' ')}", "dim")) + # Otherwise: just erase the tool-start line; spinner will overwrite next