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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lithe-cli
3
- Version: 0.6.1
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.1 · your-model · /home/me/my-project ● 完成 · 0.2s
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
- │ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
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
- ● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
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, and the streaming assistant reply). The right sidebar shows
140
- run status, recent tools, todos, session-wide cumulative input/output/
141
- cached tokens and cost, plus the latest model-call context length and
142
- window percentage. The screen stays up between turns; `Ctrl+C` cancels
143
- the current turn while running and exits when idle. In `lithe run` mode
144
- the result stays on screen until you press `q`. Layout, wrapping and
145
- alignment are East-Asian-width aware; narrower terminals stack the two
146
- panes vertically. Pipes and CI keep the plain per-line output unchanged.
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.1 · your-model · /home/me/my-project ● 完成 · 0.2s
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
- │ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
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
- ● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
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, and the streaming assistant reply). The right sidebar shows
97
- run status, recent tools, todos, session-wide cumulative input/output/
98
- cached tokens and cost, plus the latest model-call context length and
99
- window percentage. The screen stays up between turns; `Ctrl+C` cancels
100
- the current turn while running and exits when idle. In `lithe run` mode
101
- the result stays on screen until you press `q`. Layout, wrapping and
102
- alignment are East-Asian-width aware; narrower terminals stack the two
103
- panes vertically. Pipes and CI keep the plain per-line output unchanged.
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
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.6.1"
3
+ __version__ = "0.6.3"
@@ -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
- on the left, a status sidebar (run state, tool calls, todos, token /
6
- cost totals) on the right, an input line and a status bar. Turns stream
7
- into the panes and the screen stays up between runs — no falling back
8
- to the raw console after a task finishes.
5
+ in its own scrollable Window, a status sidebar (run state, tool calls,
6
+ todos, token / cost totals) in a separate Window beside it, an input line
7
+ and a status bar. Panes never share a rendering surface, so terminal
8
+ selection cannot bleed across them; F2 hides the sidebar entirely and the
9
+ conversation keeps a scroll offset (wheel, PgUp/PgDn, Home/End). Turns
10
+ stream into the panes and the screen stays up between runs — no falling
11
+ back to the raw console after a task finishes.
9
12
 
10
13
  Everything degrades: without a TTY ``main`` never routes here, and the
11
- frame composition is a pure function (``compose_frame``) so it renders
12
- identically under tests.
14
+ pane composition is pure functions (``conversation_rows`` /
15
+ ``sidebar_rows``) so they render identically under tests.
13
16
  """
14
17
 
15
18
  from __future__ import annotations
@@ -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 _panel(title: str, lines: list[tuple[str, str]], width: int, height: int) -> list[list]:
284
- """A bordered box of exactly *height* rows, each *width* columns wide."""
293
+ def _wrap_feed(lines: list[tuple[str, str]], inner: int) -> list[tuple[str, str]]:
294
+ wrapped: list[tuple[str, str]] = []
295
+ for cls, text in lines:
296
+ wrapped.extend((cls, seg) for seg in wrap_text(text, inner))
297
+ return wrapped
298
+
299
+
300
+ def _scroll_handler(state: TuiState, delta: int):
301
+ def _on_mouse(event) -> None:
302
+ state.scroll = max(0, state.scroll + delta)
303
+ state.invalidate()
304
+
305
+ return _on_mouse
306
+
307
+
308
+ def _box(
309
+ title: str,
310
+ wrapped: list[tuple[str, str]],
311
+ width: int,
312
+ height: int,
313
+ handler=None,
314
+ ) -> list[list]:
315
+ """A bordered box of exactly *height* rows; *handler* rides content lines."""
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: list[tuple[str, str]] = []
297
- for cls, text in lines:
298
- wrapped.extend((cls, seg) for seg in wrap_text(text, inner))
299
- for cls, seg in wrapped[-(height - 2):]:
327
+ shown = wrapped[-(height - 2):]
328
+ if handler is None:
329
+ body = [(f"class:{cls}" if cls else "", pad(seg, inner)) for cls, seg in shown]
330
+ else:
331
+ body = [
332
+ (f"class:{cls}" if cls else "", pad(seg, inner), handler)
333
+ for cls, seg in shown
334
+ ]
335
+ for seg in body:
300
336
  rows.append(
301
- [("class:border", "│ "), (f"class:{cls}" if cls else "", pad(seg, inner)), ("class:border", " │")]
337
+ [("class:border", "│ "), seg, ("class:border", " │")]
302
338
  )
303
- for _ in range(height - 2 - (len(rows) - 1)):
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 compose_frame(state: TuiState, width: int, height: int, version: str) -> list[list]:
384
- """The whole middle of the screen: header row + panel rows."""
385
- width = max(40, width)
386
- height = max(10, height)
387
- rows = [_header_row(state, width, version)]
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
- conv = _panel("对话", _conversation_lines(state), width - side_w, body)
392
- side = _panel("运行状态", _sidebar_lines(state), side_w, body)
393
- rows.extend(c + s for c, s in zip(conv, side, strict=True))
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
- total_tokens = state.tokens + state._turn_tokens
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 _frame():
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 compose_frame(state, cols, rows, version):
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
- frame_win = Window(
480
- FormattedTextControl(_frame, show_cursor=False), wrap_lines=False, height=Dimension(weight=1)
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([frame_win, input_row, footer_win])),
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
- /new 清空会话历史,重新开始
503
- /exit 退出(等同 /quit)"""
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.1
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.1 · your-model · /home/me/my-project ● 完成 · 0.2s
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
- │ ✓ 已修改 a.txt 0.0s ││ ◆ 待办 1/3 │
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
- ● 完成 · step 2/35 · 1,240 tok · $0.0021 Enter 发送 · /help 命令
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, and the streaming assistant reply). The right sidebar shows
140
- run status, recent tools, todos, session-wide cumulative input/output/
141
- cached tokens and cost, plus the latest model-call context length and
142
- window percentage. The screen stays up between turns; `Ctrl+C` cancels
143
- the current turn while running and exits when idle. In `lithe run` mode
144
- the result stays on screen until you press `q`. Layout, wrapping and
145
- alignment are East-Asian-width aware; narrower terminals stack the two
146
- panes vertically. Pipes and CI keep the plain per-line output unchanged.
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
@@ -1,4 +1,4 @@
1
- lithe>=0.9.8
1
+ lithe>=0.9.10
2
2
  prompt-toolkit>=3.0.38
3
3
 
4
4
  [dev]
@@ -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.8", "prompt-toolkit>=3.0.38"]
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
- def test_run_dashboard_fits_narrow_terminal():
348
- from lithe_cli.tui import TuiState, compose_footer, compose_frame
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, 30), (76, 24), (56, 24), (40, 20)):
356
- rows = compose_frame(state, width, height, "0.6.0")
357
- assert len(rows) == height - 2
358
- for row in rows:
359
- assert display_width("".join(text for _, text in row)) == width
364
+ for width, height in ((100, 27), (76, 21), (56, 21), (40, 17)):
365
+ (cw, ch), side = body_layout(state, width, height)
366
+ if side is None:
367
+ assert (cw, ch) == (width, height)
368
+ elif side[1] == ch: # side-by-side: independent windows on the same rows
369
+ assert cw + side[0] == width and ch == height
370
+ else: # stacked: panes share columns, not rows
371
+ assert cw == side[0] == width and ch + side[1] <= height
372
+ for rows, w, h in (
373
+ (conversation_rows(state, cw, ch), cw, ch),
374
+ (sidebar_rows(state, *side) if side else [], *(side or (0, 0))),
375
+ ):
376
+ if not rows:
377
+ continue
378
+ assert len(rows) == h
379
+ for row in rows:
380
+ assert display_width("".join(f[1] for f in row)) == w
381
+ assert all(display_width(t) <= width for t, _ in _header_row(state, width, "0.7.0"))
360
382
  assert all(display_width(text) <= width for _, text in compose_footer(state, width, " hint "))
361
- wide = "\n".join("".join(t for _, t in row) for row in compose_frame(state, 100, 30, "0.6.0"))
362
- narrow = "\n".join("".join(t for _, t in row) for row in compose_frame(state, 56, 24, "0.6.0"))
363
- assert "对话" in wide and "运行状态" in wide
364
- assert "处理中文任务" in narrow # stacked layout still shows todos
383
+ assert "处理中文任务" in "\n".join(
384
+ "".join(f[1] for f in row) for row in sidebar_rows(state, 40, 17)
385
+ )
386
+
387
+ state.show_sidebar = False
388
+ (cw, ch), side = body_layout(state, 100, 27)
389
+ assert side is None and (cw, ch) == (100, 27)
390
+
391
+
392
+ def test_conversation_pane_scrolls_back_and_clamps():
393
+ from lithe_cli.tui import TuiState, conversation_rows
394
+
395
+ state = TuiState("模型", "/工作区", 5)
396
+ for i in range(30):
397
+ state.say("assistant", f"第 {i} 行")
398
+ rows = conversation_rows(state, 60, 12)
399
+ text = "\n".join("".join(f[1] for f in row) for row in rows)
400
+ assert "第 29 行" in text and "第 0 行" not in text
401
+
402
+ state.scroll = 10**9 # Home: clamp to the very top
403
+ rows = conversation_rows(state, 60, 12)
404
+ text = "\n".join("".join(f[1] for f in row) for row in rows)
405
+ assert "第 0 行" in text and "第 29 行" not in text
406
+ assert state.scroll == 30 - 10
407
+
408
+ state.scroll = 5 # mid viewport: last visible line is 10 lines above the tail
409
+ rows = conversation_rows(state, 60, 12)
410
+ text = "\n".join("".join(f[1] for f in row) for row in rows)
411
+ assert "第 15 行" in text and "第 24 行" in text and "第 29 行" not in text
412
+
413
+ state.on_event({"type": "run_start"}) # a new turn resumes following the tail
414
+ assert state.scroll == 0
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