lithe-cli 0.6.1__tar.gz → 0.6.3__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.1 → lithe-cli-0.6.3}/PKG-INFO +22 -15
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/README.md +21 -14
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/__init__.py +1 -1
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/agent.py +2 -1
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/tui.py +166 -49
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli.egg-info/PKG-INFO +22 -15
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli.egg-info/requires.txt +1 -1
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/pyproject.toml +1 -1
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/tests/test_cli.py +121 -13
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/LICENSE +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/__main__.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/config.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/main.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/prompts.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/py.typed +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/setup.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli/ui.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli.egg-info/SOURCES.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
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
|
|
|
@@ -99,12 +99,12 @@ $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
|
99
99
|
⚒ read_file 读取 README.md
|
|
100
100
|
✓ 已读取 README.md(120 行) 0.1s
|
|
101
101
|
⚒ write_file 写入 SUMMARY.md
|
|
102
|
-
✓ 写入 SUMMARY.md 0.0s
|
|
102
|
+
✓ 写入 SUMMARY.md(+12 行,新建) 0.0s
|
|
103
103
|
已把要点写入 SUMMARY.md。
|
|
104
104
|
── done · steps 3 · tokens 2100 · cost 0.0042
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file
|
|
107
|
+
修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file`。工具调用界面只显示工具名、文件路径等摘要,文件写入/编辑的结果带 `+N -M 行` 的行数变化提示(lithe 0.9.10 起由内核统计)。
|
|
108
108
|
|
|
109
109
|
`--stream` streams tokens as they generate; `-v` adds per-call
|
|
110
110
|
usage/context gauges; `--max-steps` caps the tool loop;
|
|
@@ -117,13 +117,13 @@ persistent full-screen interface instead of scribbling one-line events
|
|
|
117
117
|
into the console:
|
|
118
118
|
|
|
119
119
|
```text
|
|
120
|
-
lithe 0.6.
|
|
120
|
+
lithe 0.6.2 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
121
121
|
╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
|
|
122
122
|
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
123
123
|
│ ◆ update_todos ││ 步骤 2/35 · 0.2s │
|
|
124
124
|
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
125
|
-
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
126
|
-
│ ✓
|
|
125
|
+
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
126
|
+
│ ✓ 编辑 a.txt(+3 -1 行) 0.0s ││ ◆ 待办 1/3 │
|
|
127
127
|
│ 已完成。 ││ [~] 修改 a.txt │
|
|
128
128
|
│ ││ ◆ 会话用量 │
|
|
129
129
|
│ ││ 输入 1,024 · 输出 216│
|
|
@@ -132,18 +132,25 @@ into the console:
|
|
|
132
132
|
│ ││ 上下文 1,024 / 128,000 (0.8%)│
|
|
133
133
|
╰─────────────────────────────────────╯╰────────────────────╯
|
|
134
134
|
lithe ❯ _
|
|
135
|
-
● 完成 ·
|
|
135
|
+
● 完成 · 0.2s Enter 发送 · F2 侧栏 · /help 命令
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
The left pane is the conversation (task, compact tool-call summaries
|
|
139
|
-
and outcomes
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
and outcomes — file edits land with `+N -M 行` counts — and the
|
|
140
|
+
streaming assistant reply). The right sidebar shows run status, recent
|
|
141
|
+
tools, todos, session-wide cumulative input/output/cached tokens and
|
|
142
|
+
cost, plus the latest model-call context length and window percentage;
|
|
143
|
+
usage numbers live only there, the bottom bar keeps status and key
|
|
144
|
+
hints. The two panes are separate windows — selecting text
|
|
145
|
+
in one never captures the other — and the conversation scrolls: mouse
|
|
146
|
+
wheel, PageUp/PageDown, Home/End (a new turn jumps back to the output).
|
|
147
|
+
F2 or `/sidebar` hides the status pane so a reply can be copied from a
|
|
148
|
+
full-width screen; Shift+drag keeps the terminal's native selection. The
|
|
149
|
+
screen stays up between turns; `Ctrl+C` cancels the current turn while
|
|
150
|
+
running and exits when idle. In `lithe run` mode the result stays on
|
|
151
|
+
screen until you press `q`. Layout, wrapping and alignment are
|
|
152
|
+
East-Asian-width aware; narrower terminals stack the two panes
|
|
153
|
+
vertically. Pipes and CI keep the plain per-line output unchanged.
|
|
147
154
|
|
|
148
155
|
History carries across turns within a session (each turn is its own run
|
|
149
156
|
in the store, replayed as context for the next). `/new` clears the
|
|
@@ -56,12 +56,12 @@ $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
|
56
56
|
⚒ read_file 读取 README.md
|
|
57
57
|
✓ 已读取 README.md(120 行) 0.1s
|
|
58
58
|
⚒ write_file 写入 SUMMARY.md
|
|
59
|
-
✓ 写入 SUMMARY.md 0.0s
|
|
59
|
+
✓ 写入 SUMMARY.md(+12 行,新建) 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
|
|
64
|
+
修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file`。工具调用界面只显示工具名、文件路径等摘要,文件写入/编辑的结果带 `+N -M 行` 的行数变化提示(lithe 0.9.10 起由内核统计)。
|
|
65
65
|
|
|
66
66
|
`--stream` streams tokens as they generate; `-v` adds per-call
|
|
67
67
|
usage/context gauges; `--max-steps` caps the tool loop;
|
|
@@ -74,13 +74,13 @@ persistent full-screen interface instead of scribbling one-line events
|
|
|
74
74
|
into the console:
|
|
75
75
|
|
|
76
76
|
```text
|
|
77
|
-
lithe 0.6.
|
|
77
|
+
lithe 0.6.2 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
78
78
|
╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
|
|
79
79
|
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
80
80
|
│ ◆ update_todos ││ 步骤 2/35 · 0.2s │
|
|
81
81
|
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
82
|
-
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
83
|
-
│ ✓
|
|
82
|
+
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
83
|
+
│ ✓ 编辑 a.txt(+3 -1 行) 0.0s ││ ◆ 待办 1/3 │
|
|
84
84
|
│ 已完成。 ││ [~] 修改 a.txt │
|
|
85
85
|
│ ││ ◆ 会话用量 │
|
|
86
86
|
│ ││ 输入 1,024 · 输出 216│
|
|
@@ -89,18 +89,25 @@ into the console:
|
|
|
89
89
|
│ ││ 上下文 1,024 / 128,000 (0.8%)│
|
|
90
90
|
╰─────────────────────────────────────╯╰────────────────────╯
|
|
91
91
|
lithe ❯ _
|
|
92
|
-
● 完成 ·
|
|
92
|
+
● 完成 · 0.2s Enter 发送 · F2 侧栏 · /help 命令
|
|
93
93
|
```
|
|
94
94
|
|
|
95
95
|
The left pane is the conversation (task, compact tool-call summaries
|
|
96
|
-
and outcomes
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
and outcomes — file edits land with `+N -M 行` counts — and the
|
|
97
|
+
streaming assistant reply). The right sidebar shows run status, recent
|
|
98
|
+
tools, todos, session-wide cumulative input/output/cached tokens and
|
|
99
|
+
cost, plus the latest model-call context length and window percentage;
|
|
100
|
+
usage numbers live only there, the bottom bar keeps status and key
|
|
101
|
+
hints. The two panes are separate windows — selecting text
|
|
102
|
+
in one never captures the other — and the conversation scrolls: mouse
|
|
103
|
+
wheel, PageUp/PageDown, Home/End (a new turn jumps back to the output).
|
|
104
|
+
F2 or `/sidebar` hides the status pane so a reply can be copied from a
|
|
105
|
+
full-width screen; Shift+drag keeps the terminal's native selection. The
|
|
106
|
+
screen stays up between turns; `Ctrl+C` cancels the current turn while
|
|
107
|
+
running and exits when idle. In `lithe run` mode the result stays on
|
|
108
|
+
screen until you press `q`. Layout, wrapping and alignment are
|
|
109
|
+
East-Asian-width aware; narrower terminals stack the two panes
|
|
110
|
+
vertically. Pipes and CI keep the plain per-line output unchanged.
|
|
104
111
|
|
|
105
112
|
History carries across turns within a session (each turn is its own run
|
|
106
113
|
in the store, replayed as context for the next). `/new` clears the
|
|
@@ -44,7 +44,8 @@ SYSTEM_PROMPT_BASE = (
|
|
|
44
44
|
"你是运行在命令行里的助理,工作区是用户的当前目录。"
|
|
45
45
|
"用提供的文件工具完成任务(读写前先读、谨慎修改);修改已有文件时优先用 edit_file 或 apply_patch 做局部修改,"
|
|
46
46
|
"仅在新建文件或确需整体重写时使用 write_file。"
|
|
47
|
-
"需要多步任务时先用 update_todos
|
|
47
|
+
"需要多步任务时先用 update_todos 列出计划(条数与实际步骤数一致:一两步就一两条,复杂任务可以拆到七八条,不要固定写成 3 条),"
|
|
48
|
+
"最后用简洁中文汇报结果。"
|
|
48
49
|
)
|
|
49
50
|
|
|
50
51
|
|
|
@@ -2,14 +2,17 @@
|
|
|
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
|
|
@@ -28,6 +31,7 @@ from prompt_toolkit.key_binding import KeyBindings
|
|
|
28
31
|
from prompt_toolkit.layout import (
|
|
29
32
|
BufferControl,
|
|
30
33
|
Dimension,
|
|
34
|
+
DynamicContainer,
|
|
31
35
|
FormattedTextControl,
|
|
32
36
|
HSplit,
|
|
33
37
|
Layout,
|
|
@@ -73,9 +77,12 @@ _STYLE = Style.from_dict(
|
|
|
73
77
|
|
|
74
78
|
_WELCOME = (
|
|
75
79
|
"输入任务开始对话;/help 查看命令。",
|
|
80
|
+
"PgUp/PgDn 或滚轮回看对话;F2 隐藏侧栏;Shift+拖拽复制。",
|
|
76
81
|
"Ctrl+C 在运行中取消本轮,空闲时退出。",
|
|
77
82
|
)
|
|
78
83
|
|
|
84
|
+
_SCROLL_STEP = 3
|
|
85
|
+
|
|
79
86
|
|
|
80
87
|
def screen_supported() -> bool:
|
|
81
88
|
"""The TUI needs a real terminal (never pipes, CI or dumb terms)."""
|
|
@@ -115,6 +122,8 @@ class TuiState:
|
|
|
115
122
|
self.tools: list[dict] = []
|
|
116
123
|
self.feed: list[tuple[str, str]] = []
|
|
117
124
|
self.streaming = ""
|
|
125
|
+
self.scroll = 0
|
|
126
|
+
self.show_sidebar = True
|
|
118
127
|
self.started: float | None = None
|
|
119
128
|
self.last_status: str | None = None
|
|
120
129
|
self.stop: asyncio.Event | None = None
|
|
@@ -154,6 +163,7 @@ class TuiState:
|
|
|
154
163
|
if kind == "run_start":
|
|
155
164
|
self.status = "思考中"
|
|
156
165
|
self.step = 0
|
|
166
|
+
self.scroll = 0
|
|
157
167
|
self._turn_tokens = 0
|
|
158
168
|
self._turn_cost = 0.0
|
|
159
169
|
self._turn_input_tokens = 0
|
|
@@ -280,8 +290,29 @@ def _status_class(status: str) -> str:
|
|
|
280
290
|
return "st-run"
|
|
281
291
|
|
|
282
292
|
|
|
283
|
-
def
|
|
284
|
-
|
|
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."""
|
|
285
316
|
width = max(20, width)
|
|
286
317
|
height = max(4, height)
|
|
287
318
|
inner = max(1, width - 4)
|
|
@@ -293,19 +324,45 @@ def _panel(title: str, lines: list[tuple[str, str]], width: int, height: int) ->
|
|
|
293
324
|
("class:border", "─" * (width - display_width(label) - 2) + "╮"),
|
|
294
325
|
]
|
|
295
326
|
]
|
|
296
|
-
wrapped
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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:
|
|
300
336
|
rows.append(
|
|
301
|
-
[("class:border", "│ "),
|
|
337
|
+
[("class:border", "│ "), seg, ("class:border", " │")]
|
|
302
338
|
)
|
|
303
|
-
for _ in range(height - 2 -
|
|
339
|
+
for _ in range(height - 2 - len(body)):
|
|
304
340
|
rows.append([("class:border", "│ "), ("", " " * inner), ("class:border", " │")])
|
|
305
341
|
rows.append([("class:border", "╰" + "─" * (inner + 2) + "╯")])
|
|
306
342
|
return rows
|
|
307
343
|
|
|
308
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
|
+
|
|
309
366
|
def _conversation_lines(state: TuiState) -> list[tuple[str, str]]:
|
|
310
367
|
lines = list(state.feed)
|
|
311
368
|
if state.streaming:
|
|
@@ -380,32 +437,22 @@ def _header_row(state: TuiState, width: int, version: str) -> list:
|
|
|
380
437
|
]
|
|
381
438
|
|
|
382
439
|
|
|
383
|
-
def
|
|
384
|
-
"""
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
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
|
|
389
445
|
if width >= 76:
|
|
390
446
|
side_w = min(44, max(30, width // 3))
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
else:
|
|
395
|
-
conv_h = max(4, body * 3 // 5)
|
|
396
|
-
rows.extend(_panel("对话", _conversation_lines(state), width, conv_h))
|
|
397
|
-
rows.extend(_panel("运行状态", _sidebar_lines(state), width, max(4, body - conv_h)))
|
|
398
|
-
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))
|
|
399
450
|
|
|
400
451
|
|
|
401
452
|
def compose_footer(state: TuiState, width: int, running_hint: str) -> list:
|
|
453
|
+
"""Status + key hints only; usage lives in the sidebar, not down here."""
|
|
402
454
|
dot = f"class:foot class:{_status_class(state.status)}"
|
|
403
|
-
|
|
404
|
-
total_cost = state.cost + state._turn_cost
|
|
405
|
-
left = (
|
|
406
|
-
f" ● {state.status} · step {state.step}/{state.max_steps}"
|
|
407
|
-
f" · {total_tokens:,} tok · ${total_cost:.4f}"
|
|
408
|
-
)
|
|
455
|
+
left = f" ● {state.status} "
|
|
409
456
|
room = width - display_width(running_hint) - display_width(left) - 2
|
|
410
457
|
text = left + (" " * max(1, room)) if room >= 1 else truncate(left, width - display_width(running_hint) - 1)
|
|
411
458
|
return [(dot, " "), ("class:foot", pad(text, width - display_width(running_hint))), ("class:foot", running_hint)]
|
|
@@ -429,7 +476,13 @@ def _term_size(app: Application | None) -> tuple[int, int]:
|
|
|
429
476
|
|
|
430
477
|
|
|
431
478
|
def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Application, Buffer]:
|
|
432
|
-
"""Assemble the full-screen Application; the input line drives *on_line*.
|
|
479
|
+
"""Assemble the full-screen Application; the input line drives *on_line*.
|
|
480
|
+
|
|
481
|
+
The conversation pane and the status sidebar live in separate Windows,
|
|
482
|
+
so terminal selection never spans both. The conversation keeps a scroll
|
|
483
|
+
offset (PgUp/PgDn/Home/End and the mouse wheel) instead of only ever
|
|
484
|
+
showing the tail; F2 (or /sidebar) hides the sidebar for full-width
|
|
485
|
+
copying."""
|
|
433
486
|
kb = KeyBindings()
|
|
434
487
|
|
|
435
488
|
@kb.add("c-c", eager=True)
|
|
@@ -448,6 +501,26 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
448
501
|
if not state.running:
|
|
449
502
|
event.app.exit()
|
|
450
503
|
|
|
504
|
+
def _page(dy: int):
|
|
505
|
+
def _scroll(event):
|
|
506
|
+
_, rows = _term_size(state._app)
|
|
507
|
+
step = max(1, (rows - 5) // 2)
|
|
508
|
+
state.scroll = max(0, state.scroll + dy * step)
|
|
509
|
+
state.invalidate()
|
|
510
|
+
|
|
511
|
+
return _scroll
|
|
512
|
+
|
|
513
|
+
kb.add("pageup")(_page(1))
|
|
514
|
+
kb.add("pagedown")(_page(-1))
|
|
515
|
+
kb.add("home")(lambda event: (setattr(state, "scroll", 10**9), state.invalidate()))
|
|
516
|
+
kb.add("end")(lambda event: (setattr(state, "scroll", 0), state.invalidate()))
|
|
517
|
+
|
|
518
|
+
def _toggle_sidebar(event):
|
|
519
|
+
state.show_sidebar = not state.show_sidebar
|
|
520
|
+
state.invalidate()
|
|
521
|
+
|
|
522
|
+
kb.add("f2")(_toggle_sidebar)
|
|
523
|
+
|
|
451
524
|
buffer = Buffer(
|
|
452
525
|
history=_chat_history() if mode == "chat" else None,
|
|
453
526
|
multiline=False,
|
|
@@ -455,30 +528,65 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
455
528
|
accept_handler=lambda buff: (on_line(buff.text), False)[1],
|
|
456
529
|
)
|
|
457
530
|
|
|
458
|
-
def
|
|
531
|
+
def _header():
|
|
532
|
+
cols, _ = _term_size(state._app)
|
|
533
|
+
return FormattedText(_header_row(state, cols, version))
|
|
534
|
+
|
|
535
|
+
def _conv_text():
|
|
459
536
|
cols, rows = _term_size(state._app)
|
|
537
|
+
(cw, ch), _ = body_layout(state, cols, rows - 3)
|
|
460
538
|
fragments = []
|
|
461
|
-
for row in
|
|
539
|
+
for row in conversation_rows(state, cw, ch):
|
|
462
540
|
fragments.extend(row)
|
|
463
541
|
fragments.append(("", "\n"))
|
|
464
|
-
return FormattedText(fragments)
|
|
542
|
+
return FormattedText(fragments[:-1])
|
|
543
|
+
|
|
544
|
+
def _side_text():
|
|
545
|
+
cols, rows = _term_size(state._app)
|
|
546
|
+
_, side = body_layout(state, cols, rows - 3)
|
|
547
|
+
fragments = []
|
|
548
|
+
if side is not None:
|
|
549
|
+
for row in sidebar_rows(state, side[0], side[1]):
|
|
550
|
+
fragments.extend(row)
|
|
551
|
+
fragments.append(("", "\n"))
|
|
552
|
+
return FormattedText(fragments[:-1] if fragments else "")
|
|
553
|
+
|
|
554
|
+
def _body():
|
|
555
|
+
cols, rows = _term_size(state._app)
|
|
556
|
+
(cw, ch), side = body_layout(state, cols, rows - 3)
|
|
557
|
+
conv = Window(
|
|
558
|
+
FormattedTextControl(_conv_text, show_cursor=False),
|
|
559
|
+
width=Dimension.exact(cw),
|
|
560
|
+
height=Dimension.exact(ch),
|
|
561
|
+
wrap_lines=False,
|
|
562
|
+
)
|
|
563
|
+
if side is None:
|
|
564
|
+
return conv
|
|
565
|
+
side_win = Window(
|
|
566
|
+
FormattedTextControl(_side_text, show_cursor=False),
|
|
567
|
+
width=Dimension.exact(side[0]),
|
|
568
|
+
height=Dimension.exact(side[1]),
|
|
569
|
+
wrap_lines=False,
|
|
570
|
+
)
|
|
571
|
+
if side[0] >= 30 and side[1] == ch:
|
|
572
|
+
return VSplit([conv, side_win])
|
|
573
|
+
return HSplit([conv, side_win])
|
|
465
574
|
|
|
466
575
|
def _footer():
|
|
467
576
|
cols, _ = _term_size(state._app)
|
|
468
577
|
if mode == "run":
|
|
469
|
-
hint = " q 退出 " if not state.running else " Ctrl+C 取消 "
|
|
578
|
+
hint = " q 退出 · F2 侧栏 " if not state.running else " Ctrl+C 取消 · PgUp/PgDn 滚动 "
|
|
470
579
|
elif state.running:
|
|
471
|
-
hint = " Ctrl+C 取消本轮 "
|
|
580
|
+
hint = " Ctrl+C 取消本轮 · PgUp/PgDn 滚动 "
|
|
472
581
|
else:
|
|
473
|
-
hint = " Enter 发送 · /help 命令 "
|
|
582
|
+
hint = " Enter 发送 · F2 侧栏 · /help 命令 "
|
|
474
583
|
return FormattedText(compose_footer(state, cols, hint))
|
|
475
584
|
|
|
476
585
|
def _prompt_text():
|
|
477
586
|
return FormattedText([("class:prompt", "lithe ❯ ")])
|
|
478
587
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
)
|
|
588
|
+
header_win = Window(FormattedTextControl(_header, show_cursor=False), height=Dimension.exact(1))
|
|
589
|
+
body_container = DynamicContainer(_body)
|
|
482
590
|
input_row = VSplit(
|
|
483
591
|
[
|
|
484
592
|
Window(FormattedTextControl(_prompt_text, show_cursor=False), width=Dimension.exact(8)),
|
|
@@ -487,20 +595,25 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
487
595
|
)
|
|
488
596
|
footer_win = Window(FormattedTextControl(_footer, show_cursor=False), height=Dimension.exact(1))
|
|
489
597
|
app = Application(
|
|
490
|
-
layout=Layout(HSplit([
|
|
598
|
+
layout=Layout(HSplit([header_win, body_container, input_row, footer_win])),
|
|
491
599
|
key_bindings=kb,
|
|
492
600
|
style=_STYLE,
|
|
493
601
|
full_screen=True,
|
|
602
|
+
mouse_support=True,
|
|
494
603
|
)
|
|
495
604
|
app.layout.focus(buffer)
|
|
496
605
|
return app, buffer
|
|
497
606
|
|
|
498
607
|
|
|
499
608
|
_CHAT_HELP = """可用命令:
|
|
500
|
-
/help
|
|
501
|
-
/tools
|
|
502
|
-
/
|
|
503
|
-
/
|
|
609
|
+
/help 显示这段帮助
|
|
610
|
+
/tools 列出当前注册的工具
|
|
611
|
+
/sidebar 显示/隐藏右侧状态栏(F2 同效)
|
|
612
|
+
/new 清空会话历史,重新开始
|
|
613
|
+
/exit 退出(等同 /quit)
|
|
614
|
+
|
|
615
|
+
按键:PgUp/PgDn·滚轮 滚动对话,Home/End 跳到顶/底,
|
|
616
|
+
Shift+拖拽 终端原生复制(隐藏侧栏后左栏独占整行更干净)。"""
|
|
504
617
|
|
|
505
618
|
|
|
506
619
|
async def run_screen(cfg, task: str | None, mode: str) -> int:
|
|
@@ -528,6 +641,10 @@ async def run_screen(cfg, task: str | None, mode: str) -> int:
|
|
|
528
641
|
if line == "/help":
|
|
529
642
|
state.say("dim", _CHAT_HELP)
|
|
530
643
|
return True
|
|
644
|
+
if line == "/sidebar":
|
|
645
|
+
state.show_sidebar = not state.show_sidebar
|
|
646
|
+
state.say("dim", "(侧栏已隐藏,F2 或 /sidebar 恢复)" if not state.show_sidebar else "(侧栏已恢复)")
|
|
647
|
+
return True
|
|
531
648
|
if line == "/tools":
|
|
532
649
|
names = sorted(build_registry(cfg).names())
|
|
533
650
|
state.say("dim", "已注册工具:" + "、".join(names))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.3
|
|
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
|
|
|
@@ -99,12 +99,12 @@ $ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
|
99
99
|
⚒ read_file 读取 README.md
|
|
100
100
|
✓ 已读取 README.md(120 行) 0.1s
|
|
101
101
|
⚒ write_file 写入 SUMMARY.md
|
|
102
|
-
✓ 写入 SUMMARY.md 0.0s
|
|
102
|
+
✓ 写入 SUMMARY.md(+12 行,新建) 0.0s
|
|
103
103
|
已把要点写入 SUMMARY.md。
|
|
104
104
|
── done · steps 3 · tokens 2100 · cost 0.0042
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file
|
|
107
|
+
修改已有文件时,助手优先调用 `edit_file` 或 `apply_patch` 做局部更新;只有新建文件或需要整体替换时才使用 `write_file`。工具调用界面只显示工具名、文件路径等摘要,文件写入/编辑的结果带 `+N -M 行` 的行数变化提示(lithe 0.9.10 起由内核统计)。
|
|
108
108
|
|
|
109
109
|
`--stream` streams tokens as they generate; `-v` adds per-call
|
|
110
110
|
usage/context gauges; `--max-steps` caps the tool loop;
|
|
@@ -117,13 +117,13 @@ persistent full-screen interface instead of scribbling one-line events
|
|
|
117
117
|
into the console:
|
|
118
118
|
|
|
119
119
|
```text
|
|
120
|
-
lithe 0.6.
|
|
120
|
+
lithe 0.6.2 · your-model · /home/me/my-project ● 完成 · 0.2s
|
|
121
121
|
╭─ 对话 ──────────────────────────────╮╭─ 运行状态 ─────────╮
|
|
122
122
|
│ 把 a.txt 改成三行待办清单 ││ ● 完成 · 0.2s │
|
|
123
123
|
│ ◆ update_todos ││ 步骤 2/35 · 0.2s │
|
|
124
124
|
│ ✓ 任务清单已更新(3 条) 0.0s ││ ✓ update_todos 0.0s│
|
|
125
|
-
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
126
|
-
│ ✓
|
|
125
|
+
│ ◆ edit_file · 局部修改 a.txt ││ ✓ edit_file 0.0s │
|
|
126
|
+
│ ✓ 编辑 a.txt(+3 -1 行) 0.0s ││ ◆ 待办 1/3 │
|
|
127
127
|
│ 已完成。 ││ [~] 修改 a.txt │
|
|
128
128
|
│ ││ ◆ 会话用量 │
|
|
129
129
|
│ ││ 输入 1,024 · 输出 216│
|
|
@@ -132,18 +132,25 @@ into the console:
|
|
|
132
132
|
│ ││ 上下文 1,024 / 128,000 (0.8%)│
|
|
133
133
|
╰─────────────────────────────────────╯╰────────────────────╯
|
|
134
134
|
lithe ❯ _
|
|
135
|
-
● 完成 ·
|
|
135
|
+
● 完成 · 0.2s Enter 发送 · F2 侧栏 · /help 命令
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
The left pane is the conversation (task, compact tool-call summaries
|
|
139
|
-
and outcomes
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
139
|
+
and outcomes — file edits land with `+N -M 行` counts — and the
|
|
140
|
+
streaming assistant reply). The right sidebar shows run status, recent
|
|
141
|
+
tools, todos, session-wide cumulative input/output/cached tokens and
|
|
142
|
+
cost, plus the latest model-call context length and window percentage;
|
|
143
|
+
usage numbers live only there, the bottom bar keeps status and key
|
|
144
|
+
hints. The two panes are separate windows — selecting text
|
|
145
|
+
in one never captures the other — and the conversation scrolls: mouse
|
|
146
|
+
wheel, PageUp/PageDown, Home/End (a new turn jumps back to the output).
|
|
147
|
+
F2 or `/sidebar` hides the status pane so a reply can be copied from a
|
|
148
|
+
full-width screen; Shift+drag keeps the terminal's native selection. The
|
|
149
|
+
screen stays up between turns; `Ctrl+C` cancels the current turn while
|
|
150
|
+
running and exits when idle. In `lithe run` mode the result stays on
|
|
151
|
+
screen until you press `q`. Layout, wrapping and alignment are
|
|
152
|
+
East-Asian-width aware; narrower terminals stack the two panes
|
|
153
|
+
vertically. Pipes and CI keep the plain per-line output unchanged.
|
|
147
154
|
|
|
148
155
|
History carries across turns within a session (each turn is its own run
|
|
149
156
|
in the store, replayed as context for the next). `/new` clears the
|
|
@@ -9,7 +9,7 @@ description = "Command-line interface for the lithe agent kernel: chat, one-shot
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
|
-
dependencies = ["lithe>=0.9.
|
|
12
|
+
dependencies = ["lithe>=0.9.10", "prompt-toolkit>=3.0.38"]
|
|
13
13
|
keywords = ["agent", "llm", "react", "cli", "tool-calling"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
@@ -342,26 +342,76 @@ def test_system_prompt_prefers_localized_file_edits():
|
|
|
342
342
|
|
|
343
343
|
assert "优先用 edit_file 或 apply_patch 做局部修改" in SYSTEM_PROMPT_BASE
|
|
344
344
|
assert "仅在新建文件或确需整体重写时使用 write_file" in SYSTEM_PROMPT_BASE
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
+
)
|
|
349
358
|
from lithe_cli.ui import display_width
|
|
350
359
|
|
|
351
360
|
state = TuiState("模型", "/工作区", 5)
|
|
352
361
|
state.on_event({"type": "tool_call", "id": "c1", "name": "read_file", "args": {"path": "x"}})
|
|
353
362
|
state.on_event({"type": "todo_change", "new": [{"content": "处理中文任务", "status": "pending"}]})
|
|
354
363
|
|
|
355
|
-
for width, height in ((100,
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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"))
|
|
360
382
|
assert all(display_width(text) <= width for _, text in compose_footer(state, width, " hint "))
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
365
415
|
|
|
366
416
|
|
|
367
417
|
def test_wrap_text_is_cjk_aware():
|
|
@@ -622,6 +672,64 @@ def _pty_drain(fd) -> bytes:
|
|
|
622
672
|
return out
|
|
623
673
|
|
|
624
674
|
|
|
675
|
+
def test_footer_does_not_duplicate_sidebar_usage():
|
|
676
|
+
from lithe_cli.tui import TuiState, compose_footer
|
|
677
|
+
|
|
678
|
+
state = TuiState("m", "/ws", 5)
|
|
679
|
+
state.on_event({"type": "done", "status": "done", "steps": 3, "tokens": 120, "cost": 0.5})
|
|
680
|
+
line = "".join(f[1] for f in compose_footer(state, 100, " hint "))
|
|
681
|
+
assert "完成" in line
|
|
682
|
+
assert "tok" not in line and "$" not in line and "step" not in line
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def test_file_tools_report_line_delta_in_feed(tmp_path):
|
|
686
|
+
script = [
|
|
687
|
+
{"tool_calls": [_tc("write_file", {"path": "a.txt", "content": "hi"}, "c1")]},
|
|
688
|
+
{"tool_calls": [_tc("edit_file",
|
|
689
|
+
{"path": "a.txt", "old_text": "hi", "new_text": "hi\nyo"}, "c2")]},
|
|
690
|
+
{"content": "完成"},
|
|
691
|
+
]
|
|
692
|
+
cfg = make_config(tmp_path, script)
|
|
693
|
+
seen = []
|
|
694
|
+
rid, done, _ = asyncio.run(execute(cfg, "编辑 a.txt", on_event=seen.append))
|
|
695
|
+
assert done["status"] == "done"
|
|
696
|
+
summaries = [e.get("summary", "") for e in seen if e["type"] == "tool_result"]
|
|
697
|
+
assert any("写入 a.txt(+1 行,新建)" in s for s in summaries)
|
|
698
|
+
assert any("编辑 a.txt(+1 行)" in s for s in summaries)
|
|
699
|
+
assert (cfg.workspace_dir / "a.txt").read_text(encoding="utf-8") == "hi\nyo"
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def test_fullscreen_app_scrolls_and_toggles_sidebar():
|
|
703
|
+
code = """
|
|
704
|
+
import asyncio
|
|
705
|
+
from lithe_cli.tui import TuiState, build_app
|
|
706
|
+
|
|
707
|
+
async def main():
|
|
708
|
+
state = TuiState("m", "/ws", 5)
|
|
709
|
+
for i in range(40):
|
|
710
|
+
state.say("assistant", f"line {i}")
|
|
711
|
+
app, _ = build_app(state, "chat", "0.7.0", lambda text: None)
|
|
712
|
+
await app.run_async()
|
|
713
|
+
print(f"RESULT scroll={state.scroll} sidebar={state.show_sidebar}")
|
|
714
|
+
|
|
715
|
+
asyncio.run(main())
|
|
716
|
+
"""
|
|
717
|
+
feed = [
|
|
718
|
+
b"\x1b[5~", # PageUp: scroll back
|
|
719
|
+
b"\x1b[6~", # PageDown: back to the tail
|
|
720
|
+
b"\x1bOQ", # F2: hide the sidebar
|
|
721
|
+
b"\x1b[5~", # PageUp again: leave a nonzero offset
|
|
722
|
+
b"\x1b[5~",
|
|
723
|
+
b"\x03", # Ctrl+C exits
|
|
724
|
+
]
|
|
725
|
+
out, rc = _pty_run(code, feed, settle=0.4)
|
|
726
|
+
assert rc == 0
|
|
727
|
+
assert "RESULT scroll=" in out
|
|
728
|
+
assert "scroll=0 " not in out # two PageUps must leave a nonzero offset
|
|
729
|
+
assert "sidebar=False" in out
|
|
730
|
+
assert "Traceback" not in out
|
|
731
|
+
|
|
732
|
+
|
|
625
733
|
def test_prompt_toolkit_cjk_paste_password_history(tmp_path):
|
|
626
734
|
# the exact long Chinese line that readline corrupted in the field,
|
|
627
735
|
# 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
|