lithe-cli 0.6.0__tar.gz → 0.6.1__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
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: lithe-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
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
 
@@ -38,13 +38,8 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
38
38
  Classifier: Typing :: Typed
39
39
  Requires-Python: >=3.10
40
40
  Description-Content-Type: text/markdown
41
- License-File: LICENSE
42
- Requires-Dist: lithe>=0.9.8
43
- Requires-Dist: prompt-toolkit>=3.0.38
44
41
  Provides-Extra: dev
45
- Requires-Dist: pytest>=8; extra == "dev"
46
- Requires-Dist: ruff>=0.4; extra == "dev"
47
- Dynamic: license-file
42
+ License-File: LICENSE
48
43
 
49
44
  # lithe-cli
50
45
 
@@ -101,14 +96,16 @@ servers — so you can see what a run would use before starting one.
101
96
  ```bash
102
97
  $ cd my-project
103
98
  $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
104
- ⚒ read_file {"path": "README.md"}
99
+ ⚒ read_file 读取 README.md
105
100
  ✓ 已读取 README.md(120 行) 0.1s
106
- ⚒ write_file {"path": "SUMMARY.md", "content": "..."}
101
+ ⚒ write_file 写入 SUMMARY.md
107
102
  ✓ 写入 SUMMARY.md 0.0s
108
103
  已把要点写入 SUMMARY.md。
109
104
  ── done · steps 3 · tokens 2100 · cost 0.0042
110
105
  ```
111
106
 
107
+ 修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file`。工具调用界面只显示工具名、文件路径等摘要,不再展开完整参数。
108
+
112
109
  `--stream` streams tokens as they generate; `-v` adds per-call
113
110
  usage/context gauges; `--max-steps` caps the tool loop;
114
111
  `--context-window` enables fullness gauges.
@@ -120,35 +117,38 @@ persistent full-screen interface instead of scribbling one-line events
120
117
  into the console:
121
118
 
122
119
  ```text
123
- lithe 0.6.0 · your-model · /home/me/my-project ● 完成 · 0.2s
124
- ┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
120
+ lithe 0.6.1 · your-model · /home/me/my-project ● 完成 · 0.2s
121
+ ╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
125
122
  │ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
126
- │ ⚒ update_todos {"todos": [...]} ││ 步骤 2/35 · 0.2s │
123
+ │ ◆ update_todos ││ 步骤 2/35 · 0.2s │
127
124
  │ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
128
- │ ⚒ write_file {"path": "a.txt"} ││ ✓ write_file 0.0s │
129
- │ ✓ 写入 a.txt 0.0s ││ ── 待办 1/3 │
125
+ │ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
126
+ │ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
130
127
  │ 已完成。 ││ [~] 修改 a.txt │
131
- │ ││ ── 用量 │
132
- │ ││ tokens 1,240 │
133
- └─────────────────────────────────────┘└────────────────────┘
128
+ │ ││ ◆ 会话用量 │
129
+ │ ││ 输入 1,024 · 输出 216│
130
+ │ ││ 缓存输入 768 │
131
+ │ ││ 合计 1,240 · $0.0021│
132
+ │ ││ 上下文 1,024 / 128,000 (0.8%)│
133
+ ╰─────────────────────────────────────╯╰────────────────────╯
134
134
  lithe ❯ _
135
135
  ● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
136
136
  ```
137
137
 
138
- The left pane is the conversation (task, tool calls and outcomes, the
139
- streaming assistant reply); the right sidebar shows run status, step
140
- budget and elapsed time, recent tool calls with duration, the agent's
141
- todo list (updated live as the agent rewrites it), and running token /
142
- cost / context totals. The screen stays up between turns — a finished
143
- run does not dump you back into the raw console. `Ctrl+C` cancels the
144
- current turn while running and exits when idle; in `lithe run` mode the
145
- result stays on screen until you press `q`. Layout, wrapping and
138
+ The left pane is the conversation (task, compact tool-call summaries
139
+ and outcomes, and the streaming assistant reply). The right sidebar shows
140
+ run status, recent tools, todos, session-wide cumulative input/output/
141
+ cached tokens and cost, plus the latest model-call context length and
142
+ window percentage. The screen stays up between turns; `Ctrl+C` cancels
143
+ the current turn while running and exits when idle. In `lithe run` mode
144
+ the result stays on screen until you press `q`. Layout, wrapping and
146
145
  alignment are East-Asian-width aware; narrower terminals stack the two
147
146
  panes vertically. Pipes and CI keep the plain per-line output unchanged.
148
147
 
149
148
  History carries across turns within a session (each turn is its own run
150
149
  in the store, replayed as context for the next). `/new` clears the
151
- session, `/tools` lists the registered tools, `/help` lists the commands.
150
+ conversation history and session usage totals; `/tools` lists the
151
+ registered tools, `/help` lists the commands.
152
152
 
153
153
  ### Extra capabilities
154
154
 
@@ -53,14 +53,16 @@ servers — so you can see what a run would use before starting one.
53
53
  ```bash
54
54
  $ cd my-project
55
55
  $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
56
- ⚒ read_file {"path": "README.md"}
56
+ ⚒ read_file 读取 README.md
57
57
  ✓ 已读取 README.md(120 行) 0.1s
58
- ⚒ write_file {"path": "SUMMARY.md", "content": "..."}
58
+ ⚒ write_file 写入 SUMMARY.md
59
59
  ✓ 写入 SUMMARY.md 0.0s
60
60
  已把要点写入 SUMMARY.md。
61
61
  ── done · steps 3 · tokens 2100 · cost 0.0042
62
62
  ```
63
63
 
64
+ 修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file`。工具调用界面只显示工具名、文件路径等摘要,不再展开完整参数。
65
+
64
66
  `--stream` streams tokens as they generate; `-v` adds per-call
65
67
  usage/context gauges; `--max-steps` caps the tool loop;
66
68
  `--context-window` enables fullness gauges.
@@ -72,35 +74,38 @@ persistent full-screen interface instead of scribbling one-line events
72
74
  into the console:
73
75
 
74
76
  ```text
75
- lithe 0.6.0 · your-model · /home/me/my-project ● 完成 · 0.2s
76
- ┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
77
+ lithe 0.6.1 · your-model · /home/me/my-project ● 完成 · 0.2s
78
+ ╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
77
79
  │ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
78
- │ ⚒ update_todos {"todos": [...]} ││ 步骤 2/35 · 0.2s │
80
+ │ ◆ update_todos ││ 步骤 2/35 · 0.2s │
79
81
  │ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
80
- │ ⚒ write_file {"path": "a.txt"} ││ ✓ write_file 0.0s │
81
- │ ✓ 写入 a.txt 0.0s ││ ── 待办 1/3 │
82
+ │ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
83
+ │ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
82
84
  │ 已完成。 ││ [~] 修改 a.txt │
83
- │ ││ ── 用量 │
84
- │ ││ tokens 1,240 │
85
- └─────────────────────────────────────┘└────────────────────┘
85
+ │ ││ ◆ 会话用量 │
86
+ │ ││ 输入 1,024 · 输出 216│
87
+ │ ││ 缓存输入 768 │
88
+ │ ││ 合计 1,240 · $0.0021│
89
+ │ ││ 上下文 1,024 / 128,000 (0.8%)│
90
+ ╰─────────────────────────────────────╯╰────────────────────╯
86
91
  lithe ❯ _
87
92
  ● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
88
93
  ```
89
94
 
90
- The left pane is the conversation (task, tool calls and outcomes, the
91
- streaming assistant reply); the right sidebar shows run status, step
92
- budget and elapsed time, recent tool calls with duration, the agent's
93
- todo list (updated live as the agent rewrites it), and running token /
94
- cost / context totals. The screen stays up between turns — a finished
95
- run does not dump you back into the raw console. `Ctrl+C` cancels the
96
- current turn while running and exits when idle; in `lithe run` mode the
97
- result stays on screen until you press `q`. Layout, wrapping and
95
+ The left pane is the conversation (task, compact tool-call summaries
96
+ and outcomes, and the streaming assistant reply). The right sidebar shows
97
+ run status, recent tools, todos, session-wide cumulative input/output/
98
+ cached tokens and cost, plus the latest model-call context length and
99
+ window percentage. The screen stays up between turns; `Ctrl+C` cancels
100
+ the current turn while running and exits when idle. In `lithe run` mode
101
+ the result stays on screen until you press `q`. Layout, wrapping and
98
102
  alignment are East-Asian-width aware; narrower terminals stack the two
99
103
  panes vertically. Pipes and CI keep the plain per-line output unchanged.
100
104
 
101
105
  History carries across turns within a session (each turn is its own run
102
106
  in the store, replayed as context for the next). `/new` clears the
103
- session, `/tools` lists the registered tools, `/help` lists the commands.
107
+ conversation history and session usage totals; `/tools` lists the
108
+ registered tools, `/help` lists the commands.
104
109
 
105
110
  ### Extra capabilities
106
111
 
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.1"
@@ -17,7 +17,6 @@ Everything here maps lithe's host contract onto CLI defaults:
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
- import json
21
20
  import shutil
22
21
  import sys
23
22
  import time
@@ -43,7 +42,8 @@ from .ui import CYAN, GREEN, YELLOW, ui
43
42
 
44
43
  SYSTEM_PROMPT_BASE = (
45
44
  "你是运行在命令行里的助理,工作区是用户的当前目录。"
46
- "用提供的文件工具完成任务(读写前先读、谨慎修改),"
45
+ "用提供的文件工具完成任务(读写前先读、谨慎修改);修改已有文件时优先用 edit_file 或 apply_patch 做局部修改,"
46
+ "仅在新建文件或确需整体重写时使用 write_file。"
47
47
  "需要多步任务时先用 update_todos 列出计划,最后用简洁中文汇报结果。"
48
48
  )
49
49
 
@@ -154,7 +154,7 @@ def render_event(
154
154
  if text and not (stream and pending.get("_deltas")):
155
155
  ui.assistant(text)
156
156
  elif t == "tool_call":
157
- args = json.dumps(ev.get("args", {}), ensure_ascii=False)
157
+ args = ev.get("args") or {}
158
158
  ui.tool_call(str(ev.get("name")), args)
159
159
  if ev.get("id") is not None:
160
160
  pending[ev["id"]] = time.monotonic()
@@ -15,7 +15,6 @@ identically under tests.
15
15
  from __future__ import annotations
16
16
 
17
17
  import asyncio
18
- import json
19
18
  import os
20
19
  import shutil
21
20
  import sys
@@ -37,7 +36,7 @@ from prompt_toolkit.layout import (
37
36
  )
38
37
  from prompt_toolkit.styles import Style
39
38
 
40
- from .ui import display_width, pad, truncate, wrap_text
39
+ from .ui import display_width, pad, tool_call_label, truncate, wrap_text
41
40
 
42
41
  _TODO_MARKS = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]", "cancelled": "[-]"}
43
42
  _TODO_CLASSES = {
@@ -50,25 +49,25 @@ _STATUS_DONE = "done"
50
49
 
51
50
  _STYLE = Style.from_dict(
52
51
  {
53
- "hdr": "bg:ansiblue fg:ansiwhite bold",
54
- "foot": "bg:ansibrightblack fg:ansiwhite",
55
- "border": "fg:ansibrightblack",
56
- "title": "fg:ansicyan bold",
57
- "prompt": "fg:ansicyan bold",
58
- "user": "fg:ansiblue bold",
59
- "assistant": "fg:ansigreen",
60
- "tool": "fg:ansicyan",
61
- "ok": "fg:ansigreen",
62
- "err": "fg:ansired bold",
63
- "warn": "fg:ansiyellow",
64
- "dim": "fg:ansibrightblack",
65
- "reason": "fg:ansimagenta",
66
- "todo-ip": "fg:ansiyellow",
67
- "todo-done": "fg:ansigreen",
68
- "st-idle": "fg:ansicyan bold",
69
- "st-run": "fg:ansiyellow bold",
70
- "st-ok": "fg:ansigreen bold",
71
- "st-bad": "fg:ansired bold",
52
+ "hdr": "bg:#172033 fg:#e2e8f0 bold",
53
+ "foot": "bg:#111827 fg:#cbd5e1",
54
+ "border": "fg:#475569",
55
+ "title": "fg:#38bdf8 bold",
56
+ "prompt": "fg:#38bdf8 bold",
57
+ "user": "fg:#60a5fa bold",
58
+ "assistant": "fg:#4ade80",
59
+ "tool": "fg:#22d3ee",
60
+ "ok": "fg:#4ade80",
61
+ "err": "fg:#f87171 bold",
62
+ "warn": "fg:#fbbf24",
63
+ "dim": "fg:#94a3b8",
64
+ "reason": "fg:#c084fc",
65
+ "todo-ip": "fg:#fbbf24",
66
+ "todo-done": "fg:#4ade80",
67
+ "st-idle": "fg:#22d3ee bold",
68
+ "st-run": "fg:#fbbf24 bold",
69
+ "st-ok": "fg:#4ade80 bold",
70
+ "st-bad": "fg:#f87171 bold",
72
71
  }
73
72
  )
74
73
 
@@ -102,6 +101,16 @@ class TuiState:
102
101
  self.step = 0
103
102
  self.tokens = 0
104
103
  self.cost = 0.0
104
+ self.input_tokens = 0
105
+ self.output_tokens = 0
106
+ self.cached_tokens = 0
107
+ self._turn_tokens = 0
108
+ self._turn_cost = 0.0
109
+ self._turn_input_tokens = 0
110
+ self._turn_output_tokens = 0
111
+ self._turn_cached_tokens = 0
112
+ self.context_tokens = None
113
+ self.context_window = None
105
114
  self.context_percent = None
106
115
  self.tools: list[dict] = []
107
116
  self.feed: list[tuple[str, str]] = []
@@ -125,13 +134,34 @@ class TuiState:
125
134
 
126
135
  # -- events -------------------------------------------------------------
127
136
 
137
+ def reset_usage(self) -> None:
138
+ self.tokens = 0
139
+ self.cost = 0.0
140
+ self.input_tokens = 0
141
+ self.output_tokens = 0
142
+ self.cached_tokens = 0
143
+ self._turn_tokens = 0
144
+ self._turn_cost = 0.0
145
+ self._turn_input_tokens = 0
146
+ self._turn_output_tokens = 0
147
+ self._turn_cached_tokens = 0
148
+ self.context_tokens = None
149
+ self.context_window = None
150
+ self.context_percent = None
151
+
128
152
  def on_event(self, ev: dict) -> None:
129
153
  kind = ev.get("type")
130
154
  if kind == "run_start":
131
155
  self.status = "思考中"
132
156
  self.step = 0
133
- self.tokens = 0
134
- self.cost = 0.0
157
+ self._turn_tokens = 0
158
+ self._turn_cost = 0.0
159
+ self._turn_input_tokens = 0
160
+ self._turn_output_tokens = 0
161
+ self._turn_cached_tokens = 0
162
+ self.context_tokens = None
163
+ self.context_window = None
164
+ self.context_percent = None
135
165
  self.tools = []
136
166
  self.started = time.monotonic()
137
167
  elif kind == "step":
@@ -146,17 +176,18 @@ class TuiState:
146
176
  self.streaming = ""
147
177
  elif kind == "tool_call":
148
178
  name = str(ev.get("name") or "tool")
179
+ args = ev.get("args")
180
+ args = args if isinstance(args, dict) else {}
149
181
  self.tools.append(
150
182
  {
151
183
  "id": ev.get("id"),
152
184
  "name": name,
153
- "args": json.dumps(ev.get("args") or {}, ensure_ascii=False),
154
185
  "status": "running",
155
186
  "t0": time.monotonic(),
156
187
  "elapsed": None,
157
188
  }
158
189
  )
159
- self.say("tool", f"⚒ {name} {self.tools[-1]['args']}")
190
+ self.say("tool", f"◆ {tool_call_label(name, args)}")
160
191
  self.status = f"工具 {name}"
161
192
  elif kind == "tool_result":
162
193
  self._settle_tool(ev.get("id"), bool(ev.get("ok")), ev.get("summary"))
@@ -164,8 +195,15 @@ class TuiState:
164
195
  self.todos = list(ev.get("new") or [])
165
196
  self.say("dim", f"□ 任务清单已更新({len(self.todos)} 项)")
166
197
  elif kind == "usage":
167
- self.tokens += int(ev.get("total_tokens") or 0)
168
- self.cost += float(ev.get("cost") or 0)
198
+ self._turn_input_tokens += int(ev.get("prompt_tokens") or 0)
199
+ self._turn_output_tokens += int(ev.get("completion_tokens") or 0)
200
+ self._turn_cached_tokens += int(ev.get("cached_tokens") or 0)
201
+ self._turn_tokens += int(ev.get("total_tokens") or 0)
202
+ self._turn_cost += float(ev.get("cost") or 0)
203
+ if ev.get("context_tokens") is not None:
204
+ self.context_tokens = int(ev["context_tokens"])
205
+ if ev.get("context_window") is not None:
206
+ self.context_window = int(ev["context_window"])
169
207
  if ev.get("context_percent") is not None:
170
208
  self.context_percent = ev["context_percent"]
171
209
  elif kind == "reasoning":
@@ -189,8 +227,30 @@ class TuiState:
189
227
  "empty_response": "空响应",
190
228
  }.get(status, status)
191
229
  self.step = int(ev.get("steps") or self.step)
192
- self.tokens = int(ev.get("tokens") or self.tokens)
193
- self.cost = float(ev.get("cost") or self.cost)
230
+ if ev.get("prompt_tokens") is not None:
231
+ self._turn_input_tokens = int(ev["prompt_tokens"])
232
+ if ev.get("completion_tokens") is not None:
233
+ self._turn_output_tokens = int(ev["completion_tokens"])
234
+ if ev.get("tokens") is not None:
235
+ self._turn_tokens = int(ev["tokens"])
236
+ if ev.get("cost") is not None:
237
+ self._turn_cost = float(ev["cost"])
238
+ self.input_tokens += self._turn_input_tokens
239
+ self.output_tokens += self._turn_output_tokens
240
+ self.cached_tokens += self._turn_cached_tokens
241
+ self.tokens += self._turn_tokens
242
+ self.cost += self._turn_cost
243
+ self._turn_tokens = 0
244
+ self._turn_cost = 0.0
245
+ self._turn_input_tokens = 0
246
+ self._turn_output_tokens = 0
247
+ self._turn_cached_tokens = 0
248
+ if ev.get("context_tokens") is not None:
249
+ self.context_tokens = int(ev["context_tokens"])
250
+ if ev.get("context_window") is not None:
251
+ self.context_window = int(ev["context_window"])
252
+ if ev.get("context_percent") is not None:
253
+ self.context_percent = ev["context_percent"]
194
254
  if self.started is not None:
195
255
  self.status += f" · {time.monotonic() - self.started:.1f}s"
196
256
  self.invalidate()
@@ -228,9 +288,9 @@ def _panel(title: str, lines: list[tuple[str, str]], width: int, height: int) ->
228
288
  label = truncate(f" {title} ", inner)
229
289
  rows = [
230
290
  [
231
- ("class:border", "┌"),
291
+ ("class:border", "╭"),
232
292
  ("class:title", label),
233
- ("class:border", "─" * (inner - display_width(label)) + "┐"),
293
+ ("class:border", "─" * (width - display_width(label) - 2) + "╮"),
234
294
  ]
235
295
  ]
236
296
  wrapped: list[tuple[str, str]] = []
@@ -242,7 +302,7 @@ def _panel(title: str, lines: list[tuple[str, str]], width: int, height: int) ->
242
302
  )
243
303
  for _ in range(height - 2 - (len(rows) - 1)):
244
304
  rows.append([("class:border", "│ "), ("", " " * inner), ("class:border", " │")])
245
- rows.append([("class:border", "└" + "─" * inner + "┘")])
305
+ rows.append([("class:border", "╰" + "─" * (inner + 2) + "╯")])
246
306
  return rows
247
307
 
248
308
 
@@ -266,8 +326,8 @@ def _sidebar_lines(state: TuiState) -> list[tuple[str, str]]:
266
326
  (cls, f"● {state.status}"),
267
327
  ("dim", f"步骤 {state.step}/{state.max_steps} · {elapsed:.1f}s"),
268
328
  ("dim", f"模型 {state.model}"),
269
- ("dim", f"目录 {state.workspace}"),
270
- ("dim", "── 工具调用"),
329
+ ("dim", f"工作区 {state.workspace}"),
330
+ ("dim", "◆ 工具"),
271
331
  ]
272
332
  if not state.tools:
273
333
  lines.append(("dim", "暂无调用"))
@@ -277,19 +337,32 @@ def _sidebar_lines(state: TuiState) -> list[tuple[str, str]]:
277
337
  lines.append(("ok" if tool["status"] == "done" else "err" if tool["status"] == "failed" else "tool",
278
338
  f"{mark} {tool['name']}{suffix}"))
279
339
  done = sum(todo.get("status") == "completed" for todo in state.todos)
280
- lines.extend([("dim", f"── 待办 {done}/{len(state.todos)}")])
340
+ lines.append(("dim", f"◆ 待办 {done}/{len(state.todos)}"))
281
341
  if not state.todos:
282
342
  lines.append(("dim", "暂无任务清单"))
283
343
  for todo in state.todos[-5:]:
284
344
  mark = _TODO_MARKS.get(todo.get("status"), "[ ]")
285
345
  cls_todo = _TODO_CLASSES.get(todo.get("status"), "")
286
346
  lines.append((cls_todo, f"{mark} {todo.get('content', '')}"))
287
- ctx = "—" if state.context_percent is None else f"{state.context_percent}%"
347
+ input_tokens = state.input_tokens + state._turn_input_tokens
348
+ output_tokens = state.output_tokens + state._turn_output_tokens
349
+ cached_tokens = state.cached_tokens + state._turn_cached_tokens
350
+ tokens = state.tokens + state._turn_tokens
351
+ cost = state.cost + state._turn_cost
352
+ context = "—"
353
+ if state.context_tokens is not None:
354
+ context = f"{state.context_tokens:,}"
355
+ if state.context_window:
356
+ context += f" / {state.context_window:,}"
357
+ if state.context_percent is not None:
358
+ context += f" ({state.context_percent}%)"
288
359
  lines.extend(
289
360
  [
290
- ("dim", "── 用量"),
291
- ("", f"tokens {state.tokens:,}"),
292
- ("", f"cost ${state.cost:.4f} · ctx {ctx}"),
361
+ ("dim", "◆ 会话用量"),
362
+ ("", f"输入 {input_tokens:,} · 输出 {output_tokens:,}"),
363
+ ("", f"缓存输入 {cached_tokens:,}"),
364
+ ("", f"合计 {tokens:,} · ${cost:.4f}"),
365
+ ("", f"上下文 {context}"),
293
366
  ]
294
367
  )
295
368
  return lines
@@ -327,9 +400,11 @@ def compose_frame(state: TuiState, width: int, height: int, version: str) -> lis
327
400
 
328
401
  def compose_footer(state: TuiState, width: int, running_hint: str) -> list:
329
402
  dot = f"class:foot class:{_status_class(state.status)}"
403
+ total_tokens = state.tokens + state._turn_tokens
404
+ total_cost = state.cost + state._turn_cost
330
405
  left = (
331
406
  f" ● {state.status} · step {state.step}/{state.max_steps}"
332
- f" · {state.tokens:,} tok · ${state.cost:.4f}"
407
+ f" · {total_tokens:,} tok · ${total_cost:.4f}"
333
408
  )
334
409
  room = width - display_width(running_hint) - display_width(left) - 2
335
410
  text = left + (" " * max(1, room)) if room >= 1 else truncate(left, width - display_width(running_hint) - 1)
@@ -459,7 +534,8 @@ async def run_screen(cfg, task: str | None, mode: str) -> int:
459
534
  return True
460
535
  if line == "/new":
461
536
  run_ids.clear()
462
- state.say("dim", "(已清空会话历史)")
537
+ state.reset_usage()
538
+ state.say("dim", "(已清空会话历史与用量统计)")
463
539
  return True
464
540
  return False
465
541
 
@@ -105,6 +105,27 @@ def wrap_text(text: str, width: int) -> list[str]:
105
105
  return lines
106
106
 
107
107
 
108
+ def tool_call_label(name: str, args: dict) -> str:
109
+ path = str(args.get("path") or args.get("file_path") or "").strip()
110
+ if name in {"read_file", "write_file", "edit_file"}:
111
+ action = {"read_file": "读取", "write_file": "写入", "edit_file": "局部修改"}[name]
112
+ return f"{name} · {action} {path}" if path else name
113
+ if name == "apply_patch":
114
+ patch = str(args.get("patch_text") or "")
115
+ paths = [
116
+ line.partition(":")[2].strip()
117
+ for line in patch.splitlines()
118
+ if line.startswith(("*** Update File:", "*** Add File:", "*** Delete File:"))
119
+ ]
120
+ files = "、".join(dict.fromkeys(paths))
121
+ return f"{name} · {files}" if files else f"{name} · 文件补丁"
122
+ for key in ("query", "pattern", "agent", "command", "url"):
123
+ value = args.get(key)
124
+ if isinstance(value, str) and value.strip():
125
+ return f"{name} · {value.strip()[:80]}"
126
+ return name
127
+
128
+
108
129
  class UI:
109
130
  """All terminal output goes through here; ``color`` gates every code."""
110
131
 
@@ -194,9 +215,10 @@ class UI:
194
215
  def assistant(self, text: str) -> None:
195
216
  print(self.s(text, GREEN))
196
217
 
197
- def tool_call(self, name: str, args: str) -> None:
218
+ def tool_call(self, name: str, args: dict) -> None:
219
+ label = tool_call_label(name, args if isinstance(args, dict) else {})
198
220
  prefix = f" ⚒ {name} "
199
- detail = truncate(args, max(8, self.width - display_width(prefix)))
221
+ detail = truncate(label[len(name):].strip(" ·"), max(8, self.width - display_width(prefix)))
200
222
  print(f" {self.s('⚒', CYAN)} {self.s(name, CYAN, BOLD)} {self.s(detail, DIM)}")
201
223
 
202
224
  def tool_result(self, ok: bool, summary: str, elapsed: float | None) -> None:
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: lithe-cli
3
- Version: 0.6.0
3
+ Version: 0.6.1
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
 
@@ -38,13 +38,8 @@ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
38
38
  Classifier: Typing :: Typed
39
39
  Requires-Python: >=3.10
40
40
  Description-Content-Type: text/markdown
41
- License-File: LICENSE
42
- Requires-Dist: lithe>=0.9.8
43
- Requires-Dist: prompt-toolkit>=3.0.38
44
41
  Provides-Extra: dev
45
- Requires-Dist: pytest>=8; extra == "dev"
46
- Requires-Dist: ruff>=0.4; extra == "dev"
47
- Dynamic: license-file
42
+ License-File: LICENSE
48
43
 
49
44
  # lithe-cli
50
45
 
@@ -101,14 +96,16 @@ servers — so you can see what a run would use before starting one.
101
96
  ```bash
102
97
  $ cd my-project
103
98
  $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
104
- ⚒ read_file {"path": "README.md"}
99
+ ⚒ read_file 读取 README.md
105
100
  ✓ 已读取 README.md(120 行) 0.1s
106
- ⚒ write_file {"path": "SUMMARY.md", "content": "..."}
101
+ ⚒ write_file 写入 SUMMARY.md
107
102
  ✓ 写入 SUMMARY.md 0.0s
108
103
  已把要点写入 SUMMARY.md。
109
104
  ── done · steps 3 · tokens 2100 · cost 0.0042
110
105
  ```
111
106
 
107
+ 修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file`。工具调用界面只显示工具名、文件路径等摘要,不再展开完整参数。
108
+
112
109
  `--stream` streams tokens as they generate; `-v` adds per-call
113
110
  usage/context gauges; `--max-steps` caps the tool loop;
114
111
  `--context-window` enables fullness gauges.
@@ -120,35 +117,38 @@ persistent full-screen interface instead of scribbling one-line events
120
117
  into the console:
121
118
 
122
119
  ```text
123
- lithe 0.6.0 · your-model · /home/me/my-project ● 完成 · 0.2s
124
- ┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
120
+ lithe 0.6.1 · your-model · /home/me/my-project ● 完成 · 0.2s
121
+ ╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
125
122
  │ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
126
- │ ⚒ update_todos {"todos": [...]} ││ 步骤 2/35 · 0.2s │
123
+ │ ◆ update_todos ││ 步骤 2/35 · 0.2s │
127
124
  │ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
128
- │ ⚒ write_file {"path": "a.txt"} ││ ✓ write_file 0.0s │
129
- │ ✓ 写入 a.txt 0.0s ││ ── 待办 1/3 │
125
+ │ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
126
+ │ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
130
127
  │ 已完成。 ││ [~] 修改 a.txt │
131
- │ ││ ── 用量 │
132
- │ ││ tokens 1,240 │
133
- └─────────────────────────────────────┘└────────────────────┘
128
+ │ ││ ◆ 会话用量 │
129
+ │ ││ 输入 1,024 · 输出 216│
130
+ │ ││ 缓存输入 768 │
131
+ │ ││ 合计 1,240 · $0.0021│
132
+ │ ││ 上下文 1,024 / 128,000 (0.8%)│
133
+ ╰─────────────────────────────────────╯╰────────────────────╯
134
134
  lithe ❯ _
135
135
  ● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
136
136
  ```
137
137
 
138
- The left pane is the conversation (task, tool calls and outcomes, the
139
- streaming assistant reply); the right sidebar shows run status, step
140
- budget and elapsed time, recent tool calls with duration, the agent's
141
- todo list (updated live as the agent rewrites it), and running token /
142
- cost / context totals. The screen stays up between turns — a finished
143
- run does not dump you back into the raw console. `Ctrl+C` cancels the
144
- current turn while running and exits when idle; in `lithe run` mode the
145
- result stays on screen until you press `q`. Layout, wrapping and
138
+ The left pane is the conversation (task, compact tool-call summaries
139
+ and outcomes, and the streaming assistant reply). The right sidebar shows
140
+ run status, recent tools, todos, session-wide cumulative input/output/
141
+ cached tokens and cost, plus the latest model-call context length and
142
+ window percentage. The screen stays up between turns; `Ctrl+C` cancels
143
+ the current turn while running and exits when idle. In `lithe run` mode
144
+ the result stays on screen until you press `q`. Layout, wrapping and
146
145
  alignment are East-Asian-width aware; narrower terminals stack the two
147
146
  panes vertically. Pipes and CI keep the plain per-line output unchanged.
148
147
 
149
148
  History carries across turns within a session (each turn is its own run
150
149
  in the store, replayed as context for the next). `/new` clears the
151
- session, `/tools` lists the registered tools, `/help` lists the commands.
150
+ conversation history and session usage totals; `/tools` lists the
151
+ registered tools, `/help` lists the commands.
152
152
 
153
153
  ### Extra capabilities
154
154
 
@@ -244,6 +244,18 @@ def test_ui_banner_fits_terminal_width():
244
244
  assert "…" in out
245
245
 
246
246
 
247
+ def test_ui_tool_call_hides_full_file_payload(capsys):
248
+ from lithe_cli.ui import UI
249
+
250
+ UI(color=False, width=80).tool_call(
251
+ "edit_file",
252
+ {"path": "src/example.py", "old_text": "private old text", "new_text": "replacement"},
253
+ )
254
+ output = capsys.readouterr().out
255
+ assert "edit_file" in output and "src/example.py" in output
256
+ assert "private old text" not in output and "replacement" not in output
257
+
258
+
247
259
  def test_run_dashboard_shows_tools_todos_and_usage():
248
260
  from lithe_cli.tui import TuiState
249
261
 
@@ -256,16 +268,80 @@ def test_run_dashboard_shows_tools_todos_and_usage():
256
268
  "type": "todo_change",
257
269
  "new": [{"content": "检查测试", "status": "in_progress"}],
258
270
  })
259
- state.on_event({"type": "usage", "total_tokens": 120, "context_tokens": 100, "context_percent": 10.0, "cost": 0.001})
271
+ state.on_event({
272
+ "type": "usage",
273
+ "prompt_tokens": 100,
274
+ "completion_tokens": 20,
275
+ "cached_tokens": 60,
276
+ "total_tokens": 120,
277
+ "context_tokens": 100,
278
+ "context_window": 1000,
279
+ "context_percent": 10.0,
280
+ "cost": 0.001,
281
+ })
260
282
  state.on_event({"type": "assistant", "text": "检查完成"})
261
- state.on_event({"type": "done", "status": "done", "steps": 3, "tokens": 120, "cost": 0.001})
283
+ state.on_event({
284
+ "type": "done",
285
+ "status": "done",
286
+ "steps": 3,
287
+ "tokens": 120,
288
+ "prompt_tokens": 100,
289
+ "completion_tokens": 20,
290
+ "cost": 0.001,
291
+ "context_tokens": 100,
292
+ "context_window": 1000,
293
+ "context_percent": 10.0,
294
+ })
262
295
 
263
296
  assert state.tools[0]["status"] == "done"
264
297
  assert state.tools[0]["elapsed"] is not None
265
298
  assert state.todos and state.todos[0]["content"] == "检查测试"
266
299
  assert state.tokens == 120 and state.last_status == "done"
300
+ assert state.input_tokens == 100 and state.output_tokens == 20
301
+ assert state.cached_tokens == 60 and state.context_window == 1000
267
302
  feed_text = "\n".join(text for _, text in state.feed)
268
- assert "read_file" in feed_text and "检查完成" in feed_text
303
+ assert "read_file · 读取 main.py" in feed_text and "检查完成" in feed_text
304
+ assert '"path"' not in feed_text
305
+
306
+ state.on_event({"type": "run_start"})
307
+ state.on_event({
308
+ "type": "usage",
309
+ "prompt_tokens": 50,
310
+ "completion_tokens": 10,
311
+ "cached_tokens": 25,
312
+ "total_tokens": 60,
313
+ "context_tokens": 50,
314
+ "context_window": 500,
315
+ "context_percent": 10.0,
316
+ "cost": 0.002,
317
+ })
318
+ state.on_event({
319
+ "type": "done",
320
+ "status": "done",
321
+ "tokens": 60,
322
+ "prompt_tokens": 50,
323
+ "completion_tokens": 10,
324
+ "cost": 0.002,
325
+ "context_tokens": 50,
326
+ "context_window": 500,
327
+ "context_percent": 10.0,
328
+ })
329
+ assert state.tokens == 180 and state.cost == pytest.approx(0.003)
330
+ assert state.input_tokens == 150 and state.output_tokens == 30
331
+ assert state.cached_tokens == 85 and state.context_tokens == 50
332
+ from lithe_cli.tui import _sidebar_lines
333
+
334
+ usage_text = "\n".join(text for _, text in _sidebar_lines(state))
335
+ assert "输入 150 · 输出 30" in usage_text
336
+ assert "缓存输入 85" in usage_text
337
+ assert "50 / 500 (10.0%)" in usage_text
338
+
339
+
340
+ def test_system_prompt_prefers_localized_file_edits():
341
+ from lithe_cli.agent import SYSTEM_PROMPT_BASE
342
+
343
+ assert "优先用 edit_file 或 apply_patch 做局部修改" in SYSTEM_PROMPT_BASE
344
+ assert "仅在新建文件或确需整体重写时使用 write_file" in SYSTEM_PROMPT_BASE
269
345
 
270
346
 
271
347
  def test_run_dashboard_fits_narrow_terminal():
@@ -280,7 +356,7 @@ def test_run_dashboard_fits_narrow_terminal():
280
356
  rows = compose_frame(state, width, height, "0.6.0")
281
357
  assert len(rows) == height - 2
282
358
  for row in rows:
283
- assert display_width("".join(text for _, text in row)) <= width
359
+ assert display_width("".join(text for _, text in row)) == width
284
360
  assert all(display_width(text) <= width for _, text in compose_footer(state, width, " hint "))
285
361
  wide = "\n".join("".join(t for _, t in row) for row in compose_frame(state, 100, 30, "0.6.0"))
286
362
  narrow = "\n".join("".join(t for _, t in row) for row in compose_frame(state, 56, 24, "0.6.0"))
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes