1 line
660 KiB
JSON
1 line
660 KiB
JSON
{"repo": "boots-coder/lumen", "files": {"chat.py": {"d42246c188b7607a25605e1ac79bdd77c221fb46": {"t": 1785674056.942133, "n": 89382, "h": "02f63ed7be4ce136", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\nRun directly and pick a model / enter your API key inside the UI:\n python chat.py\n\nOr specify them on the command line (skipping the wizard):\n python chat.py --model gpt-4o --api-key sk-...\n\nSlash commands:\n /help Show help\n /status Token usage and session info\n /mode Switch general / plan / review (or cycle with Shift+Tab)\n /plan Plan Mode control (on | approve | cancel)\n /lang Switch UI language (en | zh)\n /compact Manually compact context\n /reset Clear conversation history\n /save Save session to file\n /load Load session from file\n /config Reconfigure model and key\n /forget Delete locally saved API key\n /quit Quit\n\nInteraction enhancements:\n • Type / to pop open the command completer\n • Type @ to auto-complete project file paths (30s cache)\n • /load <tab> completes files in the project; picking one loads that session\n • Multi-line input: Enter sends, Shift+Enter / Alt+Enter / Ctrl+J newline\n (Shift+Enter requires a terminal emitting CSI-u or modifyOtherKeys escapes;\n iTerm2/Terminal.app default doesn't distinguish — fall back to Alt+Enter)\n • Input history persisted at ~/.lumen/history, retained across sessions\n • Shift+Tab cycles modes\n • Bottom bar always shows the current mode / phase\n • After first configuration the key is saved in ~/.lumen/config.json (mode 0600),\n reused on next launch — clear with /forget.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport re\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.completion import Completer, Completion\nfrom prompt_toolkit.filters import completion_is_selected\nfrom prompt_toolkit.history import FileHistory\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import (\n Agent, PermissionChecker, PermissionBehavior, PermissionMode,\n HookRegistry, RetryConfig, Notifier, FileEditTracker,\n)\nfrom lumen.i18n import (\n t, tt, set_language, get_language,\n SUPPORTED_LANGUAGES, language_display_name, normalize_language,\n)\nfrom lumen.context.transcript import TranscriptReader, list_recent_sessions\nfrom chat_commands import (\n ChatContext, CommandRegistry, CommandResult, SlashCommand,\n)\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.services.review_state import ReviewPhase\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\nfrom lumen.tools.approval import ConsoleApprovalHandler\n\nconsole = Console()\n\n# ── Color palette ────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ── Shared state (accessed by keybinding / bottom_toolbar closures) ──────────\n_AGENT_REF: dict = {\"agent\": None}\n_PT_SESSION_REF: dict = {\"session\": None} # Main input session (/mode, Shift+Tab)\n_INNER_PT_REF: dict = {\"session\": None} # Modal input session (permission / approval)\n_LIVE_REF: dict = {\"live\": None} # Current agent_response Live, for pause/resume\n\n\nclass _LivePause:\n \"\"\"Context manager that suspends any active Rich Live region while a\n modal prompt runs, then resumes it. Prevents prompt_toolkit from fighting\n Rich for terminal control, which is what makes modal prompts appear to\n 'swallow Enter' — they were never visible in the first place.\"\"\"\n\n def __enter__(self):\n self._live = _LIVE_REF.get(\"live\")\n if self._live is not None:\n try:\n self._live.stop()\n except Exception:\n self._live = None\n return self\n\n def __exit__(self, exc_type, exc, tb):\n if self._live is not None:\n try:\n self._live.start()\n except Exception:\n pass\n return False\n\n\nclass _LivePausingApprovalHandler:\n \"\"\"Wraps any ApprovalHandler so each `.request()` call is bracketed by\n a `_LivePause`. Without this, the approval panel draws inside an active\n Rich Live region and prompt_toolkit can't take the terminal — the user\n sees nothing and their Enter appears to do nothing.\"\"\"\n\n def __init__(self, inner) -> None:\n self._inner = inner\n\n async def request(self, phase, title, content):\n with _LivePause():\n return await self._inner.request(phase, title, content)\n\n\n# ── Slash / @path completion ──────────────────────────────────────────────────\nclass SlashCompleter(Completer):\n \"\"\"\n Unified completer, three trigger categories:\n · `/` Top-level command menu (/help /mode /load …)\n · `/mode r…` Sub-arguments (general / review)\n · `/load …` Project file paths (for loading session files)\n · `@path/to/…` Project file references (inserts text, agent sees the path)\n\n File-tree scan results cached for 30s, auto-skipping common noise dirs\n (.git / node_modules / __pycache__ / .venv / …) so complete_while_typing\n doesn't walk the disk on every keystroke.\n \"\"\"\n\n # Fallback lists used only before the CommandRegistry is attached\n # (e.g. during setup_wizard). After chat_loop builds the registry, it\n # calls `.bind_registry(reg)` and we read live from there.\n @property\n def TOP_LEVEL(self):\n return [\n (\"/help\", t(\"cmd.help\")),\n (\"/config\", t(\"cmd.config\")),\n (\"/quit\", t(\"cmd.quit\")),\n ]\n\n @property\n def MODE_ARGS(self):\n return [\n (\"general\", t(\"cmd.sub.mode.general\")),\n (\"review\", t(\"cmd.sub.mode.review\")),\n ]\n\n SKIP_DIRS = {\n \".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\",\n \"dist\", \"build\", \".pytest_cache\", \".mypy_cache\", \".ruff_cache\",\n \".tox\", \".next\", \".turbo\", \".cache\", \"target\", \".idea\", \".vscode\",\n }\n _FILE_CACHE_TTL = 30.0\n _FILE_CACHE_MAX = 2000\n\n def __init__(self) -> None:\n self._file_cache: list[str] | None = None\n self._file_cache_ts: float = 0.0\n self._registry = None # type: ignore # Optional[CommandRegistry]\n\n def bind_registry(self, registry) -> None:\n \"\"\"Attach a CommandRegistry so completion reflects user-loaded commands.\"\"\"\n self._registry = registry\n\n def _top_level_items(self) -> list[tuple[str, str]]:\n if self._registry is not None:\n return [(c.name, c.description_text()) for c in self._registry.all()]\n return list(self.TOP_LEVEL)\n\n def _subargs_for(self, head: str) -> list[tuple[str, str]]:\n if self._registry is not None:\n cmd = self._registry.find(head)\n if cmd is not None and cmd.subargs:\n return cmd.subarg_items()\n if head == \"/mode\":\n return list(self.MODE_ARGS)\n return []\n\n def _scan_files(self) -> list[str]:\n import time\n now = time.time()\n if (\n self._file_cache is not None\n and (now - self._file_cache_ts) < self._FILE_CACHE_TTL\n ):\n return self._file_cache\n\n cwd = Path.cwd()\n results: list[str] = []\n for root, dirs, files in os.walk(cwd):\n dirs[:] = [\n d for d in dirs\n if d not in self.SKIP_DIRS and not d.startswith(\".\")\n ]\n root_path = Path(root)\n for f in files:\n if f.startswith(\".\") and f not in (\".gitignore\", \".env.example\"):\n continue\n full = root_path / f\n try:\n rel = full.relative_to(cwd)\n except ValueError:\n continue\n results.append(str(rel))\n if len(results) >= self._FILE_CACHE_MAX:\n break\n if len(results) >= self._FILE_CACHE_MAX:\n break\n results.sort()\n self._file_cache = results\n self._file_cache_ts = now\n return results\n\n def _rank_files(self, prefix: str, limit: int = 50) -> list[str]:\n \"\"\"Return files matching prefix, ranked: basename-start > path-start > substring.\"\"\"\n if not prefix:\n return self._scan_files()[:limit]\n lower = prefix.lower()\n ranked: list[tuple[int, str]] = []\n for path in self._scan_files():\n p_lower = path.lower()\n base_lower = Path(path).name.lower()\n if base_lower.startswith(lower):\n rank = 0\n elif p_lower.startswith(lower):\n rank = 1\n elif lower in p_lower:\n rank = 2\n else:\n continue\n ranked.append((rank, path))\n ranked.sort(key=lambda x: (x[0], x[1]))\n return [p for _, p in ranked[:limit]]\n\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n\n # ── @path file reference (allowed anywhere in a sentence) ────────────\n at_idx = text.rfind(\"@\")\n if at_idx != -1 and (at_idx == 0 or text[at_idx - 1] in \" \\t\\n\"):\n prefix = text[at_idx + 1:]\n if prefix == \"\" or (\" \" not in prefix and \"\\t\" not in prefix and \"\\n\" not in prefix):\n for path in self._rank_files(prefix):\n yield Completion(\n path, start_position=-len(prefix),\n display=path, display_meta=\"file\",\n )\n return\n\n # ── /load <path> file-path completion ────────────────────────────────\n if text.startswith(\"/load \"):\n arg = text[len(\"/load \"):].lstrip()\n for path in self._rank_files(arg):\n yield Completion(\n path, start_position=-len(arg),\n display=path, display_meta=\"session\",\n )\n return\n\n # ── Sub-arguments (e.g. /mode review, any registered subargs) ───────\n if text.startswith(\"/\") and \" \" in text:\n head, _, rest = text.partition(\" \")\n prefix = rest.lstrip()\n subargs = self._subargs_for(head)\n for arg, desc in subargs:\n if arg.startswith(prefix):\n yield Completion(\n arg, start_position=-len(prefix),\n display=arg, display_meta=desc,\n )\n return\n\n # ── Top-level slash command ──────────────────────────────────────────\n if text.startswith(\"/\"):\n for cmd, desc in self._top_level_items():\n if cmd.startswith(text):\n yield Completion(\n cmd, start_position=-len(text),\n display=cmd, display_meta=desc,\n )\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model catalog\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str # Raw description — may be an i18n key if prefixed with \"i18n:\"\n\n def display(self) -> str:\n \"\"\"Resolve the description, translating if it uses an i18n key.\"\"\"\n if self.desc.startswith(\"i18n:\"):\n return t(self.desc[5:])\n # Mixed template: leading \"<model> \" + suffix i18n key\n if \"||\" in self.desc:\n prefix, key = self.desc.split(\"||\", 1)\n return prefix + t(key)\n return self.desc\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str # Fixed brand name or an i18n key prefixed \"i18n:\"\n key_hint: str # API key format hint (may be i18n: key)\n env_key: str | None # Env var to read the key from\n base_url: str | None # None = let the Agent auto-detect\n models: list[ModelOption]\n needs_key: bool = True\n\n def display_name(self) -> str:\n return t(self.name[5:]) if self.name.startswith(\"i18n:\") else self.name\n\n def display_key_hint(self) -> str:\n return t(self.key_hint[5:]) if self.key_hint.startswith(\"i18n:\") else self.key_hint\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"i18n:provider.getgoapi.name\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini ||provider.model.reasoning\"),\n ModelOption(\"claude-sonnet-4-6\", \"i18n:provider.model.sonnet_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"i18n:provider.model.haiku_fastcheap\"),\n ModelOption(\"deepseek-chat\", \"i18n:provider.model.deepseek_chat\"),\n ModelOption(\"deepseek-reasoner\", \"i18n:provider.model.deepseek_reasoner\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · ||provider.model.allround\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"i18n:provider.ollama.name\", key_hint=\"i18n:provider.ollama.key_hint\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"i18n:provider.custom.name\", key_hint=\"i18n:provider.custom.key_hint\",\n env_key=None,\n base_url=None,\n models=[], # User-supplied\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Configuration wizard\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n language: str = \"en\"\n provider_id: str = \"\" # Stable id (e.g. \"getgoapi\"); enables live re-translation\n\n\ndef display_provider_name(config: AgentConfig) -> str:\n \"\"\"Return the provider's display name, translated live when the provider\n is known by id. Falls back to whatever was stored in `provider_name`.\"\"\"\n pid = config.provider_id or _infer_provider_id(config.provider_name)\n if pid:\n for p in PROVIDERS:\n if p.id == pid:\n return p.display_name()\n return config.provider_name\n\n\ndef _infer_provider_id(stored_name: str) -> str:\n \"\"\"Best-effort recovery of a provider id from a legacy stored display name.\n Matches against the EN + ZH translation of every provider's name.\"\"\"\n if not stored_name:\n return \"\"\n from lumen import i18n as _i18n\n for p in PROVIDERS:\n if not p.name.startswith(\"i18n:\"):\n if p.name == stored_name:\n return p.id\n continue\n key = p.name[5:]\n for lang in _i18n.SUPPORTED_LANGUAGES:\n translated = _i18n.TRANSLATIONS.get(lang, {}).get(key)\n if translated == stored_name:\n return p.id\n return \"\"\n\n\n# ── Config persistence ───────────────────────────────────────────────────────\n# Stored at ~/.lumen/config.json (file mode 0600) so users don't have to\n# re-enter the API key on every launch.\n\n_CONFIG_DIR = Path.home() / \".lumen\"\n_CONFIG_FILE = _CONFIG_DIR / \"config.json\"\n\n\ndef save_config(config: AgentConfig) -> None:\n \"\"\"Write config to ~/.lumen/config.json with 0600 permissions.\"\"\"\n import json\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n data = {\n \"api_key\": config.api_key,\n \"model\": config.model,\n \"base_url\": config.base_url,\n \"provider_name\": config.provider_name,\n \"provider_id\": config.provider_id,\n \"language\": config.language,\n }\n _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding=\"utf-8\")\n try:\n os.chmod(_CONFIG_FILE, 0o600)\n except Exception:\n pass\n except Exception as e:\n render_system(t(\"wizard.save_failed\", err=e), C_DIM)\n\n\ndef load_config() -> AgentConfig | None:\n \"\"\"Load cached config if present and well-formed.\"\"\"\n import json\n if not _CONFIG_FILE.exists():\n return None\n try:\n data = json.loads(_CONFIG_FILE.read_text(encoding=\"utf-8\"))\n if not data.get(\"model\") or not data.get(\"api_key\"):\n return None\n lang = normalize_language(data.get(\"language\")) or get_language()\n return AgentConfig(\n api_key=data[\"api_key\"],\n model=data[\"model\"],\n base_url=data.get(\"base_url\"),\n provider_name=data.get(\"provider_name\") or \"Saved\",\n provider_id=data.get(\"provider_id\") or \"\",\n language=lang,\n )\n except Exception:\n return None\n\n\ndef forget_config() -> bool:\n \"\"\"Delete the saved config file.\"\"\"\n try:\n if _CONFIG_FILE.exists():\n _CONFIG_FILE.unlink()\n return True\n except Exception:\n pass\n return False\n\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"Interactive configuration wizard: pick Provider → pick Model → enter Key.\"\"\"\n console.print()\n console.print(Rule(t(\"wizard.title\"), style=C_BRAND))\n console.print()\n\n # ── Step 1: pick Provider ────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key detected[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.display_name() + detected, key_info)\n\n console.print(Panel(table, title=t(\"wizard.pick_provider\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(t(\"wizard.range_hint\", n=len(PROVIDERS)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(t(\"wizard.selected\", value=provider.display_name()) + \"\\n\")\n\n # ── Step 2: pick Model ───────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(t(\"wizard.custom_base_url\"))).strip()\n model = (await pt.prompt_async(t(\"wizard.custom_model\"))).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(t(\"wizard.custom_empty_model\"))\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.display())\n\n console.print(Panel(model_table, title=t(\"wizard.pick_model\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(t(\"wizard.range_hint\", n=len(provider.models)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(t(\"wizard.selected\", value=model) + \"\\n\")\n\n # ── Step 3: enter API Key ────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(t(\"wizard.local_no_key\"))\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n t(\"wizard.key_env_detected\", name=provider.env_key, masked=masked)\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(t(\"wizard.key_env_used\") + \"\\n\")\n else:\n api_key = await _prompt_key(pt, provider.display_key_hint())\n else:\n console.print(t(\"wizard.key_hint_prefix\", hint=provider.display_key_hint()))\n api_key = await _prompt_key(pt, provider.display_key_hint())\n\n # ── Summary ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(t(\"col.provider\"), provider.display_name())\n summary.add_row(t(\"col.model\"), model)\n if base_url:\n summary.add_row(t(\"col.base_url\"), base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(t(\"col.api_key\"), key_display)\n console.print(Panel(summary, title=t(\"wizard.summary_title\"), border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.display_name(),\n provider_id=provider.id,\n language=get_language(),\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"API Key input with basic masking prompt.\"\"\"\n while True:\n try:\n key = await pt.prompt_async(t(\"wizard.key_prompt\"))\n key = key.strip()\n if key:\n return key\n console.print(t(\"wizard.key_empty\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI rendering components\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(t(\"tokenbar.model\"), style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(t(\"tokenbar.msgs\"), style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(t(\"tokenbar.compactions\"), style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.review_mode:\n stats.append(\" │ \", style=C_DIM)\n phase = agent.review_state.phase\n phase_label = {\n ReviewPhase.IDLE: t(\"tokenbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"tokenbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"tokenbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"tokenbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"tokenbar.review_complete\"),\n }.get(phase, t(\"tokenbar.review_default\"))\n stats.append(f\"⚑ {phase_label}\", style=\"bold yellow\")\n elif agent.plan_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(t(\"tokenbar.plan_mode\"), style=\"bold blue\")\n\n perm_mode = agent.permissions.mode\n if perm_mode != PermissionMode.DEFAULT:\n stats.append(\" │ \", style=C_DIM)\n perm_label, perm_style = {\n PermissionMode.ACCEPT_EDITS: (\"✎ accept-edits\", \"bold green\"),\n PermissionMode.PLAN: (\"📋 plan-only\", \"bold blue\"),\n PermissionMode.BYPASS: (\"⚠ bypass\", \"bold red\"),\n }[perm_mode]\n stats.append(perm_label, style=perm_style)\n\n border = (\n \"red\" if perm_mode == PermissionMode.BYPASS\n else \"blue\" if perm_mode == PermissionMode.PLAN or agent.plan_mode\n else \"yellow\" if agent.review_mode\n else C_DIM\n )\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\n_LONE_SURROGATE_RE = re.compile(r\"[\\ud800-\\udfff]\")\n\n\ndef _safe_text(s: str) -> str:\n \"\"\"Strip lone UTF-16 surrogates that appear when streaming chunks split a\n multi-unit character in half (common with emoji / CJK across chunk\n boundaries). These codepoints are unencodable as UTF-8 and crash the\n console writer.\"\"\"\n if not s:\n return s\n # Fast path: regex strip of the surrogate range.\n cleaned = _LONE_SURROGATE_RE.sub(\"\", s)\n # Belt-and-braces: round-trip through utf-8 with 'replace' so any\n # remaining unencodable code points become '?' instead of crashing.\n try:\n cleaned.encode(\"utf-8\", \"strict\")\n return cleaned\n except UnicodeEncodeError:\n return cleaned.encode(\"utf-8\", \"replace\").decode(\"utf-8\", \"replace\")\n\n\ndef render_user_message(text_value: str) -> Panel:\n return Panel(\n Text(_safe_text(text_value), style=\"white\"),\n title=Text(t(\"msg.you\"), style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text_value: str, style: str = C_DIM) -> None:\n console.print(f\" {_safe_text(text_value)}\", style=style)\n\n\ndef render_error(text_value: str) -> None:\n console.print(Panel(_safe_text(text_value), border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help(registry: \"CommandRegistry | None\" = None) -> None:\n \"\"\"Render /help. If a CommandRegistry is supplied, the slash-command rows\n are pulled from it so user-loaded commands show up automatically.\"\"\"\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n\n if registry is not None:\n for cmd in registry.all():\n hint = cmd.description_text()\n if cmd.aliases:\n hint += f\" (aliases: {' '.join(cmd.aliases)})\"\n table.add_row(cmd.name, hint)\n else:\n table.add_row(\"/help\", t(\"cmd.help\"))\n table.add_row(\"/quit\", t(\"cmd.quit\"))\n\n # Non-slash keybinding reference\n for row in [\n (\"\", \"\"),\n (\"↑ / ↓\", t(\"help.row.history\")),\n (\"Shift+Enter\", t(\"help.row.shift_enter\")),\n (\"Alt+Enter\", t(\"help.row.alt_enter\")),\n (\"@path\", t(\"help.row.at_path\")),\n (\"Shift+Tab\", t(\"help.row.shift_tab\")),\n (\"Ctrl+C\", t(\"help.row.ctrl_c\")),\n ]:\n table.add_row(*row)\n console.print(Panel(table, title=t(\"help.title\"), border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(t(\"status.model\"), agent.model)\n table.add_row(t(\"status.context_window\"), f\"{agent.context_window:,} tokens\")\n table.add_row(t(\"status.tokens_used\"), f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(t(\"status.messages\"), str(len(agent.messages)))\n table.add_row(t(\"status.compactions\"), str(agent.session.compaction_count))\n table.add_row(t(\"status.session_start\"), agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=t(\"status.title\"), border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent response\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n Run a full agent turn: live-display tool calls and render the final\n answer as Markdown.\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(t(\"msg.lumen_thinking\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── Use a Live panel to show progress while tools run ────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n _LIVE_REF[\"live\"] = live\n tool_log.append(t(\"msg.thinking\"))\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n try:\n await _chat_with_refresh()\n finally:\n _LIVE_REF[\"live\"] = None\n else:\n # No tools: stream output straight through\n collected: list[str] = []\n panel_title = Text(t(\"msg.lumen_streaming\"), style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n _LIVE_REF[\"live\"] = live\n try:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\n _safe_text(\"\".join(collected)),\n code_theme=\"monokai\", inline_code_theme=\"monokai\",\n )\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n finally:\n _LIVE_REF[\"live\"] = None\n return \"\".join(collected)\n\n # ── Render the final answer ──────────────────────────────────────────────\n final_text = _safe_text(response.content if response else \"\")\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(t(\"msg.lumen\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Mode switching\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str, pt: PromptSession | None = None) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n if agent.review_mode:\n mode_label = t(\"mode.review_label\")\n elif agent.plan_mode:\n mode_label = t(\"mode.plan_label\")\n else:\n mode_label = t(\"mode.general_label\")\n console.print(Panel(\n t(\"mode.current\", label=mode_label) + \"\\n\\n\"\n + t(\"mode.hint_review\") + \"\\n\"\n + t(\"mode.hint_plan\") + \"\\n\"\n + t(\"mode.hint_general\") + \"\\n\"\n + t(\"mode.hint_shift_tab\"),\n border_style=C_DIM, title=t(\"mode.panel_title\"), title_align=\"left\",\n ))\n return\n\n if arg in (\"review\", \"r\", \"审阅\", \"careful\"):\n if agent.review_mode:\n render_system(t(\"mode.already_review\"), C_DIM)\n else:\n inner = _INNER_PT_REF.get(\"session\") or pt\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n console.print(Panel(\n t(\"mode.review_panel_body\"),\n border_style=\"yellow\",\n title=t(\"mode.review_panel_title\"),\n title_align=\"left\",\n ))\n\n elif arg in (\"plan\", \"p\", \"方案\", \"计划\"):\n _activate_plan_mode(agent)\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n was_plan = agent.plan_mode\n was_review = agent.review_mode\n if not was_plan and not was_review:\n render_system(t(\"mode.already_general\"), C_DIM)\n else:\n if was_review:\n agent.disable_review_mode()\n if was_plan:\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"mode.back_to_general\"), C_SUCCESS)\n\n else:\n render_system(t(\"mode.unknown\", arg=arg), C_WARN)\n\n\ndef _activate_plan_mode(agent: Agent) -> None:\n if agent.plan_mode:\n render_system(t(\"mode.already_plan\"), C_DIM)\n return\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n console.print(Panel(\n t(\"mode.plan_panel_body\"),\n border_style=\"blue\",\n title=t(\"mode.plan_panel_title\"),\n title_align=\"left\",\n ))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Command handling\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(t(\"compact.running\"), C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n t(\"compact.body\",\n mb=r.messages_before, ma=r.messages_after,\n tb=r.tokens_before, ta=r.tokens_after,\n kept=r.kept_recent_count),\n border_style=\"green\", title=t(\"compact.title\"), title_align=\"left\",\n ))\n except Exception as e:\n render_error(t(\"compact.failed\", err=e))\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(t(\"save.prompt\", default=default), default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(t(\"save.done\", path=path), C_SUCCESS)\n if agent.transcript:\n render_system(t(\"save.transcript_hint\", path=agent.transcript.path), C_DIM)\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n except Exception as e:\n render_error(t(\"save.failed\", err=e))\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(t(\"load.prompt\"))).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(t(\"load.missing\", path=arg))\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(t(\"load.done\", n=len(agent.messages), path=arg), C_SUCCESS)\n return agent\n except Exception as e:\n render_error(t(\"load.failed\", err=e))\n return None\n\n\nasync def handle_resume(config: AgentConfig, pt: PromptSession) -> Agent | None:\n \"\"\"List recent auto-saved sessions and let the user pick one to resume.\"\"\"\n cwd = Path.cwd()\n sessions = list_recent_sessions(cwd, limit=10)\n if not sessions:\n render_system(t(\"resume.none\"), C_WARN)\n return None\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"#\", style=C_NUM, width=4)\n table.add_column(t(\"resume.col.session_id\"), style=\"white\")\n table.add_column(t(\"status.model\"), style=C_DIM)\n table.add_column(t(\"resume.col.messages\"), style=\"white\", justify=\"right\")\n table.add_column(t(\"resume.col.created\"), style=C_DIM)\n table.add_column(t(\"resume.col.last_prompt\"), style=\"white\")\n\n for i, s in enumerate(sessions, 1):\n created = s.created_at[:19].replace(\"T\", \" \") if s.created_at else \"?\"\n prompt_preview = s.last_prompt[:50] + (\"...\" if len(s.last_prompt) > 50 else \"\")\n table.add_row(\n f\"[{i}]\",\n s.session_id[:12] + \"...\",\n s.model or \"?\",\n str(s.message_count),\n created,\n prompt_preview,\n )\n\n console.print(Panel(table, title=t(\"resume.table_title\"), border_style=C_DIM))\n\n try:\n raw = await pt.prompt_async(t(\"resume.pick_prompt\"))\n raw = raw.strip()\n if not raw:\n return None\n idx = int(raw) - 1\n if not (0 <= idx < len(sessions)):\n render_system(t(\"resume.range_hint\", n=len(sessions)), C_WARN)\n return None\n except (ValueError, KeyboardInterrupt, EOFError):\n return None\n\n chosen = sessions[idx]\n try:\n messages, metadata = TranscriptReader.load_session(chosen.file_path)\n if not messages:\n render_error(t(\"resume.empty_file\"))\n return None\n\n # Rebuild a Session from the transcript messages\n from lumen.context.session import Session\n session = Session(model=config.model)\n session.session_id = chosen.session_id\n session.messages = messages\n\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n session=session,\n auto_compact=True,\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n FileWriteTool(), FileEditTool(),\n GlobTool(), GrepTool(), BashTool(),\n ],\n )\n render_system(\n t(\"resume.done\", sid=chosen.session_id[:12], n=len(messages)),\n C_SUCCESS,\n )\n return agent\n except Exception as e:\n render_error(t(\"resume.failed\", err=e))\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Slash command registry\n# ─────────────────────────────────────────────────────────────────────────────\n# All slash commands (/help, /status, /mode, /load, …) register through the\n# CommandRegistry. Both SlashCompleter and /help read from here — single\n# source of truth. At startup we also auto-scan ~/.lumen/commands/*.py so\n# users can add their own commands without editing chat.py.\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _build_command_registry() -> CommandRegistry:\n \"\"\"Build the built-in command registry. Handlers close over ChatContext\n (passed in at dispatch time), so they can see live agent/config/pt state.\"\"\"\n reg = CommandRegistry()\n\n async def _quit(ctx, arg):\n render_system(t(\"common.goodbye\"), C_DIM)\n return CommandResult(quit=True)\n\n async def _help(ctx, arg):\n render_help(ctx.registry)\n return CommandResult()\n\n async def _status(ctx, arg):\n render_status(ctx.agent)\n return CommandResult()\n\n async def _mode(ctx, arg):\n handle_mode(ctx.agent, arg, pt=ctx.pt)\n return CommandResult()\n\n async def _compact(ctx, arg):\n await handle_compact(ctx.agent)\n return CommandResult()\n\n async def _reset(ctx, arg):\n ctx.agent.reset()\n render_system(t(\"reset.done\"), C_SUCCESS)\n return CommandResult()\n\n async def _save(ctx, arg):\n await handle_save(ctx.agent, ctx.pt)\n return CommandResult()\n\n async def _load(ctx, arg):\n new = await handle_load(arg, ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _resume(ctx, arg):\n new = await handle_resume(ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _config(ctx, arg):\n render_system(t(\"config.back_to_wizard\"), C_DIM)\n return CommandResult(reconfigure=True)\n\n async def _forget(ctx, arg):\n if forget_config():\n render_system(t(\"forget.done\", path=_CONFIG_FILE), C_SUCCESS)\n else:\n render_system(t(\"forget.nothing\"), C_DIM)\n return CommandResult()\n\n async def _lang(ctx, arg):\n \"\"\"Switch UI language live.\"\"\"\n arg = arg.strip().lower()\n if not arg:\n current = language_display_name()\n ctx.console.print(Panel(\n t(\"lang.current\", name=current) + \"\\n\\n\" + t(\"lang.available\"),\n border_style=C_DIM, title=\"Language / \\u8bed\\u8a00\",\n title_align=\"left\",\n ))\n return CommandResult()\n target = normalize_language(arg)\n if target is None:\n render_system(t(\"lang.unknown\", arg=arg), C_WARN)\n return CommandResult()\n if target == get_language():\n render_system(t(\"lang.already\", name=language_display_name(target)), C_DIM)\n return CommandResult()\n set_language(target)\n # Persist to config so next launch keeps this choice\n try:\n ctx.config.language = target\n save_config(ctx.config)\n except Exception:\n pass\n render_system(t(\"lang.switched\", name=language_display_name(target)), C_SUCCESS)\n return CommandResult()\n\n async def _perm(ctx, arg):\n \"\"\"Switch permission posture. /perm alone shows current + options.\"\"\"\n checker = ctx.agent.permissions\n current = checker.mode\n\n labels = {\n PermissionMode.DEFAULT: tt(\"perm.label.default\"),\n PermissionMode.ACCEPT_EDITS: tt(\"perm.label.accept_edits\"),\n PermissionMode.PLAN: tt(\"perm.label.plan\"),\n PermissionMode.BYPASS: tt(\"perm.label.bypass\"),\n }\n\n arg = arg.strip().lower()\n if not arg:\n lines = [t(\"perm.current\", name=labels[current][0])]\n for m, (name, desc) in labels.items():\n marker = \"● \" if m == current else \" \"\n style = \"bold cyan\" if m == current else \"white\"\n lines.append(f\" {marker}[{style}]/perm {name}[/] {desc}\")\n ctx.console.print(Panel(\n \"\\n\".join(lines),\n title=t(\"perm.panel_title\"), title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n\n # Parse mode name (accept several aliases — bilingual)\n alias_map = {\n \"default\": PermissionMode.DEFAULT, \"d\": PermissionMode.DEFAULT, \"常规\": PermissionMode.DEFAULT,\n \"acceptedits\": PermissionMode.ACCEPT_EDITS, \"accept\": PermissionMode.ACCEPT_EDITS,\n \"edits\": PermissionMode.ACCEPT_EDITS, \"a\": PermissionMode.ACCEPT_EDITS, \"接受\": PermissionMode.ACCEPT_EDITS,\n \"plan\": PermissionMode.PLAN, \"p\": PermissionMode.PLAN, \"计划\": PermissionMode.PLAN,\n \"bypass\": PermissionMode.BYPASS, \"b\": PermissionMode.BYPASS, \"绕过\": PermissionMode.BYPASS,\n }\n target = alias_map.get(arg.replace(\"-\", \"\").replace(\"_\", \"\"))\n if target is None:\n render_system(t(\"perm.unknown\", arg=arg), C_WARN)\n return CommandResult()\n\n if target == current:\n render_system(t(\"perm.already\", name=labels[target][0]), C_DIM)\n return CommandResult()\n\n if target == PermissionMode.BYPASS:\n # Extra friction for the nuclear option.\n try:\n confirm = await ctx.inner_pt.prompt_async(\n t(\"perm.bypass_confirm\")\n )\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n if confirm.strip().lower() not in (\"y\", \"yes\"):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n\n checker.set_mode(target)\n render_system(\n t(\"perm.switched\", name=labels[target][0], desc=labels[target][1]),\n C_SUCCESS,\n )\n return CommandResult()\n\n async def _diff(ctx, arg):\n \"\"\"Show unified diff of files the agent wrote/edited this session.\"\"\"\n from rich.syntax import Syntax\n tracker = ctx.edit_tracker\n if tracker is None:\n render_system(t(\"diff.no_tracker\"), C_WARN)\n return CommandResult()\n\n entries = tracker.entries(dirty_only=True)\n if not entries:\n render_system(t(\"diff.no_changes\"), C_DIM)\n return CommandResult()\n\n target = arg.strip()\n if target:\n # Filter: user can pass a path (absolute or cwd-relative) or basename\n keep = []\n for e in entries:\n if (\n e.path == target\n or Path(e.path).name == target\n or target in e.path\n ):\n keep.append(e)\n if not keep:\n render_system(t(\"diff.no_match\", target=target), C_WARN)\n return CommandResult()\n entries = keep\n\n # Summary table first\n summary = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n summary.add_column(t(\"diff.col.file\"), style=\"white\")\n summary.add_column(t(\"diff.col.plus\"), style=\"bold green\", justify=\"right\")\n summary.add_column(t(\"diff.col.minus\"), style=\"bold red\", justify=\"right\")\n summary.add_column(t(\"diff.col.writes\"), style=C_DIM, justify=\"right\")\n summary.add_column(t(\"diff.col.status\"), style=C_DIM)\n total_added = total_removed = 0\n for e in entries:\n added, removed = e.line_stats()\n total_added += added\n total_removed += removed\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n summary.add_row(\n rel, f\"+{added}\", f\"-{removed}\",\n str(e.write_count),\n t(\"diff.status_new\") if not e.existed_before else t(\"diff.status_edited\"),\n )\n ctx.console.print(Panel(\n summary,\n title=t(\"diff.panel_title\", n=len(entries), added=total_added, removed=total_removed),\n title_align=\"left\", border_style=C_DIM,\n ))\n\n # Per-file unified diff\n for e in entries:\n diff_text = e.unified_diff()\n if not diff_text:\n continue\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n ctx.console.print(Panel(\n Syntax(diff_text, \"diff\", theme=\"monokai\", line_numbers=False,\n word_wrap=False, background_color=\"default\"),\n title=rel, title_align=\"left\", border_style=\"blue\",\n padding=(0, 1),\n ))\n\n return CommandResult()\n\n async def _plan(ctx, arg):\n \"\"\"Plan Mode control: on/off/approve/cancel. Bare /plan shows status.\"\"\"\n agent = ctx.agent\n sub = arg.strip().lower()\n\n if not sub:\n if agent.plan_mode:\n ctx.console.print(Panel(\n t(\"plan.active_body\"),\n border_style=\"blue\", title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n else:\n ctx.console.print(Panel(\n t(\"plan.inactive_body\"),\n border_style=C_DIM, title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n return CommandResult()\n\n if sub in (\"on\", \"start\", \"开启\"):\n _activate_plan_mode(agent)\n return CommandResult()\n\n if sub in (\"off\", \"cancel\", \"取消\"):\n if not agent.plan_mode:\n render_system(t(\"plan.off_not_active\"), C_DIM)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"plan.cancelled\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"approve\", \"go\", \"执行\", \"ok\"):\n if not agent.plan_mode:\n render_system(t(\"plan.approve_not_active\"), C_WARN)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.ACCEPT_EDITS)\n render_system(t(\"plan.approved\"), C_SUCCESS)\n return CommandResult()\n\n render_system(t(\"plan.unknown_sub\", sub=sub), C_WARN)\n return CommandResult()\n\n async def _tasks(ctx, arg):\n \"\"\"Show / inspect / cancel background sub-agent tasks.\n\n Subcommands:\n /tasks → table of all tasks\n /tasks <id> → show output of that task\n /tasks stop <id> → cancel\n /tasks clear → kill every running task + clear finished\n \"\"\"\n import time as _time\n mgr = ctx.agent.subagent_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n tasks = mgr.list_tasks()\n\n if sub_lower in (\"clear\", \"reset\", \"清空\"):\n await mgr.cleanup()\n render_system(t(\"tasks.cleared\"), C_SUCCESS)\n return CommandResult()\n\n if sub_lower.startswith(\"stop \") or sub_lower.startswith(\"kill \"):\n target = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.kill(target)\n if ok:\n render_system(t(\"tasks.stopped\", id=target), C_SUCCESS)\n else:\n render_system(t(\"tasks.stop_missing\", id=target), C_WARN)\n return CommandResult()\n\n # /tasks <id> → single task detail\n if sub and not sub_lower.startswith((\"stop\", \"kill\", \"clear\")):\n match = next((task for task in tasks if task.agent_id == sub), None)\n if match is None:\n render_system(t(\"tasks.not_found\", id=sub), C_WARN)\n return CommandResult()\n result = await mgr.get_result(sub)\n if result is None:\n ctx.console.print(Panel(\n f\"status: [yellow]{match.status}[/]\\n\"\n f\"description: {match.description or '(none)'}\\n\"\n f\"elapsed: {_time.monotonic() - match.start_time:.1f}s\",\n title=f\"Task {sub}\", title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n body = result.content if result.success else (result.error or \"\")\n ctx.console.print(Panel(\n f\"[dim]duration {result.duration_ms:.0f}ms · \"\n f\"tool calls {result.tool_calls_count} · \"\n f\"success {result.success}[/]\\n\\n{body}\",\n title=f\"Task {sub} · {result.description}\",\n title_align=\"left\",\n border_style=\"green\" if result.success else \"red\",\n ))\n return CommandResult()\n\n # Default: table of all tasks\n if not tasks:\n render_system(t(\"tasks.empty_hint\"), C_DIM)\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"id\", style=C_BRAND)\n table.add_column(t(\"tasks.col.state\"), style=\"white\")\n table.add_column(t(\"tasks.col.elapsed\"), style=C_DIM, justify=\"right\")\n table.add_column(t(\"tasks.col.description\"), style=\"white\")\n now = _time.monotonic()\n status_style = {\n \"running\": \"[yellow]● running[/]\",\n \"completed\": \"[green]✓ completed[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"killed\": \"[dim]■ killed[/]\",\n }\n for task in tasks:\n elapsed = now - task.start_time if task.start_time else 0.0\n table.add_row(\n task.agent_id,\n status_style.get(task.status, task.status),\n f\"{elapsed:.1f}s\",\n task.description or \"(none)\",\n )\n running_n = sum(1 for task in tasks if task.status == \"running\")\n ctx.console.print(Panel(\n table,\n title=t(\"tasks.table_title\", n=len(tasks), r=running_n),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n render_system(t(\"tasks.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _dream(ctx, arg):\n \"\"\"Inspect / control Auto-Dream (background memory consolidation).\"\"\"\n agent = ctx.agent\n service = agent.auto_dream\n sub = arg.strip().lower()\n\n if service is None:\n render_system(t(\"dream.disabled_hint\"), C_DIM)\n return CommandResult()\n\n if sub in (\"off\", \"disable\", \"stop\"):\n agent.disable_auto_dream()\n render_system(t(\"dream.off\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"force\", \"now\", \"立刻\"):\n render_system(t(\"dream.forcing\"), C_DIM)\n await service.force_dream()\n stats = service.stats\n render_system(\n t(\"dream.forced_done\", e=stats.extractions, c=stats.consolidations),\n C_SUCCESS,\n )\n return CommandResult()\n\n if sub in (\"consolidate\", \"sum\"):\n render_system(t(\"dream.consolidating\"), C_DIM)\n summary = await service.consolidate_now()\n if summary:\n ctx.console.print(Panel(\n summary,\n title=t(\"dream.consolidate_title\"),\n title_align=\"left\",\n border_style=\"magenta\",\n ))\n else:\n render_system(t(\"dream.consolidate_empty\"), C_DIM)\n return CommandResult()\n\n # Default: show stats + config\n stats = service.stats\n cfg = service.config\n mem_count = (\n len(agent.session_memory) if agent.session_memory is not None else 0\n )\n state_tag = (\n t(\"dream.stats_state_dreaming\") if stats.currently_dreaming\n else t(\"dream.stats_state_idle\")\n )\n ctx.console.print(Panel(\n t(\n \"dream.stats_body\",\n state=state_tag,\n e=stats.extractions,\n c=stats.consolidations,\n last=stats.last_run_at or \"[dim]—[/]\",\n err=stats.last_error or f\"[dim]{t('common.none')}[/]\",\n mem=mem_count,\n interval=cfg.interval_turns,\n cons=cfg.consolidation_every,\n ),\n title=t(\"dream.stats_title\"), title_align=\"left\", border_style=\"magenta\",\n ))\n return CommandResult()\n\n async def _mcp(ctx, arg):\n \"\"\"MCP server control.\n\n /mcp list connected servers and their tools\n /mcp reconnect <n> drop and re-dial a server\n /mcp disconnect <n> disconnect and unregister tools\n \"\"\"\n agent = ctx.agent\n mgr = agent.mcp_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n\n async def _reconnect(name: str) -> None:\n state = mgr.get(name)\n if state is None:\n render_system(t(\"mcp.no_server\", name=name), C_WARN)\n return\n cfg = state.config\n await mgr.disconnect(name)\n new_state = await agent.connect_mcp(cfg)\n if new_state.status == \"connected\":\n render_system(\n t(\"mcp.reconnect_ok\", name=name, n=len(new_state.tools)),\n C_SUCCESS,\n )\n else:\n render_system(\n t(\"mcp.reconnect_failed\", name=name, err=new_state.error),\n C_WARN,\n )\n\n if sub_lower.startswith(\"reconnect \"):\n await _reconnect(sub.split(maxsplit=1)[1].strip())\n return CommandResult()\n\n if sub_lower.startswith(\"disconnect \"):\n name = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.disconnect(name)\n if ok:\n # Also drop the tools from the registry\n for tname in list(agent._tool_registry.list_tools()):\n if tname.startswith(f\"mcp__{name}__\"):\n agent._tool_registry.unregister(tname)\n render_system(t(\"mcp.disconnected\", name=name), C_SUCCESS)\n else:\n render_system(t(\"mcp.not_connected\", name=name), C_DIM)\n return CommandResult()\n\n # Default: show table\n servers = mgr.servers()\n if not servers:\n ctx.console.print(Panel(\n t(\"mcp.empty_body\", path=str(_CONFIG_DIR / \"mcp.json\")),\n border_style=C_DIM, title=\"MCP\", title_align=\"left\",\n ))\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"server\", style=C_BRAND)\n table.add_column(t(\"mcp.col.status\"), style=\"white\")\n table.add_column(t(\"mcp.col.tools\"), justify=\"right\", style=C_DIM)\n table.add_column(t(\"mcp.col.detail\"), style=C_DIM)\n status_mark = {\n \"connected\": \"[green]● connected[/]\",\n \"connecting\": \"[yellow]● connecting[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"closed\": \"[dim]■ closed[/]\",\n \"disabled\": \"[dim]⊘ disabled[/]\",\n \"idle\": \"[dim]idle[/]\",\n }\n for s in servers:\n cmd_preview = f\"{s.config.command} {' '.join(s.config.args[:3])}\"\n if len(s.config.args) > 3:\n cmd_preview += \" …\"\n detail = s.error if s.error else cmd_preview\n table.add_row(\n s.config.name,\n status_mark.get(s.status, s.status),\n str(len(s.tools)),\n detail,\n )\n ctx.console.print(Panel(\n table,\n title=t(\"mcp.table_title\", n=len(servers)),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n # Per-server tool breakdown (compact)\n for s in servers:\n if not s.tools:\n continue\n tool_names = \", \".join(tool.name for tool in s.tools)\n ctx.console.print(\n f\" [dim]{s.config.name}:[/] {tool_names}\"\n )\n render_system(t(\"mcp.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _vim(ctx, arg):\n from prompt_toolkit.enums import EditingMode\n app = ctx.pt.app\n if app.editing_mode == EditingMode.VI:\n app.editing_mode = EditingMode.EMACS\n render_system(t(\"vim.off\"), C_SUCCESS)\n else:\n app.editing_mode = EditingMode.VI\n render_system(t(\"vim.on\"), C_SUCCESS)\n return CommandResult()\n\n for cmd in [\n SlashCommand(\"/help\", lambda: t(\"cmd.help\"), _help),\n SlashCommand(\"/status\", lambda: t(\"cmd.status\"), _status),\n SlashCommand(\"/mode\", lambda: t(\"cmd.mode\"), _mode,\n subargs=((\"general\", lambda: t(\"cmd.sub.mode.general\")),\n (\"plan\", lambda: t(\"cmd.sub.mode.plan\")),\n (\"review\", lambda: t(\"cmd.sub.mode.review\")))),\n SlashCommand(\"/plan\", lambda: t(\"cmd.plan\"), _plan,\n subargs=((\"on\", lambda: t(\"cmd.sub.plan.on\")),\n (\"approve\", lambda: t(\"cmd.sub.plan.approve\")),\n (\"cancel\", lambda: t(\"cmd.sub.plan.cancel\")),\n (\"off\", lambda: t(\"cmd.sub.plan.off\")))),\n SlashCommand(\"/diff\", lambda: t(\"cmd.diff\"), _diff),\n SlashCommand(\"/tasks\", lambda: t(\"cmd.tasks\"), _tasks,\n subargs=((\"clear\", lambda: t(\"cmd.sub.tasks.clear\")),\n (\"stop\", lambda: t(\"cmd.sub.tasks.stop\")))),\n SlashCommand(\"/mcp\", lambda: t(\"cmd.mcp\"), _mcp,\n subargs=((\"reconnect\", lambda: t(\"cmd.sub.mcp.reconnect\")),\n (\"disconnect\", lambda: t(\"cmd.sub.mcp.disconnect\")))),\n SlashCommand(\"/dream\", lambda: t(\"cmd.dream\"), _dream,\n subargs=((\"force\", lambda: t(\"cmd.sub.dream.force\")),\n (\"consolidate\", lambda: t(\"cmd.sub.dream.consolidate\")),\n (\"off\", lambda: t(\"cmd.sub.dream.off\")))),\n SlashCommand(\"/perm\", lambda: t(\"cmd.perm\"), _perm,\n subargs=((\"default\", lambda: t(\"cmd.sub.perm.default\")),\n (\"acceptEdits\", lambda: t(\"cmd.sub.perm.accept_edits\")),\n (\"plan\", lambda: t(\"cmd.sub.perm.plan\")),\n (\"bypass\", lambda: t(\"cmd.sub.perm.bypass\")))),\n SlashCommand(\"/compact\", lambda: t(\"cmd.compact\"), _compact),\n SlashCommand(\"/reset\", lambda: t(\"cmd.reset\"), _reset),\n SlashCommand(\"/save\", lambda: t(\"cmd.save\"), _save),\n SlashCommand(\"/load\", lambda: t(\"cmd.load\"), _load),\n SlashCommand(\"/resume\", lambda: t(\"cmd.resume\"), _resume),\n SlashCommand(\"/config\", lambda: t(\"cmd.config\"), _config),\n SlashCommand(\"/forget\", lambda: t(\"cmd.forget\"), _forget),\n SlashCommand(\"/vim\", lambda: t(\"cmd.vim\"), _vim),\n SlashCommand(\"/lang\", lambda: t(\"cmd.lang\"), _lang,\n subargs=((\"en\", lambda: t(\"cmd.sub.lang.en\")),\n (\"zh\", lambda: t(\"cmd.sub.lang.zh\")))),\n SlashCommand(\"/quit\", lambda: t(\"cmd.quit\"), _quit,\n aliases=(\"/exit\", \"/q\")),\n ]:\n reg.register(cmd)\n\n # Let users drop ~/.lumen/commands/*.py to add their own commands.\n loaded = reg.discover_user_commands(_CONFIG_DIR / \"commands\")\n if loaded:\n render_system(t(\"msg.user_cmd_loaded\", names=\", \".join(loaded)), C_DIM)\n\n return reg\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Keybindings / bottom toolbar\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _make_key_bindings() -> KeyBindings:\n \"\"\"\n Main input-box keybindings:\n · Shift+Tab cycle general → plan → review → general\n · Enter submit the current input (overrides the default\n newline behaviour under multiline mode)\n · Enter (completion on) accept the active completion without submitting\n · Shift+Enter / Alt+Enter / Ctrl+J insert a newline\n\n About Shift+Enter:\n Terminals by default don't distinguish Shift+Enter from Enter — both\n send \\\\r. To make Shift+Enter insert a newline the terminal must emit\n one of the escape sequences below (we recognise all of them):\n · ESC [ 13 ; 2 u (CSI-u / kitty keyboard protocol; on by\n default in WezTerm/Ghostty/Kitty)\n · ESC [ 27 ; 2 ; 13 ~ (xterm modifyOtherKeys; on by default in\n xterm/urxvt)\n · ESC \\\\r (same as Alt+Enter — iTerm2 users can\n bind this via Preferences → Profiles →\n Keys, adding Shift+Return → Send Escape\n Sequence with an empty payload.)\n On macOS Terminal / iTerm2 default setups Shift+Enter is\n indistinguishable — use Alt+Enter as the fallback.\n \"\"\"\n kb = KeyBindings()\n\n @kb.add(\"s-tab\")\n def _toggle_mode(event):\n \"\"\"Cycle: general → plan → review → general.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n inner = _INNER_PT_REF.get(\"session\") or _PT_SESSION_REF.get(\"session\")\n if agent is None:\n return\n if agent.review_mode:\n # review → general\n agent.disable_review_mode()\n elif agent.plan_mode:\n # plan → review\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n else:\n # general → plan\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n # Force the bottom toolbar to redraw\n event.app.invalidate()\n\n @kb.add(\"enter\", filter=completion_is_selected)\n def _accept_completion(event):\n \"\"\"User navigated into the completion menu — Enter accepts, doesn't submit.\"\"\"\n event.current_buffer.complete_state = None\n\n @kb.add(\"enter\")\n def _submit(event):\n \"\"\"Plain Enter submits the line (overrides multiline-mode default newline).\"\"\"\n event.current_buffer.validate_and_handle()\n\n def _insert_newline(event):\n event.current_buffer.newline()\n\n # Alt+Enter (equivalent to ESC \\r) — the main newline key for iTerm2 users\n kb.add(\"escape\", \"enter\")(_insert_newline)\n\n # Ctrl+J — fallback when Alt is swallowed by the OS / window manager\n kb.add(\"c-j\")(_insert_newline)\n\n # Shift+Enter (CSI-u encoding): ESC [ 13 ; 2 u\n # prompt_toolkit parses the ESC prefix as \"escape\" then matches the\n # remaining bytes one-by-one.\n kb.add(\"escape\", \"[\", \"1\", \"3\", \";\", \"2\", \"u\")(_insert_newline)\n\n # Shift+Enter (xterm modifyOtherKeys encoding): ESC [ 27 ; 2 ; 13 ~\n kb.add(\"escape\", \"[\", \"2\", \"7\", \";\", \"2\", \";\", \"1\", \"3\", \"~\")(_insert_newline)\n\n return kb\n\n\ndef _bottom_toolbar():\n \"\"\"Persistent bottom status bar: mode · phase · keybinding hints.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n if agent is None:\n return \"\"\n if agent.review_mode:\n phase = agent.review_state.phase\n phase_text = {\n ReviewPhase.IDLE: t(\"toolbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"toolbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"toolbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"toolbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"toolbar.review_complete\"),\n }.get(phase, \"\")\n mode_part = t(\"toolbar.mode_review\", phase=phase_text)\n elif agent.plan_mode:\n mode_part = t(\"toolbar.mode_plan\")\n else:\n mode_part = t(\"toolbar.mode_general\")\n perm_tag = {\n PermissionMode.DEFAULT: \"\",\n PermissionMode.ACCEPT_EDITS: t(\"toolbar.perm_accept_edits\"),\n PermissionMode.PLAN: t(\"toolbar.perm_plan\"),\n PermissionMode.BYPASS: t(\"toolbar.perm_bypass\"),\n }[agent.permissions.mode]\n return t(\"toolbar.line\", mode=mode_part, perm=perm_tag, model=agent.model)\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main chat loop\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"Run the main chat loop. Returns True if the user asked to reconfigure, False to exit.\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\n\n Pauses any active Rich Live region so prompt_toolkit can own the\n terminal cleanly, and uses a dedicated modal PromptSession (no\n toolbar / completer / Shift+Tab binding) so keystrokes never get\n intercepted by the main input box's UX helpers.\n \"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = _safe_text(str(tool_input[key]))\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n modal_pt = _INNER_PT_REF.get(\"session\") or pt\n with _LivePause():\n console.print()\n console.print(Panel(\n _safe_text(t(\"permission.panel_body\", tool=tool_name, hint=arg_hint)),\n title=t(\"permission.panel_title\"), title_align=\"left\",\n border_style=\"yellow\",\n ))\n try:\n answer = await modal_pt.prompt_async(t(\"permission.prompt\"))\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # project directory tree\n DefinitionsTool(), # extract class/function definitions from a file\n FileReadTool(), # read file contents by line range\n FileWriteTool(), # create / overwrite files\n FileEditTool(), # exact find-and-replace editing\n GlobTool(), # match file paths by pattern\n GrepTool(), # search inside file contents\n BashTool(), # run shell commands\n ],\n )\n except Exception as e:\n render_error(t(\"msg.agent_failed\", err=e))\n return True\n\n # Register sub_agent + task_* tools so the model can spawn and observe\n # background work, and `/tasks` has something to show.\n agent.enable_subagents()\n\n # Discovery utilities: tool_search (rank tools by query) + brief (session summary).\n agent.enable_discovery_tools()\n\n # Auto-Dream: background memory consolidation. Runs async after every\n # N turns and periodically merges entries into narrative summaries.\n # Disable with LUMEN_NO_DREAM=1 if you want the old inline behavior.\n if os.environ.get(\"LUMEN_NO_DREAM\") != \"1\":\n agent.enable_auto_dream()\n\n # Auto-connect MCP servers declared in ~/.lumen/mcp.json (if any).\n mcp_cfg_path = _CONFIG_DIR / \"mcp.json\"\n if mcp_cfg_path.exists():\n try:\n mcp_states = await agent.connect_mcp_from_config(mcp_cfg_path)\n if mcp_states:\n ok = [s for s in mcp_states if s.status == \"connected\"]\n bad = [s for s in mcp_states if s.status == \"failed\"]\n if ok:\n names = \", \".join(\n f\"{s.config.name}({len(s.tools)})\" for s in ok\n )\n render_system(t(\"startup.mcp_connected\", names=names), C_DIM)\n for s in bad:\n render_system(\n t(\"startup.mcp_failed\", name=s.config.name, err=s.error),\n C_WARN,\n )\n except Exception as e:\n render_system(t(\"startup.mcp_load_failed\", err=e), C_WARN)\n\n _AGENT_REF[\"agent\"] = agent\n\n # Desktop notifier — fires on turns that exceed LUMEN_NOTIFY_THRESHOLD (15s default).\n # Disable entirely with LUMEN_NOTIFY=0.\n notifier = Notifier()\n\n # File edit tracker — session-scoped snapshot of files on first write/edit,\n # powers `/diff`.\n edit_tracker = FileEditTracker()\n edit_tracker.install(agent.hooks)\n\n # Build the slash command registry (scans ~/.lumen/commands/*.py for user cmds).\n command_registry = _build_command_registry()\n # Let the completer see registered commands (inc. user-loaded ones).\n completer = pt.completer\n if hasattr(completer, \"bind_registry\"):\n completer.bind_registry(command_registry)\n\n # Modal prompt session (for permission / approval / wizard sub-prompts).\n # Set up in main_async(); here we just reach for it.\n inner_pt = _INNER_PT_REF.get(\"session\") or pt\n\n # Startup info\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), display_provider_name(config))\n info.add_row(t(\"col.model\"), agent.model)\n info.add_row(t(\"col.session_id\"), agent.session.session_id[:12] + \"...\")\n info.add_row(t(\"col.context_window\"), f\"{agent.context_window:,} tokens\")\n info.add_row(t(\"col.auto_compact\"), t(\"startup.auto_compact\"))\n if agent.transcript:\n info.add_row(t(\"col.auto_save\"), t(\"startup.auto_save\", path=agent.transcript.path))\n info.add_row(t(\"col.git_state\"), t(\"startup.git_state\"))\n info.add_row(t(\"col.memory_files\"), t(\"startup.memory_files\"))\n info.add_row(t(\"col.tools_read\"), t(\"startup.tools_read\"))\n info.add_row(t(\"col.tools_write\"), t(\"startup.tools_write\"))\n info.add_row(t(\"col.permissions\"), t(\"startup.permissions\"))\n info.add_row(t(\"col.retry\"), t(\"startup.retry\"))\n info.add_row(t(\"col.file_cache\"), t(\"startup.file_cache\"))\n info.add_row(\n t(\"col.desktop_notify\"),\n t(\"startup.notifier_on\", channel=notifier.channel, sec=int(notifier.default_threshold_s))\n if notifier.enabled else t(\"startup.notifier_off\"),\n )\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(info, border_style=C_DIM, title=t(\"startup.ready\"), title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(t(\"startup.hint\"))\n\n _last_ctrl_c = False\n\n try:\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n\n # ── Slash commands (dispatched via CommandRegistry) ──────────────\n if user_input.startswith(\"/\"):\n head, _, rest = user_input.partition(\" \")\n command = command_registry.find(head.lower())\n if command is None:\n render_system(t(\"msg.unknown_command\", cmd=user_input), C_WARN)\n continue\n ctx = ChatContext(\n agent=agent, config=config, console=console,\n pt=pt, inner_pt=inner_pt, notifier=notifier,\n registry=command_registry, edit_tracker=edit_tracker,\n )\n try:\n result = await command.handler(ctx, rest.strip())\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n continue\n except Exception as e:\n render_error(t(\"msg.cmd_failed\", cmd=command.name, err=e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n continue\n if result.new_agent is not None:\n agent = result.new_agent\n _AGENT_REF[\"agent\"] = agent\n if result.reconfigure:\n return True\n if result.quit:\n return False\n continue\n\n # ── Normal chat turn ──────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n import time as _time\n _turn_start = _time.monotonic()\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n _elapsed = _time.monotonic() - _turn_start\n notifier.notify_if_slow(\n \"Lumen\",\n t(\"msg.reply_done\", preview=user_input[:40]),\n elapsed_s=_elapsed,\n )\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n _last_ctrl_c = True\n render_system(t(\"msg.cancel_reply\"), C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n finally:\n # Flush and close transcript on any exit path\n try:\n await agent.close()\n except Exception:\n pass\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main entry point\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Try to apply saved language preference BEFORE we render anything.\n try:\n _pre = load_config()\n if _pre is not None:\n _lang = normalize_language(_pre.language)\n if _lang:\n set_language(_lang)\n except Exception:\n pass\n\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(t(\"banner.tagline\"), style=C_DIM)))\n console.print(Align.center(Text(t(\"banner.subtitle\"), style=\"dim cyan\")))\n console.print()\n\n # Ensure config dir exists before FileHistory tries to open/append to it.\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n except Exception:\n pass\n\n # Vim mode: opt-in via LUMEN_VIM=1 or toggled at runtime via /vim\n vi_mode = os.environ.get(\"LUMEN_VIM\", \"\").strip() in (\"1\", \"true\", \"yes\", \"on\")\n\n pt = PromptSession(\n history=FileHistory(str(_CONFIG_DIR / \"history\")),\n style=PTStyle.from_dict({\n \"prompt\": \"ansicyan bold\",\n \"bottom-toolbar\": \"bg:#1a1a1a #888888\",\n \"completion-menu.completion\": \"bg:#1a1a1a #cccccc\",\n \"completion-menu.completion.current\": \"bg:#005f87 #ffffff bold\",\n \"completion-menu.meta.completion\": \"bg:#1a1a1a #707070 italic\",\n \"completion-menu.meta.completion.current\": \"bg:#005f87 #cfcfcf italic\",\n }),\n completer=SlashCompleter(),\n complete_while_typing=True,\n key_bindings=_make_key_bindings(),\n bottom_toolbar=_bottom_toolbar,\n refresh_interval=0.5,\n multiline=True, # Enter=submit, Alt+Enter/Ctrl+J=newline (see _make_key_bindings)\n vi_mode=vi_mode,\n )\n _PT_SESSION_REF[\"session\"] = pt\n\n # Inner prompt session — bare & separate, for modal prompts (permissions,\n # approvals). No toolbar, no completer, no Shift+Tab binding. This mirrors\n # src/'s pattern where permission dialogs use a dedicated Select component\n # with its own isolated input handling, so no keybindings leak in from\n # the main input box and no background refresh timer redraws over the prompt.\n inner_pt = PromptSession(\n history=None, # modal answers shouldn't pollute history\n style=PTStyle.from_dict({\"prompt\": \"ansiyellow bold\"}),\n )\n _INNER_PT_REF[\"session\"] = inner_pt\n\n # If CLI already passed enough args, skip the wizard.\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n language=get_language(),\n )\n await chat_loop(config, pt)\n return\n\n # Otherwise: config → chat → (maybe reconfigure) loop\n config: AgentConfig | None = None\n skip_saved_once = False # True on /config reconfigure: skip the \"use saved?\" prompt\n\n while True:\n # Prefer the saved config unless the user actively ran /config\n saved = None if skip_saved_once else load_config()\n skip_saved_once = False\n\n if saved is not None:\n # Apply saved language preference immediately.\n saved_lang = normalize_language(saved.language)\n if saved_lang and saved_lang != get_language():\n set_language(saved_lang)\n masked_key = (saved.api_key[:8] + \"...\" + saved.api_key[-4:]\n if len(saved.api_key) > 12 else \"****\")\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), display_provider_name(saved))\n info.add_row(t(\"col.model\"), saved.model)\n if saved.base_url:\n info.add_row(t(\"col.base_url\"), saved.base_url)\n info.add_row(t(\"col.api_key\"), masked_key)\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(\n info, border_style=\"green\",\n title=t(\"wizard.saved_detected\"), title_align=\"left\",\n ))\n try:\n raw = await pt.prompt_async(t(\"wizard.use_saved_prompt\"))\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n choice = raw.strip().lower()\n if choice in (\"\", \"y\", \"yes\"):\n config = saved\n elif choice == \"f\":\n forget_config()\n render_system(t(\"wizard.forgot\"), C_SUCCESS)\n config = None\n else:\n config = None # fall through to wizard\n\n if config is None:\n try:\n config = await setup_wizard(pt)\n save_config(config)\n render_system(\n t(\"wizard.saved_to\", path=_CONFIG_FILE),\n C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(t(\"wizard.reconfigure\"), style=C_BRAND))\n config = None\n skip_saved_once = True\n\n\ndef main() -> None:\n # Make stdout/stderr surrogate-safe so lone UTF-16 surrogates produced by\n # streaming chunk splits never crash the terminal writer. Replaces any\n # unencodable code point with '?' at the final write instead of raising\n # UnicodeEncodeError from deep inside rich/Live/Panel render paths.\n for _stream in (sys.stdout, sys.stderr):\n try:\n _stream.reconfigure(encoding=\"utf-8\", errors=\"replace\") # type: ignore[attr-defined]\n except Exception:\n pass\n\n import argparse\n parser = argparse.ArgumentParser(description=t(\"cli.description\"))\n parser.add_argument(\"--model\", default=None, help=t(\"cli.arg.model\"))\n parser.add_argument(\"--api-key\", default=None, help=t(\"cli.arg.api_key\"))\n parser.add_argument(\"--base-url\", default=None, help=t(\"cli.arg.base_url\"))\n parser.add_argument(\n \"--lang\", default=None, choices=list(SUPPORTED_LANGUAGES),\n help=t(\"cli.arg.lang\"),\n )\n args = parser.parse_args()\n if args.lang:\n set_language(args.lang)\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "ef06a410eaf02f711dd4c78a9387bcab6bc03715": {"t": 1785773565.818819, "n": 89382, "h": "02f63ed7be4ce136", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\nRun directly and pick a model / enter your API key inside the UI:\n python chat.py\n\nOr specify them on the command line (skipping the wizard):\n python chat.py --model gpt-4o --api-key sk-...\n\nSlash commands:\n /help Show help\n /status Token usage and session info\n /mode Switch general / plan / review (or cycle with Shift+Tab)\n /plan Plan Mode control (on | approve | cancel)\n /lang Switch UI language (en | zh)\n /compact Manually compact context\n /reset Clear conversation history\n /save Save session to file\n /load Load session from file\n /config Reconfigure model and key\n /forget Delete locally saved API key\n /quit Quit\n\nInteraction enhancements:\n • Type / to pop open the command completer\n • Type @ to auto-complete project file paths (30s cache)\n • /load <tab> completes files in the project; picking one loads that session\n • Multi-line input: Enter sends, Shift+Enter / Alt+Enter / Ctrl+J newline\n (Shift+Enter requires a terminal emitting CSI-u or modifyOtherKeys escapes;\n iTerm2/Terminal.app default doesn't distinguish — fall back to Alt+Enter)\n • Input history persisted at ~/.lumen/history, retained across sessions\n • Shift+Tab cycles modes\n • Bottom bar always shows the current mode / phase\n • After first configuration the key is saved in ~/.lumen/config.json (mode 0600),\n reused on next launch — clear with /forget.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport re\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.completion import Completer, Completion\nfrom prompt_toolkit.filters import completion_is_selected\nfrom prompt_toolkit.history import FileHistory\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import (\n Agent, PermissionChecker, PermissionBehavior, PermissionMode,\n HookRegistry, RetryConfig, Notifier, FileEditTracker,\n)\nfrom lumen.i18n import (\n t, tt, set_language, get_language,\n SUPPORTED_LANGUAGES, language_display_name, normalize_language,\n)\nfrom lumen.context.transcript import TranscriptReader, list_recent_sessions\nfrom chat_commands import (\n ChatContext, CommandRegistry, CommandResult, SlashCommand,\n)\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.services.review_state import ReviewPhase\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\nfrom lumen.tools.approval import ConsoleApprovalHandler\n\nconsole = Console()\n\n# ── Color palette ────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ── Shared state (accessed by keybinding / bottom_toolbar closures) ──────────\n_AGENT_REF: dict = {\"agent\": None}\n_PT_SESSION_REF: dict = {\"session\": None} # Main input session (/mode, Shift+Tab)\n_INNER_PT_REF: dict = {\"session\": None} # Modal input session (permission / approval)\n_LIVE_REF: dict = {\"live\": None} # Current agent_response Live, for pause/resume\n\n\nclass _LivePause:\n \"\"\"Context manager that suspends any active Rich Live region while a\n modal prompt runs, then resumes it. Prevents prompt_toolkit from fighting\n Rich for terminal control, which is what makes modal prompts appear to\n 'swallow Enter' — they were never visible in the first place.\"\"\"\n\n def __enter__(self):\n self._live = _LIVE_REF.get(\"live\")\n if self._live is not None:\n try:\n self._live.stop()\n except Exception:\n self._live = None\n return self\n\n def __exit__(self, exc_type, exc, tb):\n if self._live is not None:\n try:\n self._live.start()\n except Exception:\n pass\n return False\n\n\nclass _LivePausingApprovalHandler:\n \"\"\"Wraps any ApprovalHandler so each `.request()` call is bracketed by\n a `_LivePause`. Without this, the approval panel draws inside an active\n Rich Live region and prompt_toolkit can't take the terminal — the user\n sees nothing and their Enter appears to do nothing.\"\"\"\n\n def __init__(self, inner) -> None:\n self._inner = inner\n\n async def request(self, phase, title, content):\n with _LivePause():\n return await self._inner.request(phase, title, content)\n\n\n# ── Slash / @path completion ──────────────────────────────────────────────────\nclass SlashCompleter(Completer):\n \"\"\"\n Unified completer, three trigger categories:\n · `/` Top-level command menu (/help /mode /load …)\n · `/mode r…` Sub-arguments (general / review)\n · `/load …` Project file paths (for loading session files)\n · `@path/to/…` Project file references (inserts text, agent sees the path)\n\n File-tree scan results cached for 30s, auto-skipping common noise dirs\n (.git / node_modules / __pycache__ / .venv / …) so complete_while_typing\n doesn't walk the disk on every keystroke.\n \"\"\"\n\n # Fallback lists used only before the CommandRegistry is attached\n # (e.g. during setup_wizard). After chat_loop builds the registry, it\n # calls `.bind_registry(reg)` and we read live from there.\n @property\n def TOP_LEVEL(self):\n return [\n (\"/help\", t(\"cmd.help\")),\n (\"/config\", t(\"cmd.config\")),\n (\"/quit\", t(\"cmd.quit\")),\n ]\n\n @property\n def MODE_ARGS(self):\n return [\n (\"general\", t(\"cmd.sub.mode.general\")),\n (\"review\", t(\"cmd.sub.mode.review\")),\n ]\n\n SKIP_DIRS = {\n \".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\",\n \"dist\", \"build\", \".pytest_cache\", \".mypy_cache\", \".ruff_cache\",\n \".tox\", \".next\", \".turbo\", \".cache\", \"target\", \".idea\", \".vscode\",\n }\n _FILE_CACHE_TTL = 30.0\n _FILE_CACHE_MAX = 2000\n\n def __init__(self) -> None:\n self._file_cache: list[str] | None = None\n self._file_cache_ts: float = 0.0\n self._registry = None # type: ignore # Optional[CommandRegistry]\n\n def bind_registry(self, registry) -> None:\n \"\"\"Attach a CommandRegistry so completion reflects user-loaded commands.\"\"\"\n self._registry = registry\n\n def _top_level_items(self) -> list[tuple[str, str]]:\n if self._registry is not None:\n return [(c.name, c.description_text()) for c in self._registry.all()]\n return list(self.TOP_LEVEL)\n\n def _subargs_for(self, head: str) -> list[tuple[str, str]]:\n if self._registry is not None:\n cmd = self._registry.find(head)\n if cmd is not None and cmd.subargs:\n return cmd.subarg_items()\n if head == \"/mode\":\n return list(self.MODE_ARGS)\n return []\n\n def _scan_files(self) -> list[str]:\n import time\n now = time.time()\n if (\n self._file_cache is not None\n and (now - self._file_cache_ts) < self._FILE_CACHE_TTL\n ):\n return self._file_cache\n\n cwd = Path.cwd()\n results: list[str] = []\n for root, dirs, files in os.walk(cwd):\n dirs[:] = [\n d for d in dirs\n if d not in self.SKIP_DIRS and not d.startswith(\".\")\n ]\n root_path = Path(root)\n for f in files:\n if f.startswith(\".\") and f not in (\".gitignore\", \".env.example\"):\n continue\n full = root_path / f\n try:\n rel = full.relative_to(cwd)\n except ValueError:\n continue\n results.append(str(rel))\n if len(results) >= self._FILE_CACHE_MAX:\n break\n if len(results) >= self._FILE_CACHE_MAX:\n break\n results.sort()\n self._file_cache = results\n self._file_cache_ts = now\n return results\n\n def _rank_files(self, prefix: str, limit: int = 50) -> list[str]:\n \"\"\"Return files matching prefix, ranked: basename-start > path-start > substring.\"\"\"\n if not prefix:\n return self._scan_files()[:limit]\n lower = prefix.lower()\n ranked: list[tuple[int, str]] = []\n for path in self._scan_files():\n p_lower = path.lower()\n base_lower = Path(path).name.lower()\n if base_lower.startswith(lower):\n rank = 0\n elif p_lower.startswith(lower):\n rank = 1\n elif lower in p_lower:\n rank = 2\n else:\n continue\n ranked.append((rank, path))\n ranked.sort(key=lambda x: (x[0], x[1]))\n return [p for _, p in ranked[:limit]]\n\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n\n # ── @path file reference (allowed anywhere in a sentence) ────────────\n at_idx = text.rfind(\"@\")\n if at_idx != -1 and (at_idx == 0 or text[at_idx - 1] in \" \\t\\n\"):\n prefix = text[at_idx + 1:]\n if prefix == \"\" or (\" \" not in prefix and \"\\t\" not in prefix and \"\\n\" not in prefix):\n for path in self._rank_files(prefix):\n yield Completion(\n path, start_position=-len(prefix),\n display=path, display_meta=\"file\",\n )\n return\n\n # ── /load <path> file-path completion ────────────────────────────────\n if text.startswith(\"/load \"):\n arg = text[len(\"/load \"):].lstrip()\n for path in self._rank_files(arg):\n yield Completion(\n path, start_position=-len(arg),\n display=path, display_meta=\"session\",\n )\n return\n\n # ── Sub-arguments (e.g. /mode review, any registered subargs) ───────\n if text.startswith(\"/\") and \" \" in text:\n head, _, rest = text.partition(\" \")\n prefix = rest.lstrip()\n subargs = self._subargs_for(head)\n for arg, desc in subargs:\n if arg.startswith(prefix):\n yield Completion(\n arg, start_position=-len(prefix),\n display=arg, display_meta=desc,\n )\n return\n\n # ── Top-level slash command ──────────────────────────────────────────\n if text.startswith(\"/\"):\n for cmd, desc in self._top_level_items():\n if cmd.startswith(text):\n yield Completion(\n cmd, start_position=-len(text),\n display=cmd, display_meta=desc,\n )\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model catalog\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str # Raw description — may be an i18n key if prefixed with \"i18n:\"\n\n def display(self) -> str:\n \"\"\"Resolve the description, translating if it uses an i18n key.\"\"\"\n if self.desc.startswith(\"i18n:\"):\n return t(self.desc[5:])\n # Mixed template: leading \"<model> \" + suffix i18n key\n if \"||\" in self.desc:\n prefix, key = self.desc.split(\"||\", 1)\n return prefix + t(key)\n return self.desc\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str # Fixed brand name or an i18n key prefixed \"i18n:\"\n key_hint: str # API key format hint (may be i18n: key)\n env_key: str | None # Env var to read the key from\n base_url: str | None # None = let the Agent auto-detect\n models: list[ModelOption]\n needs_key: bool = True\n\n def display_name(self) -> str:\n return t(self.name[5:]) if self.name.startswith(\"i18n:\") else self.name\n\n def display_key_hint(self) -> str:\n return t(self.key_hint[5:]) if self.key_hint.startswith(\"i18n:\") else self.key_hint\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"i18n:provider.getgoapi.name\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini ||provider.model.reasoning\"),\n ModelOption(\"claude-sonnet-4-6\", \"i18n:provider.model.sonnet_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"i18n:provider.model.haiku_fastcheap\"),\n ModelOption(\"deepseek-chat\", \"i18n:provider.model.deepseek_chat\"),\n ModelOption(\"deepseek-reasoner\", \"i18n:provider.model.deepseek_reasoner\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · ||provider.model.allround\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"i18n:provider.ollama.name\", key_hint=\"i18n:provider.ollama.key_hint\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"i18n:provider.custom.name\", key_hint=\"i18n:provider.custom.key_hint\",\n env_key=None,\n base_url=None,\n models=[], # User-supplied\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Configuration wizard\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n language: str = \"en\"\n provider_id: str = \"\" # Stable id (e.g. \"getgoapi\"); enables live re-translation\n\n\ndef display_provider_name(config: AgentConfig) -> str:\n \"\"\"Return the provider's display name, translated live when the provider\n is known by id. Falls back to whatever was stored in `provider_name`.\"\"\"\n pid = config.provider_id or _infer_provider_id(config.provider_name)\n if pid:\n for p in PROVIDERS:\n if p.id == pid:\n return p.display_name()\n return config.provider_name\n\n\ndef _infer_provider_id(stored_name: str) -> str:\n \"\"\"Best-effort recovery of a provider id from a legacy stored display name.\n Matches against the EN + ZH translation of every provider's name.\"\"\"\n if not stored_name:\n return \"\"\n from lumen import i18n as _i18n\n for p in PROVIDERS:\n if not p.name.startswith(\"i18n:\"):\n if p.name == stored_name:\n return p.id\n continue\n key = p.name[5:]\n for lang in _i18n.SUPPORTED_LANGUAGES:\n translated = _i18n.TRANSLATIONS.get(lang, {}).get(key)\n if translated == stored_name:\n return p.id\n return \"\"\n\n\n# ── Config persistence ───────────────────────────────────────────────────────\n# Stored at ~/.lumen/config.json (file mode 0600) so users don't have to\n# re-enter the API key on every launch.\n\n_CONFIG_DIR = Path.home() / \".lumen\"\n_CONFIG_FILE = _CONFIG_DIR / \"config.json\"\n\n\ndef save_config(config: AgentConfig) -> None:\n \"\"\"Write config to ~/.lumen/config.json with 0600 permissions.\"\"\"\n import json\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n data = {\n \"api_key\": config.api_key,\n \"model\": config.model,\n \"base_url\": config.base_url,\n \"provider_name\": config.provider_name,\n \"provider_id\": config.provider_id,\n \"language\": config.language,\n }\n _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding=\"utf-8\")\n try:\n os.chmod(_CONFIG_FILE, 0o600)\n except Exception:\n pass\n except Exception as e:\n render_system(t(\"wizard.save_failed\", err=e), C_DIM)\n\n\ndef load_config() -> AgentConfig | None:\n \"\"\"Load cached config if present and well-formed.\"\"\"\n import json\n if not _CONFIG_FILE.exists():\n return None\n try:\n data = json.loads(_CONFIG_FILE.read_text(encoding=\"utf-8\"))\n if not data.get(\"model\") or not data.get(\"api_key\"):\n return None\n lang = normalize_language(data.get(\"language\")) or get_language()\n return AgentConfig(\n api_key=data[\"api_key\"],\n model=data[\"model\"],\n base_url=data.get(\"base_url\"),\n provider_name=data.get(\"provider_name\") or \"Saved\",\n provider_id=data.get(\"provider_id\") or \"\",\n language=lang,\n )\n except Exception:\n return None\n\n\ndef forget_config() -> bool:\n \"\"\"Delete the saved config file.\"\"\"\n try:\n if _CONFIG_FILE.exists():\n _CONFIG_FILE.unlink()\n return True\n except Exception:\n pass\n return False\n\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"Interactive configuration wizard: pick Provider → pick Model → enter Key.\"\"\"\n console.print()\n console.print(Rule(t(\"wizard.title\"), style=C_BRAND))\n console.print()\n\n # ── Step 1: pick Provider ────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key detected[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.display_name() + detected, key_info)\n\n console.print(Panel(table, title=t(\"wizard.pick_provider\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(t(\"wizard.range_hint\", n=len(PROVIDERS)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(t(\"wizard.selected\", value=provider.display_name()) + \"\\n\")\n\n # ── Step 2: pick Model ───────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(t(\"wizard.custom_base_url\"))).strip()\n model = (await pt.prompt_async(t(\"wizard.custom_model\"))).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(t(\"wizard.custom_empty_model\"))\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.display())\n\n console.print(Panel(model_table, title=t(\"wizard.pick_model\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(t(\"wizard.range_hint\", n=len(provider.models)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(t(\"wizard.selected\", value=model) + \"\\n\")\n\n # ── Step 3: enter API Key ────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(t(\"wizard.local_no_key\"))\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n t(\"wizard.key_env_detected\", name=provider.env_key, masked=masked)\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(t(\"wizard.key_env_used\") + \"\\n\")\n else:\n api_key = await _prompt_key(pt, provider.display_key_hint())\n else:\n console.print(t(\"wizard.key_hint_prefix\", hint=provider.display_key_hint()))\n api_key = await _prompt_key(pt, provider.display_key_hint())\n\n # ── Summary ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(t(\"col.provider\"), provider.display_name())\n summary.add_row(t(\"col.model\"), model)\n if base_url:\n summary.add_row(t(\"col.base_url\"), base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(t(\"col.api_key\"), key_display)\n console.print(Panel(summary, title=t(\"wizard.summary_title\"), border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.display_name(),\n provider_id=provider.id,\n language=get_language(),\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"API Key input with basic masking prompt.\"\"\"\n while True:\n try:\n key = await pt.prompt_async(t(\"wizard.key_prompt\"))\n key = key.strip()\n if key:\n return key\n console.print(t(\"wizard.key_empty\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI rendering components\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(t(\"tokenbar.model\"), style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(t(\"tokenbar.msgs\"), style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(t(\"tokenbar.compactions\"), style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.review_mode:\n stats.append(\" │ \", style=C_DIM)\n phase = agent.review_state.phase\n phase_label = {\n ReviewPhase.IDLE: t(\"tokenbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"tokenbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"tokenbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"tokenbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"tokenbar.review_complete\"),\n }.get(phase, t(\"tokenbar.review_default\"))\n stats.append(f\"⚑ {phase_label}\", style=\"bold yellow\")\n elif agent.plan_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(t(\"tokenbar.plan_mode\"), style=\"bold blue\")\n\n perm_mode = agent.permissions.mode\n if perm_mode != PermissionMode.DEFAULT:\n stats.append(\" │ \", style=C_DIM)\n perm_label, perm_style = {\n PermissionMode.ACCEPT_EDITS: (\"✎ accept-edits\", \"bold green\"),\n PermissionMode.PLAN: (\"📋 plan-only\", \"bold blue\"),\n PermissionMode.BYPASS: (\"⚠ bypass\", \"bold red\"),\n }[perm_mode]\n stats.append(perm_label, style=perm_style)\n\n border = (\n \"red\" if perm_mode == PermissionMode.BYPASS\n else \"blue\" if perm_mode == PermissionMode.PLAN or agent.plan_mode\n else \"yellow\" if agent.review_mode\n else C_DIM\n )\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\n_LONE_SURROGATE_RE = re.compile(r\"[\\ud800-\\udfff]\")\n\n\ndef _safe_text(s: str) -> str:\n \"\"\"Strip lone UTF-16 surrogates that appear when streaming chunks split a\n multi-unit character in half (common with emoji / CJK across chunk\n boundaries). These codepoints are unencodable as UTF-8 and crash the\n console writer.\"\"\"\n if not s:\n return s\n # Fast path: regex strip of the surrogate range.\n cleaned = _LONE_SURROGATE_RE.sub(\"\", s)\n # Belt-and-braces: round-trip through utf-8 with 'replace' so any\n # remaining unencodable code points become '?' instead of crashing.\n try:\n cleaned.encode(\"utf-8\", \"strict\")\n return cleaned\n except UnicodeEncodeError:\n return cleaned.encode(\"utf-8\", \"replace\").decode(\"utf-8\", \"replace\")\n\n\ndef render_user_message(text_value: str) -> Panel:\n return Panel(\n Text(_safe_text(text_value), style=\"white\"),\n title=Text(t(\"msg.you\"), style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text_value: str, style: str = C_DIM) -> None:\n console.print(f\" {_safe_text(text_value)}\", style=style)\n\n\ndef render_error(text_value: str) -> None:\n console.print(Panel(_safe_text(text_value), border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help(registry: \"CommandRegistry | None\" = None) -> None:\n \"\"\"Render /help. If a CommandRegistry is supplied, the slash-command rows\n are pulled from it so user-loaded commands show up automatically.\"\"\"\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n\n if registry is not None:\n for cmd in registry.all():\n hint = cmd.description_text()\n if cmd.aliases:\n hint += f\" (aliases: {' '.join(cmd.aliases)})\"\n table.add_row(cmd.name, hint)\n else:\n table.add_row(\"/help\", t(\"cmd.help\"))\n table.add_row(\"/quit\", t(\"cmd.quit\"))\n\n # Non-slash keybinding reference\n for row in [\n (\"\", \"\"),\n (\"↑ / ↓\", t(\"help.row.history\")),\n (\"Shift+Enter\", t(\"help.row.shift_enter\")),\n (\"Alt+Enter\", t(\"help.row.alt_enter\")),\n (\"@path\", t(\"help.row.at_path\")),\n (\"Shift+Tab\", t(\"help.row.shift_tab\")),\n (\"Ctrl+C\", t(\"help.row.ctrl_c\")),\n ]:\n table.add_row(*row)\n console.print(Panel(table, title=t(\"help.title\"), border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(t(\"status.model\"), agent.model)\n table.add_row(t(\"status.context_window\"), f\"{agent.context_window:,} tokens\")\n table.add_row(t(\"status.tokens_used\"), f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(t(\"status.messages\"), str(len(agent.messages)))\n table.add_row(t(\"status.compactions\"), str(agent.session.compaction_count))\n table.add_row(t(\"status.session_start\"), agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=t(\"status.title\"), border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent response\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n Run a full agent turn: live-display tool calls and render the final\n answer as Markdown.\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(t(\"msg.lumen_thinking\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── Use a Live panel to show progress while tools run ────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n _LIVE_REF[\"live\"] = live\n tool_log.append(t(\"msg.thinking\"))\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n try:\n await _chat_with_refresh()\n finally:\n _LIVE_REF[\"live\"] = None\n else:\n # No tools: stream output straight through\n collected: list[str] = []\n panel_title = Text(t(\"msg.lumen_streaming\"), style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n _LIVE_REF[\"live\"] = live\n try:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\n _safe_text(\"\".join(collected)),\n code_theme=\"monokai\", inline_code_theme=\"monokai\",\n )\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n finally:\n _LIVE_REF[\"live\"] = None\n return \"\".join(collected)\n\n # ── Render the final answer ──────────────────────────────────────────────\n final_text = _safe_text(response.content if response else \"\")\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(t(\"msg.lumen\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Mode switching\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str, pt: PromptSession | None = None) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n if agent.review_mode:\n mode_label = t(\"mode.review_label\")\n elif agent.plan_mode:\n mode_label = t(\"mode.plan_label\")\n else:\n mode_label = t(\"mode.general_label\")\n console.print(Panel(\n t(\"mode.current\", label=mode_label) + \"\\n\\n\"\n + t(\"mode.hint_review\") + \"\\n\"\n + t(\"mode.hint_plan\") + \"\\n\"\n + t(\"mode.hint_general\") + \"\\n\"\n + t(\"mode.hint_shift_tab\"),\n border_style=C_DIM, title=t(\"mode.panel_title\"), title_align=\"left\",\n ))\n return\n\n if arg in (\"review\", \"r\", \"审阅\", \"careful\"):\n if agent.review_mode:\n render_system(t(\"mode.already_review\"), C_DIM)\n else:\n inner = _INNER_PT_REF.get(\"session\") or pt\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n console.print(Panel(\n t(\"mode.review_panel_body\"),\n border_style=\"yellow\",\n title=t(\"mode.review_panel_title\"),\n title_align=\"left\",\n ))\n\n elif arg in (\"plan\", \"p\", \"方案\", \"计划\"):\n _activate_plan_mode(agent)\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n was_plan = agent.plan_mode\n was_review = agent.review_mode\n if not was_plan and not was_review:\n render_system(t(\"mode.already_general\"), C_DIM)\n else:\n if was_review:\n agent.disable_review_mode()\n if was_plan:\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"mode.back_to_general\"), C_SUCCESS)\n\n else:\n render_system(t(\"mode.unknown\", arg=arg), C_WARN)\n\n\ndef _activate_plan_mode(agent: Agent) -> None:\n if agent.plan_mode:\n render_system(t(\"mode.already_plan\"), C_DIM)\n return\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n console.print(Panel(\n t(\"mode.plan_panel_body\"),\n border_style=\"blue\",\n title=t(\"mode.plan_panel_title\"),\n title_align=\"left\",\n ))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Command handling\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(t(\"compact.running\"), C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n t(\"compact.body\",\n mb=r.messages_before, ma=r.messages_after,\n tb=r.tokens_before, ta=r.tokens_after,\n kept=r.kept_recent_count),\n border_style=\"green\", title=t(\"compact.title\"), title_align=\"left\",\n ))\n except Exception as e:\n render_error(t(\"compact.failed\", err=e))\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(t(\"save.prompt\", default=default), default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(t(\"save.done\", path=path), C_SUCCESS)\n if agent.transcript:\n render_system(t(\"save.transcript_hint\", path=agent.transcript.path), C_DIM)\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n except Exception as e:\n render_error(t(\"save.failed\", err=e))\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(t(\"load.prompt\"))).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(t(\"load.missing\", path=arg))\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(t(\"load.done\", n=len(agent.messages), path=arg), C_SUCCESS)\n return agent\n except Exception as e:\n render_error(t(\"load.failed\", err=e))\n return None\n\n\nasync def handle_resume(config: AgentConfig, pt: PromptSession) -> Agent | None:\n \"\"\"List recent auto-saved sessions and let the user pick one to resume.\"\"\"\n cwd = Path.cwd()\n sessions = list_recent_sessions(cwd, limit=10)\n if not sessions:\n render_system(t(\"resume.none\"), C_WARN)\n return None\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"#\", style=C_NUM, width=4)\n table.add_column(t(\"resume.col.session_id\"), style=\"white\")\n table.add_column(t(\"status.model\"), style=C_DIM)\n table.add_column(t(\"resume.col.messages\"), style=\"white\", justify=\"right\")\n table.add_column(t(\"resume.col.created\"), style=C_DIM)\n table.add_column(t(\"resume.col.last_prompt\"), style=\"white\")\n\n for i, s in enumerate(sessions, 1):\n created = s.created_at[:19].replace(\"T\", \" \") if s.created_at else \"?\"\n prompt_preview = s.last_prompt[:50] + (\"...\" if len(s.last_prompt) > 50 else \"\")\n table.add_row(\n f\"[{i}]\",\n s.session_id[:12] + \"...\",\n s.model or \"?\",\n str(s.message_count),\n created,\n prompt_preview,\n )\n\n console.print(Panel(table, title=t(\"resume.table_title\"), border_style=C_DIM))\n\n try:\n raw = await pt.prompt_async(t(\"resume.pick_prompt\"))\n raw = raw.strip()\n if not raw:\n return None\n idx = int(raw) - 1\n if not (0 <= idx < len(sessions)):\n render_system(t(\"resume.range_hint\", n=len(sessions)), C_WARN)\n return None\n except (ValueError, KeyboardInterrupt, EOFError):\n return None\n\n chosen = sessions[idx]\n try:\n messages, metadata = TranscriptReader.load_session(chosen.file_path)\n if not messages:\n render_error(t(\"resume.empty_file\"))\n return None\n\n # Rebuild a Session from the transcript messages\n from lumen.context.session import Session\n session = Session(model=config.model)\n session.session_id = chosen.session_id\n session.messages = messages\n\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n session=session,\n auto_compact=True,\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n FileWriteTool(), FileEditTool(),\n GlobTool(), GrepTool(), BashTool(),\n ],\n )\n render_system(\n t(\"resume.done\", sid=chosen.session_id[:12], n=len(messages)),\n C_SUCCESS,\n )\n return agent\n except Exception as e:\n render_error(t(\"resume.failed\", err=e))\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Slash command registry\n# ─────────────────────────────────────────────────────────────────────────────\n# All slash commands (/help, /status, /mode, /load, …) register through the\n# CommandRegistry. Both SlashCompleter and /help read from here — single\n# source of truth. At startup we also auto-scan ~/.lumen/commands/*.py so\n# users can add their own commands without editing chat.py.\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _build_command_registry() -> CommandRegistry:\n \"\"\"Build the built-in command registry. Handlers close over ChatContext\n (passed in at dispatch time), so they can see live agent/config/pt state.\"\"\"\n reg = CommandRegistry()\n\n async def _quit(ctx, arg):\n render_system(t(\"common.goodbye\"), C_DIM)\n return CommandResult(quit=True)\n\n async def _help(ctx, arg):\n render_help(ctx.registry)\n return CommandResult()\n\n async def _status(ctx, arg):\n render_status(ctx.agent)\n return CommandResult()\n\n async def _mode(ctx, arg):\n handle_mode(ctx.agent, arg, pt=ctx.pt)\n return CommandResult()\n\n async def _compact(ctx, arg):\n await handle_compact(ctx.agent)\n return CommandResult()\n\n async def _reset(ctx, arg):\n ctx.agent.reset()\n render_system(t(\"reset.done\"), C_SUCCESS)\n return CommandResult()\n\n async def _save(ctx, arg):\n await handle_save(ctx.agent, ctx.pt)\n return CommandResult()\n\n async def _load(ctx, arg):\n new = await handle_load(arg, ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _resume(ctx, arg):\n new = await handle_resume(ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _config(ctx, arg):\n render_system(t(\"config.back_to_wizard\"), C_DIM)\n return CommandResult(reconfigure=True)\n\n async def _forget(ctx, arg):\n if forget_config():\n render_system(t(\"forget.done\", path=_CONFIG_FILE), C_SUCCESS)\n else:\n render_system(t(\"forget.nothing\"), C_DIM)\n return CommandResult()\n\n async def _lang(ctx, arg):\n \"\"\"Switch UI language live.\"\"\"\n arg = arg.strip().lower()\n if not arg:\n current = language_display_name()\n ctx.console.print(Panel(\n t(\"lang.current\", name=current) + \"\\n\\n\" + t(\"lang.available\"),\n border_style=C_DIM, title=\"Language / \\u8bed\\u8a00\",\n title_align=\"left\",\n ))\n return CommandResult()\n target = normalize_language(arg)\n if target is None:\n render_system(t(\"lang.unknown\", arg=arg), C_WARN)\n return CommandResult()\n if target == get_language():\n render_system(t(\"lang.already\", name=language_display_name(target)), C_DIM)\n return CommandResult()\n set_language(target)\n # Persist to config so next launch keeps this choice\n try:\n ctx.config.language = target\n save_config(ctx.config)\n except Exception:\n pass\n render_system(t(\"lang.switched\", name=language_display_name(target)), C_SUCCESS)\n return CommandResult()\n\n async def _perm(ctx, arg):\n \"\"\"Switch permission posture. /perm alone shows current + options.\"\"\"\n checker = ctx.agent.permissions\n current = checker.mode\n\n labels = {\n PermissionMode.DEFAULT: tt(\"perm.label.default\"),\n PermissionMode.ACCEPT_EDITS: tt(\"perm.label.accept_edits\"),\n PermissionMode.PLAN: tt(\"perm.label.plan\"),\n PermissionMode.BYPASS: tt(\"perm.label.bypass\"),\n }\n\n arg = arg.strip().lower()\n if not arg:\n lines = [t(\"perm.current\", name=labels[current][0])]\n for m, (name, desc) in labels.items():\n marker = \"● \" if m == current else \" \"\n style = \"bold cyan\" if m == current else \"white\"\n lines.append(f\" {marker}[{style}]/perm {name}[/] {desc}\")\n ctx.console.print(Panel(\n \"\\n\".join(lines),\n title=t(\"perm.panel_title\"), title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n\n # Parse mode name (accept several aliases — bilingual)\n alias_map = {\n \"default\": PermissionMode.DEFAULT, \"d\": PermissionMode.DEFAULT, \"常规\": PermissionMode.DEFAULT,\n \"acceptedits\": PermissionMode.ACCEPT_EDITS, \"accept\": PermissionMode.ACCEPT_EDITS,\n \"edits\": PermissionMode.ACCEPT_EDITS, \"a\": PermissionMode.ACCEPT_EDITS, \"接受\": PermissionMode.ACCEPT_EDITS,\n \"plan\": PermissionMode.PLAN, \"p\": PermissionMode.PLAN, \"计划\": PermissionMode.PLAN,\n \"bypass\": PermissionMode.BYPASS, \"b\": PermissionMode.BYPASS, \"绕过\": PermissionMode.BYPASS,\n }\n target = alias_map.get(arg.replace(\"-\", \"\").replace(\"_\", \"\"))\n if target is None:\n render_system(t(\"perm.unknown\", arg=arg), C_WARN)\n return CommandResult()\n\n if target == current:\n render_system(t(\"perm.already\", name=labels[target][0]), C_DIM)\n return CommandResult()\n\n if target == PermissionMode.BYPASS:\n # Extra friction for the nuclear option.\n try:\n confirm = await ctx.inner_pt.prompt_async(\n t(\"perm.bypass_confirm\")\n )\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n if confirm.strip().lower() not in (\"y\", \"yes\"):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n\n checker.set_mode(target)\n render_system(\n t(\"perm.switched\", name=labels[target][0], desc=labels[target][1]),\n C_SUCCESS,\n )\n return CommandResult()\n\n async def _diff(ctx, arg):\n \"\"\"Show unified diff of files the agent wrote/edited this session.\"\"\"\n from rich.syntax import Syntax\n tracker = ctx.edit_tracker\n if tracker is None:\n render_system(t(\"diff.no_tracker\"), C_WARN)\n return CommandResult()\n\n entries = tracker.entries(dirty_only=True)\n if not entries:\n render_system(t(\"diff.no_changes\"), C_DIM)\n return CommandResult()\n\n target = arg.strip()\n if target:\n # Filter: user can pass a path (absolute or cwd-relative) or basename\n keep = []\n for e in entries:\n if (\n e.path == target\n or Path(e.path).name == target\n or target in e.path\n ):\n keep.append(e)\n if not keep:\n render_system(t(\"diff.no_match\", target=target), C_WARN)\n return CommandResult()\n entries = keep\n\n # Summary table first\n summary = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n summary.add_column(t(\"diff.col.file\"), style=\"white\")\n summary.add_column(t(\"diff.col.plus\"), style=\"bold green\", justify=\"right\")\n summary.add_column(t(\"diff.col.minus\"), style=\"bold red\", justify=\"right\")\n summary.add_column(t(\"diff.col.writes\"), style=C_DIM, justify=\"right\")\n summary.add_column(t(\"diff.col.status\"), style=C_DIM)\n total_added = total_removed = 0\n for e in entries:\n added, removed = e.line_stats()\n total_added += added\n total_removed += removed\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n summary.add_row(\n rel, f\"+{added}\", f\"-{removed}\",\n str(e.write_count),\n t(\"diff.status_new\") if not e.existed_before else t(\"diff.status_edited\"),\n )\n ctx.console.print(Panel(\n summary,\n title=t(\"diff.panel_title\", n=len(entries), added=total_added, removed=total_removed),\n title_align=\"left\", border_style=C_DIM,\n ))\n\n # Per-file unified diff\n for e in entries:\n diff_text = e.unified_diff()\n if not diff_text:\n continue\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n ctx.console.print(Panel(\n Syntax(diff_text, \"diff\", theme=\"monokai\", line_numbers=False,\n word_wrap=False, background_color=\"default\"),\n title=rel, title_align=\"left\", border_style=\"blue\",\n padding=(0, 1),\n ))\n\n return CommandResult()\n\n async def _plan(ctx, arg):\n \"\"\"Plan Mode control: on/off/approve/cancel. Bare /plan shows status.\"\"\"\n agent = ctx.agent\n sub = arg.strip().lower()\n\n if not sub:\n if agent.plan_mode:\n ctx.console.print(Panel(\n t(\"plan.active_body\"),\n border_style=\"blue\", title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n else:\n ctx.console.print(Panel(\n t(\"plan.inactive_body\"),\n border_style=C_DIM, title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n return CommandResult()\n\n if sub in (\"on\", \"start\", \"开启\"):\n _activate_plan_mode(agent)\n return CommandResult()\n\n if sub in (\"off\", \"cancel\", \"取消\"):\n if not agent.plan_mode:\n render_system(t(\"plan.off_not_active\"), C_DIM)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"plan.cancelled\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"approve\", \"go\", \"执行\", \"ok\"):\n if not agent.plan_mode:\n render_system(t(\"plan.approve_not_active\"), C_WARN)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.ACCEPT_EDITS)\n render_system(t(\"plan.approved\"), C_SUCCESS)\n return CommandResult()\n\n render_system(t(\"plan.unknown_sub\", sub=sub), C_WARN)\n return CommandResult()\n\n async def _tasks(ctx, arg):\n \"\"\"Show / inspect / cancel background sub-agent tasks.\n\n Subcommands:\n /tasks → table of all tasks\n /tasks <id> → show output of that task\n /tasks stop <id> → cancel\n /tasks clear → kill every running task + clear finished\n \"\"\"\n import time as _time\n mgr = ctx.agent.subagent_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n tasks = mgr.list_tasks()\n\n if sub_lower in (\"clear\", \"reset\", \"清空\"):\n await mgr.cleanup()\n render_system(t(\"tasks.cleared\"), C_SUCCESS)\n return CommandResult()\n\n if sub_lower.startswith(\"stop \") or sub_lower.startswith(\"kill \"):\n target = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.kill(target)\n if ok:\n render_system(t(\"tasks.stopped\", id=target), C_SUCCESS)\n else:\n render_system(t(\"tasks.stop_missing\", id=target), C_WARN)\n return CommandResult()\n\n # /tasks <id> → single task detail\n if sub and not sub_lower.startswith((\"stop\", \"kill\", \"clear\")):\n match = next((task for task in tasks if task.agent_id == sub), None)\n if match is None:\n render_system(t(\"tasks.not_found\", id=sub), C_WARN)\n return CommandResult()\n result = await mgr.get_result(sub)\n if result is None:\n ctx.console.print(Panel(\n f\"status: [yellow]{match.status}[/]\\n\"\n f\"description: {match.description or '(none)'}\\n\"\n f\"elapsed: {_time.monotonic() - match.start_time:.1f}s\",\n title=f\"Task {sub}\", title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n body = result.content if result.success else (result.error or \"\")\n ctx.console.print(Panel(\n f\"[dim]duration {result.duration_ms:.0f}ms · \"\n f\"tool calls {result.tool_calls_count} · \"\n f\"success {result.success}[/]\\n\\n{body}\",\n title=f\"Task {sub} · {result.description}\",\n title_align=\"left\",\n border_style=\"green\" if result.success else \"red\",\n ))\n return CommandResult()\n\n # Default: table of all tasks\n if not tasks:\n render_system(t(\"tasks.empty_hint\"), C_DIM)\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"id\", style=C_BRAND)\n table.add_column(t(\"tasks.col.state\"), style=\"white\")\n table.add_column(t(\"tasks.col.elapsed\"), style=C_DIM, justify=\"right\")\n table.add_column(t(\"tasks.col.description\"), style=\"white\")\n now = _time.monotonic()\n status_style = {\n \"running\": \"[yellow]● running[/]\",\n \"completed\": \"[green]✓ completed[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"killed\": \"[dim]■ killed[/]\",\n }\n for task in tasks:\n elapsed = now - task.start_time if task.start_time else 0.0\n table.add_row(\n task.agent_id,\n status_style.get(task.status, task.status),\n f\"{elapsed:.1f}s\",\n task.description or \"(none)\",\n )\n running_n = sum(1 for task in tasks if task.status == \"running\")\n ctx.console.print(Panel(\n table,\n title=t(\"tasks.table_title\", n=len(tasks), r=running_n),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n render_system(t(\"tasks.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _dream(ctx, arg):\n \"\"\"Inspect / control Auto-Dream (background memory consolidation).\"\"\"\n agent = ctx.agent\n service = agent.auto_dream\n sub = arg.strip().lower()\n\n if service is None:\n render_system(t(\"dream.disabled_hint\"), C_DIM)\n return CommandResult()\n\n if sub in (\"off\", \"disable\", \"stop\"):\n agent.disable_auto_dream()\n render_system(t(\"dream.off\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"force\", \"now\", \"立刻\"):\n render_system(t(\"dream.forcing\"), C_DIM)\n await service.force_dream()\n stats = service.stats\n render_system(\n t(\"dream.forced_done\", e=stats.extractions, c=stats.consolidations),\n C_SUCCESS,\n )\n return CommandResult()\n\n if sub in (\"consolidate\", \"sum\"):\n render_system(t(\"dream.consolidating\"), C_DIM)\n summary = await service.consolidate_now()\n if summary:\n ctx.console.print(Panel(\n summary,\n title=t(\"dream.consolidate_title\"),\n title_align=\"left\",\n border_style=\"magenta\",\n ))\n else:\n render_system(t(\"dream.consolidate_empty\"), C_DIM)\n return CommandResult()\n\n # Default: show stats + config\n stats = service.stats\n cfg = service.config\n mem_count = (\n len(agent.session_memory) if agent.session_memory is not None else 0\n )\n state_tag = (\n t(\"dream.stats_state_dreaming\") if stats.currently_dreaming\n else t(\"dream.stats_state_idle\")\n )\n ctx.console.print(Panel(\n t(\n \"dream.stats_body\",\n state=state_tag,\n e=stats.extractions,\n c=stats.consolidations,\n last=stats.last_run_at or \"[dim]—[/]\",\n err=stats.last_error or f\"[dim]{t('common.none')}[/]\",\n mem=mem_count,\n interval=cfg.interval_turns,\n cons=cfg.consolidation_every,\n ),\n title=t(\"dream.stats_title\"), title_align=\"left\", border_style=\"magenta\",\n ))\n return CommandResult()\n\n async def _mcp(ctx, arg):\n \"\"\"MCP server control.\n\n /mcp list connected servers and their tools\n /mcp reconnect <n> drop and re-dial a server\n /mcp disconnect <n> disconnect and unregister tools\n \"\"\"\n agent = ctx.agent\n mgr = agent.mcp_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n\n async def _reconnect(name: str) -> None:\n state = mgr.get(name)\n if state is None:\n render_system(t(\"mcp.no_server\", name=name), C_WARN)\n return\n cfg = state.config\n await mgr.disconnect(name)\n new_state = await agent.connect_mcp(cfg)\n if new_state.status == \"connected\":\n render_system(\n t(\"mcp.reconnect_ok\", name=name, n=len(new_state.tools)),\n C_SUCCESS,\n )\n else:\n render_system(\n t(\"mcp.reconnect_failed\", name=name, err=new_state.error),\n C_WARN,\n )\n\n if sub_lower.startswith(\"reconnect \"):\n await _reconnect(sub.split(maxsplit=1)[1].strip())\n return CommandResult()\n\n if sub_lower.startswith(\"disconnect \"):\n name = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.disconnect(name)\n if ok:\n # Also drop the tools from the registry\n for tname in list(agent._tool_registry.list_tools()):\n if tname.startswith(f\"mcp__{name}__\"):\n agent._tool_registry.unregister(tname)\n render_system(t(\"mcp.disconnected\", name=name), C_SUCCESS)\n else:\n render_system(t(\"mcp.not_connected\", name=name), C_DIM)\n return CommandResult()\n\n # Default: show table\n servers = mgr.servers()\n if not servers:\n ctx.console.print(Panel(\n t(\"mcp.empty_body\", path=str(_CONFIG_DIR / \"mcp.json\")),\n border_style=C_DIM, title=\"MCP\", title_align=\"left\",\n ))\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"server\", style=C_BRAND)\n table.add_column(t(\"mcp.col.status\"), style=\"white\")\n table.add_column(t(\"mcp.col.tools\"), justify=\"right\", style=C_DIM)\n table.add_column(t(\"mcp.col.detail\"), style=C_DIM)\n status_mark = {\n \"connected\": \"[green]● connected[/]\",\n \"connecting\": \"[yellow]● connecting[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"closed\": \"[dim]■ closed[/]\",\n \"disabled\": \"[dim]⊘ disabled[/]\",\n \"idle\": \"[dim]idle[/]\",\n }\n for s in servers:\n cmd_preview = f\"{s.config.command} {' '.join(s.config.args[:3])}\"\n if len(s.config.args) > 3:\n cmd_preview += \" …\"\n detail = s.error if s.error else cmd_preview\n table.add_row(\n s.config.name,\n status_mark.get(s.status, s.status),\n str(len(s.tools)),\n detail,\n )\n ctx.console.print(Panel(\n table,\n title=t(\"mcp.table_title\", n=len(servers)),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n # Per-server tool breakdown (compact)\n for s in servers:\n if not s.tools:\n continue\n tool_names = \", \".join(tool.name for tool in s.tools)\n ctx.console.print(\n f\" [dim]{s.config.name}:[/] {tool_names}\"\n )\n render_system(t(\"mcp.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _vim(ctx, arg):\n from prompt_toolkit.enums import EditingMode\n app = ctx.pt.app\n if app.editing_mode == EditingMode.VI:\n app.editing_mode = EditingMode.EMACS\n render_system(t(\"vim.off\"), C_SUCCESS)\n else:\n app.editing_mode = EditingMode.VI\n render_system(t(\"vim.on\"), C_SUCCESS)\n return CommandResult()\n\n for cmd in [\n SlashCommand(\"/help\", lambda: t(\"cmd.help\"), _help),\n SlashCommand(\"/status\", lambda: t(\"cmd.status\"), _status),\n SlashCommand(\"/mode\", lambda: t(\"cmd.mode\"), _mode,\n subargs=((\"general\", lambda: t(\"cmd.sub.mode.general\")),\n (\"plan\", lambda: t(\"cmd.sub.mode.plan\")),\n (\"review\", lambda: t(\"cmd.sub.mode.review\")))),\n SlashCommand(\"/plan\", lambda: t(\"cmd.plan\"), _plan,\n subargs=((\"on\", lambda: t(\"cmd.sub.plan.on\")),\n (\"approve\", lambda: t(\"cmd.sub.plan.approve\")),\n (\"cancel\", lambda: t(\"cmd.sub.plan.cancel\")),\n (\"off\", lambda: t(\"cmd.sub.plan.off\")))),\n SlashCommand(\"/diff\", lambda: t(\"cmd.diff\"), _diff),\n SlashCommand(\"/tasks\", lambda: t(\"cmd.tasks\"), _tasks,\n subargs=((\"clear\", lambda: t(\"cmd.sub.tasks.clear\")),\n (\"stop\", lambda: t(\"cmd.sub.tasks.stop\")))),\n SlashCommand(\"/mcp\", lambda: t(\"cmd.mcp\"), _mcp,\n subargs=((\"reconnect\", lambda: t(\"cmd.sub.mcp.reconnect\")),\n (\"disconnect\", lambda: t(\"cmd.sub.mcp.disconnect\")))),\n SlashCommand(\"/dream\", lambda: t(\"cmd.dream\"), _dream,\n subargs=((\"force\", lambda: t(\"cmd.sub.dream.force\")),\n (\"consolidate\", lambda: t(\"cmd.sub.dream.consolidate\")),\n (\"off\", lambda: t(\"cmd.sub.dream.off\")))),\n SlashCommand(\"/perm\", lambda: t(\"cmd.perm\"), _perm,\n subargs=((\"default\", lambda: t(\"cmd.sub.perm.default\")),\n (\"acceptEdits\", lambda: t(\"cmd.sub.perm.accept_edits\")),\n (\"plan\", lambda: t(\"cmd.sub.perm.plan\")),\n (\"bypass\", lambda: t(\"cmd.sub.perm.bypass\")))),\n SlashCommand(\"/compact\", lambda: t(\"cmd.compact\"), _compact),\n SlashCommand(\"/reset\", lambda: t(\"cmd.reset\"), _reset),\n SlashCommand(\"/save\", lambda: t(\"cmd.save\"), _save),\n SlashCommand(\"/load\", lambda: t(\"cmd.load\"), _load),\n SlashCommand(\"/resume\", lambda: t(\"cmd.resume\"), _resume),\n SlashCommand(\"/config\", lambda: t(\"cmd.config\"), _config),\n SlashCommand(\"/forget\", lambda: t(\"cmd.forget\"), _forget),\n SlashCommand(\"/vim\", lambda: t(\"cmd.vim\"), _vim),\n SlashCommand(\"/lang\", lambda: t(\"cmd.lang\"), _lang,\n subargs=((\"en\", lambda: t(\"cmd.sub.lang.en\")),\n (\"zh\", lambda: t(\"cmd.sub.lang.zh\")))),\n SlashCommand(\"/quit\", lambda: t(\"cmd.quit\"), _quit,\n aliases=(\"/exit\", \"/q\")),\n ]:\n reg.register(cmd)\n\n # Let users drop ~/.lumen/commands/*.py to add their own commands.\n loaded = reg.discover_user_commands(_CONFIG_DIR / \"commands\")\n if loaded:\n render_system(t(\"msg.user_cmd_loaded\", names=\", \".join(loaded)), C_DIM)\n\n return reg\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Keybindings / bottom toolbar\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _make_key_bindings() -> KeyBindings:\n \"\"\"\n Main input-box keybindings:\n · Shift+Tab cycle general → plan → review → general\n · Enter submit the current input (overrides the default\n newline behaviour under multiline mode)\n · Enter (completion on) accept the active completion without submitting\n · Shift+Enter / Alt+Enter / Ctrl+J insert a newline\n\n About Shift+Enter:\n Terminals by default don't distinguish Shift+Enter from Enter — both\n send \\\\r. To make Shift+Enter insert a newline the terminal must emit\n one of the escape sequences below (we recognise all of them):\n · ESC [ 13 ; 2 u (CSI-u / kitty keyboard protocol; on by\n default in WezTerm/Ghostty/Kitty)\n · ESC [ 27 ; 2 ; 13 ~ (xterm modifyOtherKeys; on by default in\n xterm/urxvt)\n · ESC \\\\r (same as Alt+Enter — iTerm2 users can\n bind this via Preferences → Profiles →\n Keys, adding Shift+Return → Send Escape\n Sequence with an empty payload.)\n On macOS Terminal / iTerm2 default setups Shift+Enter is\n indistinguishable — use Alt+Enter as the fallback.\n \"\"\"\n kb = KeyBindings()\n\n @kb.add(\"s-tab\")\n def _toggle_mode(event):\n \"\"\"Cycle: general → plan → review → general.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n inner = _INNER_PT_REF.get(\"session\") or _PT_SESSION_REF.get(\"session\")\n if agent is None:\n return\n if agent.review_mode:\n # review → general\n agent.disable_review_mode()\n elif agent.plan_mode:\n # plan → review\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n else:\n # general → plan\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n # Force the bottom toolbar to redraw\n event.app.invalidate()\n\n @kb.add(\"enter\", filter=completion_is_selected)\n def _accept_completion(event):\n \"\"\"User navigated into the completion menu — Enter accepts, doesn't submit.\"\"\"\n event.current_buffer.complete_state = None\n\n @kb.add(\"enter\")\n def _submit(event):\n \"\"\"Plain Enter submits the line (overrides multiline-mode default newline).\"\"\"\n event.current_buffer.validate_and_handle()\n\n def _insert_newline(event):\n event.current_buffer.newline()\n\n # Alt+Enter (equivalent to ESC \\r) — the main newline key for iTerm2 users\n kb.add(\"escape\", \"enter\")(_insert_newline)\n\n # Ctrl+J — fallback when Alt is swallowed by the OS / window manager\n kb.add(\"c-j\")(_insert_newline)\n\n # Shift+Enter (CSI-u encoding): ESC [ 13 ; 2 u\n # prompt_toolkit parses the ESC prefix as \"escape\" then matches the\n # remaining bytes one-by-one.\n kb.add(\"escape\", \"[\", \"1\", \"3\", \";\", \"2\", \"u\")(_insert_newline)\n\n # Shift+Enter (xterm modifyOtherKeys encoding): ESC [ 27 ; 2 ; 13 ~\n kb.add(\"escape\", \"[\", \"2\", \"7\", \";\", \"2\", \";\", \"1\", \"3\", \"~\")(_insert_newline)\n\n return kb\n\n\ndef _bottom_toolbar():\n \"\"\"Persistent bottom status bar: mode · phase · keybinding hints.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n if agent is None:\n return \"\"\n if agent.review_mode:\n phase = agent.review_state.phase\n phase_text = {\n ReviewPhase.IDLE: t(\"toolbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"toolbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"toolbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"toolbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"toolbar.review_complete\"),\n }.get(phase, \"\")\n mode_part = t(\"toolbar.mode_review\", phase=phase_text)\n elif agent.plan_mode:\n mode_part = t(\"toolbar.mode_plan\")\n else:\n mode_part = t(\"toolbar.mode_general\")\n perm_tag = {\n PermissionMode.DEFAULT: \"\",\n PermissionMode.ACCEPT_EDITS: t(\"toolbar.perm_accept_edits\"),\n PermissionMode.PLAN: t(\"toolbar.perm_plan\"),\n PermissionMode.BYPASS: t(\"toolbar.perm_bypass\"),\n }[agent.permissions.mode]\n return t(\"toolbar.line\", mode=mode_part, perm=perm_tag, model=agent.model)\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main chat loop\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"Run the main chat loop. Returns True if the user asked to reconfigure, False to exit.\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\n\n Pauses any active Rich Live region so prompt_toolkit can own the\n terminal cleanly, and uses a dedicated modal PromptSession (no\n toolbar / completer / Shift+Tab binding) so keystrokes never get\n intercepted by the main input box's UX helpers.\n \"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = _safe_text(str(tool_input[key]))\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n modal_pt = _INNER_PT_REF.get(\"session\") or pt\n with _LivePause():\n console.print()\n console.print(Panel(\n _safe_text(t(\"permission.panel_body\", tool=tool_name, hint=arg_hint)),\n title=t(\"permission.panel_title\"), title_align=\"left\",\n border_style=\"yellow\",\n ))\n try:\n answer = await modal_pt.prompt_async(t(\"permission.prompt\"))\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # project directory tree\n DefinitionsTool(), # extract class/function definitions from a file\n FileReadTool(), # read file contents by line range\n FileWriteTool(), # create / overwrite files\n FileEditTool(), # exact find-and-replace editing\n GlobTool(), # match file paths by pattern\n GrepTool(), # search inside file contents\n BashTool(), # run shell commands\n ],\n )\n except Exception as e:\n render_error(t(\"msg.agent_failed\", err=e))\n return True\n\n # Register sub_agent + task_* tools so the model can spawn and observe\n # background work, and `/tasks` has something to show.\n agent.enable_subagents()\n\n # Discovery utilities: tool_search (rank tools by query) + brief (session summary).\n agent.enable_discovery_tools()\n\n # Auto-Dream: background memory consolidation. Runs async after every\n # N turns and periodically merges entries into narrative summaries.\n # Disable with LUMEN_NO_DREAM=1 if you want the old inline behavior.\n if os.environ.get(\"LUMEN_NO_DREAM\") != \"1\":\n agent.enable_auto_dream()\n\n # Auto-connect MCP servers declared in ~/.lumen/mcp.json (if any).\n mcp_cfg_path = _CONFIG_DIR / \"mcp.json\"\n if mcp_cfg_path.exists():\n try:\n mcp_states = await agent.connect_mcp_from_config(mcp_cfg_path)\n if mcp_states:\n ok = [s for s in mcp_states if s.status == \"connected\"]\n bad = [s for s in mcp_states if s.status == \"failed\"]\n if ok:\n names = \", \".join(\n f\"{s.config.name}({len(s.tools)})\" for s in ok\n )\n render_system(t(\"startup.mcp_connected\", names=names), C_DIM)\n for s in bad:\n render_system(\n t(\"startup.mcp_failed\", name=s.config.name, err=s.error),\n C_WARN,\n )\n except Exception as e:\n render_system(t(\"startup.mcp_load_failed\", err=e), C_WARN)\n\n _AGENT_REF[\"agent\"] = agent\n\n # Desktop notifier — fires on turns that exceed LUMEN_NOTIFY_THRESHOLD (15s default).\n # Disable entirely with LUMEN_NOTIFY=0.\n notifier = Notifier()\n\n # File edit tracker — session-scoped snapshot of files on first write/edit,\n # powers `/diff`.\n edit_tracker = FileEditTracker()\n edit_tracker.install(agent.hooks)\n\n # Build the slash command registry (scans ~/.lumen/commands/*.py for user cmds).\n command_registry = _build_command_registry()\n # Let the completer see registered commands (inc. user-loaded ones).\n completer = pt.completer\n if hasattr(completer, \"bind_registry\"):\n completer.bind_registry(command_registry)\n\n # Modal prompt session (for permission / approval / wizard sub-prompts).\n # Set up in main_async(); here we just reach for it.\n inner_pt = _INNER_PT_REF.get(\"session\") or pt\n\n # Startup info\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), display_provider_name(config))\n info.add_row(t(\"col.model\"), agent.model)\n info.add_row(t(\"col.session_id\"), agent.session.session_id[:12] + \"...\")\n info.add_row(t(\"col.context_window\"), f\"{agent.context_window:,} tokens\")\n info.add_row(t(\"col.auto_compact\"), t(\"startup.auto_compact\"))\n if agent.transcript:\n info.add_row(t(\"col.auto_save\"), t(\"startup.auto_save\", path=agent.transcript.path))\n info.add_row(t(\"col.git_state\"), t(\"startup.git_state\"))\n info.add_row(t(\"col.memory_files\"), t(\"startup.memory_files\"))\n info.add_row(t(\"col.tools_read\"), t(\"startup.tools_read\"))\n info.add_row(t(\"col.tools_write\"), t(\"startup.tools_write\"))\n info.add_row(t(\"col.permissions\"), t(\"startup.permissions\"))\n info.add_row(t(\"col.retry\"), t(\"startup.retry\"))\n info.add_row(t(\"col.file_cache\"), t(\"startup.file_cache\"))\n info.add_row(\n t(\"col.desktop_notify\"),\n t(\"startup.notifier_on\", channel=notifier.channel, sec=int(notifier.default_threshold_s))\n if notifier.enabled else t(\"startup.notifier_off\"),\n )\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(info, border_style=C_DIM, title=t(\"startup.ready\"), title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(t(\"startup.hint\"))\n\n _last_ctrl_c = False\n\n try:\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n\n # ── Slash commands (dispatched via CommandRegistry) ──────────────\n if user_input.startswith(\"/\"):\n head, _, rest = user_input.partition(\" \")\n command = command_registry.find(head.lower())\n if command is None:\n render_system(t(\"msg.unknown_command\", cmd=user_input), C_WARN)\n continue\n ctx = ChatContext(\n agent=agent, config=config, console=console,\n pt=pt, inner_pt=inner_pt, notifier=notifier,\n registry=command_registry, edit_tracker=edit_tracker,\n )\n try:\n result = await command.handler(ctx, rest.strip())\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n continue\n except Exception as e:\n render_error(t(\"msg.cmd_failed\", cmd=command.name, err=e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n continue\n if result.new_agent is not None:\n agent = result.new_agent\n _AGENT_REF[\"agent\"] = agent\n if result.reconfigure:\n return True\n if result.quit:\n return False\n continue\n\n # ── Normal chat turn ──────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n import time as _time\n _turn_start = _time.monotonic()\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n _elapsed = _time.monotonic() - _turn_start\n notifier.notify_if_slow(\n \"Lumen\",\n t(\"msg.reply_done\", preview=user_input[:40]),\n elapsed_s=_elapsed,\n )\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n _last_ctrl_c = True\n render_system(t(\"msg.cancel_reply\"), C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n finally:\n # Flush and close transcript on any exit path\n try:\n await agent.close()\n except Exception:\n pass\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main entry point\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Try to apply saved language preference BEFORE we render anything.\n try:\n _pre = load_config()\n if _pre is not None:\n _lang = normalize_language(_pre.language)\n if _lang:\n set_language(_lang)\n except Exception:\n pass\n\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(t(\"banner.tagline\"), style=C_DIM)))\n console.print(Align.center(Text(t(\"banner.subtitle\"), style=\"dim cyan\")))\n console.print()\n\n # Ensure config dir exists before FileHistory tries to open/append to it.\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n except Exception:\n pass\n\n # Vim mode: opt-in via LUMEN_VIM=1 or toggled at runtime via /vim\n vi_mode = os.environ.get(\"LUMEN_VIM\", \"\").strip() in (\"1\", \"true\", \"yes\", \"on\")\n\n pt = PromptSession(\n history=FileHistory(str(_CONFIG_DIR / \"history\")),\n style=PTStyle.from_dict({\n \"prompt\": \"ansicyan bold\",\n \"bottom-toolbar\": \"bg:#1a1a1a #888888\",\n \"completion-menu.completion\": \"bg:#1a1a1a #cccccc\",\n \"completion-menu.completion.current\": \"bg:#005f87 #ffffff bold\",\n \"completion-menu.meta.completion\": \"bg:#1a1a1a #707070 italic\",\n \"completion-menu.meta.completion.current\": \"bg:#005f87 #cfcfcf italic\",\n }),\n completer=SlashCompleter(),\n complete_while_typing=True,\n key_bindings=_make_key_bindings(),\n bottom_toolbar=_bottom_toolbar,\n refresh_interval=0.5,\n multiline=True, # Enter=submit, Alt+Enter/Ctrl+J=newline (see _make_key_bindings)\n vi_mode=vi_mode,\n )\n _PT_SESSION_REF[\"session\"] = pt\n\n # Inner prompt session — bare & separate, for modal prompts (permissions,\n # approvals). No toolbar, no completer, no Shift+Tab binding. This mirrors\n # src/'s pattern where permission dialogs use a dedicated Select component\n # with its own isolated input handling, so no keybindings leak in from\n # the main input box and no background refresh timer redraws over the prompt.\n inner_pt = PromptSession(\n history=None, # modal answers shouldn't pollute history\n style=PTStyle.from_dict({\"prompt\": \"ansiyellow bold\"}),\n )\n _INNER_PT_REF[\"session\"] = inner_pt\n\n # If CLI already passed enough args, skip the wizard.\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n language=get_language(),\n )\n await chat_loop(config, pt)\n return\n\n # Otherwise: config → chat → (maybe reconfigure) loop\n config: AgentConfig | None = None\n skip_saved_once = False # True on /config reconfigure: skip the \"use saved?\" prompt\n\n while True:\n # Prefer the saved config unless the user actively ran /config\n saved = None if skip_saved_once else load_config()\n skip_saved_once = False\n\n if saved is not None:\n # Apply saved language preference immediately.\n saved_lang = normalize_language(saved.language)\n if saved_lang and saved_lang != get_language():\n set_language(saved_lang)\n masked_key = (saved.api_key[:8] + \"...\" + saved.api_key[-4:]\n if len(saved.api_key) > 12 else \"****\")\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), display_provider_name(saved))\n info.add_row(t(\"col.model\"), saved.model)\n if saved.base_url:\n info.add_row(t(\"col.base_url\"), saved.base_url)\n info.add_row(t(\"col.api_key\"), masked_key)\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(\n info, border_style=\"green\",\n title=t(\"wizard.saved_detected\"), title_align=\"left\",\n ))\n try:\n raw = await pt.prompt_async(t(\"wizard.use_saved_prompt\"))\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n choice = raw.strip().lower()\n if choice in (\"\", \"y\", \"yes\"):\n config = saved\n elif choice == \"f\":\n forget_config()\n render_system(t(\"wizard.forgot\"), C_SUCCESS)\n config = None\n else:\n config = None # fall through to wizard\n\n if config is None:\n try:\n config = await setup_wizard(pt)\n save_config(config)\n render_system(\n t(\"wizard.saved_to\", path=_CONFIG_FILE),\n C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(t(\"wizard.reconfigure\"), style=C_BRAND))\n config = None\n skip_saved_once = True\n\n\ndef main() -> None:\n # Make stdout/stderr surrogate-safe so lone UTF-16 surrogates produced by\n # streaming chunk splits never crash the terminal writer. Replaces any\n # unencodable code point with '?' at the final write instead of raising\n # UnicodeEncodeError from deep inside rich/Live/Panel render paths.\n for _stream in (sys.stdout, sys.stderr):\n try:\n _stream.reconfigure(encoding=\"utf-8\", errors=\"replace\") # type: ignore[attr-defined]\n except Exception:\n pass\n\n import argparse\n parser = argparse.ArgumentParser(description=t(\"cli.description\"))\n parser.add_argument(\"--model\", default=None, help=t(\"cli.arg.model\"))\n parser.add_argument(\"--api-key\", default=None, help=t(\"cli.arg.api_key\"))\n parser.add_argument(\"--base-url\", default=None, help=t(\"cli.arg.base_url\"))\n parser.add_argument(\n \"--lang\", default=None, choices=list(SUPPORTED_LANGUAGES),\n help=t(\"cli.arg.lang\"),\n )\n args = parser.parse_args()\n if args.lang:\n set_language(args.lang)\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "86bc00c652f351e094697f247ccb89cccb662f28": {"t": 1785773568.570731, "n": 88477, "h": "95cc3870084c5003", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\nRun directly and pick a model / enter your API key inside the UI:\n python chat.py\n\nOr specify them on the command line (skipping the wizard):\n python chat.py --model gpt-4o --api-key sk-...\n\nSlash commands:\n /help Show help\n /status Token usage and session info\n /mode Switch general / plan / review (or cycle with Shift+Tab)\n /plan Plan Mode control (on | approve | cancel)\n /lang Switch UI language (en | zh)\n /compact Manually compact context\n /reset Clear conversation history\n /save Save session to file\n /load Load session from file\n /config Reconfigure model and key\n /forget Delete locally saved API key\n /quit Quit\n\nInteraction enhancements:\n • Type / to pop open the command completer\n • Type @ to auto-complete project file paths (30s cache)\n • /load <tab> completes files in the project; picking one loads that session\n • Multi-line input: Enter sends, Shift+Enter / Alt+Enter / Ctrl+J newline\n (Shift+Enter requires a terminal emitting CSI-u or modifyOtherKeys escapes;\n iTerm2/Terminal.app default doesn't distinguish — fall back to Alt+Enter)\n • Input history persisted at ~/.lumen/history, retained across sessions\n • Shift+Tab cycles modes\n • Bottom bar always shows the current mode / phase\n • After first configuration the key is saved in ~/.lumen/config.json (mode 0600),\n reused on next launch — clear with /forget.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport re\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.completion import Completer, Completion\nfrom prompt_toolkit.filters import completion_is_selected\nfrom prompt_toolkit.history import FileHistory\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import (\n Agent, PermissionChecker, PermissionBehavior, PermissionMode,\n HookRegistry, RetryConfig, Notifier, FileEditTracker,\n)\nfrom lumen.i18n import (\n t, tt, set_language, get_language,\n SUPPORTED_LANGUAGES, language_display_name, normalize_language,\n)\nfrom lumen.context.transcript import TranscriptReader, list_recent_sessions\nfrom chat_commands import (\n ChatContext, CommandRegistry, CommandResult, SlashCommand,\n)\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.services.review_state import ReviewPhase\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\nfrom lumen.tools.approval import ConsoleApprovalHandler\n\nconsole = Console()\n\n# ── Color palette ────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ── Shared state (accessed by keybinding / bottom_toolbar closures) ──────────\n_AGENT_REF: dict = {\"agent\": None}\n_PT_SESSION_REF: dict = {\"session\": None} # Main input session (/mode, Shift+Tab)\n_INNER_PT_REF: dict = {\"session\": None} # Modal input session (permission / approval)\n_LIVE_REF: dict = {\"live\": None} # Current agent_response Live, for pause/resume\n\n\nclass _LivePause:\n \"\"\"Context manager that suspends any active Rich Live region while a\n modal prompt runs, then resumes it. Prevents prompt_toolkit from fighting\n Rich for terminal control, which is what makes modal prompts appear to\n 'swallow Enter' — they were never visible in the first place.\"\"\"\n\n def __enter__(self):\n self._live = _LIVE_REF.get(\"live\")\n if self._live is not None:\n try:\n self._live.stop()\n except Exception:\n self._live = None\n return self\n\n def __exit__(self, exc_type, exc, tb):\n if self._live is not None:\n try:\n self._live.start()\n except Exception:\n pass\n return False\n\n\nclass _LivePausingApprovalHandler:\n \"\"\"Wraps any ApprovalHandler so each `.request()` call is bracketed by\n a `_LivePause`. Without this, the approval panel draws inside an active\n Rich Live region and prompt_toolkit can't take the terminal — the user\n sees nothing and their Enter appears to do nothing.\"\"\"\n\n def __init__(self, inner) -> None:\n self._inner = inner\n\n async def request(self, phase, title, content):\n with _LivePause():\n return await self._inner.request(phase, title, content)\n\n\n# ── Slash / @path completion ──────────────────────────────────────────────────\nclass SlashCompleter(Completer):\n \"\"\"\n Unified completer, three trigger categories:\n · `/` Top-level command menu (/help /mode /load …)\n · `/mode r…` Sub-arguments (general / review)\n · `/load …` Project file paths (for loading session files)\n · `@path/to/…` Project file references (inserts text, agent sees the path)\n\n File-tree scan results cached for 30s, auto-skipping common noise dirs\n (.git / node_modules / __pycache__ / .venv / …) so complete_while_typing\n doesn't walk the disk on every keystroke.\n \"\"\"\n\n # Fallback lists used only before the CommandRegistry is attached\n # (e.g. during setup_wizard). After chat_loop builds the registry, it\n # calls `.bind_registry(reg)` and we read live from there.\n @property\n def TOP_LEVEL(self):\n return [\n (\"/help\", t(\"cmd.help\")),\n (\"/config\", t(\"cmd.config\")),\n (\"/quit\", t(\"cmd.quit\")),\n ]\n\n @property\n def MODE_ARGS(self):\n return [\n (\"general\", t(\"cmd.sub.mode.general\")),\n (\"review\", t(\"cmd.sub.mode.review\")),\n ]\n\n SKIP_DIRS = {\n \".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\",\n \"dist\", \"build\", \".pytest_cache\", \".mypy_cache\", \".ruff_cache\",\n \".tox\", \".next\", \".turbo\", \".cache\", \"target\", \".idea\", \".vscode\",\n }\n _FILE_CACHE_TTL = 30.0\n _FILE_CACHE_MAX = 2000\n\n def __init__(self) -> None:\n self._file_cache: list[str] | None = None\n self._file_cache_ts: float = 0.0\n self._registry = None # type: ignore # Optional[CommandRegistry]\n\n def bind_registry(self, registry) -> None:\n \"\"\"Attach a CommandRegistry so completion reflects user-loaded commands.\"\"\"\n self._registry = registry\n\n def _top_level_items(self) -> list[tuple[str, str]]:\n if self._registry is not None:\n return [(c.name, c.description_text()) for c in self._registry.all()]\n return list(self.TOP_LEVEL)\n\n def _subargs_for(self, head: str) -> list[tuple[str, str]]:\n if self._registry is not None:\n cmd = self._registry.find(head)\n if cmd is not None and cmd.subargs:\n return cmd.subarg_items()\n if head == \"/mode\":\n return list(self.MODE_ARGS)\n return []\n\n def _scan_files(self) -> list[str]:\n import time\n now = time.time()\n if (\n self._file_cache is not None\n and (now - self._file_cache_ts) < self._FILE_CACHE_TTL\n ):\n return self._file_cache\n\n cwd = Path.cwd()\n results: list[str] = []\n for root, dirs, files in os.walk(cwd):\n dirs[:] = [\n d for d in dirs\n if d not in self.SKIP_DIRS and not d.startswith(\".\")\n ]\n root_path = Path(root)\n for f in files:\n if f.startswith(\".\") and f not in (\".gitignore\", \".env.example\"):\n continue\n full = root_path / f\n try:\n rel = full.relative_to(cwd)\n except ValueError:\n continue\n results.append(str(rel))\n if len(results) >= self._FILE_CACHE_MAX:\n break\n if len(results) >= self._FILE_CACHE_MAX:\n break\n results.sort()\n self._file_cache = results\n self._file_cache_ts = now\n return results\n\n def _rank_files(self, prefix: str, limit: int = 50) -> list[str]:\n \"\"\"Return files matching prefix, ranked: basename-start > path-start > substring.\"\"\"\n if not prefix:\n return self._scan_files()[:limit]\n lower = prefix.lower()\n ranked: list[tuple[int, str]] = []\n for path in self._scan_files():\n p_lower = path.lower()\n base_lower = Path(path).name.lower()\n if base_lower.startswith(lower):\n rank = 0\n elif p_lower.startswith(lower):\n rank = 1\n elif lower in p_lower:\n rank = 2\n else:\n continue\n ranked.append((rank, path))\n ranked.sort(key=lambda x: (x[0], x[1]))\n return [p for _, p in ranked[:limit]]\n\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n\n # ── @path file reference (allowed anywhere in a sentence) ────────────\n at_idx = text.rfind(\"@\")\n if at_idx != -1 and (at_idx == 0 or text[at_idx - 1] in \" \\t\\n\"):\n prefix = text[at_idx + 1:]\n if prefix == \"\" or (\" \" not in prefix and \"\\t\" not in prefix and \"\\n\" not in prefix):\n for path in self._rank_files(prefix):\n yield Completion(\n path, start_position=-len(prefix),\n display=path, display_meta=\"file\",\n )\n return\n\n # ── /load <path> file-path completion ────────────────────────────────\n if text.startswith(\"/load \"):\n arg = text[len(\"/load \"):].lstrip()\n for path in self._rank_files(arg):\n yield Completion(\n path, start_position=-len(arg),\n display=path, display_meta=\"session\",\n )\n return\n\n # ── Sub-arguments (e.g. /mode review, any registered subargs) ───────\n if text.startswith(\"/\") and \" \" in text:\n head, _, rest = text.partition(\" \")\n prefix = rest.lstrip()\n subargs = self._subargs_for(head)\n for arg, desc in subargs:\n if arg.startswith(prefix):\n yield Completion(\n arg, start_position=-len(prefix),\n display=arg, display_meta=desc,\n )\n return\n\n # ── Top-level slash command ──────────────────────────────────────────\n if text.startswith(\"/\"):\n for cmd, desc in self._top_level_items():\n if cmd.startswith(text):\n yield Completion(\n cmd, start_position=-len(text),\n display=cmd, display_meta=desc,\n )\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model catalog\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str # Raw description — may be an i18n key if prefixed with \"i18n:\"\n\n def display(self) -> str:\n \"\"\"Resolve the description, translating if it uses an i18n key.\"\"\"\n if self.desc.startswith(\"i18n:\"):\n return t(self.desc[5:])\n # Mixed template: leading \"<model> \" + suffix i18n key\n if \"||\" in self.desc:\n prefix, key = self.desc.split(\"||\", 1)\n return prefix + t(key)\n return self.desc\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str # Fixed brand name or an i18n key prefixed \"i18n:\"\n key_hint: str # API key format hint (may be i18n: key)\n env_key: str | None # Env var to read the key from\n base_url: str | None # None = let the Agent auto-detect\n models: list[ModelOption]\n needs_key: bool = True\n\n def display_name(self) -> str:\n return t(self.name[5:]) if self.name.startswith(\"i18n:\") else self.name\n\n def display_key_hint(self) -> str:\n return t(self.key_hint[5:]) if self.key_hint.startswith(\"i18n:\") else self.key_hint\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"i18n:provider.getgoapi.name\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini ||provider.model.reasoning\"),\n ModelOption(\"claude-sonnet-4-6\", \"i18n:provider.model.sonnet_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"i18n:provider.model.haiku_fastcheap\"),\n ModelOption(\"deepseek-chat\", \"i18n:provider.model.deepseek_chat\"),\n ModelOption(\"deepseek-reasoner\", \"i18n:provider.model.deepseek_reasoner\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · ||provider.model.allround\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"i18n:provider.ollama.name\", key_hint=\"i18n:provider.ollama.key_hint\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"i18n:provider.custom.name\", key_hint=\"i18n:provider.custom.key_hint\",\n env_key=None,\n base_url=None,\n models=[], # User-supplied\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Configuration wizard\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n language: str = \"en\"\n provider_id: str = \"\" # Stable id (e.g. \"getgoapi\"); enables live re-translation\n\n\ndef display_provider_name(config: AgentConfig) -> str:\n \"\"\"Return the provider's display name, translated live when the provider\n is known by id. Falls back to whatever was stored in `provider_name`.\"\"\"\n pid = config.provider_id or _infer_provider_id(config.provider_name)\n if pid:\n for p in PROVIDERS:\n if p.id == pid:\n return p.display_name()\n return config.provider_name\n\n\ndef _infer_provider_id(stored_name: str) -> str:\n \"\"\"Best-effort recovery of a provider id from a legacy stored display name.\n Matches against the EN + ZH translation of every provider's name.\"\"\"\n if not stored_name:\n return \"\"\n from lumen import i18n as _i18n\n for p in PROVIDERS:\n if not p.name.startswith(\"i18n:\"):\n if p.name == stored_name:\n return p.id\n continue\n key = p.name[5:]\n for lang in _i18n.SUPPORTED_LANGUAGES:\n translated = _i18n.TRANSLATIONS.get(lang, {}).get(key)\n if translated == stored_name:\n return p.id\n return \"\"\n\n\n# ── Config persistence ───────────────────────────────────────────────────────\n# Stored at ~/.lumen/config.json (file mode 0600) so users don't have to\n# re-enter the API key on every launch.\n\n_CONFIG_DIR = Path.home() / \".lumen\"\n_CONFIG_FILE = _CONFIG_DIR / \"config.json\"\n\n\ndef save_config(config: AgentConfig) -> None:\n \"\"\"Write config to ~/.lumen/config.json with 0600 permissions.\"\"\"\n import json\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n data = {\n \"api_key\": config.api_key,\n \"model\": config.model,\n \"base_url\": config.base_url,\n \"provider_name\": config.provider_name,\n \"provider_id\": config.provider_id,\n \"language\": config.language,\n }\n _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding=\"utf-8\")\n try:\n os.chmod(_CONFIG_FILE, 0o600)\n except Exception:\n pass\n except Exception as e:\n render_system(t(\"wizard.save_failed\", err=e), C_DIM)\n\n\ndef load_config() -> AgentConfig | None:\n \"\"\"Load cached config if present and well-formed.\"\"\"\n import json\n if not _CONFIG_FILE.exists():\n return None\n try:\n data = json.loads(_CONFIG_FILE.read_text(encoding=\"utf-8\"))\n if not data.get(\"model\") or not data.get(\"api_key\"):\n return None\n lang = normalize_language(data.get(\"language\")) or get_language()\n return AgentConfig(\n api_key=data[\"api_key\"],\n model=data[\"model\"],\n base_url=data.get(\"base_url\"),\n provider_name=data.get(\"provider_name\") or \"Saved\",\n provider_id=data.get(\"provider_id\") or \"\",\n language=lang,\n )\n except Exception:\n return None\n\n\ndef forget_config() -> bool:\n \"\"\"Delete the saved config file.\"\"\"\n try:\n if _CONFIG_FILE.exists():\n _CONFIG_FILE.unlink()\n return True\n except Exception:\n pass\n return False\n\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"Interactive configuration wizard: pick Provider → pick Model → enter Key.\"\"\"\n console.print()\n console.print(Rule(t(\"wizard.title\"), style=C_BRAND))\n console.print()\n\n # ── Step 1: pick Provider ────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key detected[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.display_name() + detected, key_info)\n\n console.print(Panel(table, title=t(\"wizard.pick_provider\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(t(\"wizard.range_hint\", n=len(PROVIDERS)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(t(\"wizard.selected\", value=provider.display_name()) + \"\\n\")\n\n # ── Step 2: pick Model ───────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(t(\"wizard.custom_base_url\"))).strip()\n model = (await pt.prompt_async(t(\"wizard.custom_model\"))).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(t(\"wizard.custom_empty_model\"))\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.display())\n\n console.print(Panel(model_table, title=t(\"wizard.pick_model\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(t(\"wizard.range_hint\", n=len(provider.models)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(t(\"wizard.selected\", value=model) + \"\\n\")\n\n # ── Step 3: enter API Key ────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(t(\"wizard.local_no_key\"))\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n t(\"wizard.key_env_detected\", name=provider.env_key, masked=masked)\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(t(\"wizard.key_env_used\") + \"\\n\")\n else:\n api_key = await _prompt_key(pt, provider.display_key_hint())\n else:\n console.print(t(\"wizard.key_hint_prefix\", hint=provider.display_key_hint()))\n api_key = await _prompt_key(pt, provider.display_key_hint())\n\n # ── Summary ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(t(\"col.provider\"), provider.display_name())\n summary.add_row(t(\"col.model\"), model)\n if base_url:\n summary.add_row(t(\"col.base_url\"), base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(t(\"col.api_key\"), key_display)\n console.print(Panel(summary, title=t(\"wizard.summary_title\"), border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.display_name(),\n provider_id=provider.id,\n language=get_language(),\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"API Key input with basic masking prompt.\"\"\"\n while True:\n try:\n key = await pt.prompt_async(t(\"wizard.key_prompt\"))\n key = key.strip()\n if key:\n return key\n console.print(t(\"wizard.key_empty\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI rendering components\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(t(\"tokenbar.model\"), style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(t(\"tokenbar.msgs\"), style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(t(\"tokenbar.compactions\"), style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.review_mode:\n stats.append(\" │ \", style=C_DIM)\n phase = agent.review_state.phase\n phase_label = {\n ReviewPhase.IDLE: t(\"tokenbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"tokenbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"tokenbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"tokenbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"tokenbar.review_complete\"),\n }.get(phase, t(\"tokenbar.review_default\"))\n stats.append(f\"⚑ {phase_label}\", style=\"bold yellow\")\n elif agent.plan_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(t(\"tokenbar.plan_mode\"), style=\"bold blue\")\n\n perm_mode = agent.permissions.mode\n if perm_mode != PermissionMode.DEFAULT:\n stats.append(\" │ \", style=C_DIM)\n perm_label, perm_style = {\n PermissionMode.ACCEPT_EDITS: (\"✎ accept-edits\", \"bold green\"),\n PermissionMode.PLAN: (\"📋 plan-only\", \"bold blue\"),\n PermissionMode.BYPASS: (\"⚠ bypass\", \"bold red\"),\n }[perm_mode]\n stats.append(perm_label, style=perm_style)\n\n border = (\n \"red\" if perm_mode == PermissionMode.BYPASS\n else \"blue\" if perm_mode == PermissionMode.PLAN or agent.plan_mode\n else \"yellow\" if agent.review_mode\n else C_DIM\n )\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\n_LONE_SURROGATE_RE = re.compile(r\"[\\ud800-\\udfff]\")\n\n\ndef _safe_text(s: str) -> str:\n \"\"\"Strip lone UTF-16 surrogates that appear when streaming chunks split a\n multi-unit character in half (common with emoji / CJK across chunk\n boundaries). These codepoints are unencodable as UTF-8 and crash the\n console writer.\"\"\"\n if not s:\n return s\n return _LONE_SURROGATE_RE.sub(\"\", s)\n\n\ndef render_user_message(text_value: str) -> Panel:\n return Panel(\n Text(_safe_text(text_value), style=\"white\"),\n title=Text(t(\"msg.you\"), style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text_value: str, style: str = C_DIM) -> None:\n console.print(f\" {_safe_text(text_value)}\", style=style)\n\n\ndef render_error(text_value: str) -> None:\n console.print(Panel(_safe_text(text_value), border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help(registry: \"CommandRegistry | None\" = None) -> None:\n \"\"\"Render /help. If a CommandRegistry is supplied, the slash-command rows\n are pulled from it so user-loaded commands show up automatically.\"\"\"\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n\n if registry is not None:\n for cmd in registry.all():\n hint = cmd.description_text()\n if cmd.aliases:\n hint += f\" (aliases: {' '.join(cmd.aliases)})\"\n table.add_row(cmd.name, hint)\n else:\n table.add_row(\"/help\", t(\"cmd.help\"))\n table.add_row(\"/quit\", t(\"cmd.quit\"))\n\n # Non-slash keybinding reference\n for row in [\n (\"\", \"\"),\n (\"↑ / ↓\", t(\"help.row.history\")),\n (\"Shift+Enter\", t(\"help.row.shift_enter\")),\n (\"Alt+Enter\", t(\"help.row.alt_enter\")),\n (\"@path\", t(\"help.row.at_path\")),\n (\"Shift+Tab\", t(\"help.row.shift_tab\")),\n (\"Ctrl+C\", t(\"help.row.ctrl_c\")),\n ]:\n table.add_row(*row)\n console.print(Panel(table, title=t(\"help.title\"), border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(t(\"status.model\"), agent.model)\n table.add_row(t(\"status.context_window\"), f\"{agent.context_window:,} tokens\")\n table.add_row(t(\"status.tokens_used\"), f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(t(\"status.messages\"), str(len(agent.messages)))\n table.add_row(t(\"status.compactions\"), str(agent.session.compaction_count))\n table.add_row(t(\"status.session_start\"), agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=t(\"status.title\"), border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent response\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n Run a full agent turn: live-display tool calls and render the final\n answer as Markdown.\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(t(\"msg.lumen_thinking\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── Use a Live panel to show progress while tools run ────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n _LIVE_REF[\"live\"] = live\n tool_log.append(t(\"msg.thinking\"))\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n try:\n await _chat_with_refresh()\n finally:\n _LIVE_REF[\"live\"] = None\n else:\n # No tools: stream output straight through\n collected: list[str] = []\n panel_title = Text(t(\"msg.lumen_streaming\"), style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n _LIVE_REF[\"live\"] = live\n try:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\n _safe_text(\"\".join(collected)),\n code_theme=\"monokai\", inline_code_theme=\"monokai\",\n )\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n finally:\n _LIVE_REF[\"live\"] = None\n return \"\".join(collected)\n\n # ── Render the final answer ──────────────────────────────────────────────\n final_text = _safe_text(response.content if response else \"\")\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(t(\"msg.lumen\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Mode switching\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str, pt: PromptSession | None = None) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n if agent.review_mode:\n mode_label = t(\"mode.review_label\")\n elif agent.plan_mode:\n mode_label = t(\"mode.plan_label\")\n else:\n mode_label = t(\"mode.general_label\")\n console.print(Panel(\n t(\"mode.current\", label=mode_label) + \"\\n\\n\"\n + t(\"mode.hint_review\") + \"\\n\"\n + t(\"mode.hint_plan\") + \"\\n\"\n + t(\"mode.hint_general\") + \"\\n\"\n + t(\"mode.hint_shift_tab\"),\n border_style=C_DIM, title=t(\"mode.panel_title\"), title_align=\"left\",\n ))\n return\n\n if arg in (\"review\", \"r\", \"审阅\", \"careful\"):\n if agent.review_mode:\n render_system(t(\"mode.already_review\"), C_DIM)\n else:\n inner = _INNER_PT_REF.get(\"session\") or pt\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n console.print(Panel(\n t(\"mode.review_panel_body\"),\n border_style=\"yellow\",\n title=t(\"mode.review_panel_title\"),\n title_align=\"left\",\n ))\n\n elif arg in (\"plan\", \"p\", \"方案\", \"计划\"):\n _activate_plan_mode(agent)\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n was_plan = agent.plan_mode\n was_review = agent.review_mode\n if not was_plan and not was_review:\n render_system(t(\"mode.already_general\"), C_DIM)\n else:\n if was_review:\n agent.disable_review_mode()\n if was_plan:\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"mode.back_to_general\"), C_SUCCESS)\n\n else:\n render_system(t(\"mode.unknown\", arg=arg), C_WARN)\n\n\ndef _activate_plan_mode(agent: Agent) -> None:\n if agent.plan_mode:\n render_system(t(\"mode.already_plan\"), C_DIM)\n return\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n console.print(Panel(\n t(\"mode.plan_panel_body\"),\n border_style=\"blue\",\n title=t(\"mode.plan_panel_title\"),\n title_align=\"left\",\n ))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Command handling\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(t(\"compact.running\"), C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n t(\"compact.body\",\n mb=r.messages_before, ma=r.messages_after,\n tb=r.tokens_before, ta=r.tokens_after,\n kept=r.kept_recent_count),\n border_style=\"green\", title=t(\"compact.title\"), title_align=\"left\",\n ))\n except Exception as e:\n render_error(t(\"compact.failed\", err=e))\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(t(\"save.prompt\", default=default), default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(t(\"save.done\", path=path), C_SUCCESS)\n if agent.transcript:\n render_system(t(\"save.transcript_hint\", path=agent.transcript.path), C_DIM)\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n except Exception as e:\n render_error(t(\"save.failed\", err=e))\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(t(\"load.prompt\"))).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(t(\"load.missing\", path=arg))\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(t(\"load.done\", n=len(agent.messages), path=arg), C_SUCCESS)\n return agent\n except Exception as e:\n render_error(t(\"load.failed\", err=e))\n return None\n\n\nasync def handle_resume(config: AgentConfig, pt: PromptSession) -> Agent | None:\n \"\"\"List recent auto-saved sessions and let the user pick one to resume.\"\"\"\n cwd = Path.cwd()\n sessions = list_recent_sessions(cwd, limit=10)\n if not sessions:\n render_system(t(\"resume.none\"), C_WARN)\n return None\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"#\", style=C_NUM, width=4)\n table.add_column(t(\"resume.col.session_id\"), style=\"white\")\n table.add_column(t(\"status.model\"), style=C_DIM)\n table.add_column(t(\"resume.col.messages\"), style=\"white\", justify=\"right\")\n table.add_column(t(\"resume.col.created\"), style=C_DIM)\n table.add_column(t(\"resume.col.last_prompt\"), style=\"white\")\n\n for i, s in enumerate(sessions, 1):\n created = s.created_at[:19].replace(\"T\", \" \") if s.created_at else \"?\"\n prompt_preview = s.last_prompt[:50] + (\"...\" if len(s.last_prompt) > 50 else \"\")\n table.add_row(\n f\"[{i}]\",\n s.session_id[:12] + \"...\",\n s.model or \"?\",\n str(s.message_count),\n created,\n prompt_preview,\n )\n\n console.print(Panel(table, title=t(\"resume.table_title\"), border_style=C_DIM))\n\n try:\n raw = await pt.prompt_async(t(\"resume.pick_prompt\"))\n raw = raw.strip()\n if not raw:\n return None\n idx = int(raw) - 1\n if not (0 <= idx < len(sessions)):\n render_system(t(\"resume.range_hint\", n=len(sessions)), C_WARN)\n return None\n except (ValueError, KeyboardInterrupt, EOFError):\n return None\n\n chosen = sessions[idx]\n try:\n messages, metadata = TranscriptReader.load_session(chosen.file_path)\n if not messages:\n render_error(t(\"resume.empty_file\"))\n return None\n\n # Rebuild a Session from the transcript messages\n from lumen.context.session import Session\n session = Session(model=config.model)\n session.session_id = chosen.session_id\n session.messages = messages\n\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n session=session,\n auto_compact=True,\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n FileWriteTool(), FileEditTool(),\n GlobTool(), GrepTool(), BashTool(),\n ],\n )\n render_system(\n t(\"resume.done\", sid=chosen.session_id[:12], n=len(messages)),\n C_SUCCESS,\n )\n return agent\n except Exception as e:\n render_error(t(\"resume.failed\", err=e))\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Slash command registry\n# ─────────────────────────────────────────────────────────────────────────────\n# All slash commands (/help, /status, /mode, /load, …) register through the\n# CommandRegistry. Both SlashCompleter and /help read from here — single\n# source of truth. At startup we also auto-scan ~/.lumen/commands/*.py so\n# users can add their own commands without editing chat.py.\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _build_command_registry() -> CommandRegistry:\n \"\"\"Build the built-in command registry. Handlers close over ChatContext\n (passed in at dispatch time), so they can see live agent/config/pt state.\"\"\"\n reg = CommandRegistry()\n\n async def _quit(ctx, arg):\n render_system(t(\"common.goodbye\"), C_DIM)\n return CommandResult(quit=True)\n\n async def _help(ctx, arg):\n render_help(ctx.registry)\n return CommandResult()\n\n async def _status(ctx, arg):\n render_status(ctx.agent)\n return CommandResult()\n\n async def _mode(ctx, arg):\n handle_mode(ctx.agent, arg, pt=ctx.pt)\n return CommandResult()\n\n async def _compact(ctx, arg):\n await handle_compact(ctx.agent)\n return CommandResult()\n\n async def _reset(ctx, arg):\n ctx.agent.reset()\n render_system(t(\"reset.done\"), C_SUCCESS)\n return CommandResult()\n\n async def _save(ctx, arg):\n await handle_save(ctx.agent, ctx.pt)\n return CommandResult()\n\n async def _load(ctx, arg):\n new = await handle_load(arg, ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _resume(ctx, arg):\n new = await handle_resume(ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _config(ctx, arg):\n render_system(t(\"config.back_to_wizard\"), C_DIM)\n return CommandResult(reconfigure=True)\n\n async def _forget(ctx, arg):\n if forget_config():\n render_system(t(\"forget.done\", path=_CONFIG_FILE), C_SUCCESS)\n else:\n render_system(t(\"forget.nothing\"), C_DIM)\n return CommandResult()\n\n async def _lang(ctx, arg):\n \"\"\"Switch UI language live.\"\"\"\n arg = arg.strip().lower()\n if not arg:\n current = language_display_name()\n ctx.console.print(Panel(\n t(\"lang.current\", name=current) + \"\\n\\n\" + t(\"lang.available\"),\n border_style=C_DIM, title=\"Language / \\u8bed\\u8a00\",\n title_align=\"left\",\n ))\n return CommandResult()\n target = normalize_language(arg)\n if target is None:\n render_system(t(\"lang.unknown\", arg=arg), C_WARN)\n return CommandResult()\n if target == get_language():\n render_system(t(\"lang.already\", name=language_display_name(target)), C_DIM)\n return CommandResult()\n set_language(target)\n # Persist to config so next launch keeps this choice\n try:\n ctx.config.language = target\n save_config(ctx.config)\n except Exception:\n pass\n render_system(t(\"lang.switched\", name=language_display_name(target)), C_SUCCESS)\n return CommandResult()\n\n async def _perm(ctx, arg):\n \"\"\"Switch permission posture. /perm alone shows current + options.\"\"\"\n checker = ctx.agent.permissions\n current = checker.mode\n\n labels = {\n PermissionMode.DEFAULT: tt(\"perm.label.default\"),\n PermissionMode.ACCEPT_EDITS: tt(\"perm.label.accept_edits\"),\n PermissionMode.PLAN: tt(\"perm.label.plan\"),\n PermissionMode.BYPASS: tt(\"perm.label.bypass\"),\n }\n\n arg = arg.strip().lower()\n if not arg:\n lines = [t(\"perm.current\", name=labels[current][0])]\n for m, (name, desc) in labels.items():\n marker = \"● \" if m == current else \" \"\n style = \"bold cyan\" if m == current else \"white\"\n lines.append(f\" {marker}[{style}]/perm {name}[/] {desc}\")\n ctx.console.print(Panel(\n \"\\n\".join(lines),\n title=t(\"perm.panel_title\"), title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n\n # Parse mode name (accept several aliases — bilingual)\n alias_map = {\n \"default\": PermissionMode.DEFAULT, \"d\": PermissionMode.DEFAULT, \"常规\": PermissionMode.DEFAULT,\n \"acceptedits\": PermissionMode.ACCEPT_EDITS, \"accept\": PermissionMode.ACCEPT_EDITS,\n \"edits\": PermissionMode.ACCEPT_EDITS, \"a\": PermissionMode.ACCEPT_EDITS, \"接受\": PermissionMode.ACCEPT_EDITS,\n \"plan\": PermissionMode.PLAN, \"p\": PermissionMode.PLAN, \"计划\": PermissionMode.PLAN,\n \"bypass\": PermissionMode.BYPASS, \"b\": PermissionMode.BYPASS, \"绕过\": PermissionMode.BYPASS,\n }\n target = alias_map.get(arg.replace(\"-\", \"\").replace(\"_\", \"\"))\n if target is None:\n render_system(t(\"perm.unknown\", arg=arg), C_WARN)\n return CommandResult()\n\n if target == current:\n render_system(t(\"perm.already\", name=labels[target][0]), C_DIM)\n return CommandResult()\n\n if target == PermissionMode.BYPASS:\n # Extra friction for the nuclear option.\n try:\n confirm = await ctx.inner_pt.prompt_async(\n t(\"perm.bypass_confirm\")\n )\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n if confirm.strip().lower() not in (\"y\", \"yes\"):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n\n checker.set_mode(target)\n render_system(\n t(\"perm.switched\", name=labels[target][0], desc=labels[target][1]),\n C_SUCCESS,\n )\n return CommandResult()\n\n async def _diff(ctx, arg):\n \"\"\"Show unified diff of files the agent wrote/edited this session.\"\"\"\n from rich.syntax import Syntax\n tracker = ctx.edit_tracker\n if tracker is None:\n render_system(t(\"diff.no_tracker\"), C_WARN)\n return CommandResult()\n\n entries = tracker.entries(dirty_only=True)\n if not entries:\n render_system(t(\"diff.no_changes\"), C_DIM)\n return CommandResult()\n\n target = arg.strip()\n if target:\n # Filter: user can pass a path (absolute or cwd-relative) or basename\n keep = []\n for e in entries:\n if (\n e.path == target\n or Path(e.path).name == target\n or target in e.path\n ):\n keep.append(e)\n if not keep:\n render_system(t(\"diff.no_match\", target=target), C_WARN)\n return CommandResult()\n entries = keep\n\n # Summary table first\n summary = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n summary.add_column(t(\"diff.col.file\"), style=\"white\")\n summary.add_column(t(\"diff.col.plus\"), style=\"bold green\", justify=\"right\")\n summary.add_column(t(\"diff.col.minus\"), style=\"bold red\", justify=\"right\")\n summary.add_column(t(\"diff.col.writes\"), style=C_DIM, justify=\"right\")\n summary.add_column(t(\"diff.col.status\"), style=C_DIM)\n total_added = total_removed = 0\n for e in entries:\n added, removed = e.line_stats()\n total_added += added\n total_removed += removed\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n summary.add_row(\n rel, f\"+{added}\", f\"-{removed}\",\n str(e.write_count),\n t(\"diff.status_new\") if not e.existed_before else t(\"diff.status_edited\"),\n )\n ctx.console.print(Panel(\n summary,\n title=t(\"diff.panel_title\", n=len(entries), added=total_added, removed=total_removed),\n title_align=\"left\", border_style=C_DIM,\n ))\n\n # Per-file unified diff\n for e in entries:\n diff_text = e.unified_diff()\n if not diff_text:\n continue\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n ctx.console.print(Panel(\n Syntax(diff_text, \"diff\", theme=\"monokai\", line_numbers=False,\n word_wrap=False, background_color=\"default\"),\n title=rel, title_align=\"left\", border_style=\"blue\",\n padding=(0, 1),\n ))\n\n return CommandResult()\n\n async def _plan(ctx, arg):\n \"\"\"Plan Mode control: on/off/approve/cancel. Bare /plan shows status.\"\"\"\n agent = ctx.agent\n sub = arg.strip().lower()\n\n if not sub:\n if agent.plan_mode:\n ctx.console.print(Panel(\n t(\"plan.active_body\"),\n border_style=\"blue\", title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n else:\n ctx.console.print(Panel(\n t(\"plan.inactive_body\"),\n border_style=C_DIM, title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n return CommandResult()\n\n if sub in (\"on\", \"start\", \"开启\"):\n _activate_plan_mode(agent)\n return CommandResult()\n\n if sub in (\"off\", \"cancel\", \"取消\"):\n if not agent.plan_mode:\n render_system(t(\"plan.off_not_active\"), C_DIM)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"plan.cancelled\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"approve\", \"go\", \"执行\", \"ok\"):\n if not agent.plan_mode:\n render_system(t(\"plan.approve_not_active\"), C_WARN)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.ACCEPT_EDITS)\n render_system(t(\"plan.approved\"), C_SUCCESS)\n return CommandResult()\n\n render_system(t(\"plan.unknown_sub\", sub=sub), C_WARN)\n return CommandResult()\n\n async def _tasks(ctx, arg):\n \"\"\"Show / inspect / cancel background sub-agent tasks.\n\n Subcommands:\n /tasks → table of all tasks\n /tasks <id> → show output of that task\n /tasks stop <id> → cancel\n /tasks clear → kill every running task + clear finished\n \"\"\"\n import time as _time\n mgr = ctx.agent.subagent_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n tasks = mgr.list_tasks()\n\n if sub_lower in (\"clear\", \"reset\", \"清空\"):\n await mgr.cleanup()\n render_system(t(\"tasks.cleared\"), C_SUCCESS)\n return CommandResult()\n\n if sub_lower.startswith(\"stop \") or sub_lower.startswith(\"kill \"):\n target = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.kill(target)\n if ok:\n render_system(t(\"tasks.stopped\", id=target), C_SUCCESS)\n else:\n render_system(t(\"tasks.stop_missing\", id=target), C_WARN)\n return CommandResult()\n\n # /tasks <id> → single task detail\n if sub and not sub_lower.startswith((\"stop\", \"kill\", \"clear\")):\n match = next((task for task in tasks if task.agent_id == sub), None)\n if match is None:\n render_system(t(\"tasks.not_found\", id=sub), C_WARN)\n return CommandResult()\n result = await mgr.get_result(sub)\n if result is None:\n ctx.console.print(Panel(\n f\"status: [yellow]{match.status}[/]\\n\"\n f\"description: {match.description or '(none)'}\\n\"\n f\"elapsed: {_time.monotonic() - match.start_time:.1f}s\",\n title=f\"Task {sub}\", title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n body = result.content if result.success else (result.error or \"\")\n ctx.console.print(Panel(\n f\"[dim]duration {result.duration_ms:.0f}ms · \"\n f\"tool calls {result.tool_calls_count} · \"\n f\"success {result.success}[/]\\n\\n{body}\",\n title=f\"Task {sub} · {result.description}\",\n title_align=\"left\",\n border_style=\"green\" if result.success else \"red\",\n ))\n return CommandResult()\n\n # Default: table of all tasks\n if not tasks:\n render_system(t(\"tasks.empty_hint\"), C_DIM)\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"id\", style=C_BRAND)\n table.add_column(t(\"tasks.col.state\"), style=\"white\")\n table.add_column(t(\"tasks.col.elapsed\"), style=C_DIM, justify=\"right\")\n table.add_column(t(\"tasks.col.description\"), style=\"white\")\n now = _time.monotonic()\n status_style = {\n \"running\": \"[yellow]● running[/]\",\n \"completed\": \"[green]✓ completed[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"killed\": \"[dim]■ killed[/]\",\n }\n for task in tasks:\n elapsed = now - task.start_time if task.start_time else 0.0\n table.add_row(\n task.agent_id,\n status_style.get(task.status, task.status),\n f\"{elapsed:.1f}s\",\n task.description or \"(none)\",\n )\n running_n = sum(1 for task in tasks if task.status == \"running\")\n ctx.console.print(Panel(\n table,\n title=t(\"tasks.table_title\", n=len(tasks), r=running_n),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n render_system(t(\"tasks.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _dream(ctx, arg):\n \"\"\"Inspect / control Auto-Dream (background memory consolidation).\"\"\"\n agent = ctx.agent\n service = agent.auto_dream\n sub = arg.strip().lower()\n\n if service is None:\n render_system(t(\"dream.disabled_hint\"), C_DIM)\n return CommandResult()\n\n if sub in (\"off\", \"disable\", \"stop\"):\n agent.disable_auto_dream()\n render_system(t(\"dream.off\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"force\", \"now\", \"立刻\"):\n render_system(t(\"dream.forcing\"), C_DIM)\n await service.force_dream()\n stats = service.stats\n render_system(\n t(\"dream.forced_done\", e=stats.extractions, c=stats.consolidations),\n C_SUCCESS,\n )\n return CommandResult()\n\n if sub in (\"consolidate\", \"sum\"):\n render_system(t(\"dream.consolidating\"), C_DIM)\n summary = await service.consolidate_now()\n if summary:\n ctx.console.print(Panel(\n summary,\n title=t(\"dream.consolidate_title\"),\n title_align=\"left\",\n border_style=\"magenta\",\n ))\n else:\n render_system(t(\"dream.consolidate_empty\"), C_DIM)\n return CommandResult()\n\n # Default: show stats + config\n stats = service.stats\n cfg = service.config\n mem_count = (\n len(agent.session_memory) if agent.session_memory is not None else 0\n )\n state_tag = (\n t(\"dream.stats_state_dreaming\") if stats.currently_dreaming\n else t(\"dream.stats_state_idle\")\n )\n ctx.console.print(Panel(\n t(\n \"dream.stats_body\",\n state=state_tag,\n e=stats.extractions,\n c=stats.consolidations,\n last=stats.last_run_at or \"[dim]—[/]\",\n err=stats.last_error or f\"[dim]{t('common.none')}[/]\",\n mem=mem_count,\n interval=cfg.interval_turns,\n cons=cfg.consolidation_every,\n ),\n title=t(\"dream.stats_title\"), title_align=\"left\", border_style=\"magenta\",\n ))\n return CommandResult()\n\n async def _mcp(ctx, arg):\n \"\"\"MCP server control.\n\n /mcp list connected servers and their tools\n /mcp reconnect <n> drop and re-dial a server\n /mcp disconnect <n> disconnect and unregister tools\n \"\"\"\n agent = ctx.agent\n mgr = agent.mcp_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n\n async def _reconnect(name: str) -> None:\n state = mgr.get(name)\n if state is None:\n render_system(t(\"mcp.no_server\", name=name), C_WARN)\n return\n cfg = state.config\n await mgr.disconnect(name)\n new_state = await agent.connect_mcp(cfg)\n if new_state.status == \"connected\":\n render_system(\n t(\"mcp.reconnect_ok\", name=name, n=len(new_state.tools)),\n C_SUCCESS,\n )\n else:\n render_system(\n t(\"mcp.reconnect_failed\", name=name, err=new_state.error),\n C_WARN,\n )\n\n if sub_lower.startswith(\"reconnect \"):\n await _reconnect(sub.split(maxsplit=1)[1].strip())\n return CommandResult()\n\n if sub_lower.startswith(\"disconnect \"):\n name = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.disconnect(name)\n if ok:\n # Also drop the tools from the registry\n for tname in list(agent._tool_registry.list_tools()):\n if tname.startswith(f\"mcp__{name}__\"):\n agent._tool_registry.unregister(tname)\n render_system(t(\"mcp.disconnected\", name=name), C_SUCCESS)\n else:\n render_system(t(\"mcp.not_connected\", name=name), C_DIM)\n return CommandResult()\n\n # Default: show table\n servers = mgr.servers()\n if not servers:\n ctx.console.print(Panel(\n t(\"mcp.empty_body\", path=str(_CONFIG_DIR / \"mcp.json\")),\n border_style=C_DIM, title=\"MCP\", title_align=\"left\",\n ))\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"server\", style=C_BRAND)\n table.add_column(t(\"mcp.col.status\"), style=\"white\")\n table.add_column(t(\"mcp.col.tools\"), justify=\"right\", style=C_DIM)\n table.add_column(t(\"mcp.col.detail\"), style=C_DIM)\n status_mark = {\n \"connected\": \"[green]● connected[/]\",\n \"connecting\": \"[yellow]● connecting[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"closed\": \"[dim]■ closed[/]\",\n \"disabled\": \"[dim]⊘ disabled[/]\",\n \"idle\": \"[dim]idle[/]\",\n }\n for s in servers:\n cmd_preview = f\"{s.config.command} {' '.join(s.config.args[:3])}\"\n if len(s.config.args) > 3:\n cmd_preview += \" …\"\n detail = s.error if s.error else cmd_preview\n table.add_row(\n s.config.name,\n status_mark.get(s.status, s.status),\n str(len(s.tools)),\n detail,\n )\n ctx.console.print(Panel(\n table,\n title=t(\"mcp.table_title\", n=len(servers)),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n # Per-server tool breakdown (compact)\n for s in servers:\n if not s.tools:\n continue\n tool_names = \", \".join(tool.name for tool in s.tools)\n ctx.console.print(\n f\" [dim]{s.config.name}:[/] {tool_names}\"\n )\n render_system(t(\"mcp.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _vim(ctx, arg):\n from prompt_toolkit.enums import EditingMode\n app = ctx.pt.app\n if app.editing_mode == EditingMode.VI:\n app.editing_mode = EditingMode.EMACS\n render_system(t(\"vim.off\"), C_SUCCESS)\n else:\n app.editing_mode = EditingMode.VI\n render_system(t(\"vim.on\"), C_SUCCESS)\n return CommandResult()\n\n for cmd in [\n SlashCommand(\"/help\", lambda: t(\"cmd.help\"), _help),\n SlashCommand(\"/status\", lambda: t(\"cmd.status\"), _status),\n SlashCommand(\"/mode\", lambda: t(\"cmd.mode\"), _mode,\n subargs=((\"general\", lambda: t(\"cmd.sub.mode.general\")),\n (\"plan\", lambda: t(\"cmd.sub.mode.plan\")),\n (\"review\", lambda: t(\"cmd.sub.mode.review\")))),\n SlashCommand(\"/plan\", lambda: t(\"cmd.plan\"), _plan,\n subargs=((\"on\", lambda: t(\"cmd.sub.plan.on\")),\n (\"approve\", lambda: t(\"cmd.sub.plan.approve\")),\n (\"cancel\", lambda: t(\"cmd.sub.plan.cancel\")),\n (\"off\", lambda: t(\"cmd.sub.plan.off\")))),\n SlashCommand(\"/diff\", lambda: t(\"cmd.diff\"), _diff),\n SlashCommand(\"/tasks\", lambda: t(\"cmd.tasks\"), _tasks,\n subargs=((\"clear\", lambda: t(\"cmd.sub.tasks.clear\")),\n (\"stop\", lambda: t(\"cmd.sub.tasks.stop\")))),\n SlashCommand(\"/mcp\", lambda: t(\"cmd.mcp\"), _mcp,\n subargs=((\"reconnect\", lambda: t(\"cmd.sub.mcp.reconnect\")),\n (\"disconnect\", lambda: t(\"cmd.sub.mcp.disconnect\")))),\n SlashCommand(\"/dream\", lambda: t(\"cmd.dream\"), _dream,\n subargs=((\"force\", lambda: t(\"cmd.sub.dream.force\")),\n (\"consolidate\", lambda: t(\"cmd.sub.dream.consolidate\")),\n (\"off\", lambda: t(\"cmd.sub.dream.off\")))),\n SlashCommand(\"/perm\", lambda: t(\"cmd.perm\"), _perm,\n subargs=((\"default\", lambda: t(\"cmd.sub.perm.default\")),\n (\"acceptEdits\", lambda: t(\"cmd.sub.perm.accept_edits\")),\n (\"plan\", lambda: t(\"cmd.sub.perm.plan\")),\n (\"bypass\", lambda: t(\"cmd.sub.perm.bypass\")))),\n SlashCommand(\"/compact\", lambda: t(\"cmd.compact\"), _compact),\n SlashCommand(\"/reset\", lambda: t(\"cmd.reset\"), _reset),\n SlashCommand(\"/save\", lambda: t(\"cmd.save\"), _save),\n SlashCommand(\"/load\", lambda: t(\"cmd.load\"), _load),\n SlashCommand(\"/resume\", lambda: t(\"cmd.resume\"), _resume),\n SlashCommand(\"/config\", lambda: t(\"cmd.config\"), _config),\n SlashCommand(\"/forget\", lambda: t(\"cmd.forget\"), _forget),\n SlashCommand(\"/vim\", lambda: t(\"cmd.vim\"), _vim),\n SlashCommand(\"/lang\", lambda: t(\"cmd.lang\"), _lang,\n subargs=((\"en\", lambda: t(\"cmd.sub.lang.en\")),\n (\"zh\", lambda: t(\"cmd.sub.lang.zh\")))),\n SlashCommand(\"/quit\", lambda: t(\"cmd.quit\"), _quit,\n aliases=(\"/exit\", \"/q\")),\n ]:\n reg.register(cmd)\n\n # Let users drop ~/.lumen/commands/*.py to add their own commands.\n loaded = reg.discover_user_commands(_CONFIG_DIR / \"commands\")\n if loaded:\n render_system(t(\"msg.user_cmd_loaded\", names=\", \".join(loaded)), C_DIM)\n\n return reg\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Keybindings / bottom toolbar\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _make_key_bindings() -> KeyBindings:\n \"\"\"\n Main input-box keybindings:\n · Shift+Tab cycle general → plan → review → general\n · Enter submit the current input (overrides the default\n newline behaviour under multiline mode)\n · Enter (completion on) accept the active completion without submitting\n · Shift+Enter / Alt+Enter / Ctrl+J insert a newline\n\n About Shift+Enter:\n Terminals by default don't distinguish Shift+Enter from Enter — both\n send \\\\r. To make Shift+Enter insert a newline the terminal must emit\n one of the escape sequences below (we recognise all of them):\n · ESC [ 13 ; 2 u (CSI-u / kitty keyboard protocol; on by\n default in WezTerm/Ghostty/Kitty)\n · ESC [ 27 ; 2 ; 13 ~ (xterm modifyOtherKeys; on by default in\n xterm/urxvt)\n · ESC \\\\r (same as Alt+Enter — iTerm2 users can\n bind this via Preferences → Profiles →\n Keys, adding Shift+Return → Send Escape\n Sequence with an empty payload.)\n On macOS Terminal / iTerm2 default setups Shift+Enter is\n indistinguishable — use Alt+Enter as the fallback.\n \"\"\"\n kb = KeyBindings()\n\n @kb.add(\"s-tab\")\n def _toggle_mode(event):\n \"\"\"Cycle: general → plan → review → general.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n inner = _INNER_PT_REF.get(\"session\") or _PT_SESSION_REF.get(\"session\")\n if agent is None:\n return\n if agent.review_mode:\n # review → general\n agent.disable_review_mode()\n elif agent.plan_mode:\n # plan → review\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n else:\n # general → plan\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n # Force the bottom toolbar to redraw\n event.app.invalidate()\n\n @kb.add(\"enter\", filter=completion_is_selected)\n def _accept_completion(event):\n \"\"\"User navigated into the completion menu — Enter accepts, doesn't submit.\"\"\"\n event.current_buffer.complete_state = None\n\n @kb.add(\"enter\")\n def _submit(event):\n \"\"\"Plain Enter submits the line (overrides multiline-mode default newline).\"\"\"\n event.current_buffer.validate_and_handle()\n\n def _insert_newline(event):\n event.current_buffer.newline()\n\n # Alt+Enter (equivalent to ESC \\r) — the main newline key for iTerm2 users\n kb.add(\"escape\", \"enter\")(_insert_newline)\n\n # Ctrl+J — fallback when Alt is swallowed by the OS / window manager\n kb.add(\"c-j\")(_insert_newline)\n\n # Shift+Enter (CSI-u encoding): ESC [ 13 ; 2 u\n # prompt_toolkit parses the ESC prefix as \"escape\" then matches the\n # remaining bytes one-by-one.\n kb.add(\"escape\", \"[\", \"1\", \"3\", \";\", \"2\", \"u\")(_insert_newline)\n\n # Shift+Enter (xterm modifyOtherKeys encoding): ESC [ 27 ; 2 ; 13 ~\n kb.add(\"escape\", \"[\", \"2\", \"7\", \";\", \"2\", \";\", \"1\", \"3\", \"~\")(_insert_newline)\n\n return kb\n\n\ndef _bottom_toolbar():\n \"\"\"Persistent bottom status bar: mode · phase · keybinding hints.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n if agent is None:\n return \"\"\n if agent.review_mode:\n phase = agent.review_state.phase\n phase_text = {\n ReviewPhase.IDLE: t(\"toolbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"toolbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"toolbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"toolbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"toolbar.review_complete\"),\n }.get(phase, \"\")\n mode_part = t(\"toolbar.mode_review\", phase=phase_text)\n elif agent.plan_mode:\n mode_part = t(\"toolbar.mode_plan\")\n else:\n mode_part = t(\"toolbar.mode_general\")\n perm_tag = {\n PermissionMode.DEFAULT: \"\",\n PermissionMode.ACCEPT_EDITS: t(\"toolbar.perm_accept_edits\"),\n PermissionMode.PLAN: t(\"toolbar.perm_plan\"),\n PermissionMode.BYPASS: t(\"toolbar.perm_bypass\"),\n }[agent.permissions.mode]\n return t(\"toolbar.line\", mode=mode_part, perm=perm_tag, model=agent.model)\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main chat loop\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"Run the main chat loop. Returns True if the user asked to reconfigure, False to exit.\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\n\n Pauses any active Rich Live region so prompt_toolkit can own the\n terminal cleanly, and uses a dedicated modal PromptSession (no\n toolbar / completer / Shift+Tab binding) so keystrokes never get\n intercepted by the main input box's UX helpers.\n \"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = str(tool_input[key])\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n modal_pt = _INNER_PT_REF.get(\"session\") or pt\n with _LivePause():\n console.print()\n console.print(Panel(\n t(\"permission.panel_body\", tool=tool_name, hint=arg_hint),\n title=t(\"permission.panel_title\"), title_align=\"left\",\n border_style=\"yellow\",\n ))\n try:\n answer = await modal_pt.prompt_async(t(\"permission.prompt\"))\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # project directory tree\n DefinitionsTool(), # extract class/function definitions from a file\n FileReadTool(), # read file contents by line range\n FileWriteTool(), # create / overwrite files\n FileEditTool(), # exact find-and-replace editing\n GlobTool(), # match file paths by pattern\n GrepTool(), # search inside file contents\n BashTool(), # run shell commands\n ],\n )\n except Exception as e:\n render_error(t(\"msg.agent_failed\", err=e))\n return True\n\n # Register sub_agent + task_* tools so the model can spawn and observe\n # background work, and `/tasks` has something to show.\n agent.enable_subagents()\n\n # Discovery utilities: tool_search (rank tools by query) + brief (session summary).\n agent.enable_discovery_tools()\n\n # Auto-Dream: background memory consolidation. Runs async after every\n # N turns and periodically merges entries into narrative summaries.\n # Disable with LUMEN_NO_DREAM=1 if you want the old inline behavior.\n if os.environ.get(\"LUMEN_NO_DREAM\") != \"1\":\n agent.enable_auto_dream()\n\n # Auto-connect MCP servers declared in ~/.lumen/mcp.json (if any).\n mcp_cfg_path = _CONFIG_DIR / \"mcp.json\"\n if mcp_cfg_path.exists():\n try:\n mcp_states = await agent.connect_mcp_from_config(mcp_cfg_path)\n if mcp_states:\n ok = [s for s in mcp_states if s.status == \"connected\"]\n bad = [s for s in mcp_states if s.status == \"failed\"]\n if ok:\n names = \", \".join(\n f\"{s.config.name}({len(s.tools)})\" for s in ok\n )\n render_system(t(\"startup.mcp_connected\", names=names), C_DIM)\n for s in bad:\n render_system(\n t(\"startup.mcp_failed\", name=s.config.name, err=s.error),\n C_WARN,\n )\n except Exception as e:\n render_system(t(\"startup.mcp_load_failed\", err=e), C_WARN)\n\n _AGENT_REF[\"agent\"] = agent\n\n # Desktop notifier — fires on turns that exceed LUMEN_NOTIFY_THRESHOLD (15s default).\n # Disable entirely with LUMEN_NOTIFY=0.\n notifier = Notifier()\n\n # File edit tracker — session-scoped snapshot of files on first write/edit,\n # powers `/diff`.\n edit_tracker = FileEditTracker()\n edit_tracker.install(agent.hooks)\n\n # Build the slash command registry (scans ~/.lumen/commands/*.py for user cmds).\n command_registry = _build_command_registry()\n # Let the completer see registered commands (inc. user-loaded ones).\n completer = pt.completer\n if hasattr(completer, \"bind_registry\"):\n completer.bind_registry(command_registry)\n\n # Modal prompt session (for permission / approval / wizard sub-prompts).\n # Set up in main_async(); here we just reach for it.\n inner_pt = _INNER_PT_REF.get(\"session\") or pt\n\n # Startup info\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), display_provider_name(config))\n info.add_row(t(\"col.model\"), agent.model)\n info.add_row(t(\"col.session_id\"), agent.session.session_id[:12] + \"...\")\n info.add_row(t(\"col.context_window\"), f\"{agent.context_window:,} tokens\")\n info.add_row(t(\"col.auto_compact\"), t(\"startup.auto_compact\"))\n if agent.transcript:\n info.add_row(t(\"col.auto_save\"), t(\"startup.auto_save\", path=agent.transcript.path))\n info.add_row(t(\"col.git_state\"), t(\"startup.git_state\"))\n info.add_row(t(\"col.memory_files\"), t(\"startup.memory_files\"))\n info.add_row(t(\"col.tools_read\"), t(\"startup.tools_read\"))\n info.add_row(t(\"col.tools_write\"), t(\"startup.tools_write\"))\n info.add_row(t(\"col.permissions\"), t(\"startup.permissions\"))\n info.add_row(t(\"col.retry\"), t(\"startup.retry\"))\n info.add_row(t(\"col.file_cache\"), t(\"startup.file_cache\"))\n info.add_row(\n t(\"col.desktop_notify\"),\n t(\"startup.notifier_on\", channel=notifier.channel, sec=int(notifier.default_threshold_s))\n if notifier.enabled else t(\"startup.notifier_off\"),\n )\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(info, border_style=C_DIM, title=t(\"startup.ready\"), title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(t(\"startup.hint\"))\n\n _last_ctrl_c = False\n\n try:\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n\n # ── Slash commands (dispatched via CommandRegistry) ──────────────\n if user_input.startswith(\"/\"):\n head, _, rest = user_input.partition(\" \")\n command = command_registry.find(head.lower())\n if command is None:\n render_system(t(\"msg.unknown_command\", cmd=user_input), C_WARN)\n continue\n ctx = ChatContext(\n agent=agent, config=config, console=console,\n pt=pt, inner_pt=inner_pt, notifier=notifier,\n registry=command_registry, edit_tracker=edit_tracker,\n )\n try:\n result = await command.handler(ctx, rest.strip())\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n continue\n except Exception as e:\n render_error(t(\"msg.cmd_failed\", cmd=command.name, err=e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n continue\n if result.new_agent is not None:\n agent = result.new_agent\n _AGENT_REF[\"agent\"] = agent\n if result.reconfigure:\n return True\n if result.quit:\n return False\n continue\n\n # ── Normal chat turn ──────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n import time as _time\n _turn_start = _time.monotonic()\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n _elapsed = _time.monotonic() - _turn_start\n notifier.notify_if_slow(\n \"Lumen\",\n t(\"msg.reply_done\", preview=user_input[:40]),\n elapsed_s=_elapsed,\n )\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n _last_ctrl_c = True\n render_system(t(\"msg.cancel_reply\"), C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n finally:\n # Flush and close transcript on any exit path\n try:\n await agent.close()\n except Exception:\n pass\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main entry point\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Try to apply saved language preference BEFORE we render anything.\n try:\n _pre = load_config()\n if _pre is not None:\n _lang = normalize_language(_pre.language)\n if _lang:\n set_language(_lang)\n except Exception:\n pass\n\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(t(\"banner.tagline\"), style=C_DIM)))\n console.print(Align.center(Text(t(\"banner.subtitle\"), style=\"dim cyan\")))\n console.print()\n\n # Ensure config dir exists before FileHistory tries to open/append to it.\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n except Exception:\n pass\n\n # Vim mode: opt-in via LUMEN_VIM=1 or toggled at runtime via /vim\n vi_mode = os.environ.get(\"LUMEN_VIM\", \"\").strip() in (\"1\", \"true\", \"yes\", \"on\")\n\n pt = PromptSession(\n history=FileHistory(str(_CONFIG_DIR / \"history\")),\n style=PTStyle.from_dict({\n \"prompt\": \"ansicyan bold\",\n \"bottom-toolbar\": \"bg:#1a1a1a #888888\",\n \"completion-menu.completion\": \"bg:#1a1a1a #cccccc\",\n \"completion-menu.completion.current\": \"bg:#005f87 #ffffff bold\",\n \"completion-menu.meta.completion\": \"bg:#1a1a1a #707070 italic\",\n \"completion-menu.meta.completion.current\": \"bg:#005f87 #cfcfcf italic\",\n }),\n completer=SlashCompleter(),\n complete_while_typing=True,\n key_bindings=_make_key_bindings(),\n bottom_toolbar=_bottom_toolbar,\n refresh_interval=0.5,\n multiline=True, # Enter=submit, Alt+Enter/Ctrl+J=newline (see _make_key_bindings)\n vi_mode=vi_mode,\n )\n _PT_SESSION_REF[\"session\"] = pt\n\n # Inner prompt session — bare & separate, for modal prompts (permissions,\n # approvals). No toolbar, no completer, no Shift+Tab binding. This mirrors\n # src/'s pattern where permission dialogs use a dedicated Select component\n # with its own isolated input handling, so no keybindings leak in from\n # the main input box and no background refresh timer redraws over the prompt.\n inner_pt = PromptSession(\n history=None, # modal answers shouldn't pollute history\n style=PTStyle.from_dict({\"prompt\": \"ansiyellow bold\"}),\n )\n _INNER_PT_REF[\"session\"] = inner_pt\n\n # If CLI already passed enough args, skip the wizard.\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n language=get_language(),\n )\n await chat_loop(config, pt)\n return\n\n # Otherwise: config → chat → (maybe reconfigure) loop\n config: AgentConfig | None = None\n skip_saved_once = False # True on /config reconfigure: skip the \"use saved?\" prompt\n\n while True:\n # Prefer the saved config unless the user actively ran /config\n saved = None if skip_saved_once else load_config()\n skip_saved_once = False\n\n if saved is not None:\n # Apply saved language preference immediately.\n saved_lang = normalize_language(saved.language)\n if saved_lang and saved_lang != get_language():\n set_language(saved_lang)\n masked_key = (saved.api_key[:8] + \"...\" + saved.api_key[-4:]\n if len(saved.api_key) > 12 else \"****\")\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), display_provider_name(saved))\n info.add_row(t(\"col.model\"), saved.model)\n if saved.base_url:\n info.add_row(t(\"col.base_url\"), saved.base_url)\n info.add_row(t(\"col.api_key\"), masked_key)\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(\n info, border_style=\"green\",\n title=t(\"wizard.saved_detected\"), title_align=\"left\",\n ))\n try:\n raw = await pt.prompt_async(t(\"wizard.use_saved_prompt\"))\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n choice = raw.strip().lower()\n if choice in (\"\", \"y\", \"yes\"):\n config = saved\n elif choice == \"f\":\n forget_config()\n render_system(t(\"wizard.forgot\"), C_SUCCESS)\n config = None\n else:\n config = None # fall through to wizard\n\n if config is None:\n try:\n config = await setup_wizard(pt)\n save_config(config)\n render_system(\n t(\"wizard.saved_to\", path=_CONFIG_FILE),\n C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(t(\"wizard.reconfigure\"), style=C_BRAND))\n config = None\n skip_saved_once = True\n\n\ndef main() -> None:\n import argparse\n parser = argparse.ArgumentParser(description=t(\"cli.description\"))\n parser.add_argument(\"--model\", default=None, help=t(\"cli.arg.model\"))\n parser.add_argument(\"--api-key\", default=None, help=t(\"cli.arg.api_key\"))\n parser.add_argument(\"--base-url\", default=None, help=t(\"cli.arg.base_url\"))\n parser.add_argument(\n \"--lang\", default=None, choices=list(SUPPORTED_LANGUAGES),\n help=t(\"cli.arg.lang\"),\n )\n args = parser.parse_args()\n if args.lang:\n set_language(args.lang)\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "19c0dc677110964ad85e5faf5a43b07e5ac1f5a6": {"t": 1785773572.6220453, "n": 86610, "h": "5f928882f55a6901", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\nRun directly and pick a model / enter your API key inside the UI:\n python chat.py\n\nOr specify them on the command line (skipping the wizard):\n python chat.py --model gpt-4o --api-key sk-...\n\nSlash commands:\n /help Show help\n /status Token usage and session info\n /mode Switch general / plan / review (or cycle with Shift+Tab)\n /plan Plan Mode control (on | approve | cancel)\n /lang Switch UI language (en | zh)\n /compact Manually compact context\n /reset Clear conversation history\n /save Save session to file\n /load Load session from file\n /config Reconfigure model and key\n /forget Delete locally saved API key\n /quit Quit\n\nInteraction enhancements:\n • Type / to pop open the command completer\n • Type @ to auto-complete project file paths (30s cache)\n • /load <tab> completes files in the project; picking one loads that session\n • Multi-line input: Enter sends, Shift+Enter / Alt+Enter / Ctrl+J newline\n (Shift+Enter requires a terminal emitting CSI-u or modifyOtherKeys escapes;\n iTerm2/Terminal.app default doesn't distinguish — fall back to Alt+Enter)\n • Input history persisted at ~/.lumen/history, retained across sessions\n • Shift+Tab cycles modes\n • Bottom bar always shows the current mode / phase\n • After first configuration the key is saved in ~/.lumen/config.json (mode 0600),\n reused on next launch — clear with /forget.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.completion import Completer, Completion\nfrom prompt_toolkit.filters import completion_is_selected\nfrom prompt_toolkit.history import FileHistory\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import (\n Agent, PermissionChecker, PermissionBehavior, PermissionMode,\n HookRegistry, RetryConfig, Notifier, FileEditTracker,\n)\nfrom lumen.i18n import (\n t, tt, set_language, get_language,\n SUPPORTED_LANGUAGES, language_display_name, normalize_language,\n)\nfrom lumen.context.transcript import TranscriptReader, list_recent_sessions\nfrom chat_commands import (\n ChatContext, CommandRegistry, CommandResult, SlashCommand,\n)\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.services.review_state import ReviewPhase\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\nfrom lumen.tools.approval import ConsoleApprovalHandler\n\nconsole = Console()\n\n# ── Color palette ────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ── Shared state (accessed by keybinding / bottom_toolbar closures) ──────────\n_AGENT_REF: dict = {\"agent\": None}\n_PT_SESSION_REF: dict = {\"session\": None} # Main input session (/mode, Shift+Tab)\n_INNER_PT_REF: dict = {\"session\": None} # Modal input session (permission / approval)\n_LIVE_REF: dict = {\"live\": None} # Current agent_response Live, for pause/resume\n\n\nclass _LivePause:\n \"\"\"Context manager that suspends any active Rich Live region while a\n modal prompt runs, then resumes it. Prevents prompt_toolkit from fighting\n Rich for terminal control, which is what makes modal prompts appear to\n 'swallow Enter' — they were never visible in the first place.\"\"\"\n\n def __enter__(self):\n self._live = _LIVE_REF.get(\"live\")\n if self._live is not None:\n try:\n self._live.stop()\n except Exception:\n self._live = None\n return self\n\n def __exit__(self, exc_type, exc, tb):\n if self._live is not None:\n try:\n self._live.start()\n except Exception:\n pass\n return False\n\n\nclass _LivePausingApprovalHandler:\n \"\"\"Wraps any ApprovalHandler so each `.request()` call is bracketed by\n a `_LivePause`. Without this, the approval panel draws inside an active\n Rich Live region and prompt_toolkit can't take the terminal — the user\n sees nothing and their Enter appears to do nothing.\"\"\"\n\n def __init__(self, inner) -> None:\n self._inner = inner\n\n async def request(self, phase, title, content):\n with _LivePause():\n return await self._inner.request(phase, title, content)\n\n\n# ── Slash / @path completion ──────────────────────────────────────────────────\nclass SlashCompleter(Completer):\n \"\"\"\n Unified completer, three trigger categories:\n · `/` Top-level command menu (/help /mode /load …)\n · `/mode r…` Sub-arguments (general / review)\n · `/load …` Project file paths (for loading session files)\n · `@path/to/…` Project file references (inserts text, agent sees the path)\n\n File-tree scan results cached for 30s, auto-skipping common noise dirs\n (.git / node_modules / __pycache__ / .venv / …) so complete_while_typing\n doesn't walk the disk on every keystroke.\n \"\"\"\n\n # Fallback lists used only before the CommandRegistry is attached\n # (e.g. during setup_wizard). After chat_loop builds the registry, it\n # calls `.bind_registry(reg)` and we read live from there.\n @property\n def TOP_LEVEL(self):\n return [\n (\"/help\", t(\"cmd.help\")),\n (\"/config\", t(\"cmd.config\")),\n (\"/quit\", t(\"cmd.quit\")),\n ]\n\n @property\n def MODE_ARGS(self):\n return [\n (\"general\", t(\"cmd.sub.mode.general\")),\n (\"review\", t(\"cmd.sub.mode.review\")),\n ]\n\n SKIP_DIRS = {\n \".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\",\n \"dist\", \"build\", \".pytest_cache\", \".mypy_cache\", \".ruff_cache\",\n \".tox\", \".next\", \".turbo\", \".cache\", \"target\", \".idea\", \".vscode\",\n }\n _FILE_CACHE_TTL = 30.0\n _FILE_CACHE_MAX = 2000\n\n def __init__(self) -> None:\n self._file_cache: list[str] | None = None\n self._file_cache_ts: float = 0.0\n self._registry = None # type: ignore # Optional[CommandRegistry]\n\n def bind_registry(self, registry) -> None:\n \"\"\"Attach a CommandRegistry so completion reflects user-loaded commands.\"\"\"\n self._registry = registry\n\n def _top_level_items(self) -> list[tuple[str, str]]:\n if self._registry is not None:\n return [(c.name, c.description_text()) for c in self._registry.all()]\n return list(self.TOP_LEVEL)\n\n def _subargs_for(self, head: str) -> list[tuple[str, str]]:\n if self._registry is not None:\n cmd = self._registry.find(head)\n if cmd is not None and cmd.subargs:\n return cmd.subarg_items()\n if head == \"/mode\":\n return list(self.MODE_ARGS)\n return []\n\n def _scan_files(self) -> list[str]:\n import time\n now = time.time()\n if (\n self._file_cache is not None\n and (now - self._file_cache_ts) < self._FILE_CACHE_TTL\n ):\n return self._file_cache\n\n cwd = Path.cwd()\n results: list[str] = []\n for root, dirs, files in os.walk(cwd):\n dirs[:] = [\n d for d in dirs\n if d not in self.SKIP_DIRS and not d.startswith(\".\")\n ]\n root_path = Path(root)\n for f in files:\n if f.startswith(\".\") and f not in (\".gitignore\", \".env.example\"):\n continue\n full = root_path / f\n try:\n rel = full.relative_to(cwd)\n except ValueError:\n continue\n results.append(str(rel))\n if len(results) >= self._FILE_CACHE_MAX:\n break\n if len(results) >= self._FILE_CACHE_MAX:\n break\n results.sort()\n self._file_cache = results\n self._file_cache_ts = now\n return results\n\n def _rank_files(self, prefix: str, limit: int = 50) -> list[str]:\n \"\"\"Return files matching prefix, ranked: basename-start > path-start > substring.\"\"\"\n if not prefix:\n return self._scan_files()[:limit]\n lower = prefix.lower()\n ranked: list[tuple[int, str]] = []\n for path in self._scan_files():\n p_lower = path.lower()\n base_lower = Path(path).name.lower()\n if base_lower.startswith(lower):\n rank = 0\n elif p_lower.startswith(lower):\n rank = 1\n elif lower in p_lower:\n rank = 2\n else:\n continue\n ranked.append((rank, path))\n ranked.sort(key=lambda x: (x[0], x[1]))\n return [p for _, p in ranked[:limit]]\n\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n\n # ── @path file reference (allowed anywhere in a sentence) ────────────\n at_idx = text.rfind(\"@\")\n if at_idx != -1 and (at_idx == 0 or text[at_idx - 1] in \" \\t\\n\"):\n prefix = text[at_idx + 1:]\n if prefix == \"\" or (\" \" not in prefix and \"\\t\" not in prefix and \"\\n\" not in prefix):\n for path in self._rank_files(prefix):\n yield Completion(\n path, start_position=-len(prefix),\n display=path, display_meta=\"file\",\n )\n return\n\n # ── /load <path> file-path completion ────────────────────────────────\n if text.startswith(\"/load \"):\n arg = text[len(\"/load \"):].lstrip()\n for path in self._rank_files(arg):\n yield Completion(\n path, start_position=-len(arg),\n display=path, display_meta=\"session\",\n )\n return\n\n # ── Sub-arguments (e.g. /mode review, any registered subargs) ───────\n if text.startswith(\"/\") and \" \" in text:\n head, _, rest = text.partition(\" \")\n prefix = rest.lstrip()\n subargs = self._subargs_for(head)\n for arg, desc in subargs:\n if arg.startswith(prefix):\n yield Completion(\n arg, start_position=-len(prefix),\n display=arg, display_meta=desc,\n )\n return\n\n # ── Top-level slash command ──────────────────────────────────────────\n if text.startswith(\"/\"):\n for cmd, desc in self._top_level_items():\n if cmd.startswith(text):\n yield Completion(\n cmd, start_position=-len(text),\n display=cmd, display_meta=desc,\n )\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model catalog\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str # Raw description — may be an i18n key if prefixed with \"i18n:\"\n\n def display(self) -> str:\n \"\"\"Resolve the description, translating if it uses an i18n key.\"\"\"\n if self.desc.startswith(\"i18n:\"):\n return t(self.desc[5:])\n # Mixed template: leading \"<model> \" + suffix i18n key\n if \"||\" in self.desc:\n prefix, key = self.desc.split(\"||\", 1)\n return prefix + t(key)\n return self.desc\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str # Fixed brand name or an i18n key prefixed \"i18n:\"\n key_hint: str # API key format hint (may be i18n: key)\n env_key: str | None # Env var to read the key from\n base_url: str | None # None = let the Agent auto-detect\n models: list[ModelOption]\n needs_key: bool = True\n\n def display_name(self) -> str:\n return t(self.name[5:]) if self.name.startswith(\"i18n:\") else self.name\n\n def display_key_hint(self) -> str:\n return t(self.key_hint[5:]) if self.key_hint.startswith(\"i18n:\") else self.key_hint\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"i18n:provider.getgoapi.name\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 ||provider.model.latest_strongest\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini ||provider.model.fast_cheap\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano ||provider.model.ultra_cheap\"),\n ModelOption(\"gpt-4o\", \"GPT-4o ||provider.model.allround\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini ||provider.model.fast_cheap\"),\n ModelOption(\"o3-mini\", \"o3-mini ||provider.model.reasoning\"),\n ModelOption(\"claude-sonnet-4-6\", \"i18n:provider.model.sonnet_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"i18n:provider.model.haiku_fastcheap\"),\n ModelOption(\"deepseek-chat\", \"i18n:provider.model.deepseek_chat\"),\n ModelOption(\"deepseek-reasoner\", \"i18n:provider.model.deepseek_reasoner\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · ||provider.model.latest_strongest\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · ||provider.model.fast_cheap\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · ||provider.model.allround\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · ||provider.model.reasoning\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"i18n:provider.ollama.name\", key_hint=\"i18n:provider.ollama.key_hint\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"i18n:provider.custom.name\", key_hint=\"i18n:provider.custom.key_hint\",\n env_key=None,\n base_url=None,\n models=[], # User-supplied\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Configuration wizard\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n language: str = \"en\"\n\n\n# ── Config persistence ───────────────────────────────────────────────────────\n# Stored at ~/.lumen/config.json (file mode 0600) so users don't have to\n# re-enter the API key on every launch.\n\n_CONFIG_DIR = Path.home() / \".lumen\"\n_CONFIG_FILE = _CONFIG_DIR / \"config.json\"\n\n\ndef save_config(config: AgentConfig) -> None:\n \"\"\"Write config to ~/.lumen/config.json with 0600 permissions.\"\"\"\n import json\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n data = {\n \"api_key\": config.api_key,\n \"model\": config.model,\n \"base_url\": config.base_url,\n \"provider_name\": config.provider_name,\n \"language\": config.language,\n }\n _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding=\"utf-8\")\n try:\n os.chmod(_CONFIG_FILE, 0o600)\n except Exception:\n pass\n except Exception as e:\n render_system(t(\"wizard.save_failed\", err=e), C_DIM)\n\n\ndef load_config() -> AgentConfig | None:\n \"\"\"Load cached config if present and well-formed.\"\"\"\n import json\n if not _CONFIG_FILE.exists():\n return None\n try:\n data = json.loads(_CONFIG_FILE.read_text(encoding=\"utf-8\"))\n if not data.get(\"model\") or not data.get(\"api_key\"):\n return None\n lang = normalize_language(data.get(\"language\")) or get_language()\n return AgentConfig(\n api_key=data[\"api_key\"],\n model=data[\"model\"],\n base_url=data.get(\"base_url\"),\n provider_name=data.get(\"provider_name\") or \"Saved\",\n language=lang,\n )\n except Exception:\n return None\n\n\ndef forget_config() -> bool:\n \"\"\"Delete the saved config file.\"\"\"\n try:\n if _CONFIG_FILE.exists():\n _CONFIG_FILE.unlink()\n return True\n except Exception:\n pass\n return False\n\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"Interactive configuration wizard: pick Provider → pick Model → enter Key.\"\"\"\n console.print()\n console.print(Rule(t(\"wizard.title\"), style=C_BRAND))\n console.print()\n\n # ── Step 1: pick Provider ────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key detected[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.display_name() + detected, key_info)\n\n console.print(Panel(table, title=t(\"wizard.pick_provider\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(t(\"wizard.range_hint\", n=len(PROVIDERS)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(t(\"wizard.selected\", value=provider.display_name()) + \"\\n\")\n\n # ── Step 2: pick Model ───────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(t(\"wizard.custom_base_url\"))).strip()\n model = (await pt.prompt_async(t(\"wizard.custom_model\"))).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(t(\"wizard.custom_empty_model\"))\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.display())\n\n console.print(Panel(model_table, title=t(\"wizard.pick_model\"), border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(t(\"wizard.enter_number\"))\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(t(\"wizard.range_hint\", n=len(provider.models)))\n except ValueError:\n console.print(t(\"wizard.must_be_number\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(t(\"wizard.selected\", value=model) + \"\\n\")\n\n # ── Step 3: enter API Key ────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(t(\"wizard.local_no_key\"))\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n t(\"wizard.key_env_detected\", name=provider.env_key, masked=masked)\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(t(\"wizard.key_env_used\") + \"\\n\")\n else:\n api_key = await _prompt_key(pt, provider.display_key_hint())\n else:\n console.print(t(\"wizard.key_hint_prefix\", hint=provider.display_key_hint()))\n api_key = await _prompt_key(pt, provider.display_key_hint())\n\n # ── Summary ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(t(\"col.provider\"), provider.display_name())\n summary.add_row(t(\"col.model\"), model)\n if base_url:\n summary.add_row(t(\"col.base_url\"), base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(t(\"col.api_key\"), key_display)\n console.print(Panel(summary, title=t(\"wizard.summary_title\"), border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.display_name(),\n language=get_language(),\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"API Key input with basic masking prompt.\"\"\"\n while True:\n try:\n key = await pt.prompt_async(t(\"wizard.key_prompt\"))\n key = key.strip()\n if key:\n return key\n console.print(t(\"wizard.key_empty\"))\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI rendering components\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(t(\"tokenbar.model\"), style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(t(\"tokenbar.msgs\"), style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(t(\"tokenbar.compactions\"), style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.review_mode:\n stats.append(\" │ \", style=C_DIM)\n phase = agent.review_state.phase\n phase_label = {\n ReviewPhase.IDLE: t(\"tokenbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"tokenbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"tokenbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"tokenbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"tokenbar.review_complete\"),\n }.get(phase, t(\"tokenbar.review_default\"))\n stats.append(f\"⚑ {phase_label}\", style=\"bold yellow\")\n elif agent.plan_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(t(\"tokenbar.plan_mode\"), style=\"bold blue\")\n\n perm_mode = agent.permissions.mode\n if perm_mode != PermissionMode.DEFAULT:\n stats.append(\" │ \", style=C_DIM)\n perm_label, perm_style = {\n PermissionMode.ACCEPT_EDITS: (\"✎ accept-edits\", \"bold green\"),\n PermissionMode.PLAN: (\"📋 plan-only\", \"bold blue\"),\n PermissionMode.BYPASS: (\"⚠ bypass\", \"bold red\"),\n }[perm_mode]\n stats.append(perm_label, style=perm_style)\n\n border = (\n \"red\" if perm_mode == PermissionMode.BYPASS\n else \"blue\" if perm_mode == PermissionMode.PLAN or agent.plan_mode\n else \"yellow\" if agent.review_mode\n else C_DIM\n )\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\ndef render_user_message(text_value: str) -> Panel:\n return Panel(\n Text(text_value, style=\"white\"),\n title=Text(t(\"msg.you\"), style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text_value: str, style: str = C_DIM) -> None:\n console.print(f\" {text_value}\", style=style)\n\n\ndef render_error(text_value: str) -> None:\n console.print(Panel(text_value, border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help(registry: \"CommandRegistry | None\" = None) -> None:\n \"\"\"Render /help. If a CommandRegistry is supplied, the slash-command rows\n are pulled from it so user-loaded commands show up automatically.\"\"\"\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n\n if registry is not None:\n for cmd in registry.all():\n hint = cmd.description_text()\n if cmd.aliases:\n hint += f\" (aliases: {' '.join(cmd.aliases)})\"\n table.add_row(cmd.name, hint)\n else:\n table.add_row(\"/help\", t(\"cmd.help\"))\n table.add_row(\"/quit\", t(\"cmd.quit\"))\n\n # Non-slash keybinding reference\n for row in [\n (\"\", \"\"),\n (\"↑ / ↓\", t(\"help.row.history\")),\n (\"Shift+Enter\", t(\"help.row.shift_enter\")),\n (\"Alt+Enter\", t(\"help.row.alt_enter\")),\n (\"@path\", t(\"help.row.at_path\")),\n (\"Shift+Tab\", t(\"help.row.shift_tab\")),\n (\"Ctrl+C\", t(\"help.row.ctrl_c\")),\n ]:\n table.add_row(*row)\n console.print(Panel(table, title=t(\"help.title\"), border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(t(\"status.model\"), agent.model)\n table.add_row(t(\"status.context_window\"), f\"{agent.context_window:,} tokens\")\n table.add_row(t(\"status.tokens_used\"), f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(t(\"status.messages\"), str(len(agent.messages)))\n table.add_row(t(\"status.compactions\"), str(agent.session.compaction_count))\n table.add_row(t(\"status.session_start\"), agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=t(\"status.title\"), border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent response\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n Run a full agent turn: live-display tool calls and render the final\n answer as Markdown.\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(t(\"msg.lumen_thinking\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── Use a Live panel to show progress while tools run ────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n _LIVE_REF[\"live\"] = live\n tool_log.append(t(\"msg.thinking\"))\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n try:\n await _chat_with_refresh()\n finally:\n _LIVE_REF[\"live\"] = None\n else:\n # No tools: stream output straight through\n collected: list[str] = []\n panel_title = Text(t(\"msg.lumen_streaming\"), style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n _LIVE_REF[\"live\"] = live\n try:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\"\".join(collected), code_theme=\"monokai\", inline_code_theme=\"monokai\")\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n finally:\n _LIVE_REF[\"live\"] = None\n return \"\".join(collected)\n\n # ── Render the final answer ──────────────────────────────────────────────\n final_text = response.content if response else \"\"\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(t(\"msg.lumen\"), style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Mode switching\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str, pt: PromptSession | None = None) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n if agent.review_mode:\n mode_label = t(\"mode.review_label\")\n elif agent.plan_mode:\n mode_label = t(\"mode.plan_label\")\n else:\n mode_label = t(\"mode.general_label\")\n console.print(Panel(\n t(\"mode.current\", label=mode_label) + \"\\n\\n\"\n + t(\"mode.hint_review\") + \"\\n\"\n + t(\"mode.hint_plan\") + \"\\n\"\n + t(\"mode.hint_general\") + \"\\n\"\n + t(\"mode.hint_shift_tab\"),\n border_style=C_DIM, title=t(\"mode.panel_title\"), title_align=\"left\",\n ))\n return\n\n if arg in (\"review\", \"r\", \"审阅\", \"careful\"):\n if agent.review_mode:\n render_system(t(\"mode.already_review\"), C_DIM)\n else:\n inner = _INNER_PT_REF.get(\"session\") or pt\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n console.print(Panel(\n t(\"mode.review_panel_body\"),\n border_style=\"yellow\",\n title=t(\"mode.review_panel_title\"),\n title_align=\"left\",\n ))\n\n elif arg in (\"plan\", \"p\", \"方案\", \"计划\"):\n _activate_plan_mode(agent)\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n was_plan = agent.plan_mode\n was_review = agent.review_mode\n if not was_plan and not was_review:\n render_system(t(\"mode.already_general\"), C_DIM)\n else:\n if was_review:\n agent.disable_review_mode()\n if was_plan:\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"mode.back_to_general\"), C_SUCCESS)\n\n else:\n render_system(t(\"mode.unknown\", arg=arg), C_WARN)\n\n\ndef _activate_plan_mode(agent: Agent) -> None:\n if agent.plan_mode:\n render_system(t(\"mode.already_plan\"), C_DIM)\n return\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n console.print(Panel(\n t(\"mode.plan_panel_body\"),\n border_style=\"blue\",\n title=t(\"mode.plan_panel_title\"),\n title_align=\"left\",\n ))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Command handling\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(t(\"compact.running\"), C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n t(\"compact.body\",\n mb=r.messages_before, ma=r.messages_after,\n tb=r.tokens_before, ta=r.tokens_after,\n kept=r.kept_recent_count),\n border_style=\"green\", title=t(\"compact.title\"), title_align=\"left\",\n ))\n except Exception as e:\n render_error(t(\"compact.failed\", err=e))\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(t(\"save.prompt\", default=default), default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(t(\"save.done\", path=path), C_SUCCESS)\n if agent.transcript:\n render_system(t(\"save.transcript_hint\", path=agent.transcript.path), C_DIM)\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n except Exception as e:\n render_error(t(\"save.failed\", err=e))\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(t(\"load.prompt\"))).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(t(\"load.missing\", path=arg))\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(t(\"load.done\", n=len(agent.messages), path=arg), C_SUCCESS)\n return agent\n except Exception as e:\n render_error(t(\"load.failed\", err=e))\n return None\n\n\nasync def handle_resume(config: AgentConfig, pt: PromptSession) -> Agent | None:\n \"\"\"List recent auto-saved sessions and let the user pick one to resume.\"\"\"\n cwd = Path.cwd()\n sessions = list_recent_sessions(cwd, limit=10)\n if not sessions:\n render_system(t(\"resume.none\"), C_WARN)\n return None\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"#\", style=C_NUM, width=4)\n table.add_column(t(\"resume.col.session_id\"), style=\"white\")\n table.add_column(t(\"status.model\"), style=C_DIM)\n table.add_column(t(\"resume.col.messages\"), style=\"white\", justify=\"right\")\n table.add_column(t(\"resume.col.created\"), style=C_DIM)\n table.add_column(t(\"resume.col.last_prompt\"), style=\"white\")\n\n for i, s in enumerate(sessions, 1):\n created = s.created_at[:19].replace(\"T\", \" \") if s.created_at else \"?\"\n prompt_preview = s.last_prompt[:50] + (\"...\" if len(s.last_prompt) > 50 else \"\")\n table.add_row(\n f\"[{i}]\",\n s.session_id[:12] + \"...\",\n s.model or \"?\",\n str(s.message_count),\n created,\n prompt_preview,\n )\n\n console.print(Panel(table, title=t(\"resume.table_title\"), border_style=C_DIM))\n\n try:\n raw = await pt.prompt_async(t(\"resume.pick_prompt\"))\n raw = raw.strip()\n if not raw:\n return None\n idx = int(raw) - 1\n if not (0 <= idx < len(sessions)):\n render_system(t(\"resume.range_hint\", n=len(sessions)), C_WARN)\n return None\n except (ValueError, KeyboardInterrupt, EOFError):\n return None\n\n chosen = sessions[idx]\n try:\n messages, metadata = TranscriptReader.load_session(chosen.file_path)\n if not messages:\n render_error(t(\"resume.empty_file\"))\n return None\n\n # Rebuild a Session from the transcript messages\n from lumen.context.session import Session\n session = Session(model=config.model)\n session.session_id = chosen.session_id\n session.messages = messages\n\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n session=session,\n auto_compact=True,\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n FileWriteTool(), FileEditTool(),\n GlobTool(), GrepTool(), BashTool(),\n ],\n )\n render_system(\n t(\"resume.done\", sid=chosen.session_id[:12], n=len(messages)),\n C_SUCCESS,\n )\n return agent\n except Exception as e:\n render_error(t(\"resume.failed\", err=e))\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Slash command registry\n# ─────────────────────────────────────────────────────────────────────────────\n# All slash commands (/help, /status, /mode, /load, …) register through the\n# CommandRegistry. Both SlashCompleter and /help read from here — single\n# source of truth. At startup we also auto-scan ~/.lumen/commands/*.py so\n# users can add their own commands without editing chat.py.\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _build_command_registry() -> CommandRegistry:\n \"\"\"Build the built-in command registry. Handlers close over ChatContext\n (passed in at dispatch time), so they can see live agent/config/pt state.\"\"\"\n reg = CommandRegistry()\n\n async def _quit(ctx, arg):\n render_system(t(\"common.goodbye\"), C_DIM)\n return CommandResult(quit=True)\n\n async def _help(ctx, arg):\n render_help(ctx.registry)\n return CommandResult()\n\n async def _status(ctx, arg):\n render_status(ctx.agent)\n return CommandResult()\n\n async def _mode(ctx, arg):\n handle_mode(ctx.agent, arg, pt=ctx.pt)\n return CommandResult()\n\n async def _compact(ctx, arg):\n await handle_compact(ctx.agent)\n return CommandResult()\n\n async def _reset(ctx, arg):\n ctx.agent.reset()\n render_system(t(\"reset.done\"), C_SUCCESS)\n return CommandResult()\n\n async def _save(ctx, arg):\n await handle_save(ctx.agent, ctx.pt)\n return CommandResult()\n\n async def _load(ctx, arg):\n new = await handle_load(arg, ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _resume(ctx, arg):\n new = await handle_resume(ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _config(ctx, arg):\n render_system(t(\"config.back_to_wizard\"), C_DIM)\n return CommandResult(reconfigure=True)\n\n async def _forget(ctx, arg):\n if forget_config():\n render_system(t(\"forget.done\", path=_CONFIG_FILE), C_SUCCESS)\n else:\n render_system(t(\"forget.nothing\"), C_DIM)\n return CommandResult()\n\n async def _lang(ctx, arg):\n \"\"\"Switch UI language live.\"\"\"\n arg = arg.strip().lower()\n if not arg:\n current = language_display_name()\n ctx.console.print(Panel(\n t(\"lang.current\", name=current) + \"\\n\\n\" + t(\"lang.available\"),\n border_style=C_DIM, title=\"Language / \\u8bed\\u8a00\",\n title_align=\"left\",\n ))\n return CommandResult()\n target = normalize_language(arg)\n if target is None:\n render_system(t(\"lang.unknown\", arg=arg), C_WARN)\n return CommandResult()\n if target == get_language():\n render_system(t(\"lang.already\", name=language_display_name(target)), C_DIM)\n return CommandResult()\n set_language(target)\n # Persist to config so next launch keeps this choice\n try:\n ctx.config.language = target\n save_config(ctx.config)\n except Exception:\n pass\n render_system(t(\"lang.switched\", name=language_display_name(target)), C_SUCCESS)\n return CommandResult()\n\n async def _perm(ctx, arg):\n \"\"\"Switch permission posture. /perm alone shows current + options.\"\"\"\n checker = ctx.agent.permissions\n current = checker.mode\n\n labels = {\n PermissionMode.DEFAULT: tt(\"perm.label.default\"),\n PermissionMode.ACCEPT_EDITS: tt(\"perm.label.accept_edits\"),\n PermissionMode.PLAN: tt(\"perm.label.plan\"),\n PermissionMode.BYPASS: tt(\"perm.label.bypass\"),\n }\n\n arg = arg.strip().lower()\n if not arg:\n lines = [t(\"perm.current\", name=labels[current][0])]\n for m, (name, desc) in labels.items():\n marker = \"● \" if m == current else \" \"\n style = \"bold cyan\" if m == current else \"white\"\n lines.append(f\" {marker}[{style}]/perm {name}[/] {desc}\")\n ctx.console.print(Panel(\n \"\\n\".join(lines),\n title=t(\"perm.panel_title\"), title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n\n # Parse mode name (accept several aliases — bilingual)\n alias_map = {\n \"default\": PermissionMode.DEFAULT, \"d\": PermissionMode.DEFAULT, \"常规\": PermissionMode.DEFAULT,\n \"acceptedits\": PermissionMode.ACCEPT_EDITS, \"accept\": PermissionMode.ACCEPT_EDITS,\n \"edits\": PermissionMode.ACCEPT_EDITS, \"a\": PermissionMode.ACCEPT_EDITS, \"接受\": PermissionMode.ACCEPT_EDITS,\n \"plan\": PermissionMode.PLAN, \"p\": PermissionMode.PLAN, \"计划\": PermissionMode.PLAN,\n \"bypass\": PermissionMode.BYPASS, \"b\": PermissionMode.BYPASS, \"绕过\": PermissionMode.BYPASS,\n }\n target = alias_map.get(arg.replace(\"-\", \"\").replace(\"_\", \"\"))\n if target is None:\n render_system(t(\"perm.unknown\", arg=arg), C_WARN)\n return CommandResult()\n\n if target == current:\n render_system(t(\"perm.already\", name=labels[target][0]), C_DIM)\n return CommandResult()\n\n if target == PermissionMode.BYPASS:\n # Extra friction for the nuclear option.\n try:\n confirm = await ctx.inner_pt.prompt_async(\n t(\"perm.bypass_confirm\")\n )\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n if confirm.strip().lower() not in (\"y\", \"yes\"):\n render_system(t(\"common.cancelled\"), C_DIM)\n return CommandResult()\n\n checker.set_mode(target)\n render_system(\n t(\"perm.switched\", name=labels[target][0], desc=labels[target][1]),\n C_SUCCESS,\n )\n return CommandResult()\n\n async def _diff(ctx, arg):\n \"\"\"Show unified diff of files the agent wrote/edited this session.\"\"\"\n from rich.syntax import Syntax\n tracker = ctx.edit_tracker\n if tracker is None:\n render_system(t(\"diff.no_tracker\"), C_WARN)\n return CommandResult()\n\n entries = tracker.entries(dirty_only=True)\n if not entries:\n render_system(t(\"diff.no_changes\"), C_DIM)\n return CommandResult()\n\n target = arg.strip()\n if target:\n # Filter: user can pass a path (absolute or cwd-relative) or basename\n keep = []\n for e in entries:\n if (\n e.path == target\n or Path(e.path).name == target\n or target in e.path\n ):\n keep.append(e)\n if not keep:\n render_system(t(\"diff.no_match\", target=target), C_WARN)\n return CommandResult()\n entries = keep\n\n # Summary table first\n summary = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n summary.add_column(t(\"diff.col.file\"), style=\"white\")\n summary.add_column(t(\"diff.col.plus\"), style=\"bold green\", justify=\"right\")\n summary.add_column(t(\"diff.col.minus\"), style=\"bold red\", justify=\"right\")\n summary.add_column(t(\"diff.col.writes\"), style=C_DIM, justify=\"right\")\n summary.add_column(t(\"diff.col.status\"), style=C_DIM)\n total_added = total_removed = 0\n for e in entries:\n added, removed = e.line_stats()\n total_added += added\n total_removed += removed\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n summary.add_row(\n rel, f\"+{added}\", f\"-{removed}\",\n str(e.write_count),\n t(\"diff.status_new\") if not e.existed_before else t(\"diff.status_edited\"),\n )\n ctx.console.print(Panel(\n summary,\n title=t(\"diff.panel_title\", n=len(entries), added=total_added, removed=total_removed),\n title_align=\"left\", border_style=C_DIM,\n ))\n\n # Per-file unified diff\n for e in entries:\n diff_text = e.unified_diff()\n if not diff_text:\n continue\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n ctx.console.print(Panel(\n Syntax(diff_text, \"diff\", theme=\"monokai\", line_numbers=False,\n word_wrap=False, background_color=\"default\"),\n title=rel, title_align=\"left\", border_style=\"blue\",\n padding=(0, 1),\n ))\n\n return CommandResult()\n\n async def _plan(ctx, arg):\n \"\"\"Plan Mode control: on/off/approve/cancel. Bare /plan shows status.\"\"\"\n agent = ctx.agent\n sub = arg.strip().lower()\n\n if not sub:\n if agent.plan_mode:\n ctx.console.print(Panel(\n t(\"plan.active_body\"),\n border_style=\"blue\", title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n else:\n ctx.console.print(Panel(\n t(\"plan.inactive_body\"),\n border_style=C_DIM, title=t(\"plan.on_title\"), title_align=\"left\",\n ))\n return CommandResult()\n\n if sub in (\"on\", \"start\", \"开启\"):\n _activate_plan_mode(agent)\n return CommandResult()\n\n if sub in (\"off\", \"cancel\", \"取消\"):\n if not agent.plan_mode:\n render_system(t(\"plan.off_not_active\"), C_DIM)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(t(\"plan.cancelled\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"approve\", \"go\", \"执行\", \"ok\"):\n if not agent.plan_mode:\n render_system(t(\"plan.approve_not_active\"), C_WARN)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.ACCEPT_EDITS)\n render_system(t(\"plan.approved\"), C_SUCCESS)\n return CommandResult()\n\n render_system(t(\"plan.unknown_sub\", sub=sub), C_WARN)\n return CommandResult()\n\n async def _tasks(ctx, arg):\n \"\"\"Show / inspect / cancel background sub-agent tasks.\n\n Subcommands:\n /tasks → table of all tasks\n /tasks <id> → show output of that task\n /tasks stop <id> → cancel\n /tasks clear → kill every running task + clear finished\n \"\"\"\n import time as _time\n mgr = ctx.agent.subagent_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n tasks = mgr.list_tasks()\n\n if sub_lower in (\"clear\", \"reset\", \"清空\"):\n await mgr.cleanup()\n render_system(t(\"tasks.cleared\"), C_SUCCESS)\n return CommandResult()\n\n if sub_lower.startswith(\"stop \") or sub_lower.startswith(\"kill \"):\n target = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.kill(target)\n if ok:\n render_system(t(\"tasks.stopped\", id=target), C_SUCCESS)\n else:\n render_system(t(\"tasks.stop_missing\", id=target), C_WARN)\n return CommandResult()\n\n # /tasks <id> → single task detail\n if sub and not sub_lower.startswith((\"stop\", \"kill\", \"clear\")):\n match = next((task for task in tasks if task.agent_id == sub), None)\n if match is None:\n render_system(t(\"tasks.not_found\", id=sub), C_WARN)\n return CommandResult()\n result = await mgr.get_result(sub)\n if result is None:\n ctx.console.print(Panel(\n f\"status: [yellow]{match.status}[/]\\n\"\n f\"description: {match.description or '(none)'}\\n\"\n f\"elapsed: {_time.monotonic() - match.start_time:.1f}s\",\n title=f\"Task {sub}\", title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n body = result.content if result.success else (result.error or \"\")\n ctx.console.print(Panel(\n f\"[dim]duration {result.duration_ms:.0f}ms · \"\n f\"tool calls {result.tool_calls_count} · \"\n f\"success {result.success}[/]\\n\\n{body}\",\n title=f\"Task {sub} · {result.description}\",\n title_align=\"left\",\n border_style=\"green\" if result.success else \"red\",\n ))\n return CommandResult()\n\n # Default: table of all tasks\n if not tasks:\n render_system(t(\"tasks.empty_hint\"), C_DIM)\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"id\", style=C_BRAND)\n table.add_column(t(\"tasks.col.state\"), style=\"white\")\n table.add_column(t(\"tasks.col.elapsed\"), style=C_DIM, justify=\"right\")\n table.add_column(t(\"tasks.col.description\"), style=\"white\")\n now = _time.monotonic()\n status_style = {\n \"running\": \"[yellow]● running[/]\",\n \"completed\": \"[green]✓ completed[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"killed\": \"[dim]■ killed[/]\",\n }\n for task in tasks:\n elapsed = now - task.start_time if task.start_time else 0.0\n table.add_row(\n task.agent_id,\n status_style.get(task.status, task.status),\n f\"{elapsed:.1f}s\",\n task.description or \"(none)\",\n )\n running_n = sum(1 for task in tasks if task.status == \"running\")\n ctx.console.print(Panel(\n table,\n title=t(\"tasks.table_title\", n=len(tasks), r=running_n),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n render_system(t(\"tasks.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _dream(ctx, arg):\n \"\"\"Inspect / control Auto-Dream (background memory consolidation).\"\"\"\n agent = ctx.agent\n service = agent.auto_dream\n sub = arg.strip().lower()\n\n if service is None:\n render_system(t(\"dream.disabled_hint\"), C_DIM)\n return CommandResult()\n\n if sub in (\"off\", \"disable\", \"stop\"):\n agent.disable_auto_dream()\n render_system(t(\"dream.off\"), C_SUCCESS)\n return CommandResult()\n\n if sub in (\"force\", \"now\", \"立刻\"):\n render_system(t(\"dream.forcing\"), C_DIM)\n await service.force_dream()\n stats = service.stats\n render_system(\n t(\"dream.forced_done\", e=stats.extractions, c=stats.consolidations),\n C_SUCCESS,\n )\n return CommandResult()\n\n if sub in (\"consolidate\", \"sum\"):\n render_system(t(\"dream.consolidating\"), C_DIM)\n summary = await service.consolidate_now()\n if summary:\n ctx.console.print(Panel(\n summary,\n title=t(\"dream.consolidate_title\"),\n title_align=\"left\",\n border_style=\"magenta\",\n ))\n else:\n render_system(t(\"dream.consolidate_empty\"), C_DIM)\n return CommandResult()\n\n # Default: show stats + config\n stats = service.stats\n cfg = service.config\n mem_count = (\n len(agent.session_memory) if agent.session_memory is not None else 0\n )\n state_tag = (\n t(\"dream.stats_state_dreaming\") if stats.currently_dreaming\n else t(\"dream.stats_state_idle\")\n )\n ctx.console.print(Panel(\n t(\n \"dream.stats_body\",\n state=state_tag,\n e=stats.extractions,\n c=stats.consolidations,\n last=stats.last_run_at or \"[dim]—[/]\",\n err=stats.last_error or f\"[dim]{t('common.none')}[/]\",\n mem=mem_count,\n interval=cfg.interval_turns,\n cons=cfg.consolidation_every,\n ),\n title=t(\"dream.stats_title\"), title_align=\"left\", border_style=\"magenta\",\n ))\n return CommandResult()\n\n async def _mcp(ctx, arg):\n \"\"\"MCP server control.\n\n /mcp list connected servers and their tools\n /mcp reconnect <n> drop and re-dial a server\n /mcp disconnect <n> disconnect and unregister tools\n \"\"\"\n agent = ctx.agent\n mgr = agent.mcp_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n\n async def _reconnect(name: str) -> None:\n state = mgr.get(name)\n if state is None:\n render_system(t(\"mcp.no_server\", name=name), C_WARN)\n return\n cfg = state.config\n await mgr.disconnect(name)\n new_state = await agent.connect_mcp(cfg)\n if new_state.status == \"connected\":\n render_system(\n t(\"mcp.reconnect_ok\", name=name, n=len(new_state.tools)),\n C_SUCCESS,\n )\n else:\n render_system(\n t(\"mcp.reconnect_failed\", name=name, err=new_state.error),\n C_WARN,\n )\n\n if sub_lower.startswith(\"reconnect \"):\n await _reconnect(sub.split(maxsplit=1)[1].strip())\n return CommandResult()\n\n if sub_lower.startswith(\"disconnect \"):\n name = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.disconnect(name)\n if ok:\n # Also drop the tools from the registry\n for tname in list(agent._tool_registry.list_tools()):\n if tname.startswith(f\"mcp__{name}__\"):\n agent._tool_registry.unregister(tname)\n render_system(t(\"mcp.disconnected\", name=name), C_SUCCESS)\n else:\n render_system(t(\"mcp.not_connected\", name=name), C_DIM)\n return CommandResult()\n\n # Default: show table\n servers = mgr.servers()\n if not servers:\n ctx.console.print(Panel(\n t(\"mcp.empty_body\", path=str(_CONFIG_DIR / \"mcp.json\")),\n border_style=C_DIM, title=\"MCP\", title_align=\"left\",\n ))\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"server\", style=C_BRAND)\n table.add_column(t(\"mcp.col.status\"), style=\"white\")\n table.add_column(t(\"mcp.col.tools\"), justify=\"right\", style=C_DIM)\n table.add_column(t(\"mcp.col.detail\"), style=C_DIM)\n status_mark = {\n \"connected\": \"[green]● connected[/]\",\n \"connecting\": \"[yellow]● connecting[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"closed\": \"[dim]■ closed[/]\",\n \"disabled\": \"[dim]⊘ disabled[/]\",\n \"idle\": \"[dim]idle[/]\",\n }\n for s in servers:\n cmd_preview = f\"{s.config.command} {' '.join(s.config.args[:3])}\"\n if len(s.config.args) > 3:\n cmd_preview += \" …\"\n detail = s.error if s.error else cmd_preview\n table.add_row(\n s.config.name,\n status_mark.get(s.status, s.status),\n str(len(s.tools)),\n detail,\n )\n ctx.console.print(Panel(\n table,\n title=t(\"mcp.table_title\", n=len(servers)),\n title_align=\"left\",\n border_style=C_DIM,\n ))\n # Per-server tool breakdown (compact)\n for s in servers:\n if not s.tools:\n continue\n tool_names = \", \".join(tool.name for tool in s.tools)\n ctx.console.print(\n f\" [dim]{s.config.name}:[/] {tool_names}\"\n )\n render_system(t(\"mcp.footer_hint\"), C_DIM)\n return CommandResult()\n\n async def _vim(ctx, arg):\n from prompt_toolkit.enums import EditingMode\n app = ctx.pt.app\n if app.editing_mode == EditingMode.VI:\n app.editing_mode = EditingMode.EMACS\n render_system(t(\"vim.off\"), C_SUCCESS)\n else:\n app.editing_mode = EditingMode.VI\n render_system(t(\"vim.on\"), C_SUCCESS)\n return CommandResult()\n\n for cmd in [\n SlashCommand(\"/help\", lambda: t(\"cmd.help\"), _help),\n SlashCommand(\"/status\", lambda: t(\"cmd.status\"), _status),\n SlashCommand(\"/mode\", lambda: t(\"cmd.mode\"), _mode,\n subargs=((\"general\", lambda: t(\"cmd.sub.mode.general\")),\n (\"plan\", lambda: t(\"cmd.sub.mode.plan\")),\n (\"review\", lambda: t(\"cmd.sub.mode.review\")))),\n SlashCommand(\"/plan\", lambda: t(\"cmd.plan\"), _plan,\n subargs=((\"on\", lambda: t(\"cmd.sub.plan.on\")),\n (\"approve\", lambda: t(\"cmd.sub.plan.approve\")),\n (\"cancel\", lambda: t(\"cmd.sub.plan.cancel\")),\n (\"off\", lambda: t(\"cmd.sub.plan.off\")))),\n SlashCommand(\"/diff\", lambda: t(\"cmd.diff\"), _diff),\n SlashCommand(\"/tasks\", lambda: t(\"cmd.tasks\"), _tasks,\n subargs=((\"clear\", lambda: t(\"cmd.sub.tasks.clear\")),\n (\"stop\", lambda: t(\"cmd.sub.tasks.stop\")))),\n SlashCommand(\"/mcp\", lambda: t(\"cmd.mcp\"), _mcp,\n subargs=((\"reconnect\", lambda: t(\"cmd.sub.mcp.reconnect\")),\n (\"disconnect\", lambda: t(\"cmd.sub.mcp.disconnect\")))),\n SlashCommand(\"/dream\", lambda: t(\"cmd.dream\"), _dream,\n subargs=((\"force\", lambda: t(\"cmd.sub.dream.force\")),\n (\"consolidate\", lambda: t(\"cmd.sub.dream.consolidate\")),\n (\"off\", lambda: t(\"cmd.sub.dream.off\")))),\n SlashCommand(\"/perm\", lambda: t(\"cmd.perm\"), _perm,\n subargs=((\"default\", lambda: t(\"cmd.sub.perm.default\")),\n (\"acceptEdits\", lambda: t(\"cmd.sub.perm.accept_edits\")),\n (\"plan\", lambda: t(\"cmd.sub.perm.plan\")),\n (\"bypass\", lambda: t(\"cmd.sub.perm.bypass\")))),\n SlashCommand(\"/compact\", lambda: t(\"cmd.compact\"), _compact),\n SlashCommand(\"/reset\", lambda: t(\"cmd.reset\"), _reset),\n SlashCommand(\"/save\", lambda: t(\"cmd.save\"), _save),\n SlashCommand(\"/load\", lambda: t(\"cmd.load\"), _load),\n SlashCommand(\"/resume\", lambda: t(\"cmd.resume\"), _resume),\n SlashCommand(\"/config\", lambda: t(\"cmd.config\"), _config),\n SlashCommand(\"/forget\", lambda: t(\"cmd.forget\"), _forget),\n SlashCommand(\"/vim\", lambda: t(\"cmd.vim\"), _vim),\n SlashCommand(\"/lang\", lambda: t(\"cmd.lang\"), _lang,\n subargs=((\"en\", lambda: t(\"cmd.sub.lang.en\")),\n (\"zh\", lambda: t(\"cmd.sub.lang.zh\")))),\n SlashCommand(\"/quit\", lambda: t(\"cmd.quit\"), _quit,\n aliases=(\"/exit\", \"/q\")),\n ]:\n reg.register(cmd)\n\n # Let users drop ~/.lumen/commands/*.py to add their own commands.\n loaded = reg.discover_user_commands(_CONFIG_DIR / \"commands\")\n if loaded:\n render_system(t(\"msg.user_cmd_loaded\", names=\", \".join(loaded)), C_DIM)\n\n return reg\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Keybindings / bottom toolbar\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _make_key_bindings() -> KeyBindings:\n \"\"\"\n Main input-box keybindings:\n · Shift+Tab cycle general → plan → review → general\n · Enter submit the current input (overrides the default\n newline behaviour under multiline mode)\n · Enter (completion on) accept the active completion without submitting\n · Shift+Enter / Alt+Enter / Ctrl+J insert a newline\n\n About Shift+Enter:\n Terminals by default don't distinguish Shift+Enter from Enter — both\n send \\\\r. To make Shift+Enter insert a newline the terminal must emit\n one of the escape sequences below (we recognise all of them):\n · ESC [ 13 ; 2 u (CSI-u / kitty keyboard protocol; on by\n default in WezTerm/Ghostty/Kitty)\n · ESC [ 27 ; 2 ; 13 ~ (xterm modifyOtherKeys; on by default in\n xterm/urxvt)\n · ESC \\\\r (same as Alt+Enter — iTerm2 users can\n bind this via Preferences → Profiles →\n Keys, adding Shift+Return → Send Escape\n Sequence with an empty payload.)\n On macOS Terminal / iTerm2 default setups Shift+Enter is\n indistinguishable — use Alt+Enter as the fallback.\n \"\"\"\n kb = KeyBindings()\n\n @kb.add(\"s-tab\")\n def _toggle_mode(event):\n \"\"\"Cycle: general → plan → review → general.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n inner = _INNER_PT_REF.get(\"session\") or _PT_SESSION_REF.get(\"session\")\n if agent is None:\n return\n if agent.review_mode:\n # review → general\n agent.disable_review_mode()\n elif agent.plan_mode:\n # plan → review\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n else:\n # general → plan\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n # Force the bottom toolbar to redraw\n event.app.invalidate()\n\n @kb.add(\"enter\", filter=completion_is_selected)\n def _accept_completion(event):\n \"\"\"User navigated into the completion menu — Enter accepts, doesn't submit.\"\"\"\n event.current_buffer.complete_state = None\n\n @kb.add(\"enter\")\n def _submit(event):\n \"\"\"Plain Enter submits the line (overrides multiline-mode default newline).\"\"\"\n event.current_buffer.validate_and_handle()\n\n def _insert_newline(event):\n event.current_buffer.newline()\n\n # Alt+Enter (equivalent to ESC \\r) — the main newline key for iTerm2 users\n kb.add(\"escape\", \"enter\")(_insert_newline)\n\n # Ctrl+J — fallback when Alt is swallowed by the OS / window manager\n kb.add(\"c-j\")(_insert_newline)\n\n # Shift+Enter (CSI-u encoding): ESC [ 13 ; 2 u\n # prompt_toolkit parses the ESC prefix as \"escape\" then matches the\n # remaining bytes one-by-one.\n kb.add(\"escape\", \"[\", \"1\", \"3\", \";\", \"2\", \"u\")(_insert_newline)\n\n # Shift+Enter (xterm modifyOtherKeys encoding): ESC [ 27 ; 2 ; 13 ~\n kb.add(\"escape\", \"[\", \"2\", \"7\", \";\", \"2\", \";\", \"1\", \"3\", \"~\")(_insert_newline)\n\n return kb\n\n\ndef _bottom_toolbar():\n \"\"\"Persistent bottom status bar: mode · phase · keybinding hints.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n if agent is None:\n return \"\"\n if agent.review_mode:\n phase = agent.review_state.phase\n phase_text = {\n ReviewPhase.IDLE: t(\"toolbar.review_idle\"),\n ReviewPhase.DESIGN: t(\"toolbar.review_design\"),\n ReviewPhase.PIPELINE: t(\"toolbar.review_pipeline\"),\n ReviewPhase.IMPL: t(\"toolbar.review_impl\", n=len(agent.review_state.functions_approved)),\n ReviewPhase.COMPLETE: t(\"toolbar.review_complete\"),\n }.get(phase, \"\")\n mode_part = t(\"toolbar.mode_review\", phase=phase_text)\n elif agent.plan_mode:\n mode_part = t(\"toolbar.mode_plan\")\n else:\n mode_part = t(\"toolbar.mode_general\")\n perm_tag = {\n PermissionMode.DEFAULT: \"\",\n PermissionMode.ACCEPT_EDITS: t(\"toolbar.perm_accept_edits\"),\n PermissionMode.PLAN: t(\"toolbar.perm_plan\"),\n PermissionMode.BYPASS: t(\"toolbar.perm_bypass\"),\n }[agent.permissions.mode]\n return t(\"toolbar.line\", mode=mode_part, perm=perm_tag, model=agent.model)\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main chat loop\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"Run the main chat loop. Returns True if the user asked to reconfigure, False to exit.\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\n\n Pauses any active Rich Live region so prompt_toolkit can own the\n terminal cleanly, and uses a dedicated modal PromptSession (no\n toolbar / completer / Shift+Tab binding) so keystrokes never get\n intercepted by the main input box's UX helpers.\n \"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = str(tool_input[key])\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n modal_pt = _INNER_PT_REF.get(\"session\") or pt\n with _LivePause():\n console.print()\n console.print(Panel(\n t(\"permission.panel_body\", tool=tool_name, hint=arg_hint),\n title=t(\"permission.panel_title\"), title_align=\"left\",\n border_style=\"yellow\",\n ))\n try:\n answer = await modal_pt.prompt_async(t(\"permission.prompt\"))\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # project directory tree\n DefinitionsTool(), # extract class/function definitions from a file\n FileReadTool(), # read file contents by line range\n FileWriteTool(), # create / overwrite files\n FileEditTool(), # exact find-and-replace editing\n GlobTool(), # match file paths by pattern\n GrepTool(), # search inside file contents\n BashTool(), # run shell commands\n ],\n )\n except Exception as e:\n render_error(t(\"msg.agent_failed\", err=e))\n return True\n\n # Register sub_agent + task_* tools so the model can spawn and observe\n # background work, and `/tasks` has something to show.\n agent.enable_subagents()\n\n # Discovery utilities: tool_search (rank tools by query) + brief (session summary).\n agent.enable_discovery_tools()\n\n # Auto-Dream: background memory consolidation. Runs async after every\n # N turns and periodically merges entries into narrative summaries.\n # Disable with LUMEN_NO_DREAM=1 if you want the old inline behavior.\n if os.environ.get(\"LUMEN_NO_DREAM\") != \"1\":\n agent.enable_auto_dream()\n\n # Auto-connect MCP servers declared in ~/.lumen/mcp.json (if any).\n mcp_cfg_path = _CONFIG_DIR / \"mcp.json\"\n if mcp_cfg_path.exists():\n try:\n mcp_states = await agent.connect_mcp_from_config(mcp_cfg_path)\n if mcp_states:\n ok = [s for s in mcp_states if s.status == \"connected\"]\n bad = [s for s in mcp_states if s.status == \"failed\"]\n if ok:\n names = \", \".join(\n f\"{s.config.name}({len(s.tools)})\" for s in ok\n )\n render_system(t(\"startup.mcp_connected\", names=names), C_DIM)\n for s in bad:\n render_system(\n t(\"startup.mcp_failed\", name=s.config.name, err=s.error),\n C_WARN,\n )\n except Exception as e:\n render_system(t(\"startup.mcp_load_failed\", err=e), C_WARN)\n\n _AGENT_REF[\"agent\"] = agent\n\n # Desktop notifier — fires on turns that exceed LUMEN_NOTIFY_THRESHOLD (15s default).\n # Disable entirely with LUMEN_NOTIFY=0.\n notifier = Notifier()\n\n # File edit tracker — session-scoped snapshot of files on first write/edit,\n # powers `/diff`.\n edit_tracker = FileEditTracker()\n edit_tracker.install(agent.hooks)\n\n # Build the slash command registry (scans ~/.lumen/commands/*.py for user cmds).\n command_registry = _build_command_registry()\n # Let the completer see registered commands (inc. user-loaded ones).\n completer = pt.completer\n if hasattr(completer, \"bind_registry\"):\n completer.bind_registry(command_registry)\n\n # Modal prompt session (for permission / approval / wizard sub-prompts).\n # Set up in main_async(); here we just reach for it.\n inner_pt = _INNER_PT_REF.get(\"session\") or pt\n\n # Startup info\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), config.provider_name)\n info.add_row(t(\"col.model\"), agent.model)\n info.add_row(t(\"col.session_id\"), agent.session.session_id[:12] + \"...\")\n info.add_row(t(\"col.context_window\"), f\"{agent.context_window:,} tokens\")\n info.add_row(t(\"col.auto_compact\"), t(\"startup.auto_compact\"))\n if agent.transcript:\n info.add_row(t(\"col.auto_save\"), t(\"startup.auto_save\", path=agent.transcript.path))\n info.add_row(t(\"col.git_state\"), t(\"startup.git_state\"))\n info.add_row(t(\"col.memory_files\"), t(\"startup.memory_files\"))\n info.add_row(t(\"col.tools_read\"), t(\"startup.tools_read\"))\n info.add_row(t(\"col.tools_write\"), t(\"startup.tools_write\"))\n info.add_row(t(\"col.permissions\"), t(\"startup.permissions\"))\n info.add_row(t(\"col.retry\"), t(\"startup.retry\"))\n info.add_row(t(\"col.file_cache\"), t(\"startup.file_cache\"))\n info.add_row(\n t(\"col.desktop_notify\"),\n t(\"startup.notifier_on\", channel=notifier.channel, sec=int(notifier.default_threshold_s))\n if notifier.enabled else t(\"startup.notifier_off\"),\n )\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(info, border_style=C_DIM, title=t(\"startup.ready\"), title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(t(\"startup.hint\"))\n\n _last_ctrl_c = False\n\n try:\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n\n # ── Slash commands (dispatched via CommandRegistry) ──────────────\n if user_input.startswith(\"/\"):\n head, _, rest = user_input.partition(\" \")\n command = command_registry.find(head.lower())\n if command is None:\n render_system(t(\"msg.unknown_command\", cmd=user_input), C_WARN)\n continue\n ctx = ChatContext(\n agent=agent, config=config, console=console,\n pt=pt, inner_pt=inner_pt, notifier=notifier,\n registry=command_registry, edit_tracker=edit_tracker,\n )\n try:\n result = await command.handler(ctx, rest.strip())\n except (KeyboardInterrupt, EOFError):\n render_system(t(\"common.cancelled\"), C_DIM)\n continue\n except Exception as e:\n render_error(t(\"msg.cmd_failed\", cmd=command.name, err=e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n continue\n if result.new_agent is not None:\n agent = result.new_agent\n _AGENT_REF[\"agent\"] = agent\n if result.reconfigure:\n return True\n if result.quit:\n return False\n continue\n\n # ── Normal chat turn ──────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n import time as _time\n _turn_start = _time.monotonic()\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n _elapsed = _time.monotonic() - _turn_start\n notifier.notify_if_slow(\n \"Lumen\",\n t(\"msg.reply_done\", preview=user_input[:40]),\n elapsed_s=_elapsed,\n )\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(t(\"common.goodbye\"), C_DIM)\n return False\n _last_ctrl_c = True\n render_system(t(\"msg.cancel_reply\"), C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n finally:\n # Flush and close transcript on any exit path\n try:\n await agent.close()\n except Exception:\n pass\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Main entry point\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Try to apply saved language preference BEFORE we render anything.\n try:\n _pre = load_config()\n if _pre is not None:\n _lang = normalize_language(_pre.language)\n if _lang:\n set_language(_lang)\n except Exception:\n pass\n\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(t(\"banner.tagline\"), style=C_DIM)))\n console.print(Align.center(Text(t(\"banner.subtitle\"), style=\"dim cyan\")))\n console.print()\n\n # Ensure config dir exists before FileHistory tries to open/append to it.\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n except Exception:\n pass\n\n # Vim mode: opt-in via LUMEN_VIM=1 or toggled at runtime via /vim\n vi_mode = os.environ.get(\"LUMEN_VIM\", \"\").strip() in (\"1\", \"true\", \"yes\", \"on\")\n\n pt = PromptSession(\n history=FileHistory(str(_CONFIG_DIR / \"history\")),\n style=PTStyle.from_dict({\n \"prompt\": \"ansicyan bold\",\n \"bottom-toolbar\": \"bg:#1a1a1a #888888\",\n \"completion-menu.completion\": \"bg:#1a1a1a #cccccc\",\n \"completion-menu.completion.current\": \"bg:#005f87 #ffffff bold\",\n \"completion-menu.meta.completion\": \"bg:#1a1a1a #707070 italic\",\n \"completion-menu.meta.completion.current\": \"bg:#005f87 #cfcfcf italic\",\n }),\n completer=SlashCompleter(),\n complete_while_typing=True,\n key_bindings=_make_key_bindings(),\n bottom_toolbar=_bottom_toolbar,\n refresh_interval=0.5,\n multiline=True, # Enter=submit, Alt+Enter/Ctrl+J=newline (see _make_key_bindings)\n vi_mode=vi_mode,\n )\n _PT_SESSION_REF[\"session\"] = pt\n\n # Inner prompt session — bare & separate, for modal prompts (permissions,\n # approvals). No toolbar, no completer, no Shift+Tab binding. This mirrors\n # src/'s pattern where permission dialogs use a dedicated Select component\n # with its own isolated input handling, so no keybindings leak in from\n # the main input box and no background refresh timer redraws over the prompt.\n inner_pt = PromptSession(\n history=None, # modal answers shouldn't pollute history\n style=PTStyle.from_dict({\"prompt\": \"ansiyellow bold\"}),\n )\n _INNER_PT_REF[\"session\"] = inner_pt\n\n # If CLI already passed enough args, skip the wizard.\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n language=get_language(),\n )\n await chat_loop(config, pt)\n return\n\n # Otherwise: config → chat → (maybe reconfigure) loop\n config: AgentConfig | None = None\n skip_saved_once = False # True on /config reconfigure: skip the \"use saved?\" prompt\n\n while True:\n # Prefer the saved config unless the user actively ran /config\n saved = None if skip_saved_once else load_config()\n skip_saved_once = False\n\n if saved is not None:\n # Apply saved language preference immediately.\n saved_lang = normalize_language(saved.language)\n if saved_lang and saved_lang != get_language():\n set_language(saved_lang)\n masked_key = (saved.api_key[:8] + \"...\" + saved.api_key[-4:]\n if len(saved.api_key) > 12 else \"****\")\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(t(\"col.provider\"), saved.provider_name)\n info.add_row(t(\"col.model\"), saved.model)\n if saved.base_url:\n info.add_row(t(\"col.base_url\"), saved.base_url)\n info.add_row(t(\"col.api_key\"), masked_key)\n info.add_row(t(\"col.language\"), language_display_name())\n console.print(Panel(\n info, border_style=\"green\",\n title=t(\"wizard.saved_detected\"), title_align=\"left\",\n ))\n try:\n raw = await pt.prompt_async(t(\"wizard.use_saved_prompt\"))\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n choice = raw.strip().lower()\n if choice in (\"\", \"y\", \"yes\"):\n config = saved\n elif choice == \"f\":\n forget_config()\n render_system(t(\"wizard.forgot\"), C_SUCCESS)\n config = None\n else:\n config = None # fall through to wizard\n\n if config is None:\n try:\n config = await setup_wizard(pt)\n save_config(config)\n render_system(\n t(\"wizard.saved_to\", path=_CONFIG_FILE),\n C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(t(\"wizard.cancel_exit\"), C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(t(\"wizard.reconfigure\"), style=C_BRAND))\n config = None\n skip_saved_once = True\n\n\ndef main() -> None:\n import argparse\n parser = argparse.ArgumentParser(description=t(\"cli.description\"))\n parser.add_argument(\"--model\", default=None, help=t(\"cli.arg.model\"))\n parser.add_argument(\"--api-key\", default=None, help=t(\"cli.arg.api_key\"))\n parser.add_argument(\"--base-url\", default=None, help=t(\"cli.arg.base_url\"))\n parser.add_argument(\n \"--lang\", default=None, choices=list(SUPPORTED_LANGUAGES),\n help=t(\"cli.arg.lang\"),\n )\n args = parser.parse_args()\n if args.lang:\n set_language(args.lang)\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "c73716d723d008a3a2748744cc8c96efd3ce06fc": {"t": 1785773577.7294605, "n": 80801, "h": "5cda6645f0eb271f", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\n直接运行,在 UI 内选择模型和输入 API Key:\n python chat.py\n\n也支持命令行直接指定(跳过配置向导):\n python chat.py --model gpt-4o --api-key sk-...\n\n聊天中的 Slash 命令:\n /help 帮助\n /status Token 用量和会话信息\n /mode 切换 general / plan / review(或 Shift+Tab 循环)\n /plan Plan Mode 控制 (on | approve | cancel)\n /compact 手动压缩上下文\n /reset 清空对话历史\n /save 保存会话到文件\n /load 从文件加载会话\n /config 重新配置模型和 Key\n /forget 删除本地保存的 API Key\n /quit 退出\n\n交互增强:\n • 输入 / 自动弹出命令补全\n • 输入 @ 自动补全项目文件路径(30s 缓存)\n • /load <tab> 补全项目内文件,选中会话文件加载\n • 多行输入: Enter 发送, Shift+Enter / Alt+Enter / Ctrl+J 换行\n (Shift+Enter 要求终端发出 CSI-u 或 modifyOtherKeys 转义序列;\n iTerm2/Terminal.app 默认不区分, 用 Alt+Enter 兜底即可)\n • 输入历史持久化到 ~/.lumen/history,跨会话保留\n • Shift+Tab 在模式间快速切换\n • 底栏常驻显示当前 mode / phase\n • 首次配置后 Key 会保存在 ~/.lumen/config.json (权限 0600),\n 下次启动直接复用 — 用 /forget 清除。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.completion import Completer, Completion\nfrom prompt_toolkit.filters import completion_is_selected\nfrom prompt_toolkit.history import FileHistory\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import (\n Agent, PermissionChecker, PermissionBehavior, PermissionMode,\n HookRegistry, RetryConfig, Notifier, FileEditTracker,\n)\nfrom lumen.context.transcript import TranscriptReader, list_recent_sessions\nfrom chat_commands import (\n ChatContext, CommandRegistry, CommandResult, SlashCommand,\n)\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.services.review_state import ReviewPhase\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\nfrom lumen.tools.approval import ConsoleApprovalHandler\n\nconsole = Console()\n\n# ── 配色 ──────────────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ── 共享状态(供 keybinding / bottom_toolbar 闭包访问) ──────────────────────────\n_AGENT_REF: dict = {\"agent\": None}\n_PT_SESSION_REF: dict = {\"session\": None} # 主输入会话(/mode, Shift+Tab)\n_INNER_PT_REF: dict = {\"session\": None} # 模态输入会话(permission / approval)\n_LIVE_REF: dict = {\"live\": None} # 当前 agent_response 的 Live,用于暂停/恢复\n\n\nclass _LivePause:\n \"\"\"Context manager that suspends any active Rich Live region while a\n modal prompt runs, then resumes it. Prevents prompt_toolkit from fighting\n Rich for terminal control, which is what makes modal prompts appear to\n 'swallow Enter' — they were never visible in the first place.\"\"\"\n\n def __enter__(self):\n self._live = _LIVE_REF.get(\"live\")\n if self._live is not None:\n try:\n self._live.stop()\n except Exception:\n self._live = None\n return self\n\n def __exit__(self, exc_type, exc, tb):\n if self._live is not None:\n try:\n self._live.start()\n except Exception:\n pass\n return False\n\n\nclass _LivePausingApprovalHandler:\n \"\"\"Wraps any ApprovalHandler so each `.request()` call is bracketed by\n a `_LivePause`. Without this, the approval panel draws inside an active\n Rich Live region and prompt_toolkit can't take the terminal — the user\n sees nothing and their Enter appears to do nothing.\"\"\"\n\n def __init__(self, inner) -> None:\n self._inner = inner\n\n async def request(self, phase, title, content):\n with _LivePause():\n return await self._inner.request(phase, title, content)\n\n\n# ── Slash / @path 补全 ────────────────────────────────────────────────────────\nclass SlashCompleter(Completer):\n \"\"\"\n 统一补全器,支持三类触发:\n · `/` 顶层命令菜单(/help /mode /load …)\n · `/mode r…` 子参数(general / review)\n · `/load …` 项目内文件路径(用于加载会话文件)\n · `@path/to/…` 项目文件引用(插入文本,agent 能看到路径)\n\n 文件树扫描结果缓存 30s,自动跳过常见噪声目录(.git / node_modules /\n __pycache__ / .venv …),防止 complete_while_typing 每次按键都遍历磁盘。\n \"\"\"\n\n # Fallback lists used only before the CommandRegistry is attached\n # (e.g. during setup_wizard). After chat_loop builds the registry, it\n # calls `.bind_registry(reg)` and we read live from there.\n TOP_LEVEL = [\n (\"/help\", \"显示帮助\"),\n (\"/config\", \"重新配置模型和 Key\"),\n (\"/quit\", \"退出\"),\n ]\n MODE_ARGS = [\n (\"general\", \"通用模式\"),\n (\"review\", \"审阅模式(阶段门控)\"),\n ]\n\n SKIP_DIRS = {\n \".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\",\n \"dist\", \"build\", \".pytest_cache\", \".mypy_cache\", \".ruff_cache\",\n \".tox\", \".next\", \".turbo\", \".cache\", \"target\", \".idea\", \".vscode\",\n }\n _FILE_CACHE_TTL = 30.0\n _FILE_CACHE_MAX = 2000\n\n def __init__(self) -> None:\n self._file_cache: list[str] | None = None\n self._file_cache_ts: float = 0.0\n self._registry = None # type: ignore # Optional[CommandRegistry]\n\n def bind_registry(self, registry) -> None:\n \"\"\"Attach a CommandRegistry so completion reflects user-loaded commands.\"\"\"\n self._registry = registry\n\n def _top_level_items(self) -> list[tuple[str, str]]:\n if self._registry is not None:\n return [(c.name, c.description) for c in self._registry.all()]\n return list(self.TOP_LEVEL)\n\n def _subargs_for(self, head: str) -> list[tuple[str, str]]:\n if self._registry is not None:\n cmd = self._registry.find(head)\n if cmd is not None and cmd.subargs:\n return list(cmd.subargs)\n if head == \"/mode\":\n return list(self.MODE_ARGS)\n return []\n\n def _scan_files(self) -> list[str]:\n import time\n now = time.time()\n if (\n self._file_cache is not None\n and (now - self._file_cache_ts) < self._FILE_CACHE_TTL\n ):\n return self._file_cache\n\n cwd = Path.cwd()\n results: list[str] = []\n for root, dirs, files in os.walk(cwd):\n dirs[:] = [\n d for d in dirs\n if d not in self.SKIP_DIRS and not d.startswith(\".\")\n ]\n root_path = Path(root)\n for f in files:\n if f.startswith(\".\") and f not in (\".gitignore\", \".env.example\"):\n continue\n full = root_path / f\n try:\n rel = full.relative_to(cwd)\n except ValueError:\n continue\n results.append(str(rel))\n if len(results) >= self._FILE_CACHE_MAX:\n break\n if len(results) >= self._FILE_CACHE_MAX:\n break\n results.sort()\n self._file_cache = results\n self._file_cache_ts = now\n return results\n\n def _rank_files(self, prefix: str, limit: int = 50) -> list[str]:\n \"\"\"Return files matching prefix, ranked: basename-start > path-start > substring.\"\"\"\n if not prefix:\n return self._scan_files()[:limit]\n lower = prefix.lower()\n ranked: list[tuple[int, str]] = []\n for path in self._scan_files():\n p_lower = path.lower()\n base_lower = Path(path).name.lower()\n if base_lower.startswith(lower):\n rank = 0\n elif p_lower.startswith(lower):\n rank = 1\n elif lower in p_lower:\n rank = 2\n else:\n continue\n ranked.append((rank, path))\n ranked.sort(key=lambda x: (x[0], x[1]))\n return [p for _, p in ranked[:limit]]\n\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n\n # ── @path 文件引用(允许嵌在句子任意位置) ─────────────────────────────\n at_idx = text.rfind(\"@\")\n if at_idx != -1 and (at_idx == 0 or text[at_idx - 1] in \" \\t\\n\"):\n prefix = text[at_idx + 1:]\n if prefix == \"\" or (\" \" not in prefix and \"\\t\" not in prefix and \"\\n\" not in prefix):\n for path in self._rank_files(prefix):\n yield Completion(\n path, start_position=-len(prefix),\n display=path, display_meta=\"file\",\n )\n return\n\n # ── /load <path> 文件路径补全 ────────────────────────────────────────\n if text.startswith(\"/load \"):\n arg = text[len(\"/load \"):].lstrip()\n for path in self._rank_files(arg):\n yield Completion(\n path, start_position=-len(arg),\n display=path, display_meta=\"session\",\n )\n return\n\n # ── 子参数 (e.g. /mode review, any registered subargs) ──────────────\n if text.startswith(\"/\") and \" \" in text:\n head, _, rest = text.partition(\" \")\n prefix = rest.lstrip()\n subargs = self._subargs_for(head)\n for arg, desc in subargs:\n if arg.startswith(prefix):\n yield Completion(\n arg, start_position=-len(prefix),\n display=arg, display_meta=desc,\n )\n return\n\n # ── 顶层 slash 命令 ──────────────────────────────────────────────────\n if text.startswith(\"/\"):\n for cmd, desc in self._top_level_items():\n if cmd.startswith(text):\n yield Completion(\n cmd, start_position=-len(text),\n display=cmd, display_meta=desc,\n )\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model 目录\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str\n key_hint: str # API key 格式提示\n env_key: str | None # 从哪个环境变量读取 key\n base_url: str | None # None = 由 Agent 自动判断\n models: list[ModelOption]\n needs_key: bool = True\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · 最新最强\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"GetGoAPI (多模型代理)\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 最新最强综合\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 推理模型\"),\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 最强综合\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 快速省钱\"),\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · 最强综合\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · 最强能力\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · 快速省钱\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"Ollama (本地模型)\", key_hint=\"无需 Key\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"自定义 / 其他\", key_hint=\"你的 API Key\",\n env_key=None,\n base_url=None,\n models=[], # 用户自己输入\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 配置向导\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n\n\n# ── 配置持久化 ────────────────────────────────────────────────────────────────\n# 保存在 ~/.lumen/config.json (文件权限 0600)\n# 避免每次启动都要重新粘贴 API Key。\n\n_CONFIG_DIR = Path.home() / \".lumen\"\n_CONFIG_FILE = _CONFIG_DIR / \"config.json\"\n\n\ndef save_config(config: AgentConfig) -> None:\n \"\"\"Write config to ~/.lumen/config.json with 0600 permissions.\"\"\"\n import json\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n data = {\n \"api_key\": config.api_key,\n \"model\": config.model,\n \"base_url\": config.base_url,\n \"provider_name\": config.provider_name,\n }\n _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding=\"utf-8\")\n try:\n os.chmod(_CONFIG_FILE, 0o600)\n except Exception:\n pass\n except Exception as e:\n render_system(f\"(无法保存配置: {e})\", C_DIM)\n\n\ndef load_config() -> AgentConfig | None:\n \"\"\"Load cached config if present and well-formed.\"\"\"\n import json\n if not _CONFIG_FILE.exists():\n return None\n try:\n data = json.loads(_CONFIG_FILE.read_text(encoding=\"utf-8\"))\n if not data.get(\"model\") or not data.get(\"api_key\"):\n return None\n return AgentConfig(\n api_key=data[\"api_key\"],\n model=data[\"model\"],\n base_url=data.get(\"base_url\"),\n provider_name=data.get(\"provider_name\") or \"Saved\",\n )\n except Exception:\n return None\n\n\ndef forget_config() -> bool:\n \"\"\"Delete the saved config file.\"\"\"\n try:\n if _CONFIG_FILE.exists():\n _CONFIG_FILE.unlink()\n return True\n except Exception:\n pass\n return False\n\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"交互式配置向导:选 Provider → 选模型 → 输入 Key。\"\"\"\n console.print()\n console.print(Rule(\" 配置模型\", style=C_BRAND))\n console.print()\n\n # ── 第一步:选 Provider ───────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key 已检测[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.name + detected, key_info)\n\n console.print(Panel(table, title=\"选择 Provider\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(f\" [yellow]请输入 1–{len(PROVIDERS)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(f\" [green]✓ 已选:[/]{provider.name}\\n\")\n\n # ── 第二步:选模型 ────────────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(\" Base URL (如 http://localhost:11434/v1) › \")).strip()\n model = (await pt.prompt_async(\" Model 名称 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(\"model 不能为空\")\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.desc)\n\n console.print(Panel(model_table, title=\"选择模型\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(f\" [yellow]请输入 1–{len(provider.models)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(f\" [green]✓ 已选:[/]{model}\\n\")\n\n # ── 第三步:输入 API Key ──────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(f\" [dim]本地模型无需 API Key,使用默认占位符[/]\\n\")\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n f\" 检测到环境变量 {provider.env_key} ({masked})\\n\"\n f\" 直接使用? [Y/n] › \"\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(\" [green]✓ 使用环境变量中的 Key[/]\\n\")\n else:\n api_key = await _prompt_key(pt, provider.key_hint)\n else:\n console.print(f\" [dim]格式参考:{provider.key_hint}[/]\")\n api_key = await _prompt_key(pt, provider.key_hint)\n\n # ── 汇总展示 ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(\"Provider\", provider.name)\n summary.add_row(\"Model\", model)\n if base_url:\n summary.add_row(\"Base URL\", base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(\"API Key\", key_display)\n console.print(Panel(summary, title=\"✓ 配置确认\", border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.name,\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"带密码掩码的 API Key 输入。\"\"\"\n while True:\n try:\n key = await pt.prompt_async(\" API Key › \")\n key = key.strip()\n if key:\n return key\n console.print(\" [yellow]Key 不能为空,请重新输入[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI 渲染组件\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(\" model: \", style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(\" │ msgs: \", style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(\" │ compactions: \", style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.review_mode:\n stats.append(\" │ \", style=C_DIM)\n phase = agent.review_state.phase\n phase_label = {\n ReviewPhase.IDLE: \"审阅·待命\",\n ReviewPhase.DESIGN: \"审阅·设计\",\n ReviewPhase.PIPELINE: \"审阅·数据流\",\n ReviewPhase.IMPL: f\"审阅·实现({len(agent.review_state.functions_approved)})\",\n ReviewPhase.COMPLETE: \"审阅·完成\",\n }.get(phase, \"审阅\")\n stats.append(f\"⚑ {phase_label}\", style=\"bold yellow\")\n elif agent.plan_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(\"📋 方案模式\", style=\"bold blue\")\n\n perm_mode = agent.permissions.mode\n if perm_mode != PermissionMode.DEFAULT:\n stats.append(\" │ \", style=C_DIM)\n perm_label, perm_style = {\n PermissionMode.ACCEPT_EDITS: (\"✎ accept-edits\", \"bold green\"),\n PermissionMode.PLAN: (\"📋 plan-only\", \"bold blue\"),\n PermissionMode.BYPASS: (\"⚠ bypass\", \"bold red\"),\n }[perm_mode]\n stats.append(perm_label, style=perm_style)\n\n border = (\n \"red\" if perm_mode == PermissionMode.BYPASS\n else \"blue\" if perm_mode == PermissionMode.PLAN or agent.plan_mode\n else \"yellow\" if agent.review_mode\n else C_DIM\n )\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\ndef render_user_message(text: str) -> Panel:\n return Panel(\n Text(text, style=\"white\"),\n title=Text(\" You\", style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text: str, style: str = C_DIM) -> None:\n console.print(f\" {text}\", style=style)\n\n\ndef render_error(text: str) -> None:\n console.print(Panel(text, border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help(registry: \"CommandRegistry | None\" = None) -> None:\n \"\"\"Render /help. If a CommandRegistry is supplied, the slash-command rows\n are pulled from it so user-loaded commands show up automatically.\"\"\"\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n\n if registry is not None:\n for cmd in registry.all():\n hint = cmd.description\n if cmd.aliases:\n hint += f\" (aliases: {' '.join(cmd.aliases)})\"\n table.add_row(cmd.name, hint)\n else:\n table.add_row(\"/help\", \"显示帮助\")\n table.add_row(\"/quit\", \"退出\")\n\n # Non-slash keybinding reference\n for row in [\n (\"\", \"\"),\n (\"↑ / ↓\", \"浏览输入历史(持久化在 ~/.lumen/history)\"),\n (\"Shift+Enter\", \"换行(需终端支持;iTerm2 用户可改用 Alt+Enter)\"),\n (\"Alt+Enter\", \"换行(跨终端兜底,Ctrl+J 同效)\"),\n (\"@path\", \"自动补全项目内文件路径\"),\n (\"Shift+Tab\", \"循环切换 general → plan → review\"),\n (\"Ctrl+C\", \"取消当前输入 / 再按一次退出\"),\n ]:\n table.add_row(*row)\n console.print(Panel(table, title=\"命令列表\", border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(\"Model\", agent.model)\n table.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n table.add_row(\"Tokens used\", f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(\"Messages\", str(len(agent.messages)))\n table.add_row(\"Compactions\", str(agent.session.compaction_count))\n table.add_row(\"Session start\", agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=\"Session Status\", border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent 响应\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n 执行一次完整的 Agent 响应:工具调用过程实时展示,最终答案 Markdown 渲染。\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(\" Lumen ● thinking…\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── 有工具时用 Live 面板展示进度 ──────────────────────────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n _LIVE_REF[\"live\"] = live\n tool_log.append(\" [dim]正在思考…[/]\")\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n try:\n await _chat_with_refresh()\n finally:\n _LIVE_REF[\"live\"] = None\n else:\n # 无工具时走流式输出\n collected: list[str] = []\n panel_title = Text(\" Lumen ● streaming…\", style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n _LIVE_REF[\"live\"] = live\n try:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\"\".join(collected), code_theme=\"monokai\", inline_code_theme=\"monokai\")\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n finally:\n _LIVE_REF[\"live\"] = None\n return \"\".join(collected)\n\n # ── 渲染最终答案 ───────────────────────────────────────────────────────────\n final_text = response.content if response else \"\"\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(\" Lumen\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 模式切换\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str, pt: PromptSession | None = None) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n if agent.review_mode:\n mode = \"审阅模式 [Review Mode]\"\n elif agent.plan_mode:\n mode = \"方案模式 [Plan Mode]\"\n else:\n mode = \"通用模式 [General]\"\n console.print(Panel(\n f\" 当前模式: [bold cyan]{mode}[/]\\n\\n\"\n \" [dim]/mode review[/] → 开启审阅模式(3 阶段门控)\\n\"\n \" [dim]/mode plan[/] → 开启方案模式(只读调研 + 结构化方案)\\n\"\n \" [dim]/mode general[/] → 切回通用模式\\n\"\n \" [dim]Shift+Tab[/] → 在 general / plan / review 之间循环\",\n border_style=C_DIM, title=\"模式\", title_align=\"left\",\n ))\n return\n\n if arg in (\"review\", \"r\", \"审阅\", \"careful\"):\n if agent.review_mode:\n render_system(\"已经是审阅模式。\", C_DIM)\n else:\n inner = _INNER_PT_REF.get(\"session\") or pt\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n console.print(Panel(\n \"[bold yellow]审阅模式已开启 — 真正的阶段门控[/]\\n\\n\"\n \" 模型写代码时分 3 步进行,每步 AI 会[bold]调用 request_approval 工具[/]暂停等你确认:\\n\"\n \" · Phase 1 [cyan]设计方案[/] — 展示函数/类签名和职责\\n\"\n \" · Phase 2 [blue]数据流[/] — 文本箭头图展示 pipeline\\n\"\n \" · Phase 3 [yellow]逐步实现[/] — 一次一个函数,解释数据变化\\n\\n\"\n \" 审批面板弹出时你可以:\\n\"\n \" · [bold]Y / 空回车[/] → 通过,进入下一步\\n\"\n \" · [bold]skip / 全部写完[/] → 通过并跳过后续审批\\n\"\n \" · [bold]N[/] 然后输入反馈 → 打回重写\\n\"\n \" · 直接输入任何文字 → 当作反馈并打回\\n\\n\"\n \" [dim]Token 栏会实时显示当前 Phase。非编码任务不受影响。[/]\",\n border_style=\"yellow\", title=\"⚑ Review Mode\", title_align=\"left\",\n ))\n\n elif arg in (\"plan\", \"p\", \"方案\", \"计划\"):\n _activate_plan_mode(agent)\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n was_plan = agent.plan_mode\n was_review = agent.review_mode\n if not was_plan and not was_review:\n render_system(\"已经是通用模式。\", C_DIM)\n else:\n if was_review:\n agent.disable_review_mode()\n if was_plan:\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(\"✓ 已切回通用模式。\", C_SUCCESS)\n\n else:\n render_system(\n f\"未知模式 '{arg}'。用 /mode review | /mode plan | /mode general。\", C_WARN\n )\n\n\ndef _activate_plan_mode(agent: Agent) -> None:\n if agent.plan_mode:\n render_system(\"已经是方案模式。\", C_DIM)\n return\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n console.print(Panel(\n \"[bold blue]方案模式已开启 — 只读调研 + 结构化方案[/]\\n\\n\"\n \" 这一轮 AI 只会: [cyan]read_file · glob · tree · grep[/],不会写任何文件。\\n\"\n \" 回复末尾会给出结构化方案(Goal / Files / Steps / Risks)。\\n\\n\"\n \" · [bold]/plan approve[/] → 确认方案,自动切到 acceptEdits 开始动手\\n\"\n \" · [bold]/plan cancel[/] → 取消,回到通用模式\\n\"\n \" · [bold]Shift+Tab[/] → 循环切换模式\",\n border_style=\"blue\", title=\"📋 Plan Mode\", title_align=\"left\",\n ))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 命令处理\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(\"Compacting context…\", C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n f\"[green]✓ 压缩完成[/]\\n\\n\"\n f\" 消息数 : [white]{r.messages_before}[/] → [white]{r.messages_after}[/]\\n\"\n f\" Tokens : [white]{r.tokens_before:,}[/] → [white]{r.tokens_after:,}[/]\\n\"\n f\" 保留最近 [white]{r.kept_recent_count}[/] 条消息原文\",\n border_style=\"green\", title=\"压缩结果\", title_align=\"left\",\n ))\n except Exception as e:\n render_error(f\"压缩失败: {e}\")\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(f\" 保存到 [{default}] › \", default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(f\"✓ 已保存 → {path}\", C_SUCCESS)\n if agent.transcript:\n render_system(\n f\" (会话也在自动保存到 {agent.transcript.path})\", C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n render_system(\"已取消\", C_DIM)\n except Exception as e:\n render_error(f\"保存失败: {e}\")\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(\" 加载文件路径 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(f\"文件不存在: {arg!r}\")\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(f\"✓ 已加载 {len(agent.messages)} 条消息 from {arg}\", C_SUCCESS)\n return agent\n except Exception as e:\n render_error(f\"加载失败: {e}\")\n return None\n\n\nasync def handle_resume(config: AgentConfig, pt: PromptSession) -> Agent | None:\n \"\"\"List recent auto-saved sessions and let the user pick one to resume.\"\"\"\n cwd = Path.cwd()\n sessions = list_recent_sessions(cwd, limit=10)\n if not sessions:\n render_system(\"没有找到自动保存的会话。\", C_WARN)\n return None\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"#\", style=C_NUM, width=4)\n table.add_column(\"Session ID\", style=\"white\")\n table.add_column(\"Model\", style=C_DIM)\n table.add_column(\"Messages\", style=\"white\", justify=\"right\")\n table.add_column(\"Created\", style=C_DIM)\n table.add_column(\"Last prompt\", style=\"white\")\n\n for i, s in enumerate(sessions, 1):\n created = s.created_at[:19].replace(\"T\", \" \") if s.created_at else \"?\"\n prompt_preview = s.last_prompt[:50] + (\"...\" if len(s.last_prompt) > 50 else \"\")\n table.add_row(\n f\"[{i}]\",\n s.session_id[:12] + \"...\",\n s.model or \"?\",\n str(s.message_count),\n created,\n prompt_preview,\n )\n\n console.print(Panel(table, title=\"最近的自动保存会话\", border_style=C_DIM))\n\n try:\n raw = await pt.prompt_async(\" 输入编号恢复 (或 Enter 取消) › \")\n raw = raw.strip()\n if not raw:\n return None\n idx = int(raw) - 1\n if not (0 <= idx < len(sessions)):\n render_system(f\"请输入 1-{len(sessions)} 之间的数字\", C_WARN)\n return None\n except (ValueError, KeyboardInterrupt, EOFError):\n return None\n\n chosen = sessions[idx]\n try:\n messages, metadata = TranscriptReader.load_session(chosen.file_path)\n if not messages:\n render_error(\"会话文件为空。\")\n return None\n\n # Rebuild a Session from the transcript messages\n from lumen.context.session import Session\n session = Session(model=config.model)\n session.session_id = chosen.session_id\n session.messages = messages\n\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n session=session,\n auto_compact=True,\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n FileWriteTool(), FileEditTool(),\n GlobTool(), GrepTool(), BashTool(),\n ],\n )\n render_system(\n f\"✓ 已恢复会话 {chosen.session_id[:12]}... \"\n f\"({len(messages)} 条消息)\",\n C_SUCCESS,\n )\n return agent\n except Exception as e:\n render_error(f\"恢复失败: {e}\")\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Slash 命令注册表\n# ─────────────────────────────────────────────────────────────────────────────\n# 所有 slash 命令 (/help, /status, /mode, /load, …) 都通过一个 CommandRegistry\n# 注册。SlashCompleter 和 /help 都从这里拉数据 —— 单一来源。\n# 启动时自动扫描 ~/.lumen/commands/*.py, 用户可自定义命令而不改动 chat.py。\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _build_command_registry() -> CommandRegistry:\n \"\"\"Build the built-in command registry. Handlers close over ChatContext\n (passed in at dispatch time), so they can see live agent/config/pt state.\"\"\"\n reg = CommandRegistry()\n\n async def _quit(ctx, arg):\n render_system(\"Goodbye!\", C_DIM)\n return CommandResult(quit=True)\n\n async def _help(ctx, arg):\n render_help(ctx.registry)\n return CommandResult()\n\n async def _status(ctx, arg):\n render_status(ctx.agent)\n return CommandResult()\n\n async def _mode(ctx, arg):\n handle_mode(ctx.agent, arg, pt=ctx.pt)\n return CommandResult()\n\n async def _compact(ctx, arg):\n await handle_compact(ctx.agent)\n return CommandResult()\n\n async def _reset(ctx, arg):\n ctx.agent.reset()\n render_system(\"✓ 对话历史已清空。\", C_SUCCESS)\n return CommandResult()\n\n async def _save(ctx, arg):\n await handle_save(ctx.agent, ctx.pt)\n return CommandResult()\n\n async def _load(ctx, arg):\n new = await handle_load(arg, ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _resume(ctx, arg):\n new = await handle_resume(ctx.config, ctx.pt)\n return CommandResult(new_agent=new) if new else CommandResult()\n\n async def _config(ctx, arg):\n render_system(\"返回配置向导…\", C_DIM)\n return CommandResult(reconfigure=True)\n\n async def _forget(ctx, arg):\n if forget_config():\n render_system(\n f\"✓ 已删除本地保存的配置 ({_CONFIG_FILE})\", C_SUCCESS,\n )\n else:\n render_system(\"没有保存的配置需要删除。\", C_DIM)\n return CommandResult()\n\n async def _perm(ctx, arg):\n \"\"\"Switch permission posture. /perm alone shows current + options.\"\"\"\n checker = ctx.agent.permissions\n current = checker.mode\n\n labels = {\n PermissionMode.DEFAULT: (\"default\", \"常规:写操作逐次确认,危险命令拒\"),\n PermissionMode.ACCEPT_EDITS: (\"acceptEdits\", \"接受编辑:write/edit 自动放行,bash 仍检查\"),\n PermissionMode.PLAN: (\"plan\", \"计划模式:只读调研,write/edit/bash 全拒\"),\n PermissionMode.BYPASS: (\"bypass\", \"绕过:所有工具自动放行 ⚠ 危险\"),\n }\n\n arg = arg.strip().lower()\n if not arg:\n lines = [\" 当前: [bold cyan]\" + labels[current][0] + \"[/]\\n\"]\n for m, (name, desc) in labels.items():\n marker = \"● \" if m == current else \" \"\n style = \"bold cyan\" if m == current else \"white\"\n lines.append(f\" {marker}[{style}]/perm {name}[/] {desc}\")\n ctx.console.print(Panel(\n \"\\n\".join(lines),\n title=\"权限模式\", title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n\n # Parse mode name (accept several aliases)\n alias_map = {\n \"default\": PermissionMode.DEFAULT, \"d\": PermissionMode.DEFAULT, \"常规\": PermissionMode.DEFAULT,\n \"acceptedits\": PermissionMode.ACCEPT_EDITS, \"accept\": PermissionMode.ACCEPT_EDITS,\n \"edits\": PermissionMode.ACCEPT_EDITS, \"a\": PermissionMode.ACCEPT_EDITS, \"接受\": PermissionMode.ACCEPT_EDITS,\n \"plan\": PermissionMode.PLAN, \"p\": PermissionMode.PLAN, \"计划\": PermissionMode.PLAN,\n \"bypass\": PermissionMode.BYPASS, \"b\": PermissionMode.BYPASS, \"绕过\": PermissionMode.BYPASS,\n }\n target = alias_map.get(arg.replace(\"-\", \"\").replace(\"_\", \"\"))\n if target is None:\n render_system(\n f\"未知权限模式 '{arg}'。/perm 查看可选。\", C_WARN,\n )\n return CommandResult()\n\n if target == current:\n render_system(f\"已经是 {labels[target][0]} 模式。\", C_DIM)\n return CommandResult()\n\n if target == PermissionMode.BYPASS:\n # Extra friction for the nuclear option.\n try:\n confirm = await ctx.inner_pt.prompt_async(\n \" ⚠ bypass 模式会跳过所有权限检查 — 确认开启? [y/N] › \"\n )\n except (KeyboardInterrupt, EOFError):\n render_system(\"已取消\", C_DIM)\n return CommandResult()\n if confirm.strip().lower() not in (\"y\", \"yes\"):\n render_system(\"已取消\", C_DIM)\n return CommandResult()\n\n checker.set_mode(target)\n render_system(\n f\"✓ 权限模式切到 [bold]{labels[target][0]}[/] — {labels[target][1]}\",\n C_SUCCESS,\n )\n return CommandResult()\n\n async def _diff(ctx, arg):\n \"\"\"Show unified diff of files the agent wrote/edited this session.\"\"\"\n from rich.syntax import Syntax\n tracker = ctx.edit_tracker\n if tracker is None:\n render_system(\"文件追踪器未就绪。\", C_WARN)\n return CommandResult()\n\n entries = tracker.entries(dirty_only=True)\n if not entries:\n render_system(\"本次会话还没有文件被写入或编辑。\", C_DIM)\n return CommandResult()\n\n target = arg.strip()\n if target:\n # Filter: user can pass a path (absolute or cwd-relative) or basename\n keep = []\n for e in entries:\n if (\n e.path == target\n or Path(e.path).name == target\n or target in e.path\n ):\n keep.append(e)\n if not keep:\n render_system(\n f\"没有匹配 '{target}' 的被修改文件。\", C_WARN,\n )\n return CommandResult()\n entries = keep\n\n # Summary table first\n summary = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n summary.add_column(\"文件\", style=\"white\")\n summary.add_column(\"+\", style=\"bold green\", justify=\"right\")\n summary.add_column(\"-\", style=\"bold red\", justify=\"right\")\n summary.add_column(\"写入次数\", style=C_DIM, justify=\"right\")\n summary.add_column(\"状态\", style=C_DIM)\n total_added = total_removed = 0\n for e in entries:\n added, removed = e.line_stats()\n total_added += added\n total_removed += removed\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n summary.add_row(\n rel, f\"+{added}\", f\"-{removed}\",\n str(e.write_count),\n \"新增\" if not e.existed_before else \"修改\",\n )\n ctx.console.print(Panel(\n summary,\n title=f\"文件变更 · {len(entries)} 个文件 · +{total_added}/-{total_removed}\",\n title_align=\"left\", border_style=C_DIM,\n ))\n\n # Per-file unified diff\n for e in entries:\n diff_text = e.unified_diff()\n if not diff_text:\n continue\n try:\n rel = str(Path(e.path).relative_to(Path.cwd()))\n except ValueError:\n rel = e.path\n ctx.console.print(Panel(\n Syntax(diff_text, \"diff\", theme=\"monokai\", line_numbers=False,\n word_wrap=False, background_color=\"default\"),\n title=rel, title_align=\"left\", border_style=\"blue\",\n padding=(0, 1),\n ))\n\n return CommandResult()\n\n async def _plan(ctx, arg):\n \"\"\"Plan Mode control: on/off/approve/cancel. Bare /plan shows status.\"\"\"\n agent = ctx.agent\n sub = arg.strip().lower()\n\n if not sub:\n if agent.plan_mode:\n ctx.console.print(Panel(\n \"[bold blue]📋 Plan Mode 当前开启[/]\\n\\n\"\n \" · [bold]/plan approve[/] → 确认方案,切到 acceptEdits 执行\\n\"\n \" · [bold]/plan cancel[/] → 取消,回到通用模式\",\n border_style=\"blue\", title=\"Plan Mode\", title_align=\"left\",\n ))\n else:\n ctx.console.print(Panel(\n \"Plan Mode 未开启。\\n\\n\"\n \" · [bold]/plan on[/] → 开启(只读调研 + 结构化方案)\\n\"\n \" · 或 [bold]/mode plan[/]\\n\"\n \" · 或 Shift+Tab 在模式间循环\",\n border_style=C_DIM, title=\"Plan Mode\", title_align=\"left\",\n ))\n return CommandResult()\n\n if sub in (\"on\", \"start\", \"开启\"):\n _activate_plan_mode(agent)\n return CommandResult()\n\n if sub in (\"off\", \"cancel\", \"取消\"):\n if not agent.plan_mode:\n render_system(\"方案模式未开启。\", C_DIM)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n render_system(\"✓ 方案模式已取消,回到通用模式。\", C_SUCCESS)\n return CommandResult()\n\n if sub in (\"approve\", \"go\", \"执行\", \"ok\"):\n if not agent.plan_mode:\n render_system(\"还没开启方案模式 — 先 /plan on 或 /mode plan。\", C_WARN)\n return CommandResult()\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.ACCEPT_EDITS)\n render_system(\n \"✓ 方案已批准 — 切到 [bold green]acceptEdits[/] 模式开始执行。\",\n C_SUCCESS,\n )\n return CommandResult()\n\n render_system(\n f\"未知子命令 '{sub}'。可选: on / off / approve / cancel。\", C_WARN,\n )\n return CommandResult()\n\n async def _tasks(ctx, arg):\n \"\"\"Show / inspect / cancel background sub-agent tasks.\n\n Subcommands:\n /tasks → table of all tasks\n /tasks <id> → show output of that task\n /tasks stop <id> → cancel\n /tasks clear → kill every running task + clear finished\n \"\"\"\n import time as _time\n mgr = ctx.agent.subagent_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n tasks = mgr.list_tasks()\n\n if sub_lower in (\"clear\", \"reset\", \"清空\"):\n await mgr.cleanup()\n render_system(\"✓ 所有后台任务已清除。\", C_SUCCESS)\n return CommandResult()\n\n if sub_lower.startswith(\"stop \") or sub_lower.startswith(\"kill \"):\n target = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.kill(target)\n if ok:\n render_system(f\"✓ 任务 {target} 已停止。\", C_SUCCESS)\n else:\n render_system(\n f\"任务 {target!r} 不存在或已结束。\", C_WARN,\n )\n return CommandResult()\n\n # /tasks <id> → single task detail\n if sub and not sub_lower.startswith((\"stop\", \"kill\", \"clear\")):\n match = next((t for t in tasks if t.agent_id == sub), None)\n if match is None:\n render_system(f\"没有任务 id = {sub!r}\", C_WARN)\n return CommandResult()\n result = await mgr.get_result(sub)\n if result is None:\n ctx.console.print(Panel(\n f\"status: [yellow]{match.status}[/]\\n\"\n f\"description: {match.description or '(none)'}\\n\"\n f\"elapsed: {_time.monotonic() - match.start_time:.1f}s\",\n title=f\"Task {sub}\", title_align=\"left\", border_style=C_DIM,\n ))\n return CommandResult()\n body = result.content if result.success else (result.error or \"\")\n ctx.console.print(Panel(\n f\"[dim]duration {result.duration_ms:.0f}ms · \"\n f\"tool calls {result.tool_calls_count} · \"\n f\"success {result.success}[/]\\n\\n{body}\",\n title=f\"Task {sub} · {result.description}\",\n title_align=\"left\",\n border_style=\"green\" if result.success else \"red\",\n ))\n return CommandResult()\n\n # Default: table of all tasks\n if not tasks:\n render_system(\n \"没有后台任务。让 agent 调用 sub_agent(run_in_background=True) \"\n \"可以派生一个。\",\n C_DIM,\n )\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"id\", style=C_BRAND)\n table.add_column(\"状态\", style=\"white\")\n table.add_column(\"用时\", style=C_DIM, justify=\"right\")\n table.add_column(\"描述\", style=\"white\")\n now = _time.monotonic()\n status_style = {\n \"running\": \"[yellow]● running[/]\",\n \"completed\": \"[green]✓ completed[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"killed\": \"[dim]■ killed[/]\",\n }\n for t in tasks:\n elapsed = now - t.start_time if t.start_time else 0.0\n table.add_row(\n t.agent_id,\n status_style.get(t.status, t.status),\n f\"{elapsed:.1f}s\",\n t.description or \"(none)\",\n )\n ctx.console.print(Panel(\n table,\n title=f\"后台任务 · {len(tasks)} 个 \"\n f\"(running {sum(1 for t in tasks if t.status == 'running')})\",\n title_align=\"left\",\n border_style=C_DIM,\n ))\n render_system(\n \"提示: /tasks <id> 查看输出 · /tasks stop <id> 停止 · /tasks clear 清空\",\n C_DIM,\n )\n return CommandResult()\n\n async def _dream(ctx, arg):\n \"\"\"Inspect / control Auto-Dream (background memory consolidation).\"\"\"\n agent = ctx.agent\n service = agent.auto_dream\n sub = arg.strip().lower()\n\n if service is None:\n render_system(\n \"Auto-Dream 未启用。默认启动时会开启;用 agent.enable_auto_dream() 开。\",\n C_DIM,\n )\n return CommandResult()\n\n if sub in (\"off\", \"disable\", \"stop\"):\n agent.disable_auto_dream()\n render_system(\"✓ Auto-Dream 已关闭。\", C_SUCCESS)\n return CommandResult()\n\n if sub in (\"force\", \"now\", \"立刻\"):\n render_system(\"正在强制执行一次 dream…\", C_DIM)\n await service.force_dream()\n stats = service.stats\n render_system(\n f\"✓ dream 完成 — extractions={stats.extractions} \"\n f\"consolidations={stats.consolidations}\",\n C_SUCCESS,\n )\n return CommandResult()\n\n if sub in (\"consolidate\", \"sum\"):\n render_system(\"正在 consolidate…\", C_DIM)\n summary = await service.consolidate_now()\n if summary:\n ctx.console.print(Panel(\n summary,\n title=\"最新合并摘要\",\n title_align=\"left\",\n border_style=\"magenta\",\n ))\n else:\n render_system(\"没有足够的条目可合并。\", C_DIM)\n return CommandResult()\n\n # Default: show stats + config\n stats = service.stats\n cfg = service.config\n mem_count = (\n len(agent.session_memory) if agent.session_memory is not None else 0\n )\n ctx.console.print(Panel(\n f\" 状态 : {'[yellow]● dreaming[/]' if stats.currently_dreaming else '[green]● idle[/]'}\\n\"\n f\" 提取次数 : {stats.extractions}\\n\"\n f\" 合并次数 : {stats.consolidations}\\n\"\n f\" 上次运行 : {stats.last_run_at or '[dim]—[/]'}\\n\"\n f\" 上次错误 : {stats.last_error or '[dim]无[/]'}\\n\"\n f\" 记忆条目 : {mem_count}\\n\\n\"\n f\" 间隔 (轮) : {cfg.interval_turns}\\n\"\n f\" 合并周期 : 每 {cfg.consolidation_every} 次提取\\n\\n\"\n f\" [dim]/dream force[/] 强制执行一轮\\n\"\n f\" [dim]/dream consolidate[/] 只跑合并\\n\"\n f\" [dim]/dream off[/] 关闭\",\n title=\"Auto-Dream\", title_align=\"left\", border_style=\"magenta\",\n ))\n return CommandResult()\n\n async def _mcp(ctx, arg):\n \"\"\"MCP server control.\n\n /mcp list connected servers and their tools\n /mcp reconnect <n> drop and re-dial a server\n /mcp disconnect <n> disconnect and unregister tools\n \"\"\"\n agent = ctx.agent\n mgr = agent.mcp_manager\n sub = arg.strip()\n sub_lower = sub.lower()\n\n async def _reconnect(name: str) -> None:\n state = mgr.get(name)\n if state is None:\n render_system(f\"没有 MCP 服务器 {name!r}\", C_WARN)\n return\n cfg = state.config\n await mgr.disconnect(name)\n new_state = await agent.connect_mcp(cfg)\n if new_state.status == \"connected\":\n render_system(\n f\"✓ {name} 重连成功 — {len(new_state.tools)} 个工具\",\n C_SUCCESS,\n )\n else:\n render_system(\n f\"✗ {name} 重连失败: {new_state.error}\", C_WARN,\n )\n\n if sub_lower.startswith(\"reconnect \"):\n await _reconnect(sub.split(maxsplit=1)[1].strip())\n return CommandResult()\n\n if sub_lower.startswith(\"disconnect \"):\n name = sub.split(maxsplit=1)[1].strip()\n ok = await mgr.disconnect(name)\n if ok:\n # Also drop the tools from the registry\n for tname in list(agent._tool_registry.list_tools()):\n if tname.startswith(f\"mcp__{name}__\"):\n agent._tool_registry.unregister(tname)\n render_system(f\"✓ {name} 已断开。\", C_SUCCESS)\n else:\n render_system(f\"{name!r} 未连接。\", C_DIM)\n return CommandResult()\n\n # Default: show table\n servers = mgr.servers()\n if not servers:\n ctx.console.print(Panel(\n \"当前没有 MCP 服务器连接。\\n\\n\"\n f\"要启用: 编辑 [cyan]{_CONFIG_DIR / 'mcp.json'}[/], 格式:\\n\\n\"\n '[dim]{\\n'\n ' \"mcpServers\": {\\n'\n ' \"filesystem\": {\\n'\n ' \"command\": \"npx\",\\n'\n ' \"args\": [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/tmp\"]\\n'\n ' }\\n'\n ' }\\n'\n '}[/]\\n\\n'\n \"然后 /mcp reconnect <name> 或重启 lumen。\",\n border_style=C_DIM, title=\"MCP\", title_align=\"left\",\n ))\n return CommandResult()\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"server\", style=C_BRAND)\n table.add_column(\"状态\", style=\"white\")\n table.add_column(\"工具数\", justify=\"right\", style=C_DIM)\n table.add_column(\"错误 / 命令\", style=C_DIM)\n status_mark = {\n \"connected\": \"[green]● connected[/]\",\n \"connecting\": \"[yellow]● connecting[/]\",\n \"failed\": \"[red]✗ failed[/]\",\n \"closed\": \"[dim]■ closed[/]\",\n \"disabled\": \"[dim]⊘ disabled[/]\",\n \"idle\": \"[dim]idle[/]\",\n }\n for s in servers:\n cmd_preview = f\"{s.config.command} {' '.join(s.config.args[:3])}\"\n if len(s.config.args) > 3:\n cmd_preview += \" …\"\n detail = s.error if s.error else cmd_preview\n table.add_row(\n s.config.name,\n status_mark.get(s.status, s.status),\n str(len(s.tools)),\n detail,\n )\n ctx.console.print(Panel(\n table,\n title=f\"MCP 服务器 · {len(servers)} 个\",\n title_align=\"left\",\n border_style=C_DIM,\n ))\n # Per-server tool breakdown (compact)\n for s in servers:\n if not s.tools:\n continue\n tool_names = \", \".join(t.name for t in s.tools)\n ctx.console.print(\n f\" [dim]{s.config.name}:[/] {tool_names}\"\n )\n render_system(\n \"提示: /mcp reconnect <name> 重连 · /mcp disconnect <name> 断开\",\n C_DIM,\n )\n return CommandResult()\n\n async def _vim(ctx, arg):\n from prompt_toolkit.enums import EditingMode\n app = ctx.pt.app\n if app.editing_mode == EditingMode.VI:\n app.editing_mode = EditingMode.EMACS\n render_system(\"✓ Vim 模式关闭 (回到 Emacs/默认)\", C_SUCCESS)\n else:\n app.editing_mode = EditingMode.VI\n render_system(\n \"✓ Vim 模式开启 · Esc→Normal · i→Insert · :→冒号命令\",\n C_SUCCESS,\n )\n return CommandResult()\n\n for cmd in [\n SlashCommand(\"/help\", \"显示帮助\", _help),\n SlashCommand(\"/status\", \"Token 用量和会话信息\", _status),\n SlashCommand(\"/mode\", \"切换模式 (general | plan | review)\", _mode,\n subargs=((\"general\", \"通用模式\"),\n (\"plan\", \"方案模式(只读调研)\"),\n (\"review\", \"审阅模式(阶段门控)\"))),\n SlashCommand(\"/plan\", \"Plan Mode 控制 (on/off/approve/cancel)\", _plan,\n subargs=((\"on\", \"开启方案模式\"),\n (\"approve\", \"批准方案并开始执行\"),\n (\"cancel\", \"取消方案模式\"),\n (\"off\", \"同 cancel\"))),\n SlashCommand(\"/diff\", \"展示本次会话 agent 修改过的文件 (可带路径参数)\", _diff),\n SlashCommand(\"/tasks\", \"后台任务 (列表 / 查看 id / stop id / clear)\", _tasks,\n subargs=((\"clear\", \"清空所有任务\"),\n (\"stop\", \"/tasks stop <id> 停止任务\"))),\n SlashCommand(\"/mcp\", \"MCP 服务器 (list / reconnect / disconnect)\", _mcp,\n subargs=((\"reconnect\", \"/mcp reconnect <name>\"),\n (\"disconnect\", \"/mcp disconnect <name>\"))),\n SlashCommand(\"/dream\", \"Auto-Dream 记忆合并 (force/consolidate/off)\", _dream,\n subargs=((\"force\", \"强制跑一轮\"),\n (\"consolidate\", \"只跑合并\"),\n (\"off\", \"关闭 Auto-Dream\"))),\n SlashCommand(\"/perm\", \"权限模式 (default/acceptEdits/plan/bypass)\", _perm,\n subargs=((\"default\", \"写操作逐次确认\"),\n (\"acceptEdits\", \"write/edit 自动放行\"),\n (\"plan\", \"只读调研模式\"),\n (\"bypass\", \"⚠ 全部放行\"))),\n SlashCommand(\"/compact\", \"手动压缩上下文\", _compact),\n SlashCommand(\"/reset\", \"清空对话历史\", _reset),\n SlashCommand(\"/save\", \"保存会话到文件\", _save),\n SlashCommand(\"/load\", \"从文件加载会话\", _load),\n SlashCommand(\"/resume\", \"恢复最近的自动保存会话\", _resume),\n SlashCommand(\"/config\", \"重新配置模型和 Key\", _config),\n SlashCommand(\"/forget\", \"删除本地保存的 API Key\", _forget),\n SlashCommand(\"/vim\", \"切换 Vim 模式\", _vim),\n SlashCommand(\"/quit\", \"退出\", _quit,\n aliases=(\"/exit\", \"/q\")),\n ]:\n reg.register(cmd)\n\n # Let users drop ~/.lumen/commands/*.py to add their own commands.\n loaded = reg.discover_user_commands(_CONFIG_DIR / \"commands\")\n if loaded:\n render_system(f\"✓ 加载用户命令: {', '.join(loaded)}\", C_DIM)\n\n return reg\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 快捷键 / 底栏\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _make_key_bindings() -> KeyBindings:\n \"\"\"\n 主输入框键位:\n · Shift+Tab general → plan → review → general 循环\n · Enter 提交当前输入(在 multiline 模式下覆盖默认换行)\n · Enter (补全选中时) 接受当前补全项,不提交\n · Shift+Enter / Alt+Enter / Ctrl+J 插入换行\n\n 关于 Shift+Enter:\n 终端默认不区分 Shift+Enter 和 Enter — 两者都发 \\\\r。要让 Shift+Enter 换行,\n 终端必须发出以下任一转义序列之一(下面我们都识别):\n · ESC [ 13 ; 2 u (CSI-u / kitty keyboard protocol, WezTerm/Ghostty/Kitty 默认开)\n · ESC [ 27 ; 2 ; 13 ~ (xterm modifyOtherKeys, xterm/urxvt 默认开)\n · ESC \\\\r (等同于 Alt+Enter —— iTerm2 用户可以在\n Preferences → Profiles → Keys 新增快捷键\n Shift+Return → Send Escape Sequence → 留空即可)\n 在 macOS Terminal / iTerm2 默认设置下 Shift+Enter 不区分,用 Alt+Enter 兜底。\n \"\"\"\n kb = KeyBindings()\n\n @kb.add(\"s-tab\")\n def _toggle_mode(event):\n \"\"\"Cycle: general → plan → review → general.\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n inner = _INNER_PT_REF.get(\"session\") or _PT_SESSION_REF.get(\"session\")\n if agent is None:\n return\n if agent.review_mode:\n # review → general\n agent.disable_review_mode()\n elif agent.plan_mode:\n # plan → review\n agent.disable_plan_mode()\n agent.permissions.set_mode(PermissionMode.DEFAULT)\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n else:\n # general → plan\n agent.enable_plan_mode()\n agent.permissions.set_mode(PermissionMode.PLAN)\n # 强制重绘底栏\n event.app.invalidate()\n\n @kb.add(\"enter\", filter=completion_is_selected)\n def _accept_completion(event):\n \"\"\"User navigated into the completion menu — Enter accepts, doesn't submit.\"\"\"\n event.current_buffer.complete_state = None\n\n @kb.add(\"enter\")\n def _submit(event):\n \"\"\"Plain Enter submits the line (overrides multiline-mode default newline).\"\"\"\n event.current_buffer.validate_and_handle()\n\n def _insert_newline(event):\n event.current_buffer.newline()\n\n # Alt+Enter(等同于 ESC \\r)—— iTerm2 用户的主力换行键\n kb.add(\"escape\", \"enter\")(_insert_newline)\n\n # Ctrl+J —— 在 Alt 被系统/窗口管理器吞掉时的兜底\n kb.add(\"c-j\")(_insert_newline)\n\n # Shift+Enter (CSI-u 编码): ESC [ 13 ; 2 u\n # prompt_toolkit 把 ESC 前缀解析为 \"escape\", 然后逐字符匹配余下字节\n kb.add(\"escape\", \"[\", \"1\", \"3\", \";\", \"2\", \"u\")(_insert_newline)\n\n # Shift+Enter (xterm modifyOtherKeys 编码): ESC [ 27 ; 2 ; 13 ~\n kb.add(\"escape\", \"[\", \"2\", \"7\", \";\", \"2\", \";\", \"1\", \"3\", \"~\")(_insert_newline)\n\n return kb\n\n\ndef _bottom_toolbar():\n \"\"\"底部常驻状态条:mode · phase · 快捷键提示。\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n if agent is None:\n return \"\"\n if agent.review_mode:\n phase = agent.review_state.phase\n phase_text = {\n ReviewPhase.IDLE: \"待命\",\n ReviewPhase.DESIGN: \"设计\",\n ReviewPhase.PIPELINE: \"数据流\",\n ReviewPhase.IMPL: f\"实现×{len(agent.review_state.functions_approved)}\",\n ReviewPhase.COMPLETE: \"完成\",\n }.get(phase, \"\")\n mode_part = f\" ⚑ review · {phase_text} \"\n elif agent.plan_mode:\n mode_part = \" 📋 plan \"\n else:\n mode_part = \" general \"\n perm_tag = {\n PermissionMode.DEFAULT: \"\",\n PermissionMode.ACCEPT_EDITS: \" ✎ accept-edits │\",\n PermissionMode.PLAN: \" 📋 plan-only │\",\n PermissionMode.BYPASS: \" ⚠ BYPASS │\",\n }[agent.permissions.mode]\n return (\n f\" mode:{mode_part}│{perm_tag} ⇧⇥ 切模式 │ / 命令 │ @ 文件 │ \"\n f\"⇧⏎ / ⌥⏎ 换行 │ {agent.model} \"\n )\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主聊天循环\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"运行聊天主循环。返回 True 表示重新配置,False 表示退出。\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\n\n Pauses any active Rich Live region so prompt_toolkit can own the\n terminal cleanly, and uses a dedicated modal PromptSession (no\n toolbar / completer / Shift+Tab binding) so keystrokes never get\n intercepted by the main input box's UX helpers.\n \"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = str(tool_input[key])\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n modal_pt = _INNER_PT_REF.get(\"session\") or pt\n with _LivePause():\n console.print()\n console.print(Panel(\n f\" 工具: [bold yellow]{tool_name}[/]\\n{arg_hint}\",\n title=\"🔒 需要确认\", title_align=\"left\", border_style=\"yellow\",\n ))\n try:\n answer = await modal_pt.prompt_async(\" 允许执行? [Y/n] › \")\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # 项目目录结构树\n DefinitionsTool(), # 提取文件中所有类/函数定义\n FileReadTool(), # 按行读取文件内容\n FileWriteTool(), # 创建/覆写文件\n FileEditTool(), # 精确查找替换编辑\n GlobTool(), # 按模式匹配文件路径\n GrepTool(), # 搜索文件内容\n BashTool(), # 执行 shell 命令\n ],\n )\n except Exception as e:\n render_error(f\"Agent 创建失败: {e}\")\n return True\n\n # Register sub_agent + task_* tools so the model can spawn and observe\n # background work, and `/tasks` has something to show.\n agent.enable_subagents()\n\n # Discovery utilities: tool_search (rank tools by query) + brief (session summary).\n agent.enable_discovery_tools()\n\n # Auto-Dream: background memory consolidation. Runs async after every\n # N turns and periodically merges entries into narrative summaries.\n # Disable with LUMEN_NO_DREAM=1 if you want the old inline behavior.\n if os.environ.get(\"LUMEN_NO_DREAM\") != \"1\":\n agent.enable_auto_dream()\n\n # Auto-connect MCP servers declared in ~/.lumen/mcp.json (if any).\n mcp_cfg_path = _CONFIG_DIR / \"mcp.json\"\n if mcp_cfg_path.exists():\n try:\n mcp_states = await agent.connect_mcp_from_config(mcp_cfg_path)\n if mcp_states:\n ok = [s for s in mcp_states if s.status == \"connected\"]\n bad = [s for s in mcp_states if s.status == \"failed\"]\n if ok:\n names = \", \".join(\n f\"{s.config.name}({len(s.tools)})\" for s in ok\n )\n render_system(f\"✓ MCP 已连接: {names}\", C_DIM)\n for s in bad:\n render_system(\n f\" ⚠ MCP {s.config.name} 连接失败: {s.error}\",\n C_WARN,\n )\n except Exception as e:\n render_system(f\" ⚠ MCP 加载失败: {e}\", C_WARN)\n\n _AGENT_REF[\"agent\"] = agent\n\n # Desktop notifier — fires on turns that exceed LUMEN_NOTIFY_THRESHOLD (15s default).\n # Disable entirely with LUMEN_NOTIFY=0.\n notifier = Notifier()\n\n # File edit tracker — session-scoped snapshot of files on first write/edit,\n # powers `/diff`.\n edit_tracker = FileEditTracker()\n edit_tracker.install(agent.hooks)\n\n # Build the slash command registry (scans ~/.lumen/commands/*.py for user cmds).\n command_registry = _build_command_registry()\n # Let the completer see registered commands (inc. user-loaded ones).\n completer = pt.completer\n if hasattr(completer, \"bind_registry\"):\n completer.bind_registry(command_registry)\n\n # Modal prompt session (for permission / approval / wizard sub-prompts).\n # Set up in main_async(); here we just reach for it.\n inner_pt = _INNER_PT_REF.get(\"session\") or pt\n\n # 启动信息\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(\"Provider\", config.provider_name)\n info.add_row(\"Model\", agent.model)\n info.add_row(\"Session ID\", agent.session.session_id[:12] + \"...\")\n info.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n info.add_row(\"Auto-compact\", \"✓ 开启\")\n if agent.transcript:\n info.add_row(\"Auto-save\", f\"✓ {agent.transcript.path}\")\n info.add_row(\"Git state\", \"✓ 注入\")\n info.add_row(\"Memory files\", \"✓ ENGRAM.md 自动发现\")\n info.add_row(\"Tools (读)\", \"✓ tree · definitions · read_file · glob · grep\")\n info.add_row(\"Tools (写)\", \"[yellow]✎[/] write_file · edit_file · bash\")\n info.add_row(\"权限系统\", \"✓ 读操作静默放行 · 写操作需确认 · 危险命令拒绝\")\n info.add_row(\"重试/降级\", \"✓ 指数退避 · 429/529重试 · max_tokens自动缩减\")\n info.add_row(\"文件缓存\", \"✓ LRU 100文件/25MB\")\n info.add_row(\n \"桌面通知\",\n f\"✓ {notifier.channel} (>{int(notifier.default_threshold_s)}s 自动响)\"\n if notifier.enabled else \"关闭 (LUMEN_NOTIFY=0)\",\n )\n console.print(Panel(info, border_style=C_DIM, title=\"✓ 准备就绪\", title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(\n \" 输入消息后按 Enter 发送。\"\n \" [magenta]/help[/] 查看命令。\"\n \" [dim]Ctrl+D 或 /quit 退出。[/]\\n\"\n )\n\n _last_ctrl_c = False\n\n try:\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n # ── Slash 命令 (通过 CommandRegistry 分派) ────────────────────────\n if user_input.startswith(\"/\"):\n head, _, rest = user_input.partition(\" \")\n command = command_registry.find(head.lower())\n if command is None:\n render_system(\n f\"未知命令: {user_input} 输入 /help 查看帮助\", C_WARN,\n )\n continue\n ctx = ChatContext(\n agent=agent, config=config, console=console,\n pt=pt, inner_pt=inner_pt, notifier=notifier,\n registry=command_registry, edit_tracker=edit_tracker,\n )\n try:\n result = await command.handler(ctx, rest.strip())\n except (KeyboardInterrupt, EOFError):\n render_system(\"已取消\", C_DIM)\n continue\n except Exception as e:\n render_error(f\"{command.name} 执行失败: {e}\")\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n continue\n if result.new_agent is not None:\n agent = result.new_agent\n _AGENT_REF[\"agent\"] = agent\n if result.reconfigure:\n return True\n if result.quit:\n return False\n continue\n\n # ── 正常聊天 ──────────────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n import time as _time\n _turn_start = _time.monotonic()\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n _elapsed = _time.monotonic() - _turn_start\n notifier.notify_if_slow(\n \"Lumen\",\n f\"回复完成 · {user_input[:40]}\",\n elapsed_s=_elapsed,\n )\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n _last_ctrl_c = True\n render_system(\"已取消。(再按一次 Ctrl+C 退出)\", C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n finally:\n # Flush and close transcript on any exit path\n try:\n await agent.close()\n except Exception:\n pass\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主入口\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(\"Model-agnostic Coding Agent\", style=C_DIM)))\n console.print(Align.center(Text(\"用任意大模型读写和理解代码\", style=\"dim cyan\")))\n console.print()\n\n # Ensure config dir exists before FileHistory tries to open/append to it.\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n except Exception:\n pass\n\n # Vim mode: opt-in via LUMEN_VIM=1 or toggled at runtime via /vim\n vi_mode = os.environ.get(\"LUMEN_VIM\", \"\").strip() in (\"1\", \"true\", \"yes\", \"on\")\n\n pt = PromptSession(\n history=FileHistory(str(_CONFIG_DIR / \"history\")),\n style=PTStyle.from_dict({\n \"prompt\": \"ansicyan bold\",\n \"bottom-toolbar\": \"bg:#1a1a1a #888888\",\n \"completion-menu.completion\": \"bg:#1a1a1a #cccccc\",\n \"completion-menu.completion.current\": \"bg:#005f87 #ffffff bold\",\n \"completion-menu.meta.completion\": \"bg:#1a1a1a #707070 italic\",\n \"completion-menu.meta.completion.current\": \"bg:#005f87 #cfcfcf italic\",\n }),\n completer=SlashCompleter(),\n complete_while_typing=True,\n key_bindings=_make_key_bindings(),\n bottom_toolbar=_bottom_toolbar,\n refresh_interval=0.5,\n multiline=True, # Enter=submit, Alt+Enter/Ctrl+J=newline (see _make_key_bindings)\n vi_mode=vi_mode,\n )\n _PT_SESSION_REF[\"session\"] = pt\n\n # Inner prompt session — bare & separate, for modal prompts (permissions,\n # approvals). No toolbar, no completer, no Shift+Tab binding. This mirrors\n # src/'s pattern where permission dialogs use a dedicated Select component\n # with its own isolated input handling, so no keybindings leak in from\n # the main input box and no background refresh timer redraws over the prompt.\n inner_pt = PromptSession(\n history=None, # modal answers shouldn't pollute history\n style=PTStyle.from_dict({\"prompt\": \"ansiyellow bold\"}),\n )\n _INNER_PT_REF[\"session\"] = inner_pt\n\n # 如果命令行已经提供了完整参数,跳过向导\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n )\n await chat_loop(config, pt)\n return\n\n # 否则进入 配置 → 聊天 → 可重配置 的循环\n config: AgentConfig | None = None\n skip_saved_once = False # True on /config reconfigure: skip the \"use saved?\" prompt\n\n while True:\n # 优先复用已保存的配置,除非用户主动 /config\n saved = None if skip_saved_once else load_config()\n skip_saved_once = False\n\n if saved is not None:\n masked_key = (saved.api_key[:8] + \"...\" + saved.api_key[-4:]\n if len(saved.api_key) > 12 else \"****\")\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(\"Provider\", saved.provider_name)\n info.add_row(\"Model\", saved.model)\n if saved.base_url:\n info.add_row(\"Base URL\", saved.base_url)\n info.add_row(\"API Key\", masked_key)\n console.print(Panel(\n info, border_style=\"green\",\n title=\"✓ 检测到已保存的配置\", title_align=\"left\",\n ))\n try:\n raw = await pt.prompt_async(\n \" 直接使用? [Y/n] (n = 重新配置, f = 忘记并重新配置) › \"\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(\"已取消,退出。\", C_DIM)\n return\n choice = raw.strip().lower()\n if choice in (\"\", \"y\", \"yes\"):\n config = saved\n elif choice == \"f\":\n forget_config()\n render_system(\"✓ 已删除保存的配置。\", C_SUCCESS)\n config = None\n else:\n config = None # fall through to wizard\n\n if config is None:\n try:\n config = await setup_wizard(pt)\n save_config(config)\n render_system(\n f\"✓ 配置已保存到 {_CONFIG_FILE} (下次启动直接复用)\",\n C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(\"已取消,退出。\", C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(\" 重新配置\", style=C_BRAND))\n config = None\n skip_saved_once = True\n\n\ndef main() -> None:\n import argparse\n parser = argparse.ArgumentParser(description=\"Lumen — 代码读写 Agent\")\n parser.add_argument(\"--model\", default=None, help=\"模型名称,例如 gpt-4o\")\n parser.add_argument(\"--api-key\", default=None, help=\"API Key(覆盖环境变量)\")\n parser.add_argument(\"--base-url\", default=None, help=\"API Base URL\")\n args = parser.parse_args()\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "8512185c4fe3dee94ec7e062f23a38b9b246ab05": {"t": 1785773579.3687344, "n": 51096, "h": "0a9da026564c354c", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\n直接运行,在 UI 内选择模型和输入 API Key:\n python chat.py\n\n也支持命令行直接指定(跳过配置向导):\n python chat.py --model gpt-4o --api-key sk-...\n\n聊天中的 Slash 命令:\n /help 帮助\n /status Token 用量和会话信息\n /mode 切换 general ↔ review(或 Shift+Tab)\n /compact 手动压缩上下文\n /reset 清空对话历史\n /save 保存会话到文件\n /load 从文件加载会话\n /config 重新配置模型和 Key\n /forget 删除本地保存的 API Key\n /quit 退出\n\n交互增强:\n • 输入 / 自动弹出命令补全\n • 输入 @ 自动补全项目文件路径(30s 缓存)\n • /load <tab> 补全项目内文件,选中会话文件加载\n • 多行输入: Enter 发送, Shift+Enter / Alt+Enter / Ctrl+J 换行\n (Shift+Enter 要求终端发出 CSI-u 或 modifyOtherKeys 转义序列;\n iTerm2/Terminal.app 默认不区分, 用 Alt+Enter 兜底即可)\n • 输入历史持久化到 ~/.lumen/history,跨会话保留\n • Shift+Tab 在模式间快速切换\n • 底栏常驻显示当前 mode / phase\n • 首次配置后 Key 会保存在 ~/.lumen/config.json (权限 0600),\n 下次启动直接复用 — 用 /forget 清除。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.completion import Completer, Completion\nfrom prompt_toolkit.filters import completion_is_selected\nfrom prompt_toolkit.history import FileHistory\nfrom prompt_toolkit.key_binding import KeyBindings\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import Agent, PermissionChecker, PermissionBehavior, HookRegistry, RetryConfig\nfrom lumen.context.transcript import TranscriptReader, list_recent_sessions\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.services.review_state import ReviewPhase\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\nfrom lumen.tools.approval import ConsoleApprovalHandler\n\nconsole = Console()\n\n# ── 配色 ──────────────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ── 共享状态(供 keybinding / bottom_toolbar 闭包访问) ──────────────────────────\n_AGENT_REF: dict = {\"agent\": None}\n_PT_SESSION_REF: dict = {\"session\": None} # 主输入会话(/mode, Shift+Tab)\n_INNER_PT_REF: dict = {\"session\": None} # 模态输入会话(permission / approval)\n_LIVE_REF: dict = {\"live\": None} # 当前 agent_response 的 Live,用于暂停/恢复\n\n\nclass _LivePause:\n \"\"\"Context manager that suspends any active Rich Live region while a\n modal prompt runs, then resumes it. Prevents prompt_toolkit from fighting\n Rich for terminal control, which is what makes modal prompts appear to\n 'swallow Enter' — they were never visible in the first place.\"\"\"\n\n def __enter__(self):\n self._live = _LIVE_REF.get(\"live\")\n if self._live is not None:\n try:\n self._live.stop()\n except Exception:\n self._live = None\n return self\n\n def __exit__(self, exc_type, exc, tb):\n if self._live is not None:\n try:\n self._live.start()\n except Exception:\n pass\n return False\n\n\nclass _LivePausingApprovalHandler:\n \"\"\"Wraps any ApprovalHandler so each `.request()` call is bracketed by\n a `_LivePause`. Without this, the approval panel draws inside an active\n Rich Live region and prompt_toolkit can't take the terminal — the user\n sees nothing and their Enter appears to do nothing.\"\"\"\n\n def __init__(self, inner) -> None:\n self._inner = inner\n\n async def request(self, phase, title, content):\n with _LivePause():\n return await self._inner.request(phase, title, content)\n\n\n# ── Slash / @path 补全 ────────────────────────────────────────────────────────\nclass SlashCompleter(Completer):\n \"\"\"\n 统一补全器,支持三类触发:\n · `/` 顶层命令菜单(/help /mode /load …)\n · `/mode r…` 子参数(general / review)\n · `/load …` 项目内文件路径(用于加载会话文件)\n · `@path/to/…` 项目文件引用(插入文本,agent 能看到路径)\n\n 文件树扫描结果缓存 30s,自动跳过常见噪声目录(.git / node_modules /\n __pycache__ / .venv …),防止 complete_while_typing 每次按键都遍历磁盘。\n \"\"\"\n\n TOP_LEVEL = [\n (\"/help\", \"显示帮助\"),\n (\"/status\", \"Token 用量和会话信息\"),\n (\"/mode\", \"切换模式 (general | review)\"),\n (\"/compact\", \"手动压缩上下文\"),\n (\"/reset\", \"清空对话历史\"),\n (\"/save\", \"保存会话到文件\"),\n (\"/load\", \"从文件加载会话\"),\n (\"/resume\", \"恢复最近的自动保存会话\"),\n (\"/config\", \"重新配置模型和 Key\"),\n (\"/forget\", \"删除本地保存的 API Key\"),\n (\"/quit\", \"退出\"),\n ]\n MODE_ARGS = [\n (\"general\", \"通用模式\"),\n (\"review\", \"审阅模式(阶段门控)\"),\n ]\n\n SKIP_DIRS = {\n \".git\", \"node_modules\", \"__pycache__\", \".venv\", \"venv\",\n \"dist\", \"build\", \".pytest_cache\", \".mypy_cache\", \".ruff_cache\",\n \".tox\", \".next\", \".turbo\", \".cache\", \"target\", \".idea\", \".vscode\",\n }\n _FILE_CACHE_TTL = 30.0\n _FILE_CACHE_MAX = 2000\n\n def __init__(self) -> None:\n self._file_cache: list[str] | None = None\n self._file_cache_ts: float = 0.0\n\n def _scan_files(self) -> list[str]:\n import time\n now = time.time()\n if (\n self._file_cache is not None\n and (now - self._file_cache_ts) < self._FILE_CACHE_TTL\n ):\n return self._file_cache\n\n cwd = Path.cwd()\n results: list[str] = []\n for root, dirs, files in os.walk(cwd):\n dirs[:] = [\n d for d in dirs\n if d not in self.SKIP_DIRS and not d.startswith(\".\")\n ]\n root_path = Path(root)\n for f in files:\n if f.startswith(\".\") and f not in (\".gitignore\", \".env.example\"):\n continue\n full = root_path / f\n try:\n rel = full.relative_to(cwd)\n except ValueError:\n continue\n results.append(str(rel))\n if len(results) >= self._FILE_CACHE_MAX:\n break\n if len(results) >= self._FILE_CACHE_MAX:\n break\n results.sort()\n self._file_cache = results\n self._file_cache_ts = now\n return results\n\n def _rank_files(self, prefix: str, limit: int = 50) -> list[str]:\n \"\"\"Return files matching prefix, ranked: basename-start > path-start > substring.\"\"\"\n if not prefix:\n return self._scan_files()[:limit]\n lower = prefix.lower()\n ranked: list[tuple[int, str]] = []\n for path in self._scan_files():\n p_lower = path.lower()\n base_lower = Path(path).name.lower()\n if base_lower.startswith(lower):\n rank = 0\n elif p_lower.startswith(lower):\n rank = 1\n elif lower in p_lower:\n rank = 2\n else:\n continue\n ranked.append((rank, path))\n ranked.sort(key=lambda x: (x[0], x[1]))\n return [p for _, p in ranked[:limit]]\n\n def get_completions(self, document, complete_event):\n text = document.text_before_cursor\n\n # ── @path 文件引用(允许嵌在句子任意位置) ─────────────────────────────\n at_idx = text.rfind(\"@\")\n if at_idx != -1 and (at_idx == 0 or text[at_idx - 1] in \" \\t\\n\"):\n prefix = text[at_idx + 1:]\n if prefix == \"\" or (\" \" not in prefix and \"\\t\" not in prefix and \"\\n\" not in prefix):\n for path in self._rank_files(prefix):\n yield Completion(\n path, start_position=-len(prefix),\n display=path, display_meta=\"file\",\n )\n return\n\n # ── /load <path> 文件路径补全 ────────────────────────────────────────\n if text.startswith(\"/load \"):\n arg = text[len(\"/load \"):].lstrip()\n for path in self._rank_files(arg):\n yield Completion(\n path, start_position=-len(arg),\n display=path, display_meta=\"session\",\n )\n return\n\n # ── /mode 子参数 ─────────────────────────────────────────────────────\n if text.startswith(\"/mode \"):\n prefix = text[len(\"/mode \"):].lstrip()\n for arg, desc in self.MODE_ARGS:\n if arg.startswith(prefix):\n yield Completion(\n arg, start_position=-len(prefix),\n display=arg, display_meta=desc,\n )\n return\n\n # ── 顶层 slash 命令 ──────────────────────────────────────────────────\n if text.startswith(\"/\"):\n for cmd, desc in self.TOP_LEVEL:\n if cmd.startswith(text):\n yield Completion(\n cmd, start_position=-len(text),\n display=cmd, display_meta=desc,\n )\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model 目录\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str\n key_hint: str # API key 格式提示\n env_key: str | None # 从哪个环境变量读取 key\n base_url: str | None # None = 由 Agent 自动判断\n models: list[ModelOption]\n needs_key: bool = True\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · 最新最强\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"GetGoAPI (多模型代理)\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 最新最强综合\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 推理模型\"),\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 最强综合\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 快速省钱\"),\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · 最强综合\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · 最强能力\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · 快速省钱\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"Ollama (本地模型)\", key_hint=\"无需 Key\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"自定义 / 其他\", key_hint=\"你的 API Key\",\n env_key=None,\n base_url=None,\n models=[], # 用户自己输入\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 配置向导\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n\n\n# ── 配置持久化 ────────────────────────────────────────────────────────────────\n# 保存在 ~/.lumen/config.json (文件权限 0600)\n# 避免每次启动都要重新粘贴 API Key。\n\n_CONFIG_DIR = Path.home() / \".lumen\"\n_CONFIG_FILE = _CONFIG_DIR / \"config.json\"\n\n\ndef save_config(config: AgentConfig) -> None:\n \"\"\"Write config to ~/.lumen/config.json with 0600 permissions.\"\"\"\n import json\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n data = {\n \"api_key\": config.api_key,\n \"model\": config.model,\n \"base_url\": config.base_url,\n \"provider_name\": config.provider_name,\n }\n _CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding=\"utf-8\")\n try:\n os.chmod(_CONFIG_FILE, 0o600)\n except Exception:\n pass\n except Exception as e:\n render_system(f\"(无法保存配置: {e})\", C_DIM)\n\n\ndef load_config() -> AgentConfig | None:\n \"\"\"Load cached config if present and well-formed.\"\"\"\n import json\n if not _CONFIG_FILE.exists():\n return None\n try:\n data = json.loads(_CONFIG_FILE.read_text(encoding=\"utf-8\"))\n if not data.get(\"model\") or not data.get(\"api_key\"):\n return None\n return AgentConfig(\n api_key=data[\"api_key\"],\n model=data[\"model\"],\n base_url=data.get(\"base_url\"),\n provider_name=data.get(\"provider_name\") or \"Saved\",\n )\n except Exception:\n return None\n\n\ndef forget_config() -> bool:\n \"\"\"Delete the saved config file.\"\"\"\n try:\n if _CONFIG_FILE.exists():\n _CONFIG_FILE.unlink()\n return True\n except Exception:\n pass\n return False\n\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"交互式配置向导:选 Provider → 选模型 → 输入 Key。\"\"\"\n console.print()\n console.print(Rule(\" 配置模型\", style=C_BRAND))\n console.print()\n\n # ── 第一步:选 Provider ───────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key 已检测[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.name + detected, key_info)\n\n console.print(Panel(table, title=\"选择 Provider\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(f\" [yellow]请输入 1–{len(PROVIDERS)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(f\" [green]✓ 已选:[/]{provider.name}\\n\")\n\n # ── 第二步:选模型 ────────────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(\" Base URL (如 http://localhost:11434/v1) › \")).strip()\n model = (await pt.prompt_async(\" Model 名称 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(\"model 不能为空\")\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.desc)\n\n console.print(Panel(model_table, title=\"选择模型\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(f\" [yellow]请输入 1–{len(provider.models)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(f\" [green]✓ 已选:[/]{model}\\n\")\n\n # ── 第三步:输入 API Key ──────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(f\" [dim]本地模型无需 API Key,使用默认占位符[/]\\n\")\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n f\" 检测到环境变量 {provider.env_key} ({masked})\\n\"\n f\" 直接使用? [Y/n] › \"\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(\" [green]✓ 使用环境变量中的 Key[/]\\n\")\n else:\n api_key = await _prompt_key(pt, provider.key_hint)\n else:\n console.print(f\" [dim]格式参考:{provider.key_hint}[/]\")\n api_key = await _prompt_key(pt, provider.key_hint)\n\n # ── 汇总展示 ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(\"Provider\", provider.name)\n summary.add_row(\"Model\", model)\n if base_url:\n summary.add_row(\"Base URL\", base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(\"API Key\", key_display)\n console.print(Panel(summary, title=\"✓ 配置确认\", border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.name,\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"带密码掩码的 API Key 输入。\"\"\"\n while True:\n try:\n key = await pt.prompt_async(\" API Key › \")\n key = key.strip()\n if key:\n return key\n console.print(\" [yellow]Key 不能为空,请重新输入[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI 渲染组件\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(\" model: \", style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(\" │ msgs: \", style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(\" │ compactions: \", style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.review_mode:\n stats.append(\" │ \", style=C_DIM)\n phase = agent.review_state.phase\n phase_label = {\n ReviewPhase.IDLE: \"审阅·待命\",\n ReviewPhase.DESIGN: \"审阅·设计\",\n ReviewPhase.PIPELINE: \"审阅·数据流\",\n ReviewPhase.IMPL: f\"审阅·实现({len(agent.review_state.functions_approved)})\",\n ReviewPhase.COMPLETE: \"审阅·完成\",\n }.get(phase, \"审阅\")\n stats.append(f\"⚑ {phase_label}\", style=\"bold yellow\")\n\n border = \"yellow\" if agent.review_mode else C_DIM\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\ndef render_user_message(text: str) -> Panel:\n return Panel(\n Text(text, style=\"white\"),\n title=Text(\" You\", style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text: str, style: str = C_DIM) -> None:\n console.print(f\" {text}\", style=style)\n\n\ndef render_error(text: str) -> None:\n console.print(Panel(text, border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help() -> None:\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n for cmd, desc in [\n (\"/help\", \"显示帮助\"),\n (\"/status\", \"Token 用量和会话信息\"),\n (\"/mode\", \"切换模式:通用 ↔ 审阅 (/mode review | general, 或 Shift+Tab 快速切换)\"),\n (\"/compact\", \"手动压缩上下文\"),\n (\"/reset\", \"清空对话历史\"),\n (\"/save\", \"保存会话到文件\"),\n (\"/load\", \"从文件加载会话\"),\n (\"/resume\", \"恢复最近的自动保存会话\"),\n (\"/config\", \"重新配置模型和 Key\"),\n (\"/forget\", \"删除本地保存的 API Key\"),\n (\"/quit\", \"退出 (或 Ctrl+C / Ctrl+D)\"),\n (\"\", \"\"),\n (\"↑ / ↓\", \"浏览输入历史(持久化在 ~/.lumen/history)\"),\n (\"Shift+Enter\", \"换行(需终端支持;iTerm2 用户可改用 Alt+Enter)\"),\n (\"Alt+Enter\", \"换行(跨终端兜底,Ctrl+J 同效)\"),\n (\"@path\", \"自动补全项目内文件路径\"),\n (\"Ctrl+C\", \"取消当前输入 / 再按一次退出\"),\n ]:\n table.add_row(cmd, desc)\n console.print(Panel(table, title=\"命令列表\", border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(\"Model\", agent.model)\n table.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n table.add_row(\"Tokens used\", f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(\"Messages\", str(len(agent.messages)))\n table.add_row(\"Compactions\", str(agent.session.compaction_count))\n table.add_row(\"Session start\", agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=\"Session Status\", border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent 响应\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n 执行一次完整的 Agent 响应:工具调用过程实时展示,最终答案 Markdown 渲染。\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(\" Lumen ● thinking…\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── 有工具时用 Live 面板展示进度 ──────────────────────────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n _LIVE_REF[\"live\"] = live\n tool_log.append(\" [dim]正在思考…[/]\")\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n try:\n await _chat_with_refresh()\n finally:\n _LIVE_REF[\"live\"] = None\n else:\n # 无工具时走流式输出\n collected: list[str] = []\n panel_title = Text(\" Lumen ● streaming…\", style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n _LIVE_REF[\"live\"] = live\n try:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\"\".join(collected), code_theme=\"monokai\", inline_code_theme=\"monokai\")\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n finally:\n _LIVE_REF[\"live\"] = None\n return \"\".join(collected)\n\n # ── 渲染最终答案 ───────────────────────────────────────────────────────────\n final_text = response.content if response else \"\"\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(\" Lumen\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 模式切换\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str, pt: PromptSession | None = None) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n mode = \"审阅模式 [Review Mode]\" if agent.review_mode else \"通用模式 [General]\"\n console.print(Panel(\n f\" 当前模式: [bold cyan]{mode}[/]\\n\\n\"\n \" [dim]/mode review[/] → 开启审阅模式(阶段门控)\\n\"\n \" [dim]/mode general[/] → 切回通用模式\\n\"\n \" [dim]Shift+Tab[/] → 快速切换\",\n border_style=C_DIM, title=\"模式\", title_align=\"left\",\n ))\n return\n\n if arg in (\"review\", \"r\", \"审阅\", \"careful\"):\n if agent.review_mode:\n render_system(\"已经是审阅模式。\", C_DIM)\n else:\n inner = _INNER_PT_REF.get(\"session\") or pt\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n console.print(Panel(\n \"[bold yellow]审阅模式已开启 — 真正的阶段门控[/]\\n\\n\"\n \" 模型写代码时分 3 步进行,每步 AI 会[bold]调用 request_approval 工具[/]暂停等你确认:\\n\"\n \" · Phase 1 [cyan]设计方案[/] — 展示函数/类签名和职责\\n\"\n \" · Phase 2 [blue]数据流[/] — 文本箭头图展示 pipeline\\n\"\n \" · Phase 3 [yellow]逐步实现[/] — 一次一个函数,解释数据变化\\n\\n\"\n \" 审批面板弹出时你可以:\\n\"\n \" · [bold]Y / 空回车[/] → 通过,进入下一步\\n\"\n \" · [bold]skip / 全部写完[/] → 通过并跳过后续审批\\n\"\n \" · [bold]N[/] 然后输入反馈 → 打回重写\\n\"\n \" · 直接输入任何文字 → 当作反馈并打回\\n\\n\"\n \" [dim]Token 栏会实时显示当前 Phase。非编码任务不受影响。[/]\",\n border_style=\"yellow\", title=\"⚑ Review Mode\", title_align=\"left\",\n ))\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n if not agent.review_mode:\n render_system(\"已经是通用模式。\", C_DIM)\n else:\n agent.disable_review_mode()\n render_system(\"✓ 已切回通用模式。\", C_SUCCESS)\n\n else:\n render_system(\n f\"未知模式 '{arg}'。用 /mode review | /mode general。\", C_WARN\n )\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 命令处理\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(\"Compacting context…\", C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n f\"[green]✓ 压缩完成[/]\\n\\n\"\n f\" 消息数 : [white]{r.messages_before}[/] → [white]{r.messages_after}[/]\\n\"\n f\" Tokens : [white]{r.tokens_before:,}[/] → [white]{r.tokens_after:,}[/]\\n\"\n f\" 保留最近 [white]{r.kept_recent_count}[/] 条消息原文\",\n border_style=\"green\", title=\"压缩结果\", title_align=\"left\",\n ))\n except Exception as e:\n render_error(f\"压缩失败: {e}\")\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(f\" 保存到 [{default}] › \", default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(f\"✓ 已保存 → {path}\", C_SUCCESS)\n if agent.transcript:\n render_system(\n f\" (会话也在自动保存到 {agent.transcript.path})\", C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n render_system(\"已取消\", C_DIM)\n except Exception as e:\n render_error(f\"保存失败: {e}\")\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(\" 加载文件路径 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(f\"文件不存在: {arg!r}\")\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(f\"✓ 已加载 {len(agent.messages)} 条消息 from {arg}\", C_SUCCESS)\n return agent\n except Exception as e:\n render_error(f\"加载失败: {e}\")\n return None\n\n\nasync def handle_resume(config: AgentConfig, pt: PromptSession) -> Agent | None:\n \"\"\"List recent auto-saved sessions and let the user pick one to resume.\"\"\"\n cwd = Path.cwd()\n sessions = list_recent_sessions(cwd, limit=10)\n if not sessions:\n render_system(\"没有找到自动保存的会话。\", C_WARN)\n return None\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"#\", style=C_NUM, width=4)\n table.add_column(\"Session ID\", style=\"white\")\n table.add_column(\"Model\", style=C_DIM)\n table.add_column(\"Messages\", style=\"white\", justify=\"right\")\n table.add_column(\"Created\", style=C_DIM)\n table.add_column(\"Last prompt\", style=\"white\")\n\n for i, s in enumerate(sessions, 1):\n created = s.created_at[:19].replace(\"T\", \" \") if s.created_at else \"?\"\n prompt_preview = s.last_prompt[:50] + (\"...\" if len(s.last_prompt) > 50 else \"\")\n table.add_row(\n f\"[{i}]\",\n s.session_id[:12] + \"...\",\n s.model or \"?\",\n str(s.message_count),\n created,\n prompt_preview,\n )\n\n console.print(Panel(table, title=\"最近的自动保存会话\", border_style=C_DIM))\n\n try:\n raw = await pt.prompt_async(\" 输入编号恢复 (或 Enter 取消) › \")\n raw = raw.strip()\n if not raw:\n return None\n idx = int(raw) - 1\n if not (0 <= idx < len(sessions)):\n render_system(f\"请输入 1-{len(sessions)} 之间的数字\", C_WARN)\n return None\n except (ValueError, KeyboardInterrupt, EOFError):\n return None\n\n chosen = sessions[idx]\n try:\n messages, metadata = TranscriptReader.load_session(chosen.file_path)\n if not messages:\n render_error(\"会话文件为空。\")\n return None\n\n # Rebuild a Session from the transcript messages\n from lumen.context.session import Session\n session = Session(model=config.model)\n session.session_id = chosen.session_id\n session.messages = messages\n\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n session=session,\n auto_compact=True,\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n FileWriteTool(), FileEditTool(),\n GlobTool(), GrepTool(), BashTool(),\n ],\n )\n render_system(\n f\"✓ 已恢复会话 {chosen.session_id[:12]}... \"\n f\"({len(messages)} 条消息)\",\n C_SUCCESS,\n )\n return agent\n except Exception as e:\n render_error(f\"恢复失败: {e}\")\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 快捷键 / 底栏\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef _make_key_bindings() -> KeyBindings:\n \"\"\"\n 主输入框键位:\n · Shift+Tab general ↔ review 模式切换\n · Enter 提交当前输入(在 multiline 模式下覆盖默认换行)\n · Enter (补全选中时) 接受当前补全项,不提交\n · Shift+Enter / Alt+Enter / Ctrl+J 插入换行\n\n 关于 Shift+Enter:\n 终端默认不区分 Shift+Enter 和 Enter — 两者都发 \\\\r。要让 Shift+Enter 换行,\n 终端必须发出以下任一转义序列之一(下面我们都识别):\n · ESC [ 13 ; 2 u (CSI-u / kitty keyboard protocol, WezTerm/Ghostty/Kitty 默认开)\n · ESC [ 27 ; 2 ; 13 ~ (xterm modifyOtherKeys, xterm/urxvt 默认开)\n · ESC \\\\r (等同于 Alt+Enter —— iTerm2 用户可以在\n Preferences → Profiles → Keys 新增快捷键\n Shift+Return → Send Escape Sequence → 留空即可)\n 在 macOS Terminal / iTerm2 默认设置下 Shift+Enter 不区分,用 Alt+Enter 兜底。\n \"\"\"\n kb = KeyBindings()\n\n @kb.add(\"s-tab\")\n def _toggle_mode(event):\n agent = _AGENT_REF.get(\"agent\")\n inner = _INNER_PT_REF.get(\"session\") or _PT_SESSION_REF.get(\"session\")\n if agent is None:\n return\n if agent.review_mode:\n agent.disable_review_mode()\n else:\n handler = _LivePausingApprovalHandler(\n ConsoleApprovalHandler(pt_session=inner, console=console)\n )\n agent.enable_review_mode(handler=handler)\n # 强制重绘底栏\n event.app.invalidate()\n\n @kb.add(\"enter\", filter=completion_is_selected)\n def _accept_completion(event):\n \"\"\"User navigated into the completion menu — Enter accepts, doesn't submit.\"\"\"\n event.current_buffer.complete_state = None\n\n @kb.add(\"enter\")\n def _submit(event):\n \"\"\"Plain Enter submits the line (overrides multiline-mode default newline).\"\"\"\n event.current_buffer.validate_and_handle()\n\n def _insert_newline(event):\n event.current_buffer.newline()\n\n # Alt+Enter(等同于 ESC \\r)—— iTerm2 用户的主力换行键\n kb.add(\"escape\", \"enter\")(_insert_newline)\n\n # Ctrl+J —— 在 Alt 被系统/窗口管理器吞掉时的兜底\n kb.add(\"c-j\")(_insert_newline)\n\n # Shift+Enter (CSI-u 编码): ESC [ 13 ; 2 u\n # prompt_toolkit 把 ESC 前缀解析为 \"escape\", 然后逐字符匹配余下字节\n kb.add(\"escape\", \"[\", \"1\", \"3\", \";\", \"2\", \"u\")(_insert_newline)\n\n # Shift+Enter (xterm modifyOtherKeys 编码): ESC [ 27 ; 2 ; 13 ~\n kb.add(\"escape\", \"[\", \"2\", \"7\", \";\", \"2\", \";\", \"1\", \"3\", \"~\")(_insert_newline)\n\n return kb\n\n\ndef _bottom_toolbar():\n \"\"\"底部常驻状态条:mode · phase · 快捷键提示。\"\"\"\n agent = _AGENT_REF.get(\"agent\")\n if agent is None:\n return \"\"\n if agent.review_mode:\n phase = agent.review_state.phase\n phase_text = {\n ReviewPhase.IDLE: \"待命\",\n ReviewPhase.DESIGN: \"设计\",\n ReviewPhase.PIPELINE: \"数据流\",\n ReviewPhase.IMPL: f\"实现×{len(agent.review_state.functions_approved)}\",\n ReviewPhase.COMPLETE: \"完成\",\n }.get(phase, \"\")\n mode_part = f\" ⚑ review · {phase_text} \"\n else:\n mode_part = \" general \"\n return (\n f\" mode:{mode_part}│ ⇧⇥ 切模式 │ / 命令 │ @ 文件 │ \"\n f\"⇧⏎ / ⌥⏎ 换行 │ {agent.model} \"\n )\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主聊天循环\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"运行聊天主循环。返回 True 表示重新配置,False 表示退出。\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\n\n Pauses any active Rich Live region so prompt_toolkit can own the\n terminal cleanly, and uses a dedicated modal PromptSession (no\n toolbar / completer / Shift+Tab binding) so keystrokes never get\n intercepted by the main input box's UX helpers.\n \"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = str(tool_input[key])\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n modal_pt = _INNER_PT_REF.get(\"session\") or pt\n with _LivePause():\n console.print()\n console.print(Panel(\n f\" 工具: [bold yellow]{tool_name}[/]\\n{arg_hint}\",\n title=\"🔒 需要确认\", title_align=\"left\", border_style=\"yellow\",\n ))\n try:\n answer = await modal_pt.prompt_async(\" 允许执行? [Y/n] › \")\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # 项目目录结构树\n DefinitionsTool(), # 提取文件中所有类/函数定义\n FileReadTool(), # 按行读取文件内容\n FileWriteTool(), # 创建/覆写文件\n FileEditTool(), # 精确查找替换编辑\n GlobTool(), # 按模式匹配文件路径\n GrepTool(), # 搜索文件内容\n BashTool(), # 执行 shell 命令\n ],\n )\n except Exception as e:\n render_error(f\"Agent 创建失败: {e}\")\n return True\n\n _AGENT_REF[\"agent\"] = agent\n\n # 启动信息\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(\"Provider\", config.provider_name)\n info.add_row(\"Model\", agent.model)\n info.add_row(\"Session ID\", agent.session.session_id[:12] + \"...\")\n info.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n info.add_row(\"Auto-compact\", \"✓ 开启\")\n if agent.transcript:\n info.add_row(\"Auto-save\", f\"✓ {agent.transcript.path}\")\n info.add_row(\"Git state\", \"✓ 注入\")\n info.add_row(\"Memory files\", \"✓ ENGRAM.md 自动发现\")\n info.add_row(\"Tools (读)\", \"✓ tree · definitions · read_file · glob · grep\")\n info.add_row(\"Tools (写)\", \"[yellow]✎[/] write_file · edit_file · bash\")\n info.add_row(\"权限系统\", \"✓ 读操作静默放行 · 写操作需确认 · 危险命令拒绝\")\n info.add_row(\"重试/降级\", \"✓ 指数退避 · 429/529重试 · max_tokens自动缩减\")\n info.add_row(\"文件缓存\", \"✓ LRU 100文件/25MB\")\n console.print(Panel(info, border_style=C_DIM, title=\"✓ 准备就绪\", title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(\n \" 输入消息后按 Enter 发送。\"\n \" [magenta]/help[/] 查看命令。\"\n \" [dim]Ctrl+D 或 /quit 退出。[/]\\n\"\n )\n\n _last_ctrl_c = False\n\n try:\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n cmd = user_input.lower()\n\n # ── Slash 命令 ────────────────────────────────────────────────────\n if cmd in (\"/quit\", \"/exit\", \"/q\"):\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n elif cmd == \"/help\":\n render_help(); continue\n\n elif cmd == \"/status\":\n render_status(agent); continue\n\n elif cmd.startswith(\"/mode\"):\n handle_mode(agent, user_input[5:].strip(), pt=pt); continue\n\n elif cmd == \"/compact\":\n await handle_compact(agent); continue\n\n elif cmd == \"/reset\":\n agent.reset()\n render_system(\"✓ 对话历史已清空。\", C_SUCCESS); continue\n\n elif cmd == \"/save\":\n await handle_save(agent, pt); continue\n\n elif cmd.startswith(\"/load\"):\n new = await handle_load(user_input[5:].strip(), config, pt)\n if new:\n agent = new\n _AGENT_REF[\"agent\"] = agent\n continue\n\n elif cmd == \"/resume\":\n new = await handle_resume(config, pt)\n if new:\n agent = new\n _AGENT_REF[\"agent\"] = agent\n continue\n\n elif cmd == \"/config\":\n render_system(\"返回配置向导…\", C_DIM)\n return True\n\n elif cmd == \"/forget\":\n if forget_config():\n render_system(\n f\"✓ 已删除本地保存的配置 ({_CONFIG_FILE})\", C_SUCCESS,\n )\n else:\n render_system(\"没有保存的配置需要删除。\", C_DIM)\n continue\n\n elif cmd.startswith(\"/\"):\n render_system(f\"未知命令: {user_input} 输入 /help 查看帮助\", C_WARN)\n continue\n\n # ── 正常聊天 ──────────────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n _last_ctrl_c = True\n render_system(\"已取消。(再按一次 Ctrl+C 退出)\", C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n finally:\n # Flush and close transcript on any exit path\n try:\n await agent.close()\n except Exception:\n pass\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主入口\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(\"Model-agnostic Coding Agent\", style=C_DIM)))\n console.print(Align.center(Text(\"用任意大模型读写和理解代码\", style=\"dim cyan\")))\n console.print()\n\n # Ensure config dir exists before FileHistory tries to open/append to it.\n try:\n _CONFIG_DIR.mkdir(parents=True, exist_ok=True)\n except Exception:\n pass\n\n pt = PromptSession(\n history=FileHistory(str(_CONFIG_DIR / \"history\")),\n style=PTStyle.from_dict({\n \"prompt\": \"ansicyan bold\",\n \"bottom-toolbar\": \"bg:#1a1a1a #888888\",\n \"completion-menu.completion\": \"bg:#1a1a1a #cccccc\",\n \"completion-menu.completion.current\": \"bg:#005f87 #ffffff bold\",\n \"completion-menu.meta.completion\": \"bg:#1a1a1a #707070 italic\",\n \"completion-menu.meta.completion.current\": \"bg:#005f87 #cfcfcf italic\",\n }),\n completer=SlashCompleter(),\n complete_while_typing=True,\n key_bindings=_make_key_bindings(),\n bottom_toolbar=_bottom_toolbar,\n refresh_interval=0.5,\n multiline=True, # Enter=submit, Alt+Enter/Ctrl+J=newline (see _make_key_bindings)\n )\n _PT_SESSION_REF[\"session\"] = pt\n\n # Inner prompt session — bare & separate, for modal prompts (permissions,\n # approvals). No toolbar, no completer, no Shift+Tab binding. This mirrors\n # src/'s pattern where permission dialogs use a dedicated Select component\n # with its own isolated input handling, so no keybindings leak in from\n # the main input box and no background refresh timer redraws over the prompt.\n inner_pt = PromptSession(\n history=None, # modal answers shouldn't pollute history\n style=PTStyle.from_dict({\"prompt\": \"ansiyellow bold\"}),\n )\n _INNER_PT_REF[\"session\"] = inner_pt\n\n # 如果命令行已经提供了完整参数,跳过向导\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n )\n await chat_loop(config, pt)\n return\n\n # 否则进入 配置 → 聊天 → 可重配置 的循环\n config: AgentConfig | None = None\n skip_saved_once = False # True on /config reconfigure: skip the \"use saved?\" prompt\n\n while True:\n # 优先复用已保存的配置,除非用户主动 /config\n saved = None if skip_saved_once else load_config()\n skip_saved_once = False\n\n if saved is not None:\n masked_key = (saved.api_key[:8] + \"...\" + saved.api_key[-4:]\n if len(saved.api_key) > 12 else \"****\")\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(\"Provider\", saved.provider_name)\n info.add_row(\"Model\", saved.model)\n if saved.base_url:\n info.add_row(\"Base URL\", saved.base_url)\n info.add_row(\"API Key\", masked_key)\n console.print(Panel(\n info, border_style=\"green\",\n title=\"✓ 检测到已保存的配置\", title_align=\"left\",\n ))\n try:\n raw = await pt.prompt_async(\n \" 直接使用? [Y/n] (n = 重新配置, f = 忘记并重新配置) › \"\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(\"已取消,退出。\", C_DIM)\n return\n choice = raw.strip().lower()\n if choice in (\"\", \"y\", \"yes\"):\n config = saved\n elif choice == \"f\":\n forget_config()\n render_system(\"✓ 已删除保存的配置。\", C_SUCCESS)\n config = None\n else:\n config = None # fall through to wizard\n\n if config is None:\n try:\n config = await setup_wizard(pt)\n save_config(config)\n render_system(\n f\"✓ 配置已保存到 {_CONFIG_FILE} (下次启动直接复用)\",\n C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(\"已取消,退出。\", C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(\" 重新配置\", style=C_BRAND))\n config = None\n skip_saved_once = True\n\n\ndef main() -> None:\n import argparse\n parser = argparse.ArgumentParser(description=\"Lumen — 代码读写 Agent\")\n parser.add_argument(\"--model\", default=None, help=\"模型名称,例如 gpt-4o\")\n parser.add_argument(\"--api-key\", default=None, help=\"API Key(覆盖环境变量)\")\n parser.add_argument(\"--base-url\", default=None, help=\"API Base URL\")\n args = parser.parse_args()\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "54286d05ceb6fdf9763c03ec829ebc5f8b6956df": {"t": 1785773580.8339047, "n": 32938, "h": "19664541fc58ec2d", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\n直接运行,在 UI 内选择模型和输入 API Key:\n python chat.py\n\n也支持命令行直接指定(跳过配置向导):\n python chat.py --model gpt-4o --api-key sk-...\n\n聊天中的 Slash 命令:\n /help 帮助\n /status Token 用量和会话信息\n /compact 手动压缩上下文\n /reset 清空对话历史\n /save 保存会话到文件\n /load 从文件加载会话\n /config 重新配置模型和 Key\n /quit 退出\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.history import InMemoryHistory\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import Agent, PermissionChecker, PermissionBehavior, HookRegistry, RetryConfig\nfrom lumen.context.transcript import TranscriptReader, list_recent_sessions\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\n\nconsole = Console()\n\n# ── 配色 ──────────────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model 目录\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str\n key_hint: str # API key 格式提示\n env_key: str | None # 从哪个环境变量读取 key\n base_url: str | None # None = 由 Agent 自动判断\n models: list[ModelOption]\n needs_key: bool = True\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · 最新最强\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"GetGoAPI (多模型代理)\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 最新最强综合\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 推理模型\"),\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 最强综合\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 快速省钱\"),\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · 最强综合\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · 最强能力\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · 快速省钱\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"Ollama (本地模型)\", key_hint=\"无需 Key\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"自定义 / 其他\", key_hint=\"你的 API Key\",\n env_key=None,\n base_url=None,\n models=[], # 用户自己输入\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 配置向导\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"交互式配置向导:选 Provider → 选模型 → 输入 Key。\"\"\"\n console.print()\n console.print(Rule(\" 配置模型\", style=C_BRAND))\n console.print()\n\n # ── 第一步:选 Provider ───────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key 已检测[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.name + detected, key_info)\n\n console.print(Panel(table, title=\"选择 Provider\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(f\" [yellow]请输入 1–{len(PROVIDERS)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(f\" [green]✓ 已选:[/]{provider.name}\\n\")\n\n # ── 第二步:选模型 ────────────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(\" Base URL (如 http://localhost:11434/v1) › \")).strip()\n model = (await pt.prompt_async(\" Model 名称 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(\"model 不能为空\")\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.desc)\n\n console.print(Panel(model_table, title=\"选择模型\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(f\" [yellow]请输入 1–{len(provider.models)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(f\" [green]✓ 已选:[/]{model}\\n\")\n\n # ── 第三步:输入 API Key ──────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(f\" [dim]本地模型无需 API Key,使用默认占位符[/]\\n\")\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n f\" 检测到环境变量 {provider.env_key} ({masked})\\n\"\n f\" 直接使用? [Y/n] › \"\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(\" [green]✓ 使用环境变量中的 Key[/]\\n\")\n else:\n api_key = await _prompt_key(pt, provider.key_hint)\n else:\n console.print(f\" [dim]格式参考:{provider.key_hint}[/]\")\n api_key = await _prompt_key(pt, provider.key_hint)\n\n # ── 汇总展示 ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(\"Provider\", provider.name)\n summary.add_row(\"Model\", model)\n if base_url:\n summary.add_row(\"Base URL\", base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(\"API Key\", key_display)\n console.print(Panel(summary, title=\"✓ 配置确认\", border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.name,\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"带密码掩码的 API Key 输入。\"\"\"\n while True:\n try:\n key = await pt.prompt_async(\" API Key › \")\n key = key.strip()\n if key:\n return key\n console.print(\" [yellow]Key 不能为空,请重新输入[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI 渲染组件\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(\" model: \", style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(\" │ msgs: \", style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(\" │ compactions: \", style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.code_reading_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(\"⚑ 代码深读\", style=\"bold cyan\")\n\n border = \"cyan\" if agent.code_reading_mode else C_DIM\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\ndef render_user_message(text: str) -> Panel:\n return Panel(\n Text(text, style=\"white\"),\n title=Text(\" You\", style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text: str, style: str = C_DIM) -> None:\n console.print(f\" {text}\", style=style)\n\n\ndef render_error(text: str) -> None:\n console.print(Panel(text, border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help() -> None:\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n for cmd, desc in [\n (\"/help\", \"显示帮助\"),\n (\"/status\", \"Token 用量和会话信息\"),\n (\"/mode\", \"切换模式:通用 ↔ 代码深读 (/mode code | /mode general)\"),\n (\"/compact\", \"手动压缩上下文\"),\n (\"/reset\", \"清空对话历史\"),\n (\"/save\", \"保存会话到文件\"),\n (\"/load\", \"从文件加载会话\"),\n (\"/resume\", \"恢复最近的自动保存会话\"),\n (\"/config\", \"重新配置模型和 Key\"),\n (\"/quit\", \"退出 (或 Ctrl+C / Ctrl+D)\"),\n (\"\", \"\"),\n (\"↑ / ↓\", \"浏览输入历史\"),\n (\"Ctrl+C\", \"取消当前输入 / 再按一次退出\"),\n ]:\n table.add_row(cmd, desc)\n console.print(Panel(table, title=\"命令列表\", border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(\"Model\", agent.model)\n table.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n table.add_row(\"Tokens used\", f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(\"Messages\", str(len(agent.messages)))\n table.add_row(\"Compactions\", str(agent.session.compaction_count))\n table.add_row(\"Session start\", agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=\"Session Status\", border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent 响应\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n 执行一次完整的 Agent 响应:工具调用过程实时展示,最终答案 Markdown 渲染。\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(\" Lumen ● thinking…\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── 有工具时用 Live 面板展示进度 ──────────────────────────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n tool_log.append(\" [dim]正在思考…[/]\")\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n await _chat_with_refresh()\n else:\n # 无工具时走流式输出\n collected: list[str] = []\n panel_title = Text(\" Lumen ● streaming…\", style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\"\".join(collected), code_theme=\"monokai\", inline_code_theme=\"monokai\")\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n return \"\".join(collected)\n\n # ── 渲染最终答案 ───────────────────────────────────────────────────────────\n final_text = response.content if response else \"\"\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(\" Lumen\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 模式切换\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n mode = \"代码深读模式 [Code Reading Mode]\" if agent.code_reading_mode else \"通用模式 [General]\"\n console.print(Panel(\n f\" 当前模式: [bold cyan]{mode}[/]\\n\\n\"\n \" [dim]/mode code[/] → 开启代码深读模式\\n\"\n \" [dim]/mode general[/] → 切回通用模式\",\n border_style=C_DIM, title=\"模式\", title_align=\"left\",\n ))\n return\n\n if arg in (\"code\", \"c\", \"read\", \"深读\"):\n if agent.code_reading_mode:\n render_system(\"已经是代码深读模式。\", C_DIM)\n else:\n agent.enable_code_reading_mode()\n console.print(Panel(\n \"[bold cyan]代码深读模式已开启[/]\\n\\n\"\n \" 模型现在会:\\n\"\n \" · 永远先用工具读代码,再回答 (不猜测)\\n\"\n \" · 每次解释覆盖 WHAT / HOW / WHY / WHERE 四个维度\\n\"\n \" · 自动追踪调用链并标注 file:line\\n\"\n \" · 先讲架构全貌,再讲实现细节\\n\\n\"\n \" [dim]通用能力(写代码、回答问题等)仍然完整保留。[/]\",\n border_style=\"cyan\", title=\"✦ Code Reading Mode\", title_align=\"left\",\n ))\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n if not agent.code_reading_mode:\n render_system(\"已经是通用模式。\", C_DIM)\n else:\n agent.disable_code_reading_mode()\n render_system(\"✓ 已切回通用模式。\", C_SUCCESS)\n\n else:\n render_system(\n f\"未知模式 '{arg}'。用 /mode code 或 /mode general。\", C_WARN\n )\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 命令处理\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(\"Compacting context…\", C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n f\"[green]✓ 压缩完成[/]\\n\\n\"\n f\" 消息数 : [white]{r.messages_before}[/] → [white]{r.messages_after}[/]\\n\"\n f\" Tokens : [white]{r.tokens_before:,}[/] → [white]{r.tokens_after:,}[/]\\n\"\n f\" 保留最近 [white]{r.kept_recent_count}[/] 条消息原文\",\n border_style=\"green\", title=\"压缩结果\", title_align=\"left\",\n ))\n except Exception as e:\n render_error(f\"压缩失败: {e}\")\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(f\" 保存到 [{default}] › \", default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(f\"✓ 已保存 → {path}\", C_SUCCESS)\n if agent.transcript:\n render_system(\n f\" (会话也在自动保存到 {agent.transcript.path})\", C_DIM,\n )\n except (KeyboardInterrupt, EOFError):\n render_system(\"已取消\", C_DIM)\n except Exception as e:\n render_error(f\"保存失败: {e}\")\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(\" 加载文件路径 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(f\"文件不存在: {arg!r}\")\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(f\"✓ 已加载 {len(agent.messages)} 条消息 from {arg}\", C_SUCCESS)\n return agent\n except Exception as e:\n render_error(f\"加载失败: {e}\")\n return None\n\n\nasync def handle_resume(config: AgentConfig, pt: PromptSession) -> Agent | None:\n \"\"\"List recent auto-saved sessions and let the user pick one to resume.\"\"\"\n cwd = Path.cwd()\n sessions = list_recent_sessions(cwd, limit=10)\n if not sessions:\n render_system(\"没有找到自动保存的会话。\", C_WARN)\n return None\n\n table = Table(box=box.SIMPLE, show_header=True, padding=(0, 1))\n table.add_column(\"#\", style=C_NUM, width=4)\n table.add_column(\"Session ID\", style=\"white\")\n table.add_column(\"Model\", style=C_DIM)\n table.add_column(\"Messages\", style=\"white\", justify=\"right\")\n table.add_column(\"Created\", style=C_DIM)\n table.add_column(\"Last prompt\", style=\"white\")\n\n for i, s in enumerate(sessions, 1):\n created = s.created_at[:19].replace(\"T\", \" \") if s.created_at else \"?\"\n prompt_preview = s.last_prompt[:50] + (\"...\" if len(s.last_prompt) > 50 else \"\")\n table.add_row(\n f\"[{i}]\",\n s.session_id[:12] + \"...\",\n s.model or \"?\",\n str(s.message_count),\n created,\n prompt_preview,\n )\n\n console.print(Panel(table, title=\"最近的自动保存会话\", border_style=C_DIM))\n\n try:\n raw = await pt.prompt_async(\" 输入编号恢复 (或 Enter 取消) › \")\n raw = raw.strip()\n if not raw:\n return None\n idx = int(raw) - 1\n if not (0 <= idx < len(sessions)):\n render_system(f\"请输入 1-{len(sessions)} 之间的数字\", C_WARN)\n return None\n except (ValueError, KeyboardInterrupt, EOFError):\n return None\n\n chosen = sessions[idx]\n try:\n messages, metadata = TranscriptReader.load_session(chosen.file_path)\n if not messages:\n render_error(\"会话文件为空。\")\n return None\n\n # Rebuild a Session from the transcript messages\n from lumen.context.session import Session\n session = Session(model=config.model)\n session.session_id = chosen.session_id\n session.messages = messages\n\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n session=session,\n auto_compact=True,\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n FileWriteTool(), FileEditTool(),\n GlobTool(), GrepTool(), BashTool(),\n ],\n )\n render_system(\n f\"✓ 已恢复会话 {chosen.session_id[:12]}... \"\n f\"({len(messages)} 条消息)\",\n C_SUCCESS,\n )\n return agent\n except Exception as e:\n render_error(f\"恢复失败: {e}\")\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主聊天循环\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"运行聊天主循环。返回 True 表示重新配置,False 表示退出。\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = str(tool_input[key])\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n console.print()\n console.print(Panel(\n f\" 工具: [bold yellow]{tool_name}[/]\\n{arg_hint}\",\n title=\"🔒 需要确认\", title_align=\"left\", border_style=\"yellow\",\n ))\n try:\n answer = await pt.prompt_async(\" 允许执行? [Y/n] › \")\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # 项目目录结构树\n DefinitionsTool(), # 提取文件中所有类/函数定义\n FileReadTool(), # 按行读取文件内容\n FileWriteTool(), # 创建/覆写文件\n FileEditTool(), # 精确查找替换编辑\n GlobTool(), # 按模式匹配文件路径\n GrepTool(), # 搜索文件内容\n BashTool(), # 执行 shell 命令\n ],\n )\n except Exception as e:\n render_error(f\"Agent 创建失败: {e}\")\n return True\n\n # 启动信息\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(\"Provider\", config.provider_name)\n info.add_row(\"Model\", agent.model)\n info.add_row(\"Session ID\", agent.session.session_id[:12] + \"...\")\n info.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n info.add_row(\"Auto-compact\", \"✓ 开启\")\n if agent.transcript:\n info.add_row(\"Auto-save\", f\"✓ {agent.transcript.path}\")\n info.add_row(\"Git state\", \"✓ 注入\")\n info.add_row(\"Memory files\", \"✓ ENGRAM.md 自动发现\")\n info.add_row(\"Tools (读)\", \"✓ tree · definitions · read_file · glob · grep\")\n info.add_row(\"Tools (写)\", \"[yellow]✎[/] write_file · edit_file · bash\")\n info.add_row(\"权限系统\", \"✓ 读操作静默放行 · 写操作需确认 · 危险命令拒绝\")\n info.add_row(\"重试/降级\", \"✓ 指数退避 · 429/529重试 · max_tokens自动缩减\")\n info.add_row(\"文件缓存\", \"✓ LRU 100文件/25MB\")\n console.print(Panel(info, border_style=C_DIM, title=\"✓ 准备就绪\", title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(\n \" 输入消息后按 Enter 发送。\"\n \" [magenta]/help[/] 查看命令。\"\n \" [dim]Ctrl+D 或 /quit 退出。[/]\\n\"\n )\n\n _last_ctrl_c = False\n\n try:\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n cmd = user_input.lower()\n\n # ── Slash 命令 ────────────────────────────────────────────────────\n if cmd in (\"/quit\", \"/exit\", \"/q\"):\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n elif cmd == \"/help\":\n render_help(); continue\n\n elif cmd == \"/status\":\n render_status(agent); continue\n\n elif cmd.startswith(\"/mode\"):\n handle_mode(agent, user_input[5:].strip()); continue\n\n elif cmd == \"/compact\":\n await handle_compact(agent); continue\n\n elif cmd == \"/reset\":\n agent.reset()\n render_system(\"✓ 对话历史已清空。\", C_SUCCESS); continue\n\n elif cmd == \"/save\":\n await handle_save(agent, pt); continue\n\n elif cmd.startswith(\"/load\"):\n new = await handle_load(user_input[5:].strip(), config, pt)\n if new:\n agent = new\n continue\n\n elif cmd == \"/resume\":\n new = await handle_resume(config, pt)\n if new:\n agent = new\n continue\n\n elif cmd == \"/config\":\n render_system(\"返回配置向导…\", C_DIM)\n return True\n\n elif cmd.startswith(\"/\"):\n render_system(f\"未知命令: {user_input} 输入 /help 查看帮助\", C_WARN)\n continue\n\n # ── 正常聊天 ──────────────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n _last_ctrl_c = True\n render_system(\"已取消。(再按一次 Ctrl+C 退出)\", C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n finally:\n # Flush and close transcript on any exit path\n try:\n await agent.close()\n except Exception:\n pass\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主入口\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(\"Model-agnostic Coding Agent\", style=C_DIM)))\n console.print(Align.center(Text(\"用任意大模型读写和理解代码\", style=\"dim cyan\")))\n console.print()\n\n pt = PromptSession(\n history=InMemoryHistory(),\n style=PTStyle.from_dict({\"prompt\": \"ansicyan bold\"}),\n )\n\n # 如果命令行已经提供了完整参数,跳过向导\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n )\n await chat_loop(config, pt)\n return\n\n # 否则进入 配置 → 聊天 → 可重配置 的循环\n config: AgentConfig | None = None\n\n while True:\n try:\n config = await setup_wizard(pt)\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(\"已取消,退出。\", C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(\" 重新配置\", style=C_BRAND))\n\n\ndef main() -> None:\n import argparse\n parser = argparse.ArgumentParser(description=\"Lumen — 代码读写 Agent\")\n parser.add_argument(\"--model\", default=None, help=\"模型名称,例如 gpt-4o\")\n parser.add_argument(\"--api-key\", default=None, help=\"API Key(覆盖环境变量)\")\n parser.add_argument(\"--base-url\", default=None, help=\"API Base URL\")\n args = parser.parse_args()\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "62a0a1cf3425df5f18f1985d05bbadc5b57470bc": {"t": 1785773584.3797739, "n": 29302, "h": "f51fc12e7ceaa9d1", "c": "\"\"\"\nLumen — Interactive Coding Agent\n\n直接运行,在 UI 内选择模型和输入 API Key:\n python chat.py\n\n也支持命令行直接指定(跳过配置向导):\n python chat.py --model gpt-4o --api-key sk-...\n\n聊天中的 Slash 命令:\n /help 帮助\n /status Token 用量和会话信息\n /compact 手动压缩上下文\n /reset 清空对话历史\n /save 保存会话到文件\n /load 从文件加载会话\n /config 重新配置模型和 Key\n /quit 退出\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.history import InMemoryHistory\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import Agent, PermissionChecker, PermissionBehavior, HookRegistry, RetryConfig\nfrom lumen.services.permissions import PermissionRule\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool,\n)\n\nconsole = Console()\n\n# ── 配色 ──────────────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model 目录\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str\n key_hint: str # API key 格式提示\n env_key: str | None # 从哪个环境变量读取 key\n base_url: str | None # None = 由 Agent 自动判断\n models: list[ModelOption]\n needs_key: bool = True\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 1M ctx · 最新最强\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 1M ctx · 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 1M ctx · 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"getgoapi\", name=\"GetGoAPI (多模型代理)\", key_hint=\"sk-...\",\n env_key=\"GETGOAPI_API_KEY\",\n base_url=\"https://api.getgoapi.com/v1\",\n models=[\n ModelOption(\"gpt-4.1-2025-04-14\", \"GPT-4.1 最新最强综合\"),\n ModelOption(\"gpt-4.1-mini-2025-04-14\", \"GPT-4.1 Mini 快速省钱\"),\n ModelOption(\"gpt-4.1-nano-2025-04-14\", \"GPT-4.1 Nano 极速超省\"),\n ModelOption(\"gpt-4o\", \"GPT-4o 综合能力\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 推理模型\"),\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 最强综合\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 快速省钱\"),\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · 最强综合\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · 最强能力\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · 快速省钱\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"Ollama (本地模型)\", key_hint=\"无需 Key\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"自定义 / 其他\", key_hint=\"你的 API Key\",\n env_key=None,\n base_url=None,\n models=[], # 用户自己输入\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 配置向导\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"交互式配置向导:选 Provider → 选模型 → 输入 Key。\"\"\"\n console.print()\n console.print(Rule(\" 配置模型\", style=C_BRAND))\n console.print()\n\n # ── 第一步:选 Provider ───────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key 已检测[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.name + detected, key_info)\n\n console.print(Panel(table, title=\"选择 Provider\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(f\" [yellow]请输入 1–{len(PROVIDERS)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(f\" [green]✓ 已选:[/]{provider.name}\\n\")\n\n # ── 第二步:选模型 ────────────────────────────────────────────────────────\n if provider.id == \"custom\":\n try:\n base_url = (await pt.prompt_async(\" Base URL (如 http://localhost:11434/v1) › \")).strip()\n model = (await pt.prompt_async(\" Model 名称 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(\"model 不能为空\")\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.desc)\n\n console.print(Panel(model_table, title=\"选择模型\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(f\" [yellow]请输入 1–{len(provider.models)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(f\" [green]✓ 已选:[/]{model}\\n\")\n\n # ── 第三步:输入 API Key ──────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(f\" [dim]本地模型无需 API Key,使用默认占位符[/]\\n\")\n else:\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n f\" 检测到环境变量 {provider.env_key} ({masked})\\n\"\n f\" 直接使用? [Y/n] › \"\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(\" [green]✓ 使用环境变量中的 Key[/]\\n\")\n else:\n api_key = await _prompt_key(pt, provider.key_hint)\n else:\n console.print(f\" [dim]格式参考:{provider.key_hint}[/]\")\n api_key = await _prompt_key(pt, provider.key_hint)\n\n # ── 汇总展示 ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(\"Provider\", provider.name)\n summary.add_row(\"Model\", model)\n if base_url:\n summary.add_row(\"Base URL\", base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(\"API Key\", key_display)\n console.print(Panel(summary, title=\"✓ 配置确认\", border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.name,\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"带密码掩码的 API Key 输入。\"\"\"\n while True:\n try:\n key = await pt.prompt_async(\" API Key › \")\n key = key.strip()\n if key:\n return key\n console.print(\" [yellow]Key 不能为空,请重新输入[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI 渲染组件\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(\" model: \", style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(\" │ msgs: \", style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(\" │ compactions: \", style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.code_reading_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(\"⚑ 代码深读\", style=\"bold cyan\")\n\n border = \"cyan\" if agent.code_reading_mode else C_DIM\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\ndef render_user_message(text: str) -> Panel:\n return Panel(\n Text(text, style=\"white\"),\n title=Text(\" You\", style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text: str, style: str = C_DIM) -> None:\n console.print(f\" {text}\", style=style)\n\n\ndef render_error(text: str) -> None:\n console.print(Panel(text, border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help() -> None:\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n for cmd, desc in [\n (\"/help\", \"显示帮助\"),\n (\"/status\", \"Token 用量和会话信息\"),\n (\"/mode\", \"切换模式:通用 ↔ 代码深读 (/mode code | /mode general)\"),\n (\"/compact\", \"手动压缩上下文\"),\n (\"/reset\", \"清空对话历史\"),\n (\"/save\", \"保存会话到文件\"),\n (\"/load\", \"从文件加载会话\"),\n (\"/config\", \"重新配置模型和 Key\"),\n (\"/quit\", \"退出 (或 Ctrl+C / Ctrl+D)\"),\n (\"\", \"\"),\n (\"↑ / ↓\", \"浏览输入历史\"),\n (\"Ctrl+C\", \"取消当前输入 / 再按一次退出\"),\n ]:\n table.add_row(cmd, desc)\n console.print(Panel(table, title=\"命令列表\", border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(\"Model\", agent.model)\n table.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n table.add_row(\"Tokens used\", f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(\"Messages\", str(len(agent.messages)))\n table.add_row(\"Compactions\", str(agent.session.compaction_count))\n table.add_row(\"Session start\", agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=\"Session Status\", border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Agent 响应\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n 执行一次完整的 Agent 响应:工具调用过程实时展示,最终答案 Markdown 渲染。\n \"\"\"\n tool_log: list[str] = []\n\n def _tool_panel() -> Panel:\n lines = Text()\n for entry in tool_log[-15:]:\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(\" Lumen ● thinking…\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n # Mark write/edit tools specially\n if name in (\"write_file\", \"edit_file\"):\n tool_log.append(f\" [bold yellow]✎ {name}[/]{arg_hint}\")\n else:\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── 有工具时用 Live 面板展示进度 ──────────────────────────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True,\n ) as live:\n tool_log.append(\" [dim]正在思考…[/]\")\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n await _chat_with_refresh()\n else:\n # 无工具时走流式输出\n collected: list[str] = []\n panel_title = Text(\" Lumen ● streaming…\", style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\"\".join(collected), code_theme=\"monokai\", inline_code_theme=\"monokai\")\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n return \"\".join(collected)\n\n # ── 渲染最终答案 ───────────────────────────────────────────────────────────\n final_text = response.content if response else \"\"\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(\" Lumen\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 模式切换\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str) -> None:\n arg = arg.strip().lower()\n\n if not arg:\n mode = \"代码深读模式 [Code Reading Mode]\" if agent.code_reading_mode else \"通用模式 [General]\"\n console.print(Panel(\n f\" 当前模式: [bold cyan]{mode}[/]\\n\\n\"\n \" [dim]/mode code[/] → 开启代码深读模式\\n\"\n \" [dim]/mode general[/] → 切回通用模式\",\n border_style=C_DIM, title=\"模式\", title_align=\"left\",\n ))\n return\n\n if arg in (\"code\", \"c\", \"read\", \"深读\"):\n if agent.code_reading_mode:\n render_system(\"已经是代码深读模式。\", C_DIM)\n else:\n agent.enable_code_reading_mode()\n console.print(Panel(\n \"[bold cyan]代码深读模式已开启[/]\\n\\n\"\n \" 模型现在会:\\n\"\n \" · 永远先用工具读代码,再回答 (不猜测)\\n\"\n \" · 每次解释覆盖 WHAT / HOW / WHY / WHERE 四个维度\\n\"\n \" · 自动追踪调用链并标注 file:line\\n\"\n \" · 先讲架构全貌,再讲实现细节\\n\\n\"\n \" [dim]通用能力(写代码、回答问题等)仍然完整保留。[/]\",\n border_style=\"cyan\", title=\"✦ Code Reading Mode\", title_align=\"left\",\n ))\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n if not agent.code_reading_mode:\n render_system(\"已经是通用模式。\", C_DIM)\n else:\n agent.disable_code_reading_mode()\n render_system(\"✓ 已切回通用模式。\", C_SUCCESS)\n\n else:\n render_system(\n f\"未知模式 '{arg}'。用 /mode code 或 /mode general。\", C_WARN\n )\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 命令处理\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(\"Compacting context…\", C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n f\"[green]✓ 压缩完成[/]\\n\\n\"\n f\" 消息数 : [white]{r.messages_before}[/] → [white]{r.messages_after}[/]\\n\"\n f\" Tokens : [white]{r.tokens_before:,}[/] → [white]{r.tokens_after:,}[/]\\n\"\n f\" 保留最近 [white]{r.kept_recent_count}[/] 条消息原文\",\n border_style=\"green\", title=\"压缩结果\", title_align=\"left\",\n ))\n except Exception as e:\n render_error(f\"压缩失败: {e}\")\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(f\" 保存到 [{default}] › \", default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(f\"✓ 已保存 → {path}\", C_SUCCESS)\n except (KeyboardInterrupt, EOFError):\n render_system(\"已取消\", C_DIM)\n except Exception as e:\n render_error(f\"保存失败: {e}\")\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(\" 加载文件路径 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(f\"文件不存在: {arg!r}\")\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(f\"✓ 已加载 {len(agent.messages)} 条消息 from {arg}\", C_SUCCESS)\n return agent\n except Exception as e:\n render_error(f\"加载失败: {e}\")\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主聊天循环\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"运行聊天主循环。返回 True 表示重新配置,False 表示退出。\"\"\"\n\n # ── Permission system: ask user for write/risky operations ────────────\n async def ask_permission(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Interactive permission prompt for write/risky tools.\"\"\"\n arg_hint = \"\"\n for key in (\"file_path\", \"command\", \"pattern\"):\n if key in tool_input:\n val = str(tool_input[key])\n arg_hint = f\" {val[:80]}{'…' if len(val)>80 else ''}\"\n break\n\n console.print()\n console.print(Panel(\n f\" 工具: [bold yellow]{tool_name}[/]\\n{arg_hint}\",\n title=\"🔒 需要确认\", title_align=\"left\", border_style=\"yellow\",\n ))\n try:\n answer = await pt.prompt_async(\" 允许执行? [Y/n] › \")\n return answer.strip().lower() in (\"\", \"y\", \"yes\")\n except (KeyboardInterrupt, EOFError):\n return False\n\n permission_checker = PermissionChecker(ask_fn=ask_permission)\n\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n permission_checker=permission_checker,\n retry_config=RetryConfig(max_retries=10, fallback_model=None),\n enable_file_cache=True,\n tools=[\n TreeTool(), # 项目目录结构树\n DefinitionsTool(), # 提取文件中所有类/函数定义\n FileReadTool(), # 按行读取文件内容\n FileWriteTool(), # 创建/覆写文件\n FileEditTool(), # 精确查找替换编辑\n GlobTool(), # 按模式匹配文件路径\n GrepTool(), # 搜索文件内容\n BashTool(), # 执行 shell 命令\n ],\n )\n except Exception as e:\n render_error(f\"Agent 创建失败: {e}\")\n return True\n\n # 启动信息\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(\"Provider\", config.provider_name)\n info.add_row(\"Model\", agent.model)\n info.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n info.add_row(\"Auto-compact\", \"✓ 开启\")\n info.add_row(\"Git state\", \"✓ 注入\")\n info.add_row(\"Memory files\", \"✓ ENGRAM.md 自动发现\")\n info.add_row(\"Tools (读)\", \"✓ tree · definitions · read_file · glob · grep\")\n info.add_row(\"Tools (写)\", \"[yellow]✎[/] write_file · edit_file · bash\")\n info.add_row(\"权限系统\", \"✓ 读操作静默放行 · 写操作需确认 · 危险命令拒绝\")\n info.add_row(\"重试/降级\", \"✓ 指数退避 · 429/529重试 · max_tokens自动缩减\")\n info.add_row(\"文件缓存\", \"✓ LRU 100文件/25MB\")\n console.print(Panel(info, border_style=C_DIM, title=\"✓ 准备就绪\", title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(\n \" 输入消息后按 Enter 发送。\"\n \" [magenta]/help[/] 查看命令。\"\n \" [dim]Ctrl+D 或 /quit 退出。[/]\\n\"\n )\n\n _last_ctrl_c = False\n\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n cmd = user_input.lower()\n\n # ── Slash 命令 ────────────────────────────────────────────────────────\n if cmd in (\"/quit\", \"/exit\", \"/q\"):\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n elif cmd == \"/help\":\n render_help(); continue\n\n elif cmd == \"/status\":\n render_status(agent); continue\n\n elif cmd.startswith(\"/mode\"):\n handle_mode(agent, user_input[5:].strip()); continue\n\n elif cmd == \"/compact\":\n await handle_compact(agent); continue\n\n elif cmd == \"/reset\":\n agent.reset()\n render_system(\"✓ 对话历史已清空。\", C_SUCCESS); continue\n\n elif cmd == \"/save\":\n await handle_save(agent, pt); continue\n\n elif cmd.startswith(\"/load\"):\n new = await handle_load(user_input[5:].strip(), config, pt)\n if new:\n agent = new\n continue\n\n elif cmd == \"/config\":\n render_system(\"返回配置向导…\", C_DIM)\n return True\n\n elif cmd.startswith(\"/\"):\n render_system(f\"未知命令: {user_input} 输入 /help 查看帮助\", C_WARN)\n continue\n\n # ── 正常聊天 ──────────────────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n try:\n agent.reset_abort() # Clear any previous abort state\n await agent_response(agent, user_input)\n console.print()\n except KeyboardInterrupt:\n agent.abort(\"User pressed Ctrl+C\")\n if _last_ctrl_c:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n _last_ctrl_c = True\n render_system(\"已取消。(再按一次 Ctrl+C 退出)\", C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"LUMEN_DEBUG\"):\n traceback.print_exc()\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主入口\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(\"Model-agnostic Coding Agent\", style=C_DIM)))\n console.print(Align.center(Text(\"用任意大模型读写和理解代码\", style=\"dim cyan\")))\n console.print()\n\n pt = PromptSession(\n history=InMemoryHistory(),\n style=PTStyle.from_dict({\"prompt\": \"ansicyan bold\"}),\n )\n\n # 如果命令行已经提供了完整参数,跳过向导\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n )\n await chat_loop(config, pt)\n return\n\n # 否则进入 配置 → 聊天 → 可重配置 的循环\n config: AgentConfig | None = None\n\n while True:\n try:\n config = await setup_wizard(pt)\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(\"已取消,退出。\", C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(\" 重新配置\", style=C_BRAND))\n\n\ndef main() -> None:\n import argparse\n parser = argparse.ArgumentParser(description=\"Lumen — 代码读写 Agent\")\n parser.add_argument(\"--model\", default=None, help=\"模型名称,例如 gpt-4o\")\n parser.add_argument(\"--api-key\", default=None, help=\"API Key(覆盖环境变量)\")\n parser.add_argument(\"--base-url\", default=None, help=\"API Base URL\")\n args = parser.parse_args()\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}, "241305336e85b9b61847f390d51889b81eb0b031": {"t": 1785773585.9238725, "n": 26896, "h": "185f7dce78ea9f8c", "c": "\"\"\"\nLumen — Interactive Code Reading Agent\n\n直接运行,在 UI 内选择模型和输入 API Key:\n python chat.py\n\n也支持命令行直接指定(跳过配置向导):\n python chat.py --model gpt-4o --api-key sk-...\n\n聊天中的 Slash 命令:\n /help 帮助\n /status Token 用量和会话信息\n /compact 手动压缩上下文\n /reset 清空对话历史\n /save 保存会话到文件\n /load 从文件加载会话\n /config 重新配置模型和 Key\n /quit 退出\n\"\"\"\n\nfrom __future__ import annotations\n\nimport asyncio\nimport os\nimport sys\nimport traceback\nfrom dataclasses import dataclass\nfrom datetime import datetime\nfrom pathlib import Path\nfrom typing import Optional\n\nsys.path.insert(0, str(Path(__file__).parent))\n\nfrom rich import box\nfrom rich.align import Align\nfrom rich.columns import Columns\nfrom rich.console import Console\nfrom rich.live import Live\nfrom rich.markdown import Markdown\nfrom rich.panel import Panel\nfrom rich.progress import BarColumn, Progress, TextColumn\nfrom rich.rule import Rule\nfrom rich.table import Table\nfrom rich.text import Text\n\nfrom prompt_toolkit import PromptSession\nfrom prompt_toolkit.history import InMemoryHistory\nfrom prompt_toolkit.styles import Style as PTStyle\n\nfrom lumen import Agent\nfrom lumen.tools import FileReadTool, GlobTool, GrepTool, BashTool, TreeTool, DefinitionsTool\n\nconsole = Console()\n\n# ── 配色 ──────────────────────────────────────────────────────────────────────\nC_BRAND = \"bold cyan\"\nC_USER = \"bold green\"\nC_AGENT = \"bold blue\"\nC_DIM = \"dim white\"\nC_WARN = \"bold yellow\"\nC_SUCCESS = \"bold green\"\nC_CMD = \"bold magenta\"\nC_NUM = \"bold cyan\"\n\nBANNER = r\"\"\"\n ██╗ ██╗ ██╗███╗ ███╗███████╗███╗ ██╗\n ██║ ██║ ██║████╗ ████║██╔════╝████╗ ██║\n ██║ ██║ ██║██╔████╔██║█████╗ ██╔██╗ ██║\n ██║ ██║ ██║██║╚██╔╝██║██╔══╝ ██║╚██╗██║\n ███████╗╚██████╔╝██║ ╚═╝ ██║███████╗██║ ╚████║\n ╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝\n\"\"\"\n\n# ─────────────────────────────────────────────────────────────────────────────\n# Provider / Model 目录\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass ModelOption:\n id: str\n desc: str\n\n@dataclass\nclass ProviderOption:\n id: str\n name: str\n key_hint: str # API key 格式提示\n env_key: str | None # 从哪个环境变量读取 key\n base_url: str | None # None = 由 Agent 自动判断\n models: list[ModelOption]\n needs_key: bool = True\n\nPROVIDERS: list[ProviderOption] = [\n ProviderOption(\n id=\"openai\", name=\"OpenAI\", key_hint=\"sk-proj-...\",\n env_key=\"OPENAI_API_KEY\",\n base_url=\"https://api.openai.com/v1\",\n models=[\n ModelOption(\"gpt-4o\", \"GPT-4o 128K ctx · 最强综合\"),\n ModelOption(\"gpt-4o-mini\", \"GPT-4o Mini 128K ctx · 快速省钱\"),\n ModelOption(\"o3-mini\", \"o3-mini 200K ctx · 推理模型\"),\n ModelOption(\"o1\", \"o1 200K ctx · 高级推理\"),\n ],\n ),\n ProviderOption(\n id=\"anthropic\", name=\"Anthropic (Claude)\", key_hint=\"sk-ant-...\",\n env_key=\"ANTHROPIC_API_KEY\",\n base_url=None,\n models=[\n ModelOption(\"claude-sonnet-4-6\", \"Claude Sonnet 4.6 200K ctx · 最强综合\"),\n ModelOption(\"claude-opus-4-6\", \"Claude Opus 4.6 200K ctx · 最强能力\"),\n ModelOption(\"claude-haiku-4-5\", \"Claude Haiku 4.5 200K ctx · 快速省钱\"),\n ModelOption(\"claude-3-5-sonnet-20241022\", \"Claude 3.5 Sonnet 200K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"deepseek\", name=\"DeepSeek\", key_hint=\"sk-...\",\n env_key=\"DEEPSEEK_API_KEY\",\n base_url=\"https://api.deepseek.com/v1\",\n models=[\n ModelOption(\"deepseek-chat\", \"DeepSeek Chat 64K ctx · 通用对话\"),\n ModelOption(\"deepseek-reasoner\", \"DeepSeek Reasoner 64K ctx · 推理模型\"),\n ],\n ),\n ProviderOption(\n id=\"ollama\", name=\"Ollama (本地模型)\", key_hint=\"无需 Key\",\n env_key=None,\n base_url=\"http://localhost:11434/v1\",\n needs_key=False,\n models=[\n ModelOption(\"llama3.1\", \"Llama 3.1 128K ctx\"),\n ModelOption(\"llama3.2\", \"Llama 3.2 128K ctx\"),\n ModelOption(\"qwen2.5\", \"Qwen 2.5 128K ctx\"),\n ModelOption(\"mistral\", \"Mistral 32K ctx\"),\n ModelOption(\"deepseek-r1\", \"DeepSeek R1 64K ctx\"),\n ModelOption(\"phi3\", \"Phi-3 128K ctx\"),\n ],\n ),\n ProviderOption(\n id=\"custom\", name=\"自定义 / 其他\", key_hint=\"你的 API Key\",\n env_key=None,\n base_url=None,\n models=[], # 用户自己输入\n ),\n]\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 配置向导\n# ─────────────────────────────────────────────────────────────────────────────\n\n@dataclass\nclass AgentConfig:\n api_key: str\n model: str\n base_url: str | None\n provider_name: str\n\nasync def setup_wizard(pt: PromptSession) -> AgentConfig:\n \"\"\"\n 交互式配置向导:选 Provider → 选模型 → 输入 Key。\n 返回 AgentConfig,失败时抛出 KeyboardInterrupt。\n \"\"\"\n console.print()\n console.print(Rule(\" 配置模型\", style=C_BRAND))\n console.print()\n\n # ── 第一步:选 Provider ───────────────────────────────────────────────────\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n table.add_column(style=C_NUM, width=4)\n table.add_column(style=\"white\")\n table.add_column(style=C_DIM)\n\n for i, p in enumerate(PROVIDERS, 1):\n key_info = f\"env: {p.env_key}\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n detected = f\" [green]✓ Key 已检测[/]\" if p.env_key and os.environ.get(p.env_key or \"\") else \"\"\n table.add_row(f\"[{i}]\", p.name + detected, key_info)\n\n console.print(Panel(table, title=\"选择 Provider\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(PROVIDERS):\n provider = PROVIDERS[idx]\n break\n console.print(f\" [yellow]请输入 1–{len(PROVIDERS)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n console.print(f\" [green]✓ 已选:[/]{provider.name}\\n\")\n\n # ── 第二步:选模型 ────────────────────────────────────────────────────────\n if provider.id == \"custom\":\n # 自定义:手动输入 base_url 和 model\n try:\n base_url = (await pt.prompt_async(\" Base URL (如 http://localhost:11434/v1) › \")).strip()\n model = (await pt.prompt_async(\" Model 名称 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n if not model:\n raise ValueError(\"model 不能为空\")\n else:\n model_table = Table(box=box.SIMPLE, show_header=False, padding=(0, 1))\n model_table.add_column(style=C_NUM, width=4)\n model_table.add_column(style=\"white\")\n for i, m in enumerate(provider.models, 1):\n model_table.add_row(f\"[{i}]\", m.desc)\n\n console.print(Panel(model_table, title=\"选择模型\", border_style=C_DIM))\n\n while True:\n try:\n raw = await pt.prompt_async(\" 输入编号 › \")\n idx = int(raw.strip()) - 1\n if 0 <= idx < len(provider.models):\n model = provider.models[idx].id\n break\n console.print(f\" [yellow]请输入 1–{len(provider.models)} 之间的数字[/]\")\n except ValueError:\n console.print(\" [yellow]请输入数字[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n base_url = provider.base_url\n\n console.print(f\" [green]✓ 已选:[/]{model}\\n\")\n\n # ── 第三步:输入 API Key ──────────────────────────────────────────────────\n if not provider.needs_key:\n api_key = \"ollama\"\n console.print(f\" [dim]本地模型无需 API Key,使用默认占位符[/]\\n\")\n else:\n # 优先使用环境变量中已有的 key\n env_val = os.environ.get(provider.env_key or \"\", \"\") if provider.env_key else \"\"\n if env_val:\n masked = env_val[:8] + \"...\" + env_val[-4:]\n use_env = (await pt.prompt_async(\n f\" 检测到环境变量 {provider.env_key} ({masked})\\n\"\n f\" 直接使用? [Y/n] › \"\n )).strip().lower()\n if use_env in (\"\", \"y\", \"yes\"):\n api_key = env_val\n console.print(\" [green]✓ 使用环境变量中的 Key[/]\\n\")\n else:\n api_key = await _prompt_key(pt, provider.key_hint)\n else:\n console.print(f\" [dim]格式参考:{provider.key_hint}[/]\")\n api_key = await _prompt_key(pt, provider.key_hint)\n\n # ── 汇总展示 ──────────────────────────────────────────────────────────────\n summary = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n summary.add_column(style=C_DIM)\n summary.add_column(style=\"white\")\n summary.add_row(\"Provider\", provider.name)\n summary.add_row(\"Model\", model)\n if base_url:\n summary.add_row(\"Base URL\", base_url)\n key_display = api_key[:8] + \"...\" + api_key[-4:] if len(api_key) > 12 else \"****\"\n summary.add_row(\"API Key\", key_display)\n console.print(Panel(summary, title=\"✓ 配置确认\", border_style=\"green\"))\n\n return AgentConfig(\n api_key=api_key,\n model=model,\n base_url=base_url if provider.id != \"custom\" else (base_url or None),\n provider_name=provider.name,\n )\n\n\nasync def _prompt_key(pt: PromptSession, hint: str) -> str:\n \"\"\"带密码掩码的 API Key 输入。\"\"\"\n from prompt_toolkit.formatted_text import HTML\n while True:\n try:\n key = await pt.prompt_async(\" API Key › \")\n key = key.strip()\n if key:\n return key\n console.print(\" [yellow]Key 不能为空,请重新输入[/]\")\n except (KeyboardInterrupt, EOFError):\n raise KeyboardInterrupt\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# UI 渲染组件\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef make_token_bar(agent: Agent) -> Panel:\n total = agent.token_usage.total\n window = agent.context_window\n pct = total / window if window else 0\n bar_style = \"green\" if pct < 0.6 else (\"yellow\" if pct < 0.8 else \"red\")\n\n progress = Progress(\n TextColumn(\"[{task.description}]\", style=C_DIM),\n BarColumn(bar_width=36, style=bar_style, complete_style=bar_style),\n TextColumn(\"{task.percentage:>5.1f}%\", style=C_DIM),\n expand=False,\n )\n progress.add_task(f\"{total:,} / {window:,} tokens\", total=window, completed=total)\n\n stats = Text()\n stats.append(\" model: \", style=C_DIM)\n stats.append(agent.model, style=C_BRAND)\n stats.append(\" │ msgs: \", style=C_DIM)\n stats.append(str(len(agent.messages)), style=\"white\")\n stats.append(\" │ compactions: \", style=C_DIM)\n stats.append(str(agent.session.compaction_count), style=\"white\")\n if agent.code_reading_mode:\n stats.append(\" │ \", style=C_DIM)\n stats.append(\"⚑ 代码深读\", style=\"bold cyan\")\n\n border = \"cyan\" if agent.code_reading_mode else C_DIM\n return Panel(Columns([progress, stats]), border_style=border, padding=(0, 1))\n\n\ndef render_user_message(text: str) -> Panel:\n return Panel(\n Text(text, style=\"white\"),\n title=Text(\" You\", style=C_USER),\n title_align=\"left\", border_style=\"green\", padding=(0, 1),\n )\n\n\ndef render_system(text: str, style: str = C_DIM) -> None:\n console.print(f\" {text}\", style=style)\n\n\ndef render_error(text: str) -> None:\n console.print(Panel(text, border_style=\"red\", title=\"Error\", title_align=\"left\"))\n\n\ndef render_help() -> None:\n table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2))\n table.add_column(style=C_CMD)\n table.add_column(style=\"white\")\n for cmd, desc in [\n (\"/help\", \"显示帮助\"),\n (\"/status\", \"Token 用量和会话信息\"),\n (\"/mode\", \"切换模式:通用 ↔ 代码深读 (/mode code | /mode general)\"),\n (\"/compact\", \"手动压缩上下文\"),\n (\"/reset\", \"清空对话历史\"),\n (\"/save\", \"保存会话到文件\"),\n (\"/load\", \"从文件加载会话\"),\n (\"/config\", \"重新配置模型和 Key\"),\n (\"/quit\", \"退出 (或 Ctrl+C / Ctrl+D)\"),\n (\"\", \"\"),\n (\"↑ / ↓\", \"浏览输入历史\"),\n (\"Ctrl+C\", \"取消当前输入 / 再按一次退出\"),\n ]:\n table.add_row(cmd, desc)\n console.print(Panel(table, title=\"命令列表\", border_style=C_DIM))\n\n\ndef render_status(agent: Agent) -> None:\n usage = agent.token_usage\n table = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n table.add_column(style=C_DIM)\n table.add_column(style=\"white\")\n table.add_row(\"Model\", agent.model)\n table.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n table.add_row(\"Tokens used\", f\"{usage.total:,} ({usage.total/agent.context_window*100:.1f}%)\")\n table.add_row(\"Messages\", str(len(agent.messages)))\n table.add_row(\"Compactions\", str(agent.session.compaction_count))\n table.add_row(\"Session start\", agent.session.created_at[:19].replace(\"T\", \" \"))\n console.print(Panel(table, title=\"Session Status\", border_style=C_DIM))\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 流式输出\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def agent_response(agent: Agent, message: str) -> str:\n \"\"\"\n 执行一次完整的 Agent 响应:工具调用过程实时展示,最终答案 Markdown 渲染。\n 使用 agent.chat() 以支持完整的工具调用循环。\n \"\"\"\n tool_log: list[str] = [] # 记录工具调用历史,用于刷新 Live\n\n def _tool_panel() -> Panel:\n \"\"\"构建工具调用日志面板。\"\"\"\n lines = Text()\n for entry in tool_log[-12:]: # 最多展示最近 12 条\n lines.append(entry + \"\\n\")\n return Panel(\n lines,\n title=Text(\" Lumen ● thinking…\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n )\n\n def on_tool_call(name: str, arguments: dict) -> None:\n # 把关键参数提炼成一行摘要\n arg_hint = \"\"\n for key in (\"path\", \"file_path\", \"pattern\", \"command\", \"query\"):\n if key in arguments:\n val = str(arguments[key])\n arg_hint = f\" [dim]{val[:60]}{'…' if len(val)>60 else ''}[/]\"\n break\n tool_log.append(f\" [cyan]⚙ {name}[/]{arg_hint}\")\n\n def on_tool_result(name: str, output: str, is_error: bool) -> None:\n preview = output.strip().splitlines()\n first_line = preview[0][:80] if preview else \"\"\n more = f\" +{len(preview)-1} lines\" if len(preview) > 1 else \"\"\n status = \"[red]✗[/]\" if is_error else \"[green]✓[/]\"\n tool_log.append(f\" {status} {first_line}{more}\")\n\n # ── 有工具时用 Live 面板展示进度 ──────────────────────────────────────────\n response = None\n if len(agent._tool_registry) > 0:\n with Live(\n _tool_panel(),\n console=console,\n refresh_per_second=8,\n vertical_overflow=\"ellipsis\",\n transient=True, # 完成后清除,换成最终答案面板\n ) as live:\n tool_log.append(\" [dim]正在思考…[/]\")\n\n async def _chat_with_refresh():\n nonlocal response\n response = await agent.chat(\n message,\n on_tool_call=lambda n, a: (on_tool_call(n, a), live.update(_tool_panel())),\n on_tool_result=lambda n, o, e: (on_tool_result(n, o, e), live.update(_tool_panel())),\n )\n\n await _chat_with_refresh()\n else:\n # 无工具时走流式输出(体验更好)\n collected: list[str] = []\n panel_title = Text(\" Lumen ● streaming…\", style=C_AGENT)\n with Live(\n Panel(Text(\"\"), title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1)),\n console=console,\n refresh_per_second=4,\n vertical_overflow=\"ellipsis\",\n transient=False,\n ) as live:\n async for chunk in agent.stream(message):\n collected.append(chunk)\n md = Markdown(\"\".join(collected), code_theme=\"monokai\", inline_code_theme=\"monokai\")\n live.update(Panel(\n md, title=panel_title, title_align=\"left\",\n border_style=\"blue\", padding=(0, 1),\n width=min(console.width, 120),\n ))\n return \"\".join(collected)\n\n # ── 渲染最终答案 ───────────────────────────────────────────────────────────\n final_text = response.content if response else \"\"\n md = Markdown(final_text, code_theme=\"monokai\", inline_code_theme=\"monokai\")\n console.print(Panel(\n md,\n title=Text(\" Lumen\", style=C_AGENT),\n title_align=\"left\",\n border_style=\"blue\",\n padding=(0, 1),\n width=min(console.width, 120),\n ))\n return final_text\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 模式切换\n# ─────────────────────────────────────────────────────────────────────────────\n\ndef handle_mode(agent: Agent, arg: str) -> None:\n \"\"\"\n /mode — 显示当前模式\n /mode code — 开启代码深读模式\n /mode general — 切回通用模式\n \"\"\"\n arg = arg.strip().lower()\n\n if not arg:\n # 显示当前状态\n mode = \"代码深读模式 [Code Reading Mode]\" if agent.code_reading_mode else \"通用模式 [General]\"\n console.print(Panel(\n f\" 当前模式: [bold cyan]{mode}[/]\\n\\n\"\n \" [dim]/mode code[/] → 开启代码深读模式\\n\"\n \" [dim]/mode general[/] → 切回通用模式\",\n border_style=C_DIM, title=\"模式\", title_align=\"left\",\n ))\n return\n\n if arg in (\"code\", \"c\", \"read\", \"深读\"):\n if agent.code_reading_mode:\n render_system(\"已经是代码深读模式。\", C_DIM)\n else:\n agent.enable_code_reading_mode()\n console.print(Panel(\n \"[bold cyan]代码深读模式已开启[/]\\n\\n\"\n \" 模型现在会:\\n\"\n \" · 永远先用工具读代码,再回答 (不猜测)\\n\"\n \" · 每次解释覆盖 WHAT / HOW / WHY / WHERE 四个维度\\n\"\n \" · 自动追踪调用链并标注 file:line\\n\"\n \" · 先讲架构全貌,再讲实现细节\\n\\n\"\n \" [dim]通用能力(写代码、回答问题等)仍然完整保留。[/]\",\n border_style=\"cyan\", title=\"✦ Code Reading Mode\", title_align=\"left\",\n ))\n\n elif arg in (\"general\", \"g\", \"normal\", \"通用\"):\n if not agent.code_reading_mode:\n render_system(\"已经是通用模式。\", C_DIM)\n else:\n agent.disable_code_reading_mode()\n render_system(\"✓ 已切回通用模式。\", C_SUCCESS)\n\n else:\n render_system(\n f\"未知模式 '{arg}'。用 /mode code 或 /mode general。\", C_WARN\n )\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 命令处理\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def handle_compact(agent: Agent) -> None:\n render_system(\"Compacting context…\", C_WARN)\n try:\n r = await agent.compact(partial=True)\n console.print(Panel(\n f\"[green]✓ 压缩完成[/]\\n\\n\"\n f\" 消息数 : [white]{r.messages_before}[/] → [white]{r.messages_after}[/]\\n\"\n f\" Tokens : [white]{r.tokens_before:,}[/] → [white]{r.tokens_after:,}[/]\\n\"\n f\" 保留最近 [white]{r.kept_recent_count}[/] 条消息原文\",\n border_style=\"green\", title=\"压缩结果\", title_align=\"left\",\n ))\n except Exception as e:\n render_error(f\"压缩失败: {e}\")\n\n\nasync def handle_save(agent: Agent, pt: PromptSession) -> None:\n default = f\"session_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json\"\n try:\n raw = await pt.prompt_async(f\" 保存到 [{default}] › \", default=default)\n path = raw.strip() or default\n agent.save_session(path)\n render_system(f\"✓ 已保存 → {path}\", C_SUCCESS)\n except (KeyboardInterrupt, EOFError):\n render_system(\"已取消\", C_DIM)\n except Exception as e:\n render_error(f\"保存失败: {e}\")\n\n\nasync def handle_load(\n arg: str, config: AgentConfig, pt: PromptSession\n) -> Agent | None:\n if not arg:\n try:\n arg = (await pt.prompt_async(\" 加载文件路径 › \")).strip()\n except (KeyboardInterrupt, EOFError):\n return None\n if not arg or not Path(arg).exists():\n render_error(f\"文件不存在: {arg!r}\")\n return None\n try:\n agent = Agent.load_session(\n arg, api_key=config.api_key,\n model=config.model, base_url=config.base_url,\n )\n render_system(f\"✓ 已加载 {len(agent.messages)} 条消息 from {arg}\", C_SUCCESS)\n return agent\n except Exception as e:\n render_error(f\"加载失败: {e}\")\n return None\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主聊天循环\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def chat_loop(config: AgentConfig, pt: PromptSession) -> bool:\n \"\"\"\n 运行聊天主循环。\n 返回 True 表示用户想重新配置(/config),False 表示退出。\n \"\"\"\n # 创建 Agent(注册全套代码阅读工具)\n try:\n agent = Agent(\n api_key=config.api_key,\n model=config.model,\n base_url=config.base_url,\n inject_git_state=True,\n inject_memory=True,\n auto_compact=True,\n tools=[\n TreeTool(), # 项目目录结构树\n DefinitionsTool(), # 提取文件中所有类/函数定义\n FileReadTool(), # 按行读取文件内容\n GlobTool(), # 按模式匹配文件路径\n GrepTool(), # 搜索文件内容\n BashTool(), # 执行 shell 命令\n ],\n )\n except Exception as e:\n render_error(f\"Agent 创建失败: {e}\")\n return True # 回到配置向导\n\n # 启动信息\n info = Table(box=box.SIMPLE, show_header=False, padding=(0, 2))\n info.add_column(style=C_DIM)\n info.add_column(style=\"white\")\n info.add_row(\"Provider\", config.provider_name)\n info.add_row(\"Model\", agent.model)\n info.add_row(\"Context window\", f\"{agent.context_window:,} tokens\")\n info.add_row(\"Auto-compact\", \"✓ 开启\")\n info.add_row(\"Git state\", \"✓ 注入\")\n info.add_row(\"Memory files\", \"✓ ENGRAM.md 自动发现\")\n info.add_row(\"Tools\", \"✓ tree · definitions · read_file · glob · grep · bash\")\n console.print(Panel(info, border_style=C_DIM, title=\"✓ 准备就绪\", title_align=\"left\"))\n console.print(Rule(style=C_DIM))\n console.print(\n \" 输入消息后按 Enter 发送。\"\n \" [magenta]/help[/] 查看命令。\"\n \" [dim]Ctrl+D 或 /quit 退出。[/]\\n\"\n )\n\n _last_ctrl_c = False\n\n while True:\n _last_ctrl_c = False\n try:\n console.print(make_token_bar(agent))\n user_input = (await pt.prompt_async(\" You › \")).strip()\n if not user_input:\n continue\n except EOFError:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n except KeyboardInterrupt:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n cmd = user_input.lower()\n\n # ── Slash 命令 ────────────────────────────────────────────────────────\n if cmd in (\"/quit\", \"/exit\", \"/q\"):\n render_system(\"Goodbye!\", C_DIM)\n return False\n\n elif cmd == \"/help\":\n render_help(); continue\n\n elif cmd == \"/status\":\n render_status(agent); continue\n\n elif cmd.startswith(\"/mode\"):\n handle_mode(agent, user_input[5:].strip()); continue\n\n elif cmd == \"/compact\":\n await handle_compact(agent); continue\n\n elif cmd == \"/reset\":\n agent.reset()\n render_system(\"✓ 对话历史已清空。\", C_SUCCESS); continue\n\n elif cmd == \"/save\":\n await handle_save(agent, pt); continue\n\n elif cmd.startswith(\"/load\"):\n new = await handle_load(user_input[5:].strip(), config, pt)\n if new:\n agent = new\n continue\n\n elif cmd == \"/config\":\n render_system(\"返回配置向导…\", C_DIM)\n return True # 信号:重新配置\n\n elif cmd.startswith(\"/\"):\n render_system(f\"未知命令: {user_input} 输入 /help 查看帮助\", C_WARN)\n continue\n\n # ── 正常聊天 ──────────────────────────────────────────────────────────\n console.print(render_user_message(user_input))\n console.print()\n\n try:\n await agent_response(agent, user_input)\n console.print()\n except KeyboardInterrupt:\n if _last_ctrl_c:\n console.print()\n render_system(\"Goodbye!\", C_DIM)\n return False\n _last_ctrl_c = True\n render_system(\"已取消。(再按一次 Ctrl+C 退出)\", C_WARN)\n except Exception as e:\n render_error(str(e))\n if os.environ.get(\"ENGRAM_DEBUG\"):\n traceback.print_exc()\n\n\n# ─────────────────────────────────────────────────────────────────────────────\n# 主入口\n# ─────────────────────────────────────────────────────────────────────────────\n\nasync def main_async(\n cli_api_key: str | None,\n cli_model: str | None,\n cli_base_url: str | None,\n) -> None:\n # Banner\n console.print(Text(BANNER, style=C_BRAND), justify=\"center\")\n console.print(Align.center(Text(\"Model-agnostic Deep Code Reading Agent\", style=C_DIM)))\n console.print(Align.center(Text(\"用任意大模型深度阅读和理解代码\", style=\"dim cyan\")))\n console.print()\n\n pt = PromptSession(\n history=InMemoryHistory(),\n style=PTStyle.from_dict({\"prompt\": \"ansicyan bold\"}),\n )\n\n # 如果命令行已经提供了完整参数,跳过向导\n if cli_api_key and cli_model:\n config = AgentConfig(\n api_key=cli_api_key,\n model=cli_model,\n base_url=cli_base_url,\n provider_name=\"CLI\",\n )\n await chat_loop(config, pt)\n return\n\n # 否则进入 配置 → 聊天 → 可重配置 的循环\n config: AgentConfig | None = None\n\n while True:\n try:\n # 尝试从环境变量中自动填充(仍然走向导让用户确认)\n config = await setup_wizard(pt)\n except (KeyboardInterrupt, EOFError):\n console.print()\n render_system(\"已取消,退出。\", C_DIM)\n return\n\n console.print()\n reconfigure = await chat_loop(config, pt)\n if not reconfigure:\n break\n console.print()\n console.print(Rule(\" 重新配置\", style=C_BRAND))\n\n\ndef main() -> None:\n import argparse\n parser = argparse.ArgumentParser(description=\"Lumen — 代码深度阅读 Agent\")\n parser.add_argument(\"--model\", default=None, help=\"模型名称,例如 gpt-4o\")\n parser.add_argument(\"--api-key\", default=None, help=\"API Key(覆盖环境变量)\")\n parser.add_argument(\"--base-url\", default=None, help=\"API Base URL\")\n args = parser.parse_args()\n\n try:\n asyncio.run(main_async(\n cli_api_key=args.api_key,\n cli_model=args.model,\n cli_base_url=args.base_url,\n ))\n except KeyboardInterrupt:\n console.print(\"\\n [dim]Goodbye![/]\")\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "README.md": {"d42246c188b7607a25605e1ac79bdd77c221fb46": {"t": 1785674072.8639965, "n": 10362, "h": "a0bb4a404a22f2ff", "c": "**English** | [中文](README.zh.md)\n\n# Lumen — A Model-Agnostic AI Coding Agent SDK\n\n> **One command turns any LLM into your code architect.**\n\nLumen is a model-agnostic, production-grade AI coding agent framework (Python 3.11+). Give it an API key and a model name, and you get an AI assistant that can deeply read, write, and analyze code. Works with OpenAI, Anthropic, DeepSeek, Ollama, and any OpenAI-compatible LLM.\n\n<div align=\"center\">\n <img src=\"展示.gif\" alt=\"Lumen demo\" width=\"800\"/>\n</div>\n\n---\n\n## Examples\n\n### Pomodoro timer — generated end-to-end in Review Mode\n\nA pure HTML/CSS/JS Pomodoro timer with an animated SVG countdown ring, auto-switching work/break sessions, a WebAudio beep, and a localStorage-backed task list — built end-to-end by Lumen running `gpt-4.1` under Review Mode (design → pipeline → implementation, gated by `request_approval` between phases).\n\nSource: [examples/pomodoro/](examples/pomodoro/)\n\n<div align=\"center\">\n <img src=\"examples/pomodoro/demo.gif\" alt=\"Pomodoro demo generated by Lumen\" width=\"800\"/>\n</div>\n\n---\n\n## Features at a glance\n\n- **Model-agnostic** — OpenAI / Anthropic / DeepSeek / Ollama / any OpenAI-compatible API, swap with a single line\n- **13 built-in tools** — file read/write, code search, shell execution, web search/fetch, LSP code intelligence, sub-agents\n- **Extended Thinking** — Anthropic thinking blocks / OpenAI `reasoning_effort` / generic CoT, with adaptive budgeting\n- **Prompt Cache** — native Anthropic `cache_control` plus a generic hash-based cache; automatically reduces API cost\n- **Structured output** — JSON Schema-enforced output for OpenAI / Anthropic / Gemini, validated via Pydantic\n- **Smart permission control** — feature-scored command classifier plus regex dual engine, five risk levels, fully explainable\n- **Dynamic session memory** — LLM auto-extracts key facts, TF-IDF retrieval, no vector database required\n- **Automatic context compaction** — compresses conversation history as the window fills up, continues seamlessly\n- **Skill system** — reusable task templates, defined in JSON/YAML with fuzzy search\n- **Persistent retry** — tiered model escalation plus webhook alerts, designed for CI / batch workloads\n- **Lifecycle hooks** — pre/post tool hooks for interception, mutation, and remediation\n- **Auto-persisted transcripts** — JSONL append-only, crash-safe, resume past sessions with `/resume`\n- **Sub-agent system** — parent agents can spawn sub-agents for independent tasks, sync/async modes, linked aborts\n\n---\n\n## Quick start\n\n```bash\n# Clone, create an env, and run\ngit clone https://github.com/boots-coder/lumen.git\ncd lumen\nconda create -n lumen python=3.11 -y && conda activate lumen\npip install httpx pydantic tiktoken rich prompt_toolkit\n# Optional: web search tool, YAML-defined skills\npip install duckduckgo_search pyyaml\npython chat.py\n```\n\nOr skip the wizard and launch directly:\n\n```bash\n# OpenAI\npython chat.py --model gpt-4o --api-key sk-proj-...\n\n# Anthropic\npython chat.py --model claude-sonnet-4-6 --api-key sk-ant-...\n\n# Local Ollama (no key needed)\npython chat.py --model llama3.1 --base-url http://localhost:11434/v1\n```\n\n---\n\n## Core capabilities\n\n### Tool suite (invoked autonomously by the model)\n\n| Tool | Purpose |\n|------|---------|\n| `read_file` | Read file content by line, with `offset` + `limit` pagination |\n| `write_file` | Create a new file |\n| `edit_file` | Edit an existing file (line-level replacement) |\n| `tree` | Render the project directory (auto-filters `node_modules` etc.) |\n| `definitions` | Extract every class / function / method from a file with line numbers |\n| `glob` | Match file paths by pattern, sorted by modification time |\n| `grep` | ripgrep-powered regex search with three output modes |\n| `bash` | Execute shell commands |\n| `web_search` | DuckDuckGo search (no API key required) |\n| `web_fetch` | Fetch a web page and extract readable text |\n| `lsp` | LSP code intelligence: go-to-definition / find-references / hover / symbol search |\n| `sub_agent` | Spawn a sub-agent for an independent task (requires `enable_subagents()`) |\n\n### Extended Thinking\n\nThree strategies automatically adapt to different models:\n\n| Model family | Mechanism |\n|--------------|-----------|\n| Anthropic | `thinking: {\"type\": \"enabled\", \"budget_tokens\": N}` |\n| OpenAI o-series | `reasoning_effort: \"low\"/\"medium\"/\"high\"` |\n| Other models | CoT instructions injected via the system prompt |\n\nThe thinking budget adapts to context usage — as the window fills up, it shrinks automatically to leave room for the actual response.\n\n### Structured output\n\n```python\nfrom pydantic import BaseModel\n\nclass CodeAnalysis(BaseModel):\n complexity: str\n issues: list[str]\n suggestions: list[str]\n\nresult = await agent.query(\"Analyze this code\", schema=CodeAnalysis)\n# result is a validated CodeAnalysis instance\n```\n\nSupports OpenAI `json_schema` / Anthropic tool-use trick / Gemini / generic JSON mode.\n\n### Command safety classifier\n\nReplaces pure regex with feature scoring — a weighted analysis across four dimensions:\n\n- **Executable** (weight 0.4) — `rm` 0.7, `sudo` 0.9, `ls` 0.0\n- **Flags** (weight 0.2) — `-rf` 0.8, `--force` 0.3, `--help` 0.0\n- **Argument paths** (weight 0.1) — `/` 0.9, `/etc` 0.4, `./src` 0.0\n- **Command composition** (weight 0.3) — `curl | bash` 0.9, subshell 0.3\n\nContext-aware: `rm -f` is much riskier than `-f` on its own.\n\n### Auto-persisted transcripts\n\nEvery message is appended to a JSONL file — crash-safe:\n\n```\n~/.lumen/projects/{project-path}/{session-id}.jsonl\n```\n\n- **Append-only writes** — 100 ms buffer, async flush\n- **Fast recovery** — reads metadata from the tail 64 KB, `/resume` restores in one step\n- **Session ID** — UUID auto-generated, shown at startup\n\n### Sub-agent system\n\nA parent agent can spawn an isolated sub-agent to parallelize work:\n\n```python\nagent.enable_subagents() # Register the sub_agent tool\n\n# Or use it programmatically\nfrom lumen import SubAgentConfig\nresult = await agent.subagent_manager.spawn(SubAgentConfig(\n prompt=\"Audit the security of the auth module\",\n description=\"Security audit\",\n run_in_background=True, # Run asynchronously in the background\n))\n```\n\nIsolation policy:\n- **File cache** — cloned (no cross-contamination)\n- **Abort** — parent/child linked (parent abort cascades to child; child abort does not affect parent)\n- **Session** — independent conversation history\n- **Tools** — inherited or customized\n\n### Dynamic session memory\n\n- Every three turns, key facts (preferences / project / patterns / corrections) are auto-extracted\n- TF-IDF keyword retrieval — no vector database required\n- Jaccard similarity deduplication, persisted to a JSON file\n- Relevant memories are automatically injected on the first turn\n\n### Context management\n\n- **Automatic token counting**: precise per-turn counts with a live progress bar\n- **Automatic compaction**: when the window is nearly full, history is compacted into a structured summary\n- **Manual compaction `/compact`**: trigger any time, keeping the most recent N messages\n\n### Memory system (ENGRAM.md)\n\nFour priority levels, auto-discovered and loaded:\n\n```\n/etc/lumen/ENGRAM.md # System level (lowest priority)\n~/.engram/ENGRAM.md # User level\n./ENGRAM.md # Project level (team-shared)\n./ENGRAM.local.md # Local private (gitignored)\n```\n\n---\n\n## SDK usage\n\n### Basic usage\n\n```python\nimport asyncio\nfrom lumen import Agent\nfrom lumen.tools import (\n FileReadTool, FileWriteTool, FileEditTool,\n GlobTool, GrepTool, TreeTool, DefinitionsTool, BashTool,\n)\n\nasync def main():\n agent = Agent(\n api_key=\"sk-...\",\n model=\"gpt-4o\",\n tools=[\n TreeTool(), DefinitionsTool(), FileReadTool(),\n GlobTool(), GrepTool(), BashTool(),\n FileWriteTool(), FileEditTool(),\n ],\n auto_compact=True,\n inject_git_state=True,\n )\n\n response = await agent.chat(\"Explain the overall architecture of this project\")\n print(response.content)\n\nasyncio.run(main())\n```\n\n### Advanced: structured output + Thinking\n\n```python\nfrom lumen import Agent, ThinkingConfig, ThinkingMode\n\nagent = Agent(\n api_key=\"sk-ant-...\",\n model=\"claude-sonnet-4-6\",\n tools=[...],\n thinking=ThinkingConfig(\n enabled=True,\n budget_tokens=10000,\n mode=ThinkingMode.AUTO,\n ),\n)\n\n# Structured query\nresult = await agent.query(\"List every API endpoint\", schema=APIEndpoints)\n```\n\n### Advanced: persistent retry (CI scenario)\n\n```python\nfrom lumen import Agent, PersistentRetryConfig\n\nagent = Agent(\n api_key=\"sk-...\",\n model=\"gpt-4o\",\n tools=[...],\n persistent_retry=PersistentRetryConfig(\n enabled=True,\n escalation_threshold=5,\n fallback_models=[\"gpt-4o-mini\", \"claude-sonnet-4-6\"],\n total_timeout_seconds=3600,\n ),\n)\n```\n\n---\n\n## Slash commands\n\n| Command | Description |\n|---------|-------------|\n| `/help` | Show help |\n| `/status` | Token usage and session info |\n| `/mode code` | Enter deep code-reading mode |\n| `/mode general` | Return to general mode |\n| `/compact` | Manually compact the context |\n| `/reset` | Clear conversation history |\n| `/save` | Save the session to a JSON file (transcripts are already auto-persisted) |\n| `/load` | Restore a session from a JSON file |\n| `/resume` | List recent sessions and pick one to resume |\n| `/config` | Reconfigure the model and API key |\n| `/quit` | Exit (or Ctrl+D) |\n\n---\n\n## Supported models\n\n| Provider | Example models | Notes |\n|----------|----------------|-------|\n| **OpenAI** | `gpt-4o`, `gpt-4o-mini`, `o3-mini`, `o1` | Auto-handles API differences for reasoning models |\n| **Anthropic** | `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-haiku-4-5` | Native API + thinking blocks |\n| **DeepSeek** | `deepseek-chat`, `deepseek-reasoner` | OpenAI-compatible protocol |\n| **Ollama** | `llama3.1`, `qwen2.5`, `mistral`, `deepseek-r1` | Run locally, no key needed |\n| **Other** | Any OpenAI-compatible API | Custom `base_url` |\n\n---\n\n## Requirements\n\n- Python 3.11+\n- `ripgrep` (optional, used by the grep tool; `brew install ripgrep`)\n- An API key for OpenAI / Anthropic / DeepSeek, or a local Ollama\n\nOptional dependencies:\n- `duckduckgo_search` — required for the web search tool\n- `pyyaml` — required for YAML-formatted skill definitions\n\n---\n\n## License\n\nMIT\n"}}}} |