lithe-cli 0.4.0__tar.gz → 0.5.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.
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/PKG-INFO +2 -2
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/agent.py +22 -3
- lithe_cli-0.5.0/lithe_cli/ui.py +486 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli.egg-info/PKG-INFO +2 -2
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli.egg-info/requires.txt +1 -1
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/pyproject.toml +1 -1
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/tests/test_cli.py +36 -0
- lithe_cli-0.4.0/lithe_cli/ui.py +0 -242
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/LICENSE +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/README.md +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/config.py +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/main.py +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/prompts.py +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli/setup.py +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli.egg-info/SOURCES.txt +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.4.0 → lithe_cli-0.5.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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,7 @@ 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.
|
|
42
|
+
Requires-Dist: lithe>=0.9.8
|
|
43
43
|
Requires-Dist: prompt-toolkit>=3.0.38
|
|
44
44
|
Provides-Extra: dev
|
|
45
45
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -135,6 +135,7 @@ def render_event(
|
|
|
135
135
|
verbose: bool = False,
|
|
136
136
|
stream: bool = False,
|
|
137
137
|
pending: dict | None = None,
|
|
138
|
+
dashboard=None,
|
|
138
139
|
) -> None:
|
|
139
140
|
"""Render one runtime event onto the terminal.
|
|
140
141
|
|
|
@@ -142,6 +143,9 @@ def render_event(
|
|
|
142
143
|
elapsed seconds. ``stream`` suppresses the full-text echo of the final
|
|
143
144
|
answer when its tokens were already streamed.
|
|
144
145
|
"""
|
|
146
|
+
if dashboard is not None:
|
|
147
|
+
dashboard.update(ev, verbose=verbose)
|
|
148
|
+
return
|
|
145
149
|
t = ev.get("type")
|
|
146
150
|
pending = pending if pending is not None else {}
|
|
147
151
|
if t == "assistant_delta":
|
|
@@ -210,14 +214,29 @@ async def execute(
|
|
|
210
214
|
ui.error(f"MCP {name}: {res}")
|
|
211
215
|
ev: dict = {}
|
|
212
216
|
pending: dict[str, float] = {}
|
|
217
|
+
todo_path = cfg.store_dir / f"todos-{cfg.user_id}.json"
|
|
218
|
+
dashboard = ui.dashboard(
|
|
219
|
+
cfg.model or "(scripted)",
|
|
220
|
+
str(cfg.workspace_dir.resolve()),
|
|
221
|
+
rid,
|
|
222
|
+
task,
|
|
223
|
+
cfg.max_steps,
|
|
224
|
+
JsonTodoStore(todo_path).list(),
|
|
225
|
+
)
|
|
213
226
|
try:
|
|
227
|
+
if dashboard is not None:
|
|
228
|
+
dashboard.start()
|
|
214
229
|
async for ev in host.run(ctx, task, history=history):
|
|
215
|
-
render_event(ev, cfg.verbose, cfg.stream, pending)
|
|
230
|
+
render_event(ev, cfg.verbose, cfg.stream, pending, dashboard)
|
|
216
231
|
if ev.get("type") == "done":
|
|
217
|
-
if
|
|
218
|
-
|
|
232
|
+
if dashboard is not None:
|
|
233
|
+
dashboard.finish(ev)
|
|
234
|
+
elif cfg.stream and pending.get("_deltas"):
|
|
235
|
+
print()
|
|
219
236
|
ui.footer(ev)
|
|
220
237
|
finally:
|
|
238
|
+
if dashboard is not None:
|
|
239
|
+
dashboard.close()
|
|
221
240
|
if manager is not None:
|
|
222
241
|
await manager.close()
|
|
223
242
|
return rid, ev, host
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""Zero-dependency terminal rendering for the lithe CLI.
|
|
2
|
+
|
|
3
|
+
Color policy: on only when stdout is a TTY, ``NO_COLOR`` is unset
|
|
4
|
+
(https://no-color.org) and ``TERM`` is not ``dumb``; ``--color`` /
|
|
5
|
+
``--no-color`` override everything. Every helper degrades to plain text
|
|
6
|
+
when color is off, so piped output and test capture stay clean.
|
|
7
|
+
|
|
8
|
+
Widths are computed with East-Asian widths in mind (task text is often
|
|
9
|
+
Chinese), so tables stay aligned where a bare ``len()`` would not.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
import unicodedata
|
|
21
|
+
|
|
22
|
+
RESET = "\x1b[0m"
|
|
23
|
+
BOLD = "\x1b[1m"
|
|
24
|
+
DIM = "\x1b[2m"
|
|
25
|
+
RED = "\x1b[31m"
|
|
26
|
+
GREEN = "\x1b[32m"
|
|
27
|
+
YELLOW = "\x1b[33m"
|
|
28
|
+
BLUE = "\x1b[34m"
|
|
29
|
+
MAGENTA = "\x1b[35m"
|
|
30
|
+
CYAN = "\x1b[36m"
|
|
31
|
+
|
|
32
|
+
# Terminal states worth coloring differently (everything else stays plain).
|
|
33
|
+
_OK_STATES = frozenset({"done"})
|
|
34
|
+
_BAD_STATES = frozenset({"failed", "error", "budget_exceeded", "max_steps"})
|
|
35
|
+
_WARN_STATES = frozenset({"cancelled", "running"})
|
|
36
|
+
|
|
37
|
+
_ROLE_STYLES = {
|
|
38
|
+
"user": BLUE,
|
|
39
|
+
"assistant": GREEN,
|
|
40
|
+
"tool": CYAN,
|
|
41
|
+
"system": MAGENTA,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_color() -> bool:
|
|
46
|
+
"""Auto-detect: a TTY, no NO_COLOR, and TERM not "dumb"."""
|
|
47
|
+
if os.environ.get("NO_COLOR"):
|
|
48
|
+
return False
|
|
49
|
+
if os.environ.get("TERM", "") == "dumb":
|
|
50
|
+
return False
|
|
51
|
+
return bool(sys.stdout.isatty())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def display_width(text: str) -> int:
|
|
58
|
+
"""Column width of *text* counting East-Asian glyphs as double; ANSI
|
|
59
|
+
escape sequences are transparent."""
|
|
60
|
+
clean = _ANSI_RE.sub("", text)
|
|
61
|
+
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in clean)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pad(text: str, width: int) -> str:
|
|
65
|
+
"""Left-aligned *text* padded to *width* display columns."""
|
|
66
|
+
return text + " " * max(0, width - display_width(text))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def truncate(text: str, width: int) -> str:
|
|
70
|
+
"""Cut *text* to *width* display columns, appending an ellipsis."""
|
|
71
|
+
if display_width(text) <= width:
|
|
72
|
+
return text
|
|
73
|
+
out = ""
|
|
74
|
+
used = 0
|
|
75
|
+
for ch in text:
|
|
76
|
+
w = 2 if unicodedata.east_asian_width(ch) in "WF" else 1
|
|
77
|
+
if used + w > width - 1:
|
|
78
|
+
break
|
|
79
|
+
out += ch
|
|
80
|
+
used += w
|
|
81
|
+
return out + "…"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UI:
|
|
85
|
+
"""All terminal output goes through here; ``color`` gates every code."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, color: bool = False, width: int | None = None):
|
|
88
|
+
self.color = bool(color)
|
|
89
|
+
self._width = width
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def width(self) -> int:
|
|
93
|
+
return max(12, self._width or shutil.get_terminal_size((100, 24)).columns)
|
|
94
|
+
|
|
95
|
+
def s(self, text: str, *styles: str) -> str:
|
|
96
|
+
"""Wrap *text* in *styles* when color is on and text is non-empty."""
|
|
97
|
+
if not self.color or not text or not styles:
|
|
98
|
+
return text
|
|
99
|
+
return "".join(styles) + text + RESET
|
|
100
|
+
|
|
101
|
+
# -- structural ---------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def rule(self, char: str = "─", width: int = 60) -> str:
|
|
104
|
+
return self.s(char * min(width, self.width), DIM)
|
|
105
|
+
|
|
106
|
+
def kv(self, label: str, value: str) -> str:
|
|
107
|
+
return f"{self.s(pad(label, 11), CYAN, BOLD)} {value}"
|
|
108
|
+
|
|
109
|
+
def section(self, title: str) -> str:
|
|
110
|
+
width = min(50, self.width)
|
|
111
|
+
label = f"── {title} "
|
|
112
|
+
if display_width(label) > width:
|
|
113
|
+
return self.s(truncate(label, width), DIM)
|
|
114
|
+
return self.s(label + "─" * (width - display_width(label)), DIM)
|
|
115
|
+
|
|
116
|
+
def table(
|
|
117
|
+
self, headers: list[str], rows: list[list[str]], cell_styles: list | None = None
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Aligned, terminal-width-aware table with display-width styling."""
|
|
120
|
+
widths = [display_width(h) for h in headers]
|
|
121
|
+
for row in rows:
|
|
122
|
+
for i, cell in enumerate(row[:len(headers)]):
|
|
123
|
+
widths[i] = max(widths[i], display_width(cell))
|
|
124
|
+
spacing = " " if self.width >= 3 * len(headers) else " "
|
|
125
|
+
available = max(1, self.width - display_width(spacing) * (len(headers) - 1))
|
|
126
|
+
overflow = max(0, sum(widths) - available)
|
|
127
|
+
for i in range(len(widths) - 1, -1, -1):
|
|
128
|
+
floor = min(widths[i], max(1, display_width(headers[i])))
|
|
129
|
+
reduction = min(overflow, widths[i] - floor)
|
|
130
|
+
widths[i] -= reduction
|
|
131
|
+
overflow -= reduction
|
|
132
|
+
for i in range(len(widths) - 1, -1, -1):
|
|
133
|
+
reduction = min(overflow, widths[i] - 1)
|
|
134
|
+
widths[i] -= reduction
|
|
135
|
+
overflow -= reduction
|
|
136
|
+
shown_headers = [truncate(h, widths[i]) for i, h in enumerate(headers)]
|
|
137
|
+
head = self.s(
|
|
138
|
+
spacing.join(pad(shown_headers[i], widths[i]) for i in range(len(headers))),
|
|
139
|
+
BOLD,
|
|
140
|
+
DIM,
|
|
141
|
+
)
|
|
142
|
+
sep = self.s(spacing.join("─" * w for w in widths), DIM)
|
|
143
|
+
lines = [head, sep]
|
|
144
|
+
for row in rows:
|
|
145
|
+
cells = []
|
|
146
|
+
for i, cell in enumerate(row[:len(headers)]):
|
|
147
|
+
shown = truncate(cell, widths[i])
|
|
148
|
+
styled = cell_styles[i](shown) if cell_styles and cell_styles[i] else shown
|
|
149
|
+
cells.append(pad(styled, widths[i]))
|
|
150
|
+
lines.append(spacing.join(cells))
|
|
151
|
+
return "\n".join(lines)
|
|
152
|
+
|
|
153
|
+
def status(self, text: str) -> str:
|
|
154
|
+
if text in _OK_STATES:
|
|
155
|
+
return self.s(text, GREEN)
|
|
156
|
+
if text in _BAD_STATES:
|
|
157
|
+
return self.s(text, RED)
|
|
158
|
+
if text in _WARN_STATES:
|
|
159
|
+
return self.s(text, YELLOW)
|
|
160
|
+
return text
|
|
161
|
+
|
|
162
|
+
def role(self, text: str) -> str:
|
|
163
|
+
return self.s(text, _ROLE_STYLES.get(text, DIM))
|
|
164
|
+
|
|
165
|
+
# -- events -------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def delta(self, text: str) -> None:
|
|
168
|
+
print(text, end="", flush=True)
|
|
169
|
+
|
|
170
|
+
def assistant(self, text: str) -> None:
|
|
171
|
+
print(self.s(text, GREEN))
|
|
172
|
+
|
|
173
|
+
def tool_call(self, name: str, args: str) -> None:
|
|
174
|
+
prefix = f" ⚒ {name} "
|
|
175
|
+
detail = truncate(args, max(8, self.width - display_width(prefix)))
|
|
176
|
+
print(f" {self.s('⚒', CYAN)} {self.s(name, CYAN, BOLD)} {self.s(detail, DIM)}")
|
|
177
|
+
|
|
178
|
+
def tool_result(self, ok: bool, summary: str, elapsed: float | None) -> None:
|
|
179
|
+
mark = self.s("✓", GREEN) if ok else self.s("✗", RED)
|
|
180
|
+
tail = self.s(f" {elapsed:.1f}s", DIM) if elapsed is not None else ""
|
|
181
|
+
label = truncate(summary, max(8, self.width - 8 - display_width(tail)))
|
|
182
|
+
print(f" {mark} {self.s(label, GREEN if ok else RED)}{tail}")
|
|
183
|
+
|
|
184
|
+
def error(self, msg: str) -> None:
|
|
185
|
+
print(f" {self.s('!', RED)} {self.s(msg, RED)}")
|
|
186
|
+
|
|
187
|
+
def warn(self, msg: str) -> None:
|
|
188
|
+
print(f" {self.s('~', YELLOW)} {self.s(msg, YELLOW)}")
|
|
189
|
+
|
|
190
|
+
def usage(self, prompt_tk, completion_tk, ctx_tk, ctx_pct) -> str:
|
|
191
|
+
return self.s(
|
|
192
|
+
f" · tokens p{prompt_tk}/c{completion_tk} "
|
|
193
|
+
f"ctx={ctx_tk or '?'}tk ({ctx_pct}%)",
|
|
194
|
+
DIM,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def reasoning(self, digest: str) -> str:
|
|
198
|
+
return self.s(f" ~ {digest}", DIM)
|
|
199
|
+
|
|
200
|
+
def footer(self, ev: dict) -> None:
|
|
201
|
+
"""The one-line wrap-up after a run: status · steps · tokens · cost."""
|
|
202
|
+
parts = [self.status(str(ev.get("status", "?")))]
|
|
203
|
+
if ev.get("steps") is not None:
|
|
204
|
+
parts.append(f"steps {ev['steps']}")
|
|
205
|
+
tokens = ev.get("tokens")
|
|
206
|
+
if isinstance(tokens, dict):
|
|
207
|
+
tokens = tokens.get("total_tokens")
|
|
208
|
+
if tokens:
|
|
209
|
+
parts.append(f"tokens {tokens}")
|
|
210
|
+
cost = ev.get("cost")
|
|
211
|
+
if cost:
|
|
212
|
+
parts.append(f"cost {cost:.4f}")
|
|
213
|
+
print(f"{self.s('──', DIM)} {' · '.join(parts)}")
|
|
214
|
+
|
|
215
|
+
# -- chat ---------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def banner(self, version: str, model: str, workspace: str) -> str:
|
|
218
|
+
rows = [
|
|
219
|
+
f"lithe {version} · command agent",
|
|
220
|
+
f"◆ model {model}",
|
|
221
|
+
f"⌂ workspace {workspace}",
|
|
222
|
+
"/help 查看会话命令 · /exit 退出",
|
|
223
|
+
]
|
|
224
|
+
inner = max(8, self.width - 4)
|
|
225
|
+
rows = [truncate(row, inner - 2) for row in rows]
|
|
226
|
+
width = max(display_width(row) for row in rows) + 2
|
|
227
|
+
top = "╭" + "─" * width + "╮"
|
|
228
|
+
bot = "╰" + "─" * width + "╯"
|
|
229
|
+
body = "\n".join("│" + pad(" " + row, width) + "│" for row in rows)
|
|
230
|
+
art = "\n".join((top, body, bot))
|
|
231
|
+
return self.s(art, CYAN)
|
|
232
|
+
|
|
233
|
+
def dashboard(
|
|
234
|
+
self,
|
|
235
|
+
model: str,
|
|
236
|
+
workspace: str,
|
|
237
|
+
run_id: str,
|
|
238
|
+
task: str,
|
|
239
|
+
max_steps: int,
|
|
240
|
+
todos: list[dict] | None = None,
|
|
241
|
+
) -> RunDashboard | None:
|
|
242
|
+
if not sys.stdout.isatty() or os.environ.get("TERM") == "dumb":
|
|
243
|
+
return None
|
|
244
|
+
return RunDashboard(self, model, workspace, run_id, task, max_steps, todos)
|
|
245
|
+
|
|
246
|
+
def prompt(self) -> str:
|
|
247
|
+
return self.s("lithe ❯ ", CYAN, BOLD)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class RunDashboard:
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
renderer: UI,
|
|
254
|
+
model: str,
|
|
255
|
+
workspace: str,
|
|
256
|
+
run_id: str,
|
|
257
|
+
task: str,
|
|
258
|
+
max_steps: int,
|
|
259
|
+
todos: list[dict] | None = None,
|
|
260
|
+
):
|
|
261
|
+
self.ui = renderer
|
|
262
|
+
self.model = model
|
|
263
|
+
self.workspace = workspace
|
|
264
|
+
self.run_id = run_id
|
|
265
|
+
self.task = task
|
|
266
|
+
self.max_steps = max_steps
|
|
267
|
+
self.todos = list(todos or [])
|
|
268
|
+
self.status = "starting"
|
|
269
|
+
self.step = 0
|
|
270
|
+
self.tokens = 0
|
|
271
|
+
self.context_tokens = None
|
|
272
|
+
self.context_percent = None
|
|
273
|
+
self.cost = 0.0
|
|
274
|
+
self.started = time.monotonic()
|
|
275
|
+
self.elapsed = 0.0
|
|
276
|
+
self.feed: list[str] = []
|
|
277
|
+
self.tools: list[dict] = []
|
|
278
|
+
self.response = ""
|
|
279
|
+
self.reasoning = ""
|
|
280
|
+
self._delta_pending = False
|
|
281
|
+
self._active = False
|
|
282
|
+
|
|
283
|
+
def _wrap(self, text: str, width: int) -> list[str]:
|
|
284
|
+
if width < 1:
|
|
285
|
+
return []
|
|
286
|
+
lines: list[str] = []
|
|
287
|
+
current = ""
|
|
288
|
+
used = 0
|
|
289
|
+
for char in str(text):
|
|
290
|
+
if char == "\n":
|
|
291
|
+
lines.append(current)
|
|
292
|
+
current = ""
|
|
293
|
+
used = 0
|
|
294
|
+
continue
|
|
295
|
+
char_width = 2 if unicodedata.east_asian_width(char) in "WF" else 1
|
|
296
|
+
if used + char_width > width and current:
|
|
297
|
+
lines.append(current)
|
|
298
|
+
current = ""
|
|
299
|
+
used = 0
|
|
300
|
+
if char_width <= width:
|
|
301
|
+
current += char
|
|
302
|
+
used += char_width
|
|
303
|
+
if current or not lines:
|
|
304
|
+
lines.append(current)
|
|
305
|
+
return lines
|
|
306
|
+
|
|
307
|
+
def _panel(self, title: str, lines: list[str], width: int, height: int) -> list[str]:
|
|
308
|
+
width = max(12, width)
|
|
309
|
+
height = max(4, height)
|
|
310
|
+
inner = width - 2
|
|
311
|
+
label = truncate(f"─ {title} ", inner)
|
|
312
|
+
top = "┌" + label + "─" * max(0, inner - display_width(label)) + "┐"
|
|
313
|
+
bottom = "└" + "─" * inner + "┘"
|
|
314
|
+
content_width = max(1, inner - 2)
|
|
315
|
+
body = []
|
|
316
|
+
for line in lines:
|
|
317
|
+
body.extend(self._wrap(line, content_width))
|
|
318
|
+
visible = body[-(height - 2):]
|
|
319
|
+
visible = ["│ " + pad(truncate(line, content_width), content_width) + " │" for line in visible]
|
|
320
|
+
visible.extend("│ " + " " * content_width + " │" for _ in range(height - 2 - len(visible)))
|
|
321
|
+
return [top, *visible, bottom]
|
|
322
|
+
|
|
323
|
+
def _left_lines(self) -> list[str]:
|
|
324
|
+
lines = ["任务", *self._wrap(self.task, 120), "", "运行记录"]
|
|
325
|
+
lines.extend(self.feed[-12:] or ["等待 Agent 开始处理"])
|
|
326
|
+
if self.reasoning:
|
|
327
|
+
lines.extend(["", "思考摘要", self.reasoning])
|
|
328
|
+
lines.extend(["", "Agent 回复"])
|
|
329
|
+
lines.extend(self._wrap(self.response[-2400:], 120) if self.response else ["等待模型响应"])
|
|
330
|
+
return lines
|
|
331
|
+
|
|
332
|
+
def _right_lines(self) -> list[str]:
|
|
333
|
+
elapsed = self.elapsed or time.monotonic() - self.started
|
|
334
|
+
lines = [
|
|
335
|
+
f"状态 {self.status}",
|
|
336
|
+
f"模型 {self.model}",
|
|
337
|
+
f"步骤 {self.step}/{self.max_steps} · {elapsed:.1f}s",
|
|
338
|
+
f"Run {self.run_id}",
|
|
339
|
+
f"目录 {self.workspace}",
|
|
340
|
+
"",
|
|
341
|
+
f"工具调用 {len(self.tools)}",
|
|
342
|
+
]
|
|
343
|
+
if not self.tools:
|
|
344
|
+
lines.append("暂无调用")
|
|
345
|
+
for tool in self.tools[-3:]:
|
|
346
|
+
mark = {"running": "…", "done": "✓", "failed": "✗"}.get(tool["status"], "·")
|
|
347
|
+
suffix = f" {tool['elapsed']:.1f}s" if tool.get("elapsed") is not None else ""
|
|
348
|
+
lines.append(f"{mark} {tool['name']}{suffix}")
|
|
349
|
+
if tool["status"] == "running" and tool.get("args"):
|
|
350
|
+
lines.append(f" {truncate(tool['args'], 36)}")
|
|
351
|
+
done_count = sum(todo.get("status") == "completed" for todo in self.todos)
|
|
352
|
+
lines.extend(["", f"待办 {done_count}/{len(self.todos)} 完成"])
|
|
353
|
+
if not self.todos:
|
|
354
|
+
lines.append("暂无任务清单")
|
|
355
|
+
markers = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]", "cancelled": "[-]"}
|
|
356
|
+
for todo in self.todos[-4:]:
|
|
357
|
+
marker = markers.get(todo.get("status"), "[ ]")
|
|
358
|
+
lines.append(f"{marker} {todo.get('content', '')}")
|
|
359
|
+
lines.extend([
|
|
360
|
+
"",
|
|
361
|
+
f"Token {self.tokens or '—'} · ${self.cost:.6f}",
|
|
362
|
+
f"上下文 {self.context_tokens or '—'} · {self.context_percent if self.context_percent is not None else '—'}%",
|
|
363
|
+
])
|
|
364
|
+
return lines
|
|
365
|
+
|
|
366
|
+
def render(self, width: int | None = None, height: int | None = None) -> str:
|
|
367
|
+
width = max(12, width or self.ui.width)
|
|
368
|
+
term_height = shutil.get_terminal_size((100, 24)).lines
|
|
369
|
+
height = max(6, height or term_height)
|
|
370
|
+
status = f" {self.status.upper()} · step {self.step}/{self.max_steps} "
|
|
371
|
+
title = " LITHE / AGENT SESSION "
|
|
372
|
+
header = title + status
|
|
373
|
+
header = truncate(header, width)
|
|
374
|
+
if width >= 76:
|
|
375
|
+
left_width = int((width - 1) * 0.66)
|
|
376
|
+
right_width = width - left_width - 1
|
|
377
|
+
panel_height = max(4, height - 3)
|
|
378
|
+
left = self._panel("任务与执行", self._left_lines(), left_width, panel_height)
|
|
379
|
+
right = self._panel("运行侧栏", self._right_lines(), right_width, panel_height)
|
|
380
|
+
rows = [a + " " + b for a, b in zip(left, right, strict=True)]
|
|
381
|
+
else:
|
|
382
|
+
available = max(4, height - 3)
|
|
383
|
+
left_height = max(4, int(available * 0.62))
|
|
384
|
+
right_height = max(4, available - left_height)
|
|
385
|
+
rows = self._panel("任务与执行", self._left_lines(), width, left_height)
|
|
386
|
+
rows.extend(self._panel("运行侧栏", self._right_lines(), width, right_height))
|
|
387
|
+
footer = truncate(" Ctrl+C 中断运行 · 会话结束后显示最终回复 ", width)
|
|
388
|
+
return "\n".join([self.ui.s(header, CYAN, BOLD), *rows, self.ui.s(footer, DIM)])
|
|
389
|
+
|
|
390
|
+
def update(self, event: dict, verbose: bool = False) -> None:
|
|
391
|
+
kind = event.get("type")
|
|
392
|
+
if kind == "run_start":
|
|
393
|
+
self.status = "running"
|
|
394
|
+
self.model = str(event.get("model") or self.model)
|
|
395
|
+
self.max_steps = int(event.get("max_steps") or self.max_steps)
|
|
396
|
+
elif kind == "step":
|
|
397
|
+
self.step = int(event.get("step") or self.step)
|
|
398
|
+
self.status = "thinking"
|
|
399
|
+
elif kind == "assistant_delta":
|
|
400
|
+
text = str(event.get("text") or "")
|
|
401
|
+
self.response += text
|
|
402
|
+
self._delta_pending = True
|
|
403
|
+
self.status = "writing"
|
|
404
|
+
elif kind == "assistant":
|
|
405
|
+
text = str(event.get("text") or "")
|
|
406
|
+
if text and not self._delta_pending:
|
|
407
|
+
self.response += ("\n" if self.response else "") + text
|
|
408
|
+
self._delta_pending = False
|
|
409
|
+
self.status = "running"
|
|
410
|
+
elif kind == "tool_call":
|
|
411
|
+
name = str(event.get("name") or "tool")
|
|
412
|
+
args = event.get("args") or {}
|
|
413
|
+
tool = {
|
|
414
|
+
"id": event.get("id"),
|
|
415
|
+
"name": name,
|
|
416
|
+
"args": json.dumps(args, ensure_ascii=False),
|
|
417
|
+
"started": time.monotonic(),
|
|
418
|
+
"elapsed": None,
|
|
419
|
+
"status": "running",
|
|
420
|
+
}
|
|
421
|
+
self.tools.append(tool)
|
|
422
|
+
self.feed.append(f"调用工具 {name}")
|
|
423
|
+
self.status = f"tool: {name}"
|
|
424
|
+
elif kind == "tool_result":
|
|
425
|
+
tool_id = event.get("id")
|
|
426
|
+
tool = next((item for item in reversed(self.tools) if item.get("id") == tool_id and item["status"] == "running"), None)
|
|
427
|
+
if tool is not None:
|
|
428
|
+
tool["elapsed"] = time.monotonic() - tool["started"]
|
|
429
|
+
tool["status"] = "done" if event.get("ok") else "failed"
|
|
430
|
+
summary = str(event.get("summary") or "").replace("\n", " ")
|
|
431
|
+
mark = "完成" if event.get("ok") else "失败"
|
|
432
|
+
self.feed.append(f"{mark} {summary or (tool or {}).get('name', '工具')}")
|
|
433
|
+
self.status = "thinking"
|
|
434
|
+
elif kind == "todo_change":
|
|
435
|
+
self.todos = list(event.get("new") or [])
|
|
436
|
+
self.feed.append(f"待办清单已更新 {len(self.todos)} 项")
|
|
437
|
+
elif kind == "usage":
|
|
438
|
+
self.tokens += int(event.get("total_tokens") or 0)
|
|
439
|
+
self.context_tokens = event.get("context_tokens", self.context_tokens)
|
|
440
|
+
self.context_percent = event.get("context_percent", self.context_percent)
|
|
441
|
+
self.cost += float(event.get("cost") or 0)
|
|
442
|
+
elif verbose and kind == "reasoning":
|
|
443
|
+
self.reasoning = str(event.get("text") or event.get("summary") or "").replace("\n", " ")[:240]
|
|
444
|
+
elif kind == "error":
|
|
445
|
+
self.status = "failed"
|
|
446
|
+
self.feed.append(f"错误 {event.get('message', '')}")
|
|
447
|
+
elif kind == "cancelled":
|
|
448
|
+
self.status = "cancelled"
|
|
449
|
+
elif kind == "done":
|
|
450
|
+
self.status = str(event.get("status") or "done")
|
|
451
|
+
self.step = int(event.get("steps") or self.step)
|
|
452
|
+
self.tokens = int(event.get("tokens") or self.tokens)
|
|
453
|
+
self.cost = float(event.get("cost") or self.cost)
|
|
454
|
+
self.elapsed = time.monotonic() - self.started
|
|
455
|
+
if self._active:
|
|
456
|
+
sys.stdout.write("\x1b[H\x1b[2J" + self.render())
|
|
457
|
+
sys.stdout.flush()
|
|
458
|
+
|
|
459
|
+
def start(self) -> None:
|
|
460
|
+
self._active = True
|
|
461
|
+
sys.stdout.write("\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H")
|
|
462
|
+
sys.stdout.write(self.render())
|
|
463
|
+
sys.stdout.flush()
|
|
464
|
+
|
|
465
|
+
def finish(self, event: dict) -> None:
|
|
466
|
+
if event.get("type") != "done":
|
|
467
|
+
self.update(event)
|
|
468
|
+
self.close()
|
|
469
|
+
if self.response.strip():
|
|
470
|
+
self.ui.assistant(self.response.strip())
|
|
471
|
+
|
|
472
|
+
def close(self) -> None:
|
|
473
|
+
if self._active:
|
|
474
|
+
sys.stdout.write("\x1b[?25h\x1b[?1049l")
|
|
475
|
+
sys.stdout.flush()
|
|
476
|
+
self._active = False
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# Module-level singleton; main() reconfigures it from flags/env.
|
|
480
|
+
ui = UI(default_color())
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def configure(color: bool | None = None) -> UI:
|
|
484
|
+
"""(Re)configure the singleton; ``None`` means auto-detect."""
|
|
485
|
+
ui.color = default_color() if color is None else bool(color)
|
|
486
|
+
return ui
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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,7 @@ 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.
|
|
42
|
+
Requires-Dist: lithe>=0.9.8
|
|
43
43
|
Requires-Dist: prompt-toolkit>=3.0.38
|
|
44
44
|
Provides-Extra: dev
|
|
45
45
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -9,7 +9,7 @@ description = "Command-line interface for the lithe agent kernel: chat, one-shot
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
|
-
dependencies = ["lithe>=0.9.
|
|
12
|
+
dependencies = ["lithe>=0.9.8", "prompt-toolkit>=3.0.38"]
|
|
13
13
|
keywords = ["agent", "llm", "react", "cli", "tool-calling"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
@@ -244,6 +244,42 @@ def test_ui_banner_fits_terminal_width():
|
|
|
244
244
|
assert "…" in out
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
def test_run_dashboard_shows_tools_todos_and_usage():
|
|
248
|
+
from lithe_cli.ui import RunDashboard, UI, display_width
|
|
249
|
+
|
|
250
|
+
dashboard = RunDashboard(
|
|
251
|
+
UI(color=False), "model-x", "/workspace", "run-123", "检查项目代码", 12
|
|
252
|
+
)
|
|
253
|
+
dashboard.update({"type": "run_start", "model": "model-x", "max_steps": 12})
|
|
254
|
+
dashboard.update({"type": "step", "step": 2})
|
|
255
|
+
dashboard.update({"type": "tool_call", "id": "c1", "name": "read_file", "args": {"path": "main.py"}})
|
|
256
|
+
dashboard.update({"type": "tool_result", "id": "c1", "ok": True, "summary": "已读取文件"})
|
|
257
|
+
dashboard.update({
|
|
258
|
+
"type": "todo_change",
|
|
259
|
+
"new": [{"content": "检查测试", "status": "in_progress"}],
|
|
260
|
+
})
|
|
261
|
+
dashboard.update({"type": "usage", "total_tokens": 120, "context_tokens": 100, "context_percent": 10.0, "cost": 0.001})
|
|
262
|
+
dashboard.update({"type": "assistant", "text": "检查完成"})
|
|
263
|
+
|
|
264
|
+
out = dashboard.render(width=100, height=32)
|
|
265
|
+
assert "LITHE" in out
|
|
266
|
+
assert "read_file" in out
|
|
267
|
+
assert "检查测试" in out
|
|
268
|
+
assert "120" in out
|
|
269
|
+
assert "检查完成" in out
|
|
270
|
+
assert all(display_width(line) <= 100 for line in out.splitlines())
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_run_dashboard_fits_narrow_terminal():
|
|
274
|
+
from lithe_cli.ui import RunDashboard, UI, display_width
|
|
275
|
+
|
|
276
|
+
dashboard = RunDashboard(UI(color=False), "模型", "/工作区", "r1", "处理中文任务", 5)
|
|
277
|
+
out = dashboard.render(width=56, height=24)
|
|
278
|
+
assert all(display_width(line) <= 56 for line in out.splitlines())
|
|
279
|
+
assert "任务与执行" in out
|
|
280
|
+
assert "运行侧栏" in out
|
|
281
|
+
|
|
282
|
+
|
|
247
283
|
def test_no_command_prints_help(capsys):
|
|
248
284
|
from lithe_cli.main import main
|
|
249
285
|
|
lithe_cli-0.4.0/lithe_cli/ui.py
DELETED
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
"""Zero-dependency terminal rendering for the lithe CLI.
|
|
2
|
-
|
|
3
|
-
Color policy: on only when stdout is a TTY, ``NO_COLOR`` is unset
|
|
4
|
-
(https://no-color.org) and ``TERM`` is not ``dumb``; ``--color`` /
|
|
5
|
-
``--no-color`` override everything. Every helper degrades to plain text
|
|
6
|
-
when color is off, so piped output and test capture stay clean.
|
|
7
|
-
|
|
8
|
-
Widths are computed with East-Asian widths in mind (task text is often
|
|
9
|
-
Chinese), so tables stay aligned where a bare ``len()`` would not.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import os
|
|
15
|
-
import re
|
|
16
|
-
import shutil
|
|
17
|
-
import sys
|
|
18
|
-
import unicodedata
|
|
19
|
-
|
|
20
|
-
RESET = "\x1b[0m"
|
|
21
|
-
BOLD = "\x1b[1m"
|
|
22
|
-
DIM = "\x1b[2m"
|
|
23
|
-
RED = "\x1b[31m"
|
|
24
|
-
GREEN = "\x1b[32m"
|
|
25
|
-
YELLOW = "\x1b[33m"
|
|
26
|
-
BLUE = "\x1b[34m"
|
|
27
|
-
MAGENTA = "\x1b[35m"
|
|
28
|
-
CYAN = "\x1b[36m"
|
|
29
|
-
|
|
30
|
-
# Terminal states worth coloring differently (everything else stays plain).
|
|
31
|
-
_OK_STATES = frozenset({"done"})
|
|
32
|
-
_BAD_STATES = frozenset({"failed", "error", "budget_exceeded", "max_steps"})
|
|
33
|
-
_WARN_STATES = frozenset({"cancelled", "running"})
|
|
34
|
-
|
|
35
|
-
_ROLE_STYLES = {
|
|
36
|
-
"user": BLUE,
|
|
37
|
-
"assistant": GREEN,
|
|
38
|
-
"tool": CYAN,
|
|
39
|
-
"system": MAGENTA,
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def default_color() -> bool:
|
|
44
|
-
"""Auto-detect: a TTY, no NO_COLOR, and TERM not "dumb"."""
|
|
45
|
-
if os.environ.get("NO_COLOR"):
|
|
46
|
-
return False
|
|
47
|
-
if os.environ.get("TERM", "") == "dumb":
|
|
48
|
-
return False
|
|
49
|
-
return bool(sys.stdout.isatty())
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def display_width(text: str) -> int:
|
|
56
|
-
"""Column width of *text* counting East-Asian glyphs as double; ANSI
|
|
57
|
-
escape sequences are transparent."""
|
|
58
|
-
clean = _ANSI_RE.sub("", text)
|
|
59
|
-
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in clean)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def pad(text: str, width: int) -> str:
|
|
63
|
-
"""Left-aligned *text* padded to *width* display columns."""
|
|
64
|
-
return text + " " * max(0, width - display_width(text))
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def truncate(text: str, width: int) -> str:
|
|
68
|
-
"""Cut *text* to *width* display columns, appending an ellipsis."""
|
|
69
|
-
if display_width(text) <= width:
|
|
70
|
-
return text
|
|
71
|
-
out = ""
|
|
72
|
-
used = 0
|
|
73
|
-
for ch in text:
|
|
74
|
-
w = 2 if unicodedata.east_asian_width(ch) in "WF" else 1
|
|
75
|
-
if used + w > width - 1:
|
|
76
|
-
break
|
|
77
|
-
out += ch
|
|
78
|
-
used += w
|
|
79
|
-
return out + "…"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class UI:
|
|
83
|
-
"""All terminal output goes through here; ``color`` gates every code."""
|
|
84
|
-
|
|
85
|
-
def __init__(self, color: bool = False, width: int | None = None):
|
|
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)
|
|
92
|
-
|
|
93
|
-
def s(self, text: str, *styles: str) -> str:
|
|
94
|
-
"""Wrap *text* in *styles* when color is on and text is non-empty."""
|
|
95
|
-
if not self.color or not text or not styles:
|
|
96
|
-
return text
|
|
97
|
-
return "".join(styles) + text + RESET
|
|
98
|
-
|
|
99
|
-
# -- structural ---------------------------------------------------------
|
|
100
|
-
|
|
101
|
-
def rule(self, char: str = "─", width: int = 60) -> str:
|
|
102
|
-
return self.s(char * min(width, self.width), DIM)
|
|
103
|
-
|
|
104
|
-
def kv(self, label: str, value: str) -> str:
|
|
105
|
-
return f"{self.s(pad(label, 11), CYAN, BOLD)} {value}"
|
|
106
|
-
|
|
107
|
-
def section(self, title: str) -> str:
|
|
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)
|
|
113
|
-
|
|
114
|
-
def table(
|
|
115
|
-
self, headers: list[str], rows: list[list[str]], cell_styles: list | None = None
|
|
116
|
-
) -> str:
|
|
117
|
-
"""Aligned, terminal-width-aware table with display-width styling."""
|
|
118
|
-
widths = [display_width(h) for h in headers]
|
|
119
|
-
for row in rows:
|
|
120
|
-
for i, cell in enumerate(row[:len(headers)]):
|
|
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)]
|
|
135
|
-
head = self.s(
|
|
136
|
-
spacing.join(pad(shown_headers[i], widths[i]) for i in range(len(headers))),
|
|
137
|
-
BOLD,
|
|
138
|
-
DIM,
|
|
139
|
-
)
|
|
140
|
-
sep = self.s(spacing.join("─" * w for w in widths), DIM)
|
|
141
|
-
lines = [head, sep]
|
|
142
|
-
for row in rows:
|
|
143
|
-
cells = []
|
|
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
|
|
147
|
-
cells.append(pad(styled, widths[i]))
|
|
148
|
-
lines.append(spacing.join(cells))
|
|
149
|
-
return "\n".join(lines)
|
|
150
|
-
|
|
151
|
-
def status(self, text: str) -> str:
|
|
152
|
-
if text in _OK_STATES:
|
|
153
|
-
return self.s(text, GREEN)
|
|
154
|
-
if text in _BAD_STATES:
|
|
155
|
-
return self.s(text, RED)
|
|
156
|
-
if text in _WARN_STATES:
|
|
157
|
-
return self.s(text, YELLOW)
|
|
158
|
-
return text
|
|
159
|
-
|
|
160
|
-
def role(self, text: str) -> str:
|
|
161
|
-
return self.s(text, _ROLE_STYLES.get(text, DIM))
|
|
162
|
-
|
|
163
|
-
# -- events -------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
def delta(self, text: str) -> None:
|
|
166
|
-
print(text, end="", flush=True)
|
|
167
|
-
|
|
168
|
-
def assistant(self, text: str) -> None:
|
|
169
|
-
print(self.s(text, GREEN))
|
|
170
|
-
|
|
171
|
-
def tool_call(self, name: str, args: str) -> None:
|
|
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)}")
|
|
175
|
-
|
|
176
|
-
def tool_result(self, ok: bool, summary: str, elapsed: float | None) -> None:
|
|
177
|
-
mark = self.s("✓", GREEN) if ok else self.s("✗", RED)
|
|
178
|
-
tail = self.s(f" {elapsed:.1f}s", DIM) if elapsed is not None else ""
|
|
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}")
|
|
181
|
-
|
|
182
|
-
def error(self, msg: str) -> None:
|
|
183
|
-
print(f" {self.s('!', RED)} {self.s(msg, RED)}")
|
|
184
|
-
|
|
185
|
-
def warn(self, msg: str) -> None:
|
|
186
|
-
print(f" {self.s('~', YELLOW)} {self.s(msg, YELLOW)}")
|
|
187
|
-
|
|
188
|
-
def usage(self, prompt_tk, completion_tk, ctx_tk, ctx_pct) -> str:
|
|
189
|
-
return self.s(
|
|
190
|
-
f" · tokens p{prompt_tk}/c{completion_tk} "
|
|
191
|
-
f"ctx={ctx_tk or '?'}tk ({ctx_pct}%)",
|
|
192
|
-
DIM,
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
def reasoning(self, digest: str) -> str:
|
|
196
|
-
return self.s(f" ~ {digest}", DIM)
|
|
197
|
-
|
|
198
|
-
def footer(self, ev: dict) -> None:
|
|
199
|
-
"""The one-line wrap-up after a run: status · steps · tokens · cost."""
|
|
200
|
-
parts = [self.status(str(ev.get("status", "?")))]
|
|
201
|
-
if ev.get("steps") is not None:
|
|
202
|
-
parts.append(f"steps {ev['steps']}")
|
|
203
|
-
tokens = ev.get("tokens")
|
|
204
|
-
if isinstance(tokens, dict):
|
|
205
|
-
tokens = tokens.get("total_tokens")
|
|
206
|
-
if tokens:
|
|
207
|
-
parts.append(f"tokens {tokens}")
|
|
208
|
-
cost = ev.get("cost")
|
|
209
|
-
if cost:
|
|
210
|
-
parts.append(f"cost {cost:.4f}")
|
|
211
|
-
print(f"{self.s('──', DIM)} {' · '.join(parts)}")
|
|
212
|
-
|
|
213
|
-
# -- chat ---------------------------------------------------------------
|
|
214
|
-
|
|
215
|
-
def banner(self, version: str, model: str, workspace: str) -> str:
|
|
216
|
-
rows = [
|
|
217
|
-
f"lithe {version} · command agent",
|
|
218
|
-
f"◆ model {model}",
|
|
219
|
-
f"⌂ workspace {workspace}",
|
|
220
|
-
"/help 查看会话命令 · /exit 退出",
|
|
221
|
-
]
|
|
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
|
|
225
|
-
top = "╭" + "─" * width + "╮"
|
|
226
|
-
bot = "╰" + "─" * width + "╯"
|
|
227
|
-
body = "\n".join("│" + pad(" " + row, width) + "│" for row in rows)
|
|
228
|
-
art = "\n".join((top, body, bot))
|
|
229
|
-
return self.s(art, CYAN)
|
|
230
|
-
|
|
231
|
-
def prompt(self) -> str:
|
|
232
|
-
return self.s("lithe ❯ ", CYAN, BOLD)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
# Module-level singleton; main() reconfigures it from flags/env.
|
|
236
|
-
ui = UI(default_color())
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def configure(color: bool | None = None) -> UI:
|
|
240
|
-
"""(Re)configure the singleton; ``None`` means auto-detect."""
|
|
241
|
-
ui.color = default_color() if color is None else bool(color)
|
|
242
|
-
return ui
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|