lcode-agent 0.1.0__py3-none-any.whl
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.
- lcode/__init__.py +3 -0
- lcode/__main__.py +3 -0
- lcode/app.py +4140 -0
- lcode/compress.py +80 -0
- lcode/default_config.json +10 -0
- lcode/protocol.py +800 -0
- lcode/settings.py +229 -0
- lcode/store.py +889 -0
- lcode/tools.py +1147 -0
- lcode_agent-0.1.0.dist-info/METADATA +123 -0
- lcode_agent-0.1.0.dist-info/RECORD +13 -0
- lcode_agent-0.1.0.dist-info/WHEEL +4 -0
- lcode_agent-0.1.0.dist-info/entry_points.txt +2 -0
lcode/app.py
ADDED
|
@@ -0,0 +1,4140 @@
|
|
|
1
|
+
"""一个最小的 Textual TUI agent 骨架。
|
|
2
|
+
|
|
3
|
+
运行方式:
|
|
4
|
+
uv run lcode 普通运行
|
|
5
|
+
uv run textual run 开发模式(多一个 dev console)
|
|
6
|
+
uv run python -m lcode 等价于 lcode
|
|
7
|
+
|
|
8
|
+
这个文件演示了 Textual 的五个核心概念:
|
|
9
|
+
1. App 子类 = 整个应用
|
|
10
|
+
2. compose() = 声明界面有哪些部件
|
|
11
|
+
3. on_<部件>_<事件> = 事件处理
|
|
12
|
+
4. @work 装饰器 = 后台任务(不卡 UI)
|
|
13
|
+
5. 内联 CSS = 布局和样式
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import base64
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from rich.console import Group
|
|
25
|
+
from rich.console import RenderableType
|
|
26
|
+
from rich.markdown import Markdown
|
|
27
|
+
from rich.table import Table
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
from textual import work
|
|
30
|
+
from textual.app import App, ComposeResult
|
|
31
|
+
from textual.binding import Binding
|
|
32
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
33
|
+
from textual.events import Click
|
|
34
|
+
from textual.message import Message
|
|
35
|
+
from textual.reactive import reactive
|
|
36
|
+
from textual.screen import ModalScreen
|
|
37
|
+
from textual.timer import Timer
|
|
38
|
+
from textual.widget import Widget
|
|
39
|
+
from textual.widgets import Button, Collapsible, Footer, Input, Label, OptionList, Static
|
|
40
|
+
from textual.widgets.option_list import Option
|
|
41
|
+
from textual.worker import get_current_worker
|
|
42
|
+
|
|
43
|
+
from lcode.compress import prepare_context
|
|
44
|
+
from lcode.protocol import (
|
|
45
|
+
REASONING_EFFORT,
|
|
46
|
+
ask_stream,
|
|
47
|
+
fetch_model_limits,
|
|
48
|
+
reset_model_limits,
|
|
49
|
+
set_model,
|
|
50
|
+
set_reasoning_effort,
|
|
51
|
+
)
|
|
52
|
+
from lcode.settings import get_permission_mode, load_runtime, save_permission_mode
|
|
53
|
+
from lcode.store import Store
|
|
54
|
+
from lcode.tools import (
|
|
55
|
+
execute_tool_async,
|
|
56
|
+
looks_like_tool_args,
|
|
57
|
+
normalize_tool_name,
|
|
58
|
+
openai_tools,
|
|
59
|
+
parse_tool_arguments,
|
|
60
|
+
permit_preview,
|
|
61
|
+
pretty_stream_text,
|
|
62
|
+
preview_tool_arguments,
|
|
63
|
+
set_todo_hook,
|
|
64
|
+
set_todos,
|
|
65
|
+
set_write_hook,
|
|
66
|
+
terminate_running_tools,
|
|
67
|
+
tool_is_sensitive,
|
|
68
|
+
tool_result_summary,
|
|
69
|
+
tool_title,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
CONFIG = load_runtime()
|
|
73
|
+
|
|
74
|
+
# 请求失败自动重试总次数;每次失败在输入框上方红字显示错误和 重试 n/10
|
|
75
|
+
_RETRY_TOTAL = 10
|
|
76
|
+
|
|
77
|
+
# 每百万 token 的单价(美元),config.json 里可配:
|
|
78
|
+
# "pricing": {"input": 0.3, "output": 1.2, "cacheRead": 0.03}
|
|
79
|
+
_PRICING = CONFIG.get("pricing") if isinstance(CONFIG.get("pricing"), dict) else {}
|
|
80
|
+
|
|
81
|
+
# @补全扫描项目文件时的上限,防止巨大仓库拖慢输入
|
|
82
|
+
_AT_SCAN_CAP = 8000
|
|
83
|
+
_AT_MATCH_CAP = 8
|
|
84
|
+
_IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".gif")
|
|
85
|
+
_MIME = {
|
|
86
|
+
".png": "image/png",
|
|
87
|
+
".jpg": "image/jpeg",
|
|
88
|
+
".jpeg": "image/jpeg",
|
|
89
|
+
".webp": "image/webp",
|
|
90
|
+
".gif": "image/gif",
|
|
91
|
+
}
|
|
92
|
+
# 图片 data 落库的 base64 总量上限;超了只存文件名
|
|
93
|
+
_IMG_PERSIST_CAP = 3 * 1024 * 1024
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _est_tokens(text: str) -> int:
|
|
97
|
+
"""本地粗估 token:汉字约 1 个,英文约 4 字符 1 个。接口正式用量到了会再校准。"""
|
|
98
|
+
if not text:
|
|
99
|
+
return 0
|
|
100
|
+
cjk = 0
|
|
101
|
+
other = 0
|
|
102
|
+
for ch in text:
|
|
103
|
+
if ord(ch) >= 0x2E80:
|
|
104
|
+
cjk += 1
|
|
105
|
+
else:
|
|
106
|
+
other += 1
|
|
107
|
+
return cjk + (other + 3) // 4
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fmt_tok(n: int) -> str:
|
|
111
|
+
"""上千用 k,上百万用 m。"""
|
|
112
|
+
n = max(0, int(n))
|
|
113
|
+
if n >= 1_000_000:
|
|
114
|
+
text = f"{n / 1_000_000:.1f}m"
|
|
115
|
+
return text.replace(".0m", "m")
|
|
116
|
+
if n >= 1000:
|
|
117
|
+
text = f"{n / 1000:.1f}k"
|
|
118
|
+
return text.replace(".0k", "k")
|
|
119
|
+
return str(n)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _value_tokens(value: object) -> int:
|
|
123
|
+
if value is None:
|
|
124
|
+
return 0
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
return _est_tokens(value)
|
|
127
|
+
if isinstance(value, (dict, list)):
|
|
128
|
+
try:
|
|
129
|
+
return _est_tokens(json.dumps(value, ensure_ascii=False))
|
|
130
|
+
except (TypeError, ValueError):
|
|
131
|
+
return _est_tokens(str(value))
|
|
132
|
+
return _est_tokens(str(value))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _messages_tokens(msgs: list) -> int:
|
|
136
|
+
total = 0
|
|
137
|
+
for msg in msgs:
|
|
138
|
+
if not isinstance(msg, dict):
|
|
139
|
+
continue
|
|
140
|
+
total += _value_tokens(msg.get("content"))
|
|
141
|
+
total += _value_tokens(msg.get("tool_calls"))
|
|
142
|
+
return total
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cache_pct(cache: int, inp: int) -> int:
|
|
146
|
+
"""缓存命中百分比:没命中或没有输入就返回 0,调用方按 0 隐藏。"""
|
|
147
|
+
if cache <= 0 or inp <= 0:
|
|
148
|
+
return 0
|
|
149
|
+
return min(100, round(cache * 100 / inp))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _msg_meta(raw: str) -> dict:
|
|
153
|
+
if not raw:
|
|
154
|
+
return {}
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(raw)
|
|
157
|
+
except (TypeError, json.JSONDecodeError):
|
|
158
|
+
return {}
|
|
159
|
+
return data if isinstance(data, dict) else {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# 只读工具的大结果送进上下文时的长度上限(字符);截断只影响发给模型的,
|
|
163
|
+
# 磁盘里的全文和卡片上展示的都还是完整的。
|
|
164
|
+
_TOOL_CONTEXT_CAP = 16_000
|
|
165
|
+
_TOOL_CONTEXT_HEAD = 12_000
|
|
166
|
+
_TOOL_CONTEXT_TAIL = 3_600
|
|
167
|
+
|
|
168
|
+
_CLIP_TOOLS = (
|
|
169
|
+
"read_file",
|
|
170
|
+
"grep",
|
|
171
|
+
"list_dir",
|
|
172
|
+
"run_terminal_command",
|
|
173
|
+
"web_search",
|
|
174
|
+
"web_fetch",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _clip_tool_context(name: str, result: str) -> str:
|
|
179
|
+
"""大文件 read_file / grep / 终端输出掐头去尾,别把窗口迅速打满。"""
|
|
180
|
+
if normalize_tool_name(name) not in _CLIP_TOOLS:
|
|
181
|
+
return result
|
|
182
|
+
if len(result) <= _TOOL_CONTEXT_CAP:
|
|
183
|
+
return result
|
|
184
|
+
omitted = len(result) - _TOOL_CONTEXT_HEAD - _TOOL_CONTEXT_TAIL
|
|
185
|
+
return (
|
|
186
|
+
result[:_TOOL_CONTEXT_HEAD]
|
|
187
|
+
+ f"\n…(中间省略 {omitted} 字,完整结果太长;需要更多请用 offset/limit 再读)…\n"
|
|
188
|
+
+ result[-_TOOL_CONTEXT_TAIL:]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_SKIP_DIR_NAMES = {".git", "__pycache__", ".venv", "node_modules", ".lcode", ".idea"}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _project_files() -> list[str]:
|
|
196
|
+
"""项目里的相对路径文件列表,@补全用;目录太深就截断。"""
|
|
197
|
+
out: list[str] = []
|
|
198
|
+
root = Path.cwd()
|
|
199
|
+
stack = [root]
|
|
200
|
+
while stack and len(out) < _AT_SCAN_CAP:
|
|
201
|
+
current = stack.pop()
|
|
202
|
+
try:
|
|
203
|
+
entries = sorted(current.iterdir(), key=lambda p: p.name)
|
|
204
|
+
except OSError:
|
|
205
|
+
continue
|
|
206
|
+
for entry in entries:
|
|
207
|
+
if entry.name.startswith(".") or entry.name in _SKIP_DIR_NAMES:
|
|
208
|
+
continue
|
|
209
|
+
if entry.is_dir():
|
|
210
|
+
stack.append(entry)
|
|
211
|
+
else:
|
|
212
|
+
try:
|
|
213
|
+
out.append(str(entry.relative_to(root)).replace("\\", "/"))
|
|
214
|
+
except ValueError:
|
|
215
|
+
continue
|
|
216
|
+
if len(out) >= _AT_SCAN_CAP:
|
|
217
|
+
break
|
|
218
|
+
return out
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _at_matches(typed: str) -> tuple[str, list[str]]:
|
|
222
|
+
"""从输入末尾抠出 @token,返回 (前缀, 匹配的文件路径)。不是 @补全就返回 ("", [])。"""
|
|
223
|
+
m = re.search(r"@([^\s@]*)$", typed or "")
|
|
224
|
+
if not m:
|
|
225
|
+
return "", []
|
|
226
|
+
prefix = m.group(1).lower()
|
|
227
|
+
if len(prefix) < 1:
|
|
228
|
+
return "", []
|
|
229
|
+
files = getattr(_at_matches, "_cache", None)
|
|
230
|
+
now = time.monotonic()
|
|
231
|
+
if files is None or now - files[0] > 20:
|
|
232
|
+
files = (now, _project_files())
|
|
233
|
+
_at_matches._cache = files
|
|
234
|
+
hits = [f for f in files[1] if prefix in f.lower()][:_AT_MATCH_CAP]
|
|
235
|
+
return prefix, hits
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_TOOLS_TOKENS_CACHE = -1
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _tools_tokens() -> int:
|
|
242
|
+
global _TOOLS_TOKENS_CACHE
|
|
243
|
+
if _TOOLS_TOKENS_CACHE < 0:
|
|
244
|
+
_TOOLS_TOKENS_CACHE = _est_tokens(json.dumps(openai_tools(), ensure_ascii=False))
|
|
245
|
+
return _TOOLS_TOKENS_CACHE
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_RULE_FILES = ("AGENTS.md", "LCODE.md")
|
|
249
|
+
_RULES_CHAR_CAP = 16_000
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _project_rules() -> str:
|
|
253
|
+
chunks: list[str] = []
|
|
254
|
+
used = 0
|
|
255
|
+
root = Path.cwd()
|
|
256
|
+
for name in _RULE_FILES:
|
|
257
|
+
path = root / name
|
|
258
|
+
if not path.is_file():
|
|
259
|
+
continue
|
|
260
|
+
try:
|
|
261
|
+
text = path.read_text(encoding="utf-8", errors="replace").strip()
|
|
262
|
+
except OSError:
|
|
263
|
+
continue
|
|
264
|
+
if not text:
|
|
265
|
+
continue
|
|
266
|
+
remain = _RULES_CHAR_CAP - used
|
|
267
|
+
if remain <= 80:
|
|
268
|
+
break
|
|
269
|
+
if len(text) > remain:
|
|
270
|
+
text = text[:remain].rstrip() + "\n…"
|
|
271
|
+
chunks.append(f"Project rules from {name}:\n{text}")
|
|
272
|
+
used += len(text)
|
|
273
|
+
return "\n\n".join(chunks)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _system_prompt() -> str:
|
|
277
|
+
cwd = str(Path.cwd())
|
|
278
|
+
body = (
|
|
279
|
+
"You are Lcode, a coding agent in the user's project. "
|
|
280
|
+
f"Workspace: {cwd}. Use tools to read and edit files; "
|
|
281
|
+
"do not guess file contents. Prefer search_replace for edits. "
|
|
282
|
+
"Use write to create new files or overwrite a whole file. "
|
|
283
|
+
"Use grep and list_dir to explore. Run commands with "
|
|
284
|
+
"run_terminal_command."
|
|
285
|
+
)
|
|
286
|
+
rules = _project_rules()
|
|
287
|
+
if rules:
|
|
288
|
+
body += "\n\n" + rules
|
|
289
|
+
return body
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _rel_time(ts: int) -> str:
|
|
293
|
+
delta = max(0, int(time.time()) - int(ts or 0))
|
|
294
|
+
if delta < 60:
|
|
295
|
+
return "刚刚"
|
|
296
|
+
if delta < 3600:
|
|
297
|
+
return f"{delta // 60}分钟前"
|
|
298
|
+
if delta < 86400:
|
|
299
|
+
return f"{delta // 3600}小时前"
|
|
300
|
+
if delta < 86400 * 7:
|
|
301
|
+
return f"{delta // 86400}天前"
|
|
302
|
+
return time.strftime("%m-%d", time.localtime(ts))
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _session_prompt(row: dict, current_id: str) -> Text:
|
|
306
|
+
line = Text()
|
|
307
|
+
current = row.get("id") == current_id
|
|
308
|
+
if current:
|
|
309
|
+
line.append("当前 ", style="bold yellow")
|
|
310
|
+
else:
|
|
311
|
+
line.append("窗口 ", style="dim")
|
|
312
|
+
title = " ".join(str(row.get("title") or "").split()) or "无标题"
|
|
313
|
+
if len(title) > 36:
|
|
314
|
+
title = title[:35] + "…"
|
|
315
|
+
line.append(title, style="bold" if current else "")
|
|
316
|
+
n = int(row.get("n") or 0)
|
|
317
|
+
if n:
|
|
318
|
+
line.append(f" {n}条", style="dim")
|
|
319
|
+
line.append(f" {str(row.get('id') or '')[:8]} {_rel_time(int(row.get('updated_at') or 0))}", style="dim")
|
|
320
|
+
return line
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---------- 欢迎横幅 ----------
|
|
324
|
+
|
|
325
|
+
# "LCODE" 细体:每个字母 5 列宽、5 行高,用 █▀▄ 勾边,不套框
|
|
326
|
+
_LETTERS: dict[str, list[str]] = {
|
|
327
|
+
"L": ["█ ", "█ ", "█ ", "█ ", "█▄▄▄▄"],
|
|
328
|
+
"C": ["█▀▀▀▄", "█ ", "█ ", "█ ", "█▄▄▄▀"],
|
|
329
|
+
"O": ["▄▀▀▀▄", "█ █", "█ █", "█ █", "▀▄▄▄▀"],
|
|
330
|
+
"D": ["█▀▀▀▄", "█ █", "█ █", "█ █", "█▄▄▄▀"],
|
|
331
|
+
"E": ["█▀▀▀▀", "█ ", "█▀▀▀ ", "█ ", "█▄▄▄▄"],
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
# 字母配色:L 黄色,CODE 灰色
|
|
335
|
+
# UI 暖色板:琥珀主色 + 暖灰辅助;绿红只做状态色,不再混青蓝
|
|
336
|
+
_AMBER = "#d9a84e"
|
|
337
|
+
_AMBER_DIM = "#a8843c"
|
|
338
|
+
_WARM = "#8a8578"
|
|
339
|
+
_WARM_HI = "#b5ae9d"
|
|
340
|
+
_CREAM = "#f0e2c0"
|
|
341
|
+
_OK = "#8fbf9f"
|
|
342
|
+
_ERR = "#e08a8a"
|
|
343
|
+
_YELLOW = _AMBER
|
|
344
|
+
_GREY = _WARM
|
|
345
|
+
|
|
346
|
+
# 灯笼:照参考图重画——圆顶红盖、椭圆鼓身、弧形红肋、底下一排流苏 + 黄色长尾。
|
|
347
|
+
_RED = "#e33333"
|
|
348
|
+
_GOLD = "#f5d90a"
|
|
349
|
+
_LANTERN: list[str] = [
|
|
350
|
+
" ║",
|
|
351
|
+
" ║",
|
|
352
|
+
" ▄█▄",
|
|
353
|
+
" ▄█████▄",
|
|
354
|
+
" ▼▼▼▼▼▼▼▼▼▼▼",
|
|
355
|
+
" ███│█│███│█│███",
|
|
356
|
+
" ████│█│█████│█│████",
|
|
357
|
+
" ████│██│███████│██│████",
|
|
358
|
+
" ████│███│███████│███│████",
|
|
359
|
+
" ████│███│███████│███│████",
|
|
360
|
+
" ███│███│█████████│███│███",
|
|
361
|
+
" ████│███│███████│███│████",
|
|
362
|
+
" ████│███│███████│███│████",
|
|
363
|
+
" ████│██│███████│██│████",
|
|
364
|
+
" ████│██│███████│██│████",
|
|
365
|
+
" ████│█│█████│█│████",
|
|
366
|
+
" ███│█│███│█│███",
|
|
367
|
+
" ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼",
|
|
368
|
+
" █████████",
|
|
369
|
+
" │││││││││││││",
|
|
370
|
+
" │││││││││││││",
|
|
371
|
+
" │││││││││",
|
|
372
|
+
" ▄█▄",
|
|
373
|
+
" ║",
|
|
374
|
+
" ║",
|
|
375
|
+
" ║",
|
|
376
|
+
" ▀█▀",
|
|
377
|
+
]
|
|
378
|
+
_GOLD_DIM = "#8a6a08"
|
|
379
|
+
_GOLD_LIT = "#fff3a0"
|
|
380
|
+
_RED_DIM = "#8a1c1c"
|
|
381
|
+
_RED_LIT = "#ff6a6a"
|
|
382
|
+
_PAPER_DIM = "#c48a12"
|
|
383
|
+
_PAPER_LIT = "#ffe14a"
|
|
384
|
+
_RIB_DIM = "#8a5a08"
|
|
385
|
+
_RIB_LIT = "#d4a01a"
|
|
386
|
+
|
|
387
|
+
# 灯笼结构行号:颜色和摆动逻辑按段处理
|
|
388
|
+
_LANTERN_W = 27
|
|
389
|
+
# 渲染时每行统一补到这个宽度:尾巴左右摆不改变列宽,右边的文字栏才不会跟着晃
|
|
390
|
+
_L_PAD_W = 31
|
|
391
|
+
_L_HANGER = (0, 1)
|
|
392
|
+
_L_CAP = (2, 3)
|
|
393
|
+
_L_TRIM = (4, 17)
|
|
394
|
+
_L_BODY = range(5, 17)
|
|
395
|
+
_L_BAND = 18
|
|
396
|
+
_L_FRINGE = (19, 20, 21)
|
|
397
|
+
_L_KNOT = 22
|
|
398
|
+
_L_TAIL = (23, 24, 25, 26)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _lcode_word() -> Text:
|
|
402
|
+
"""细体 LCODE:L 黄色,CODE 灰色,字母间空 2 格。"""
|
|
403
|
+
lines: list[Text | str] = []
|
|
404
|
+
for y in range(5):
|
|
405
|
+
if y:
|
|
406
|
+
lines.append("\n")
|
|
407
|
+
row = Text()
|
|
408
|
+
for i, letter in enumerate("LCODE"):
|
|
409
|
+
if i:
|
|
410
|
+
row.append(" ")
|
|
411
|
+
row.append(_LETTERS[letter][y], style=_YELLOW if letter == "L" else _GREY)
|
|
412
|
+
lines.append(row)
|
|
413
|
+
return Text.assemble(*lines)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _lerp_hex(dark: str, lit: str, k: float) -> str:
|
|
417
|
+
k = max(0.0, min(1.0, k))
|
|
418
|
+
dr, dg, db = int(dark[1:3], 16), int(dark[3:5], 16), int(dark[5:7], 16)
|
|
419
|
+
lr, lg, lb = int(lit[1:3], 16), int(lit[3:5], 16), int(lit[5:7], 16)
|
|
420
|
+
r = int(dr + (lr - dr) * k)
|
|
421
|
+
g = int(dg + (lg - dg) * k)
|
|
422
|
+
b = int(db + (lb - db) * k)
|
|
423
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _wind_lit(t: float, row: int, col: int) -> float:
|
|
427
|
+
"""风从斜上方扫过,亮度 0~1,结身不动,只是光在绳上走。"""
|
|
428
|
+
wave = math.sin(t * 1.7 - row * 0.45 - col * 0.22)
|
|
429
|
+
slow = math.sin(t * 0.55 + row * 0.12)
|
|
430
|
+
k = 0.5 + 0.5 * (0.72 * wave + 0.28 * slow)
|
|
431
|
+
return max(0.0, min(1.0, k))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _lantern(t: float = 0.0, gust: float = 0.0) -> Text:
|
|
435
|
+
"""照参考图的灯笼:红盖压边、黄纸鼓身、弧形红肋、流苏长尾。
|
|
436
|
+
|
|
437
|
+
动画:风扫纸面亮暗(不变);新增尾巴摆动,点击后的 gust 会加大摆幅。
|
|
438
|
+
"""
|
|
439
|
+
lamp = Text(no_wrap=True, overflow="crop")
|
|
440
|
+
sway_speed = 1.4 + 2.5 * gust
|
|
441
|
+
for i, line in enumerate(_LANTERN):
|
|
442
|
+
if i:
|
|
443
|
+
lamp.append("\n")
|
|
444
|
+
chars = list(line.ljust(_L_PAD_W))
|
|
445
|
+
if i in _L_TAIL:
|
|
446
|
+
# 尾巴摆:越往下摆得越多,像挂坠;行补齐固定宽度,摆动不改列宽
|
|
447
|
+
reach = 0.55 * (i - 22) + 2.2 * gust
|
|
448
|
+
dx = round(math.sin(t * sway_speed) * reach)
|
|
449
|
+
dx = max(-3, min(3, dx))
|
|
450
|
+
if dx:
|
|
451
|
+
shifted = [" "] * _L_PAD_W
|
|
452
|
+
for j, ch in enumerate(chars):
|
|
453
|
+
nj = j + dx
|
|
454
|
+
if 0 <= nj < _L_PAD_W:
|
|
455
|
+
shifted[nj] = ch
|
|
456
|
+
chars = shifted
|
|
457
|
+
for j, ch in enumerate(chars):
|
|
458
|
+
if ch == " ":
|
|
459
|
+
lamp.append(" ")
|
|
460
|
+
continue
|
|
461
|
+
wind = _wind_lit(t, i, j)
|
|
462
|
+
k = max(wind, 0.5 + 0.5 * math.sin(t * 2.4))
|
|
463
|
+
if i in _L_TAIL or i == _L_KNOT:
|
|
464
|
+
lamp.append(ch, style=_lerp_hex(_GOLD_DIM, _GOLD_LIT, k))
|
|
465
|
+
elif i in _L_HANGER or i in _L_CAP or i in _L_TRIM or i == _L_BAND:
|
|
466
|
+
lamp.append(ch, style=_lerp_hex(_RED_DIM, _RED_LIT, k))
|
|
467
|
+
elif i in _L_FRINGE:
|
|
468
|
+
# 流苏:整排红线,亮度做一点行间相位差,像被风掀
|
|
469
|
+
k2 = max(wind, 0.5 + 0.5 * math.sin(t * 2.4 - i * 0.7))
|
|
470
|
+
lamp.append(ch, style=_lerp_hex(_RED_DIM, _RED_LIT, k2))
|
|
471
|
+
elif ch == "│":
|
|
472
|
+
lamp.append(ch, style=_lerp_hex(_RIB_DIM, _RIB_LIT, k))
|
|
473
|
+
else:
|
|
474
|
+
lamp.append(ch, style=_lerp_hex(_PAPER_DIM, _PAPER_LIT, k))
|
|
475
|
+
return lamp
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _cwd_display() -> str:
|
|
479
|
+
"""当前文件夹路径;家目录前缀缩写成 ~。"""
|
|
480
|
+
cwd = str(Path.cwd())
|
|
481
|
+
home = str(Path.home())
|
|
482
|
+
if cwd.startswith(home):
|
|
483
|
+
cwd = "~" + cwd[len(home):]
|
|
484
|
+
return cwd
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _lcode_mark(version: str, phrase: str = "") -> Table:
|
|
488
|
+
"""头部文字栏:品牌行、LCODE 字标、分隔线、路径、模型、操作提示。
|
|
489
|
+
|
|
490
|
+
和灯笼并排放,整体垂直居中(见 _welcome_banner)。
|
|
491
|
+
phrase 是点击灯笼冒出来的彩蛋,追加在最后一行。
|
|
492
|
+
"""
|
|
493
|
+
brand = Text()
|
|
494
|
+
brand.append("▎", style=f"bold {_AMBER}")
|
|
495
|
+
brand.append("Lcode agent", style="bold")
|
|
496
|
+
brand.append(f" v{version}", style="dim")
|
|
497
|
+
|
|
498
|
+
cwd_line = Text()
|
|
499
|
+
cwd_line.append("◆ ", style="dim")
|
|
500
|
+
cwd_line.append(_cwd_display(), style="dim")
|
|
501
|
+
|
|
502
|
+
model_line = Text()
|
|
503
|
+
model = str(CONFIG.get("model") or "")
|
|
504
|
+
model_line.append("◆ ", style="dim")
|
|
505
|
+
model_line.append("当前模型 ", style="dim")
|
|
506
|
+
if model:
|
|
507
|
+
model_line.append(model, style=f"bold {_AMBER}")
|
|
508
|
+
else:
|
|
509
|
+
model_line.append("(未配置)", style="dim")
|
|
510
|
+
|
|
511
|
+
help_line = Text()
|
|
512
|
+
help_line.append("◆ ", style="dim")
|
|
513
|
+
help_line.append("输入 / 看命令 · Ctrl+Q 退出", style="dim")
|
|
514
|
+
|
|
515
|
+
egg_line = Text()
|
|
516
|
+
egg_line.append("◆ ", style=_RED)
|
|
517
|
+
egg_line.append("点一点灯笼,讨个好彩头", style=f"{_AMBER} italic")
|
|
518
|
+
|
|
519
|
+
block = Table.grid(expand=False)
|
|
520
|
+
block.add_row(brand)
|
|
521
|
+
block.add_row(_lcode_word())
|
|
522
|
+
block.add_row(Text("─" * 21, style="dim"))
|
|
523
|
+
block.add_row(cwd_line)
|
|
524
|
+
block.add_row(model_line)
|
|
525
|
+
block.add_row(help_line)
|
|
526
|
+
block.add_row(egg_line)
|
|
527
|
+
if phrase:
|
|
528
|
+
block.add_row(Text(f"❝ {phrase} ❞", style=f"bold {_AMBER}"))
|
|
529
|
+
return block
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _renderable_rows(renderable: object, width: int = 64) -> int:
|
|
533
|
+
"""渲染一遍数行数,给头部排版做垂直居中用。"""
|
|
534
|
+
import io
|
|
535
|
+
|
|
536
|
+
from rich.console import Console
|
|
537
|
+
|
|
538
|
+
buf = io.StringIO()
|
|
539
|
+
Console(
|
|
540
|
+
width=width, file=buf, legacy_windows=False, color_system=None
|
|
541
|
+
).print(renderable)
|
|
542
|
+
return max(1, len(buf.getvalue().rstrip("\n").splitlines()))
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _welcome_banner(version: str, t: float = 0.0, phrase: str = "") -> RenderableType:
|
|
546
|
+
"""欢迎横幅:左边灯笼,右边文字栏,文字栏对着灯笼身子垂直居中。"""
|
|
547
|
+
lamp = _lantern(t)
|
|
548
|
+
mark = _lcode_mark(version, phrase)
|
|
549
|
+
lamp_rows = str(lamp.plain).count("\n") + 1
|
|
550
|
+
mark_rows = _renderable_rows(mark)
|
|
551
|
+
pad = max(0, (lamp_rows - mark_rows) // 2)
|
|
552
|
+
stack = Table.grid(expand=False)
|
|
553
|
+
stack.add_column()
|
|
554
|
+
for _ in range(pad):
|
|
555
|
+
stack.add_row(Text(""))
|
|
556
|
+
stack.add_row(mark)
|
|
557
|
+
art = Table.grid(padding=(0, 4), expand=False, pad_edge=False)
|
|
558
|
+
art.add_column(vertical="top", no_wrap=True)
|
|
559
|
+
art.add_column(vertical="top")
|
|
560
|
+
art.add_row(lamp, stack)
|
|
561
|
+
return art
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class Hero(Static):
|
|
565
|
+
"""欢迎横幅:躺在对话流最上面,往下滚就滑走。点灯笼有彩蛋。"""
|
|
566
|
+
|
|
567
|
+
_EGG_PHRASES = ("恭喜发财!", "大吉大利!", "码到成功!", "诸事顺遂!", "bug 退散!")
|
|
568
|
+
|
|
569
|
+
def on_mount(self) -> None:
|
|
570
|
+
self._t0 = time.monotonic()
|
|
571
|
+
self._gust = 0.0 # 点击后的风,越点越大摆幅
|
|
572
|
+
self._phrase = ""
|
|
573
|
+
self._phrase_until = 0.0
|
|
574
|
+
version = getattr(self.app, "VERSION", "0.0.0")
|
|
575
|
+
self.update(_welcome_banner(version, 0.0))
|
|
576
|
+
self.set_interval(0.08, self._tick)
|
|
577
|
+
|
|
578
|
+
def on_click(self, event: Click) -> None:
|
|
579
|
+
# 点灯笼:起风 + 冒一句彩蛋;点别处只起风
|
|
580
|
+
import random
|
|
581
|
+
|
|
582
|
+
self._gust = min(2.0, self._gust + 1.0)
|
|
583
|
+
self._phrase = random.choice(self._EGG_PHRASES)
|
|
584
|
+
self._phrase_until = time.monotonic() + 2.4
|
|
585
|
+
|
|
586
|
+
def _tick(self) -> None:
|
|
587
|
+
# 滚出视口就别再重绘,省刷新
|
|
588
|
+
parent = self.parent
|
|
589
|
+
if parent is not None and getattr(parent, "scroll_offset", None) is not None:
|
|
590
|
+
if parent.scroll_offset.y >= max(1, self.outer_size.height):
|
|
591
|
+
return
|
|
592
|
+
version = getattr(self.app, "VERSION", "0.0.0")
|
|
593
|
+
self._gust = max(0.0, self._gust * 0.965 - 0.004)
|
|
594
|
+
now = time.monotonic()
|
|
595
|
+
phrase = self._phrase if now < self._phrase_until else ""
|
|
596
|
+
self.update(
|
|
597
|
+
_welcome_banner(version, now - self._t0, phrase)
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
class ChatLog(VerticalScroll):
|
|
602
|
+
"""对话区:用户句、思考折叠、正文,都能点选展开。"""
|
|
603
|
+
|
|
604
|
+
can_focus = True
|
|
605
|
+
|
|
606
|
+
def compose(self) -> ComposeResult:
|
|
607
|
+
yield Hero(id="hero")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class UserTurn(Static):
|
|
611
|
+
"""用户说的那一句。"""
|
|
612
|
+
|
|
613
|
+
DEFAULT_CSS = """
|
|
614
|
+
UserTurn {
|
|
615
|
+
width: 1fr;
|
|
616
|
+
height: auto;
|
|
617
|
+
background: ansi_default;
|
|
618
|
+
text-wrap: wrap;
|
|
619
|
+
margin-bottom: 0;
|
|
620
|
+
}
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
def __init__(self, prompt: str, **kwargs) -> None:
|
|
624
|
+
body = Text()
|
|
625
|
+
body.append("▎ ", style="bold #a8843c")
|
|
626
|
+
body.append(prompt)
|
|
627
|
+
super().__init__(body, **kwargs)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class AgentTurn(Widget):
|
|
631
|
+
"""一轮回复:等待动画、默认折叠的思考、正文。"""
|
|
632
|
+
|
|
633
|
+
DEFAULT_CSS = """
|
|
634
|
+
AgentTurn {
|
|
635
|
+
width: 1fr;
|
|
636
|
+
height: auto;
|
|
637
|
+
layout: vertical;
|
|
638
|
+
background: ansi_default;
|
|
639
|
+
margin: 0 0 1 0;
|
|
640
|
+
}
|
|
641
|
+
AgentTurn > #wait {
|
|
642
|
+
width: 1fr;
|
|
643
|
+
height: auto;
|
|
644
|
+
background: ansi_default;
|
|
645
|
+
text-wrap: wrap;
|
|
646
|
+
}
|
|
647
|
+
AgentTurn > #thinking {
|
|
648
|
+
width: 1fr;
|
|
649
|
+
height: auto;
|
|
650
|
+
background: ansi_default;
|
|
651
|
+
border: none;
|
|
652
|
+
padding: 0;
|
|
653
|
+
margin: 0 0 1 0;
|
|
654
|
+
}
|
|
655
|
+
AgentTurn > #thinking.-collapsed > Contents {
|
|
656
|
+
display: none;
|
|
657
|
+
}
|
|
658
|
+
AgentTurn Collapsible Contents {
|
|
659
|
+
width: 1fr;
|
|
660
|
+
height: auto;
|
|
661
|
+
padding: 0;
|
|
662
|
+
background: ansi_default;
|
|
663
|
+
}
|
|
664
|
+
AgentTurn CollapsibleTitle {
|
|
665
|
+
width: auto;
|
|
666
|
+
height: 1;
|
|
667
|
+
background: ansi_default;
|
|
668
|
+
padding: 0 1 0 0;
|
|
669
|
+
color: #a8843c;
|
|
670
|
+
text-style: bold;
|
|
671
|
+
}
|
|
672
|
+
AgentTurn CollapsibleTitle:hover {
|
|
673
|
+
background: ansi_default;
|
|
674
|
+
color: #d9a84e;
|
|
675
|
+
}
|
|
676
|
+
AgentTurn CollapsibleTitle:focus {
|
|
677
|
+
background: ansi_default;
|
|
678
|
+
color: #d9a84e;
|
|
679
|
+
text-style: bold;
|
|
680
|
+
}
|
|
681
|
+
AgentTurn #thinking-body {
|
|
682
|
+
width: 1fr;
|
|
683
|
+
height: auto;
|
|
684
|
+
background: ansi_default;
|
|
685
|
+
text-wrap: wrap;
|
|
686
|
+
color: grey;
|
|
687
|
+
text-style: italic;
|
|
688
|
+
padding: 0 0 0 2;
|
|
689
|
+
margin: 0 0 1 0;
|
|
690
|
+
}
|
|
691
|
+
AgentTurn > #answer {
|
|
692
|
+
width: 1fr;
|
|
693
|
+
height: auto;
|
|
694
|
+
background: ansi_default;
|
|
695
|
+
text-wrap: wrap;
|
|
696
|
+
margin: 0;
|
|
697
|
+
padding: 0 0 0 1;
|
|
698
|
+
border-left: tall #57534a;
|
|
699
|
+
}
|
|
700
|
+
AgentTurn #tool-log {
|
|
701
|
+
width: 1fr;
|
|
702
|
+
height: auto;
|
|
703
|
+
background: ansi_default;
|
|
704
|
+
margin: 0 0 1 0;
|
|
705
|
+
}
|
|
706
|
+
AgentTurn #tool-log Collapsible {
|
|
707
|
+
width: 1fr;
|
|
708
|
+
height: auto;
|
|
709
|
+
background: ansi_default;
|
|
710
|
+
border: none;
|
|
711
|
+
padding: 0;
|
|
712
|
+
margin: 0 0 1 0;
|
|
713
|
+
}
|
|
714
|
+
AgentTurn #tool-log Collapsible:ansi {
|
|
715
|
+
background: ansi_default;
|
|
716
|
+
border: none;
|
|
717
|
+
}
|
|
718
|
+
AgentTurn #tool-log Collapsible.-collapsed > Contents {
|
|
719
|
+
display: none;
|
|
720
|
+
}
|
|
721
|
+
AgentTurn #tool-log CollapsibleTitle {
|
|
722
|
+
width: auto;
|
|
723
|
+
height: 1;
|
|
724
|
+
background: ansi_default;
|
|
725
|
+
padding: 0 1 0 0;
|
|
726
|
+
color: #d9a84e;
|
|
727
|
+
text-style: bold;
|
|
728
|
+
}
|
|
729
|
+
AgentTurn #tool-log Collapsible.tool-quiet CollapsibleTitle {
|
|
730
|
+
color: #8a8578;
|
|
731
|
+
}
|
|
732
|
+
AgentTurn #tool-log Collapsible.tool-quiet CollapsibleTitle:hover,
|
|
733
|
+
AgentTurn #tool-log Collapsible.tool-quiet CollapsibleTitle:focus {
|
|
734
|
+
color: #c4bfb2;
|
|
735
|
+
}
|
|
736
|
+
AgentTurn #tool-log Collapsible.tool-ok CollapsibleTitle {
|
|
737
|
+
color: #8fbf9f;
|
|
738
|
+
}
|
|
739
|
+
AgentTurn #tool-log Collapsible.tool-err CollapsibleTitle {
|
|
740
|
+
color: #e08a8a;
|
|
741
|
+
}
|
|
742
|
+
AgentTurn #tool-log CollapsibleTitle:hover,
|
|
743
|
+
AgentTurn #tool-log CollapsibleTitle:focus {
|
|
744
|
+
background: ansi_default;
|
|
745
|
+
color: #f0e2c0;
|
|
746
|
+
text-style: bold;
|
|
747
|
+
}
|
|
748
|
+
AgentTurn #tool-log .tool-body {
|
|
749
|
+
width: 1fr;
|
|
750
|
+
height: auto;
|
|
751
|
+
background: ansi_default;
|
|
752
|
+
text-wrap: wrap;
|
|
753
|
+
padding: 0 0 0 2;
|
|
754
|
+
margin: 0 0 1 0;
|
|
755
|
+
}
|
|
756
|
+
"""
|
|
757
|
+
|
|
758
|
+
def compose(self) -> ComposeResult:
|
|
759
|
+
yield Static(id="wait")
|
|
760
|
+
yield Collapsible(
|
|
761
|
+
Static(id="thinking-body"),
|
|
762
|
+
title="思考",
|
|
763
|
+
collapsed=True,
|
|
764
|
+
collapsed_symbol="▶",
|
|
765
|
+
expanded_symbol="▼",
|
|
766
|
+
id="thinking",
|
|
767
|
+
)
|
|
768
|
+
yield Vertical(id="tool-log")
|
|
769
|
+
yield Static(id="answer")
|
|
770
|
+
|
|
771
|
+
def on_mount(self) -> None:
|
|
772
|
+
self._thinking_cache = ""
|
|
773
|
+
self._answer_cache = ""
|
|
774
|
+
self._title_in = 0
|
|
775
|
+
self._title_out = 0
|
|
776
|
+
self._title_cache = 0
|
|
777
|
+
self._tool_list = []
|
|
778
|
+
self.query_one("#thinking").display = False
|
|
779
|
+
self.query_one("#answer").display = False
|
|
780
|
+
|
|
781
|
+
def _set_thinking_title(
|
|
782
|
+
self,
|
|
783
|
+
elapsed: float,
|
|
784
|
+
out_tokens: int | None = None,
|
|
785
|
+
in_tokens: int | None = None,
|
|
786
|
+
cache_tokens: int | None = None,
|
|
787
|
+
) -> None:
|
|
788
|
+
box = self.query_one("#thinking", Collapsible)
|
|
789
|
+
if not box.display:
|
|
790
|
+
return
|
|
791
|
+
if out_tokens is not None:
|
|
792
|
+
self._title_out = max(0, int(out_tokens))
|
|
793
|
+
if in_tokens is not None:
|
|
794
|
+
self._title_in = max(0, int(in_tokens))
|
|
795
|
+
if cache_tokens is not None:
|
|
796
|
+
self._title_cache = max(0, int(cache_tokens))
|
|
797
|
+
# ↑ 本轮请求(含工具结果),cache 命中百分比,↓ 思考输出;跟底部栏同一套
|
|
798
|
+
bits = [f"◆ 思考 {elapsed:.1f}s"]
|
|
799
|
+
if self._title_in:
|
|
800
|
+
bits.append(f"↑ {fmt_tok(self._title_in)}")
|
|
801
|
+
pct = _cache_pct(self._title_cache, self._title_in)
|
|
802
|
+
if pct:
|
|
803
|
+
bits.append(f"cache {pct}%")
|
|
804
|
+
if self._title_out:
|
|
805
|
+
bits.append(f"↓ {fmt_tok(self._title_out)}")
|
|
806
|
+
box.title = " ".join(bits)
|
|
807
|
+
|
|
808
|
+
def _flush_thinking(self) -> None:
|
|
809
|
+
self.query_one("#thinking-body", Static).update(
|
|
810
|
+
Text(self._thinking_cache, style="dim italic") if self._thinking_cache else Text()
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None:
|
|
814
|
+
event.stop()
|
|
815
|
+
self._flush_thinking()
|
|
816
|
+
|
|
817
|
+
def show_wait(self, text: Text) -> None:
|
|
818
|
+
wait = self.query_one("#wait", Static)
|
|
819
|
+
wait.display = True
|
|
820
|
+
wait.update(text)
|
|
821
|
+
self.query_one("#thinking").display = False
|
|
822
|
+
self.query_one("#answer").display = False
|
|
823
|
+
|
|
824
|
+
def show_stream(
|
|
825
|
+
self,
|
|
826
|
+
thinking: str,
|
|
827
|
+
answer: str,
|
|
828
|
+
*,
|
|
829
|
+
elapsed: float = 0.0,
|
|
830
|
+
out_tokens: int = 0,
|
|
831
|
+
in_tokens: int | None = None,
|
|
832
|
+
cache_tokens: int | None = None,
|
|
833
|
+
final: bool = False,
|
|
834
|
+
) -> None:
|
|
835
|
+
self.query_one("#wait", Static).display = False
|
|
836
|
+
box = self.query_one("#thinking", Collapsible)
|
|
837
|
+
if thinking:
|
|
838
|
+
box.display = True
|
|
839
|
+
self._thinking_cache = thinking
|
|
840
|
+
self._set_thinking_title(elapsed, out_tokens, in_tokens, cache_tokens)
|
|
841
|
+
# 折叠时只改标题;展开才写入思考正文,避免正文和思考糊成一块
|
|
842
|
+
if not box.collapsed:
|
|
843
|
+
self._flush_thinking()
|
|
844
|
+
else:
|
|
845
|
+
box.display = False
|
|
846
|
+
ans = self.query_one("#answer", Static)
|
|
847
|
+
shown = pretty_stream_text(answer) if answer else ""
|
|
848
|
+
if shown:
|
|
849
|
+
ans.display = True
|
|
850
|
+
if shown != self._answer_cache:
|
|
851
|
+
# 流式时 Markdown 每 0.15s 重排一次就够;final 必须渲染到最新
|
|
852
|
+
now = time.monotonic()
|
|
853
|
+
if final or now - getattr(self, "_md_t", 0.0) >= 0.15:
|
|
854
|
+
self._md_t = now
|
|
855
|
+
self._answer_cache = shown
|
|
856
|
+
ans.update(Markdown(shown))
|
|
857
|
+
else:
|
|
858
|
+
ans.display = False
|
|
859
|
+
|
|
860
|
+
def pulse_title(
|
|
861
|
+
self,
|
|
862
|
+
elapsed: float,
|
|
863
|
+
out_tokens: int,
|
|
864
|
+
in_tokens: int | None = None,
|
|
865
|
+
cache_tokens: int | None = None,
|
|
866
|
+
) -> None:
|
|
867
|
+
"""思考折叠时标题上的秒数和 token 也要跟着走。"""
|
|
868
|
+
self._set_thinking_title(elapsed, out_tokens, in_tokens, cache_tokens)
|
|
869
|
+
|
|
870
|
+
def show_error(self, message: str) -> None:
|
|
871
|
+
self.query_one("#wait", Static).display = False
|
|
872
|
+
self.query_one("#thinking").display = False
|
|
873
|
+
ans = self.query_one("#answer", Static)
|
|
874
|
+
ans.display = True
|
|
875
|
+
ans.update(Text(f"请求失败: {message}"))
|
|
876
|
+
|
|
877
|
+
def show_empty(self) -> None:
|
|
878
|
+
self.query_one("#wait", Static).display = False
|
|
879
|
+
self.query_one("#thinking").display = False
|
|
880
|
+
ans = self.query_one("#answer", Static)
|
|
881
|
+
ans.display = True
|
|
882
|
+
ans.update(Text("(空回复)", style="dim"))
|
|
883
|
+
|
|
884
|
+
def show_interrupted(
|
|
885
|
+
self,
|
|
886
|
+
thinking: str,
|
|
887
|
+
answer: str,
|
|
888
|
+
*,
|
|
889
|
+
elapsed: float = 0.0,
|
|
890
|
+
in_tokens: int = 0,
|
|
891
|
+
cache_tokens: int = 0,
|
|
892
|
+
) -> None:
|
|
893
|
+
"""Esc / Ctrl+C 打断后留下已到的字,并标明已打断。"""
|
|
894
|
+
self.query_one("#wait", Static).display = False
|
|
895
|
+
box = self.query_one("#thinking", Collapsible)
|
|
896
|
+
if thinking:
|
|
897
|
+
box.display = True
|
|
898
|
+
self._thinking_cache = thinking
|
|
899
|
+
self._set_thinking_title(
|
|
900
|
+
elapsed,
|
|
901
|
+
_est_tokens(thinking),
|
|
902
|
+
in_tokens,
|
|
903
|
+
cache_tokens,
|
|
904
|
+
)
|
|
905
|
+
else:
|
|
906
|
+
box.display = False
|
|
907
|
+
ans = self.query_one("#answer", Static)
|
|
908
|
+
ans.display = True
|
|
909
|
+
shown = pretty_stream_text(answer) if answer else ""
|
|
910
|
+
if shown:
|
|
911
|
+
body: RenderableType = Group(Markdown(shown), Text("(已打断)", style="dim"))
|
|
912
|
+
else:
|
|
913
|
+
body = Text("(已打断)", style="dim")
|
|
914
|
+
self._answer_cache = (shown + "\n" if shown else "") + "(已打断)"
|
|
915
|
+
ans.update(body)
|
|
916
|
+
|
|
917
|
+
async def sync_tools(self, calls: list) -> None:
|
|
918
|
+
"""工具参数一边到一边画,写入/替换从半截 JSON 起就按真换行展示。"""
|
|
919
|
+
self.query_one("#wait").display = False
|
|
920
|
+
log = self.query_one("#tool-log", Vertical)
|
|
921
|
+
cards = getattr(self, "_tool_list", None)
|
|
922
|
+
if cards is None:
|
|
923
|
+
cards = []
|
|
924
|
+
self._tool_list = cards
|
|
925
|
+
for i, call in enumerate(calls or []):
|
|
926
|
+
if not isinstance(call, dict):
|
|
927
|
+
continue
|
|
928
|
+
name = str(call.get("name") or "")
|
|
929
|
+
raw = str(call.get("arguments") or "")
|
|
930
|
+
parsed = parse_tool_arguments(raw)
|
|
931
|
+
title = tool_title(name, parsed) if name else (name or "tool")
|
|
932
|
+
if i < len(cards):
|
|
933
|
+
card = cards[i]
|
|
934
|
+
card.set_heading(title)
|
|
935
|
+
else:
|
|
936
|
+
card = ToolCall(title, name=name)
|
|
937
|
+
cards.append(card)
|
|
938
|
+
await log.mount(card)
|
|
939
|
+
card.stream_preview(name, raw)
|
|
940
|
+
|
|
941
|
+
async def begin_tool(
|
|
942
|
+
self, title: str, index: int | None = None, name: str = ""
|
|
943
|
+
) -> "ToolCall":
|
|
944
|
+
self.query_one("#wait").display = False
|
|
945
|
+
cards = getattr(self, "_tool_list", None)
|
|
946
|
+
if cards is None:
|
|
947
|
+
cards = []
|
|
948
|
+
self._tool_list = cards
|
|
949
|
+
if index is not None and 0 <= index < len(cards):
|
|
950
|
+
card = cards[index]
|
|
951
|
+
card.set_heading(title)
|
|
952
|
+
return card
|
|
953
|
+
widget = ToolCall(title, name=name)
|
|
954
|
+
cards.append(widget)
|
|
955
|
+
await self.query_one("#tool-log", Vertical).mount(widget)
|
|
956
|
+
return widget
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
_DEL_STYLE = "bold #e08a8a"
|
|
960
|
+
_ADD_STYLE = "bold #8fbf9f"
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def paint_tool_body(raw: str) -> Text:
|
|
964
|
+
"""工具正文:改文件的 +/- 行上色,其余变淡。"""
|
|
965
|
+
body = Text(no_wrap=False, overflow="fold")
|
|
966
|
+
lines = (raw or "").splitlines()
|
|
967
|
+
total = len(lines)
|
|
968
|
+
if total > 400:
|
|
969
|
+
lines = lines[:400]
|
|
970
|
+
lines.append(f"…(截断,共 {total} 行)")
|
|
971
|
+
for i, line in enumerate(lines):
|
|
972
|
+
if i:
|
|
973
|
+
body.append("\n")
|
|
974
|
+
mark = line[:1] if line else ""
|
|
975
|
+
if mark == "-" and not line.startswith("---"):
|
|
976
|
+
body.append(line, style=_DEL_STYLE)
|
|
977
|
+
elif mark == "+" and not line.startswith("+++"):
|
|
978
|
+
body.append(line, style=_ADD_STYLE)
|
|
979
|
+
else:
|
|
980
|
+
body.append(line, style="dim")
|
|
981
|
+
return body if raw else Text("运行中…", style="dim")
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
_QUIET_TOOLS = {"read_file", "grep", "list_dir", "todo_write", "web_search", "web_fetch"}
|
|
985
|
+
_FAIL_PREFIXES = (
|
|
986
|
+
"工具失败", "找不到", "超时", "不是文件", "不是目录", "文件不存在",
|
|
987
|
+
"缺少", "未知工具", "正则无效", "用户拒绝", "old_string", "无结果",
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
class ToolCall(Widget):
|
|
992
|
+
"""一次工具调用:默认折叠,改文件时展开并高亮增删行。
|
|
993
|
+
|
|
994
|
+
标题按状态着色:运行中金黄、成功绿勾、失败红叉、只读工具整条灰。
|
|
995
|
+
"""
|
|
996
|
+
|
|
997
|
+
DEFAULT_CSS = """
|
|
998
|
+
ToolCall {
|
|
999
|
+
width: 1fr;
|
|
1000
|
+
height: auto;
|
|
1001
|
+
layout: vertical;
|
|
1002
|
+
background: ansi_default;
|
|
1003
|
+
margin: 0;
|
|
1004
|
+
}
|
|
1005
|
+
"""
|
|
1006
|
+
|
|
1007
|
+
def __init__(self, title: str, name: str = "", **kwargs) -> None:
|
|
1008
|
+
super().__init__(**kwargs)
|
|
1009
|
+
self._base_title = title
|
|
1010
|
+
self._tool_name = normalize_tool_name(name)
|
|
1011
|
+
self._raw = ""
|
|
1012
|
+
self._kind = ""
|
|
1013
|
+
self._phase = "stream"
|
|
1014
|
+
|
|
1015
|
+
def compose(self) -> ComposeResult:
|
|
1016
|
+
classes = "tool-box tool-run"
|
|
1017
|
+
if self._tool_name in _QUIET_TOOLS:
|
|
1018
|
+
classes += " tool-quiet"
|
|
1019
|
+
yield Collapsible(
|
|
1020
|
+
Static(classes="tool-body"),
|
|
1021
|
+
title=f"⠋ {self._base_title} 运行中…",
|
|
1022
|
+
collapsed=True,
|
|
1023
|
+
collapsed_symbol="▶",
|
|
1024
|
+
expanded_symbol="▼",
|
|
1025
|
+
classes=classes,
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
def _box(self) -> Collapsible:
|
|
1029
|
+
return self.query_one(".tool-box", Collapsible)
|
|
1030
|
+
|
|
1031
|
+
def _flush(self) -> None:
|
|
1032
|
+
self.query_one(".tool-body", Static).update(paint_tool_body(self._raw))
|
|
1033
|
+
|
|
1034
|
+
def set_heading(self, title: str) -> None:
|
|
1035
|
+
title = (title or "").strip() or self._base_title
|
|
1036
|
+
self._base_title = title
|
|
1037
|
+
if self._phase == "result":
|
|
1038
|
+
return
|
|
1039
|
+
try:
|
|
1040
|
+
self._box().title = f"⠋ {self._base_title} 运行中…"
|
|
1041
|
+
except Exception:
|
|
1042
|
+
pass
|
|
1043
|
+
|
|
1044
|
+
def stream_output(self, text: str) -> None:
|
|
1045
|
+
"""终端跑的时候把输出实时刷进卡片;结束时 finish() 再按结果收起/展开。"""
|
|
1046
|
+
if self._phase == "result":
|
|
1047
|
+
return
|
|
1048
|
+
self._raw = text or ""
|
|
1049
|
+
box = self._box()
|
|
1050
|
+
now = time.monotonic()
|
|
1051
|
+
if now - getattr(self, "_preview_t", 0.0) < 0.1:
|
|
1052
|
+
return
|
|
1053
|
+
self._preview_t = now
|
|
1054
|
+
if box.collapsed:
|
|
1055
|
+
box.collapsed = False
|
|
1056
|
+
self._flush()
|
|
1057
|
+
|
|
1058
|
+
def stream_preview(self, name: str, raw_args: str) -> None:
|
|
1059
|
+
if self._phase == "result":
|
|
1060
|
+
return
|
|
1061
|
+
self._kind = name
|
|
1062
|
+
self._tool_name = normalize_tool_name(name) or self._tool_name
|
|
1063
|
+
self._raw = preview_tool_arguments(name, raw_args)
|
|
1064
|
+
parsed = parse_tool_arguments(raw_args)
|
|
1065
|
+
if name and parsed:
|
|
1066
|
+
try:
|
|
1067
|
+
self._base_title = tool_title(name, parsed)
|
|
1068
|
+
except Exception:
|
|
1069
|
+
pass
|
|
1070
|
+
box = self._box()
|
|
1071
|
+
box.title = f"⠋ {self._base_title} 运行中…"
|
|
1072
|
+
key = (name or "").strip()
|
|
1073
|
+
expand = key in ("write", "write_file", "search_replace")
|
|
1074
|
+
if expand and box.collapsed:
|
|
1075
|
+
box.collapsed = False
|
|
1076
|
+
now = time.monotonic()
|
|
1077
|
+
last = getattr(self, "_preview_t", 0.0)
|
|
1078
|
+
if expand or not box.collapsed:
|
|
1079
|
+
if now - last >= 0.05 or len(self._raw) < 240:
|
|
1080
|
+
self._preview_t = now
|
|
1081
|
+
self._flush()
|
|
1082
|
+
|
|
1083
|
+
def on_collapsible_expanded(self, event: Collapsible.Expanded) -> None:
|
|
1084
|
+
event.stop()
|
|
1085
|
+
if self._raw:
|
|
1086
|
+
self._flush()
|
|
1087
|
+
|
|
1088
|
+
def finish(self, name: str, result: str) -> None:
|
|
1089
|
+
self._phase = "result"
|
|
1090
|
+
self._kind = name
|
|
1091
|
+
self._raw = result or ""
|
|
1092
|
+
summary = tool_result_summary(name, self._raw)
|
|
1093
|
+
box = self._box()
|
|
1094
|
+
head = "✓" if not self._failed() else "✗"
|
|
1095
|
+
box.title = (
|
|
1096
|
+
f"{head} {self._base_title} {summary}".rstrip()
|
|
1097
|
+
if summary
|
|
1098
|
+
else f"{head} {self._base_title}"
|
|
1099
|
+
)
|
|
1100
|
+
ok = not self._failed()
|
|
1101
|
+
box.remove_class("tool-run")
|
|
1102
|
+
box.add_class("tool-ok" if ok else "tool-err")
|
|
1103
|
+
expand = False
|
|
1104
|
+
if name == "search_replace" and self._raw.startswith("已替换"):
|
|
1105
|
+
expand = True
|
|
1106
|
+
elif name in ("write", "write_file") and (
|
|
1107
|
+
self._raw.startswith("已写入") or self._raw.startswith("已覆盖")
|
|
1108
|
+
):
|
|
1109
|
+
expand = True
|
|
1110
|
+
elif self._failed():
|
|
1111
|
+
expand = True
|
|
1112
|
+
box.collapsed = not expand
|
|
1113
|
+
if expand:
|
|
1114
|
+
self._flush()
|
|
1115
|
+
|
|
1116
|
+
def _failed(self) -> bool:
|
|
1117
|
+
raw = self._raw or ""
|
|
1118
|
+
return any(raw.startswith(p) for p in _FAIL_PREFIXES)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
class QueueItem(Horizontal):
|
|
1122
|
+
"""输入框上方的一条排队稿:左边预览,右边编辑/发送图标。"""
|
|
1123
|
+
|
|
1124
|
+
DEFAULT_CSS = """
|
|
1125
|
+
QueueItem {
|
|
1126
|
+
width: 1fr;
|
|
1127
|
+
height: 3;
|
|
1128
|
+
layout: horizontal;
|
|
1129
|
+
background: ansi_default;
|
|
1130
|
+
border: round grey;
|
|
1131
|
+
margin: 0 1 1 1;
|
|
1132
|
+
padding: 0 0 0 1;
|
|
1133
|
+
align: left middle;
|
|
1134
|
+
}
|
|
1135
|
+
QueueItem #q-text {
|
|
1136
|
+
width: 1fr;
|
|
1137
|
+
height: 1;
|
|
1138
|
+
background: ansi_default;
|
|
1139
|
+
text-wrap: nowrap;
|
|
1140
|
+
overflow: hidden;
|
|
1141
|
+
padding: 0 1 0 0;
|
|
1142
|
+
}
|
|
1143
|
+
QueueItem #q-input {
|
|
1144
|
+
width: 1fr;
|
|
1145
|
+
height: 1;
|
|
1146
|
+
background: ansi_default;
|
|
1147
|
+
border: none;
|
|
1148
|
+
padding: 0 1 0 0;
|
|
1149
|
+
margin: 0;
|
|
1150
|
+
}
|
|
1151
|
+
QueueItem #q-edit, QueueItem #q-send {
|
|
1152
|
+
width: 3;
|
|
1153
|
+
min-width: 3;
|
|
1154
|
+
height: 3;
|
|
1155
|
+
border: none;
|
|
1156
|
+
background: ansi_default;
|
|
1157
|
+
padding: 0;
|
|
1158
|
+
margin: 0;
|
|
1159
|
+
}
|
|
1160
|
+
"""
|
|
1161
|
+
|
|
1162
|
+
class Send(Message):
|
|
1163
|
+
def __init__(self, ticket_id: int) -> None:
|
|
1164
|
+
super().__init__()
|
|
1165
|
+
self.ticket_id = ticket_id
|
|
1166
|
+
|
|
1167
|
+
class Edited(Message):
|
|
1168
|
+
def __init__(self, ticket_id: int, text: str) -> None:
|
|
1169
|
+
super().__init__()
|
|
1170
|
+
self.ticket_id = ticket_id
|
|
1171
|
+
self.text = text
|
|
1172
|
+
|
|
1173
|
+
def __init__(self, ticket_id: int, text: str, **kwargs) -> None:
|
|
1174
|
+
super().__init__(**kwargs)
|
|
1175
|
+
self.ticket_id = ticket_id
|
|
1176
|
+
self._text = text
|
|
1177
|
+
|
|
1178
|
+
@property
|
|
1179
|
+
def text(self) -> str:
|
|
1180
|
+
return self._text
|
|
1181
|
+
|
|
1182
|
+
def compose(self) -> ComposeResult:
|
|
1183
|
+
yield Static(self._text, id="q-text")
|
|
1184
|
+
yield Button("✎", id="q-edit", compact=True, tooltip="编辑")
|
|
1185
|
+
yield Button("➤", id="q-send", compact=True, tooltip="发送")
|
|
1186
|
+
|
|
1187
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1188
|
+
event.stop()
|
|
1189
|
+
if event.button.id == "q-edit":
|
|
1190
|
+
self._start_edit()
|
|
1191
|
+
elif event.button.id == "q-send":
|
|
1192
|
+
sender = getattr(self.app, "force_send_queued", None)
|
|
1193
|
+
if callable(sender):
|
|
1194
|
+
sender(self.ticket_id)
|
|
1195
|
+
|
|
1196
|
+
def _start_edit(self) -> None:
|
|
1197
|
+
if self.query("#q-input"):
|
|
1198
|
+
self.query_one("#q-input", Input).focus()
|
|
1199
|
+
return
|
|
1200
|
+
self.query_one("#q-text").display = False
|
|
1201
|
+
field = Input(value=self._text, id="q-input")
|
|
1202
|
+
self.mount(field, before=self.query_one("#q-edit"))
|
|
1203
|
+
field.focus()
|
|
1204
|
+
|
|
1205
|
+
def _finish_edit(self, value: str) -> None:
|
|
1206
|
+
text = value.strip() or self._text
|
|
1207
|
+
self._text = text
|
|
1208
|
+
label = self.query_one("#q-text", Static)
|
|
1209
|
+
label.update(text)
|
|
1210
|
+
label.display = True
|
|
1211
|
+
if self.query("#q-input"):
|
|
1212
|
+
self.query_one("#q-input").remove()
|
|
1213
|
+
self.post_message(self.Edited(self.ticket_id, text))
|
|
1214
|
+
|
|
1215
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
1216
|
+
if event.input.id != "q-input":
|
|
1217
|
+
return
|
|
1218
|
+
event.stop()
|
|
1219
|
+
self._finish_edit(event.value)
|
|
1220
|
+
|
|
1221
|
+
def on_input_blurred(self, event: Input.Blurred) -> None:
|
|
1222
|
+
if event.input.id != "q-input":
|
|
1223
|
+
return
|
|
1224
|
+
event.stop()
|
|
1225
|
+
self._finish_edit(event.value)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
class QueueList(Vertical):
|
|
1229
|
+
"""输入框上方的排队弹窗列表,有几条就显示几条。"""
|
|
1230
|
+
|
|
1231
|
+
DEFAULT_CSS = """
|
|
1232
|
+
QueueList {
|
|
1233
|
+
width: 1fr;
|
|
1234
|
+
height: auto;
|
|
1235
|
+
background: ansi_default;
|
|
1236
|
+
display: none;
|
|
1237
|
+
}
|
|
1238
|
+
"""
|
|
1239
|
+
|
|
1240
|
+
def __init__(self, **kwargs) -> None:
|
|
1241
|
+
super().__init__(**kwargs)
|
|
1242
|
+
self._items: list[QueueItem] = []
|
|
1243
|
+
|
|
1244
|
+
def enqueue(self, ticket_id: int, text: str) -> None:
|
|
1245
|
+
item = QueueItem(ticket_id, text)
|
|
1246
|
+
self._items.append(item)
|
|
1247
|
+
self.display = True
|
|
1248
|
+
self.mount(item)
|
|
1249
|
+
|
|
1250
|
+
def _sync_display(self) -> None:
|
|
1251
|
+
self.display = bool(self._items)
|
|
1252
|
+
|
|
1253
|
+
def take(self, ticket_id: int) -> str | None:
|
|
1254
|
+
for i, item in enumerate(self._items):
|
|
1255
|
+
if item.ticket_id == ticket_id:
|
|
1256
|
+
text = item.text
|
|
1257
|
+
self._items.pop(i)
|
|
1258
|
+
item.remove()
|
|
1259
|
+
self._sync_display()
|
|
1260
|
+
return text
|
|
1261
|
+
return None
|
|
1262
|
+
|
|
1263
|
+
def pop_front(self) -> str | None:
|
|
1264
|
+
if not self._items:
|
|
1265
|
+
return None
|
|
1266
|
+
item = self._items.pop(0)
|
|
1267
|
+
text = item.text
|
|
1268
|
+
item.remove()
|
|
1269
|
+
self._sync_display()
|
|
1270
|
+
return text
|
|
1271
|
+
|
|
1272
|
+
def move_front(self, ticket_id: int) -> None:
|
|
1273
|
+
idx = next(
|
|
1274
|
+
(i for i, item in enumerate(self._items) if item.ticket_id == ticket_id),
|
|
1275
|
+
-1,
|
|
1276
|
+
)
|
|
1277
|
+
if idx <= 0:
|
|
1278
|
+
return
|
|
1279
|
+
item = self._items.pop(idx)
|
|
1280
|
+
self._items.insert(0, item)
|
|
1281
|
+
others = [c for c in self.children if c is not item]
|
|
1282
|
+
if others:
|
|
1283
|
+
self.move_child(item, before=others[0])
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
class RunStatus(Static):
|
|
1287
|
+
"""输入框上方一行:等待、思考、跑工具时的加载反馈。"""
|
|
1288
|
+
|
|
1289
|
+
DEFAULT_CSS = """
|
|
1290
|
+
RunStatus {
|
|
1291
|
+
width: 1fr;
|
|
1292
|
+
height: 1;
|
|
1293
|
+
background: ansi_default;
|
|
1294
|
+
display: none;
|
|
1295
|
+
padding: 0 1;
|
|
1296
|
+
text-wrap: nowrap;
|
|
1297
|
+
overflow-x: hidden;
|
|
1298
|
+
overflow-y: hidden;
|
|
1299
|
+
}
|
|
1300
|
+
"""
|
|
1301
|
+
|
|
1302
|
+
def show_line(self, renderable: Text) -> None:
|
|
1303
|
+
self.display = True
|
|
1304
|
+
self.update(renderable)
|
|
1305
|
+
|
|
1306
|
+
def hide(self) -> None:
|
|
1307
|
+
self.display = False
|
|
1308
|
+
self.update("")
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
SLASH_COMMANDS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
|
1312
|
+
("/new", "新开一扇对话窗口,当前会话先收进栈里", ("/clear",)),
|
|
1313
|
+
("/changewin", "打开窗口列表选择会话,也可跟 id", ("/win", "/change-win")),
|
|
1314
|
+
("/zip", "手动压缩上下文,可加保留提示", ("/compact",)),
|
|
1315
|
+
("/history", "回退到某次提问前的代码和对话", ("/rewind", "/undo")),
|
|
1316
|
+
("/model", "切换模型:/model 弹列表,/model <序号或id> 直接切", ()),
|
|
1317
|
+
("/effort", "推理等级:/effort 弹列表,或 /effort low|medium|high|默认", ()),
|
|
1318
|
+
("/rename", "给当前窗口改名:/rename <新标题>", ()),
|
|
1319
|
+
("/delwin", "删除一个历史窗口:/delwin <id前缀>,当前窗口不能删", ()),
|
|
1320
|
+
("/export", "把当前窗口的对话导出成 Markdown 文件", ("/save",)),
|
|
1321
|
+
("/img", "附加一张图片随下一条消息发给模型:/img <路径>", ("/image", "/pic")),
|
|
1322
|
+
("/ask", "切换到 ask:敏感操作先问你", ()),
|
|
1323
|
+
("/pass", "切换到 pass:一条龙不问", ()),
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def _slash_matches(typed: str) -> list[tuple[str, str]]:
|
|
1328
|
+
raw = typed.strip()
|
|
1329
|
+
if not raw.startswith("/"):
|
|
1330
|
+
return []
|
|
1331
|
+
head = raw.split(None, 1)[0].lower()
|
|
1332
|
+
if " " in raw.strip():
|
|
1333
|
+
return []
|
|
1334
|
+
out: list[tuple[str, str]] = []
|
|
1335
|
+
for cmd, help_text, aliases in SLASH_COMMANDS:
|
|
1336
|
+
keys = (cmd,) + aliases
|
|
1337
|
+
if head == "/" or any(key.startswith(head) for key in keys):
|
|
1338
|
+
alias = f" 别名 {' '.join(aliases)}" if aliases else ""
|
|
1339
|
+
out.append((cmd, help_text + alias))
|
|
1340
|
+
return out
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
class CommandHints(Vertical):
|
|
1344
|
+
"""输入 / 时出现的命令补全,带每条命令的作用。"""
|
|
1345
|
+
|
|
1346
|
+
DEFAULT_CSS = """
|
|
1347
|
+
CommandHints {
|
|
1348
|
+
width: 1fr;
|
|
1349
|
+
height: auto;
|
|
1350
|
+
background: ansi_default;
|
|
1351
|
+
display: none;
|
|
1352
|
+
padding: 0 1 1 1;
|
|
1353
|
+
}
|
|
1354
|
+
CommandHints Static {
|
|
1355
|
+
width: 1fr;
|
|
1356
|
+
height: 1;
|
|
1357
|
+
background: ansi_default;
|
|
1358
|
+
text-wrap: nowrap;
|
|
1359
|
+
overflow: hidden;
|
|
1360
|
+
}
|
|
1361
|
+
"""
|
|
1362
|
+
|
|
1363
|
+
def __init__(self, **kwargs) -> None:
|
|
1364
|
+
super().__init__(**kwargs)
|
|
1365
|
+
self._items: list[tuple[str, str]] = []
|
|
1366
|
+
self._index = 0
|
|
1367
|
+
|
|
1368
|
+
def hide(self) -> None:
|
|
1369
|
+
self._items = []
|
|
1370
|
+
self._index = 0
|
|
1371
|
+
self.display = False
|
|
1372
|
+
self.remove_children()
|
|
1373
|
+
|
|
1374
|
+
def set_items(self, items: list[tuple[str, str]]) -> None:
|
|
1375
|
+
if not items:
|
|
1376
|
+
self.hide()
|
|
1377
|
+
return
|
|
1378
|
+
if self._items != items:
|
|
1379
|
+
self._index = 0
|
|
1380
|
+
self._items = items
|
|
1381
|
+
if self._index >= len(items):
|
|
1382
|
+
self._index = 0
|
|
1383
|
+
self.display = True
|
|
1384
|
+
self._render_rows()
|
|
1385
|
+
|
|
1386
|
+
def move(self, delta: int) -> None:
|
|
1387
|
+
if not self._items:
|
|
1388
|
+
return
|
|
1389
|
+
self._index = (self._index + delta) % len(self._items)
|
|
1390
|
+
self._render_rows()
|
|
1391
|
+
|
|
1392
|
+
def current_cmd(self) -> str:
|
|
1393
|
+
if not self._items:
|
|
1394
|
+
return ""
|
|
1395
|
+
return self._items[self._index][0]
|
|
1396
|
+
|
|
1397
|
+
def _render_rows(self) -> None:
|
|
1398
|
+
self.remove_children()
|
|
1399
|
+
for i, (cmd, help_text) in enumerate(self._items):
|
|
1400
|
+
line = Text()
|
|
1401
|
+
if i == self._index:
|
|
1402
|
+
line.append("› ", style="bold #d9a84e")
|
|
1403
|
+
line.append(f"{cmd:<12}", style="bold #d9a84e")
|
|
1404
|
+
else:
|
|
1405
|
+
line.append(" ")
|
|
1406
|
+
line.append(f"{cmd:<12}", style="bold #b5ae9d")
|
|
1407
|
+
line.append(help_text, style="dim")
|
|
1408
|
+
self.mount(Static(line))
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _cost_usd(inp: int, out: int, cache: int) -> float:
|
|
1412
|
+
"""按 config.json 的 pricing(每百万 token 美元)估成本;没配就返回 0。"""
|
|
1413
|
+
if not _PRICING:
|
|
1414
|
+
return 0.0
|
|
1415
|
+
pin = float(_PRICING.get("input") or 0)
|
|
1416
|
+
pout = float(_PRICING.get("output") or 0)
|
|
1417
|
+
pcache = _PRICING.get("cacheRead")
|
|
1418
|
+
if pcache is None:
|
|
1419
|
+
pcache = _PRICING.get("cache_read")
|
|
1420
|
+
pcache = float(pcache or 0)
|
|
1421
|
+
if not (pin or pout or pcache):
|
|
1422
|
+
return 0.0
|
|
1423
|
+
miss = max(0, int(inp) - int(cache))
|
|
1424
|
+
return (miss * pin + int(cache) * pcache + int(out) * pout) / 1_000_000
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
class TodoPanel(Vertical):
|
|
1428
|
+
"""todo_write 的可视面板:模型列待办,这里实时画出来;没待办就整块隐藏。"""
|
|
1429
|
+
|
|
1430
|
+
_MARKS = (
|
|
1431
|
+
("completed", "✓", "bold #8fbf9f"),
|
|
1432
|
+
("in_progress", "→", "bold #d9a84e"),
|
|
1433
|
+
("cancelled", "x", "dim strike"),
|
|
1434
|
+
("pending", "·", "dim"),
|
|
1435
|
+
)
|
|
1436
|
+
|
|
1437
|
+
DEFAULT_CSS = """
|
|
1438
|
+
TodoPanel {
|
|
1439
|
+
width: 1fr;
|
|
1440
|
+
height: auto;
|
|
1441
|
+
max-height: 8;
|
|
1442
|
+
background: ansi_default;
|
|
1443
|
+
display: none;
|
|
1444
|
+
padding: 0 1;
|
|
1445
|
+
overflow-x: hidden;
|
|
1446
|
+
overflow-y: auto;
|
|
1447
|
+
}
|
|
1448
|
+
TodoPanel Static {
|
|
1449
|
+
width: 1fr;
|
|
1450
|
+
height: 1;
|
|
1451
|
+
background: ansi_default;
|
|
1452
|
+
text-wrap: nowrap;
|
|
1453
|
+
overflow: hidden;
|
|
1454
|
+
}
|
|
1455
|
+
"""
|
|
1456
|
+
|
|
1457
|
+
def set_todos(self, todos: list) -> None:
|
|
1458
|
+
self.remove_children()
|
|
1459
|
+
rows = [t for t in (todos or []) if isinstance(t, dict)]
|
|
1460
|
+
self.display = bool(rows)
|
|
1461
|
+
for t in rows:
|
|
1462
|
+
status = str(t.get("status") or "pending")
|
|
1463
|
+
mark, style = next(
|
|
1464
|
+
((m, s) for key, m, s in self._MARKS if key == status), ("·", "dim")
|
|
1465
|
+
)
|
|
1466
|
+
line = Text()
|
|
1467
|
+
line.append(f"{mark} ", style=style)
|
|
1468
|
+
line.append(str(t.get("content") or t.get("id") or ""), style=style)
|
|
1469
|
+
self.mount(Static(line))
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
class FileHints(Vertical):
|
|
1473
|
+
"""输入 @ 时弹出的项目文件补全,Tab 选中。"""
|
|
1474
|
+
|
|
1475
|
+
DEFAULT_CSS = """
|
|
1476
|
+
FileHints {
|
|
1477
|
+
width: 1fr;
|
|
1478
|
+
height: auto;
|
|
1479
|
+
max-height: 9;
|
|
1480
|
+
background: ansi_default;
|
|
1481
|
+
display: none;
|
|
1482
|
+
padding: 0 1 1 1;
|
|
1483
|
+
overflow-x: hidden;
|
|
1484
|
+
overflow-y: auto;
|
|
1485
|
+
}
|
|
1486
|
+
FileHints Static {
|
|
1487
|
+
width: 1fr;
|
|
1488
|
+
height: 1;
|
|
1489
|
+
background: ansi_default;
|
|
1490
|
+
text-wrap: nowrap;
|
|
1491
|
+
overflow: hidden;
|
|
1492
|
+
}
|
|
1493
|
+
"""
|
|
1494
|
+
|
|
1495
|
+
def __init__(self, **kwargs) -> None:
|
|
1496
|
+
super().__init__(**kwargs)
|
|
1497
|
+
self._items: list[str] = []
|
|
1498
|
+
self._prefix = ""
|
|
1499
|
+
self._index = 0
|
|
1500
|
+
|
|
1501
|
+
def set_items(self, prefix: str, items: list[str]) -> None:
|
|
1502
|
+
if not items:
|
|
1503
|
+
self.hide()
|
|
1504
|
+
return
|
|
1505
|
+
self._prefix = prefix
|
|
1506
|
+
self._items = items
|
|
1507
|
+
if self._index >= len(items):
|
|
1508
|
+
self._index = 0
|
|
1509
|
+
self.display = True
|
|
1510
|
+
self._render_rows()
|
|
1511
|
+
|
|
1512
|
+
def hide(self) -> None:
|
|
1513
|
+
self._items = []
|
|
1514
|
+
self._index = 0
|
|
1515
|
+
self.display = False
|
|
1516
|
+
self.remove_children()
|
|
1517
|
+
|
|
1518
|
+
def move(self, delta: int) -> None:
|
|
1519
|
+
if not self._items:
|
|
1520
|
+
return
|
|
1521
|
+
self._index = (self._index + delta) % len(self._items)
|
|
1522
|
+
self._render_rows()
|
|
1523
|
+
|
|
1524
|
+
def current(self) -> str:
|
|
1525
|
+
if not self._items:
|
|
1526
|
+
return ""
|
|
1527
|
+
return self._items[self._index]
|
|
1528
|
+
|
|
1529
|
+
def _render_rows(self) -> None:
|
|
1530
|
+
self.remove_children()
|
|
1531
|
+
for i, path in enumerate(self._items):
|
|
1532
|
+
line = Text()
|
|
1533
|
+
if i == self._index:
|
|
1534
|
+
line.append("› ", style="bold #d9a84e")
|
|
1535
|
+
line.append(path, style="bold #d9a84e")
|
|
1536
|
+
else:
|
|
1537
|
+
line.append(" ")
|
|
1538
|
+
line.append(path, style="#b5ae9d")
|
|
1539
|
+
self.mount(Static(line))
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
class ModelPicker(Vertical):
|
|
1543
|
+
""" /model 或 Ctrl+M 弹出的模型列表,选一个立即切换。"""
|
|
1544
|
+
|
|
1545
|
+
class Picked(Message):
|
|
1546
|
+
def __init__(self, model_id: str) -> None:
|
|
1547
|
+
super().__init__()
|
|
1548
|
+
self.model_id = model_id
|
|
1549
|
+
|
|
1550
|
+
DEFAULT_CSS = """
|
|
1551
|
+
ModelPicker {
|
|
1552
|
+
width: 1fr;
|
|
1553
|
+
height: auto;
|
|
1554
|
+
max-height: 14;
|
|
1555
|
+
background: ansi_default;
|
|
1556
|
+
display: none;
|
|
1557
|
+
padding: 0 1 1 1;
|
|
1558
|
+
overflow-x: hidden;
|
|
1559
|
+
overflow-y: auto;
|
|
1560
|
+
}
|
|
1561
|
+
ModelPicker #model-head {
|
|
1562
|
+
width: 1fr;
|
|
1563
|
+
height: 1;
|
|
1564
|
+
background: ansi_default;
|
|
1565
|
+
color: grey;
|
|
1566
|
+
text-wrap: nowrap;
|
|
1567
|
+
overflow: hidden;
|
|
1568
|
+
}
|
|
1569
|
+
ModelPicker OptionList {
|
|
1570
|
+
width: 1fr;
|
|
1571
|
+
height: auto;
|
|
1572
|
+
max-height: 12;
|
|
1573
|
+
background: ansi_default;
|
|
1574
|
+
color: grey;
|
|
1575
|
+
border: none;
|
|
1576
|
+
padding: 0;
|
|
1577
|
+
overflow-x: hidden;
|
|
1578
|
+
}
|
|
1579
|
+
ModelPicker OptionList:focus {
|
|
1580
|
+
background: ansi_default;
|
|
1581
|
+
background-tint: ansi_default;
|
|
1582
|
+
border: none;
|
|
1583
|
+
}
|
|
1584
|
+
ModelPicker OptionList > .option-list--option-highlighted {
|
|
1585
|
+
color: #f0e2c0;
|
|
1586
|
+
background: #4a3f22;
|
|
1587
|
+
text-style: bold;
|
|
1588
|
+
}
|
|
1589
|
+
ModelPicker OptionList:focus > .option-list--option-highlighted {
|
|
1590
|
+
color: #f5e6c4;
|
|
1591
|
+
background: #5a4b28;
|
|
1592
|
+
text-style: bold;
|
|
1593
|
+
}
|
|
1594
|
+
ModelPicker OptionList > .option-list--option-hover {
|
|
1595
|
+
background: #3a3222;
|
|
1596
|
+
color: #f0e2c0;
|
|
1597
|
+
}
|
|
1598
|
+
"""
|
|
1599
|
+
|
|
1600
|
+
def __init__(self, **kwargs) -> None:
|
|
1601
|
+
super().__init__(**kwargs)
|
|
1602
|
+
self._models: list[str] = []
|
|
1603
|
+
|
|
1604
|
+
def compose(self) -> ComposeResult:
|
|
1605
|
+
yield Static(
|
|
1606
|
+
Text.from_markup(f"[bold {_AMBER}]▎[/]选择模型 ↑↓ 移动 回车切换 Esc取消"),
|
|
1607
|
+
id="model-head",
|
|
1608
|
+
)
|
|
1609
|
+
yield OptionList(id="model-list", compact=True)
|
|
1610
|
+
|
|
1611
|
+
def _listing(self) -> OptionList:
|
|
1612
|
+
return self.query_one("#model-list", OptionList)
|
|
1613
|
+
|
|
1614
|
+
def set_models(self, models: list[str], current: str) -> None:
|
|
1615
|
+
self._models = list(models)
|
|
1616
|
+
options: list[Option] = []
|
|
1617
|
+
for m in self._models:
|
|
1618
|
+
label = Text()
|
|
1619
|
+
if m == current:
|
|
1620
|
+
label.append("● ", style=f"bold {_AMBER}")
|
|
1621
|
+
label.append(m, style=f"bold {_AMBER}")
|
|
1622
|
+
else:
|
|
1623
|
+
label.append(" ")
|
|
1624
|
+
label.append(m, style="#b5ae9d")
|
|
1625
|
+
options.append(Option(label, id=m))
|
|
1626
|
+
listing = self._listing()
|
|
1627
|
+
if options:
|
|
1628
|
+
listing.set_options(options)
|
|
1629
|
+
listing.highlighted = (
|
|
1630
|
+
self._models.index(current) if current in self._models else 0
|
|
1631
|
+
)
|
|
1632
|
+
else:
|
|
1633
|
+
listing.clear_options()
|
|
1634
|
+
self.display = True
|
|
1635
|
+
|
|
1636
|
+
def hide(self) -> None:
|
|
1637
|
+
self.display = False
|
|
1638
|
+
try:
|
|
1639
|
+
self._listing().clear_options()
|
|
1640
|
+
except Exception:
|
|
1641
|
+
pass
|
|
1642
|
+
|
|
1643
|
+
def confirm(self) -> None:
|
|
1644
|
+
try:
|
|
1645
|
+
self._listing().action_select()
|
|
1646
|
+
except Exception:
|
|
1647
|
+
pass
|
|
1648
|
+
|
|
1649
|
+
def move(self, delta: int) -> None:
|
|
1650
|
+
listing = self._listing()
|
|
1651
|
+
if delta > 0:
|
|
1652
|
+
listing.action_cursor_down()
|
|
1653
|
+
elif delta < 0:
|
|
1654
|
+
listing.action_cursor_up()
|
|
1655
|
+
|
|
1656
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
1657
|
+
event.stop()
|
|
1658
|
+
mid = event.option_id or (event.option.id if event.option else "")
|
|
1659
|
+
if mid:
|
|
1660
|
+
self.post_message(self.Picked(mid))
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
# 等级名用协议原值,说明给人看;"" 即 default,不发参数
|
|
1664
|
+
_EFFORT_DESC: dict[str, str] = {
|
|
1665
|
+
"": "服务端自己决定",
|
|
1666
|
+
"low": "快,省 token",
|
|
1667
|
+
"medium": "均衡",
|
|
1668
|
+
"high": "慢,推理更深",
|
|
1669
|
+
"minimal": "最快",
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
# 启发式:模型 id 长这样的多半是推理模型(o3/gpt-5/R1/QwQ/thinking…)
|
|
1673
|
+
_REASONING_HINT = re.compile(
|
|
1674
|
+
r"(^|[-_/.])(o[1345](-mini|preview|-pro)?|gpt-5|reasoner|r[12]|qwq|thinking)",
|
|
1675
|
+
re.I,
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def _looks_like_reasoning_model(model_id: str) -> bool:
|
|
1680
|
+
return bool(_REASONING_HINT.search(model_id or ""))
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
class EffortPicker(Vertical):
|
|
1684
|
+
"""选完模型后弹出的推理等级选择;也由 /effort 唤出。"""
|
|
1685
|
+
|
|
1686
|
+
class Picked(Message):
|
|
1687
|
+
def __init__(self, effort: str) -> None:
|
|
1688
|
+
super().__init__()
|
|
1689
|
+
self.effort = effort
|
|
1690
|
+
|
|
1691
|
+
DEFAULT_CSS = """
|
|
1692
|
+
EffortPicker {
|
|
1693
|
+
width: 1fr;
|
|
1694
|
+
height: auto;
|
|
1695
|
+
max-height: 14;
|
|
1696
|
+
background: ansi_default;
|
|
1697
|
+
display: none;
|
|
1698
|
+
padding: 0 1 1 1;
|
|
1699
|
+
overflow-x: hidden;
|
|
1700
|
+
overflow-y: auto;
|
|
1701
|
+
}
|
|
1702
|
+
EffortPicker #effort-head {
|
|
1703
|
+
width: 1fr;
|
|
1704
|
+
height: 1;
|
|
1705
|
+
background: ansi_default;
|
|
1706
|
+
color: grey;
|
|
1707
|
+
text-wrap: nowrap;
|
|
1708
|
+
overflow: hidden;
|
|
1709
|
+
}
|
|
1710
|
+
EffortPicker OptionList {
|
|
1711
|
+
width: 1fr;
|
|
1712
|
+
height: auto;
|
|
1713
|
+
max-height: 8;
|
|
1714
|
+
background: ansi_default;
|
|
1715
|
+
color: grey;
|
|
1716
|
+
border: none;
|
|
1717
|
+
padding: 0;
|
|
1718
|
+
overflow-x: hidden;
|
|
1719
|
+
}
|
|
1720
|
+
EffortPicker OptionList:focus {
|
|
1721
|
+
background: ansi_default;
|
|
1722
|
+
background-tint: ansi_default;
|
|
1723
|
+
border: none;
|
|
1724
|
+
}
|
|
1725
|
+
EffortPicker OptionList > .option-list--option-highlighted {
|
|
1726
|
+
color: #f0e2c0;
|
|
1727
|
+
background: #4a3f22;
|
|
1728
|
+
text-style: bold;
|
|
1729
|
+
}
|
|
1730
|
+
EffortPicker OptionList:focus > .option-list--option-highlighted {
|
|
1731
|
+
color: #f5e6c4;
|
|
1732
|
+
background: #5a4b28;
|
|
1733
|
+
text-style: bold;
|
|
1734
|
+
}
|
|
1735
|
+
EffortPicker OptionList > .option-list--option-hover {
|
|
1736
|
+
background: #3a3222;
|
|
1737
|
+
color: #f0e2c0;
|
|
1738
|
+
}
|
|
1739
|
+
"""
|
|
1740
|
+
|
|
1741
|
+
def __init__(self, **kwargs) -> None:
|
|
1742
|
+
super().__init__(**kwargs)
|
|
1743
|
+
self._values: list[str] = []
|
|
1744
|
+
|
|
1745
|
+
def compose(self) -> ComposeResult:
|
|
1746
|
+
yield Static("", id="effort-head")
|
|
1747
|
+
yield OptionList(id="effort-list", compact=True)
|
|
1748
|
+
|
|
1749
|
+
def _listing(self) -> OptionList:
|
|
1750
|
+
return self.query_one("#effort-list", OptionList)
|
|
1751
|
+
|
|
1752
|
+
def set_efforts(
|
|
1753
|
+
self,
|
|
1754
|
+
model_id: str,
|
|
1755
|
+
current: str,
|
|
1756
|
+
detected: bool,
|
|
1757
|
+
levels: list[str] | None = None,
|
|
1758
|
+
) -> None:
|
|
1759
|
+
"""levels 是 /models 元数据里拿到的支持等级;空则用标准四档兜底。"""
|
|
1760
|
+
head = self.query_one("#effort-head", Static)
|
|
1761
|
+
line = Text()
|
|
1762
|
+
line.append("▎", style=f"bold {_AMBER}")
|
|
1763
|
+
line.append(f"{model_id} ", style=f"bold {_AMBER}")
|
|
1764
|
+
if levels:
|
|
1765
|
+
line.append("支持的推理等级(来自 /models) ", style="dim")
|
|
1766
|
+
elif detected:
|
|
1767
|
+
line.append("疑似推理模型,选推理等级 ", style="dim")
|
|
1768
|
+
else:
|
|
1769
|
+
line.append("选推理等级(看不出来的就选 default) ", style="dim")
|
|
1770
|
+
line.append("↑↓ 移动 回车确定 Esc跳过", style="dim")
|
|
1771
|
+
head.update(line)
|
|
1772
|
+
known = levels or ["low", "medium", "high"]
|
|
1773
|
+
self._values = [""] + [v for v in known if v in _EFFORT_DESC and v]
|
|
1774
|
+
options: list[Option] = []
|
|
1775
|
+
highlight = 0
|
|
1776
|
+
for i, value in enumerate(self._values):
|
|
1777
|
+
name = value or "default"
|
|
1778
|
+
label = Text()
|
|
1779
|
+
chosen = value == current
|
|
1780
|
+
if chosen:
|
|
1781
|
+
label.append("● ", style=f"bold {_AMBER}")
|
|
1782
|
+
label.append(f"{name:<8}", style=f"bold {_AMBER}")
|
|
1783
|
+
else:
|
|
1784
|
+
label.append(" ")
|
|
1785
|
+
label.append(f"{name:<8}", style="#b5ae9d")
|
|
1786
|
+
label.append(_EFFORT_DESC.get(value, ""), style="dim")
|
|
1787
|
+
options.append(Option(label, id=name))
|
|
1788
|
+
if chosen:
|
|
1789
|
+
highlight = i
|
|
1790
|
+
listing = self._listing()
|
|
1791
|
+
listing.set_options(options)
|
|
1792
|
+
listing.highlighted = highlight
|
|
1793
|
+
self.display = True
|
|
1794
|
+
|
|
1795
|
+
def hide(self) -> None:
|
|
1796
|
+
self.display = False
|
|
1797
|
+
try:
|
|
1798
|
+
self._listing().clear_options()
|
|
1799
|
+
except Exception:
|
|
1800
|
+
pass
|
|
1801
|
+
|
|
1802
|
+
def confirm(self) -> None:
|
|
1803
|
+
try:
|
|
1804
|
+
self._listing().action_select()
|
|
1805
|
+
except Exception:
|
|
1806
|
+
pass
|
|
1807
|
+
|
|
1808
|
+
def move(self, delta: int) -> None:
|
|
1809
|
+
listing = self._listing()
|
|
1810
|
+
if delta > 0:
|
|
1811
|
+
listing.action_cursor_down()
|
|
1812
|
+
elif delta < 0:
|
|
1813
|
+
listing.action_cursor_up()
|
|
1814
|
+
|
|
1815
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
1816
|
+
event.stop()
|
|
1817
|
+
oid = event.option_id or (event.option.id if event.option else "")
|
|
1818
|
+
self.post_message(self.Picked("" if oid == "default" else oid))
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
class SessionPicker(Vertical):
|
|
1822
|
+
""" /changewin 弹出的窗口列表,选一扇再切。"""
|
|
1823
|
+
|
|
1824
|
+
class Picked(Message):
|
|
1825
|
+
def __init__(self, session_id: str) -> None:
|
|
1826
|
+
super().__init__()
|
|
1827
|
+
self.session_id = session_id
|
|
1828
|
+
|
|
1829
|
+
DEFAULT_CSS = """
|
|
1830
|
+
SessionPicker {
|
|
1831
|
+
width: 1fr;
|
|
1832
|
+
height: auto;
|
|
1833
|
+
max-height: 14;
|
|
1834
|
+
background: ansi_default;
|
|
1835
|
+
display: none;
|
|
1836
|
+
padding: 0 1 1 1;
|
|
1837
|
+
overflow-x: hidden;
|
|
1838
|
+
overflow-y: auto;
|
|
1839
|
+
}
|
|
1840
|
+
SessionPicker #win-head {
|
|
1841
|
+
width: 1fr;
|
|
1842
|
+
height: 1;
|
|
1843
|
+
background: ansi_default;
|
|
1844
|
+
color: grey;
|
|
1845
|
+
text-wrap: nowrap;
|
|
1846
|
+
overflow: hidden;
|
|
1847
|
+
margin-bottom: 0;
|
|
1848
|
+
}
|
|
1849
|
+
SessionPicker OptionList {
|
|
1850
|
+
width: 1fr;
|
|
1851
|
+
height: auto;
|
|
1852
|
+
max-height: 12;
|
|
1853
|
+
background: ansi_default;
|
|
1854
|
+
color: grey;
|
|
1855
|
+
border: none;
|
|
1856
|
+
padding: 0;
|
|
1857
|
+
overflow-x: hidden;
|
|
1858
|
+
}
|
|
1859
|
+
SessionPicker OptionList:focus {
|
|
1860
|
+
background: ansi_default;
|
|
1861
|
+
background-tint: ansi_default;
|
|
1862
|
+
border: none;
|
|
1863
|
+
}
|
|
1864
|
+
SessionPicker OptionList > .option-list--option-highlighted {
|
|
1865
|
+
color: #f0e2c0;
|
|
1866
|
+
background: #4a3f22;
|
|
1867
|
+
text-style: bold;
|
|
1868
|
+
}
|
|
1869
|
+
SessionPicker OptionList:focus > .option-list--option-highlighted {
|
|
1870
|
+
color: #f5e6c4;
|
|
1871
|
+
background: #5a4b28;
|
|
1872
|
+
text-style: bold;
|
|
1873
|
+
}
|
|
1874
|
+
SessionPicker OptionList > .option-list--option-hover {
|
|
1875
|
+
background: #3a3222;
|
|
1876
|
+
color: #f0e2c0;
|
|
1877
|
+
}
|
|
1878
|
+
"""
|
|
1879
|
+
|
|
1880
|
+
def compose(self) -> ComposeResult:
|
|
1881
|
+
yield Static(
|
|
1882
|
+
Text.from_markup(f"[bold {_AMBER}]▎[/]选择窗口 ↑↓ 移动 回车打开 Esc取消"),
|
|
1883
|
+
id="win-head",
|
|
1884
|
+
)
|
|
1885
|
+
yield OptionList(id="win-list", compact=True)
|
|
1886
|
+
|
|
1887
|
+
def set_sessions(
|
|
1888
|
+
self,
|
|
1889
|
+
rows: list[dict],
|
|
1890
|
+
current_id: str,
|
|
1891
|
+
preferred_id: str = "",
|
|
1892
|
+
) -> None:
|
|
1893
|
+
listing = self.query_one("#win-list", OptionList)
|
|
1894
|
+
options: list[Option] = []
|
|
1895
|
+
ids: list[str] = []
|
|
1896
|
+
for row in rows:
|
|
1897
|
+
sid = str(row.get("id") or "")
|
|
1898
|
+
if not sid:
|
|
1899
|
+
continue
|
|
1900
|
+
options.append(Option(_session_prompt(row, current_id), id=sid))
|
|
1901
|
+
ids.append(sid)
|
|
1902
|
+
highlight = 0
|
|
1903
|
+
if preferred_id and preferred_id in ids:
|
|
1904
|
+
highlight = ids.index(preferred_id)
|
|
1905
|
+
else:
|
|
1906
|
+
for i, sid in enumerate(ids):
|
|
1907
|
+
if sid != current_id:
|
|
1908
|
+
highlight = i
|
|
1909
|
+
break
|
|
1910
|
+
if options:
|
|
1911
|
+
listing.set_options(options)
|
|
1912
|
+
listing.highlighted = highlight
|
|
1913
|
+
else:
|
|
1914
|
+
listing.clear_options()
|
|
1915
|
+
self.display = True
|
|
1916
|
+
|
|
1917
|
+
def hide(self) -> None:
|
|
1918
|
+
self.display = False
|
|
1919
|
+
try:
|
|
1920
|
+
self.query_one("#win-list", OptionList).clear_options()
|
|
1921
|
+
except Exception:
|
|
1922
|
+
pass
|
|
1923
|
+
|
|
1924
|
+
def confirm(self) -> None:
|
|
1925
|
+
try:
|
|
1926
|
+
self.query_one("#win-list", OptionList).action_select()
|
|
1927
|
+
except Exception:
|
|
1928
|
+
pass
|
|
1929
|
+
|
|
1930
|
+
def move(self, delta: int) -> None:
|
|
1931
|
+
listing = self.query_one("#win-list", OptionList)
|
|
1932
|
+
if delta > 0:
|
|
1933
|
+
listing.action_cursor_down()
|
|
1934
|
+
elif delta < 0:
|
|
1935
|
+
listing.action_cursor_up()
|
|
1936
|
+
|
|
1937
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
1938
|
+
event.stop()
|
|
1939
|
+
sid = event.option_id or (event.option.id if event.option else "")
|
|
1940
|
+
if sid:
|
|
1941
|
+
self.post_message(self.Picked(sid))
|
|
1942
|
+
|
|
1943
|
+
|
|
1944
|
+
class HistoryPicker(Vertical):
|
|
1945
|
+
""" /history 弹出的回退点,先选提问再选回退代码/对话。"""
|
|
1946
|
+
|
|
1947
|
+
class Picked(Message):
|
|
1948
|
+
def __init__(self, checkpoint_id: str, action: str) -> None:
|
|
1949
|
+
super().__init__()
|
|
1950
|
+
self.checkpoint_id = checkpoint_id
|
|
1951
|
+
self.action = action
|
|
1952
|
+
|
|
1953
|
+
DEFAULT_CSS = """
|
|
1954
|
+
HistoryPicker {
|
|
1955
|
+
width: 1fr;
|
|
1956
|
+
height: auto;
|
|
1957
|
+
max-height: 14;
|
|
1958
|
+
background: ansi_default;
|
|
1959
|
+
display: none;
|
|
1960
|
+
padding: 0 1 1 1;
|
|
1961
|
+
overflow-x: hidden;
|
|
1962
|
+
overflow-y: auto;
|
|
1963
|
+
}
|
|
1964
|
+
HistoryPicker #hist-head {
|
|
1965
|
+
width: 1fr;
|
|
1966
|
+
height: 1;
|
|
1967
|
+
background: ansi_default;
|
|
1968
|
+
color: grey;
|
|
1969
|
+
text-wrap: nowrap;
|
|
1970
|
+
overflow: hidden;
|
|
1971
|
+
}
|
|
1972
|
+
HistoryPicker OptionList {
|
|
1973
|
+
width: 1fr;
|
|
1974
|
+
height: auto;
|
|
1975
|
+
max-height: 12;
|
|
1976
|
+
background: ansi_default;
|
|
1977
|
+
color: grey;
|
|
1978
|
+
border: none;
|
|
1979
|
+
padding: 0;
|
|
1980
|
+
overflow-x: hidden;
|
|
1981
|
+
}
|
|
1982
|
+
HistoryPicker OptionList:focus {
|
|
1983
|
+
background: ansi_default;
|
|
1984
|
+
background-tint: ansi_default;
|
|
1985
|
+
border: none;
|
|
1986
|
+
}
|
|
1987
|
+
HistoryPicker OptionList > .option-list--option-highlighted {
|
|
1988
|
+
color: #f0e2c0;
|
|
1989
|
+
background: #4a3f22;
|
|
1990
|
+
text-style: bold;
|
|
1991
|
+
}
|
|
1992
|
+
HistoryPicker OptionList:focus > .option-list--option-highlighted {
|
|
1993
|
+
color: #f5e6c4;
|
|
1994
|
+
background: #5a4b28;
|
|
1995
|
+
text-style: bold;
|
|
1996
|
+
}
|
|
1997
|
+
HistoryPicker OptionList > .option-list--option-hover {
|
|
1998
|
+
background: #3a3222;
|
|
1999
|
+
color: #f0e2c0;
|
|
2000
|
+
}
|
|
2001
|
+
"""
|
|
2002
|
+
|
|
2003
|
+
def __init__(self, **kwargs) -> None:
|
|
2004
|
+
super().__init__(**kwargs)
|
|
2005
|
+
self._rows: list[dict] = []
|
|
2006
|
+
self._phase = "turns"
|
|
2007
|
+
self._current: dict | None = None
|
|
2008
|
+
|
|
2009
|
+
def compose(self) -> ComposeResult:
|
|
2010
|
+
yield Static(
|
|
2011
|
+
Text.from_markup(f"[bold {_AMBER}]▎[/]选择回退点 ↑↓ 移动 回车 Esc取消"),
|
|
2012
|
+
id="hist-head",
|
|
2013
|
+
)
|
|
2014
|
+
yield OptionList(id="hist-list", compact=True)
|
|
2015
|
+
|
|
2016
|
+
def _listing(self) -> OptionList:
|
|
2017
|
+
return self.query_one("#hist-list", OptionList)
|
|
2018
|
+
|
|
2019
|
+
def _head(self) -> Static:
|
|
2020
|
+
return self.query_one("#hist-head", Static)
|
|
2021
|
+
|
|
2022
|
+
def set_rows(self, rows: list[dict]) -> None:
|
|
2023
|
+
self._rows = list(rows)
|
|
2024
|
+
self._phase = "turns"
|
|
2025
|
+
self._current = None
|
|
2026
|
+
self._render_turns()
|
|
2027
|
+
self.display = True
|
|
2028
|
+
|
|
2029
|
+
def hide(self) -> None:
|
|
2030
|
+
self.display = False
|
|
2031
|
+
self._phase = "turns"
|
|
2032
|
+
self._current = None
|
|
2033
|
+
self._rows = []
|
|
2034
|
+
try:
|
|
2035
|
+
self._listing().clear_options()
|
|
2036
|
+
except Exception:
|
|
2037
|
+
pass
|
|
2038
|
+
|
|
2039
|
+
def confirm(self) -> None:
|
|
2040
|
+
try:
|
|
2041
|
+
self._listing().action_select()
|
|
2042
|
+
except Exception:
|
|
2043
|
+
pass
|
|
2044
|
+
|
|
2045
|
+
def move(self, delta: int) -> None:
|
|
2046
|
+
listing = self._listing()
|
|
2047
|
+
if delta > 0:
|
|
2048
|
+
listing.action_cursor_down()
|
|
2049
|
+
elif delta < 0:
|
|
2050
|
+
listing.action_cursor_up()
|
|
2051
|
+
|
|
2052
|
+
def back_or_close(self) -> bool:
|
|
2053
|
+
"""Esc:动作菜单退回列表,返回 True 表示已处理。"""
|
|
2054
|
+
if self._phase == "actions":
|
|
2055
|
+
self._render_turns()
|
|
2056
|
+
return True
|
|
2057
|
+
return False
|
|
2058
|
+
|
|
2059
|
+
def _render_turns(self) -> None:
|
|
2060
|
+
self._phase = "turns"
|
|
2061
|
+
self._current = None
|
|
2062
|
+
self._head().update("选择回退点 ↑↓ 移动 回车 Esc取消")
|
|
2063
|
+
options: list[Option] = []
|
|
2064
|
+
for row in self._rows:
|
|
2065
|
+
cid = str(row.get("id") or "")
|
|
2066
|
+
if not cid:
|
|
2067
|
+
continue
|
|
2068
|
+
options.append(Option(_history_prompt(row), id=cid))
|
|
2069
|
+
listing = self._listing()
|
|
2070
|
+
if options:
|
|
2071
|
+
listing.set_options(options)
|
|
2072
|
+
listing.highlighted = 0
|
|
2073
|
+
else:
|
|
2074
|
+
listing.clear_options()
|
|
2075
|
+
|
|
2076
|
+
def _render_actions(self, row: dict) -> None:
|
|
2077
|
+
self._phase = "actions"
|
|
2078
|
+
self._current = row
|
|
2079
|
+
title = str(row.get("title") or "这一轮")
|
|
2080
|
+
if len(title) > 24:
|
|
2081
|
+
title = title[:23] + "…"
|
|
2082
|
+
self._head().update(f"回退到「{title}」之前 Esc返回")
|
|
2083
|
+
dirty = int(row.get("later_files") or 0) > 0
|
|
2084
|
+
options: list[Option] = []
|
|
2085
|
+
if dirty:
|
|
2086
|
+
options.append(Option("回退代码和对话", id="both"))
|
|
2087
|
+
options.append(Option("只回退对话,代码不动", id="chat"))
|
|
2088
|
+
options.append(Option(f"只回退代码({row.get('later_files')} 个文件)", id="code"))
|
|
2089
|
+
else:
|
|
2090
|
+
options.append(Option("回退对话(这轮之后没有改文件)", id="chat"))
|
|
2091
|
+
options.append(Option("取消", id="back"))
|
|
2092
|
+
listing = self._listing()
|
|
2093
|
+
listing.set_options(options)
|
|
2094
|
+
listing.highlighted = 0
|
|
2095
|
+
|
|
2096
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
2097
|
+
event.stop()
|
|
2098
|
+
oid = event.option_id or (event.option.id if event.option else "")
|
|
2099
|
+
if not oid:
|
|
2100
|
+
return
|
|
2101
|
+
if self._phase == "turns":
|
|
2102
|
+
hit = next((r for r in self._rows if r.get("id") == oid), None)
|
|
2103
|
+
if hit:
|
|
2104
|
+
self._render_actions(hit)
|
|
2105
|
+
return
|
|
2106
|
+
if oid == "back":
|
|
2107
|
+
self._render_turns()
|
|
2108
|
+
return
|
|
2109
|
+
current = self._current
|
|
2110
|
+
if not current:
|
|
2111
|
+
return
|
|
2112
|
+
self.post_message(self.Picked(str(current["id"]), oid))
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
class PermitScreen(ModalScreen[bool]):
|
|
2116
|
+
"""ask 模式:弹窗让用户允许或拒绝敏感操作。"""
|
|
2117
|
+
|
|
2118
|
+
BINDINGS = [
|
|
2119
|
+
Binding("y", "yes", show=False),
|
|
2120
|
+
Binding("n", "no", show=False),
|
|
2121
|
+
Binding("escape", "no", show=False),
|
|
2122
|
+
]
|
|
2123
|
+
|
|
2124
|
+
DEFAULT_CSS = """
|
|
2125
|
+
PermitScreen {
|
|
2126
|
+
align: center middle;
|
|
2127
|
+
background: ansi_default 40%;
|
|
2128
|
+
}
|
|
2129
|
+
PermitScreen #permit-box {
|
|
2130
|
+
width: 64;
|
|
2131
|
+
max-width: 90%;
|
|
2132
|
+
height: auto;
|
|
2133
|
+
background: ansi_default;
|
|
2134
|
+
border: round #d9a84e;
|
|
2135
|
+
padding: 1 2;
|
|
2136
|
+
}
|
|
2137
|
+
PermitScreen #permit-title {
|
|
2138
|
+
width: 1fr;
|
|
2139
|
+
height: auto;
|
|
2140
|
+
color: #d9a84e;
|
|
2141
|
+
text-style: bold;
|
|
2142
|
+
background: ansi_default;
|
|
2143
|
+
}
|
|
2144
|
+
PermitScreen #permit-body {
|
|
2145
|
+
width: 1fr;
|
|
2146
|
+
height: auto;
|
|
2147
|
+
margin: 1 0;
|
|
2148
|
+
background: ansi_default;
|
|
2149
|
+
text-wrap: wrap;
|
|
2150
|
+
}
|
|
2151
|
+
PermitScreen #permit-actions {
|
|
2152
|
+
width: 1fr;
|
|
2153
|
+
height: auto;
|
|
2154
|
+
align: center middle;
|
|
2155
|
+
background: ansi_default;
|
|
2156
|
+
}
|
|
2157
|
+
PermitScreen Button {
|
|
2158
|
+
min-width: 10;
|
|
2159
|
+
margin: 0 1;
|
|
2160
|
+
background: ansi_default;
|
|
2161
|
+
}
|
|
2162
|
+
"""
|
|
2163
|
+
|
|
2164
|
+
def __init__(self, preview: str, **kwargs) -> None:
|
|
2165
|
+
super().__init__(**kwargs)
|
|
2166
|
+
self._preview = (preview or "").strip() or "(无说明)"
|
|
2167
|
+
|
|
2168
|
+
def compose(self) -> ComposeResult:
|
|
2169
|
+
with Vertical(id="permit-box"):
|
|
2170
|
+
yield Static("允许这次操作?", id="permit-title")
|
|
2171
|
+
yield Static(self._preview, id="permit-body")
|
|
2172
|
+
with Horizontal(id="permit-actions"):
|
|
2173
|
+
yield Button("允许 Y", id="permit-yes")
|
|
2174
|
+
yield Button("拒绝 N", id="permit-no")
|
|
2175
|
+
|
|
2176
|
+
def on_mount(self) -> None:
|
|
2177
|
+
self.query_one("#permit-yes", Button).focus()
|
|
2178
|
+
|
|
2179
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
2180
|
+
event.stop()
|
|
2181
|
+
self.dismiss(event.button.id == "permit-yes")
|
|
2182
|
+
|
|
2183
|
+
def action_yes(self) -> None:
|
|
2184
|
+
self.dismiss(True)
|
|
2185
|
+
|
|
2186
|
+
def action_no(self) -> None:
|
|
2187
|
+
self.dismiss(False)
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
def _history_prompt(row: dict) -> Text:
|
|
2191
|
+
line = Text()
|
|
2192
|
+
title = " ".join(str(row.get("title") or "").split()) or "无标题"
|
|
2193
|
+
if len(title) > 32:
|
|
2194
|
+
title = title[:31] + "…"
|
|
2195
|
+
line.append(title)
|
|
2196
|
+
n = int(row.get("later_files") or 0)
|
|
2197
|
+
if n:
|
|
2198
|
+
line.append(f" {n} 个文件", style="dim")
|
|
2199
|
+
else:
|
|
2200
|
+
line.append(" 无改动", style="dim")
|
|
2201
|
+
line.append(f" {_rel_time(int(row.get('created_at') or 0))}", style="dim")
|
|
2202
|
+
return line
|
|
2203
|
+
|
|
2204
|
+
|
|
2205
|
+
class AgentFooter(Footer):
|
|
2206
|
+
"""底部栏:左边模型名和 token,右边仍是快捷键 / palette。"""
|
|
2207
|
+
|
|
2208
|
+
meta = reactive(Text(""))
|
|
2209
|
+
|
|
2210
|
+
def compose(self) -> ComposeResult:
|
|
2211
|
+
yield Label(self.meta, id="footer-meta")
|
|
2212
|
+
yield from super().compose()
|
|
2213
|
+
|
|
2214
|
+
def watch_meta(self, value: str) -> None:
|
|
2215
|
+
try:
|
|
2216
|
+
self.query_one("#footer-meta", Label).update(value)
|
|
2217
|
+
except Exception:
|
|
2218
|
+
pass
|
|
2219
|
+
|
|
2220
|
+
|
|
2221
|
+
class AgentApp(App):
|
|
2222
|
+
"""一个聊天风格的终端 agent 外壳。"""
|
|
2223
|
+
|
|
2224
|
+
# 窗口标题,显示在 Header 里
|
|
2225
|
+
TITLE = CONFIG.get("name", "Agent TUI")
|
|
2226
|
+
# agent 版本号,来自 config.json,欢迎语里会显示
|
|
2227
|
+
VERSION = CONFIG.get("version", "0.0.0")
|
|
2228
|
+
|
|
2229
|
+
BINDINGS = [
|
|
2230
|
+
Binding("escape", "interrupt", "打断", show=False, priority=True),
|
|
2231
|
+
Binding("ctrl+c", "interrupt_or_quit", "打断", show=False, priority=True),
|
|
2232
|
+
Binding("ctrl+m", "pick_model", "切模型", show=True, priority=True),
|
|
2233
|
+
Binding("tab", "slash_complete", show=False, priority=True),
|
|
2234
|
+
Binding("down", "slash_next", show=False, priority=True),
|
|
2235
|
+
Binding("up", "slash_prev", show=False, priority=True),
|
|
2236
|
+
Binding("enter", "confirm_overlay", show=False, priority=True),
|
|
2237
|
+
Binding("shift+tab", "toggle_perm_mode", show=False, priority=True),
|
|
2238
|
+
]
|
|
2239
|
+
|
|
2240
|
+
# 内联样式表。Textual 用 CSS 描述布局和外观。
|
|
2241
|
+
# 颜色用十六进制值或内置色名(grey/white 等)都可以。
|
|
2242
|
+
#
|
|
2243
|
+
# 背景必须用 ansi_default,不能用 transparent:
|
|
2244
|
+
# transparent 的 RGB 是 (0,0,0)、alpha 是 0;Textual 画底时会丢掉 alpha,
|
|
2245
|
+
# 变成纯黑 #000000,于是日志区四周、输入框左右会出现一圈黑边。
|
|
2246
|
+
# ansi_default 发给终端的是"用你自己的底色"(SGR 49),终端透明底才能透出来。
|
|
2247
|
+
CSS = """
|
|
2248
|
+
App {
|
|
2249
|
+
background: ansi_default;
|
|
2250
|
+
}
|
|
2251
|
+
Screen {
|
|
2252
|
+
background: ansi_default;
|
|
2253
|
+
}
|
|
2254
|
+
#composer {
|
|
2255
|
+
dock: bottom;
|
|
2256
|
+
height: auto;
|
|
2257
|
+
background: ansi_default;
|
|
2258
|
+
margin-bottom: 1; /* 给底部 Footer 留一行 */
|
|
2259
|
+
}
|
|
2260
|
+
#prompt {
|
|
2261
|
+
background: ansi_default;
|
|
2262
|
+
padding: 0 1; /* 覆盖默认 padding: 0 2,否则左右各空 2 格,透出黑底 */
|
|
2263
|
+
margin-bottom: 0;
|
|
2264
|
+
border-top: tall grey; /* 上边框:灰色 */
|
|
2265
|
+
border-bottom: tall grey; /* 下边框:灰色 */
|
|
2266
|
+
border-left: none; /* 去掉左边竖线 */
|
|
2267
|
+
border-right: none; /* 去掉右边竖线 */
|
|
2268
|
+
}
|
|
2269
|
+
#prompt:focus {
|
|
2270
|
+
background: ansi_default;
|
|
2271
|
+
background-tint: ansi_default; /* 关掉默认的 $foreground 5% 着色,否则聚焦时又变成实心色块 */
|
|
2272
|
+
border-top: tall grey; /* 聚焦时同样只留上下两条灰线 */
|
|
2273
|
+
border-bottom: tall grey;
|
|
2274
|
+
border-left: none;
|
|
2275
|
+
border-right: none;
|
|
2276
|
+
}
|
|
2277
|
+
ChatLog {
|
|
2278
|
+
background: ansi_default;
|
|
2279
|
+
border: none;
|
|
2280
|
+
padding: 1 1;
|
|
2281
|
+
overflow-x: hidden;
|
|
2282
|
+
overflow-y: auto;
|
|
2283
|
+
scrollbar-background: ansi_default;
|
|
2284
|
+
scrollbar-color: grey;
|
|
2285
|
+
scrollbar-size-vertical: 1;
|
|
2286
|
+
scrollbar-corner-color: ansi_default;
|
|
2287
|
+
}
|
|
2288
|
+
ChatLog:focus {
|
|
2289
|
+
background: ansi_default;
|
|
2290
|
+
background-tint: ansi_default;
|
|
2291
|
+
}
|
|
2292
|
+
ChatLog Collapsible {
|
|
2293
|
+
width: 1fr;
|
|
2294
|
+
height: auto;
|
|
2295
|
+
background: ansi_default;
|
|
2296
|
+
border: none;
|
|
2297
|
+
padding: 0;
|
|
2298
|
+
}
|
|
2299
|
+
ChatLog Collapsible.-collapsed Contents {
|
|
2300
|
+
display: none;
|
|
2301
|
+
}
|
|
2302
|
+
ChatLog CollapsibleTitle {
|
|
2303
|
+
width: auto;
|
|
2304
|
+
background: ansi_default;
|
|
2305
|
+
}
|
|
2306
|
+
Footer {
|
|
2307
|
+
background: ansi_default; /* 底部状态栏跟终端底色走,不留实心色块 */
|
|
2308
|
+
}
|
|
2309
|
+
FooterKey {
|
|
2310
|
+
background: ansi_default;
|
|
2311
|
+
}
|
|
2312
|
+
#footer-meta {
|
|
2313
|
+
width: auto;
|
|
2314
|
+
height: 1;
|
|
2315
|
+
padding: 0 1;
|
|
2316
|
+
color: grey;
|
|
2317
|
+
background: ansi_default;
|
|
2318
|
+
text-wrap: nowrap;
|
|
2319
|
+
}
|
|
2320
|
+
Hero {
|
|
2321
|
+
width: auto;
|
|
2322
|
+
height: auto;
|
|
2323
|
+
background: ansi_default;
|
|
2324
|
+
padding: 0 0 1 0;
|
|
2325
|
+
overflow-x: hidden;
|
|
2326
|
+
overflow-y: hidden;
|
|
2327
|
+
}
|
|
2328
|
+
SessionPicker {
|
|
2329
|
+
width: 1fr;
|
|
2330
|
+
height: auto;
|
|
2331
|
+
max-height: 14;
|
|
2332
|
+
background: ansi_default;
|
|
2333
|
+
display: none;
|
|
2334
|
+
padding: 0 1 1 1;
|
|
2335
|
+
}
|
|
2336
|
+
HistoryPicker {
|
|
2337
|
+
width: 1fr;
|
|
2338
|
+
height: auto;
|
|
2339
|
+
max-height: 14;
|
|
2340
|
+
background: ansi_default;
|
|
2341
|
+
display: none;
|
|
2342
|
+
padding: 0 1 1 1;
|
|
2343
|
+
}
|
|
2344
|
+
#run-status {
|
|
2345
|
+
width: 1fr;
|
|
2346
|
+
height: 1;
|
|
2347
|
+
background: ansi_default;
|
|
2348
|
+
display: none;
|
|
2349
|
+
padding: 0 1;
|
|
2350
|
+
text-wrap: nowrap;
|
|
2351
|
+
overflow-x: hidden;
|
|
2352
|
+
overflow-y: hidden;
|
|
2353
|
+
}
|
|
2354
|
+
"""
|
|
2355
|
+
|
|
2356
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
2357
|
+
# 必须开原生 ANSI 色:默认主题会把 ansi_default 转成 Monokai 的 #0c0c0c,看起来仍是黑底。
|
|
2358
|
+
kwargs.setdefault("ansi_color", True)
|
|
2359
|
+
super().__init__(*args, **kwargs)
|
|
2360
|
+
self._committed_in = 0
|
|
2361
|
+
self._committed_out = 0
|
|
2362
|
+
self._committed_cache = 0
|
|
2363
|
+
self._live_in = 0
|
|
2364
|
+
self._live_out = 0
|
|
2365
|
+
self._live_cache = 0
|
|
2366
|
+
# 本轮已收到的官方用量累加(多轮工具调用每轮都给一份 usage)
|
|
2367
|
+
self._usage_in = 0
|
|
2368
|
+
self._usage_out = 0
|
|
2369
|
+
self._usage_cache = 0
|
|
2370
|
+
self._got_official = False
|
|
2371
|
+
self._retry_attempt = 0
|
|
2372
|
+
self._retry_total = 0
|
|
2373
|
+
self._pending_images: list[tuple[str, str]] = []
|
|
2374
|
+
self._current_prompt = ""
|
|
2375
|
+
self._waiting = False
|
|
2376
|
+
self._wait_timer: Timer | None = None
|
|
2377
|
+
self._wait_t0 = 0.0
|
|
2378
|
+
self._active_turn: AgentTurn | None = None
|
|
2379
|
+
self._thinking_text = ""
|
|
2380
|
+
self._answer_text = ""
|
|
2381
|
+
self._store = Store()
|
|
2382
|
+
self._session_id = self._store.open_or_create()
|
|
2383
|
+
# 上次跑到一半退过,也要把库里记的用量接上,底部 ↑↓ 才和磁盘会话对得上
|
|
2384
|
+
saved_in, saved_out, saved_cache = self._store.usage_of(self._session_id)
|
|
2385
|
+
self._committed_in = saved_in
|
|
2386
|
+
self._committed_out = saved_out
|
|
2387
|
+
self._committed_cache = saved_cache
|
|
2388
|
+
self._ticket_seq = 0
|
|
2389
|
+
self._busy = False
|
|
2390
|
+
self._hold_queue = False
|
|
2391
|
+
self._pending_prompt: str | None = None
|
|
2392
|
+
self._pending_model: str = "" # 模型选择器里选中、等待选推理等级的模型
|
|
2393
|
+
self._session_stack: list[str] = []
|
|
2394
|
+
self._checkpoint_id: str | None = None
|
|
2395
|
+
self._run_id = 0
|
|
2396
|
+
self._writes_frozen = False
|
|
2397
|
+
self._perm_mode = get_permission_mode()
|
|
2398
|
+
self._permit_future: asyncio.Future | None = None
|
|
2399
|
+
self._input_history: list[str] = []
|
|
2400
|
+
self._input_hist_idx = -1
|
|
2401
|
+
self._input_draft = ""
|
|
2402
|
+
self._status_kind = "idle"
|
|
2403
|
+
self._status_label = ""
|
|
2404
|
+
|
|
2405
|
+
# compose() 返回界面上所有部件,从上到下、按 yield 顺序排列。
|
|
2406
|
+
# 部件可以带 id(如 id="output"),之后用 query_one("#output") 精准拿到它。
|
|
2407
|
+
def compose(self) -> ComposeResult:
|
|
2408
|
+
yield ChatLog(id="output") # 欢迎横幅在对话流里,往下滚就看不见
|
|
2409
|
+
with Vertical(id="composer"):
|
|
2410
|
+
yield TodoPanel(id="todo-panel")
|
|
2411
|
+
yield QueueList(id="queue")
|
|
2412
|
+
yield CommandHints(id="slash-hints")
|
|
2413
|
+
yield FileHints(id="file-hints")
|
|
2414
|
+
yield SessionPicker(id="win-picker")
|
|
2415
|
+
yield ModelPicker(id="model-picker")
|
|
2416
|
+
yield EffortPicker(id="effort-picker")
|
|
2417
|
+
yield HistoryPicker(id="hist-picker")
|
|
2418
|
+
yield RunStatus(id="run-status")
|
|
2419
|
+
yield Input(placeholder="Ask the Lcode something, then Enter, bro.", id="prompt")
|
|
2420
|
+
yield AgentFooter() # 底部:模型 + token,右边 palette
|
|
2421
|
+
|
|
2422
|
+
# on_mount 在界面第一次渲染完成后调用,是初始化的好地方。
|
|
2423
|
+
def on_mount(self) -> None:
|
|
2424
|
+
# 把键盘焦点放到输入框上,这样打开就能直接打字
|
|
2425
|
+
self.query_one(Input).focus()
|
|
2426
|
+
set_write_hook(self._on_file_write)
|
|
2427
|
+
set_todo_hook(self._on_todos_changed)
|
|
2428
|
+
todos = self._store.load_todos(str(Path.cwd()))
|
|
2429
|
+
if todos:
|
|
2430
|
+
set_todos(todos) # 会通过 hook 画到面板上
|
|
2431
|
+
self._refresh_footer_meta()
|
|
2432
|
+
self._restore_history()
|
|
2433
|
+
self._load_model_limits()
|
|
2434
|
+
|
|
2435
|
+
def _on_todos_changed(self, todos: list) -> None:
|
|
2436
|
+
"""todo_write 变化:落库 + 刷新面板。"""
|
|
2437
|
+
try:
|
|
2438
|
+
self._store.save_todos(str(Path.cwd()), todos)
|
|
2439
|
+
except Exception:
|
|
2440
|
+
pass
|
|
2441
|
+
try:
|
|
2442
|
+
self.query_one("#todo-panel", TodoPanel).set_todos(todos)
|
|
2443
|
+
except Exception:
|
|
2444
|
+
pass
|
|
2445
|
+
|
|
2446
|
+
@work
|
|
2447
|
+
async def _load_model_limits(self) -> None:
|
|
2448
|
+
"""按模型 context_length 的百分比自动 compact,和 Grok 一样。"""
|
|
2449
|
+
info = await fetch_model_limits()
|
|
2450
|
+
window = int(info.get("context_length") or info.get("input_budget") or 128_000)
|
|
2451
|
+
try:
|
|
2452
|
+
percent = int(CONFIG.get("autoCompactPercent") or 85)
|
|
2453
|
+
except (TypeError, ValueError):
|
|
2454
|
+
percent = 85
|
|
2455
|
+
self._store.apply_window(window, percent)
|
|
2456
|
+
self._refresh_footer_meta()
|
|
2457
|
+
|
|
2458
|
+
async def _mount_history_messages(self, chat: ChatLog) -> None:
|
|
2459
|
+
"""把 session 的对话挂回画面:用户句、思考、正文,连带工具卡片一起。"""
|
|
2460
|
+
turn: AgentTurn | None = None
|
|
2461
|
+
# call_id -> (卡片, 工具名),等后面的 tool 结果消息来收尾
|
|
2462
|
+
pending: dict[str, tuple[ToolCall, str]] = {}
|
|
2463
|
+
for msg in self._store.list_messages(self._session_id):
|
|
2464
|
+
meta = _msg_meta(msg.meta)
|
|
2465
|
+
if msg.role == "user":
|
|
2466
|
+
turn = None
|
|
2467
|
+
pending = {}
|
|
2468
|
+
await chat.mount(UserTurn(msg.content))
|
|
2469
|
+
names = meta.get("images") or []
|
|
2470
|
+
if names:
|
|
2471
|
+
shown = "、".join(str(n) for n in names[:6])
|
|
2472
|
+
extra = f" 等 {len(names)} 张" if len(names) > 6 else ""
|
|
2473
|
+
await chat.mount(Static(Text(f" 🖼 {shown}{extra}", style="dim")))
|
|
2474
|
+
elif msg.role == "assistant":
|
|
2475
|
+
calls = meta.get("tool_calls") or []
|
|
2476
|
+
if calls:
|
|
2477
|
+
if turn is None:
|
|
2478
|
+
turn = AgentTurn()
|
|
2479
|
+
await chat.mount(turn)
|
|
2480
|
+
pending = {}
|
|
2481
|
+
for i, call in enumerate(calls):
|
|
2482
|
+
if not isinstance(call, dict):
|
|
2483
|
+
continue
|
|
2484
|
+
fn = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
2485
|
+
name = str(call.get("name") or fn.get("name") or "")
|
|
2486
|
+
raw = str(fn.get("arguments") or call.get("arguments") or "{}")
|
|
2487
|
+
args = parse_tool_arguments(raw)
|
|
2488
|
+
title = tool_title(name, args) if name else f"tool {i}"
|
|
2489
|
+
card = await turn.begin_tool(title, name=name)
|
|
2490
|
+
card.stream_preview(name, raw)
|
|
2491
|
+
cid = str(call.get("id") or fn.get("id") or "")
|
|
2492
|
+
if cid:
|
|
2493
|
+
pending[cid] = (card, name)
|
|
2494
|
+
elif msg.content.strip() or msg.thinking.strip():
|
|
2495
|
+
if turn is None:
|
|
2496
|
+
turn = AgentTurn()
|
|
2497
|
+
await chat.mount(turn)
|
|
2498
|
+
turn.show_stream(
|
|
2499
|
+
msg.thinking,
|
|
2500
|
+
msg.content,
|
|
2501
|
+
elapsed=0.0,
|
|
2502
|
+
out_tokens=_est_tokens(msg.thinking),
|
|
2503
|
+
final=True,
|
|
2504
|
+
)
|
|
2505
|
+
turn._thinking_cache = msg.thinking
|
|
2506
|
+
turn = None
|
|
2507
|
+
elif msg.role == "tool":
|
|
2508
|
+
hit = pending.get(str(meta.get("tool_call_id") or ""))
|
|
2509
|
+
if hit is not None:
|
|
2510
|
+
card, name = hit
|
|
2511
|
+
card.finish(name, msg.content)
|
|
2512
|
+
|
|
2513
|
+
@work
|
|
2514
|
+
async def _restore_history(self) -> None:
|
|
2515
|
+
"""把当前 session 里已有的对话挂回画面。"""
|
|
2516
|
+
await self._mount_history_messages(self.query_one(ChatLog))
|
|
2517
|
+
self._reload_input_history()
|
|
2518
|
+
self._stick_bottom()
|
|
2519
|
+
|
|
2520
|
+
# 事件处理:方法名遵循 on_<部件类型>_<事件名> 的约定,Textual 会自动绑定。
|
|
2521
|
+
# Input.Submitted 表示用户在输入框按了回车。
|
|
2522
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
2523
|
+
prompt = event.value.strip() # event.value 是输入框当前内容
|
|
2524
|
+
if self._file_hints_open():
|
|
2525
|
+
# @补全开着,回车先选中高亮的文件;补完带空格,再按回车才发送
|
|
2526
|
+
self._complete_at()
|
|
2527
|
+
return
|
|
2528
|
+
if self._slash_open() and " " not in prompt:
|
|
2529
|
+
picked = self._slash_menu().current_cmd()
|
|
2530
|
+
if picked:
|
|
2531
|
+
prompt = picked
|
|
2532
|
+
if not prompt: # 空输入直接忽略
|
|
2533
|
+
return
|
|
2534
|
+
event.input.value = "" # 清空输入框,准备下一次输入
|
|
2535
|
+
self._hide_slash_hints()
|
|
2536
|
+
self._hide_picker()
|
|
2537
|
+
self._hide_hist()
|
|
2538
|
+
self._hide_model_picker()
|
|
2539
|
+
self._hide_effort_picker()
|
|
2540
|
+
if prompt.startswith("/"):
|
|
2541
|
+
self._slash(prompt)
|
|
2542
|
+
return
|
|
2543
|
+
self._remember_prompt(prompt)
|
|
2544
|
+
if self._is_generating():
|
|
2545
|
+
self._enqueue(prompt)
|
|
2546
|
+
return
|
|
2547
|
+
self._current_prompt = prompt
|
|
2548
|
+
self._begin_turn(prompt)
|
|
2549
|
+
|
|
2550
|
+
@work
|
|
2551
|
+
async def _slash(self, raw: str) -> None:
|
|
2552
|
+
parts = raw.strip().split(None, 1)
|
|
2553
|
+
cmd = parts[0].lower()
|
|
2554
|
+
arg = parts[1] if len(parts) > 1 else ""
|
|
2555
|
+
if cmd in ("/new", "/clear"):
|
|
2556
|
+
self._run_id += 1
|
|
2557
|
+
self._writes_frozen = True
|
|
2558
|
+
self._checkpoint_id = None
|
|
2559
|
+
if self._is_generating():
|
|
2560
|
+
self.action_interrupt()
|
|
2561
|
+
self._session_stack.append(self._session_id)
|
|
2562
|
+
self._session_id = self._store.create_session()
|
|
2563
|
+
self._pending_images = []
|
|
2564
|
+
self._committed_in = 0
|
|
2565
|
+
self._committed_out = 0
|
|
2566
|
+
self._committed_cache = 0
|
|
2567
|
+
self._live_in = 0
|
|
2568
|
+
self._live_out = 0
|
|
2569
|
+
self._live_cache = 0
|
|
2570
|
+
await self._reload_session_view()
|
|
2571
|
+
self._refresh_footer_meta()
|
|
2572
|
+
await self._note("新窗口已打开。/changewin 可选择历史窗口。")
|
|
2573
|
+
return
|
|
2574
|
+
if cmd in ("/changewin", "/change-win", "/win"):
|
|
2575
|
+
if arg.strip():
|
|
2576
|
+
target, err = self._resolve_session_id(arg.strip())
|
|
2577
|
+
if not target:
|
|
2578
|
+
await self._note(err or "找不到窗口。")
|
|
2579
|
+
return
|
|
2580
|
+
await self._switch_window(target)
|
|
2581
|
+
return
|
|
2582
|
+
rows = self._store.list_sessions_for_project()
|
|
2583
|
+
if not rows:
|
|
2584
|
+
await self._note("没有可切换的历史窗口。")
|
|
2585
|
+
return
|
|
2586
|
+
self._show_session_picker(rows)
|
|
2587
|
+
return
|
|
2588
|
+
if cmd in ("/zip", "/compact"):
|
|
2589
|
+
await self._note("正在压缩上下文…")
|
|
2590
|
+
await prepare_context(self._store, self._session_id, force=True, hint=arg)
|
|
2591
|
+
used = self._occupancy_tokens()
|
|
2592
|
+
await self._note(f"压缩完成,当前带上约 {fmt_tok(used)} token。")
|
|
2593
|
+
self._refresh_footer_meta()
|
|
2594
|
+
return
|
|
2595
|
+
if cmd in ("/history", "/rewind", "/undo"):
|
|
2596
|
+
rows = self._store.list_checkpoints(self._session_id)
|
|
2597
|
+
if not rows:
|
|
2598
|
+
await self._note("还没有可回退的提问。发一条消息后才会有快照。")
|
|
2599
|
+
return
|
|
2600
|
+
self._show_history_picker(rows)
|
|
2601
|
+
return
|
|
2602
|
+
if cmd in ("/model",):
|
|
2603
|
+
models = self._model_list()
|
|
2604
|
+
arg = arg.strip()
|
|
2605
|
+
if not arg:
|
|
2606
|
+
# 弹出模型列表,↑↓ 回车切换
|
|
2607
|
+
self._show_model_picker()
|
|
2608
|
+
return
|
|
2609
|
+
if arg.isdigit() and 1 <= int(arg) <= len(models):
|
|
2610
|
+
name = models[int(arg) - 1]
|
|
2611
|
+
else:
|
|
2612
|
+
name = arg # 直接给 id,不在列表里也允许切
|
|
2613
|
+
if name == str(CONFIG.get("model") or ""):
|
|
2614
|
+
await self._note(f"当前就是这个模型: {name}")
|
|
2615
|
+
return
|
|
2616
|
+
self._switch_model(name)
|
|
2617
|
+
await self._note(f"已切到模型 {name},写回 ~/.lcode/config.json。")
|
|
2618
|
+
return
|
|
2619
|
+
if cmd in ("/effort", "/reasoning"):
|
|
2620
|
+
arg2 = arg.strip().lower()
|
|
2621
|
+
if arg2 in ("low", "medium", "high", "minimal", "default", "none"):
|
|
2622
|
+
picked = "" if arg2 in ("default", "none") else arg2
|
|
2623
|
+
if picked != self._current_effort():
|
|
2624
|
+
set_reasoning_effort(picked)
|
|
2625
|
+
CONFIG["reasoningEffort"] = picked
|
|
2626
|
+
await self._note(
|
|
2627
|
+
f"reasoning effort: {picked or 'default'}{'(不发参数)' if not picked else ''}。"
|
|
2628
|
+
)
|
|
2629
|
+
return
|
|
2630
|
+
self._open_effort_picker(str(CONFIG.get("model") or ""))
|
|
2631
|
+
return
|
|
2632
|
+
if cmd in ("/rename",):
|
|
2633
|
+
if not arg.strip():
|
|
2634
|
+
await self._note("用法: /rename <新标题>")
|
|
2635
|
+
return
|
|
2636
|
+
self._store.rename_session(self._session_id, arg)
|
|
2637
|
+
await self._note(f"已改名为「{' '.join(arg.split())[:40]}」。")
|
|
2638
|
+
return
|
|
2639
|
+
if cmd in ("/delwin",):
|
|
2640
|
+
target, err = self._resolve_session_id(arg.strip())
|
|
2641
|
+
if not target:
|
|
2642
|
+
await self._note(err or "用法: /delwin <id前缀或标题>")
|
|
2643
|
+
return
|
|
2644
|
+
if target == self._session_id:
|
|
2645
|
+
await self._note("当前窗口不能删,先 /changewin 切走。")
|
|
2646
|
+
return
|
|
2647
|
+
self._store.delete_session(target)
|
|
2648
|
+
await self._note(f"已删除窗口 {target[:8]}。")
|
|
2649
|
+
return
|
|
2650
|
+
if cmd in ("/export", "/save"):
|
|
2651
|
+
path = await self._export_markdown(arg.strip())
|
|
2652
|
+
await self._note(f"已导出: {path}" if path else "导出失败。")
|
|
2653
|
+
return
|
|
2654
|
+
if cmd in ("/img", "/image", "/pic"):
|
|
2655
|
+
await self._attach_image(arg.strip())
|
|
2656
|
+
return
|
|
2657
|
+
if cmd == "/ask":
|
|
2658
|
+
self._set_perm_mode("ask")
|
|
2659
|
+
await self._note("已切到 ask:改文件和终端会先问你。Shift+Tab 也可切换。")
|
|
2660
|
+
return
|
|
2661
|
+
if cmd == "/pass":
|
|
2662
|
+
self._set_perm_mode("pass")
|
|
2663
|
+
await self._note("已切到 pass:一条龙,不再询问。Shift+Tab 也可切换。")
|
|
2664
|
+
return
|
|
2665
|
+
await self._note(
|
|
2666
|
+
"未知命令。可用: /new /changewin /zip /history /model /rename /delwin /export /img /ask /pass"
|
|
2667
|
+
)
|
|
2668
|
+
|
|
2669
|
+
async def _export_markdown(self, raw_name: str) -> str:
|
|
2670
|
+
"""把当前窗口的对话写成 Markdown;返回文件路径,空串表示失败。"""
|
|
2671
|
+
try:
|
|
2672
|
+
msgs = self._store.list_messages(self._session_id)
|
|
2673
|
+
except Exception:
|
|
2674
|
+
return ""
|
|
2675
|
+
name = re.sub(r'[\\/:*?"<>|]+', "-", raw_name) if raw_name else ""
|
|
2676
|
+
if not name:
|
|
2677
|
+
title = " ".join(
|
|
2678
|
+
str(next((r["title"] for r in self._store.list_sessions_for_project() if r["id"] == self._session_id), "") or "").split()
|
|
2679
|
+
)[:24] or self._session_id[:8]
|
|
2680
|
+
name = f"lcode-{title}-{time.strftime('%m%d-%H%M%S')}"
|
|
2681
|
+
if not name.endswith(".md"):
|
|
2682
|
+
name += ".md"
|
|
2683
|
+
dest = Path.cwd() / name
|
|
2684
|
+
lines = [f"# Lcode 会话导出 · {self._session_id[:8]}", ""]
|
|
2685
|
+
for msg in msgs:
|
|
2686
|
+
meta = _msg_meta(msg.meta)
|
|
2687
|
+
if msg.role == "user":
|
|
2688
|
+
lines += ["## 您", "", msg.content, ""]
|
|
2689
|
+
elif msg.role == "assistant":
|
|
2690
|
+
if msg.thinking.strip():
|
|
2691
|
+
lines += ["<details><summary>思考</summary>", "", msg.thinking, "", "</details>", ""]
|
|
2692
|
+
if msg.content.strip():
|
|
2693
|
+
lines += [msg.content, ""]
|
|
2694
|
+
calls = meta.get("tool_calls") or []
|
|
2695
|
+
for call in calls:
|
|
2696
|
+
fn = call.get("function") if isinstance(call.get("function"), dict) else {}
|
|
2697
|
+
args = parse_tool_arguments(str(fn.get("arguments") or "{}"))
|
|
2698
|
+
lines += [f"- 调用 `{tool_title(str(fn.get('name') or ''), args)}`"]
|
|
2699
|
+
if calls:
|
|
2700
|
+
lines.append("")
|
|
2701
|
+
elif msg.role == "tool":
|
|
2702
|
+
first = " ".join(msg.content.split())[:200]
|
|
2703
|
+
lines += [f"> 工具结果: {first}", ""]
|
|
2704
|
+
try:
|
|
2705
|
+
dest.write_text("\n".join(lines), encoding="utf-8")
|
|
2706
|
+
except OSError:
|
|
2707
|
+
return ""
|
|
2708
|
+
return str(dest)
|
|
2709
|
+
|
|
2710
|
+
async def _attach_image(self, raw_path: str) -> None:
|
|
2711
|
+
"""/img:读图片转 base64,随下一条消息发出去;只存名字进磁盘。"""
|
|
2712
|
+
if not raw_path:
|
|
2713
|
+
await self._note("用法: /img <图片路径>,可连用多次附加多张。")
|
|
2714
|
+
return
|
|
2715
|
+
path = Path(raw_path)
|
|
2716
|
+
if not path.is_absolute():
|
|
2717
|
+
path = Path.cwd() / path
|
|
2718
|
+
path = path.resolve()
|
|
2719
|
+
suffix = path.suffix.lower()
|
|
2720
|
+
if suffix not in _IMAGE_SUFFIXES or not path.is_file():
|
|
2721
|
+
await self._note(f"不是支持的图片文件: {raw_path}")
|
|
2722
|
+
return
|
|
2723
|
+
try:
|
|
2724
|
+
data = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
2725
|
+
except OSError as exc:
|
|
2726
|
+
await self._note(f"读不出来: {exc}")
|
|
2727
|
+
return
|
|
2728
|
+
if not hasattr(self, "_pending_images"):
|
|
2729
|
+
self._pending_images = []
|
|
2730
|
+
self._pending_images.append((path.name, f"data:{_MIME[suffix]};base64,{data}"))
|
|
2731
|
+
await self._note(
|
|
2732
|
+
f"已附加 {path.name}(共 {len(self._pending_images)} 张),随下一条消息发送。"
|
|
2733
|
+
)
|
|
2734
|
+
|
|
2735
|
+
async def _note(self, text: str) -> None:
|
|
2736
|
+
chat = self.query_one(ChatLog)
|
|
2737
|
+
await chat.mount(Static(Text(text, style="dim")))
|
|
2738
|
+
self._stick_bottom()
|
|
2739
|
+
|
|
2740
|
+
async def _reload_session_view(self) -> None:
|
|
2741
|
+
chat = self.query_one(ChatLog)
|
|
2742
|
+
for child in list(chat.children):
|
|
2743
|
+
if getattr(child, "id", None) == "hero":
|
|
2744
|
+
continue
|
|
2745
|
+
await child.remove()
|
|
2746
|
+
await self._mount_history_messages(chat)
|
|
2747
|
+
self._reload_input_history()
|
|
2748
|
+
self._stick_bottom()
|
|
2749
|
+
|
|
2750
|
+
def _reload_input_history(self) -> None:
|
|
2751
|
+
self._input_history = [
|
|
2752
|
+
msg.content
|
|
2753
|
+
for msg in self._store.list_messages(self._session_id)
|
|
2754
|
+
if msg.role == "user" and msg.content.strip()
|
|
2755
|
+
]
|
|
2756
|
+
if len(self._input_history) > 200:
|
|
2757
|
+
self._input_history = self._input_history[-200:]
|
|
2758
|
+
self._input_hist_idx = -1
|
|
2759
|
+
self._input_draft = ""
|
|
2760
|
+
|
|
2761
|
+
def _remember_prompt(self, text: str) -> None:
|
|
2762
|
+
text = text.strip()
|
|
2763
|
+
if not text:
|
|
2764
|
+
return
|
|
2765
|
+
if not self._input_history or self._input_history[-1] != text:
|
|
2766
|
+
self._input_history.append(text)
|
|
2767
|
+
if len(self._input_history) > 200:
|
|
2768
|
+
self._input_history = self._input_history[-200:]
|
|
2769
|
+
self._input_hist_idx = -1
|
|
2770
|
+
self._input_draft = ""
|
|
2771
|
+
|
|
2772
|
+
def _recall_prompt(self, delta: int) -> None:
|
|
2773
|
+
"""delta < 0 更早, > 0 更新。"""
|
|
2774
|
+
hist = self._input_history
|
|
2775
|
+
try:
|
|
2776
|
+
field = self.query_one("#prompt", Input)
|
|
2777
|
+
except Exception:
|
|
2778
|
+
return
|
|
2779
|
+
if self._input_hist_idx < 0:
|
|
2780
|
+
if delta >= 0 or not hist:
|
|
2781
|
+
return
|
|
2782
|
+
self._input_draft = field.value
|
|
2783
|
+
self._input_hist_idx = len(hist) - 1
|
|
2784
|
+
else:
|
|
2785
|
+
nxt = self._input_hist_idx + delta
|
|
2786
|
+
if nxt >= len(hist):
|
|
2787
|
+
self._input_hist_idx = -1
|
|
2788
|
+
field.value = self._input_draft
|
|
2789
|
+
field.cursor_position = len(field.value)
|
|
2790
|
+
return
|
|
2791
|
+
if nxt < 0:
|
|
2792
|
+
nxt = 0
|
|
2793
|
+
self._input_hist_idx = nxt
|
|
2794
|
+
field.value = hist[self._input_hist_idx]
|
|
2795
|
+
field.cursor_position = len(field.value)
|
|
2796
|
+
|
|
2797
|
+
def _enqueue(self, text: str) -> None:
|
|
2798
|
+
self._ticket_seq += 1
|
|
2799
|
+
self.query_one(QueueList).enqueue(self._ticket_seq, text)
|
|
2800
|
+
|
|
2801
|
+
def _kickoff(self, text: str) -> None:
|
|
2802
|
+
self._current_prompt = text
|
|
2803
|
+
self._begin_turn(text)
|
|
2804
|
+
|
|
2805
|
+
def _send_next_queued(self) -> None:
|
|
2806
|
+
if self._hold_queue or self._is_generating():
|
|
2807
|
+
return
|
|
2808
|
+
text = self.query_one(QueueList).pop_front()
|
|
2809
|
+
if not text:
|
|
2810
|
+
return
|
|
2811
|
+
self._kickoff(text)
|
|
2812
|
+
|
|
2813
|
+
def force_send_queued(self, ticket_id: int) -> None:
|
|
2814
|
+
"""队列上点发送:打断当前回答,立刻发出这一条。"""
|
|
2815
|
+
text = self.query_one(QueueList).take(ticket_id)
|
|
2816
|
+
if not text:
|
|
2817
|
+
return
|
|
2818
|
+
if not self._is_generating():
|
|
2819
|
+
self._hold_queue = False
|
|
2820
|
+
self._pending_prompt = None
|
|
2821
|
+
self._kickoff(text)
|
|
2822
|
+
return
|
|
2823
|
+
self._pending_prompt = text
|
|
2824
|
+
self._hold_queue = True
|
|
2825
|
+
self.action_interrupt()
|
|
2826
|
+
|
|
2827
|
+
def on_queue_item_send(self, event: QueueItem.Send) -> None:
|
|
2828
|
+
event.stop()
|
|
2829
|
+
self.force_send_queued(event.ticket_id)
|
|
2830
|
+
|
|
2831
|
+
def on_queue_item_edited(self, event: QueueItem.Edited) -> None:
|
|
2832
|
+
event.stop()
|
|
2833
|
+
|
|
2834
|
+
def _is_generating(self) -> bool:
|
|
2835
|
+
return (
|
|
2836
|
+
self._busy
|
|
2837
|
+
or self._waiting
|
|
2838
|
+
or any(
|
|
2839
|
+
worker.group == "agent" and worker.is_running for worker in self.workers
|
|
2840
|
+
)
|
|
2841
|
+
)
|
|
2842
|
+
|
|
2843
|
+
def _slash_menu(self) -> CommandHints:
|
|
2844
|
+
return self.query_one("#slash-hints", CommandHints)
|
|
2845
|
+
|
|
2846
|
+
def _slash_open(self) -> bool:
|
|
2847
|
+
try:
|
|
2848
|
+
return bool(self._slash_menu().display)
|
|
2849
|
+
except Exception:
|
|
2850
|
+
return False
|
|
2851
|
+
|
|
2852
|
+
def _hide_slash_hints(self) -> None:
|
|
2853
|
+
try:
|
|
2854
|
+
self._slash_menu().hide()
|
|
2855
|
+
except Exception:
|
|
2856
|
+
pass
|
|
2857
|
+
|
|
2858
|
+
def _refresh_slash_hints(self, typed: str) -> None:
|
|
2859
|
+
try:
|
|
2860
|
+
self._slash_menu().set_items(_slash_matches(typed))
|
|
2861
|
+
except Exception:
|
|
2862
|
+
pass
|
|
2863
|
+
|
|
2864
|
+
def _picker(self) -> SessionPicker:
|
|
2865
|
+
return self.query_one("#win-picker", SessionPicker)
|
|
2866
|
+
|
|
2867
|
+
def _model_picker(self) -> ModelPicker:
|
|
2868
|
+
return self.query_one("#model-picker", ModelPicker)
|
|
2869
|
+
|
|
2870
|
+
def _model_picker_open(self) -> bool:
|
|
2871
|
+
try:
|
|
2872
|
+
return bool(self._model_picker().display)
|
|
2873
|
+
except Exception:
|
|
2874
|
+
return False
|
|
2875
|
+
|
|
2876
|
+
def _hide_model_picker(self, *, focus_input: bool = False) -> None:
|
|
2877
|
+
try:
|
|
2878
|
+
self._model_picker().hide()
|
|
2879
|
+
except Exception:
|
|
2880
|
+
pass
|
|
2881
|
+
if focus_input:
|
|
2882
|
+
try:
|
|
2883
|
+
self.query_one("#prompt", Input).focus()
|
|
2884
|
+
except Exception:
|
|
2885
|
+
pass
|
|
2886
|
+
|
|
2887
|
+
def _model_list(self) -> list[str]:
|
|
2888
|
+
"""config 里的 models 列表;没配就把当前模型兜底成唯一一项。"""
|
|
2889
|
+
raw = CONFIG.get("models")
|
|
2890
|
+
out: list[str] = []
|
|
2891
|
+
if isinstance(raw, list):
|
|
2892
|
+
out = [str(m).strip() for m in raw if str(m).strip()]
|
|
2893
|
+
current = str(CONFIG.get("model") or "").strip()
|
|
2894
|
+
if current and current not in out:
|
|
2895
|
+
out.insert(0, current)
|
|
2896
|
+
return out
|
|
2897
|
+
|
|
2898
|
+
def _show_model_picker(self) -> None:
|
|
2899
|
+
self._hide_slash_hints()
|
|
2900
|
+
self._hide_file_hints()
|
|
2901
|
+
picker = self._model_picker()
|
|
2902
|
+
picker.set_models(self._model_list(), str(CONFIG.get("model") or ""))
|
|
2903
|
+
try:
|
|
2904
|
+
self.set_focus(picker.query_one("#model-list", OptionList))
|
|
2905
|
+
except Exception:
|
|
2906
|
+
pass
|
|
2907
|
+
|
|
2908
|
+
def action_pick_model(self) -> None:
|
|
2909
|
+
if not self._is_generating() and not self._model_picker_open():
|
|
2910
|
+
self._show_model_picker()
|
|
2911
|
+
|
|
2912
|
+
def _switch_model(self, name: str) -> None:
|
|
2913
|
+
set_model(name)
|
|
2914
|
+
CONFIG["model"] = name
|
|
2915
|
+
self._load_model_limits()
|
|
2916
|
+
self._refresh_footer_meta()
|
|
2917
|
+
|
|
2918
|
+
def on_model_picker_picked(self, event: ModelPicker.Picked) -> None:
|
|
2919
|
+
event.stop()
|
|
2920
|
+
self._hide_model_picker()
|
|
2921
|
+
mid = event.model_id
|
|
2922
|
+
if not mid:
|
|
2923
|
+
return
|
|
2924
|
+
if mid == str(CONFIG.get("model") or ""):
|
|
2925
|
+
# 同一个模型也允许只改推理等级:接着弹等级选择
|
|
2926
|
+
self._open_effort_picker(mid)
|
|
2927
|
+
return
|
|
2928
|
+
# 选完模型 → 链式弹出推理等级选择
|
|
2929
|
+
self._open_effort_picker(mid)
|
|
2930
|
+
|
|
2931
|
+
def _effort_picker(self) -> EffortPicker:
|
|
2932
|
+
return self.query_one("#effort-picker", EffortPicker)
|
|
2933
|
+
|
|
2934
|
+
def _effort_picker_open(self) -> bool:
|
|
2935
|
+
try:
|
|
2936
|
+
return bool(self._effort_picker().display)
|
|
2937
|
+
except Exception:
|
|
2938
|
+
return False
|
|
2939
|
+
|
|
2940
|
+
def _hide_effort_picker(self, *, focus_input: bool = False) -> None:
|
|
2941
|
+
try:
|
|
2942
|
+
self._effort_picker().hide()
|
|
2943
|
+
except Exception:
|
|
2944
|
+
pass
|
|
2945
|
+
if focus_input:
|
|
2946
|
+
try:
|
|
2947
|
+
self.query_one("#prompt", Input).focus()
|
|
2948
|
+
except Exception:
|
|
2949
|
+
pass
|
|
2950
|
+
|
|
2951
|
+
def _current_effort(self) -> str:
|
|
2952
|
+
effort = str(CONFIG.get("reasoningEffort") or REASONING_EFFORT or "").strip().lower()
|
|
2953
|
+
return "" if effort in ("default", "none", "无", "默认") else effort
|
|
2954
|
+
|
|
2955
|
+
def _show_effort_picker(
|
|
2956
|
+
self, model_id: str, levels: list[str] | None = None
|
|
2957
|
+
) -> None:
|
|
2958
|
+
self._hide_slash_hints()
|
|
2959
|
+
self._hide_file_hints()
|
|
2960
|
+
self._pending_model = model_id or self._pending_model
|
|
2961
|
+
detected = bool(levels) or _looks_like_reasoning_model(self._pending_model)
|
|
2962
|
+
self._effort_picker().set_efforts(
|
|
2963
|
+
self._pending_model,
|
|
2964
|
+
self._current_effort(),
|
|
2965
|
+
detected,
|
|
2966
|
+
levels,
|
|
2967
|
+
)
|
|
2968
|
+
try:
|
|
2969
|
+
self.set_focus(self._effort_picker().query_one("#effort-list", OptionList))
|
|
2970
|
+
except Exception:
|
|
2971
|
+
pass
|
|
2972
|
+
|
|
2973
|
+
@work
|
|
2974
|
+
async def _open_effort_picker(self, model_id: str) -> None:
|
|
2975
|
+
"""先问 /models 拿这个模型支持的推理等级,拿不到就走启发式。"""
|
|
2976
|
+
levels: list[str] = []
|
|
2977
|
+
try:
|
|
2978
|
+
info = await fetch_model_limits(model_id)
|
|
2979
|
+
levels = [str(x) for x in (info.get("reasoning_levels") or [])]
|
|
2980
|
+
except Exception:
|
|
2981
|
+
levels = []
|
|
2982
|
+
self._show_effort_picker(model_id, levels)
|
|
2983
|
+
|
|
2984
|
+
def on_effort_picker_picked(self, event: EffortPicker.Picked) -> None:
|
|
2985
|
+
event.stop()
|
|
2986
|
+
self._hide_effort_picker(focus_input=True)
|
|
2987
|
+
effort = event.effort or ""
|
|
2988
|
+
target = self._pending_model or str(CONFIG.get("model") or "")
|
|
2989
|
+
self._pending_model = ""
|
|
2990
|
+
if effort != self._current_effort():
|
|
2991
|
+
set_reasoning_effort(effort)
|
|
2992
|
+
CONFIG["reasoningEffort"] = effort
|
|
2993
|
+
switched = bool(target) and target != str(CONFIG.get("model") or "")
|
|
2994
|
+
if switched:
|
|
2995
|
+
self._switch_model(target)
|
|
2996
|
+
bits = []
|
|
2997
|
+
if switched:
|
|
2998
|
+
bits.append(f"已切到模型 {target}")
|
|
2999
|
+
else:
|
|
3000
|
+
bits.append(f"推理等级已更新({target})")
|
|
3001
|
+
bits.append(f"reasoning {effort or 'default'}")
|
|
3002
|
+
self.run_worker(self._note(",".join(bits) + "。"))
|
|
3003
|
+
|
|
3004
|
+
def _picker_open(self) -> bool:
|
|
3005
|
+
try:
|
|
3006
|
+
return bool(self._picker().display)
|
|
3007
|
+
except Exception:
|
|
3008
|
+
return False
|
|
3009
|
+
|
|
3010
|
+
def _hide_picker(self, *, focus_input: bool = False) -> None:
|
|
3011
|
+
try:
|
|
3012
|
+
self._picker().hide()
|
|
3013
|
+
except Exception:
|
|
3014
|
+
pass
|
|
3015
|
+
if focus_input:
|
|
3016
|
+
try:
|
|
3017
|
+
self.query_one("#prompt", Input).focus()
|
|
3018
|
+
except Exception:
|
|
3019
|
+
pass
|
|
3020
|
+
|
|
3021
|
+
def _hist(self) -> HistoryPicker:
|
|
3022
|
+
return self.query_one("#hist-picker", HistoryPicker)
|
|
3023
|
+
|
|
3024
|
+
def _hist_open(self) -> bool:
|
|
3025
|
+
try:
|
|
3026
|
+
return bool(self._hist().display)
|
|
3027
|
+
except Exception:
|
|
3028
|
+
return False
|
|
3029
|
+
|
|
3030
|
+
def _hide_hist(self, *, focus_input: bool = False) -> None:
|
|
3031
|
+
try:
|
|
3032
|
+
self._hist().hide()
|
|
3033
|
+
except Exception:
|
|
3034
|
+
pass
|
|
3035
|
+
if focus_input:
|
|
3036
|
+
try:
|
|
3037
|
+
self.query_one("#prompt", Input).focus()
|
|
3038
|
+
except Exception:
|
|
3039
|
+
pass
|
|
3040
|
+
|
|
3041
|
+
def _show_history_picker(self, rows: list[dict]) -> None:
|
|
3042
|
+
self._hide_slash_hints()
|
|
3043
|
+
self._hide_picker()
|
|
3044
|
+
picker = self._hist()
|
|
3045
|
+
picker.set_rows(rows)
|
|
3046
|
+
try:
|
|
3047
|
+
self.set_focus(picker.query_one("#hist-list", OptionList))
|
|
3048
|
+
except Exception:
|
|
3049
|
+
pass
|
|
3050
|
+
|
|
3051
|
+
def _on_file_write(self, path: Path) -> None:
|
|
3052
|
+
if self._writes_frozen:
|
|
3053
|
+
raise RuntimeError("已回退,未写入")
|
|
3054
|
+
cid = self._checkpoint_id
|
|
3055
|
+
if cid:
|
|
3056
|
+
self._store.snapshot_file(cid, path)
|
|
3057
|
+
|
|
3058
|
+
def _show_session_picker(self, rows: list[dict]) -> None:
|
|
3059
|
+
self._hide_slash_hints()
|
|
3060
|
+
preferred = self._session_stack[-1] if self._session_stack else ""
|
|
3061
|
+
picker = self._picker()
|
|
3062
|
+
picker.set_sessions(rows, self._session_id, preferred)
|
|
3063
|
+
try:
|
|
3064
|
+
self.set_focus(picker.query_one("#win-list", OptionList))
|
|
3065
|
+
except Exception:
|
|
3066
|
+
pass
|
|
3067
|
+
|
|
3068
|
+
def _resolve_session_id(self, raw: str) -> tuple[str, str]:
|
|
3069
|
+
text = (raw or "").strip()
|
|
3070
|
+
if not text:
|
|
3071
|
+
return "", "没有可切换的历史窗口。"
|
|
3072
|
+
rows = self._store.list_sessions_for_project()
|
|
3073
|
+
for row in rows:
|
|
3074
|
+
if row["id"] == text:
|
|
3075
|
+
return row["id"], ""
|
|
3076
|
+
prefix = [row for row in rows if row["id"].startswith(text)]
|
|
3077
|
+
if len(prefix) == 1:
|
|
3078
|
+
return prefix[0]["id"], ""
|
|
3079
|
+
if len(prefix) > 1:
|
|
3080
|
+
return "", f"有 {len(prefix)} 个窗口 id 以 {text} 开头,请写完整一点。"
|
|
3081
|
+
titled = [
|
|
3082
|
+
row
|
|
3083
|
+
for row in rows
|
|
3084
|
+
if text.lower() in str(row.get("title") or "").lower()
|
|
3085
|
+
]
|
|
3086
|
+
if len(titled) == 1:
|
|
3087
|
+
return titled[0]["id"], ""
|
|
3088
|
+
if len(titled) > 1:
|
|
3089
|
+
return "", f"有 {len(titled)} 个窗口标题包含「{text}」,请用 id。"
|
|
3090
|
+
return "", f"找不到窗口: {text}"
|
|
3091
|
+
|
|
3092
|
+
async def _switch_window(self, target: str) -> None:
|
|
3093
|
+
self._hide_picker()
|
|
3094
|
+
if target == self._session_id:
|
|
3095
|
+
await self._note("已在这个窗口。")
|
|
3096
|
+
try:
|
|
3097
|
+
self.query_one("#prompt", Input).focus()
|
|
3098
|
+
except Exception:
|
|
3099
|
+
pass
|
|
3100
|
+
return
|
|
3101
|
+
self._run_id += 1
|
|
3102
|
+
self._writes_frozen = True
|
|
3103
|
+
self._checkpoint_id = None
|
|
3104
|
+
if self._is_generating():
|
|
3105
|
+
self.action_interrupt()
|
|
3106
|
+
self._session_stack.append(self._session_id)
|
|
3107
|
+
try:
|
|
3108
|
+
self._store.switch_session(target)
|
|
3109
|
+
except Exception as exc:
|
|
3110
|
+
await self._note(f"切换失败: {exc}")
|
|
3111
|
+
return
|
|
3112
|
+
self._session_id = target
|
|
3113
|
+
saved_in, saved_out, saved_cache = self._store.usage_of(target)
|
|
3114
|
+
self._committed_in = saved_in
|
|
3115
|
+
self._committed_out = saved_out
|
|
3116
|
+
self._committed_cache = saved_cache
|
|
3117
|
+
self._live_in = 0
|
|
3118
|
+
self._live_out = 0
|
|
3119
|
+
self._live_cache = 0
|
|
3120
|
+
await self._reload_session_view()
|
|
3121
|
+
title = ""
|
|
3122
|
+
for row in self._store.list_sessions_for_project():
|
|
3123
|
+
if row["id"] == target:
|
|
3124
|
+
title = row["title"]
|
|
3125
|
+
break
|
|
3126
|
+
await self._note(f"已切到窗口 {target[:8]} {title}".rstrip())
|
|
3127
|
+
self._refresh_footer_meta()
|
|
3128
|
+
try:
|
|
3129
|
+
self.query_one("#prompt", Input).focus()
|
|
3130
|
+
except Exception:
|
|
3131
|
+
pass
|
|
3132
|
+
|
|
3133
|
+
def on_session_picker_picked(self, event: SessionPicker.Picked) -> None:
|
|
3134
|
+
event.stop()
|
|
3135
|
+
self._hide_picker()
|
|
3136
|
+
self.run_worker(self._switch_window(event.session_id))
|
|
3137
|
+
|
|
3138
|
+
def on_history_picker_picked(self, event: HistoryPicker.Picked) -> None:
|
|
3139
|
+
event.stop()
|
|
3140
|
+
self._hide_hist()
|
|
3141
|
+
self.run_worker(self._apply_history(event.checkpoint_id, event.action))
|
|
3142
|
+
|
|
3143
|
+
async def _apply_history(self, checkpoint_id: str, action: str) -> None:
|
|
3144
|
+
rows = self._store.list_checkpoints(self._session_id)
|
|
3145
|
+
hit = next((r for r in rows if r.get("id") == checkpoint_id), None)
|
|
3146
|
+
if not hit:
|
|
3147
|
+
await self._note("找不到这个快照。")
|
|
3148
|
+
return
|
|
3149
|
+
self._run_id += 1
|
|
3150
|
+
self._writes_frozen = True
|
|
3151
|
+
self._checkpoint_id = None
|
|
3152
|
+
if self._is_generating():
|
|
3153
|
+
self.action_interrupt()
|
|
3154
|
+
prompt = str(hit.get("title") or "")
|
|
3155
|
+
for msg in self._store.list_messages(self._session_id):
|
|
3156
|
+
if msg.id == int(hit["message_id"]) and msg.role == "user":
|
|
3157
|
+
prompt = msg.content
|
|
3158
|
+
break
|
|
3159
|
+
files: list[str] = []
|
|
3160
|
+
try:
|
|
3161
|
+
if action in ("both", "code"):
|
|
3162
|
+
files = self._store.restore_files(self._session_id, checkpoint_id)
|
|
3163
|
+
if action in ("both", "chat"):
|
|
3164
|
+
self._store.truncate_from_message(
|
|
3165
|
+
self._session_id, int(hit["message_id"])
|
|
3166
|
+
)
|
|
3167
|
+
await self._reload_session_view()
|
|
3168
|
+
elif action == "code":
|
|
3169
|
+
self._store.drop_checkpoints_after(
|
|
3170
|
+
self._session_id, int(hit["message_id"]), include=False
|
|
3171
|
+
)
|
|
3172
|
+
except Exception as exc:
|
|
3173
|
+
await self._note(f"回退失败: {exc}")
|
|
3174
|
+
return
|
|
3175
|
+
bits = []
|
|
3176
|
+
if action in ("both", "chat"):
|
|
3177
|
+
bits.append("对话")
|
|
3178
|
+
if action in ("both", "code"):
|
|
3179
|
+
bits.append("代码")
|
|
3180
|
+
extra = f",还原 {len(files)} 个文件" if files else ""
|
|
3181
|
+
await self._note(f"已回退{'和'.join(bits)}到这一问之前{extra}。")
|
|
3182
|
+
self._refresh_footer_meta()
|
|
3183
|
+
try:
|
|
3184
|
+
field = self.query_one("#prompt", Input)
|
|
3185
|
+
if action in ("both", "chat") and prompt:
|
|
3186
|
+
field.value = prompt
|
|
3187
|
+
field.cursor_position = len(prompt)
|
|
3188
|
+
field.focus()
|
|
3189
|
+
except Exception:
|
|
3190
|
+
pass
|
|
3191
|
+
|
|
3192
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
3193
|
+
if event.input.id != "prompt":
|
|
3194
|
+
return
|
|
3195
|
+
if event.value.strip():
|
|
3196
|
+
if self._picker_open():
|
|
3197
|
+
self._hide_picker()
|
|
3198
|
+
if self._hist_open():
|
|
3199
|
+
self._hide_hist()
|
|
3200
|
+
if self._model_picker_open():
|
|
3201
|
+
self._hide_model_picker()
|
|
3202
|
+
if self._effort_picker_open():
|
|
3203
|
+
self._hide_effort_picker()
|
|
3204
|
+
if event.value.lstrip().startswith("/"):
|
|
3205
|
+
try:
|
|
3206
|
+
self._file_hints().hide()
|
|
3207
|
+
except Exception:
|
|
3208
|
+
pass
|
|
3209
|
+
else:
|
|
3210
|
+
self._refresh_file_hints(event.value)
|
|
3211
|
+
self._refresh_slash_hints(event.value)
|
|
3212
|
+
|
|
3213
|
+
def _file_hints(self) -> FileHints:
|
|
3214
|
+
return self.query_one("#file-hints", FileHints)
|
|
3215
|
+
|
|
3216
|
+
def _file_hints_open(self) -> bool:
|
|
3217
|
+
try:
|
|
3218
|
+
return bool(self._file_hints().display)
|
|
3219
|
+
except Exception:
|
|
3220
|
+
return False
|
|
3221
|
+
|
|
3222
|
+
def _hide_file_hints(self) -> None:
|
|
3223
|
+
try:
|
|
3224
|
+
self._file_hints().hide()
|
|
3225
|
+
except Exception:
|
|
3226
|
+
pass
|
|
3227
|
+
|
|
3228
|
+
def _refresh_file_hints(self, typed: str) -> None:
|
|
3229
|
+
try:
|
|
3230
|
+
prefix, hits = _at_matches(typed)
|
|
3231
|
+
if hits:
|
|
3232
|
+
self._file_hints().set_items(prefix, hits)
|
|
3233
|
+
else:
|
|
3234
|
+
self._file_hints().hide()
|
|
3235
|
+
except Exception:
|
|
3236
|
+
pass
|
|
3237
|
+
|
|
3238
|
+
def _complete_at(self) -> None:
|
|
3239
|
+
"""Tab/回车选中 @补全,把选中的路径替换进输入框。"""
|
|
3240
|
+
path = self._file_hints().current()
|
|
3241
|
+
self._hide_file_hints()
|
|
3242
|
+
if not path:
|
|
3243
|
+
return
|
|
3244
|
+
try:
|
|
3245
|
+
field = self.query_one("#prompt", Input)
|
|
3246
|
+
except Exception:
|
|
3247
|
+
return
|
|
3248
|
+
m = re.search(r"@([^\s@]*)$", field.value)
|
|
3249
|
+
if not m:
|
|
3250
|
+
return
|
|
3251
|
+
field.value = field.value[: m.start()] + "@" + path + " "
|
|
3252
|
+
field.cursor_position = len(field.value)
|
|
3253
|
+
|
|
3254
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
3255
|
+
if action == "slash_complete":
|
|
3256
|
+
return not self._permit_open()
|
|
3257
|
+
if action == "confirm_overlay":
|
|
3258
|
+
if self._permit_open():
|
|
3259
|
+
return False
|
|
3260
|
+
return (
|
|
3261
|
+
self._picker_open()
|
|
3262
|
+
or self._hist_open()
|
|
3263
|
+
or self._model_picker_open()
|
|
3264
|
+
or self._effort_picker_open()
|
|
3265
|
+
)
|
|
3266
|
+
if action in ("slash_next", "slash_prev"):
|
|
3267
|
+
if self._permit_open():
|
|
3268
|
+
return False
|
|
3269
|
+
if (
|
|
3270
|
+
self._hist_open()
|
|
3271
|
+
or self._picker_open()
|
|
3272
|
+
or self._model_picker_open()
|
|
3273
|
+
or self._effort_picker_open()
|
|
3274
|
+
or self._slash_open()
|
|
3275
|
+
):
|
|
3276
|
+
return True
|
|
3277
|
+
focused = self.focused
|
|
3278
|
+
return bool(focused is not None and getattr(focused, "id", None) == "prompt")
|
|
3279
|
+
return True
|
|
3280
|
+
|
|
3281
|
+
def action_confirm_overlay(self) -> None:
|
|
3282
|
+
if self._hist_open():
|
|
3283
|
+
self._hist().confirm()
|
|
3284
|
+
return
|
|
3285
|
+
if self._picker_open():
|
|
3286
|
+
self._picker().confirm()
|
|
3287
|
+
return
|
|
3288
|
+
if self._model_picker_open():
|
|
3289
|
+
self._model_picker().confirm()
|
|
3290
|
+
return
|
|
3291
|
+
if self._effort_picker_open():
|
|
3292
|
+
self._effort_picker().confirm()
|
|
3293
|
+
|
|
3294
|
+
def action_toggle_perm_mode(self) -> None:
|
|
3295
|
+
nxt = "pass" if self._perm_mode == "ask" else "ask"
|
|
3296
|
+
self._set_perm_mode(nxt)
|
|
3297
|
+
|
|
3298
|
+
def _set_perm_mode(self, mode: str) -> None:
|
|
3299
|
+
self._perm_mode = save_permission_mode(mode)
|
|
3300
|
+
self._refresh_footer_meta()
|
|
3301
|
+
|
|
3302
|
+
def _permit_open(self) -> bool:
|
|
3303
|
+
return isinstance(self.screen, PermitScreen)
|
|
3304
|
+
|
|
3305
|
+
def _dismiss_permit(self, allow: bool) -> None:
|
|
3306
|
+
if isinstance(self.screen, PermitScreen):
|
|
3307
|
+
self.screen.dismiss(allow)
|
|
3308
|
+
return
|
|
3309
|
+
fut = self._permit_future
|
|
3310
|
+
if fut is not None and not fut.done():
|
|
3311
|
+
fut.set_result(bool(allow))
|
|
3312
|
+
|
|
3313
|
+
async def _confirm_tool(self, name: str, args: dict, title: str = "") -> bool:
|
|
3314
|
+
if self._perm_mode != "ask":
|
|
3315
|
+
return True
|
|
3316
|
+
if not tool_is_sensitive(name, args):
|
|
3317
|
+
return True
|
|
3318
|
+
loop = asyncio.get_running_loop()
|
|
3319
|
+
fut: asyncio.Future = loop.create_future()
|
|
3320
|
+
self._permit_future = fut
|
|
3321
|
+
preview = permit_preview(name, args)
|
|
3322
|
+
self._set_run_status("permit", title or preview)
|
|
3323
|
+
|
|
3324
|
+
def _done(result: bool | None) -> None:
|
|
3325
|
+
if not fut.done():
|
|
3326
|
+
fut.set_result(bool(result))
|
|
3327
|
+
|
|
3328
|
+
self.push_screen(PermitScreen(preview), _done)
|
|
3329
|
+
try:
|
|
3330
|
+
return bool(await fut)
|
|
3331
|
+
finally:
|
|
3332
|
+
if self._permit_future is fut:
|
|
3333
|
+
self._permit_future = None
|
|
3334
|
+
if self._status_kind == "permit":
|
|
3335
|
+
self._set_run_status("tool", title or preview)
|
|
3336
|
+
|
|
3337
|
+
def action_slash_complete(self) -> None:
|
|
3338
|
+
if self._hist_open():
|
|
3339
|
+
self._hist().confirm()
|
|
3340
|
+
return
|
|
3341
|
+
if self._picker_open():
|
|
3342
|
+
self._picker().confirm()
|
|
3343
|
+
return
|
|
3344
|
+
if self._model_picker_open():
|
|
3345
|
+
self._model_picker().confirm()
|
|
3346
|
+
return
|
|
3347
|
+
if self._effort_picker_open():
|
|
3348
|
+
self._effort_picker().confirm()
|
|
3349
|
+
return
|
|
3350
|
+
if self._file_hints_open():
|
|
3351
|
+
self._complete_at()
|
|
3352
|
+
return
|
|
3353
|
+
if not self._slash_open():
|
|
3354
|
+
return
|
|
3355
|
+
cmd = self._slash_menu().current_cmd()
|
|
3356
|
+
if not cmd:
|
|
3357
|
+
return
|
|
3358
|
+
prompt = self.query_one("#prompt", Input)
|
|
3359
|
+
prompt.value = cmd + " "
|
|
3360
|
+
prompt.cursor_position = len(prompt.value)
|
|
3361
|
+
self._refresh_slash_hints(prompt.value)
|
|
3362
|
+
|
|
3363
|
+
def action_slash_next(self) -> None:
|
|
3364
|
+
if self._hist_open():
|
|
3365
|
+
self._hist().move(1)
|
|
3366
|
+
return
|
|
3367
|
+
if self._picker_open():
|
|
3368
|
+
self._picker().move(1)
|
|
3369
|
+
return
|
|
3370
|
+
if self._model_picker_open():
|
|
3371
|
+
self._model_picker().move(1)
|
|
3372
|
+
return
|
|
3373
|
+
if self._effort_picker_open():
|
|
3374
|
+
self._effort_picker().move(1)
|
|
3375
|
+
return
|
|
3376
|
+
if self._file_hints_open():
|
|
3377
|
+
self._file_hints().move(1)
|
|
3378
|
+
return
|
|
3379
|
+
if self._slash_open():
|
|
3380
|
+
self._slash_menu().move(1)
|
|
3381
|
+
return
|
|
3382
|
+
self._recall_prompt(1)
|
|
3383
|
+
|
|
3384
|
+
def action_slash_prev(self) -> None:
|
|
3385
|
+
if self._hist_open():
|
|
3386
|
+
self._hist().move(-1)
|
|
3387
|
+
return
|
|
3388
|
+
if self._picker_open():
|
|
3389
|
+
self._picker().move(-1)
|
|
3390
|
+
return
|
|
3391
|
+
if self._model_picker_open():
|
|
3392
|
+
self._model_picker().move(-1)
|
|
3393
|
+
return
|
|
3394
|
+
if self._effort_picker_open():
|
|
3395
|
+
self._effort_picker().move(-1)
|
|
3396
|
+
return
|
|
3397
|
+
if self._file_hints_open():
|
|
3398
|
+
self._file_hints().move(-1)
|
|
3399
|
+
return
|
|
3400
|
+
if self._slash_open():
|
|
3401
|
+
self._slash_menu().move(-1)
|
|
3402
|
+
return
|
|
3403
|
+
self._recall_prompt(-1)
|
|
3404
|
+
|
|
3405
|
+
def action_interrupt(self) -> None:
|
|
3406
|
+
"""打断当前这一轮生成,不自动发队列里的下一条。"""
|
|
3407
|
+
if self._permit_open():
|
|
3408
|
+
self._dismiss_permit(False)
|
|
3409
|
+
return
|
|
3410
|
+
if self._hist_open():
|
|
3411
|
+
if self._hist().back_or_close():
|
|
3412
|
+
try:
|
|
3413
|
+
self.set_focus(self._hist().query_one("#hist-list", OptionList))
|
|
3414
|
+
except Exception:
|
|
3415
|
+
pass
|
|
3416
|
+
return
|
|
3417
|
+
self._hide_hist(focus_input=True)
|
|
3418
|
+
return
|
|
3419
|
+
if self._picker_open():
|
|
3420
|
+
self._hide_picker(focus_input=True)
|
|
3421
|
+
return
|
|
3422
|
+
if self._model_picker_open() and not self._is_generating():
|
|
3423
|
+
self._hide_model_picker()
|
|
3424
|
+
return
|
|
3425
|
+
if self._effort_picker_open() and not self._is_generating():
|
|
3426
|
+
# Esc 跳过等级选择:刚选的模型仍然生效,等级保持原样
|
|
3427
|
+
self._hide_effort_picker()
|
|
3428
|
+
pending = self._pending_model
|
|
3429
|
+
self._pending_model = ""
|
|
3430
|
+
if pending and pending != str(CONFIG.get("model") or ""):
|
|
3431
|
+
self._switch_model(pending)
|
|
3432
|
+
self.run_worker(
|
|
3433
|
+
self._note(f"已切到模型 {pending}(推理等级保持不变)。")
|
|
3434
|
+
)
|
|
3435
|
+
return
|
|
3436
|
+
if self._file_hints_open() and not self._is_generating():
|
|
3437
|
+
self._hide_file_hints()
|
|
3438
|
+
return
|
|
3439
|
+
if self._slash_open() and not self._is_generating():
|
|
3440
|
+
self._hide_slash_hints()
|
|
3441
|
+
return
|
|
3442
|
+
if not self._is_generating():
|
|
3443
|
+
return
|
|
3444
|
+
if self._pending_prompt is None:
|
|
3445
|
+
self._hold_queue = True
|
|
3446
|
+
self.workers.cancel_group(self, "agent")
|
|
3447
|
+
# 还在跑的终端命令一并杀掉,别让它挂在后台
|
|
3448
|
+
try:
|
|
3449
|
+
terminate_running_tools()
|
|
3450
|
+
except Exception:
|
|
3451
|
+
pass
|
|
3452
|
+
self._waiting = False
|
|
3453
|
+
self._stop_turn_timer()
|
|
3454
|
+
self._hide_run_status()
|
|
3455
|
+
|
|
3456
|
+
def action_interrupt_or_quit(self) -> None:
|
|
3457
|
+
"""生成中 Ctrl+C 打断;空闲时沿用 Textual 的退出提示。"""
|
|
3458
|
+
if self._permit_open():
|
|
3459
|
+
self._dismiss_permit(False)
|
|
3460
|
+
if self._hist_open():
|
|
3461
|
+
if not self._hist().back_or_close():
|
|
3462
|
+
self._hide_hist(focus_input=True)
|
|
3463
|
+
return
|
|
3464
|
+
if self._picker_open():
|
|
3465
|
+
self._hide_picker(focus_input=True)
|
|
3466
|
+
return
|
|
3467
|
+
if self._is_generating():
|
|
3468
|
+
self.action_interrupt()
|
|
3469
|
+
return
|
|
3470
|
+
self.action_help_quit()
|
|
3471
|
+
|
|
3472
|
+
@work
|
|
3473
|
+
async def _begin_turn(self, prompt: str) -> None:
|
|
3474
|
+
"""先挂好这一轮的气泡,再开等待动画和模型请求。"""
|
|
3475
|
+
self._busy = True
|
|
3476
|
+
try:
|
|
3477
|
+
chat = self.query_one(ChatLog)
|
|
3478
|
+
await chat.mount(UserTurn(prompt))
|
|
3479
|
+
turn = AgentTurn()
|
|
3480
|
+
await chat.mount(turn)
|
|
3481
|
+
self._active_turn = turn
|
|
3482
|
+
self._start_turn()
|
|
3483
|
+
self._stick_bottom(force=True) # 用户自己发的消息,必须看到
|
|
3484
|
+
self.run_agent(prompt)
|
|
3485
|
+
except Exception:
|
|
3486
|
+
self._busy = False
|
|
3487
|
+
raise
|
|
3488
|
+
|
|
3489
|
+
# 等待动画收着做:两句朴素文案慢慢轮换,金色三档慢呼吸,点点保留
|
|
3490
|
+
_WAIT_PHRASES = ("思考中", "跑着呢")
|
|
3491
|
+
_WAIT_GLOW = ("#7a6238", "#b08d4a", "#d9a84e", "#b08d4a")
|
|
3492
|
+
_SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
3493
|
+
|
|
3494
|
+
def _paint_wait(self, elapsed: float) -> Text:
|
|
3495
|
+
"""等待行:金色慢呼吸,点点 1~3 循环跳,后面跟秒数。"""
|
|
3496
|
+
phrase = self._WAIT_PHRASES[int(elapsed // 6) % len(self._WAIT_PHRASES)]
|
|
3497
|
+
dots = "." * (1 + int(elapsed * 4) % 3)
|
|
3498
|
+
glow = self._WAIT_GLOW[int(elapsed * 3) % len(self._WAIT_GLOW)]
|
|
3499
|
+
wait = Text()
|
|
3500
|
+
wait.append("▎ ", style=f"bold {_AMBER}")
|
|
3501
|
+
wait.append(phrase, style=f"bold {glow}")
|
|
3502
|
+
wait.append(f"{dots:<3}", style=f"bold {glow}")
|
|
3503
|
+
wait.append(f" {elapsed:.1f}s", style="dim")
|
|
3504
|
+
return wait
|
|
3505
|
+
|
|
3506
|
+
def _in_tokens(self) -> int:
|
|
3507
|
+
return self._committed_in + self._live_in
|
|
3508
|
+
|
|
3509
|
+
def _out_tokens(self) -> int:
|
|
3510
|
+
return self._committed_out + self._live_out
|
|
3511
|
+
|
|
3512
|
+
def _cache_tokens(self) -> int:
|
|
3513
|
+
return self._committed_cache + self._live_cache
|
|
3514
|
+
|
|
3515
|
+
def _start_turn(self) -> None:
|
|
3516
|
+
"""本轮开始:立刻把输入 token 估上,等待动画和标题定时器一起跑。"""
|
|
3517
|
+
self._stop_turn_timer()
|
|
3518
|
+
self._waiting = True
|
|
3519
|
+
self._got_official = False
|
|
3520
|
+
self._thinking_text = ""
|
|
3521
|
+
self._answer_text = ""
|
|
3522
|
+
self._wait_t0 = time.monotonic()
|
|
3523
|
+
self._live_in = self._idle_occupancy() + _est_tokens(self._current_prompt)
|
|
3524
|
+
self._live_out = 0
|
|
3525
|
+
self._live_cache = 0
|
|
3526
|
+
self._usage_in = 0
|
|
3527
|
+
self._usage_out = 0
|
|
3528
|
+
self._usage_cache = 0
|
|
3529
|
+
self._retry_attempt = 0
|
|
3530
|
+
self._retry_total = 0
|
|
3531
|
+
# run_agent 里会按整段请求(系统+工具+历史+工具结果)再校准 ↑
|
|
3532
|
+
self._refresh_footer_meta()
|
|
3533
|
+
self._set_run_status("wait")
|
|
3534
|
+
self._tick_turn()
|
|
3535
|
+
self._wait_timer = self.set_interval(0.08, self._tick_turn)
|
|
3536
|
+
|
|
3537
|
+
def _stop_turn_timer(self) -> None:
|
|
3538
|
+
if self._wait_timer is not None:
|
|
3539
|
+
self._wait_timer.stop()
|
|
3540
|
+
self._wait_timer = None
|
|
3541
|
+
|
|
3542
|
+
def _commit_live_usage(self, session_id: str | None = None) -> None:
|
|
3543
|
+
sid = session_id or self._session_id
|
|
3544
|
+
# 落库:重启后底部 ↑↓ 接着这个数往上加,不再归零
|
|
3545
|
+
try:
|
|
3546
|
+
self._store.add_usage(
|
|
3547
|
+
sid, self._live_in, self._live_out, self._live_cache
|
|
3548
|
+
)
|
|
3549
|
+
except Exception:
|
|
3550
|
+
pass
|
|
3551
|
+
self._committed_in += self._live_in
|
|
3552
|
+
self._committed_out += self._live_out
|
|
3553
|
+
self._committed_cache += self._live_cache
|
|
3554
|
+
self._live_in = 0
|
|
3555
|
+
self._live_out = 0
|
|
3556
|
+
self._live_cache = 0
|
|
3557
|
+
self._refresh_footer_meta()
|
|
3558
|
+
|
|
3559
|
+
def _tick_turn(self) -> None:
|
|
3560
|
+
elapsed = time.monotonic() - self._wait_t0
|
|
3561
|
+
if self._active_turn is not None:
|
|
3562
|
+
if self._waiting:
|
|
3563
|
+
self._active_turn.show_wait(self._paint_wait(elapsed))
|
|
3564
|
+
else:
|
|
3565
|
+
self._active_turn.pulse_title(
|
|
3566
|
+
elapsed,
|
|
3567
|
+
_est_tokens(self._thinking_text),
|
|
3568
|
+
self._live_in,
|
|
3569
|
+
self._live_cache,
|
|
3570
|
+
)
|
|
3571
|
+
self._stick_bottom()
|
|
3572
|
+
self._paint_run_status()
|
|
3573
|
+
|
|
3574
|
+
def _set_run_status(self, kind: str, label: str = "") -> None:
|
|
3575
|
+
if self._status_kind == kind and self._status_label == label:
|
|
3576
|
+
return
|
|
3577
|
+
self._status_kind = kind
|
|
3578
|
+
self._status_label = label
|
|
3579
|
+
self._paint_run_status()
|
|
3580
|
+
|
|
3581
|
+
def _hide_run_status(self) -> None:
|
|
3582
|
+
self._status_kind = "idle"
|
|
3583
|
+
self._status_label = ""
|
|
3584
|
+
try:
|
|
3585
|
+
self.query_one("#run-status", RunStatus).hide()
|
|
3586
|
+
except Exception:
|
|
3587
|
+
pass
|
|
3588
|
+
|
|
3589
|
+
def _paint_run_status(self) -> None:
|
|
3590
|
+
try:
|
|
3591
|
+
bar = self.query_one("#run-status", RunStatus)
|
|
3592
|
+
except Exception:
|
|
3593
|
+
return
|
|
3594
|
+
kind = self._status_kind
|
|
3595
|
+
if kind == "idle":
|
|
3596
|
+
bar.hide()
|
|
3597
|
+
return
|
|
3598
|
+
elapsed = max(0.0, time.monotonic() - self._wait_t0)
|
|
3599
|
+
# 金色慢呼吸;转圈只留给跑工具的状态,其他状态用静的 ◆
|
|
3600
|
+
spin = self._SPIN[int(elapsed * 10) % len(self._SPIN)] if kind == "tool" else "◆"
|
|
3601
|
+
glow = self._WAIT_GLOW[int(elapsed * 3) % len(self._WAIT_GLOW)]
|
|
3602
|
+
label = " ".join((self._status_label or "").split())
|
|
3603
|
+
if len(label) > 52:
|
|
3604
|
+
label = label[:51] + "…"
|
|
3605
|
+
line = Text(no_wrap=True, overflow="ellipsis")
|
|
3606
|
+
line.append(f"{spin} ", style=f"bold {glow}")
|
|
3607
|
+
if kind == "wait":
|
|
3608
|
+
phrase = self._WAIT_PHRASES[int(elapsed // 6) % len(self._WAIT_PHRASES)]
|
|
3609
|
+
line.append(phrase, style=f"bold {glow}")
|
|
3610
|
+
elif kind == "think":
|
|
3611
|
+
line.append("思考中", style=f"bold {glow}")
|
|
3612
|
+
elif kind == "stream":
|
|
3613
|
+
line.append("回复中", style=f"bold {glow}")
|
|
3614
|
+
elif kind == "tool":
|
|
3615
|
+
line.append("运行 ", style=f"bold {glow}")
|
|
3616
|
+
line.append(label or "工具", style="bold #b5ae9d")
|
|
3617
|
+
elif kind == "permit":
|
|
3618
|
+
line.append("等待允许 ", style=f"bold {glow}")
|
|
3619
|
+
line.append(label or "操作", style="bold #b5ae9d")
|
|
3620
|
+
elif kind == "error":
|
|
3621
|
+
# 请求失败重试:输入框上方红字显示错误信息和 重试 n/10
|
|
3622
|
+
line.append("✗ ", style="bold #ff6a6a")
|
|
3623
|
+
line.append(label or "请求失败", style="bold #ff6a6a")
|
|
3624
|
+
else:
|
|
3625
|
+
line.append(label or "进行中", style=f"bold {glow}")
|
|
3626
|
+
if kind != "error":
|
|
3627
|
+
line.append(f" {elapsed:.1f}s", style="dim")
|
|
3628
|
+
if kind == "error":
|
|
3629
|
+
if self._retry_total:
|
|
3630
|
+
line.append(
|
|
3631
|
+
f" 重试 {self._retry_attempt}/{self._retry_total}",
|
|
3632
|
+
style="bold #ff6a6a",
|
|
3633
|
+
)
|
|
3634
|
+
line.append(" Esc 打断", style="dim")
|
|
3635
|
+
elif kind == "permit":
|
|
3636
|
+
line.append(" Y 允许 N 拒绝", style="dim")
|
|
3637
|
+
else:
|
|
3638
|
+
line.append(" Esc 打断", style="dim")
|
|
3639
|
+
bar.show_line(line)
|
|
3640
|
+
|
|
3641
|
+
def _stick_bottom(self, force: bool = False) -> None:
|
|
3642
|
+
"""吸底;但用户往上翻了历史就别拽,滚回底部附近才恢复自动跟随。"""
|
|
3643
|
+
try:
|
|
3644
|
+
chat = self.query_one(ChatLog)
|
|
3645
|
+
except Exception:
|
|
3646
|
+
return
|
|
3647
|
+
if not force and chat.max_scroll_y - chat.scroll_y > 3:
|
|
3648
|
+
return
|
|
3649
|
+
chat.scroll_end(animate=False)
|
|
3650
|
+
|
|
3651
|
+
def _fmt_tok(self, n: int) -> str:
|
|
3652
|
+
return fmt_tok(n)
|
|
3653
|
+
|
|
3654
|
+
def _status_label_for_calls(self, calls: list) -> str:
|
|
3655
|
+
titles: list[str] = []
|
|
3656
|
+
for call in calls or []:
|
|
3657
|
+
if not isinstance(call, dict):
|
|
3658
|
+
continue
|
|
3659
|
+
name = str(call.get("name") or "")
|
|
3660
|
+
parsed = parse_tool_arguments(str(call.get("arguments") or ""))
|
|
3661
|
+
title = tool_title(name, parsed) if name else "工具"
|
|
3662
|
+
if title:
|
|
3663
|
+
titles.append(title)
|
|
3664
|
+
if not titles:
|
|
3665
|
+
return "工具"
|
|
3666
|
+
if len(titles) == 1:
|
|
3667
|
+
return titles[-1]
|
|
3668
|
+
return f"{titles[-1]} · {len(titles)} 个"
|
|
3669
|
+
|
|
3670
|
+
def _idle_occupancy(self) -> int:
|
|
3671
|
+
"""下一轮会带上的上下文:系统 + 工具定义 + 摘要/历史。"""
|
|
3672
|
+
try:
|
|
3673
|
+
hist = self._store.context_tokens(self._session_id)
|
|
3674
|
+
except Exception:
|
|
3675
|
+
hist = 0
|
|
3676
|
+
return _tools_tokens() + _est_tokens(_system_prompt()) + hist
|
|
3677
|
+
|
|
3678
|
+
def _occupancy_tokens(self) -> int:
|
|
3679
|
+
if self._live_in or self._live_out:
|
|
3680
|
+
return max(0, self._live_in) + max(0, self._live_out)
|
|
3681
|
+
return self._idle_occupancy()
|
|
3682
|
+
|
|
3683
|
+
def _sync_live_in(self, working: list[dict]) -> None:
|
|
3684
|
+
est = _messages_tokens(working) + _tools_tokens()
|
|
3685
|
+
if self._got_official:
|
|
3686
|
+
# 官方用量是每轮累加的,比"当前 working 整体估一遍"准;别再覆盖
|
|
3687
|
+
self._live_in = max(self._live_in, self._usage_in)
|
|
3688
|
+
else:
|
|
3689
|
+
self._live_in = est
|
|
3690
|
+
self._refresh_footer_meta()
|
|
3691
|
+
|
|
3692
|
+
def _refresh_footer_meta(self) -> None:
|
|
3693
|
+
model = str(CONFIG.get("model") or "")
|
|
3694
|
+
inp = self._in_tokens()
|
|
3695
|
+
out = self._out_tokens()
|
|
3696
|
+
cache = self._cache_tokens()
|
|
3697
|
+
bar = Text(no_wrap=True)
|
|
3698
|
+
sep = Text(" · ", style="#57534a")
|
|
3699
|
+
|
|
3700
|
+
def part(text: str, style: str = "") -> None:
|
|
3701
|
+
if bar.plain:
|
|
3702
|
+
bar.append(sep)
|
|
3703
|
+
bar.append(text, style=style or None)
|
|
3704
|
+
|
|
3705
|
+
if model:
|
|
3706
|
+
part(model, f"bold {_AMBER}")
|
|
3707
|
+
effort = self._current_effort()
|
|
3708
|
+
if effort:
|
|
3709
|
+
part(f"reason {effort}", f"bold {_YELLOW}")
|
|
3710
|
+
part(f"↑ {fmt_tok(inp)}")
|
|
3711
|
+
# cache 百分比分色:命中高显绿(省钱),低命中暗灰
|
|
3712
|
+
pct = _cache_pct(cache, inp)
|
|
3713
|
+
if pct:
|
|
3714
|
+
if pct >= 50:
|
|
3715
|
+
part(f"cache {pct}%", "bold #8fbf9f")
|
|
3716
|
+
elif pct < 20:
|
|
3717
|
+
part(f"cache {pct}%", "#6e6a5e")
|
|
3718
|
+
else:
|
|
3719
|
+
part(f"cache {pct}%")
|
|
3720
|
+
part(f"↓ {fmt_tok(out)}")
|
|
3721
|
+
part(f"tot {fmt_tok(inp + out)}")
|
|
3722
|
+
cost = _cost_usd(inp, out, cache)
|
|
3723
|
+
if cost:
|
|
3724
|
+
part("$" + f"{cost:.4f}".rstrip("0").rstrip("."), "#3ecf8e")
|
|
3725
|
+
part("pass" if getattr(self, "_perm_mode", "ask") == "pass" else "ask", "dim")
|
|
3726
|
+
used = self._occupancy_tokens()
|
|
3727
|
+
window = int(getattr(self._store, "model_window", 0) or 0)
|
|
3728
|
+
if window:
|
|
3729
|
+
# 快到 auto-compact 阈值就提醒黄
|
|
3730
|
+
occ_style = "bold #d9a84e" if used > window * 0.8 else ""
|
|
3731
|
+
part(f"{fmt_tok(used)}/{fmt_tok(window)}", occ_style)
|
|
3732
|
+
self.query_one(AgentFooter).meta = bar
|
|
3733
|
+
|
|
3734
|
+
def _set_live_out(self, thinking: str, answer: str) -> None:
|
|
3735
|
+
est = _est_tokens(thinking) + _est_tokens(answer)
|
|
3736
|
+
# 已到账的官方用量打底,流式估算盖在上面;下一轮 usage 到了再校准
|
|
3737
|
+
self._live_out = max(self._usage_out, est)
|
|
3738
|
+
self._refresh_footer_meta()
|
|
3739
|
+
|
|
3740
|
+
def _apply_usage(self, usage: object) -> None:
|
|
3741
|
+
if not isinstance(usage, dict):
|
|
3742
|
+
return
|
|
3743
|
+
|
|
3744
|
+
def _num(*keys: str) -> int:
|
|
3745
|
+
"""键可以是 "a" 或 "a.b",后者读嵌套 dict。"""
|
|
3746
|
+
for key in keys:
|
|
3747
|
+
value: object = usage
|
|
3748
|
+
for part in key.split("."):
|
|
3749
|
+
if not isinstance(value, dict):
|
|
3750
|
+
value = None
|
|
3751
|
+
break
|
|
3752
|
+
value = value.get(part)
|
|
3753
|
+
if value is None:
|
|
3754
|
+
continue
|
|
3755
|
+
try:
|
|
3756
|
+
return int(value)
|
|
3757
|
+
except (TypeError, ValueError):
|
|
3758
|
+
continue
|
|
3759
|
+
return 0
|
|
3760
|
+
|
|
3761
|
+
inp = _num("prompt_tokens", "input_tokens", "input")
|
|
3762
|
+
out = _num("completion_tokens", "output_tokens", "output")
|
|
3763
|
+
# 缓存命中:DeepSeek 平铺一个字段,OpenAI / Responses 放在 details 里
|
|
3764
|
+
cache = _num(
|
|
3765
|
+
"prompt_cache_hit_tokens",
|
|
3766
|
+
"prompt_tokens_details.cached_tokens",
|
|
3767
|
+
"input_tokens_details.cached_tokens",
|
|
3768
|
+
"cache_read_input_tokens",
|
|
3769
|
+
)
|
|
3770
|
+
if inp == 0 and out == 0:
|
|
3771
|
+
return
|
|
3772
|
+
self._got_official = True
|
|
3773
|
+
# 官方用量按轮累加:一轮里跑多次工具,每轮结束都给一份 usage
|
|
3774
|
+
self._usage_in += inp
|
|
3775
|
+
self._usage_out += out
|
|
3776
|
+
self._usage_cache += max(0, min(cache, inp) if inp else cache)
|
|
3777
|
+
self._live_in = self._usage_in
|
|
3778
|
+
self._live_out = self._usage_out
|
|
3779
|
+
self._live_cache = self._usage_cache
|
|
3780
|
+
self._refresh_footer_meta()
|
|
3781
|
+
|
|
3782
|
+
# @work 把方法放到后台跑,调模型这种慢请求不会冻住整个界面。
|
|
3783
|
+
@work(group="agent", exclusive=True)
|
|
3784
|
+
async def run_agent(self, prompt: str) -> None:
|
|
3785
|
+
turn = self._active_turn
|
|
3786
|
+
thinking = ""
|
|
3787
|
+
answer = ""
|
|
3788
|
+
err = ""
|
|
3789
|
+
interrupted = False
|
|
3790
|
+
shown_in = 0
|
|
3791
|
+
shown_cache = 0
|
|
3792
|
+
run_id = self._run_id
|
|
3793
|
+
sid = self._session_id # 中途切窗口时,这轮的用量仍记到开始的会话上
|
|
3794
|
+
self._writes_frozen = False
|
|
3795
|
+
user_id = self._store.add_message(self._session_id, "user", prompt)
|
|
3796
|
+
self._store.set_title_if_empty(self._session_id, prompt)
|
|
3797
|
+
self._checkpoint_id = self._store.open_checkpoint(
|
|
3798
|
+
self._session_id, user_id, prompt
|
|
3799
|
+
)
|
|
3800
|
+
history = await prepare_context(self._store, self._session_id)
|
|
3801
|
+
working: list[dict] = [{"role": "system", "content": _system_prompt()}]
|
|
3802
|
+
working.extend(history)
|
|
3803
|
+
if self._pending_images:
|
|
3804
|
+
# 图片只随这条消息进当前请求;磁盘存名字,限量内连 data 也存(回放可见)
|
|
3805
|
+
self._store.merge_message_meta(
|
|
3806
|
+
self._session_id,
|
|
3807
|
+
user_id,
|
|
3808
|
+
{"images": [name for name, _ in self._pending_images]},
|
|
3809
|
+
)
|
|
3810
|
+
persist = [
|
|
3811
|
+
{"name": name, "data": url}
|
|
3812
|
+
for name, url in self._pending_images
|
|
3813
|
+
]
|
|
3814
|
+
if sum(len(p["data"]) for p in persist) <= _IMG_PERSIST_CAP:
|
|
3815
|
+
self._store.merge_message_meta(
|
|
3816
|
+
self._session_id, user_id, {"images_data": persist}
|
|
3817
|
+
)
|
|
3818
|
+
for msg in reversed(working):
|
|
3819
|
+
if msg.get("role") == "user":
|
|
3820
|
+
parts: list[dict] = []
|
|
3821
|
+
if msg.get("content"):
|
|
3822
|
+
parts.append({"type": "text", "text": str(msg["content"])})
|
|
3823
|
+
parts += [
|
|
3824
|
+
{"type": "image_url", "image_url": {"url": url}}
|
|
3825
|
+
for _, url in self._pending_images
|
|
3826
|
+
]
|
|
3827
|
+
msg["content"] = parts
|
|
3828
|
+
break
|
|
3829
|
+
self._pending_images = []
|
|
3830
|
+
tools = openai_tools()
|
|
3831
|
+
self._sync_live_in(working)
|
|
3832
|
+
|
|
3833
|
+
try:
|
|
3834
|
+
for _round in range(16):
|
|
3835
|
+
round_answer = ""
|
|
3836
|
+
calls: list = []
|
|
3837
|
+
thinking_base = thinking
|
|
3838
|
+
attempt = 0
|
|
3839
|
+
while True:
|
|
3840
|
+
attempt += 1
|
|
3841
|
+
round_usage: list = [] # usage 先缓冲,整轮成功才入账,重试不重复计
|
|
3842
|
+
try:
|
|
3843
|
+
async for kind, payload in ask_stream(working, tools=tools):
|
|
3844
|
+
try:
|
|
3845
|
+
if get_current_worker().is_cancelled:
|
|
3846
|
+
interrupted = True
|
|
3847
|
+
break
|
|
3848
|
+
except Exception:
|
|
3849
|
+
pass
|
|
3850
|
+
if kind == "usage":
|
|
3851
|
+
round_usage.append(payload)
|
|
3852
|
+
continue
|
|
3853
|
+
if kind == "tool_calls":
|
|
3854
|
+
calls = list(payload or [])
|
|
3855
|
+
continue
|
|
3856
|
+
if kind == "tool_delta":
|
|
3857
|
+
if self._waiting:
|
|
3858
|
+
self._waiting = False
|
|
3859
|
+
if looks_like_tool_args(round_answer):
|
|
3860
|
+
round_answer = ""
|
|
3861
|
+
answer = ""
|
|
3862
|
+
calls_delta = list(payload or [])
|
|
3863
|
+
self._set_run_status("tool", self._status_label_for_calls(calls_delta))
|
|
3864
|
+
if turn is not None:
|
|
3865
|
+
elapsed = time.monotonic() - self._wait_t0
|
|
3866
|
+
turn.show_stream(
|
|
3867
|
+
thinking,
|
|
3868
|
+
round_answer,
|
|
3869
|
+
elapsed=elapsed,
|
|
3870
|
+
out_tokens=_est_tokens(thinking),
|
|
3871
|
+
in_tokens=self._live_in,
|
|
3872
|
+
cache_tokens=self._live_cache,
|
|
3873
|
+
)
|
|
3874
|
+
await turn.sync_tools(calls_delta)
|
|
3875
|
+
self._stick_bottom()
|
|
3876
|
+
await asyncio.sleep(0)
|
|
3877
|
+
continue
|
|
3878
|
+
if self._waiting:
|
|
3879
|
+
self._waiting = False
|
|
3880
|
+
if kind == "thinking":
|
|
3881
|
+
thinking += str(payload)
|
|
3882
|
+
if self._status_kind != "tool":
|
|
3883
|
+
self._set_run_status("think")
|
|
3884
|
+
elif kind == "answer":
|
|
3885
|
+
round_answer += str(payload)
|
|
3886
|
+
if self._status_kind != "tool":
|
|
3887
|
+
self._set_run_status("stream")
|
|
3888
|
+
else:
|
|
3889
|
+
continue
|
|
3890
|
+
answer = round_answer
|
|
3891
|
+
self._thinking_text = thinking
|
|
3892
|
+
self._answer_text = answer
|
|
3893
|
+
self._set_live_out(thinking, answer)
|
|
3894
|
+
if turn is not None:
|
|
3895
|
+
elapsed = time.monotonic() - self._wait_t0
|
|
3896
|
+
turn.show_stream(
|
|
3897
|
+
thinking,
|
|
3898
|
+
answer,
|
|
3899
|
+
elapsed=elapsed,
|
|
3900
|
+
out_tokens=_est_tokens(thinking),
|
|
3901
|
+
in_tokens=self._live_in,
|
|
3902
|
+
cache_tokens=self._live_cache,
|
|
3903
|
+
)
|
|
3904
|
+
self._stick_bottom()
|
|
3905
|
+
await asyncio.sleep(0)
|
|
3906
|
+
for _usage in round_usage:
|
|
3907
|
+
self._apply_usage(_usage)
|
|
3908
|
+
break
|
|
3909
|
+
except Exception as exc:
|
|
3910
|
+
if interrupted:
|
|
3911
|
+
break
|
|
3912
|
+
if attempt > _RETRY_TOTAL or run_id != self._run_id:
|
|
3913
|
+
raise
|
|
3914
|
+
# 半截内容回滚,重试从头生成本轮
|
|
3915
|
+
thinking = thinking_base
|
|
3916
|
+
round_answer = ""
|
|
3917
|
+
answer = ""
|
|
3918
|
+
self._thinking_text = thinking
|
|
3919
|
+
self._answer_text = ""
|
|
3920
|
+
if turn is not None:
|
|
3921
|
+
turn.show_stream(
|
|
3922
|
+
thinking,
|
|
3923
|
+
"",
|
|
3924
|
+
elapsed=time.monotonic() - self._wait_t0,
|
|
3925
|
+
out_tokens=_est_tokens(thinking),
|
|
3926
|
+
in_tokens=self._live_in,
|
|
3927
|
+
cache_tokens=self._live_cache,
|
|
3928
|
+
)
|
|
3929
|
+
self._retry_attempt = attempt
|
|
3930
|
+
self._retry_total = _RETRY_TOTAL
|
|
3931
|
+
self._set_run_status("error", str(exc))
|
|
3932
|
+
await asyncio.sleep(min(1.5 * attempt, 8.0))
|
|
3933
|
+
self._retry_attempt = 0
|
|
3934
|
+
self._retry_total = 0
|
|
3935
|
+
if interrupted or run_id != self._run_id:
|
|
3936
|
+
interrupted = True
|
|
3937
|
+
break
|
|
3938
|
+
if not calls:
|
|
3939
|
+
answer = round_answer
|
|
3940
|
+
break
|
|
3941
|
+
if looks_like_tool_args(round_answer):
|
|
3942
|
+
round_answer = ""
|
|
3943
|
+
answer = ""
|
|
3944
|
+
if turn is not None:
|
|
3945
|
+
elapsed = time.monotonic() - self._wait_t0
|
|
3946
|
+
turn.show_stream(
|
|
3947
|
+
thinking,
|
|
3948
|
+
"",
|
|
3949
|
+
elapsed=elapsed,
|
|
3950
|
+
out_tokens=_est_tokens(thinking),
|
|
3951
|
+
in_tokens=self._live_in,
|
|
3952
|
+
cache_tokens=self._live_cache,
|
|
3953
|
+
)
|
|
3954
|
+
tool_calls = [
|
|
3955
|
+
{
|
|
3956
|
+
"id": c.get("id") or f"call_{i}",
|
|
3957
|
+
"type": "function",
|
|
3958
|
+
"function": {
|
|
3959
|
+
"name": c.get("name") or "",
|
|
3960
|
+
"arguments": c.get("arguments") or "{}",
|
|
3961
|
+
},
|
|
3962
|
+
}
|
|
3963
|
+
for i, c in enumerate(calls)
|
|
3964
|
+
]
|
|
3965
|
+
assistant_msg: dict = {"role": "assistant", "tool_calls": tool_calls}
|
|
3966
|
+
if round_answer:
|
|
3967
|
+
assistant_msg["content"] = round_answer
|
|
3968
|
+
working.append(assistant_msg)
|
|
3969
|
+
if run_id == self._run_id:
|
|
3970
|
+
self._store.add_message(
|
|
3971
|
+
self._session_id,
|
|
3972
|
+
"assistant",
|
|
3973
|
+
(round_answer or "").strip(),
|
|
3974
|
+
meta={"tool_calls": tool_calls},
|
|
3975
|
+
)
|
|
3976
|
+
for i, call in enumerate(calls):
|
|
3977
|
+
name = str(call.get("name") or "")
|
|
3978
|
+
raw_args = call.get("arguments") or "{}"
|
|
3979
|
+
try:
|
|
3980
|
+
args = json.loads(raw_args)
|
|
3981
|
+
except json.JSONDecodeError:
|
|
3982
|
+
args = parse_tool_arguments(str(raw_args))
|
|
3983
|
+
if not isinstance(args, dict):
|
|
3984
|
+
args = {}
|
|
3985
|
+
title = tool_title(name, args)
|
|
3986
|
+
self._set_run_status("tool", title)
|
|
3987
|
+
card = None
|
|
3988
|
+
if turn is not None:
|
|
3989
|
+
if self._waiting:
|
|
3990
|
+
self._waiting = False
|
|
3991
|
+
elapsed = time.monotonic() - self._wait_t0
|
|
3992
|
+
turn.show_stream(
|
|
3993
|
+
thinking,
|
|
3994
|
+
answer,
|
|
3995
|
+
elapsed=elapsed,
|
|
3996
|
+
out_tokens=_est_tokens(thinking),
|
|
3997
|
+
in_tokens=self._live_in,
|
|
3998
|
+
cache_tokens=self._live_cache,
|
|
3999
|
+
)
|
|
4000
|
+
card = await turn.begin_tool(title, index=i, name=name)
|
|
4001
|
+
self._stick_bottom()
|
|
4002
|
+
allowed = True
|
|
4003
|
+
if run_id == self._run_id:
|
|
4004
|
+
allowed = await self._confirm_tool(name, args, title=title)
|
|
4005
|
+
if run_id != self._run_id:
|
|
4006
|
+
interrupted = True
|
|
4007
|
+
break
|
|
4008
|
+
if not allowed:
|
|
4009
|
+
result = "用户拒绝了这次操作。"
|
|
4010
|
+
else:
|
|
4011
|
+
# 终端命令边跑边把输出刷进卡片
|
|
4012
|
+
on_output = None
|
|
4013
|
+
if card is not None and normalize_tool_name(name) in (
|
|
4014
|
+
"run_terminal_command",
|
|
4015
|
+
):
|
|
4016
|
+
on_output = card.stream_output
|
|
4017
|
+
result = await execute_tool_async(
|
|
4018
|
+
name, args, on_output=on_output
|
|
4019
|
+
)
|
|
4020
|
+
if card is not None:
|
|
4021
|
+
card.finish(name, result)
|
|
4022
|
+
self._stick_bottom()
|
|
4023
|
+
tool_msg = {
|
|
4024
|
+
"role": "tool",
|
|
4025
|
+
"tool_call_id": call.get("id") or "",
|
|
4026
|
+
# 只读工具的大结果掐头去尾再进上下文;磁盘和卡片上仍是全文
|
|
4027
|
+
"content": _clip_tool_context(name, result),
|
|
4028
|
+
}
|
|
4029
|
+
working.append(tool_msg)
|
|
4030
|
+
if run_id == self._run_id:
|
|
4031
|
+
self._store.add_message(
|
|
4032
|
+
self._session_id,
|
|
4033
|
+
"tool",
|
|
4034
|
+
result,
|
|
4035
|
+
meta={
|
|
4036
|
+
"tool_call_id": call.get("id") or "",
|
|
4037
|
+
"name": name,
|
|
4038
|
+
},
|
|
4039
|
+
)
|
|
4040
|
+
self._sync_live_in(working)
|
|
4041
|
+
await asyncio.sleep(0)
|
|
4042
|
+
if interrupted or run_id != self._run_id:
|
|
4043
|
+
interrupted = True
|
|
4044
|
+
break
|
|
4045
|
+
self._set_run_status("wait")
|
|
4046
|
+
except asyncio.CancelledError:
|
|
4047
|
+
interrupted = True
|
|
4048
|
+
except Exception as exc:
|
|
4049
|
+
err = str(exc)
|
|
4050
|
+
finally:
|
|
4051
|
+
self._waiting = False
|
|
4052
|
+
self._busy = False
|
|
4053
|
+
self._stop_turn_timer()
|
|
4054
|
+
self._hide_run_status()
|
|
4055
|
+
shown_in = self._live_in
|
|
4056
|
+
shown_out = self._live_out
|
|
4057
|
+
shown_cache = self._live_cache
|
|
4058
|
+
self._commit_live_usage(sid)
|
|
4059
|
+
self._checkpoint_id = None
|
|
4060
|
+
if isinstance(self.screen, PermitScreen):
|
|
4061
|
+
self.screen.dismiss(False)
|
|
4062
|
+
elif self._permit_future is not None and not self._permit_future.done():
|
|
4063
|
+
self._permit_future.set_result(False)
|
|
4064
|
+
self._permit_future = None
|
|
4065
|
+
|
|
4066
|
+
if run_id != self._run_id:
|
|
4067
|
+
return
|
|
4068
|
+
if turn is None:
|
|
4069
|
+
if interrupted:
|
|
4070
|
+
self._resume_after_interrupt()
|
|
4071
|
+
elif not self._hold_queue:
|
|
4072
|
+
self.call_after_refresh(self._send_next_queued)
|
|
4073
|
+
return
|
|
4074
|
+
if interrupted:
|
|
4075
|
+
turn.show_interrupted(
|
|
4076
|
+
thinking,
|
|
4077
|
+
answer,
|
|
4078
|
+
elapsed=time.monotonic() - self._wait_t0,
|
|
4079
|
+
in_tokens=shown_in,
|
|
4080
|
+
cache_tokens=shown_cache,
|
|
4081
|
+
)
|
|
4082
|
+
if answer.strip() and run_id == self._run_id:
|
|
4083
|
+
self._store.add_message(
|
|
4084
|
+
self._session_id,
|
|
4085
|
+
"assistant",
|
|
4086
|
+
answer.strip(),
|
|
4087
|
+
thinking=thinking,
|
|
4088
|
+
)
|
|
4089
|
+
self._refresh_footer_meta()
|
|
4090
|
+
self._stick_bottom()
|
|
4091
|
+
self._resume_after_interrupt()
|
|
4092
|
+
return
|
|
4093
|
+
if err:
|
|
4094
|
+
turn.show_error(err)
|
|
4095
|
+
elif not thinking and not answer:
|
|
4096
|
+
turn.show_empty()
|
|
4097
|
+
else:
|
|
4098
|
+
self._store.add_message(
|
|
4099
|
+
self._session_id,
|
|
4100
|
+
"assistant",
|
|
4101
|
+
answer.strip(),
|
|
4102
|
+
thinking=thinking,
|
|
4103
|
+
)
|
|
4104
|
+
elapsed = time.monotonic() - self._wait_t0
|
|
4105
|
+
turn.show_stream(
|
|
4106
|
+
thinking,
|
|
4107
|
+
answer,
|
|
4108
|
+
elapsed=elapsed,
|
|
4109
|
+
out_tokens=_est_tokens(thinking),
|
|
4110
|
+
in_tokens=shown_in,
|
|
4111
|
+
cache_tokens=shown_cache,
|
|
4112
|
+
final=True,
|
|
4113
|
+
)
|
|
4114
|
+
turn._thinking_cache = thinking
|
|
4115
|
+
# 折叠时不要把思考正文画出来,否则会和下面的回复叠在一起
|
|
4116
|
+
if thinking and not turn.query_one("#thinking", Collapsible).collapsed:
|
|
4117
|
+
turn._flush_thinking()
|
|
4118
|
+
self._refresh_footer_meta()
|
|
4119
|
+
self._stick_bottom()
|
|
4120
|
+
self._hold_queue = False
|
|
4121
|
+
self.call_after_refresh(self._send_next_queued)
|
|
4122
|
+
|
|
4123
|
+
def _resume_after_interrupt(self) -> None:
|
|
4124
|
+
"""打断后:若是队列发送按钮触发的,只发那一条;Esc 则队列原样停住。"""
|
|
4125
|
+
pending = self._pending_prompt
|
|
4126
|
+
self._pending_prompt = None
|
|
4127
|
+
if pending:
|
|
4128
|
+
self._hold_queue = False
|
|
4129
|
+
self.call_after_refresh(self._kickoff, pending)
|
|
4130
|
+
return
|
|
4131
|
+
self._hold_queue = True
|
|
4132
|
+
|
|
4133
|
+
|
|
4134
|
+
def main() -> None:
|
|
4135
|
+
# .run() 会启动事件循环、渲染界面,并一直运行到用户按 Ctrl+Q 退出
|
|
4136
|
+
AgentApp().run()
|
|
4137
|
+
|
|
4138
|
+
|
|
4139
|
+
if __name__ == "__main__":
|
|
4140
|
+
main()
|