lithe-cli 0.4.1__tar.gz → 0.6.0__tar.gz

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