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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.4.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.7
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"
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.5.0"
@@ -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 cfg.stream and pending.get("_deltas"):
218
- print() # close the streaming line before the footer
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.4.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.7
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"
@@ -1,4 +1,4 @@
1
- lithe>=0.9.7
1
+ lithe>=0.9.8
2
2
  prompt-toolkit>=3.0.38
3
3
 
4
4
  [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.7", "prompt-toolkit>=3.0.38"]
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
 
@@ -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