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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lithe-cli
3
- Version: 0.6.1
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.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 │
@@ -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 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.
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.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 │
@@ -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 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.
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
@@ -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.2"
@@ -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,22 +437,16 @@ 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:
@@ -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 _frame():
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 compose_frame(state, cols, rows, version):
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
- frame_win = Window(
480
- FormattedTextControl(_frame, show_cursor=False), wrap_lines=False, height=Dimension(weight=1)
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([frame_win, input_row, footer_win])),
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
- /new 清空会话历史,重新开始
503
- /exit 退出(等同 /quit)"""
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.1
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.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 │
@@ -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 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.
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
- 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,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