lithe-cli 0.4.1__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/PKG-INFO +35 -20
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/README.md +34 -19
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/agent.py +16 -4
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/main.py +9 -0
- lithe_cli-0.6.0/lithe_cli/tui.py +512 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/ui.py +26 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli.egg-info/PKG-INFO +35 -20
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli.egg-info/SOURCES.txt +1 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/tests/test_cli.py +63 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/LICENSE +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/config.py +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/prompts.py +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli/setup.py +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli.egg-info/requires.txt +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/pyproject.toml +0 -0
- {lithe_cli-0.4.1 → lithe_cli-0.6.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -113,27 +113,42 @@ $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
|
113
113
|
usage/context gauges; `--max-steps` caps the tool loop;
|
|
114
114
|
`--context-window` enables fullness gauges.
|
|
115
115
|
|
|
116
|
-
###
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
116
|
+
### Full-screen interface
|
|
117
|
+
|
|
118
|
+
On an interactive terminal, `lithe chat` and `lithe run TASK` open a
|
|
119
|
+
persistent full-screen interface instead of scribbling one-line events
|
|
120
|
+
into the console:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
lithe 0.6.0 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
124
|
+
┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
|
|
125
|
+
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
126
|
+
│ ⚒ update_todos {"todos": [...]} ││ 步骤 2/35 · 0.2s │
|
|
127
|
+
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
128
|
+
│ ⚒ write_file {"path": "a.txt"} ││ ✓ write_file 0.0s │
|
|
129
|
+
│ ✓ 写入 a.txt 0.0s ││ ── 待办 1/3 │
|
|
130
|
+
│ 已完成。 ││ [~] 修改 a.txt │
|
|
131
|
+
│ ││ ── 用量 │
|
|
132
|
+
│ ││ tokens 1,240 │
|
|
133
|
+
└─────────────────────────────────────┘└────────────────────┘
|
|
134
|
+
lithe ❯ _
|
|
135
|
+
● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
|
|
132
136
|
```
|
|
133
137
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
148
|
+
|
|
149
|
+
History carries across turns within a session (each turn is its own run
|
|
150
|
+
in the store, replayed as context for the next). `/new` clears the
|
|
151
|
+
session, `/tools` lists the registered tools, `/help` lists the commands.
|
|
137
152
|
|
|
138
153
|
### Extra capabilities
|
|
139
154
|
|
|
@@ -65,27 +65,42 @@ $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
|
65
65
|
usage/context gauges; `--max-steps` caps the tool loop;
|
|
66
66
|
`--context-window` enables fullness gauges.
|
|
67
67
|
|
|
68
|
-
###
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
68
|
+
### Full-screen interface
|
|
69
|
+
|
|
70
|
+
On an interactive terminal, `lithe chat` and `lithe run TASK` open a
|
|
71
|
+
persistent full-screen interface instead of scribbling one-line events
|
|
72
|
+
into the console:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
lithe 0.6.0 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
76
|
+
┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
|
|
77
|
+
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
78
|
+
│ ⚒ update_todos {"todos": [...]} ││ 步骤 2/35 · 0.2s │
|
|
79
|
+
│ ✓ 任务清单已更新(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
|
+
│ 已完成。 ││ [~] 修改 a.txt │
|
|
83
|
+
│ ││ ── 用量 │
|
|
84
|
+
│ ││ tokens 1,240 │
|
|
85
|
+
└─────────────────────────────────────┘└────────────────────┘
|
|
86
|
+
lithe ❯ _
|
|
87
|
+
● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
|
|
84
88
|
```
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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.
|
|
100
|
+
|
|
101
|
+
History carries across turns within a session (each turn is its own run
|
|
102
|
+
in the store, replayed as context for the next). `/new` clears the
|
|
103
|
+
session, `/tools` lists the registered tools, `/help` lists the commands.
|
|
89
104
|
|
|
90
105
|
### Extra capabilities
|
|
91
106
|
|
|
@@ -187,8 +187,17 @@ async def execute(
|
|
|
187
187
|
task: str,
|
|
188
188
|
history: list[dict] | None = None,
|
|
189
189
|
run_id: str | None = None,
|
|
190
|
+
on_event=None,
|
|
191
|
+
stop=None,
|
|
190
192
|
) -> tuple[str, dict, Any]:
|
|
191
|
-
"""Run one task end-to-end; returns (run_id, done_event, host).
|
|
193
|
+
"""Run one task end-to-end; returns (run_id, done_event, host).
|
|
194
|
+
|
|
195
|
+
``on_event`` (the full-screen TUI supplies one) receives every kernel
|
|
196
|
+
event and silences the line-oriented printing below, including the
|
|
197
|
+
footer — the caller renders from the events instead. ``stop`` is the
|
|
198
|
+
kernel's cancellation handle, surfaced so Ctrl+C can cancel a turn
|
|
199
|
+
without killing the process.
|
|
200
|
+
"""
|
|
192
201
|
reg = build_registry(cfg)
|
|
193
202
|
host = build_host(cfg, reg)
|
|
194
203
|
rid = run_id or uuid.uuid4().hex[:12]
|
|
@@ -211,9 +220,12 @@ async def execute(
|
|
|
211
220
|
ev: dict = {}
|
|
212
221
|
pending: dict[str, float] = {}
|
|
213
222
|
try:
|
|
214
|
-
async for ev in host.run(ctx, task, history=history):
|
|
215
|
-
|
|
216
|
-
|
|
223
|
+
async for ev in host.run(ctx, task, history=history, stop=stop):
|
|
224
|
+
if on_event is not None:
|
|
225
|
+
on_event(ev)
|
|
226
|
+
else:
|
|
227
|
+
render_event(ev, cfg.verbose, cfg.stream, pending)
|
|
228
|
+
if ev.get("type") == "done" and on_event is None:
|
|
217
229
|
if cfg.stream and pending.get("_deltas"):
|
|
218
230
|
print() # close the streaming line before the footer
|
|
219
231
|
ui.footer(ev)
|
|
@@ -33,6 +33,7 @@ from . import prompts
|
|
|
33
33
|
from .agent import build_registry, execute, sandbox_backend, undo
|
|
34
34
|
from .config import config_file, load_config, require_endpoint
|
|
35
35
|
from .prompts import open_history
|
|
36
|
+
from .tui import screen_supported
|
|
36
37
|
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, configure, truncate, ui
|
|
37
38
|
|
|
38
39
|
|
|
@@ -397,9 +398,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
397
398
|
configure(cfg.color)
|
|
398
399
|
if args.command == "run":
|
|
399
400
|
require_endpoint(cfg, interactive=not args.no_setup)
|
|
401
|
+
if screen_supported():
|
|
402
|
+
from .tui import run_screen
|
|
403
|
+
|
|
404
|
+
return asyncio.run(run_screen(cfg, args.task, "run"))
|
|
400
405
|
return _cmd_run(cfg, args.task)
|
|
401
406
|
if args.command == "chat":
|
|
402
407
|
require_endpoint(cfg, interactive=not args.no_setup)
|
|
408
|
+
if screen_supported():
|
|
409
|
+
from .tui import run_screen
|
|
410
|
+
|
|
411
|
+
return asyncio.run(run_screen(cfg, None, "chat"))
|
|
403
412
|
return _chat_loop(cfg)
|
|
404
413
|
if args.command == "tools":
|
|
405
414
|
return _cmd_tools(cfg)
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Persistent full-screen interface for chat and one-shot runs.
|
|
2
|
+
|
|
3
|
+
One prompt_toolkit ``Application`` owns the terminal for the whole
|
|
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.
|
|
9
|
+
|
|
10
|
+
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.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from prompt_toolkit.application import Application
|
|
25
|
+
from prompt_toolkit.buffer import Buffer
|
|
26
|
+
from prompt_toolkit.filters import Condition
|
|
27
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
28
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
29
|
+
from prompt_toolkit.layout import (
|
|
30
|
+
BufferControl,
|
|
31
|
+
Dimension,
|
|
32
|
+
FormattedTextControl,
|
|
33
|
+
HSplit,
|
|
34
|
+
Layout,
|
|
35
|
+
VSplit,
|
|
36
|
+
Window,
|
|
37
|
+
)
|
|
38
|
+
from prompt_toolkit.styles import Style
|
|
39
|
+
|
|
40
|
+
from .ui import display_width, pad, truncate, wrap_text
|
|
41
|
+
|
|
42
|
+
_TODO_MARKS = {"pending": "[ ]", "in_progress": "[~]", "completed": "[x]", "cancelled": "[-]"}
|
|
43
|
+
_TODO_CLASSES = {
|
|
44
|
+
"pending": "",
|
|
45
|
+
"in_progress": "todo-ip",
|
|
46
|
+
"completed": "todo-done",
|
|
47
|
+
"cancelled": "dim",
|
|
48
|
+
}
|
|
49
|
+
_STATUS_DONE = "done"
|
|
50
|
+
|
|
51
|
+
_STYLE = Style.from_dict(
|
|
52
|
+
{
|
|
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",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_WELCOME = (
|
|
76
|
+
"输入任务开始对话;/help 查看命令。",
|
|
77
|
+
"Ctrl+C 在运行中取消本轮,空闲时退出。",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def screen_supported() -> bool:
|
|
82
|
+
"""The TUI needs a real terminal (never pipes, CI or dumb terms)."""
|
|
83
|
+
return sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class TuiState:
|
|
87
|
+
"""Everything the frame renders; mutated by kernel events only."""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
model: str,
|
|
92
|
+
workspace: str,
|
|
93
|
+
max_steps: int,
|
|
94
|
+
todos: list[dict] | None = None,
|
|
95
|
+
):
|
|
96
|
+
self.model = model
|
|
97
|
+
self.workspace = workspace
|
|
98
|
+
self.max_steps = max_steps
|
|
99
|
+
self.todos = list(todos or [])
|
|
100
|
+
self.running = False
|
|
101
|
+
self.status = "就绪"
|
|
102
|
+
self.step = 0
|
|
103
|
+
self.tokens = 0
|
|
104
|
+
self.cost = 0.0
|
|
105
|
+
self.context_percent = None
|
|
106
|
+
self.tools: list[dict] = []
|
|
107
|
+
self.feed: list[tuple[str, str]] = []
|
|
108
|
+
self.streaming = ""
|
|
109
|
+
self.started: float | None = None
|
|
110
|
+
self.last_status: str | None = None
|
|
111
|
+
self.stop: asyncio.Event | None = None
|
|
112
|
+
self._app: Application | None = None
|
|
113
|
+
|
|
114
|
+
# -- feed ---------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def say(self, cls: str, text: str) -> None:
|
|
117
|
+
for line in str(text).splitlines() or [""]:
|
|
118
|
+
self.feed.append((cls, line))
|
|
119
|
+
del self.feed[:-400]
|
|
120
|
+
self.invalidate()
|
|
121
|
+
|
|
122
|
+
def invalidate(self) -> None:
|
|
123
|
+
if self._app is not None and self._app.is_running:
|
|
124
|
+
self._app.invalidate()
|
|
125
|
+
|
|
126
|
+
# -- events -------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def on_event(self, ev: dict) -> None:
|
|
129
|
+
kind = ev.get("type")
|
|
130
|
+
if kind == "run_start":
|
|
131
|
+
self.status = "思考中"
|
|
132
|
+
self.step = 0
|
|
133
|
+
self.tokens = 0
|
|
134
|
+
self.cost = 0.0
|
|
135
|
+
self.tools = []
|
|
136
|
+
self.started = time.monotonic()
|
|
137
|
+
elif kind == "step":
|
|
138
|
+
self.step = int(ev.get("step") or 0)
|
|
139
|
+
self.status = "思考中"
|
|
140
|
+
elif kind == "assistant_delta":
|
|
141
|
+
self.streaming += str(ev.get("text") or "")
|
|
142
|
+
elif kind == "assistant":
|
|
143
|
+
text = str(ev.get("text") or "")
|
|
144
|
+
if text and not self.streaming:
|
|
145
|
+
self.say("assistant", text)
|
|
146
|
+
self.streaming = ""
|
|
147
|
+
elif kind == "tool_call":
|
|
148
|
+
name = str(ev.get("name") or "tool")
|
|
149
|
+
self.tools.append(
|
|
150
|
+
{
|
|
151
|
+
"id": ev.get("id"),
|
|
152
|
+
"name": name,
|
|
153
|
+
"args": json.dumps(ev.get("args") or {}, ensure_ascii=False),
|
|
154
|
+
"status": "running",
|
|
155
|
+
"t0": time.monotonic(),
|
|
156
|
+
"elapsed": None,
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
self.say("tool", f"⚒ {name} {self.tools[-1]['args']}")
|
|
160
|
+
self.status = f"工具 {name}"
|
|
161
|
+
elif kind == "tool_result":
|
|
162
|
+
self._settle_tool(ev.get("id"), bool(ev.get("ok")), ev.get("summary"))
|
|
163
|
+
elif kind == "todo_change":
|
|
164
|
+
self.todos = list(ev.get("new") or [])
|
|
165
|
+
self.say("dim", f"□ 任务清单已更新({len(self.todos)} 项)")
|
|
166
|
+
elif kind == "usage":
|
|
167
|
+
self.tokens += int(ev.get("total_tokens") or 0)
|
|
168
|
+
self.cost += float(ev.get("cost") or 0)
|
|
169
|
+
if ev.get("context_percent") is not None:
|
|
170
|
+
self.context_percent = ev["context_percent"]
|
|
171
|
+
elif kind == "reasoning":
|
|
172
|
+
digest = str(ev.get("text") or ev.get("summary") or "").replace("\n", " ")
|
|
173
|
+
if digest:
|
|
174
|
+
self.say("reason", f"~ {digest[:200]}")
|
|
175
|
+
elif kind == "error":
|
|
176
|
+
self.status = "失败"
|
|
177
|
+
self.say("err", str(ev.get("message") or "运行出错"))
|
|
178
|
+
elif kind == "cancelled":
|
|
179
|
+
self.say("warn", "(已取消)")
|
|
180
|
+
elif kind == "done":
|
|
181
|
+
status = str(ev.get("status") or _STATUS_DONE)
|
|
182
|
+
self.last_status = status
|
|
183
|
+
self.status = {
|
|
184
|
+
"done": "完成",
|
|
185
|
+
"failed": "失败",
|
|
186
|
+
"cancelled": "已取消",
|
|
187
|
+
"max_steps": "步数上限",
|
|
188
|
+
"budget_exceeded": "预算超限",
|
|
189
|
+
"empty_response": "空响应",
|
|
190
|
+
}.get(status, status)
|
|
191
|
+
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)
|
|
194
|
+
if self.started is not None:
|
|
195
|
+
self.status += f" · {time.monotonic() - self.started:.1f}s"
|
|
196
|
+
self.invalidate()
|
|
197
|
+
|
|
198
|
+
def _settle_tool(self, tool_id, ok: bool, summary) -> None:
|
|
199
|
+
tool = next(
|
|
200
|
+
(t for t in reversed(self.tools) if t["id"] == tool_id and t["status"] == "running"),
|
|
201
|
+
None,
|
|
202
|
+
)
|
|
203
|
+
if tool is not None:
|
|
204
|
+
tool["elapsed"] = time.monotonic() - tool["t0"]
|
|
205
|
+
tool["status"] = "done" if ok else "failed"
|
|
206
|
+
mark = "✓" if ok else "✗"
|
|
207
|
+
label = str(summary or tool["name"]).replace("\n", " ")
|
|
208
|
+
self.say("ok" if ok else "err", f"{mark} {label} {tool['elapsed']:.1f}s")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# -- frame composition (pure; shared with tests) ---------------------------
|
|
212
|
+
|
|
213
|
+
def _status_class(status: str) -> str:
|
|
214
|
+
if "失败" in status or "上限" in status or "超限" in status or "空响应" in status:
|
|
215
|
+
return "st-bad"
|
|
216
|
+
if status in ("就绪",):
|
|
217
|
+
return "st-idle"
|
|
218
|
+
if "完成" in status or "取消" in status:
|
|
219
|
+
return "st-ok"
|
|
220
|
+
return "st-run"
|
|
221
|
+
|
|
222
|
+
|
|
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."""
|
|
225
|
+
width = max(20, width)
|
|
226
|
+
height = max(4, height)
|
|
227
|
+
inner = max(1, width - 4)
|
|
228
|
+
label = truncate(f" {title} ", inner)
|
|
229
|
+
rows = [
|
|
230
|
+
[
|
|
231
|
+
("class:border", "┌"),
|
|
232
|
+
("class:title", label),
|
|
233
|
+
("class:border", "─" * (inner - display_width(label)) + "┐"),
|
|
234
|
+
]
|
|
235
|
+
]
|
|
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):]:
|
|
240
|
+
rows.append(
|
|
241
|
+
[("class:border", "│ "), (f"class:{cls}" if cls else "", pad(seg, inner)), ("class:border", " │")]
|
|
242
|
+
)
|
|
243
|
+
for _ in range(height - 2 - (len(rows) - 1)):
|
|
244
|
+
rows.append([("class:border", "│ "), ("", " " * inner), ("class:border", " │")])
|
|
245
|
+
rows.append([("class:border", "└" + "─" * inner + "┘")])
|
|
246
|
+
return rows
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _conversation_lines(state: TuiState) -> list[tuple[str, str]]:
|
|
250
|
+
lines = list(state.feed)
|
|
251
|
+
if state.streaming:
|
|
252
|
+
lines.append(("assistant", state.streaming))
|
|
253
|
+
if not lines:
|
|
254
|
+
lines = [("dim", text) for text in _WELCOME]
|
|
255
|
+
return lines
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _sidebar_lines(state: TuiState) -> list[tuple[str, str]]:
|
|
259
|
+
cls = _status_class(state.status)
|
|
260
|
+
elapsed = (
|
|
261
|
+
time.monotonic() - state.started
|
|
262
|
+
if state.started is not None and state.running
|
|
263
|
+
else 0.0
|
|
264
|
+
)
|
|
265
|
+
lines = [
|
|
266
|
+
(cls, f"● {state.status}"),
|
|
267
|
+
("dim", f"步骤 {state.step}/{state.max_steps} · {elapsed:.1f}s"),
|
|
268
|
+
("dim", f"模型 {state.model}"),
|
|
269
|
+
("dim", f"目录 {state.workspace}"),
|
|
270
|
+
("dim", "── 工具调用"),
|
|
271
|
+
]
|
|
272
|
+
if not state.tools:
|
|
273
|
+
lines.append(("dim", "暂无调用"))
|
|
274
|
+
for tool in state.tools[-4:]:
|
|
275
|
+
mark = {"running": "…", "done": "✓", "failed": "✗"}.get(tool["status"], "·")
|
|
276
|
+
suffix = f" {tool['elapsed']:.1f}s" if tool.get("elapsed") is not None else ""
|
|
277
|
+
lines.append(("ok" if tool["status"] == "done" else "err" if tool["status"] == "failed" else "tool",
|
|
278
|
+
f"{mark} {tool['name']}{suffix}"))
|
|
279
|
+
done = sum(todo.get("status") == "completed" for todo in state.todos)
|
|
280
|
+
lines.extend([("dim", f"── 待办 {done}/{len(state.todos)}")])
|
|
281
|
+
if not state.todos:
|
|
282
|
+
lines.append(("dim", "暂无任务清单"))
|
|
283
|
+
for todo in state.todos[-5:]:
|
|
284
|
+
mark = _TODO_MARKS.get(todo.get("status"), "[ ]")
|
|
285
|
+
cls_todo = _TODO_CLASSES.get(todo.get("status"), "")
|
|
286
|
+
lines.append((cls_todo, f"{mark} {todo.get('content', '')}"))
|
|
287
|
+
ctx = "—" if state.context_percent is None else f"{state.context_percent}%"
|
|
288
|
+
lines.extend(
|
|
289
|
+
[
|
|
290
|
+
("dim", "── 用量"),
|
|
291
|
+
("", f"tokens {state.tokens:,}"),
|
|
292
|
+
("", f"cost ${state.cost:.4f} · ctx {ctx}"),
|
|
293
|
+
]
|
|
294
|
+
)
|
|
295
|
+
return lines
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _header_row(state: TuiState, width: int, version: str) -> list:
|
|
299
|
+
chip = f" {state.status} "
|
|
300
|
+
left = truncate(
|
|
301
|
+
f" lithe {version} · {state.model} · {state.workspace}",
|
|
302
|
+
width - display_width(chip) - 1,
|
|
303
|
+
)
|
|
304
|
+
return [
|
|
305
|
+
("class:hdr", pad(left, width - display_width(chip))),
|
|
306
|
+
(f"class:hdr class:{_status_class(state.status)}", chip),
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
|
|
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
|
|
316
|
+
if width >= 76:
|
|
317
|
+
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
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def compose_footer(state: TuiState, width: int, running_hint: str) -> list:
|
|
329
|
+
dot = f"class:foot class:{_status_class(state.status)}"
|
|
330
|
+
left = (
|
|
331
|
+
f" ● {state.status} · step {state.step}/{state.max_steps}"
|
|
332
|
+
f" · {state.tokens:,} tok · ${state.cost:.4f}"
|
|
333
|
+
)
|
|
334
|
+
room = width - display_width(running_hint) - display_width(left) - 2
|
|
335
|
+
text = left + (" " * max(1, room)) if room >= 1 else truncate(left, width - display_width(running_hint) - 1)
|
|
336
|
+
return [(dot, " "), ("class:foot", pad(text, width - display_width(running_hint))), ("class:foot", running_hint)]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# -- application ------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def _chat_history():
|
|
342
|
+
"""Persistent chat history (prompt_toolkit FileHistory), shared with the plain REPL."""
|
|
343
|
+
from .prompts import open_history
|
|
344
|
+
|
|
345
|
+
return open_history()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _term_size(app: Application | None) -> tuple[int, int]:
|
|
349
|
+
if app is not None and app.is_running:
|
|
350
|
+
size = app.output.get_size()
|
|
351
|
+
return size.columns, size.rows
|
|
352
|
+
cols, rows = shutil.get_terminal_size((100, 30))
|
|
353
|
+
return cols, rows
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
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*."""
|
|
358
|
+
kb = KeyBindings()
|
|
359
|
+
|
|
360
|
+
@kb.add("c-c", eager=True)
|
|
361
|
+
def _cancel(event):
|
|
362
|
+
if state.running and state.stop is not None:
|
|
363
|
+
state.stop.set()
|
|
364
|
+
state.say("warn", "(正在取消本轮…)")
|
|
365
|
+
else:
|
|
366
|
+
event.app.exit()
|
|
367
|
+
|
|
368
|
+
if mode == "run":
|
|
369
|
+
|
|
370
|
+
@kb.add("q", eager=True)
|
|
371
|
+
@kb.add("enter", eager=True)
|
|
372
|
+
def _quit(event):
|
|
373
|
+
if not state.running:
|
|
374
|
+
event.app.exit()
|
|
375
|
+
|
|
376
|
+
buffer = Buffer(
|
|
377
|
+
history=_chat_history() if mode == "chat" else None,
|
|
378
|
+
multiline=False,
|
|
379
|
+
read_only=Condition(lambda: state.running or mode == "run"),
|
|
380
|
+
accept_handler=lambda buff: (on_line(buff.text), False)[1],
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def _frame():
|
|
384
|
+
cols, rows = _term_size(state._app)
|
|
385
|
+
fragments = []
|
|
386
|
+
for row in compose_frame(state, cols, rows, version):
|
|
387
|
+
fragments.extend(row)
|
|
388
|
+
fragments.append(("", "\n"))
|
|
389
|
+
return FormattedText(fragments)
|
|
390
|
+
|
|
391
|
+
def _footer():
|
|
392
|
+
cols, _ = _term_size(state._app)
|
|
393
|
+
if mode == "run":
|
|
394
|
+
hint = " q 退出 " if not state.running else " Ctrl+C 取消 "
|
|
395
|
+
elif state.running:
|
|
396
|
+
hint = " Ctrl+C 取消本轮 "
|
|
397
|
+
else:
|
|
398
|
+
hint = " Enter 发送 · /help 命令 "
|
|
399
|
+
return FormattedText(compose_footer(state, cols, hint))
|
|
400
|
+
|
|
401
|
+
def _prompt_text():
|
|
402
|
+
return FormattedText([("class:prompt", "lithe ❯ ")])
|
|
403
|
+
|
|
404
|
+
frame_win = Window(
|
|
405
|
+
FormattedTextControl(_frame, show_cursor=False), wrap_lines=False, height=Dimension(weight=1)
|
|
406
|
+
)
|
|
407
|
+
input_row = VSplit(
|
|
408
|
+
[
|
|
409
|
+
Window(FormattedTextControl(_prompt_text, show_cursor=False), width=Dimension.exact(8)),
|
|
410
|
+
Window(BufferControl(buffer=buffer), wrap_lines=False),
|
|
411
|
+
]
|
|
412
|
+
)
|
|
413
|
+
footer_win = Window(FormattedTextControl(_footer, show_cursor=False), height=Dimension.exact(1))
|
|
414
|
+
app = Application(
|
|
415
|
+
layout=Layout(HSplit([frame_win, input_row, footer_win])),
|
|
416
|
+
key_bindings=kb,
|
|
417
|
+
style=_STYLE,
|
|
418
|
+
full_screen=True,
|
|
419
|
+
)
|
|
420
|
+
app.layout.focus(buffer)
|
|
421
|
+
return app, buffer
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
_CHAT_HELP = """可用命令:
|
|
425
|
+
/help 显示这段帮助
|
|
426
|
+
/tools 列出当前注册的工具
|
|
427
|
+
/new 清空会话历史,重新开始
|
|
428
|
+
/exit 退出(等同 /quit)"""
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def run_screen(cfg, task: str | None, mode: str) -> int:
|
|
432
|
+
"""Full-screen driver for `lithe chat` (mode='chat') and `lithe run`."""
|
|
433
|
+
from lithe.bundles import JsonlRunStore, JsonTodoStore
|
|
434
|
+
|
|
435
|
+
from . import __version__
|
|
436
|
+
from .agent import build_registry, execute
|
|
437
|
+
|
|
438
|
+
todo_store = JsonTodoStore(cfg.store_dir / f"todos-{cfg.user_id}.json")
|
|
439
|
+
state = TuiState(
|
|
440
|
+
cfg.model or "(scripted)",
|
|
441
|
+
str(cfg.workspace_dir.resolve()),
|
|
442
|
+
cfg.max_steps,
|
|
443
|
+
todo_store.list(),
|
|
444
|
+
)
|
|
445
|
+
store = JsonlRunStore(cfg.store_dir)
|
|
446
|
+
run_ids: list[str] = []
|
|
447
|
+
current: dict = {"task": None}
|
|
448
|
+
|
|
449
|
+
def _slash(line: str) -> bool:
|
|
450
|
+
if line in ("/exit", "/quit"):
|
|
451
|
+
state._app.exit()
|
|
452
|
+
return True
|
|
453
|
+
if line == "/help":
|
|
454
|
+
state.say("dim", _CHAT_HELP)
|
|
455
|
+
return True
|
|
456
|
+
if line == "/tools":
|
|
457
|
+
names = sorted(build_registry(cfg).names())
|
|
458
|
+
state.say("dim", "已注册工具:" + "、".join(names))
|
|
459
|
+
return True
|
|
460
|
+
if line == "/new":
|
|
461
|
+
run_ids.clear()
|
|
462
|
+
state.say("dim", "(已清空会话历史)")
|
|
463
|
+
return True
|
|
464
|
+
return False
|
|
465
|
+
|
|
466
|
+
async def _turn(line: str) -> None:
|
|
467
|
+
stop = asyncio.Event()
|
|
468
|
+
state.stop = stop
|
|
469
|
+
state.running = True
|
|
470
|
+
state.started = time.monotonic()
|
|
471
|
+
state.invalidate()
|
|
472
|
+
try:
|
|
473
|
+
history = store.messages_for_runs(run_ids, cfg.user_id) if run_ids else None
|
|
474
|
+
rid, done, _ = await execute(
|
|
475
|
+
cfg, line, history=history, on_event=state.on_event, stop=stop
|
|
476
|
+
)
|
|
477
|
+
run_ids.append(rid)
|
|
478
|
+
if done.get("status") != "done":
|
|
479
|
+
state.say("warn", f"(本轮状态:{done.get('status')})")
|
|
480
|
+
except asyncio.CancelledError:
|
|
481
|
+
raise
|
|
482
|
+
except Exception as exc: # noqa: BLE001
|
|
483
|
+
state.say("err", f"运行出错:{exc}")
|
|
484
|
+
finally:
|
|
485
|
+
state.running = False
|
|
486
|
+
state.stop = None
|
|
487
|
+
state.invalidate()
|
|
488
|
+
|
|
489
|
+
def on_line(text: str) -> None:
|
|
490
|
+
line = text.strip()
|
|
491
|
+
if not line or state.running:
|
|
492
|
+
return
|
|
493
|
+
state.say("user", line)
|
|
494
|
+
if line.startswith("/") and _slash(line):
|
|
495
|
+
return
|
|
496
|
+
current["task"] = asyncio.create_task(_turn(line))
|
|
497
|
+
|
|
498
|
+
app, _ = build_app(state, mode, __version__, on_line)
|
|
499
|
+
state._app = app
|
|
500
|
+
if mode == "run" and task:
|
|
501
|
+
|
|
502
|
+
async def _start() -> None:
|
|
503
|
+
state.say("user", task)
|
|
504
|
+
await _turn(task)
|
|
505
|
+
|
|
506
|
+
asyncio.create_task(_start())
|
|
507
|
+
await app.run_async()
|
|
508
|
+
if current["task"] is not None and not current["task"].done():
|
|
509
|
+
current["task"].cancel()
|
|
510
|
+
if mode == "run":
|
|
511
|
+
return 0 if state.last_status == "done" else 1
|
|
512
|
+
return 0
|
|
@@ -79,6 +79,32 @@ def truncate(text: str, width: int) -> str:
|
|
|
79
79
|
return out + "…"
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
def wrap_text(text: str, width: int) -> list[str]:
|
|
83
|
+
"""Wrap *text* to *width* display columns (CJK-aware, newline-aware)."""
|
|
84
|
+
if width < 1:
|
|
85
|
+
return []
|
|
86
|
+
lines: list[str] = []
|
|
87
|
+
current = ""
|
|
88
|
+
used = 0
|
|
89
|
+
for ch in str(text):
|
|
90
|
+
if ch == "\n":
|
|
91
|
+
lines.append(current)
|
|
92
|
+
current = ""
|
|
93
|
+
used = 0
|
|
94
|
+
continue
|
|
95
|
+
w = 2 if unicodedata.east_asian_width(ch) in "WF" else 1
|
|
96
|
+
if used + w > width and current:
|
|
97
|
+
lines.append(current)
|
|
98
|
+
current = ""
|
|
99
|
+
used = 0
|
|
100
|
+
if w <= width:
|
|
101
|
+
current += ch
|
|
102
|
+
used += w
|
|
103
|
+
if current or not lines:
|
|
104
|
+
lines.append(current)
|
|
105
|
+
return lines
|
|
106
|
+
|
|
107
|
+
|
|
82
108
|
class UI:
|
|
83
109
|
"""All terminal output goes through here; ``color`` gates every code."""
|
|
84
110
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -113,27 +113,42 @@ $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
|
113
113
|
usage/context gauges; `--max-steps` caps the tool loop;
|
|
114
114
|
`--context-window` enables fullness gauges.
|
|
115
115
|
|
|
116
|
-
###
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
116
|
+
### Full-screen interface
|
|
117
|
+
|
|
118
|
+
On an interactive terminal, `lithe chat` and `lithe run TASK` open a
|
|
119
|
+
persistent full-screen interface instead of scribbling one-line events
|
|
120
|
+
into the console:
|
|
121
|
+
|
|
122
|
+
```text
|
|
123
|
+
lithe 0.6.0 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
124
|
+
┌─ 对话 ──────────────────────────────┐┌─ 运行状态 ─────────┐
|
|
125
|
+
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
126
|
+
│ ⚒ update_todos {"todos": [...]} ││ 步骤 2/35 · 0.2s │
|
|
127
|
+
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
128
|
+
│ ⚒ write_file {"path": "a.txt"} ││ ✓ write_file 0.0s │
|
|
129
|
+
│ ✓ 写入 a.txt 0.0s ││ ── 待办 1/3 │
|
|
130
|
+
│ 已完成。 ││ [~] 修改 a.txt │
|
|
131
|
+
│ ││ ── 用量 │
|
|
132
|
+
│ ││ tokens 1,240 │
|
|
133
|
+
└─────────────────────────────────────┘└────────────────────┘
|
|
134
|
+
lithe ❯ _
|
|
135
|
+
● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
|
|
132
136
|
```
|
|
133
137
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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.
|
|
148
|
+
|
|
149
|
+
History carries across turns within a session (each turn is its own run
|
|
150
|
+
in the store, replayed as context for the next). `/new` clears the
|
|
151
|
+
session, `/tools` lists the registered tools, `/help` lists the commands.
|
|
137
152
|
|
|
138
153
|
### Extra capabilities
|
|
139
154
|
|
|
@@ -244,6 +244,69 @@ def test_ui_banner_fits_terminal_width():
|
|
|
244
244
|
assert "…" in out
|
|
245
245
|
|
|
246
246
|
|
|
247
|
+
def test_run_dashboard_shows_tools_todos_and_usage():
|
|
248
|
+
from lithe_cli.tui import TuiState
|
|
249
|
+
|
|
250
|
+
state = TuiState("model-x", "/workspace", 12)
|
|
251
|
+
state.on_event({"type": "run_start", "model": "model-x", "max_steps": 12})
|
|
252
|
+
state.on_event({"type": "step", "step": 2})
|
|
253
|
+
state.on_event({"type": "tool_call", "id": "c1", "name": "read_file", "args": {"path": "main.py"}})
|
|
254
|
+
state.on_event({"type": "tool_result", "id": "c1", "ok": True, "summary": "已读取文件"})
|
|
255
|
+
state.on_event({
|
|
256
|
+
"type": "todo_change",
|
|
257
|
+
"new": [{"content": "检查测试", "status": "in_progress"}],
|
|
258
|
+
})
|
|
259
|
+
state.on_event({"type": "usage", "total_tokens": 120, "context_tokens": 100, "context_percent": 10.0, "cost": 0.001})
|
|
260
|
+
state.on_event({"type": "assistant", "text": "检查完成"})
|
|
261
|
+
state.on_event({"type": "done", "status": "done", "steps": 3, "tokens": 120, "cost": 0.001})
|
|
262
|
+
|
|
263
|
+
assert state.tools[0]["status"] == "done"
|
|
264
|
+
assert state.tools[0]["elapsed"] is not None
|
|
265
|
+
assert state.todos and state.todos[0]["content"] == "检查测试"
|
|
266
|
+
assert state.tokens == 120 and state.last_status == "done"
|
|
267
|
+
feed_text = "\n".join(text for _, text in state.feed)
|
|
268
|
+
assert "read_file" in feed_text and "检查完成" in feed_text
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_run_dashboard_fits_narrow_terminal():
|
|
272
|
+
from lithe_cli.tui import TuiState, compose_footer, compose_frame
|
|
273
|
+
from lithe_cli.ui import display_width
|
|
274
|
+
|
|
275
|
+
state = TuiState("模型", "/工作区", 5)
|
|
276
|
+
state.on_event({"type": "tool_call", "id": "c1", "name": "read_file", "args": {"path": "x"}})
|
|
277
|
+
state.on_event({"type": "todo_change", "new": [{"content": "处理中文任务", "status": "pending"}]})
|
|
278
|
+
|
|
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
|
|
284
|
+
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
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_wrap_text_is_cjk_aware():
|
|
292
|
+
from lithe_cli.ui import wrap_text
|
|
293
|
+
|
|
294
|
+
assert wrap_text("abcdefghij", 4) == ["abcd", "efgh", "ij"]
|
|
295
|
+
assert wrap_text("中文字符测试", 4) == ["中文", "字符", "测试"]
|
|
296
|
+
assert wrap_text("a\nbc", 4) == ["a", "bc"]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_execute_on_event_is_quiet_and_yields_events(tmp_path, capsys):
|
|
300
|
+
seen = []
|
|
301
|
+
cfg = make_config(tmp_path, WRITE_THEN_ANSWER)
|
|
302
|
+
rid, done, _ = asyncio.run(execute(cfg, "把 a.txt 写为 hi", on_event=seen.append))
|
|
303
|
+
assert done["status"] == "done"
|
|
304
|
+
assert any(e["type"] == "tool_call" for e in seen)
|
|
305
|
+
assert any(e["type"] == "done" for e in seen)
|
|
306
|
+
assert capsys.readouterr().out == "" # the TUI path prints nothing
|
|
307
|
+
assert (cfg.workspace_dir / "a.txt").exists()
|
|
308
|
+
|
|
309
|
+
|
|
247
310
|
def test_no_command_prints_help(capsys):
|
|
248
311
|
from lithe_cli.main import main
|
|
249
312
|
|
|
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
|