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.
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/PKG-INFO +27 -27
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/README.md +24 -19
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/agent.py +3 -3
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/tui.py +117 -41
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/ui.py +24 -2
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli.egg-info/PKG-INFO +27 -27
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/tests/test_cli.py +80 -4
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/LICENSE +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/config.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/main.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/prompts.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli/setup.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli.egg-info/SOURCES.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli.egg-info/requires.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/pyproject.toml +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.6.
|
|
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
|
-
|
|
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
|
|
99
|
+
⚒ read_file 读取 README.md
|
|
105
100
|
✓ 已读取 README.md(120 行) 0.1s
|
|
106
|
-
⚒ write_file
|
|
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.
|
|
124
|
-
|
|
120
|
+
lithe 0.6.1 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
121
|
+
╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
|
|
125
122
|
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
126
|
-
│
|
|
123
|
+
│ ◆ update_todos ││ 步骤 2/35 · 0.2s │
|
|
127
124
|
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
128
|
-
│
|
|
129
|
-
│ ✓
|
|
125
|
+
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
126
|
+
│ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
|
|
130
127
|
│ 已完成。 ││ [~] 修改 a.txt │
|
|
131
|
-
│ ││
|
|
132
|
-
│ ││
|
|
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
|
|
139
|
-
streaming assistant reply)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
56
|
+
⚒ read_file 读取 README.md
|
|
57
57
|
✓ 已读取 README.md(120 行) 0.1s
|
|
58
|
-
⚒ write_file
|
|
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.
|
|
76
|
-
|
|
77
|
+
lithe 0.6.1 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
78
|
+
╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
|
|
77
79
|
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
78
|
-
│
|
|
80
|
+
│ ◆ update_todos ││ 步骤 2/35 · 0.2s │
|
|
79
81
|
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
80
|
-
│
|
|
81
|
-
│ ✓
|
|
82
|
+
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
83
|
+
│ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
|
|
82
84
|
│ 已完成。 ││ [~] 修改 a.txt │
|
|
83
|
-
│ ││
|
|
84
|
-
│ ││
|
|
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
|
|
91
|
-
streaming assistant reply)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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 =
|
|
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
|
|
54
|
-
"foot": "bg
|
|
55
|
-
"border": "fg
|
|
56
|
-
"title": "fg
|
|
57
|
-
"prompt": "fg
|
|
58
|
-
"user": "fg
|
|
59
|
-
"assistant": "fg
|
|
60
|
-
"tool": "fg
|
|
61
|
-
"ok": "fg
|
|
62
|
-
"err": "fg
|
|
63
|
-
"warn": "fg
|
|
64
|
-
"dim": "fg
|
|
65
|
-
"reason": "fg
|
|
66
|
-
"todo-ip": "fg
|
|
67
|
-
"todo-done": "fg
|
|
68
|
-
"st-idle": "fg
|
|
69
|
-
"st-run": "fg
|
|
70
|
-
"st-ok": "fg
|
|
71
|
-
"st-bad": "fg
|
|
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.
|
|
134
|
-
self.
|
|
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"
|
|
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.
|
|
168
|
-
self.
|
|
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
|
-
|
|
193
|
-
|
|
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", "─" * (
|
|
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", "
|
|
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"
|
|
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.
|
|
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
|
-
|
|
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"
|
|
292
|
-
("", f"
|
|
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" · {
|
|
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.
|
|
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:
|
|
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(
|
|
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.
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.6.
|
|
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
|
-
|
|
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
|
|
99
|
+
⚒ read_file 读取 README.md
|
|
105
100
|
✓ 已读取 README.md(120 行) 0.1s
|
|
106
|
-
⚒ write_file
|
|
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.
|
|
124
|
-
|
|
120
|
+
lithe 0.6.1 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
121
|
+
╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
|
|
125
122
|
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
126
|
-
│
|
|
123
|
+
│ ◆ update_todos ││ 步骤 2/35 · 0.2s │
|
|
127
124
|
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
128
|
-
│
|
|
129
|
-
│ ✓
|
|
125
|
+
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
126
|
+
│ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
|
|
130
127
|
│ 已完成。 ││ [~] 修改 a.txt │
|
|
131
|
-
│ ││
|
|
132
|
-
│ ││
|
|
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
|
|
139
|
-
streaming assistant reply)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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))
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|