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.
@@ -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.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
- 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,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.0 · your-model · /home/me/my-project ● 完成 · 0.2s
124
- ┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
120
+ lithe 0.6.2 · 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
146
- alignment are East-Asian-width aware; narrower terminals stack the two
147
- panes vertically. Pipes and CI keep the plain per-line output unchanged.
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
- session, `/tools` lists the registered tools, `/help` lists the commands.
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 {"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,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.0 · your-model · /home/me/my-project ● 完成 · 0.2s
76
- ┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
77
+ lithe 0.6.2 · 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
98
- alignment are East-Asian-width aware; narrower terminals stack the two
99
- panes vertically. Pipes and CI keep the plain per-line output unchanged.
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
- session, `/tools` lists the registered tools, `/help` lists the commands.
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
 
@@ -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.2"
@@ -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
- "需要多步任务时先用 update_todos 列出计划,最后用简洁中文汇报结果。"
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 = json.dumps(ev.get("args", {}), ensure_ascii=False)
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
- on the left, a status sidebar (run state, tool calls, todos, token /
6
- cost totals) on the right, an input line and a status bar. Turns stream
7
- into the panes and the screen stays up between runs — no falling back
8
- to the raw console after a task finishes.
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
- frame composition is a pure function (``compose_frame``) so it renders
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: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",
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.tokens = 0
134
- self.cost = 0.0
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"⚒ {name} {self.tools[-1]['args']}")
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.tokens += int(ev.get("total_tokens") or 0)
168
- self.cost += float(ev.get("cost") or 0)
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
- self.tokens = int(ev.get("tokens") or self.tokens)
193
- self.cost = float(ev.get("cost") or self.cost)
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 _panel(title: str, lines: list[tuple[str, str]], width: int, height: int) -> list[list]:
224
- """A bordered box of exactly *height* rows, each *width* columns wide."""
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", "─" * (inner - display_width(label)) + "┐"),
324
+ ("class:border", "─" * (width - display_width(label) - 2) + "╮"),
234
325
  ]
235
326
  ]
236
- wrapped: list[tuple[str, str]] = []
237
- for cls, text in lines:
238
- wrapped.extend((cls, seg) for seg in wrap_text(text, inner))
239
- for cls, seg in wrapped[-(height - 2):]:
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", "│ "), (f"class:{cls}" if cls else "", pad(seg, inner)), ("class:border", " │")]
337
+ [("class:border", "│ "), seg, ("class:border", " │")]
242
338
  )
243
- for _ in range(height - 2 - (len(rows) - 1)):
339
+ for _ in range(height - 2 - len(body)):
244
340
  rows.append([("class:border", "│ "), ("", " " * inner), ("class:border", " │")])
245
- rows.append([("class:border", "└" + "─" * inner + "┘")])
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"目录 {state.workspace}"),
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.extend([("dim", f"── 待办 {done}/{len(state.todos)}")])
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
- ctx = "—" if state.context_percent is None else f"{state.context_percent}%"
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"tokens {state.tokens:,}"),
292
- ("", f"cost ${state.cost:.4f} · ctx {ctx}"),
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 compose_frame(state: TuiState, width: int, height: int, version: str) -> list[list]:
311
- """The whole middle of the screen: header row + panel rows."""
312
- width = max(40, width)
313
- height = max(10, height)
314
- rows = [_header_row(state, width, version)]
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
- conv = _panel("对话", _conversation_lines(state), width - side_w, body)
319
- side = _panel("运行状态", _sidebar_lines(state), side_w, body)
320
- rows.extend(c + s for c, s in zip(conv, side, strict=True))
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" · {state.tokens:,} tok · ${state.cost:.4f}"
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 _frame():
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 compose_frame(state, cols, rows, version):
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
- frame_win = Window(
405
- FormattedTextControl(_frame, show_cursor=False), wrap_lines=False, height=Dimension(weight=1)
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([frame_win, input_row, footer_win])),
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
- /new 清空会话历史,重新开始
428
- /exit 退出(等同 /quit)"""
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.say("dim", "(已清空会话历史)")
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: 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.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
- 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,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.0 · your-model · /home/me/my-project ● 完成 · 0.2s
124
- ┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
120
+ lithe 0.6.2 · 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
146
- alignment are East-Asian-width aware; narrower terminals stack the two
147
- panes vertically. Pipes and CI keep the plain per-line output unchanged.
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
- session, `/tools` lists the registered tools, `/help` lists the commands.
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({"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
269
305
 
270
-
271
- def test_run_dashboard_fits_narrow_terminal():
272
- from lithe_cli.tui import TuiState, compose_footer, compose_frame
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, 30), (76, 24), (56, 24), (40, 20)):
280
- rows = compose_frame(state, width, height, "0.6.0")
281
- assert len(rows) == height - 2
282
- for row in rows:
283
- assert display_width("".join(text for _, text in row)) <= width
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
- wide = "\n".join("".join(t for _, t in row) for row in compose_frame(state, 100, 30, "0.6.0"))
286
- narrow = "\n".join("".join(t for _, t in row) for row in compose_frame(state, 56, 24, "0.6.0"))
287
- assert "对话" in wide and "运行状态" in wide
288
- assert "处理中文任务" in narrow # stacked layout still shows todos
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