chcode 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.
chcode/display.py ADDED
@@ -0,0 +1,325 @@
1
+ """
2
+ Rich 输出渲染 — Markdown、流式输出、状态栏、消息样式
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from rich.console import Console
10
+ from rich.markdown import Markdown
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+ from rich.rule import Rule
14
+ from rich.live import Live
15
+
16
+ import asyncio
17
+ import contextvars
18
+ import threading
19
+ import time
20
+
21
+ _subagent_count = 0
22
+ _subagent_count_lock = threading.Lock()
23
+ _subagent_parallel = False
24
+
25
+ _current_agent_tag: contextvars.ContextVar[str | None] = contextvars.ContextVar(
26
+ "_current_agent_tag", default=None
27
+ )
28
+ _agent_progress: dict[str, dict] = {}
29
+ _agent_progress_lock = threading.Lock()
30
+ _progress_live: Live | None = None
31
+ _progress_task: asyncio.Task | None = None
32
+
33
+ if TYPE_CHECKING:
34
+ pass
35
+
36
+ console = Console()
37
+
38
+ # ─── 消息渲染 ──────────────────────────────────────────
39
+
40
+
41
+ def render_human(message: str) -> None:
42
+ """渲染用户消息"""
43
+ console.print(
44
+ Panel(
45
+ Markdown(message),
46
+ border_style="blue",
47
+ title="You",
48
+ title_align="right",
49
+ padding=(0, 1),
50
+ )
51
+ )
52
+
53
+
54
+ def render_ai_chunk(content: str) -> None:
55
+ """渲染 AI 回复片段(流式)"""
56
+ if _subagent_parallel or _subagent_count > 0:
57
+ return
58
+ console.print(content, end="", style="white")
59
+
60
+
61
+ def render_ai_start():
62
+ """AI 回复开始"""
63
+ global _subagent_parallel
64
+ # 先完成并清理之前的进度显示
65
+ _finalize_progress()
66
+ _subagent_parallel = False
67
+ with _agent_progress_lock:
68
+ _agent_progress.clear()
69
+ if _subagent_count > 0:
70
+ return
71
+ console.print()
72
+
73
+
74
+ def render_ai_end() -> None:
75
+ """AI 回复结束"""
76
+ if _subagent_parallel or _subagent_count > 0:
77
+ return
78
+ console.print()
79
+
80
+
81
+ def render_reasoning(reasoning: str) -> None:
82
+ """渲染推理/思考内容(灰色斜体,折叠)"""
83
+ if _subagent_parallel or _subagent_count > 0:
84
+ return
85
+ console.print(
86
+ Panel(
87
+ Text(reasoning, style="dim italic"),
88
+ border_style="dim",
89
+ title="Thinking",
90
+ title_align="left",
91
+ padding=(0, 1),
92
+ )
93
+ )
94
+
95
+
96
+ def _start_progress():
97
+ global _progress_live
98
+ if _progress_live is None:
99
+ _progress_live = Live("", transient=True, console=console, refresh_per_second=1)
100
+ _progress_live.start()
101
+
102
+
103
+ def _update_progress():
104
+ if not _progress_live:
105
+ return
106
+ with _agent_progress_lock:
107
+ if not _agent_progress:
108
+ _progress_live.update("")
109
+ return
110
+ now = time.time()
111
+ lines = []
112
+ for tag, info in _agent_progress.items():
113
+ elapsed = int(now - info["start"])
114
+ timeout = info.get("timeout", 300)
115
+
116
+ if info.get("failed"):
117
+ lines.append(f" [red]\u274c {tag}: \u8d85\u65f6 ({timeout}s)[/red]")
118
+ elif info.get("done"):
119
+ lines.append(f" [green]\u2705 {tag}: done ({elapsed}s)[/green]")
120
+ else:
121
+ remaining = max(0, timeout - elapsed)
122
+ pct = min(elapsed / timeout, 1.0) if timeout > 0 else 1.0
123
+ bar_len = int(pct * 16)
124
+ bar = "\u2588" * bar_len + "\u2591" * (16 - bar_len)
125
+ lines.append(f" {tag}: {remaining}s \u5269\u4f59 [{bar}]")
126
+ _progress_live.update("\n".join(lines))
127
+
128
+
129
+ async def _progress_updater():
130
+ """定期更新进度显示的后台任务"""
131
+ try:
132
+ while True:
133
+ await asyncio.sleep(1)
134
+ if _progress_live is None:
135
+ break
136
+ _update_progress()
137
+ except asyncio.CancelledError:
138
+ pass # 优雅处理取消
139
+
140
+
141
+ def _finalize_progress():
142
+ """停止进度显示,打印最终状态并清理资源"""
143
+ global _progress_live, _progress_task
144
+
145
+ # 先取消更新任务
146
+ if _progress_task is not None and not _progress_task.done():
147
+ _progress_task.cancel()
148
+ _progress_task = None
149
+
150
+ # 停止 Live(transient=True 会自动清除显示内容)
151
+ if _progress_live is not None:
152
+ _progress_live.stop()
153
+ _progress_live = None
154
+
155
+ # 清空进度数据
156
+ with _agent_progress_lock:
157
+ _agent_progress.clear()
158
+
159
+
160
+ def render_tool_call(name: str, summary: str) -> None:
161
+ tag = _current_agent_tag.get()
162
+ if tag:
163
+ with _agent_progress_lock:
164
+ if tag in _agent_progress:
165
+ _agent_progress[tag]["calls"] += 1
166
+ if len(summary) > 120:
167
+ summary = summary[:117] + "..."
168
+ if name == "agent":
169
+ console.print(Text(f"\n[{name}] {summary}", style="bold cyan"))
170
+ return
171
+ if _subagent_parallel or _subagent_count >= 2:
172
+ return
173
+ if _subagent_count == 1:
174
+ console.print(Text(f" [{name}] {summary}", style="dim cyan"))
175
+ return
176
+ console.print(Text(f"\n[{name}] {summary}", style="bold cyan"))
177
+
178
+
179
+ def render_tool(name: str, content: str) -> None:
180
+ """渲染工具调用结果"""
181
+ if _subagent_parallel or _subagent_count > 0:
182
+ return
183
+ # 截断过长内容
184
+ lines = content.split("\n")
185
+ if len(lines) > 50:
186
+ content = "\n".join(lines[:50]) + f"\n... ({len(lines) - 50} more lines)"
187
+ console.print(
188
+ Panel(
189
+ Text(content, style="yellow"),
190
+ border_style="yellow",
191
+ title=f"Tool: {name}",
192
+ title_align="left",
193
+ padding=(0, 1),
194
+ )
195
+ )
196
+
197
+
198
+ def render_error(message: str) -> None:
199
+ """渲染错误信息"""
200
+ if _subagent_parallel or _subagent_count > 0:
201
+ return
202
+ console.print(Text("Error: ", style="red bold"), Text(message, style="red bold"))
203
+
204
+
205
+ def render_info(message: str) -> None:
206
+ """渲染信息"""
207
+ if _subagent_parallel or _subagent_count > 0:
208
+ return
209
+ console.print(f"[cyan]{message}[/cyan]")
210
+
211
+
212
+ def render_success(message: str) -> None:
213
+ """渲染成功信息"""
214
+ if _subagent_parallel or _subagent_count > 0:
215
+ return
216
+ console.print(f"[green]{message}[/green]")
217
+
218
+
219
+ def render_warning(message: str) -> None:
220
+ """渲染警告信息"""
221
+ if _subagent_parallel or _subagent_count > 0:
222
+ return
223
+ console.print(f"[yellow]{message}[/yellow]")
224
+
225
+
226
+ def render_separator() -> None:
227
+ """渲染分隔线"""
228
+ console.print(Rule(style="dim"))
229
+
230
+
231
+ def render_welcome() -> None:
232
+ """渲染欢迎信息"""
233
+ console.print()
234
+ console.print(
235
+ Panel(
236
+ "[bold]ChCode[/bold] — Terminal-based AI Coding Agent\n"
237
+ "Enter 发送 | Ctrl+Enter 换行 | /help 查看命令\n"
238
+ "Ctrl+C 中断生成 | Tab 切换模式 | /quit 退出",
239
+ border_style="cyan",
240
+ padding=(1, 2),
241
+ )
242
+ )
243
+ console.print()
244
+
245
+
246
+ # ─── 消息列表渲染(加载历史) ─────────────────────────────
247
+
248
+
249
+ def render_conversation(messages: list) -> None:
250
+ """渲染完整对话历史"""
251
+ top_flag = True
252
+ for i, message in enumerate(messages):
253
+ if message.additional_kwargs.get("hide", ""):
254
+ continue
255
+ msg_type = message.type
256
+ content = message.content
257
+ from chcode.utils import get_text_content
258
+ content = get_text_content(content)
259
+
260
+ if msg_type == "human":
261
+ if top_flag:
262
+ top_flag = False
263
+ else:
264
+ render_separator()
265
+ render_human(content or "")
266
+
267
+ elif msg_type == "ai":
268
+ reasoning = message.additional_kwargs.get("reasoning")
269
+ if reasoning:
270
+ render_reasoning(reasoning)
271
+ if content:
272
+ render_ai_start()
273
+ console.print(Markdown(content))
274
+ render_ai_end()
275
+
276
+ elif msg_type == "tool":
277
+ if content:
278
+ render_tool(message.name or "tool", content)
279
+
280
+ console.print()
281
+
282
+
283
+ # ─── 上下文用量 ──────────────────────────────────────────
284
+
285
+
286
+ def _format_tokens(n: int) -> str:
287
+ """格式化 token 数:123456 → 123.5K"""
288
+ if n >= 1000:
289
+ return f"{n / 1000:.1f}K"
290
+ return str(n)
291
+
292
+
293
+ def get_context_usage_text(messages: list, max_context: int) -> str:
294
+ """
295
+ 从消息列表计算上下文占用,返回带样式的文本。
296
+
297
+ 取最后一次 AIMessage 的 input_tokens 作为上下文快照
298
+ (因为每次请求的 input_tokens 包含了完整上下文)。
299
+ """
300
+ input_tokens = 0
301
+ for message in reversed(messages):
302
+ from langchain_core.messages import AIMessage
303
+
304
+ if isinstance(message, AIMessage):
305
+ usage = message.usage_metadata
306
+ if usage and usage.get("input_tokens"):
307
+ input_tokens = usage["input_tokens"]
308
+ break
309
+
310
+ if input_tokens == 0:
311
+ return ""
312
+
313
+ pct = input_tokens / max_context
314
+ used_str = _format_tokens(input_tokens)
315
+ max_str = _format_tokens(max_context)
316
+ pct_str = f"{pct * 100:.0f}%"
317
+
318
+ if pct < 0.7:
319
+ style = "yellow"
320
+ elif pct < 0.9:
321
+ style = "bold yellow"
322
+ else:
323
+ style = "bold red"
324
+
325
+ return f"[{style}]{used_str}/{max_str} {pct_str}[/{style}]"