citrus-code 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.
- citrus_code/__init__.py +0 -0
- citrus_code/__main__.py +5 -0
- citrus_code/agent/__init__.py +0 -0
- citrus_code/agent/agent.py +255 -0
- citrus_code/agent/cancellation.py +66 -0
- citrus_code/agent/context.py +20 -0
- citrus_code/agent/events.py +97 -0
- citrus_code/agent/hooks.py +37 -0
- citrus_code/agent/loop.py +429 -0
- citrus_code/agent/message_queue.py +44 -0
- citrus_code/agent/state.py +13 -0
- citrus_code/agent/tool_runner.py +161 -0
- citrus_code/agent/types.py +34 -0
- citrus_code/ai/__init__.py +0 -0
- citrus_code/ai/completion.py +30 -0
- citrus_code/ai/events.py +78 -0
- citrus_code/ai/provider.py +20 -0
- citrus_code/ai/providers/__init__.py +0 -0
- citrus_code/ai/providers/chat_completions.py +503 -0
- citrus_code/ai/providers/chat_stream_types.py +47 -0
- citrus_code/ai/providers/deepseek.py +104 -0
- citrus_code/ai/providers/fake.py +101 -0
- citrus_code/ai/providers/tool_call_buffer.py +73 -0
- citrus_code/ai/registry.py +40 -0
- citrus_code/ai/types.py +142 -0
- citrus_code/cli/__init__.py +5 -0
- citrus_code/cli/main.py +258 -0
- citrus_code/cli/preview.py +55 -0
- citrus_code/cli/prompts.py +52 -0
- citrus_code/cli/render.py +306 -0
- citrus_code/cli/session_files.py +75 -0
- citrus_code/cli/terminal.py +280 -0
- citrus_code/cli/theme.py +51 -0
- citrus_code/coding/__init__.py +0 -0
- citrus_code/coding/coding_session.py +287 -0
- citrus_code/coding/compaction/__init__.py +5 -0
- citrus_code/coding/compaction/controller.py +168 -0
- citrus_code/coding/compaction/summarize.py +125 -0
- citrus_code/coding/compaction/tokens.py +48 -0
- citrus_code/coding/context_hooks.py +41 -0
- citrus_code/coding/history.py +47 -0
- citrus_code/coding/project_context.py +44 -0
- citrus_code/coding/session/__init__.py +30 -0
- citrus_code/coding/session/manager.py +200 -0
- citrus_code/coding/session/projection.py +50 -0
- citrus_code/coding/session/recovery.py +33 -0
- citrus_code/coding/session/storage.py +138 -0
- citrus_code/coding/session/types.py +108 -0
- citrus_code/coding/tools/__init__.py +35 -0
- citrus_code/coding/tools/bash.py +5 -0
- citrus_code/coding/tools/edit.py +101 -0
- citrus_code/coding/tools/edit_text.py +172 -0
- citrus_code/coding/tools/factory.py +57 -0
- citrus_code/coding/tools/find.py +86 -0
- citrus_code/coding/tools/grep.py +151 -0
- citrus_code/coding/tools/ls.py +61 -0
- citrus_code/coding/tools/mutation.py +39 -0
- citrus_code/coding/tools/paths.py +11 -0
- citrus_code/coding/tools/powershell.py +5 -0
- citrus_code/coding/tools/processes.py +103 -0
- citrus_code/coding/tools/read.py +133 -0
- citrus_code/coding/tools/shell.py +218 -0
- citrus_code/coding/tools/truncate.py +87 -0
- citrus_code/coding/tools/write.py +94 -0
- citrus_code/config.py +34 -0
- citrus_code-0.1.0.dist-info/METADATA +412 -0
- citrus_code-0.1.0.dist-info/RECORD +70 -0
- citrus_code-0.1.0.dist-info/WHEEL +4 -0
- citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
- citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
citrus_code/cli/main.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Parse CLI options and assemble providers, tools, storage, and terminal UI."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ..ai.provider import Provider
|
|
15
|
+
from ..ai.providers.deepseek import DEEPSEEK_VISION_MODEL_IDS, DeepSeekProvider
|
|
16
|
+
from ..ai.providers.fake import FakeProvider
|
|
17
|
+
from ..ai.types import ModelOptions, ModelSpec
|
|
18
|
+
from ..coding.coding_session import CodingSession
|
|
19
|
+
from ..coding.compaction.tokens import CONTEXT_WINDOW
|
|
20
|
+
from ..coding.context_hooks import SystemPromptHooks
|
|
21
|
+
from ..coding.project_context import load_project_context
|
|
22
|
+
from ..coding.session import SessionManager
|
|
23
|
+
from ..coding.session.recovery import recover_pending_tools
|
|
24
|
+
from ..coding.tools import READ_ONLY_TOOLS, create_tools
|
|
25
|
+
from ..config import load_connection_config
|
|
26
|
+
from .prompts import build_system_prompt
|
|
27
|
+
from .render import Console
|
|
28
|
+
from .session_files import (
|
|
29
|
+
LaunchSettings,
|
|
30
|
+
ShellName,
|
|
31
|
+
load_settings,
|
|
32
|
+
save_settings,
|
|
33
|
+
session_lease,
|
|
34
|
+
session_paths,
|
|
35
|
+
)
|
|
36
|
+
from .terminal import InteractiveTerminal, cancel_on_sigint, run_turn
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Options:
|
|
41
|
+
prompt: str | None
|
|
42
|
+
cwd: Path | None
|
|
43
|
+
session_dir: Path | None
|
|
44
|
+
resume: str | None
|
|
45
|
+
sessions: bool
|
|
46
|
+
fake: bool
|
|
47
|
+
shell: ShellName | None
|
|
48
|
+
tools: str | None
|
|
49
|
+
model: str | None
|
|
50
|
+
max_turns: int | None
|
|
51
|
+
max_output_tokens: int
|
|
52
|
+
model_timeout: int
|
|
53
|
+
verbose: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def positive_integer(value: str) -> int:
|
|
57
|
+
number = int(value)
|
|
58
|
+
if number < 1:
|
|
59
|
+
raise argparse.ArgumentTypeError("必须是大于 0 的整数")
|
|
60
|
+
return number
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_options(argv: list[str] | None) -> Options:
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
prog="citrus",
|
|
66
|
+
description="Python coding agent;不传任务进入交互模式,传入任务则单次运行。",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument("prompt", nargs="?", help="单次任务;多词任务请用引号包裹")
|
|
69
|
+
parser.add_argument("--cwd", type=Path, help="工作目录;恢复时默认使用会话原目录")
|
|
70
|
+
parser.add_argument("--session-dir", type=Path, help="会话目录,默认 <工作目录>/.citrus-code/sessions")
|
|
71
|
+
parser.add_argument("--resume", nargs="?", const="latest", metavar="FILE", help="恢复指定 JSONL 文件;省略路径时恢复当前会话目录中最近的文件")
|
|
72
|
+
parser.add_argument("--sessions", action="store_true", help="列出当前会话目录的文件并退出")
|
|
73
|
+
parser.add_argument("--fake", action="store_true", help="离线固定文字响应,不调用远程模型")
|
|
74
|
+
parser.add_argument("--shell", choices=("bash", "powershell"), help="新会话的命令工具;Windows 默认 powershell,其他平台默认 bash")
|
|
75
|
+
parser.add_argument("--tools", choices=("coding", "read-only", "all"), help="新会话的工具组合,默认 coding")
|
|
76
|
+
parser.add_argument("--model", help="模型 ID;恢复时须与会话记录匹配")
|
|
77
|
+
parser.add_argument("--max-turns", type=positive_integer, help="本次运行的模型调用轮数上限,默认不限制")
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--max-output-tokens", type=positive_integer, default=32768,
|
|
80
|
+
help="每次模型调用的输出上限,默认 32768;工具参数和文件正文也计入",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--model-timeout", type=positive_integer, default=300,
|
|
84
|
+
help="等待模型响应数据的最长空闲时间(秒),默认 300;不是整项任务的总时限",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument("--verbose", action="store_true", help="同时打印成功工具调用的完整返回文字")
|
|
87
|
+
args = parser.parse_args(argv)
|
|
88
|
+
if args.sessions and (args.prompt is not None or args.resume is not None):
|
|
89
|
+
parser.error("--sessions 请单独使用,可搭配 --cwd 和 --session-dir")
|
|
90
|
+
prompt = args.prompt
|
|
91
|
+
if not args.sessions and prompt is None:
|
|
92
|
+
if not sys.stdin.isatty():
|
|
93
|
+
prompt = sys.stdin.read().strip()
|
|
94
|
+
if not prompt:
|
|
95
|
+
parser.error("标准输入为空;请传入任务或在终端中启动交互模式")
|
|
96
|
+
elif not sys.stdout.isatty():
|
|
97
|
+
parser.error("交互模式需要终端输出;重定向输出时请传入单次任务")
|
|
98
|
+
if prompt is not None and not prompt.strip():
|
|
99
|
+
parser.error("任务不能为空")
|
|
100
|
+
return Options(
|
|
101
|
+
prompt, args.cwd, args.session_dir, args.resume, args.sessions, args.fake,
|
|
102
|
+
args.shell, args.tools, args.model, args.max_turns, args.max_output_tokens,
|
|
103
|
+
args.model_timeout, args.verbose,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def choose_tools(options: Options, default_shell: ShellName | None = None) -> LaunchSettings:
|
|
108
|
+
shell: ShellName = options.shell or default_shell or ("powershell" if os.name == "nt" else "bash")
|
|
109
|
+
if shell == "powershell" and os.name != "nt":
|
|
110
|
+
raise ValueError("当前内置 powershell 工具只支持 Windows")
|
|
111
|
+
if options.tools == "read-only":
|
|
112
|
+
names = list(READ_ONLY_TOOLS)
|
|
113
|
+
elif options.tools == "all":
|
|
114
|
+
names = ["read", "bash", "edit", "write", "grep", "find", "ls"]
|
|
115
|
+
if os.name == "nt":
|
|
116
|
+
names.append("powershell")
|
|
117
|
+
else:
|
|
118
|
+
names = ["read", shell, "edit", "write"]
|
|
119
|
+
return LaunchSettings(shell=shell, tools=names)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def list_sessions(directory: Path) -> None:
|
|
123
|
+
paths = session_paths(directory)
|
|
124
|
+
print(f"会话目录:{directory}")
|
|
125
|
+
if not paths:
|
|
126
|
+
print("没有保存的会话。")
|
|
127
|
+
for path in paths:
|
|
128
|
+
modified = datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat(timespec="seconds")
|
|
129
|
+
print(f"{modified} {path}")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def run_application(options: Options) -> int:
|
|
133
|
+
launch_cwd = (options.cwd or Path.cwd()).expanduser().resolve()
|
|
134
|
+
if not launch_cwd.is_dir():
|
|
135
|
+
raise NotADirectoryError(f"工作目录不存在:{launch_cwd}")
|
|
136
|
+
directory = (
|
|
137
|
+
options.session_dir.expanduser().resolve()
|
|
138
|
+
if options.session_dir is not None
|
|
139
|
+
else launch_cwd / ".citrus-code" / "sessions"
|
|
140
|
+
)
|
|
141
|
+
if options.sessions:
|
|
142
|
+
list_sessions(directory)
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
console = Console(options.verbose, max_output_tokens=options.max_output_tokens)
|
|
146
|
+
async with AsyncExitStack() as resources:
|
|
147
|
+
if options.resume is not None:
|
|
148
|
+
if options.resume == "latest":
|
|
149
|
+
paths = session_paths(directory)
|
|
150
|
+
if not paths:
|
|
151
|
+
raise FileNotFoundError(f"没有可以恢复的会话:{directory}")
|
|
152
|
+
path = paths[0]
|
|
153
|
+
else:
|
|
154
|
+
path = Path(options.resume).expanduser().resolve()
|
|
155
|
+
if not path.is_file():
|
|
156
|
+
raise FileNotFoundError(f"会话文件不存在:{path}")
|
|
157
|
+
resources.enter_context(session_lease(path))
|
|
158
|
+
manager = SessionManager.load(path)
|
|
159
|
+
cwd = Path(manager.header.cwd).expanduser().resolve()
|
|
160
|
+
if options.cwd is not None and launch_cwd != cwd:
|
|
161
|
+
raise ValueError(f"--cwd 与会话原工作目录不一致:{cwd}")
|
|
162
|
+
if not cwd.is_dir():
|
|
163
|
+
raise NotADirectoryError(f"会话原工作目录不存在:{cwd}")
|
|
164
|
+
stored_settings = load_settings(path)
|
|
165
|
+
if stored_settings is not None:
|
|
166
|
+
requested = choose_tools(options, default_shell=stored_settings.shell)
|
|
167
|
+
if options.shell is not None and requested.shell != stored_settings.shell:
|
|
168
|
+
raise ValueError("恢复时沿用原命令工具;更换工具请创建新会话")
|
|
169
|
+
if options.tools is not None and requested.tools != stored_settings.tools:
|
|
170
|
+
raise ValueError("恢复时沿用原工具组合;更换工具请创建新会话")
|
|
171
|
+
settings = stored_settings
|
|
172
|
+
else:
|
|
173
|
+
settings = choose_tools(options)
|
|
174
|
+
console.note("[恢复] 此会话没有 CLI 工具配置,使用当前选择的工具组合。")
|
|
175
|
+
else:
|
|
176
|
+
cwd = launch_cwd
|
|
177
|
+
manager = SessionManager(cwd)
|
|
178
|
+
path = directory / f"{manager.header.id}.jsonl"
|
|
179
|
+
settings = choose_tools(options)
|
|
180
|
+
resources.enter_context(session_lease(path))
|
|
181
|
+
|
|
182
|
+
context_files = load_project_context(cwd)
|
|
183
|
+
context = manager.build_context()
|
|
184
|
+
fake = options.fake or context.provider == "fake"
|
|
185
|
+
provider: Provider
|
|
186
|
+
model: ModelSpec
|
|
187
|
+
if fake:
|
|
188
|
+
if context.provider not in (None, "fake"):
|
|
189
|
+
raise ValueError("--fake 不能用于恢复真实模型会话")
|
|
190
|
+
fake_provider = FakeProvider(
|
|
191
|
+
chunks=["这是离线模拟回答。", "当前入口已连接事件流、", "会话保存和消息队列。",
|
|
192
|
+
"模拟模型只输出预设文字,", "不会执行你的编程任务。\n"],
|
|
193
|
+
delay_seconds=0.25,
|
|
194
|
+
)
|
|
195
|
+
provider = fake_provider
|
|
196
|
+
model = fake_provider.get_models()[0]
|
|
197
|
+
if options.model is not None and options.model != model.id:
|
|
198
|
+
raise ValueError("离线模式的模型 ID 为 fake-text")
|
|
199
|
+
else:
|
|
200
|
+
if context.provider not in (None, "deepseek"):
|
|
201
|
+
raise ValueError(f"CLI 尚未接入此 Provider:{context.provider}")
|
|
202
|
+
config = load_connection_config()
|
|
203
|
+
model_id = options.model or context.model_id or config.model_id
|
|
204
|
+
model = ModelSpec(
|
|
205
|
+
id=model_id, name=model_id, provider=config.provider_id,
|
|
206
|
+
api="chat-completions", base_url=config.base_url,
|
|
207
|
+
context_window=CONTEXT_WINDOW,
|
|
208
|
+
supports_images=model_id in DEEPSEEK_VISION_MODEL_IDS,
|
|
209
|
+
)
|
|
210
|
+
client = await resources.enter_async_context(httpx.AsyncClient(
|
|
211
|
+
timeout=httpx.Timeout(connect=30.0, read=options.model_timeout, write=120.0, pool=30.0),
|
|
212
|
+
))
|
|
213
|
+
provider = DeepSeekProvider(client, model, config.api_key)
|
|
214
|
+
|
|
215
|
+
if context.provider is not None and (
|
|
216
|
+
context.provider != model.provider or context.model_id != model.id
|
|
217
|
+
):
|
|
218
|
+
raise ValueError("恢复时必须使用会话记录的模型,当前尚未提供模型切换入口")
|
|
219
|
+
tools = create_tools(cwd, settings.tools)
|
|
220
|
+
system_prompt = build_system_prompt(
|
|
221
|
+
cwd, settings.tools, context_files, supports_images=model.supports_images,
|
|
222
|
+
)
|
|
223
|
+
if options.resume is None:
|
|
224
|
+
manager.save(path)
|
|
225
|
+
save_settings(path, settings)
|
|
226
|
+
|
|
227
|
+
recovered = recover_pending_tools(manager)
|
|
228
|
+
session = CodingSession(
|
|
229
|
+
provider=provider, model=model, tools=tools, session_manager=manager,
|
|
230
|
+
system_prompt=system_prompt,
|
|
231
|
+
hooks=SystemPromptHooks(system_prompt),
|
|
232
|
+
options=ModelOptions(max_output_tokens=options.max_output_tokens),
|
|
233
|
+
max_turns=options.max_turns,
|
|
234
|
+
)
|
|
235
|
+
console.welcome(
|
|
236
|
+
cwd, model, settings.tools, path, [item.path for item in context_files],
|
|
237
|
+
fake=fake, resumed=options.resume is not None, interactive=options.prompt is None,
|
|
238
|
+
)
|
|
239
|
+
if recovered:
|
|
240
|
+
console.note(f"[恢复] 为 {recovered} 个缺少结果的工具调用补充了执行状态未知记录。")
|
|
241
|
+
if options.resume is not None and options.prompt is None:
|
|
242
|
+
console.history(session, limit=4)
|
|
243
|
+
if options.prompt is not None:
|
|
244
|
+
with cancel_on_sigint(session, console):
|
|
245
|
+
outcome = await run_turn(session, console, options.prompt)
|
|
246
|
+
return outcome.exit_code
|
|
247
|
+
return await InteractiveTerminal(session, console).run()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main(argv: list[str] | None = None) -> int:
|
|
251
|
+
try:
|
|
252
|
+
options = parse_options(argv)
|
|
253
|
+
return asyncio.run(run_application(options))
|
|
254
|
+
except KeyboardInterrupt:
|
|
255
|
+
return 130
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
print(f"citrus: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
258
|
+
return 2
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""A bounded Markdown preview rendered by the existing prompt application."""
|
|
2
|
+
|
|
3
|
+
from prompt_toolkit import PromptSession
|
|
4
|
+
from prompt_toolkit.filters import Condition
|
|
5
|
+
from prompt_toolkit.formatted_text import ANSI, FormattedText
|
|
6
|
+
from prompt_toolkit.layout import ConditionalContainer, HSplit, Layout, Window
|
|
7
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
8
|
+
|
|
9
|
+
from .render import Console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MarkdownPreview:
|
|
13
|
+
def __init__(self, console: Console, prompt: PromptSession[str]) -> None:
|
|
14
|
+
self.console = console
|
|
15
|
+
self.prompt = prompt
|
|
16
|
+
self._cache_key: tuple[str, int, int] | None = None
|
|
17
|
+
self._cache = ANSI("")
|
|
18
|
+
original = prompt.layout
|
|
19
|
+
preview = ConditionalContainer(
|
|
20
|
+
HSplit([
|
|
21
|
+
Window(FormattedTextControl(FormattedText([
|
|
22
|
+
("class:prompt", " Citrus Code"),
|
|
23
|
+
("class:status.dim", " · 正在生成"),
|
|
24
|
+
])), height=1),
|
|
25
|
+
Window(
|
|
26
|
+
FormattedTextControl(self._text), dont_extend_height=True,
|
|
27
|
+
wrap_lines=False, always_hide_cursor=True,
|
|
28
|
+
),
|
|
29
|
+
Window(height=1),
|
|
30
|
+
]),
|
|
31
|
+
filter=Condition(lambda: (
|
|
32
|
+
bool(console.response) and not prompt.app.is_done
|
|
33
|
+
and prompt.app.output.get_size().rows >= 12
|
|
34
|
+
)),
|
|
35
|
+
)
|
|
36
|
+
# Keep the existing buffers, completion menus, search and key bindings.
|
|
37
|
+
prompt.layout = Layout(HSplit([preview, original.container]), original.current_window)
|
|
38
|
+
prompt.app.layout = prompt.layout
|
|
39
|
+
console.set_preview_listener(prompt.app.invalidate)
|
|
40
|
+
|
|
41
|
+
def _text(self) -> ANSI:
|
|
42
|
+
size = self.prompt.app.output.get_size()
|
|
43
|
+
width = max(10, size.columns - 2)
|
|
44
|
+
height = max(2, min(18, size.rows - 11))
|
|
45
|
+
key = (self.console.response, width, height)
|
|
46
|
+
if key != self._cache_key:
|
|
47
|
+
lines = self.console.preview_ansi(width).splitlines()
|
|
48
|
+
if len(lines) > height:
|
|
49
|
+
lines = ["… 较早内容将在生成完成后显示"] + lines[-(height - 1):]
|
|
50
|
+
self._cache = ANSI("\n".join(" " + line for line in lines))
|
|
51
|
+
self._cache_key = key
|
|
52
|
+
return self._cache
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
self.console.set_preview_listener(None)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ..coding.project_context import ProjectContextFile
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_system_prompt(
|
|
7
|
+
cwd: Path,
|
|
8
|
+
tool_names: list[str],
|
|
9
|
+
context_files: list[ProjectContextFile],
|
|
10
|
+
*,
|
|
11
|
+
supports_images: bool = False,
|
|
12
|
+
) -> str:
|
|
13
|
+
base_prompt = (
|
|
14
|
+
f"你是一个 coding agent,工作目录是 {cwd}。\n"
|
|
15
|
+
f"当前可用工具:{', '.join(tool_names)}。\n"
|
|
16
|
+
"先读取相关文件,再进行修改,以实际工具结果为依据。\n"
|
|
17
|
+
"用 edit 精确修改,用 write 创建文件或完整重写。"
|
|
18
|
+
"edit 参数是 path 和 edits;每项包含 oldText、newText。"
|
|
19
|
+
"同一批替换针对原文件定位,oldText 必须唯一且互不重叠。\n"
|
|
20
|
+
"生成较大文件时,先写可运行的骨架,再用 edit 分批补充,"
|
|
21
|
+
"避免一次工具调用携带过长内容。只有收到工具结果后,才能判断修改是否成功。\n"
|
|
22
|
+
"bash 工具使用 Bash 语法,powershell 工具使用 PowerShell 语法。\n"
|
|
23
|
+
"如果历史中的工具结果说明执行状态未知,先检查实际文件、日志或进程状态,"
|
|
24
|
+
"再决定下一步。\n"
|
|
25
|
+
"切换对话分支不会撤销项目文件的修改。\n"
|
|
26
|
+
"完成任务后简要说明修改、依据以及尚未完成的工作。使用中文回答。"
|
|
27
|
+
)
|
|
28
|
+
if "powershell" in tool_names:
|
|
29
|
+
base_prompt += (
|
|
30
|
+
"\nPowerShell 命令按 Windows PowerShell 5.1 兼容语法编写,不使用 &&、|| 或 Bash heredoc。"
|
|
31
|
+
"反斜杠不是 PowerShell 的引号转义符;正则、路径和不需要变量展开的文字优先用单引号。"
|
|
32
|
+
"每次命令使用独立会话,变量和 cd 不会传给下一次调用。"
|
|
33
|
+
"复杂 Python、JavaScript 和多层引号代码先用 write 写成脚本文件,再运行脚本,避免嵌套 -c/-e。"
|
|
34
|
+
"检查命令退出状态及预期文件,不用 Out-Null 隐藏排错所需的输出。"
|
|
35
|
+
)
|
|
36
|
+
if supports_images:
|
|
37
|
+
base_prompt += (
|
|
38
|
+
"\n当前模型配置已启用图片理解。用户给出本地图片路径时,"
|
|
39
|
+
"先调用 read 取得图片附件,再根据实际图像回答,不要仅凭路径声称无法看图。"
|
|
40
|
+
"支持 PNG、JPEG、GIF 和 WebP,其他图片格式需先转换。"
|
|
41
|
+
"SVG 和 HTML 是源码;分析源码不等于已经看到浏览器渲染结果,"
|
|
42
|
+
"需要视觉检查时应取得截图后读取。"
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
base_prompt += "\n当前模型配置未启用图片输入;读取图片得到的附件不会发送给模型。"
|
|
46
|
+
if not context_files:
|
|
47
|
+
return base_prompt
|
|
48
|
+
|
|
49
|
+
sections = [base_prompt, "以下是当前项目的工作约定,请在执行任务时遵守。"]
|
|
50
|
+
for context_file in context_files:
|
|
51
|
+
sections.append(f"项目指令来源:{context_file.path}\n\n{context_file.content}")
|
|
52
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Rich transcript rendering; the terminal owns interactive cursor movement."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from rich.console import Console as RichConsole, Group
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.padding import Padding
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from ..agent.events import (
|
|
18
|
+
AgentEndEvent, AgentEvent, AssistantMessageCommittedEvent,
|
|
19
|
+
ContextCompactedEvent, MessageAcceptedEvent, ModelEvent,
|
|
20
|
+
ToolExecutionEndEvent, ToolExecutionStartEvent, TurnStartEvent,
|
|
21
|
+
)
|
|
22
|
+
from ..ai.events import DoneEvent, ErrorEvent, StartEvent, TextDeltaEvent, TextStartEvent, ToolCallStartEvent
|
|
23
|
+
from ..ai.types import Message, ModelSpec, TextContent, ToolCall
|
|
24
|
+
from ..coding.coding_session import CodingSession
|
|
25
|
+
from ..coding.session import CompactionEntry, SessionMessageEntry
|
|
26
|
+
from .theme import ACCENT, BORDER, LOGO, RICH_THEME, clean_text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def message_text(message: Message) -> str:
|
|
30
|
+
if isinstance(message.content, str):
|
|
31
|
+
return clean_text(message.content)
|
|
32
|
+
return clean_text("\n".join(block.text for block in message.content if isinstance(block, TextContent)))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def markdown(text: str) -> Markdown:
|
|
36
|
+
return Markdown(clean_text(text), code_theme="monokai", hyperlinks=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Console:
|
|
40
|
+
def __init__(self, verbose: bool = False, *, max_output_tokens: int | None = None) -> None:
|
|
41
|
+
self.verbose = verbose
|
|
42
|
+
self.max_output_tokens = max_output_tokens
|
|
43
|
+
self.rich = RichConsole(theme=RICH_THEME, markup=False, highlight=False, emoji=False)
|
|
44
|
+
self.model_name = ""
|
|
45
|
+
self.supports_images = False
|
|
46
|
+
self.phase = "就绪"
|
|
47
|
+
self._response = ""
|
|
48
|
+
self._preview_listener: Callable[[], None] | None = None
|
|
49
|
+
self._live: Live | None = None
|
|
50
|
+
self._started = 0.0
|
|
51
|
+
self._tool_started: dict[str, float] = {}
|
|
52
|
+
self._input_tokens = 0
|
|
53
|
+
self._output_tokens = 0
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def response(self) -> str:
|
|
57
|
+
return self._response
|
|
58
|
+
|
|
59
|
+
def set_preview_listener(self, listener: Callable[[], None] | None) -> None:
|
|
60
|
+
self._preview_listener = listener
|
|
61
|
+
|
|
62
|
+
def _refresh(self) -> None:
|
|
63
|
+
if self._preview_listener is not None:
|
|
64
|
+
self._preview_listener()
|
|
65
|
+
elif self._response and self.rich.is_terminal and not self.rich.is_dumb_terminal:
|
|
66
|
+
content = Group(Text("Citrus Code", style="accent"), markdown(self._response))
|
|
67
|
+
if self._live is None:
|
|
68
|
+
self._live = Live(
|
|
69
|
+
content, console=self.rich, transient=True, refresh_per_second=8,
|
|
70
|
+
redirect_stdout=False, redirect_stderr=False,
|
|
71
|
+
)
|
|
72
|
+
self._live.start()
|
|
73
|
+
else:
|
|
74
|
+
self._live.update(content)
|
|
75
|
+
|
|
76
|
+
def finish_response(self, text: str | None = None) -> None:
|
|
77
|
+
content = self._response if text is None else text
|
|
78
|
+
self._response = ""
|
|
79
|
+
if self._live is not None:
|
|
80
|
+
self._live.stop()
|
|
81
|
+
self._live = None
|
|
82
|
+
self._refresh()
|
|
83
|
+
if content.strip():
|
|
84
|
+
self.rich.print(Padding(Group(
|
|
85
|
+
Text("Citrus Code", style="accent"), markdown(content),
|
|
86
|
+
), (1, 1, 0, 1)))
|
|
87
|
+
|
|
88
|
+
def preview_ansi(self, width: int) -> str:
|
|
89
|
+
"""Format a frame only; no cursor control or direct terminal output."""
|
|
90
|
+
buffer = StringIO()
|
|
91
|
+
renderer = RichConsole(
|
|
92
|
+
file=buffer, width=max(10, width), force_terminal=True,
|
|
93
|
+
color_system="truecolor", theme=RICH_THEME, markup=False,
|
|
94
|
+
highlight=False, emoji=False,
|
|
95
|
+
)
|
|
96
|
+
renderer.print(markdown(self._response))
|
|
97
|
+
return buffer.getvalue()
|
|
98
|
+
|
|
99
|
+
def welcome(
|
|
100
|
+
self, cwd: Path, model: ModelSpec, tools: list[str], session_path: Path,
|
|
101
|
+
context_paths: list[Path], *, fake: bool, resumed: bool, interactive: bool,
|
|
102
|
+
) -> None:
|
|
103
|
+
self.model_name = model.id
|
|
104
|
+
self.supports_images = model.supports_images
|
|
105
|
+
if interactive:
|
|
106
|
+
left = Group(
|
|
107
|
+
Text(LOGO, style="accent"), Text(),
|
|
108
|
+
Text("Citrus Code · Python coding agent", style="bold"),
|
|
109
|
+
Text(model.id + (" · 图像已启用" if model.supports_images else ""), style="muted"),
|
|
110
|
+
Text(str(cwd), style="muted"),
|
|
111
|
+
)
|
|
112
|
+
right = Group(
|
|
113
|
+
Text("继续你的项目" if resumed else "从一个具体任务开始", style="accent"),
|
|
114
|
+
Text(), Text("/help 查看全部命令"),
|
|
115
|
+
Text("/history 回看当前对话"), Text("/tree 查看与切换分支"),
|
|
116
|
+
Text(), Text("Enter 发送 · Alt+Enter 换行", style="muted"),
|
|
117
|
+
Text("Ctrl+C 取消 · Ctrl+D 退出", style="muted"),
|
|
118
|
+
)
|
|
119
|
+
if self.rich.width >= 82:
|
|
120
|
+
grid = Table.grid(expand=True, padding=(0, 2))
|
|
121
|
+
grid.add_column(ratio=1)
|
|
122
|
+
grid.add_column(ratio=1)
|
|
123
|
+
grid.add_row(left, right)
|
|
124
|
+
self.rich.print(Panel(
|
|
125
|
+
grid, title=Text(" Citrus Code v0.1.0 ", style="accent"),
|
|
126
|
+
title_align="left", border_style=ACCENT, padding=(1, 2),
|
|
127
|
+
))
|
|
128
|
+
else:
|
|
129
|
+
self.rich.print(Panel(
|
|
130
|
+
Group(left, Text(), right), title=Text(" Citrus Code v0.1.0 ", style="accent"),
|
|
131
|
+
title_align="left", border_style=ACCENT, padding=(1, 1),
|
|
132
|
+
))
|
|
133
|
+
else:
|
|
134
|
+
self.rich.print(Text.assemble(("Citrus Code", "accent"), f" {model.id} ", (str(cwd), "muted")))
|
|
135
|
+
details = Text("工具 ", style="muted")
|
|
136
|
+
details.append(" · ".join(tools))
|
|
137
|
+
details.append("\n项目指令 ", style="muted")
|
|
138
|
+
details.append(" · ".join(str(path) for path in context_paths) or "无")
|
|
139
|
+
details.append("\n会话 ", style="muted")
|
|
140
|
+
details.append(str(session_path))
|
|
141
|
+
details.append("\n上下文 1M · 60% 自动压缩", style="muted")
|
|
142
|
+
if self.max_output_tokens is not None:
|
|
143
|
+
details.append(f"\n单次输出上限 {self.max_output_tokens:,} tokens", style="muted")
|
|
144
|
+
self.rich.print(Padding(details, (0, 1, 1, 1)))
|
|
145
|
+
if fake:
|
|
146
|
+
self.note("[离线模式] 只输出预设文字,不执行编程任务。")
|
|
147
|
+
|
|
148
|
+
def begin_turn(self, text: str) -> None:
|
|
149
|
+
self.finish_response()
|
|
150
|
+
self._started = time.monotonic()
|
|
151
|
+
self._input_tokens = self._output_tokens = 0
|
|
152
|
+
self.phase = "思考中"
|
|
153
|
+
self.rich.print(Panel(
|
|
154
|
+
Text(clean_text(text)), title=Text("你", style="info"),
|
|
155
|
+
title_align="left", border_style=BORDER, padding=(0, 1),
|
|
156
|
+
))
|
|
157
|
+
self._refresh()
|
|
158
|
+
|
|
159
|
+
def note(self, text: str) -> None:
|
|
160
|
+
style = "error" if any(word in text for word in ("失败", "错误")) else "muted"
|
|
161
|
+
self.rich.print(Padding(Text(clean_text(text), style=style), (0, 1)))
|
|
162
|
+
|
|
163
|
+
def event(self, event: AgentEvent) -> None:
|
|
164
|
+
if isinstance(event, TurnStartEvent):
|
|
165
|
+
self.phase = "思考中"
|
|
166
|
+
elif isinstance(event, ModelEvent):
|
|
167
|
+
model_event = event.event
|
|
168
|
+
if isinstance(model_event, StartEvent):
|
|
169
|
+
self.phase = "生成中"
|
|
170
|
+
self._response = ""
|
|
171
|
+
elif isinstance(model_event, TextStartEvent):
|
|
172
|
+
if self._response:
|
|
173
|
+
self._response += "\n\n"
|
|
174
|
+
elif isinstance(model_event, TextDeltaEvent):
|
|
175
|
+
self._response += clean_text(model_event.delta)
|
|
176
|
+
elif isinstance(model_event, ToolCallStartEvent):
|
|
177
|
+
self.phase = "准备工具调用"
|
|
178
|
+
elif isinstance(model_event, DoneEvent):
|
|
179
|
+
self.finish_response(message_text(model_event.message))
|
|
180
|
+
elif isinstance(model_event, ErrorEvent):
|
|
181
|
+
self.finish_response(message_text(model_event.error) or self._response)
|
|
182
|
+
elif isinstance(event, AssistantMessageCommittedEvent):
|
|
183
|
+
if event.message.usage is not None:
|
|
184
|
+
self._input_tokens += event.message.usage.input_tokens
|
|
185
|
+
self._output_tokens += event.message.usage.output_tokens
|
|
186
|
+
elif isinstance(event, MessageAcceptedEvent):
|
|
187
|
+
label = "引导消息已接纳" if event.source == "steering" else "后续任务已接纳"
|
|
188
|
+
self.rich.print(Panel(Text(message_text(event.message)), title=label, border_style="info"))
|
|
189
|
+
elif isinstance(event, ToolExecutionStartEvent):
|
|
190
|
+
self.phase = f"执行 {event.call.name}"
|
|
191
|
+
self._tool_started[event.call.id] = time.monotonic()
|
|
192
|
+
line = Text(f" {event.call.name} ", style="info")
|
|
193
|
+
line.append(self._call_summary(event.call), style="muted")
|
|
194
|
+
self.rich.print(line)
|
|
195
|
+
elif isinstance(event, ToolExecutionEndEvent):
|
|
196
|
+
self._tool_result(event)
|
|
197
|
+
self.phase = "思考中"
|
|
198
|
+
elif isinstance(event, ContextCompactedEvent):
|
|
199
|
+
self.note(f"上下文已压缩 {event.tokens_before:,} → {event.tokens_after:,} tokens;继续当前任务。")
|
|
200
|
+
elif isinstance(event, AgentEndEvent):
|
|
201
|
+
self.finish_response()
|
|
202
|
+
elapsed = time.monotonic() - self._started if self._started else 0
|
|
203
|
+
label = {"stop": "完成", "aborted": "已取消", "length": "回复被截断",
|
|
204
|
+
"max_turns": "达到轮数上限", "error": "运行错误"}[event.reason]
|
|
205
|
+
style = "success" if event.reason == "stop" else ("accent" if event.reason == "length" else "error")
|
|
206
|
+
line = Text(f" {label} · {elapsed:.1f}s", style=style)
|
|
207
|
+
if self._input_tokens or self._output_tokens:
|
|
208
|
+
line.append(f" · 输入 {self._input_tokens:,} / 输出 {self._output_tokens:,} tokens", style="muted")
|
|
209
|
+
self.rich.print(line)
|
|
210
|
+
self.rich.print()
|
|
211
|
+
if event.error_message:
|
|
212
|
+
self.note(event.error_message)
|
|
213
|
+
elif event.reason == "error":
|
|
214
|
+
self.note("运行失败,但未收到具体错误详情。请检查模型连接;已经完成的工具结果仍保留在会话中。")
|
|
215
|
+
elif event.reason == "length":
|
|
216
|
+
self.note("模型达到本次生成长度限制。可提高 --max-output-tokens,或将较大文件分批写入。")
|
|
217
|
+
self.phase = "就绪"
|
|
218
|
+
self._refresh()
|
|
219
|
+
|
|
220
|
+
def _call_summary(self, call: ToolCall) -> str:
|
|
221
|
+
parts: list[str] = []
|
|
222
|
+
for key in ("path", "command", "pattern"):
|
|
223
|
+
value = call.arguments.get(key)
|
|
224
|
+
if isinstance(value, str) and value:
|
|
225
|
+
parts.append(value)
|
|
226
|
+
return " ".join(clean_text(" · ".join(parts)).split())[:160]
|
|
227
|
+
|
|
228
|
+
def _tool_result(self, event: ToolExecutionEndEvent) -> None:
|
|
229
|
+
call, message = event.call, event.result.message
|
|
230
|
+
started = self._tool_started.pop(call.id, time.monotonic())
|
|
231
|
+
elapsed = time.monotonic() - started
|
|
232
|
+
text = message_text(message)
|
|
233
|
+
if not self.verbose and not message.is_error:
|
|
234
|
+
lines = text.splitlines()
|
|
235
|
+
preview = "\n".join(lines[:8])
|
|
236
|
+
omitted = len(lines) > 8 or len(preview) > 1800
|
|
237
|
+
text = preview[:1800]
|
|
238
|
+
if omitted:
|
|
239
|
+
text += "\n… 输出展示已折叠;使用 --verbose 启动可查看完整返回。"
|
|
240
|
+
language = "text"
|
|
241
|
+
if call.name == "edit":
|
|
242
|
+
language = "diff"
|
|
243
|
+
elif call.name == "read":
|
|
244
|
+
path = call.arguments.get("path")
|
|
245
|
+
if isinstance(path, str):
|
|
246
|
+
language = {".py": "python", ".ts": "typescript", ".js": "javascript",
|
|
247
|
+
".json": "json", ".toml": "toml", ".yaml": "yaml",
|
|
248
|
+
".yml": "yaml", ".md": "markdown", ".java": "java"}.get(Path(path).suffix, "text")
|
|
249
|
+
body = Syntax(text or "(无文字输出)", language, theme="monokai", word_wrap=True, background_color="default")
|
|
250
|
+
status = "失败" if message.is_error else "完成"
|
|
251
|
+
self.rich.print(Padding(Panel(
|
|
252
|
+
body, title=Text(f"{call.name} · {status} · {elapsed:.1f}s"),
|
|
253
|
+
title_align="left", border_style="error" if message.is_error else BORDER,
|
|
254
|
+
padding=(0, 1),
|
|
255
|
+
), (0, 1)))
|
|
256
|
+
for block in message.content:
|
|
257
|
+
if not isinstance(block, TextContent):
|
|
258
|
+
self.note(f"图片附件 {block.mime_type}")
|
|
259
|
+
|
|
260
|
+
def status(self, session: CodingSession) -> None:
|
|
261
|
+
manager = session.session_manager
|
|
262
|
+
estimate = session.compaction.last_estimate if session.compaction is not None else None
|
|
263
|
+
table = Table.grid(padding=(0, 2))
|
|
264
|
+
for label, value in (
|
|
265
|
+
("状态", self.phase), ("模型", self.model_name),
|
|
266
|
+
("模型输入", "文字、图片" if self.supports_images else "文字"),
|
|
267
|
+
("单次输出上限", f"{self.max_output_tokens:,} tokens" if self.max_output_tokens is not None else "由调用方配置"),
|
|
268
|
+
("轮次", str(session.agent.state.turn)),
|
|
269
|
+
("排队消息", "有" if session.has_queued_messages() else "无"),
|
|
270
|
+
("上下文", f"约 {estimate:,} / 1,000,000 tokens(上次检查)" if estimate is not None else "尚未估算"),
|
|
271
|
+
("当前节点", manager.leaf_id or "无"), ("会话文件", str(manager.file_path)),
|
|
272
|
+
):
|
|
273
|
+
table.add_row(Text(label, style="muted"), Text(clean_text(value)))
|
|
274
|
+
self.rich.print(Panel(table, title="会话状态", border_style=BORDER, title_align="left"))
|
|
275
|
+
|
|
276
|
+
def history(self, session: CodingSession, limit: int | None = None) -> None:
|
|
277
|
+
messages = [m for m in session.agent.state.messages if m.role in ("user", "assistant")]
|
|
278
|
+
if limit is not None:
|
|
279
|
+
messages = messages[-limit:]
|
|
280
|
+
for message in messages:
|
|
281
|
+
text = message_text(message)
|
|
282
|
+
if not text:
|
|
283
|
+
continue
|
|
284
|
+
if message.role == "assistant":
|
|
285
|
+
self.rich.print(Padding(Group(Text("Citrus Code", style="accent"), markdown(text[:6000])), (1, 1)))
|
|
286
|
+
else:
|
|
287
|
+
self.rich.print(Panel(Text(text[:6000]), title="你 / 历史背景", border_style=BORDER, title_align="left"))
|
|
288
|
+
if len(text) > 6000:
|
|
289
|
+
self.note("此消息展示已截断,完整内容保存在会话中。")
|
|
290
|
+
|
|
291
|
+
def tree(self, session: CodingSession) -> None:
|
|
292
|
+
manager = session.session_manager
|
|
293
|
+
table = Table(box=None, padding=(0, 1), expand=True)
|
|
294
|
+
for column in ("", "节点", "父节点", "内容"):
|
|
295
|
+
table.add_column(column, style="muted" if column == "父节点" else "")
|
|
296
|
+
for entry in manager.get_entries():
|
|
297
|
+
marker = ">" if entry.id == manager.leaf_id else ""
|
|
298
|
+
parent = entry.parent_id[:12] if entry.parent_id else "root"
|
|
299
|
+
if isinstance(entry, SessionMessageEntry):
|
|
300
|
+
label = f"{entry.message.role}: {' '.join(message_text(entry.message).split())[:90]}"
|
|
301
|
+
elif isinstance(entry, CompactionEntry):
|
|
302
|
+
label = f"压缩: {entry.tokens_before:,} → {entry.tokens_after:,} tokens"
|
|
303
|
+
else:
|
|
304
|
+
label = f"模型: {entry.provider}/{entry.model_id}"
|
|
305
|
+
table.add_row(marker, entry.id[:12], parent, Text(label))
|
|
306
|
+
self.rich.print(Panel(table, title="会话分支", border_style=BORDER, title_align="left"))
|