lithe-cli 0.6.1__tar.gz → 0.6.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/PKG-INFO +12 -7
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/README.md +11 -6
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/__init__.py +1 -1
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/agent.py +2 -1
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/tui.py +164 -43
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli.egg-info/PKG-INFO +12 -7
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/tests/test_cli.py +94 -13
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/LICENSE +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/__main__.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/config.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/main.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/prompts.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/py.typed +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/setup.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli/ui.py +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli.egg-info/SOURCES.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli.egg-info/requires.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/pyproject.toml +0 -0
- {lithe-cli-0.6.1 → lithe-cli-0.6.2}/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.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
|
|
|
@@ -117,7 +117,7 @@ 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 │
|
|
@@ -139,11 +139,16 @@ The left pane is the conversation (task, compact tool-call summaries
|
|
|
139
139
|
and outcomes, and the streaming assistant reply). The right sidebar shows
|
|
140
140
|
run status, recent tools, todos, session-wide cumulative input/output/
|
|
141
141
|
cached tokens and cost, plus the latest model-call context length and
|
|
142
|
-
window percentage. The
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
147
152
|
|
|
148
153
|
History carries across turns within a session (each turn is its own run
|
|
149
154
|
in the store, replayed as context for the next). `/new` clears the
|
|
@@ -74,7 +74,7 @@ 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 │
|
|
@@ -96,11 +96,16 @@ The left pane is the conversation (task, compact tool-call summaries
|
|
|
96
96
|
and outcomes, and the streaming assistant reply). The right sidebar shows
|
|
97
97
|
run status, recent tools, todos, session-wide cumulative input/output/
|
|
98
98
|
cached tokens and cost, plus the latest model-call context length and
|
|
99
|
-
window percentage. The
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
104
109
|
|
|
105
110
|
History carries across turns within a session (each turn is its own run
|
|
106
111
|
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,22 +437,16 @@ 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:
|
|
@@ -429,7 +480,13 @@ def _term_size(app: Application | None) -> tuple[int, int]:
|
|
|
429
480
|
|
|
430
481
|
|
|
431
482
|
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*.
|
|
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."""
|
|
433
490
|
kb = KeyBindings()
|
|
434
491
|
|
|
435
492
|
@kb.add("c-c", eager=True)
|
|
@@ -448,6 +505,26 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
448
505
|
if not state.running:
|
|
449
506
|
event.app.exit()
|
|
450
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
|
+
|
|
451
528
|
buffer = Buffer(
|
|
452
529
|
history=_chat_history() if mode == "chat" else None,
|
|
453
530
|
multiline=False,
|
|
@@ -455,30 +532,65 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
455
532
|
accept_handler=lambda buff: (on_line(buff.text), False)[1],
|
|
456
533
|
)
|
|
457
534
|
|
|
458
|
-
def
|
|
535
|
+
def _header():
|
|
536
|
+
cols, _ = _term_size(state._app)
|
|
537
|
+
return FormattedText(_header_row(state, cols, version))
|
|
538
|
+
|
|
539
|
+
def _conv_text():
|
|
459
540
|
cols, rows = _term_size(state._app)
|
|
541
|
+
(cw, ch), _ = body_layout(state, cols, rows - 3)
|
|
460
542
|
fragments = []
|
|
461
|
-
for row in
|
|
543
|
+
for row in conversation_rows(state, cw, ch):
|
|
462
544
|
fragments.extend(row)
|
|
463
545
|
fragments.append(("", "\n"))
|
|
464
|
-
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])
|
|
465
578
|
|
|
466
579
|
def _footer():
|
|
467
580
|
cols, _ = _term_size(state._app)
|
|
468
581
|
if mode == "run":
|
|
469
|
-
hint = " q 退出 " if not state.running else " Ctrl+C 取消 "
|
|
582
|
+
hint = " q 退出 · F2 侧栏 " if not state.running else " Ctrl+C 取消 · PgUp/PgDn 滚动 "
|
|
470
583
|
elif state.running:
|
|
471
|
-
hint = " Ctrl+C 取消本轮 "
|
|
584
|
+
hint = " Ctrl+C 取消本轮 · PgUp/PgDn 滚动 "
|
|
472
585
|
else:
|
|
473
|
-
hint = " Enter 发送 · /help 命令 "
|
|
586
|
+
hint = " Enter 发送 · F2 侧栏 · /help 命令 "
|
|
474
587
|
return FormattedText(compose_footer(state, cols, hint))
|
|
475
588
|
|
|
476
589
|
def _prompt_text():
|
|
477
590
|
return FormattedText([("class:prompt", "lithe ❯ ")])
|
|
478
591
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
)
|
|
592
|
+
header_win = Window(FormattedTextControl(_header, show_cursor=False), height=Dimension.exact(1))
|
|
593
|
+
body_container = DynamicContainer(_body)
|
|
482
594
|
input_row = VSplit(
|
|
483
595
|
[
|
|
484
596
|
Window(FormattedTextControl(_prompt_text, show_cursor=False), width=Dimension.exact(8)),
|
|
@@ -487,20 +599,25 @@ def build_app(state: TuiState, mode: str, version: str, on_line) -> tuple[Applic
|
|
|
487
599
|
)
|
|
488
600
|
footer_win = Window(FormattedTextControl(_footer, show_cursor=False), height=Dimension.exact(1))
|
|
489
601
|
app = Application(
|
|
490
|
-
layout=Layout(HSplit([
|
|
602
|
+
layout=Layout(HSplit([header_win, body_container, input_row, footer_win])),
|
|
491
603
|
key_bindings=kb,
|
|
492
604
|
style=_STYLE,
|
|
493
605
|
full_screen=True,
|
|
606
|
+
mouse_support=True,
|
|
494
607
|
)
|
|
495
608
|
app.layout.focus(buffer)
|
|
496
609
|
return app, buffer
|
|
497
610
|
|
|
498
611
|
|
|
499
612
|
_CHAT_HELP = """可用命令:
|
|
500
|
-
/help
|
|
501
|
-
/tools
|
|
502
|
-
/
|
|
503
|
-
/
|
|
613
|
+
/help 显示这段帮助
|
|
614
|
+
/tools 列出当前注册的工具
|
|
615
|
+
/sidebar 显示/隐藏右侧状态栏(F2 同效)
|
|
616
|
+
/new 清空会话历史,重新开始
|
|
617
|
+
/exit 退出(等同 /quit)
|
|
618
|
+
|
|
619
|
+
按键:PgUp/PgDn·滚轮 滚动对话,Home/End 跳到顶/底,
|
|
620
|
+
Shift+拖拽 终端原生复制(隐藏侧栏后左栏独占整行更干净)。"""
|
|
504
621
|
|
|
505
622
|
|
|
506
623
|
async def run_screen(cfg, task: str | None, mode: str) -> int:
|
|
@@ -528,6 +645,10 @@ async def run_screen(cfg, task: str | None, mode: str) -> int:
|
|
|
528
645
|
if line == "/help":
|
|
529
646
|
state.say("dim", _CHAT_HELP)
|
|
530
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
|
|
531
652
|
if line == "/tools":
|
|
532
653
|
names = sorted(build_registry(cfg).names())
|
|
533
654
|
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.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
|
|
|
@@ -117,7 +117,7 @@ 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 │
|
|
@@ -139,11 +139,16 @@ The left pane is the conversation (task, compact tool-call summaries
|
|
|
139
139
|
and outcomes, and the streaming assistant reply). The right sidebar shows
|
|
140
140
|
run status, recent tools, todos, session-wide cumulative input/output/
|
|
141
141
|
cached tokens and cost, plus the latest model-call context length and
|
|
142
|
-
window percentage. The
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
147
152
|
|
|
148
153
|
History carries across turns within a session (each turn is its own run
|
|
149
154
|
in the store, replayed as context for the next). `/new` clears the
|
|
@@ -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,37 @@ def _pty_drain(fd) -> bytes:
|
|
|
622
672
|
return out
|
|
623
673
|
|
|
624
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
|
+
|
|
625
706
|
def test_prompt_toolkit_cjk_paste_password_history(tmp_path):
|
|
626
707
|
# the exact long Chinese line that readline corrupted in the field,
|
|
627
708
|
# driven through the real prompt_toolkit stack
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|