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/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()