lithe-cli 0.2.4__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.2.4
3
+ Version: 0.4.0
4
4
  Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
5
5
  License: MIT License
6
6
 
@@ -39,7 +39,8 @@ Classifier: Typing :: Typed
39
39
  Requires-Python: >=3.10
40
40
  Description-Content-Type: text/markdown
41
41
  License-File: LICENSE
42
- Requires-Dist: lithe>=0.9.5
42
+ Requires-Dist: lithe>=0.9.7
43
+ Requires-Dist: prompt-toolkit>=3.0.38
43
44
  Provides-Extra: dev
44
45
  Requires-Dist: pytest>=8; extra == "dev"
45
46
  Requires-Dist: ruff>=0.4; extra == "dev"
@@ -117,7 +118,7 @@ usage/context gauges; `--max-steps` caps the tool loop;
117
118
  ```bash
118
119
  $ lithe chat
119
120
  ╭──────────────────────────────────────╮
120
- │ lithe 0.2.0 │
121
+ │ lithe 0.4.0 │
121
122
  │ model your-model │
122
123
  │ workdir /home/me/my-project │
123
124
  │ /help 查看会话命令,/exit 退出 │
@@ -141,6 +142,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
141
142
  | Flag | Tools granted | Notes |
142
143
  | --- | --- | --- |
143
144
  | `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
145
+ | `--shell` | `run_command` | Native bash/sh on Linux and macOS, PowerShell/cmd on Windows; runs with the current user's host permissions, is not sandboxed, and cannot be undone |
144
146
  | `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
145
147
  | `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
146
148
  | `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
@@ -148,6 +150,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
148
150
 
149
151
  ```bash
150
152
  $ lithe run --code "用 run_code 验证 results.csv 的行数"
153
+ $ lithe run --shell "检查当前目录的项目状态"
151
154
  $ lithe chat --skills ~/my-skills --mcp @~/mcp.json
152
155
  ```
153
156
 
@@ -197,8 +200,12 @@ them only after a session has started the servers.)
197
200
  ### Colors
198
201
 
199
202
  Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
200
- / `--no-color` force either way. Rendering has zero dependencies — plain
201
- text degrades cleanly through pipes and cron.
203
+ / `--no-color` force either way. Output rendering itself stays
204
+ dependency-free — plain text degrades cleanly through pipes and cron.
205
+ Interactive *input* (the chat prompt and the setup wizard) is
206
+ [prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/):
207
+ wide-character-safe editing, bracketed-paste handling, star-echoed API
208
+ keys, inline wizard validation, and persistent history.
202
209
 
203
210
  ## Where things land
204
211
 
@@ -208,6 +215,7 @@ text degrades cleanly through pipes and cron.
208
215
  | `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
209
216
  | `~/.lithe/runs/todos-<user>.json` | the agent's task list |
210
217
  | `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
218
+ | `~/.lithe/history` | chat input history (prompt_toolkit `FileHistory`; Up/Ctrl+R recall) |
211
219
 
212
220
  ## Design notes
213
221
 
@@ -70,7 +70,7 @@ usage/context gauges; `--max-steps` caps the tool loop;
70
70
  ```bash
71
71
  $ lithe chat
72
72
  ╭──────────────────────────────────────╮
73
- │ lithe 0.2.0 │
73
+ │ lithe 0.4.0 │
74
74
  │ model your-model │
75
75
  │ workdir /home/me/my-project │
76
76
  │ /help 查看会话命令,/exit 退出 │
@@ -94,6 +94,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
94
94
  | Flag | Tools granted | Notes |
95
95
  | --- | --- | --- |
96
96
  | `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
97
+ | `--shell` | `run_command` | Native bash/sh on Linux and macOS, PowerShell/cmd on Windows; runs with the current user's host permissions, is not sandboxed, and cannot be undone |
97
98
  | `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
98
99
  | `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
99
100
  | `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
@@ -101,6 +102,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
101
102
 
102
103
  ```bash
103
104
  $ lithe run --code "用 run_code 验证 results.csv 的行数"
105
+ $ lithe run --shell "检查当前目录的项目状态"
104
106
  $ lithe chat --skills ~/my-skills --mcp @~/mcp.json
105
107
  ```
106
108
 
@@ -150,8 +152,12 @@ them only after a session has started the servers.)
150
152
  ### Colors
151
153
 
152
154
  Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
153
- / `--no-color` force either way. Rendering has zero dependencies — plain
154
- text degrades cleanly through pipes and cron.
155
+ / `--no-color` force either way. Output rendering itself stays
156
+ dependency-free — plain text degrades cleanly through pipes and cron.
157
+ Interactive *input* (the chat prompt and the setup wizard) is
158
+ [prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/):
159
+ wide-character-safe editing, bracketed-paste handling, star-echoed API
160
+ keys, inline wizard validation, and persistent history.
155
161
 
156
162
  ## Where things land
157
163
 
@@ -161,6 +167,7 @@ text degrades cleanly through pipes and cron.
161
167
  | `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
162
168
  | `~/.lithe/runs/todos-<user>.json` | the agent's task list |
163
169
  | `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
170
+ | `~/.lithe/history` | chat input history (prompt_toolkit `FileHistory`; Up/Ctrl+R recall) |
164
171
 
165
172
  ## Design notes
166
173
 
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.2.4"
3
+ __version__ = "0.4.0"
@@ -8,9 +8,9 @@ Everything here maps lithe's host contract onto CLI defaults:
8
8
  kernel's zero-database default;
9
9
  - tools — the workspace bundle (read/write/edit/list/search/glob +
10
10
  apply_patch) and todos, plus opt-in capabilities: ``--code`` (sandboxed
11
- Python), ``--skills`` (markdown skill library), ``--vision`` (image
12
- probe/analysis), ``--download`` (SSRF-guarded network fetch) and
13
- ``--mcp`` (external MCP servers);
11
+ Python), ``--shell`` (native host commands), ``--skills`` (markdown skill
12
+ library), ``--vision`` (image probe/analysis), ``--download`` (SSRF-guarded
13
+ network fetch) and ``--mcp`` (external MCP servers);
14
14
  - undo — the bundled tools register reverters, so ``lithe undo`` works with
15
15
  zero configuration.
16
16
  """
@@ -32,6 +32,7 @@ from lithe.bundles import (
32
32
  Workspace,
33
33
  undo_run,
34
34
  )
35
+ from lithe.bundles.command import register_command_tools
35
36
  from lithe.bundles.download import register_download_tools
36
37
  from lithe.bundles.patch import register_apply_patch_tool
37
38
  from lithe.bundles.workspace import register_file_tools
@@ -57,6 +58,8 @@ def build_system_prompt(cfg: Config) -> str:
57
58
  extras = []
58
59
  if cfg.code:
59
60
  extras.append("可以用 run_code 执行 Python 来验证想法、计算或测试。")
61
+ if cfg.shell:
62
+ extras.append("可以用 run_command 在主机上运行系统命令;命令有当前用户权限且副作用不可撤销,执行前先确认必要性。")
60
63
  if cfg.download:
61
64
  extras.append("可以用 download_file 下载网络文件到工作区。")
62
65
  if cfg.skills_dir is not None:
@@ -97,6 +100,12 @@ def build_registry(cfg: Config) -> ToolRegistry:
97
100
 
98
101
  runner = CodeRunner(sys.executable, backend=sandbox_backend())
99
102
  register_code_tools(reg, lambda ctx: str(cfg.workspace_dir.resolve()), runner)
103
+ if cfg.shell:
104
+ from lithe.bundles.command import CommandRunner
105
+
106
+ register_command_tools(
107
+ reg, lambda ctx: cfg.workspace_dir.resolve(), CommandRunner()
108
+ )
100
109
  if cfg.skills_dir is not None:
101
110
  from lithe.bundles.skills import SkillLibrary, register_skill_tool
102
111
 
@@ -146,14 +155,14 @@ def render_event(
146
155
  ui.assistant(text)
147
156
  elif t == "tool_call":
148
157
  args = json.dumps(ev.get("args", {}), ensure_ascii=False)
149
- ui.tool_call(str(ev.get("name")), args[:120])
158
+ ui.tool_call(str(ev.get("name")), args)
150
159
  if ev.get("id") is not None:
151
160
  pending[ev["id"]] = time.monotonic()
152
161
  elif t == "tool_result":
153
162
  summary = (ev.get("summary") or "").replace("\n", " ")
154
163
  started = pending.pop(ev.get("id"), None) if ev.get("id") else None
155
164
  elapsed = (time.monotonic() - started) if started is not None else None
156
- ui.tool_result(bool(ev.get("ok")), summary[:160], elapsed)
165
+ ui.tool_result(bool(ev.get("ok")), summary, elapsed)
157
166
  elif t == "error":
158
167
  ui.error(str(ev.get("message", "")))
159
168
  elif t == "cancelled":
@@ -57,6 +57,7 @@ class Config:
57
57
  download: bool = False
58
58
  verbose: bool = False
59
59
  code: bool = False
60
+ shell: bool = False
60
61
  skills_dir: Path | None = None
61
62
  mcp_spec: str | None = None
62
63
  mcp_servers: list = field(default_factory=list)
@@ -204,6 +205,7 @@ def load_config(args: Any) -> Config:
204
205
  download=g("download", False),
205
206
  verbose=g("verbose", False),
206
207
  code=g("code", False),
208
+ shell=g("shell", False),
207
209
  skills_dir=skills_dir,
208
210
  mcp_spec=mcp_raw,
209
211
  mcp_servers=load_mcp_spec(mcp_raw) if mcp_raw else [],
@@ -29,9 +29,10 @@ from lithe import __version__ as lithe_version
29
29
  from lithe.bundles import JsonlRunStore
30
30
 
31
31
  from . import __version__
32
+ from . import prompts
32
33
  from .agent import build_registry, execute, sandbox_backend, undo
33
34
  from .config import config_file, load_config, require_endpoint
34
- from . import ttyio
35
+ from .prompts import open_history
35
36
  from .ui import BOLD, CYAN, GREEN, RED, YELLOW, configure, truncate, ui
36
37
 
37
38
 
@@ -65,6 +66,11 @@ def build_parser() -> argparse.ArgumentParser:
65
66
  help="grant run_code/run_file (sandboxed Python; "
66
67
  "bwrap when available, passthrough otherwise)",
67
68
  )
69
+ sp.add_argument(
70
+ "--shell",
71
+ action="store_true",
72
+ help="grant run_command (native shell with current-user host access)",
73
+ )
68
74
  sp.add_argument(
69
75
  "--skills",
70
76
  metavar="DIR",
@@ -177,7 +183,7 @@ def _cmd_runs(cfg: Any, limit: int) -> int:
177
183
  rows = []
178
184
  for r in reversed(runs): # newest last, like a log
179
185
  task = (r.task or "").replace("\n", " ")
180
- rows.append([r.run_id, r.status, str(r.steps), f"{r.cost:.4f}", task[:60]])
186
+ rows.append([r.run_id, r.status, str(r.steps), f"{r.cost:.4f}", truncate(task, 60)])
181
187
  print(
182
188
  ui.table(
183
189
  ["run", "status", "steps", "cost", "task"],
@@ -223,7 +229,7 @@ def _cmd_doctor() -> int:
223
229
  class _Args: # doctor reuses load_config over env-only defaults
224
230
  api_key = base_url = model = store = workspace = user = None
225
231
  skills = mcp = None
226
- download = code = vision = color = no_color = verbose = False
232
+ download = code = shell = vision = color = no_color = verbose = False
227
233
 
228
234
  cfg = load_config(_Args())
229
235
  print(ui.s(f"lithe-cli {__version__}(lithe {lithe_version})", CYAN, BOLD))
@@ -263,6 +269,8 @@ def _cmd_doctor() -> int:
263
269
  else:
264
270
  sandbox_line = ui.s("passthrough(未找到 bwrap,无隔离)", RED)
265
271
  print(ui.kv("sandbox", sandbox_line + ";--code 启用 run_code"))
272
+ command_line = "已启用(run_command 可执行主机命令)" if cfg.shell else "未启用(--shell 显式授权)"
273
+ print(ui.kv("shell", command_line))
266
274
 
267
275
  skills = default_skills_dir()
268
276
  if cfg.skills_dir is not None:
@@ -330,13 +338,13 @@ _CHAT_HELP = """可用命令:
330
338
  def _chat_loop(cfg: Any) -> int:
331
339
  store = JsonlRunStore(cfg.store_dir)
332
340
  run_ids: list[str] = []
333
- history: list[str] = []
341
+ input_history = open_history() # FileHistory under $LITHE_HOME, shared runs
334
342
  print(ui.banner(__version__, cfg.model or "(scripted)", str(cfg.workspace_dir)))
335
343
  while True:
336
344
  try:
337
- # raw cbreak reader, not input(): readline's redisplay can
338
- # corrupt multibyte buffers on long wrapped lines
339
- line = ttyio.read_line(ui.prompt(), history=history).strip()
345
+ # prompt_toolkit owns the terminal: wide-char safe, paste-safe,
346
+ # persistent history — never input()/readline
347
+ line = prompts.chat_line(ui.prompt(), input_history).strip()
340
348
  except (EOFError, KeyboardInterrupt):
341
349
  print()
342
350
  break
@@ -351,7 +359,6 @@ def _chat_loop(cfg: Any) -> int:
351
359
  continue
352
360
  if not line:
353
361
  continue
354
- history.append(line)
355
362
  if line in ("/exit", "/quit", "exit", "quit"):
356
363
  break
357
364
  if line == "/help":
@@ -0,0 +1,118 @@
1
+ """All interactive input goes through prompt_toolkit.
2
+
3
+ One seam for every prompt the CLI shows: the chat line (persistent
4
+ history, ANSI-colored prompt), the wizard's questions (inline
5
+ validation, hidden API key with star echo), and yes/no confirms.
6
+
7
+ prompt_toolkit owns the terminal: it draws its own line editor with
8
+ proper wide-character widths (no readline multibyte corruption), turns
9
+ on and consumes bracketed paste itself, and masks ``is_password``
10
+ input with asterisks — the three failure classes this CLI hit when it
11
+ used ``input``/``getpass``/readline are all upstream concerns now.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from prompt_toolkit import prompt
17
+ from prompt_toolkit.formatted_text import ANSI
18
+ from prompt_toolkit.history import FileHistory, InMemoryHistory
19
+ from prompt_toolkit.validation import ValidationError, Validator
20
+
21
+ from .ui import RED, YELLOW, ui
22
+
23
+ _MAX_EMPTY_TRIES = 3
24
+
25
+ _UTF8_HINT = (
26
+ "\n 这行输入无法按 UTF-8 解码,已丢弃;"
27
+ "请把终端与 locale 设为 UTF-8(如 export LANG=C.UTF-8)后重试。"
28
+ )
29
+
30
+
31
+ def history_file():
32
+ """Where chat history persists, next to the saved endpoint."""
33
+ from .config import lithe_home
34
+
35
+ return lithe_home() / "history"
36
+
37
+
38
+ def open_history() -> FileHistory:
39
+ """A FileHistory under ``$LITHE_HOME`` (dir created on demand)."""
40
+ path = history_file()
41
+ try:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ except OSError:
44
+ return InMemoryHistory()
45
+ return FileHistory(str(path))
46
+
47
+
48
+ def chat_line(message: str, history) -> str:
49
+ """The chat REPL prompt: colored, history-searchable, paste-safe."""
50
+ return prompt(
51
+ ANSI(message),
52
+ history=history,
53
+ enable_history_search=True,
54
+ )
55
+
56
+
57
+ class _FnValidator(Validator):
58
+ """Adapt a ``text -> error string | None`` callable for prompt_toolkit."""
59
+
60
+ def __init__(self, fn):
61
+ self.fn = fn
62
+
63
+ def validate(self, document) -> None:
64
+ problem = self.fn(document.text)
65
+ if problem:
66
+ raise ValidationError(message=problem, cursor_position=len(document.text))
67
+
68
+
69
+ def ask(
70
+ label: str,
71
+ default: str = "",
72
+ password: bool = False,
73
+ validate=None,
74
+ ) -> str | None:
75
+ """Ask for one value; empty answers keep the default or re-prompt.
76
+
77
+ ``validate`` (text -> error string or None) is enforced inline by
78
+ prompt_toolkit — Enter is refused with the message until the value
79
+ passes. Returns None when the user bails out (empty too often,
80
+ Ctrl+C/D).
81
+ """
82
+ message = f"{label} [回车沿用已保存值]: " if password and default else f"{label}: "
83
+ validator = _FnValidator(validate) if validate is not None else None
84
+ for _ in range(_MAX_EMPTY_TRIES):
85
+ try:
86
+ answer = prompt(
87
+ message,
88
+ default=default if not password else "",
89
+ is_password=password,
90
+ validator=validator,
91
+ ).strip()
92
+ except (EOFError, KeyboardInterrupt):
93
+ print()
94
+ return None
95
+ except UnicodeDecodeError:
96
+ print(ui.s(_UTF8_HINT, RED))
97
+ continue
98
+ if not answer and default:
99
+ return default
100
+ if not answer:
101
+ print(ui.s(" 这个值不能为空。", YELLOW))
102
+ continue
103
+ return answer
104
+ return None
105
+
106
+
107
+ def yes_no(label: str, default: bool) -> bool:
108
+ """A y/n question; empty answer takes the default."""
109
+ hint = "Y/n" if default else "y/N"
110
+ try:
111
+ answer = prompt(f"{label} [{hint}]: ").strip().lower()
112
+ except (EOFError, KeyboardInterrupt):
113
+ print()
114
+ return default
115
+ except UnicodeDecodeError:
116
+ print(ui.s("\n 这行输入无法按 UTF-8 解码,按默认值处理。", RED))
117
+ return default
118
+ return default if not answer else answer in ("y", "yes", "是")
@@ -0,0 +1,109 @@
1
+ """First-run endpoint setup: the wizard behind `lithe-cli config`.
2
+
3
+ The CLI still ships no default endpoint — that principle is untouched.
4
+ What changes is the failure mode: on an interactive terminal, a missing
5
+ endpoint means three prompts and a saved config file instead of a
6
+ refusal with a wall of export lines. Non-interactive contexts (pipes,
7
+ scripts, CI) never see the wizard; they keep the hard error.
8
+
9
+ Precedence stays flag > env > config file, so the wizard only fills what
10
+ nothing else provided. All prompting is prompt_toolkit (see
11
+ :mod:`lithe_cli.prompts`): inline URL validation, star-echoed API key,
12
+ paste-safe input.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from urllib.error import HTTPError, URLError
20
+ from urllib.request import Request, urlopen
21
+
22
+ from . import prompts
23
+ from .config import (
24
+ ENV_API_KEY,
25
+ ENV_BASE_URL,
26
+ ENV_MODEL,
27
+ config_file,
28
+ load_saved_endpoint,
29
+ save_endpoint,
30
+ )
31
+ from .ui import BOLD, CYAN, GREEN, RED, YELLOW, ui
32
+
33
+
34
+ def stdin_is_interactive() -> bool:
35
+ """True only when both ends are TTYs — pipes and captures stay clean."""
36
+ return sys.stdin.isatty() and sys.stdout.isatty()
37
+
38
+
39
+ def _http_like(value: str) -> str | None:
40
+ return (
41
+ None
42
+ if value.startswith(("http://", "https://"))
43
+ else ("Base URL 需以 http:// 或 https:// 开头。")
44
+ )
45
+
46
+
47
+ def probe_endpoint(base_url: str, api_key: str, timeout: float = 10.0) -> bool:
48
+ """One cheap GET ``{base_url}/models`` to catch typos before chat.
49
+
50
+ The save already happened: a failed probe warns, it never blocks —
51
+ endpoints legitimately lack ``/models`` or auth differently.
52
+ """
53
+ url = base_url.rstrip("/") + "/models"
54
+ req = Request(url, headers={"Authorization": f"Bearer {api_key}"})
55
+ try:
56
+ with urlopen(req, timeout=timeout) as resp:
57
+ print(ui.s(f"✓ 端点可达(HTTP {resp.status})", GREEN))
58
+ return True
59
+ except HTTPError as exc:
60
+ if exc.code in (401, 403):
61
+ print(ui.s(f"~ 端点可达但拒绝了该 key(HTTP {exc.code})", YELLOW))
62
+ else:
63
+ print(ui.s(f"~ 端点有响应:HTTP {exc.code}(无 /models 也无妨)", YELLOW))
64
+ return True # reachable is all we wanted to know
65
+ except (URLError, OSError, TimeoutError) as exc:
66
+ print(ui.s(f"✗ 连不上 {url}:{exc}", RED))
67
+ return False
68
+
69
+
70
+ def run_setup_wizard() -> dict | None:
71
+ """Prompt for base_url / api_key / model and save the config file.
72
+
73
+ Defaults come from the already-saved file, then env vars, so
74
+ re-running the wizard is re-editing, not retyping. Returns the saved
75
+ endpoint dict, or None when the user cancels.
76
+ """
77
+ if not stdin_is_interactive():
78
+ print(ui.s("交互配置需要终端(stdin/stdout 不是 TTY)。", RED))
79
+ return None
80
+ saved = load_saved_endpoint()
81
+ env = {
82
+ "base_url": os.environ.get(ENV_BASE_URL, "").strip(),
83
+ "api_key": os.environ.get(ENV_API_KEY, "").strip(),
84
+ "model": os.environ.get(ENV_MODEL, "").strip(),
85
+ }
86
+ defaults = {k: saved.get(k, "") or env[k] for k in env}
87
+
88
+ print(ui.s("初始设置:配置模型端点", CYAN, BOLD))
89
+ print(
90
+ f"值保存在 {config_file()}(权限 600);"
91
+ "优先级 flag > 环境变量 > 配置文件。\n"
92
+ "之后可用 `lithe-cli config` 重新配置。"
93
+ )
94
+ base_url = prompts.ask(
95
+ "Base URL(如 https://your-endpoint/api/v1)",
96
+ defaults["base_url"],
97
+ validate=_http_like,
98
+ )
99
+ api_key = prompts.ask("API key", defaults["api_key"], password=True)
100
+ model = prompts.ask("模型名(如 glm-4.6)", defaults["model"])
101
+ if not (base_url and api_key and model):
102
+ print(ui.s("已取消,未保存任何配置。", YELLOW))
103
+ return None
104
+
105
+ path = save_endpoint(api_key, base_url, model)
106
+ print(ui.s(f"✓ 已保存到 {path}", GREEN))
107
+ if prompts.yes_no("现在测试一下连接吗", True):
108
+ probe_endpoint(base_url, api_key)
109
+ return {"api_key": api_key, "base_url": base_url, "model": model}
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import os
15
15
  import re
16
+ import shutil
16
17
  import sys
17
18
  import unicodedata
18
19
 
@@ -81,8 +82,13 @@ def truncate(text: str, width: int) -> str:
81
82
  class UI:
82
83
  """All terminal output goes through here; ``color`` gates every code."""
83
84
 
84
- def __init__(self, color: bool = False):
85
+ def __init__(self, color: bool = False, width: int | None = None):
85
86
  self.color = bool(color)
87
+ self._width = width
88
+
89
+ @property
90
+ def width(self) -> int:
91
+ return max(12, self._width or shutil.get_terminal_size((100, 24)).columns)
86
92
 
87
93
  def s(self, text: str, *styles: str) -> str:
88
94
  """Wrap *text* in *styles* when color is on and text is non-empty."""
@@ -93,36 +99,53 @@ class UI:
93
99
  # -- structural ---------------------------------------------------------
94
100
 
95
101
  def rule(self, char: str = "─", width: int = 60) -> str:
96
- return self.s(char * width, DIM)
102
+ return self.s(char * min(width, self.width), DIM)
97
103
 
98
104
  def kv(self, label: str, value: str) -> str:
99
105
  return f"{self.s(pad(label, 11), CYAN, BOLD)} {value}"
100
106
 
101
107
  def section(self, title: str) -> str:
102
- tail = "─" * max(0, 50 - display_width(title))
103
- return self.s(f"── {title} {tail}", DIM)
108
+ width = min(50, self.width)
109
+ label = f"── {title} "
110
+ if display_width(label) > width:
111
+ return self.s(truncate(label, width), DIM)
112
+ return self.s(label + "─" * (width - display_width(label)), DIM)
104
113
 
105
114
  def table(
106
115
  self, headers: list[str], rows: list[list[str]], cell_styles: list | None = None
107
116
  ) -> str:
108
- """Aligned table; ``cell_styles[i](text) -> str`` styles column i."""
117
+ """Aligned, terminal-width-aware table with display-width styling."""
109
118
  widths = [display_width(h) for h in headers]
110
119
  for row in rows:
111
- for i, cell in enumerate(row):
120
+ for i, cell in enumerate(row[:len(headers)]):
112
121
  widths[i] = max(widths[i], display_width(cell))
122
+ spacing = " " if self.width >= 3 * len(headers) else " "
123
+ available = max(1, self.width - display_width(spacing) * (len(headers) - 1))
124
+ overflow = max(0, sum(widths) - available)
125
+ for i in range(len(widths) - 1, -1, -1):
126
+ floor = min(widths[i], max(1, display_width(headers[i])))
127
+ reduction = min(overflow, widths[i] - floor)
128
+ widths[i] -= reduction
129
+ overflow -= reduction
130
+ for i in range(len(widths) - 1, -1, -1):
131
+ reduction = min(overflow, widths[i] - 1)
132
+ widths[i] -= reduction
133
+ overflow -= reduction
134
+ shown_headers = [truncate(h, widths[i]) for i, h in enumerate(headers)]
113
135
  head = self.s(
114
- " ".join(pad(h, widths[i]) for i, h in enumerate(headers)), BOLD, DIM
136
+ spacing.join(pad(shown_headers[i], widths[i]) for i in range(len(headers))),
137
+ BOLD,
138
+ DIM,
115
139
  )
116
- sep = self.s(" ".join("─" * w for w in widths), DIM)
140
+ sep = self.s(spacing.join("─" * w for w in widths), DIM)
117
141
  lines = [head, sep]
118
142
  for row in rows:
119
143
  cells = []
120
- for i, cell in enumerate(row):
121
- styled = cell
122
- if cell_styles and cell_styles[i]:
123
- styled = cell_styles[i](cell)
144
+ for i, cell in enumerate(row[:len(headers)]):
145
+ shown = truncate(cell, widths[i])
146
+ styled = cell_styles[i](shown) if cell_styles and cell_styles[i] else shown
124
147
  cells.append(pad(styled, widths[i]))
125
- lines.append(" ".join(cells))
148
+ lines.append(spacing.join(cells))
126
149
  return "\n".join(lines)
127
150
 
128
151
  def status(self, text: str) -> str:
@@ -146,12 +169,15 @@ class UI:
146
169
  print(self.s(text, GREEN))
147
170
 
148
171
  def tool_call(self, name: str, args: str) -> None:
149
- print(f" {self.s('⚒', CYAN)} {self.s(name, CYAN, BOLD)} {self.s(args, DIM)}")
172
+ prefix = f" ⚒ {name} "
173
+ detail = truncate(args, max(8, self.width - display_width(prefix)))
174
+ print(f" {self.s('⚒', CYAN)} {self.s(name, CYAN, BOLD)} {self.s(detail, DIM)}")
150
175
 
151
176
  def tool_result(self, ok: bool, summary: str, elapsed: float | None) -> None:
152
177
  mark = self.s("✓", GREEN) if ok else self.s("✗", RED)
153
178
  tail = self.s(f" {elapsed:.1f}s", DIM) if elapsed is not None else ""
154
- print(f" {mark} {self.s(summary, GREEN if ok else RED)}{tail}")
179
+ label = truncate(summary, max(8, self.width - 8 - display_width(tail)))
180
+ print(f" {mark} {self.s(label, GREEN if ok else RED)}{tail}")
155
181
 
156
182
  def error(self, msg: str) -> None:
157
183
  print(f" {self.s('!', RED)} {self.s(msg, RED)}")
@@ -188,15 +214,17 @@ class UI:
188
214
 
189
215
  def banner(self, version: str, model: str, workspace: str) -> str:
190
216
  rows = [
191
- f"lithe {version}",
192
- f"model {model}",
193
- f"workdir {workspace}",
194
- "/help 查看会话命令,/exit 退出",
217
+ f"lithe {version} · command agent",
218
+ f"◆ model {model}",
219
+ f"⌂ workspace {workspace}",
220
+ "/help 查看会话命令 · /exit 退出",
195
221
  ]
196
- width = max(display_width(r) for r in rows) + 2
222
+ inner = max(8, self.width - 4)
223
+ rows = [truncate(row, inner - 2) for row in rows]
224
+ width = max(display_width(row) for row in rows) + 2
197
225
  top = "╭" + "─" * width + "╮"
198
226
  bot = "╰" + "─" * width + "╯"
199
- body = "\n".join("│" + pad(" " + r, width) + "│" for r in rows)
227
+ body = "\n".join("│" + pad(" " + row, width) + "│" for row in rows)
200
228
  art = "\n".join((top, body, bot))
201
229
  return self.s(art, CYAN)
202
230