lithe-cli 0.6.0__tar.gz → 0.6.2__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.2}/PKG-INFO +34 -29
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/README.md +31 -21
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/agent.py +5 -4
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/tui.py +281 -84
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/ui.py +24 -2
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli.egg-info/PKG-INFO +34 -29
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/tests/test_cli.py +172 -15
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/LICENSE +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/config.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/main.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/prompts.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli/setup.py +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli.egg-info/SOURCES.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli.egg-info/requires.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/pyproject.toml +0 -0
- {lithe_cli-0.6.0 → lithe-cli-0.6.2}/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.2
|
|
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,43 @@ 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.2 · 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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 two panes are separate windows — selecting text
|
|
143
|
+
in one never captures the other — and the conversation scrolls: mouse
|
|
144
|
+
wheel, PageUp/PageDown, Home/End (a new turn jumps back to the output).
|
|
145
|
+
F2 or `/sidebar` hides the status pane so a reply can be copied from a
|
|
146
|
+
full-width screen; Shift+drag keeps the terminal's native selection. The
|
|
147
|
+
screen stays up between turns; `Ctrl+C` cancels the current turn while
|
|
148
|
+
running and exits when idle. In `lithe run` mode the result stays on
|
|
149
|
+
screen until you press `q`. Layout, wrapping and alignment are
|
|
150
|
+
East-Asian-width aware; narrower terminals stack the two panes
|
|
151
|
+
vertically. Pipes and CI keep the plain per-line output unchanged.
|
|
148
152
|
|
|
149
153
|
History carries across turns within a session (each turn is its own run
|
|
150
154
|
in the store, replayed as context for the next). `/new` clears the
|
|
151
|
-
|
|
155
|
+
conversation history and session usage totals; `/tools` lists the
|
|
156
|
+
registered tools, `/help` lists the commands.
|
|
152
157
|
|
|
153
158
|
### Extra capabilities
|
|
154
159
|
|
|
@@ -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,43 @@ 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.2 · 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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 two panes are separate windows — selecting text
|
|
100
|
+
in one never captures the other — and the conversation scrolls: mouse
|
|
101
|
+
wheel, PageUp/PageDown, Home/End (a new turn jumps back to the output).
|
|
102
|
+
F2 or `/sidebar` hides the status pane so a reply can be copied from a
|
|
103
|
+
full-width screen; Shift+drag keeps the terminal's native selection. The
|
|
104
|
+
screen stays up between turns; `Ctrl+C` cancels the current turn while
|
|
105
|
+
running and exits when idle. In `lithe run` mode the result stays on
|
|
106
|
+
screen until you press `q`. Layout, wrapping and alignment are
|
|
107
|
+
East-Asian-width aware; narrower terminals stack the two panes
|
|
108
|
+
vertically. Pipes and CI keep the plain per-line output unchanged.
|
|
100
109
|
|
|
101
110
|
History carries across turns within a session (each turn is its own run
|
|
102
111
|
in the store, replayed as context for the next). `/new` clears the
|
|
103
|
-
|
|
112
|
+
conversation history and session usage totals; `/tools` lists the
|
|
113
|
+
registered tools, `/help` lists the commands.
|
|
104
114
|
|
|
105
115
|
### Extra capabilities
|
|
106
116
|
|
|
@@ -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,8 +42,10 @@ from .ui import CYAN, GREEN, YELLOW, ui
|
|
|
43
42
|
|
|
44
43
|
SYSTEM_PROMPT_BASE = (
|
|
45
44
|
"你是运行在命令行里的助理,工作区是用户的当前目录。"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
45
|
+
"用提供的文件工具完成任务(读写前先读、谨慎修改);修改已有文件时优先用 edit_file 或 apply_patch 做局部修改,"
|
|
46
|
+
"仅在新建文件或确需整体重写时使用 write_file。"
|
|
47
|
+
"需要多步任务时先用 update_todos 列出计划(条数与实际步骤数一致:一两步就一两条,复杂任务可以拆到七八条,不要固定写成 3 条),"
|
|
48
|
+
"最后用简洁中文汇报结果。"
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
|
|
@@ -154,7 +155,7 @@ def render_event(
|
|
|
154
155
|
if text and not (stream and pending.get("_deltas")):
|
|
155
156
|
ui.assistant(text)
|
|
156
157
|
elif t == "tool_call":
|
|
157
|
-
args =
|
|
158
|
+
args = ev.get("args") or {}
|
|
158
159
|
ui.tool_call(str(ev.get("name")), args)
|
|
159
160
|
if ev.get("id") is not None:
|
|
160
161
|
pending[ev["id"]] = time.monotonic()
|
|
@@ -2,20 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
One prompt_toolkit ``Application`` owns the terminal for the whole
|
|
4
4
|
session: a conversation pane (task, tool feed, streaming assistant text)
|
|
5
|
-
|
|
6
|
-
cost totals)
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
in its own scrollable Window, a status sidebar (run state, tool calls,
|
|
6
|
+
todos, token / cost totals) in a separate Window beside it, an input line
|
|
7
|
+
and a status bar. Panes never share a rendering surface, so terminal
|
|
8
|
+
selection cannot bleed across them; F2 hides the sidebar entirely and the
|
|
9
|
+
conversation keeps a scroll offset (wheel, PgUp/PgDn, Home/End). Turns
|
|
10
|
+
stream into the panes and the screen stays up between runs — no falling
|
|
11
|
+
back to the raw console after a task finishes.
|
|
9
12
|
|
|
10
13
|
Everything degrades: without a TTY ``main`` never routes here, and the
|
|
11
|
-
|
|
12
|
-
identically under tests.
|
|
14
|
+
pane composition is pure functions (``conversation_rows`` /
|
|
15
|
+
``sidebar_rows``) so they render identically under tests.
|
|
13
16
|
"""
|
|
14
17
|
|
|
15
18
|
from __future__ import annotations
|
|
16
19
|
|
|
17
20
|
import asyncio
|
|
18
|
-
import json
|
|
19
21
|
import os
|
|
20
22
|
import shutil
|
|
21
23
|
import sys
|
|
@@ -29,6 +31,7 @@ from prompt_toolkit.key_binding import KeyBindings
|
|
|
29
31
|
from prompt_toolkit.layout import (
|
|
30
32
|
BufferControl,
|
|
31
33
|
Dimension,
|
|
34
|
+
DynamicContainer,
|
|
32
35
|
FormattedTextControl,
|
|
33
36
|
HSplit,
|
|
34
37
|
Layout,
|
|
@@ -37,7 +40,7 @@ from prompt_toolkit.layout import (
|
|
|
37
40
|
)
|
|
38
41
|
from prompt_toolkit.styles import Style
|
|
39
42
|
|
|
40
|
-
from .ui import display_width, pad, truncate, wrap_text
|
|
43
|
+
from .ui import display_width, pad, tool_call_label, truncate, wrap_text
|
|
41
44
|
|
|
42
45
|
_TODO_MARKS = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]", "cancelled": "[-]"}
|
|
43
46
|
_TODO_CLASSES = {
|
|
@@ -50,33 +53,36 @@ _STATUS_DONE = "done"
|
|
|
50
53
|
|
|
51
54
|
_STYLE = Style.from_dict(
|
|
52
55
|
{
|
|
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
|
|
56
|
+
"hdr": "bg:#172033 fg:#e2e8f0 bold",
|
|
57
|
+
"foot": "bg:#111827 fg:#cbd5e1",
|
|
58
|
+
"border": "fg:#475569",
|
|
59
|
+
"title": "fg:#38bdf8 bold",
|
|
60
|
+
"prompt": "fg:#38bdf8 bold",
|
|
61
|
+
"user": "fg:#60a5fa bold",
|
|
62
|
+
"assistant": "fg:#4ade80",
|
|
63
|
+
"tool": "fg:#22d3ee",
|
|
64
|
+
"ok": "fg:#4ade80",
|
|
65
|
+
"err": "fg:#f87171 bold",
|
|
66
|
+
"warn": "fg:#fbbf24",
|
|
67
|
+
"dim": "fg:#94a3b8",
|
|
68
|
+
"reason": "fg:#c084fc",
|
|
69
|
+
"todo-ip": "fg:#fbbf24",
|
|
70
|
+
"todo-done": "fg:#4ade80",
|
|
71
|
+
"st-idle": "fg:#22d3ee bold",
|
|
72
|
+
"st-run": "fg:#fbbf24 bold",
|
|
73
|
+
"st-ok": "fg:#4ade80 bold",
|
|
74
|
+
"st-bad": "fg:#f87171 bold",
|
|
72
75
|
}
|
|
73
76
|
)
|
|
74
77
|
|
|
75
78
|
_WELCOME = (
|
|
76
79
|
"输入任务开始对话;/help 查看命令。",
|
|
80
|
+
"PgUp/PgDn 或滚轮回看对话;F2 隐藏侧栏;Shift+拖拽复制。",
|
|
77
81
|
"Ctrl+C 在运行中取消本轮,空闲时退出。",
|
|
78
82
|
)
|
|
79
83
|
|
|
84
|
+
_SCROLL_STEP = 3
|
|
85
|
+
|
|
80
86
|
|
|
81
87
|
def screen_supported() -> bool:
|
|
82
88
|
"""The TUI needs a real terminal (never pipes, CI or dumb terms)."""
|
|
@@ -102,10 +108,22 @@ class TuiState:
|
|
|
102
108
|
self.step = 0
|
|
103
109
|
self.tokens = 0
|
|
104
110
|
self.cost = 0.0
|
|
111
|
+
self.input_tokens = 0
|
|
112
|
+
self.output_tokens = 0
|
|
113
|
+
self.cached_tokens = 0
|
|
114
|
+
self._turn_tokens = 0
|
|
115
|
+
self._turn_cost = 0.0
|
|
116
|
+
self._turn_input_tokens = 0
|
|
117
|
+
self._turn_output_tokens = 0
|
|
118
|
+
self._turn_cached_tokens = 0
|
|
119
|
+
self.context_tokens = None
|
|
120
|
+
self.context_window = None
|
|
105
121
|
self.context_percent = None
|
|
106
122
|
self.tools: list[dict] = []
|
|
107
123
|
self.feed: list[tuple[str, str]] = []
|
|
108
124
|
self.streaming = ""
|
|
125
|
+
self.scroll = 0
|
|
126
|
+
self.show_sidebar = True
|
|
109
127
|
self.started: float | None = None
|
|
110
128
|
self.last_status: str | None = None
|
|
111
129
|
self.stop: asyncio.Event | None = None
|
|
@@ -125,13 +143,35 @@ class TuiState:
|
|
|
125
143
|
|
|
126
144
|
# -- events -------------------------------------------------------------
|
|
127
145
|
|
|
146
|
+
def reset_usage(self) -> None:
|
|
147
|
+
self.tokens = 0
|
|
148
|
+
self.cost = 0.0
|
|
149
|
+
self.input_tokens = 0
|
|
150
|
+
self.output_tokens = 0
|
|
151
|
+
self.cached_tokens = 0
|
|
152
|
+
self._turn_tokens = 0
|
|
153
|
+
self._turn_cost = 0.0
|
|
154
|
+
self._turn_input_tokens = 0
|
|
155
|
+
self._turn_output_tokens = 0
|
|
156
|
+
self._turn_cached_tokens = 0
|
|
157
|
+
self.context_tokens = None
|
|
158
|
+
self.context_window = None
|
|
159
|
+
self.context_percent = None
|
|
160
|
+
|
|
128
161
|
def on_event(self, ev: dict) -> None:
|
|
129
162
|
kind = ev.get("type")
|
|
130
163
|
if kind == "run_start":
|
|
131
164
|
self.status = "思考中"
|
|
132
165
|
self.step = 0
|
|
133
|
-
self.
|
|
134
|
-
self.
|
|
166
|
+
self.scroll = 0
|
|
167
|
+
self._turn_tokens = 0
|
|
168
|
+
self._turn_cost = 0.0
|
|
169
|
+
self._turn_input_tokens = 0
|
|
170
|
+
self._turn_output_tokens = 0
|
|
171
|
+
self._turn_cached_tokens = 0
|
|
172
|
+
self.context_tokens = None
|
|
173
|
+
self.context_window = None
|
|
174
|
+
self.context_percent = None
|
|
135
175
|
self.tools = []
|
|
136
176
|
self.started = time.monotonic()
|
|
137
177
|
elif kind == "step":
|
|
@@ -146,17 +186,18 @@ class TuiState:
|
|
|
146
186
|
self.streaming = ""
|
|
147
187
|
elif kind == "tool_call":
|
|
148
188
|
name = str(ev.get("name") or "tool")
|
|
189
|
+
args = ev.get("args")
|
|
190
|
+
args = args if isinstance(args, dict) else {}
|
|
149
191
|
self.tools.append(
|
|
150
192
|
{
|
|
151
193
|
"id": ev.get("id"),
|
|
152
194
|
"name": name,
|
|
153
|
-
"args": json.dumps(ev.get("args") or {}, ensure_ascii=False),
|
|
154
195
|
"status": "running",
|
|
155
196
|
"t0": time.monotonic(),
|
|
156
197
|
"elapsed": None,
|
|
157
198
|
}
|
|
158
199
|
)
|
|
159
|
-
self.say("tool", f"
|
|
200
|
+
self.say("tool", f"◆ {tool_call_label(name, args)}")
|
|
160
201
|
self.status = f"工具 {name}"
|
|
161
202
|
elif kind == "tool_result":
|
|
162
203
|
self._settle_tool(ev.get("id"), bool(ev.get("ok")), ev.get("summary"))
|
|
@@ -164,8 +205,15 @@ class TuiState:
|
|
|
164
205
|
self.todos = list(ev.get("new") or [])
|
|
165
206
|
self.say("dim", f"□ 任务清单已更新({len(self.todos)} 项)")
|
|
166
207
|
elif kind == "usage":
|
|
167
|
-
self.
|
|
168
|
-
self.
|
|
208
|
+
self._turn_input_tokens += int(ev.get("prompt_tokens") or 0)
|
|
209
|
+
self._turn_output_tokens += int(ev.get("completion_tokens") or 0)
|
|
210
|
+
self._turn_cached_tokens += int(ev.get("cached_tokens") or 0)
|
|
211
|
+
self._turn_tokens += int(ev.get("total_tokens") or 0)
|
|
212
|
+
self._turn_cost += float(ev.get("cost") or 0)
|
|
213
|
+
if ev.get("context_tokens") is not None:
|
|
214
|
+
self.context_tokens = int(ev["context_tokens"])
|
|
215
|
+
if ev.get("context_window") is not None:
|
|
216
|
+
self.context_window = int(ev["context_window"])
|
|
169
217
|
if ev.get("context_percent") is not None:
|
|
170
218
|
self.context_percent = ev["context_percent"]
|
|
171
219
|
elif kind == "reasoning":
|
|
@@ -189,8 +237,30 @@ class TuiState:
|
|
|
189
237
|
"empty_response": "空响应",
|
|
190
238
|
}.get(status, status)
|
|
191
239
|
self.step = int(ev.get("steps") or self.step)
|
|
192
|
-
|
|
193
|
-
|
|
240
|
+
if ev.get("prompt_tokens") is not None:
|
|
241
|
+
self._turn_input_tokens = int(ev["prompt_tokens"])
|
|
242
|
+
if ev.get("completion_tokens") is not None:
|
|
243
|
+
self._turn_output_tokens = int(ev["completion_tokens"])
|
|
244
|
+
if ev.get("tokens") is not None:
|
|
245
|
+
self._turn_tokens = int(ev["tokens"])
|
|
246
|
+
if ev.get("cost") is not None:
|
|
247
|
+
self._turn_cost = float(ev["cost"])
|
|
248
|
+
self.input_tokens += self._turn_input_tokens
|
|
249
|
+
self.output_tokens += self._turn_output_tokens
|
|
250
|
+
self.cached_tokens += self._turn_cached_tokens
|
|
251
|
+
self.tokens += self._turn_tokens
|
|
252
|
+
self.cost += self._turn_cost
|
|
253
|
+
self._turn_tokens = 0
|
|
254
|
+
self._turn_cost = 0.0
|
|
255
|
+
self._turn_input_tokens = 0
|
|
256
|
+
self._turn_output_tokens = 0
|
|
257
|
+
self._turn_cached_tokens = 0
|
|
258
|
+
if ev.get("context_tokens") is not None:
|
|
259
|
+
self.context_tokens = int(ev["context_tokens"])
|
|
260
|
+
if ev.get("context_window") is not None:
|
|
261
|
+
self.context_window = int(ev["context_window"])
|
|
262
|
+
if ev.get("context_percent") is not None:
|
|
263
|
+
self.context_percent = ev["context_percent"]
|
|
194
264
|
if self.started is not None:
|
|
195
265
|
self.status += f" · {time.monotonic() - self.started:.1f}s"
|
|
196
266
|
self.invalidate()
|
|
@@ -220,32 +290,79 @@ def _status_class(status: str) -> str:
|
|
|
220
290
|
return "st-run"
|
|
221
291
|
|
|
222
292
|
|
|
223
|
-
def
|
|
224
|
-
|
|
293
|
+
def _wrap_feed(lines: list[tuple[str, str]], inner: int) -> list[tuple[str, str]]:
|
|
294
|
+
wrapped: list[tuple[str, str]] = []
|
|
295
|
+
for cls, text in lines:
|
|
296
|
+
wrapped.extend((cls, seg) for seg in wrap_text(text, inner))
|
|
297
|
+
return wrapped
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _scroll_handler(state: TuiState, delta: int):
|
|
301
|
+
def _on_mouse(event) -> None:
|
|
302
|
+
state.scroll = max(0, state.scroll + delta)
|
|
303
|
+
state.invalidate()
|
|
304
|
+
|
|
305
|
+
return _on_mouse
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _box(
|
|
309
|
+
title: str,
|
|
310
|
+
wrapped: list[tuple[str, str]],
|
|
311
|
+
width: int,
|
|
312
|
+
height: int,
|
|
313
|
+
handler=None,
|
|
314
|
+
) -> list[list]:
|
|
315
|
+
"""A bordered box of exactly *height* rows; *handler* rides content lines."""
|
|
225
316
|
width = max(20, width)
|
|
226
317
|
height = max(4, height)
|
|
227
318
|
inner = max(1, width - 4)
|
|
228
319
|
label = truncate(f" {title} ", inner)
|
|
229
320
|
rows = [
|
|
230
321
|
[
|
|
231
|
-
("class:border", "
|
|
322
|
+
("class:border", "╭"),
|
|
232
323
|
("class:title", label),
|
|
233
|
-
("class:border", "─" * (
|
|
324
|
+
("class:border", "─" * (width - display_width(label) - 2) + "╮"),
|
|
234
325
|
]
|
|
235
326
|
]
|
|
236
|
-
wrapped
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
327
|
+
shown = wrapped[-(height - 2):]
|
|
328
|
+
if handler is None:
|
|
329
|
+
body = [(f"class:{cls}" if cls else "", pad(seg, inner)) for cls, seg in shown]
|
|
330
|
+
else:
|
|
331
|
+
body = [
|
|
332
|
+
(f"class:{cls}" if cls else "", pad(seg, inner), handler)
|
|
333
|
+
for cls, seg in shown
|
|
334
|
+
]
|
|
335
|
+
for seg in body:
|
|
240
336
|
rows.append(
|
|
241
|
-
[("class:border", "│ "),
|
|
337
|
+
[("class:border", "│ "), seg, ("class:border", " │")]
|
|
242
338
|
)
|
|
243
|
-
for _ in range(height - 2 -
|
|
339
|
+
for _ in range(height - 2 - len(body)):
|
|
244
340
|
rows.append([("class:border", "│ "), ("", " " * inner), ("class:border", " │")])
|
|
245
|
-
rows.append([("class:border", "
|
|
341
|
+
rows.append([("class:border", "╰" + "─" * (inner + 2) + "╯")])
|
|
246
342
|
return rows
|
|
247
343
|
|
|
248
344
|
|
|
345
|
+
def _panel(title: str, lines: list[tuple[str, str]], width: int, height: int) -> list[list]:
|
|
346
|
+
inner = max(1, max(20, width) - 4)
|
|
347
|
+
return _box(title, _wrap_feed(lines, inner), width, height)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def conversation_rows(state: TuiState, width: int, height: int) -> list[list]:
|
|
351
|
+
"""The conversation pane, windowed by *state.scroll* (0 = follow the tail)."""
|
|
352
|
+
inner = max(1, max(20, width) - 4)
|
|
353
|
+
wrapped = _wrap_feed(_conversation_lines(state), inner)
|
|
354
|
+
visible = max(1, height - 2)
|
|
355
|
+
max_scroll = max(0, len(wrapped) - visible)
|
|
356
|
+
state.scroll = min(state.scroll, max_scroll)
|
|
357
|
+
start = max(0, len(wrapped) - visible - state.scroll)
|
|
358
|
+
window = wrapped[start:start + visible]
|
|
359
|
+
return _box("对话", window, width, height, handler=_scroll_handler(state, _SCROLL_STEP))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def sidebar_rows(state: TuiState, width: int, height: int) -> list[list]:
|
|
363
|
+
return _panel("运行状态", _sidebar_lines(state), width, height)
|
|
364
|
+
|
|
365
|
+
|
|
249
366
|
def _conversation_lines(state: TuiState) -> list[tuple[str, str]]:
|
|
250
367
|
lines = list(state.feed)
|
|
251
368
|
if state.streaming:
|
|
@@ -266,8 +383,8 @@ def _sidebar_lines(state: TuiState) -> list[tuple[str, str]]:
|
|
|
266
383
|
(cls, f"● {state.status}"),
|
|
267
384
|
("dim", f"步骤 {state.step}/{state.max_steps} · {elapsed:.1f}s"),
|
|
268
385
|
("dim", f"模型 {state.model}"),
|
|
269
|
-
("dim", f"
|
|
270
|
-
("dim", "
|
|
386
|
+
("dim", f"工作区 {state.workspace}"),
|
|
387
|
+
("dim", "◆ 工具"),
|
|
271
388
|
]
|
|
272
389
|
if not state.tools:
|
|
273
390
|
lines.append(("dim", "暂无调用"))
|
|
@@ -277,19 +394,32 @@ def _sidebar_lines(state: TuiState) -> list[tuple[str, str]]:
|
|
|
277
394
|
lines.append(("ok" if tool["status"] == "done" else "err" if tool["status"] == "failed" else "tool",
|
|
278
395
|
f"{mark} {tool['name']}{suffix}"))
|
|
279
396
|
done = sum(todo.get("status") == "completed" for todo in state.todos)
|
|
280
|
-
lines.
|
|
397
|
+
lines.append(("dim", f"◆ 待办 {done}/{len(state.todos)}"))
|
|
281
398
|
if not state.todos:
|
|
282
399
|
lines.append(("dim", "暂无任务清单"))
|
|
283
400
|
for todo in state.todos[-5:]:
|
|
284
401
|
mark = _TODO_MARKS.get(todo.get("status"), "[ ]")
|
|
285
402
|
cls_todo = _TODO_CLASSES.get(todo.get("status"), "")
|
|
286
403
|
lines.append((cls_todo, f"{mark} {todo.get('content', '')}"))
|
|
287
|
-
|
|
404
|
+
input_tokens = state.input_tokens + state._turn_input_tokens
|
|
405
|
+
output_tokens = state.output_tokens + state._turn_output_tokens
|
|
406
|
+
cached_tokens = state.cached_tokens + state._turn_cached_tokens
|
|
407
|
+
tokens = state.tokens + state._turn_tokens
|
|
408
|
+
cost = state.cost + state._turn_cost
|
|
409
|
+
context = "—"
|
|
410
|
+
if state.context_tokens is not None:
|
|
411
|
+
context = f"{state.context_tokens:,}"
|
|
412
|
+
if state.context_window:
|
|
413
|
+
context += f" / {state.context_window:,}"
|
|
414
|
+
if state.context_percent is not None:
|
|
415
|
+
context += f" ({state.context_percent}%)"
|
|
288
416
|
lines.extend(
|
|
289
417
|
[
|
|
290
|
-
("dim", "
|
|
291
|
-
("", f"
|
|
292
|
-
("", f"
|
|
418
|
+
("dim", "◆ 会话用量"),
|
|
419
|
+
("", f"输入 {input_tokens:,} · 输出 {output_tokens:,}"),
|
|
420
|
+
("", f"缓存输入 {cached_tokens:,}"),
|
|
421
|
+
("", f"合计 {tokens:,} · ${cost:.4f}"),
|
|
422
|
+
("", f"上下文 {context}"),
|
|
293
423
|
]
|
|
294
424
|
)
|
|
295
425
|
return lines
|
|
@@ -307,29 +437,25 @@ def _header_row(state: TuiState, width: int, version: str) -> list:
|
|
|
307
437
|
]
|
|
308
438
|
|
|
309
439
|
|
|
310
|
-
def
|
|
311
|
-
"""
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
body = height - 3 # header 1 + input 1 + footer 1
|
|
440
|
+
def body_layout(state: TuiState, width: int, height: int):
|
|
441
|
+
"""Pane geometry: (conversation w×h, sidebar w×h | None)."""
|
|
442
|
+
height = max(6, height)
|
|
443
|
+
if not state.show_sidebar:
|
|
444
|
+
return (width, height), None
|
|
316
445
|
if width >= 76:
|
|
317
446
|
side_w = min(44, max(30, width // 3))
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
else:
|
|
322
|
-
conv_h = max(4, body * 3 // 5)
|
|
323
|
-
rows.extend(_panel("对话", _conversation_lines(state), width, conv_h))
|
|
324
|
-
rows.extend(_panel("运行状态", _sidebar_lines(state), width, max(4, body - conv_h)))
|
|
325
|
-
return rows
|
|
447
|
+
return (width - side_w, height), (side_w, height)
|
|
448
|
+
conv_h = max(4, height * 3 // 5)
|
|
449
|
+
return (width, conv_h), (width, max(4, height - conv_h))
|
|
326
450
|
|
|
327
451
|
|
|
328
452
|
def compose_footer(state: TuiState, width: int, running_hint: str) -> list:
|
|
329
453
|
dot = f"class:foot class:{_status_class(state.status)}"
|
|
454
|
+
total_tokens = state.tokens + state._turn_tokens
|
|
455
|
+
total_cost = state.cost + state._turn_cost
|
|
330
456
|
left = (
|
|
331
457
|
f" ● {state.status} · step {state.step}/{state.max_steps}"
|
|
332
|
-
f" · {
|
|
458
|
+
f" · {total_tokens:,} tok · ${total_cost:.4f}"
|
|
333
459
|
)
|
|
334
460
|
room = width - display_width(running_hint) - display_width(left) - 2
|
|
335
461
|
text = left + (" " * max(1, room)) if room >= 1 else truncate(left, width - display_width(running_hint) - 1)
|
|
@@ -354,7 +480,13 @@ def _term_size(app: Application | None) -> tuple[int, int]:
|
|
|
354
480
|
|
|
355
481
|
|
|
356
482
|
def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Application, Buffer]:
|
|
357
|
-
"""Assemble the full-screen Application; the input line drives *on_line*.
|
|
483
|
+
"""Assemble the full-screen Application; the input line drives *on_line*.
|
|
484
|
+
|
|
485
|
+
The conversation pane and the status sidebar live in separate Windows,
|
|
486
|
+
so terminal selection never spans both. The conversation keeps a scroll
|
|
487
|
+
offset (PgUp/PgDn/Home/End and the mouse wheel) instead of only ever
|
|
488
|
+
showing the tail; F2 (or /sidebar) hides the sidebar for full-width
|
|
489
|
+
copying."""
|
|
358
490
|
kb = KeyBindings()
|
|
359
491
|
|
|
360
492
|
@kb.add("c-c", eager=True)
|
|
@@ -373,6 +505,26 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
373
505
|
if not state.running:
|
|
374
506
|
event.app.exit()
|
|
375
507
|
|
|
508
|
+
def _page(dy: int):
|
|
509
|
+
def _scroll(event):
|
|
510
|
+
_, rows = _term_size(state._app)
|
|
511
|
+
step = max(1, (rows - 5) // 2)
|
|
512
|
+
state.scroll = max(0, state.scroll + dy * step)
|
|
513
|
+
state.invalidate()
|
|
514
|
+
|
|
515
|
+
return _scroll
|
|
516
|
+
|
|
517
|
+
kb.add("pageup")(_page(1))
|
|
518
|
+
kb.add("pagedown")(_page(-1))
|
|
519
|
+
kb.add("home")(lambda event: (setattr(state, "scroll", 10**9), state.invalidate()))
|
|
520
|
+
kb.add("end")(lambda event: (setattr(state, "scroll", 0), state.invalidate()))
|
|
521
|
+
|
|
522
|
+
def _toggle_sidebar(event):
|
|
523
|
+
state.show_sidebar = not state.show_sidebar
|
|
524
|
+
state.invalidate()
|
|
525
|
+
|
|
526
|
+
kb.add("f2")(_toggle_sidebar)
|
|
527
|
+
|
|
376
528
|
buffer = Buffer(
|
|
377
529
|
history=_chat_history() if mode == "chat" else None,
|
|
378
530
|
multiline=False,
|
|
@@ -380,30 +532,65 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
380
532
|
accept_handler=lambda buff: (on_line(buff.text), False)[1],
|
|
381
533
|
)
|
|
382
534
|
|
|
383
|
-
def
|
|
535
|
+
def _header():
|
|
536
|
+
cols, _ = _term_size(state._app)
|
|
537
|
+
return FormattedText(_header_row(state, cols, version))
|
|
538
|
+
|
|
539
|
+
def _conv_text():
|
|
384
540
|
cols, rows = _term_size(state._app)
|
|
541
|
+
(cw, ch), _ = body_layout(state, cols, rows - 3)
|
|
385
542
|
fragments = []
|
|
386
|
-
for row in
|
|
543
|
+
for row in conversation_rows(state, cw, ch):
|
|
387
544
|
fragments.extend(row)
|
|
388
545
|
fragments.append(("", "\n"))
|
|
389
|
-
return FormattedText(fragments)
|
|
546
|
+
return FormattedText(fragments[:-1])
|
|
547
|
+
|
|
548
|
+
def _side_text():
|
|
549
|
+
cols, rows = _term_size(state._app)
|
|
550
|
+
_, side = body_layout(state, cols, rows - 3)
|
|
551
|
+
fragments = []
|
|
552
|
+
if side is not None:
|
|
553
|
+
for row in sidebar_rows(state, side[0], side[1]):
|
|
554
|
+
fragments.extend(row)
|
|
555
|
+
fragments.append(("", "\n"))
|
|
556
|
+
return FormattedText(fragments[:-1] if fragments else "")
|
|
557
|
+
|
|
558
|
+
def _body():
|
|
559
|
+
cols, rows = _term_size(state._app)
|
|
560
|
+
(cw, ch), side = body_layout(state, cols, rows - 3)
|
|
561
|
+
conv = Window(
|
|
562
|
+
FormattedTextControl(_conv_text, show_cursor=False),
|
|
563
|
+
width=Dimension.exact(cw),
|
|
564
|
+
height=Dimension.exact(ch),
|
|
565
|
+
wrap_lines=False,
|
|
566
|
+
)
|
|
567
|
+
if side is None:
|
|
568
|
+
return conv
|
|
569
|
+
side_win = Window(
|
|
570
|
+
FormattedTextControl(_side_text, show_cursor=False),
|
|
571
|
+
width=Dimension.exact(side[0]),
|
|
572
|
+
height=Dimension.exact(side[1]),
|
|
573
|
+
wrap_lines=False,
|
|
574
|
+
)
|
|
575
|
+
if side[0] >= 30 and side[1] == ch:
|
|
576
|
+
return VSplit([conv, side_win])
|
|
577
|
+
return HSplit([conv, side_win])
|
|
390
578
|
|
|
391
579
|
def _footer():
|
|
392
580
|
cols, _ = _term_size(state._app)
|
|
393
581
|
if mode == "run":
|
|
394
|
-
hint = " q 退出 " if not state.running else " Ctrl+C 取消 "
|
|
582
|
+
hint = " q 退出 · F2 侧栏 " if not state.running else " Ctrl+C 取消 · PgUp/PgDn 滚动 "
|
|
395
583
|
elif state.running:
|
|
396
|
-
hint = " Ctrl+C 取消本轮 "
|
|
584
|
+
hint = " Ctrl+C 取消本轮 · PgUp/PgDn 滚动 "
|
|
397
585
|
else:
|
|
398
|
-
hint = " Enter 发送 · /help 命令 "
|
|
586
|
+
hint = " Enter 发送 · F2 侧栏 · /help 命令 "
|
|
399
587
|
return FormattedText(compose_footer(state, cols, hint))
|
|
400
588
|
|
|
401
589
|
def _prompt_text():
|
|
402
590
|
return FormattedText([("class:prompt", "lithe ❯ ")])
|
|
403
591
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
)
|
|
592
|
+
header_win = Window(FormattedTextControl(_header, show_cursor=False), height=Dimension.exact(1))
|
|
593
|
+
body_container = DynamicContainer(_body)
|
|
407
594
|
input_row = VSplit(
|
|
408
595
|
[
|
|
409
596
|
Window(FormattedTextControl(_prompt_text, show_cursor=False), width=Dimension.exact(8)),
|
|
@@ -412,20 +599,25 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
412
599
|
)
|
|
413
600
|
footer_win = Window(FormattedTextControl(_footer, show_cursor=False), height=Dimension.exact(1))
|
|
414
601
|
app = Application(
|
|
415
|
-
layout=Layout(HSplit([
|
|
602
|
+
layout=Layout(HSplit([header_win, body_container, input_row, footer_win])),
|
|
416
603
|
key_bindings=kb,
|
|
417
604
|
style=_STYLE,
|
|
418
605
|
full_screen=True,
|
|
606
|
+
mouse_support=True,
|
|
419
607
|
)
|
|
420
608
|
app.layout.focus(buffer)
|
|
421
609
|
return app, buffer
|
|
422
610
|
|
|
423
611
|
|
|
424
612
|
_CHAT_HELP = """可用命令:
|
|
425
|
-
/help
|
|
426
|
-
/tools
|
|
427
|
-
/
|
|
428
|
-
/
|
|
613
|
+
/help 显示这段帮助
|
|
614
|
+
/tools 列出当前注册的工具
|
|
615
|
+
/sidebar 显示/隐藏右侧状态栏(F2 同效)
|
|
616
|
+
/new 清空会话历史,重新开始
|
|
617
|
+
/exit 退出(等同 /quit)
|
|
618
|
+
|
|
619
|
+
按键:PgUp/PgDn·滚轮 滚动对话,Home/End 跳到顶/底,
|
|
620
|
+
Shift+拖拽 终端原生复制(隐藏侧栏后左栏独占整行更干净)。"""
|
|
429
621
|
|
|
430
622
|
|
|
431
623
|
async def run_screen(cfg, task: str | None, mode: str) -> int:
|
|
@@ -453,13 +645,18 @@ async def run_screen(cfg, task: str | None, mode: str) -> int:
|
|
|
453
645
|
if line == "/help":
|
|
454
646
|
state.say("dim", _CHAT_HELP)
|
|
455
647
|
return True
|
|
648
|
+
if line == "/sidebar":
|
|
649
|
+
state.show_sidebar = not state.show_sidebar
|
|
650
|
+
state.say("dim", "(侧栏已隐藏,F2 或 /sidebar 恢复)" if not state.show_sidebar else "(侧栏已恢复)")
|
|
651
|
+
return True
|
|
456
652
|
if line == "/tools":
|
|
457
653
|
names = sorted(build_registry(cfg).names())
|
|
458
654
|
state.say("dim", "已注册工具:" + "、".join(names))
|
|
459
655
|
return True
|
|
460
656
|
if line == "/new":
|
|
461
657
|
run_ids.clear()
|
|
462
|
-
state.
|
|
658
|
+
state.reset_usage()
|
|
659
|
+
state.say("dim", "(已清空会话历史与用量统计)")
|
|
463
660
|
return True
|
|
464
661
|
return False
|
|
465
662
|
|
|
@@ -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.2
|
|
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,43 @@ 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.2 · 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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 two panes are separate windows — selecting text
|
|
143
|
+
in one never captures the other — and the conversation scrolls: mouse
|
|
144
|
+
wheel, PageUp/PageDown, Home/End (a new turn jumps back to the output).
|
|
145
|
+
F2 or `/sidebar` hides the status pane so a reply can be copied from a
|
|
146
|
+
full-width screen; Shift+drag keeps the terminal's native selection. The
|
|
147
|
+
screen stays up between turns; `Ctrl+C` cancels the current turn while
|
|
148
|
+
running and exits when idle. In `lithe run` mode the result stays on
|
|
149
|
+
screen until you press `q`. Layout, wrapping and alignment are
|
|
150
|
+
East-Asian-width aware; narrower terminals stack the two panes
|
|
151
|
+
vertically. Pipes and CI keep the plain per-line output unchanged.
|
|
148
152
|
|
|
149
153
|
History carries across turns within a session (each turn is its own run
|
|
150
154
|
in the store, replayed as context for the next). `/new` clears the
|
|
151
|
-
|
|
155
|
+
conversation history and session usage totals; `/tools` lists the
|
|
156
|
+
registered tools, `/help` lists the commands.
|
|
152
157
|
|
|
153
158
|
### Extra capabilities
|
|
154
159
|
|
|
@@ -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,36 +268,150 @@ 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
|
|
269
305
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
345
|
+
assert "条数与实际步骤数一致" in SYSTEM_PROMPT_BASE
|
|
346
|
+
assert "不要固定写成 3 条" in SYSTEM_PROMPT_BASE
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def test_panes_fit_terminal_and_stay_independent():
|
|
350
|
+
from lithe_cli.tui import (
|
|
351
|
+
TuiState,
|
|
352
|
+
_header_row,
|
|
353
|
+
body_layout,
|
|
354
|
+
compose_footer,
|
|
355
|
+
conversation_rows,
|
|
356
|
+
sidebar_rows,
|
|
357
|
+
)
|
|
273
358
|
from lithe_cli.ui import display_width
|
|
274
359
|
|
|
275
360
|
state = TuiState("模型", "/工作区", 5)
|
|
276
361
|
state.on_event({"type": "tool_call", "id": "c1", "name": "read_file", "args": {"path": "x"}})
|
|
277
362
|
state.on_event({"type": "todo_change", "new": [{"content": "处理中文任务", "status": "pending"}]})
|
|
278
363
|
|
|
279
|
-
for width, height in ((100,
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
364
|
+
for width, height in ((100, 27), (76, 21), (56, 21), (40, 17)):
|
|
365
|
+
(cw, ch), side = body_layout(state, width, height)
|
|
366
|
+
if side is None:
|
|
367
|
+
assert (cw, ch) == (width, height)
|
|
368
|
+
elif side[1] == ch: # side-by-side: independent windows on the same rows
|
|
369
|
+
assert cw + side[0] == width and ch == height
|
|
370
|
+
else: # stacked: panes share columns, not rows
|
|
371
|
+
assert cw == side[0] == width and ch + side[1] <= height
|
|
372
|
+
for rows, w, h in (
|
|
373
|
+
(conversation_rows(state, cw, ch), cw, ch),
|
|
374
|
+
(sidebar_rows(state, *side) if side else [], *(side or (0, 0))),
|
|
375
|
+
):
|
|
376
|
+
if not rows:
|
|
377
|
+
continue
|
|
378
|
+
assert len(rows) == h
|
|
379
|
+
for row in rows:
|
|
380
|
+
assert display_width("".join(f[1] for f in row)) == w
|
|
381
|
+
assert all(display_width(t) <= width for t, _ in _header_row(state, width, "0.7.0"))
|
|
284
382
|
assert all(display_width(text) <= width for _, text in compose_footer(state, width, " hint "))
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
383
|
+
assert "处理中文任务" in "\n".join(
|
|
384
|
+
"".join(f[1] for f in row) for row in sidebar_rows(state, 40, 17)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
state.show_sidebar = False
|
|
388
|
+
(cw, ch), side = body_layout(state, 100, 27)
|
|
389
|
+
assert side is None and (cw, ch) == (100, 27)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_conversation_pane_scrolls_back_and_clamps():
|
|
393
|
+
from lithe_cli.tui import TuiState, conversation_rows
|
|
394
|
+
|
|
395
|
+
state = TuiState("模型", "/工作区", 5)
|
|
396
|
+
for i in range(30):
|
|
397
|
+
state.say("assistant", f"第 {i} 行")
|
|
398
|
+
rows = conversation_rows(state, 60, 12)
|
|
399
|
+
text = "\n".join("".join(f[1] for f in row) for row in rows)
|
|
400
|
+
assert "第 29 行" in text and "第 0 行" not in text
|
|
401
|
+
|
|
402
|
+
state.scroll = 10**9 # Home: clamp to the very top
|
|
403
|
+
rows = conversation_rows(state, 60, 12)
|
|
404
|
+
text = "\n".join("".join(f[1] for f in row) for row in rows)
|
|
405
|
+
assert "第 0 行" in text and "第 29 行" not in text
|
|
406
|
+
assert state.scroll == 30 - 10
|
|
407
|
+
|
|
408
|
+
state.scroll = 5 # mid viewport: last visible line is 10 lines above the tail
|
|
409
|
+
rows = conversation_rows(state, 60, 12)
|
|
410
|
+
text = "\n".join("".join(f[1] for f in row) for row in rows)
|
|
411
|
+
assert "第 15 行" in text and "第 24 行" in text and "第 29 行" not in text
|
|
412
|
+
|
|
413
|
+
state.on_event({"type": "run_start"}) # a new turn resumes following the tail
|
|
414
|
+
assert state.scroll == 0
|
|
289
415
|
|
|
290
416
|
|
|
291
417
|
def test_wrap_text_is_cjk_aware():
|
|
@@ -546,6 +672,37 @@ def _pty_drain(fd) -> bytes:
|
|
|
546
672
|
return out
|
|
547
673
|
|
|
548
674
|
|
|
675
|
+
def test_fullscreen_app_scrolls_and_toggles_sidebar():
|
|
676
|
+
code = """
|
|
677
|
+
import asyncio
|
|
678
|
+
from lithe_cli.tui import TuiState, build_app
|
|
679
|
+
|
|
680
|
+
async def main():
|
|
681
|
+
state = TuiState("m", "/ws", 5)
|
|
682
|
+
for i in range(40):
|
|
683
|
+
state.say("assistant", f"line {i}")
|
|
684
|
+
app, _ = build_app(state, "chat", "0.7.0", lambda text: None)
|
|
685
|
+
await app.run_async()
|
|
686
|
+
print(f"RESULT scroll={state.scroll} sidebar={state.show_sidebar}")
|
|
687
|
+
|
|
688
|
+
asyncio.run(main())
|
|
689
|
+
"""
|
|
690
|
+
feed = [
|
|
691
|
+
b"\x1b[5~", # PageUp: scroll back
|
|
692
|
+
b"\x1b[6~", # PageDown: back to the tail
|
|
693
|
+
b"\x1bOQ", # F2: hide the sidebar
|
|
694
|
+
b"\x1b[5~", # PageUp again: leave a nonzero offset
|
|
695
|
+
b"\x1b[5~",
|
|
696
|
+
b"\x03", # Ctrl+C exits
|
|
697
|
+
]
|
|
698
|
+
out, rc = _pty_run(code, feed, settle=0.4)
|
|
699
|
+
assert rc == 0
|
|
700
|
+
assert "RESULT scroll=" in out
|
|
701
|
+
assert "scroll=0 " not in out # two PageUps must leave a nonzero offset
|
|
702
|
+
assert "sidebar=False" in out
|
|
703
|
+
assert "Traceback" not in out
|
|
704
|
+
|
|
705
|
+
|
|
549
706
|
def test_prompt_toolkit_cjk_paste_password_history(tmp_path):
|
|
550
707
|
# the exact long Chinese line that readline corrupted in the field,
|
|
551
708
|
# driven through the real prompt_toolkit stack
|
|
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
|