zrcoder 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zrcoder
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands
5
5
  Keywords: cli,agent,coding,terminal,openai
6
6
  Author: Zr
@@ -18,6 +18,7 @@ Classifier: Topic :: Software Development
18
18
  Requires-Dist: prompt-toolkit>=3.0.53
19
19
  Requires-Dist: pydantic-ai>=2.40.0
20
20
  Requires-Dist: python-dotenv>=1.2.3
21
+ Requires-Dist: questionary>=2.1.1
21
22
  Requires-Dist: rich>=15.0.0
22
23
  Requires-Python: >=3.12
23
24
  Project-URL: Homepage, https://github.com/ZRMYDYCG/ClaudeCode
@@ -28,7 +29,11 @@ Description-Content-Type: text/markdown
28
29
 
29
30
  # Zrcoder
30
31
 
31
- 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持 `/help` 等斜杠命令。
32
+ <p align="center">
33
+ <img src="docs/zrcoder-banner.jpg" alt="Zrcoder — AI Terminal CLI" width="100%">
34
+ </p>
35
+
36
+ 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持斜杠命令与会话恢复。
32
37
 
33
38
  ## 安装
34
39
 
@@ -48,7 +53,11 @@ pip install zrcoder
48
53
 
49
54
  ## 配置
50
55
 
51
- 在环境变量或项目目录的 `.env` 中设置(可参考 `.env.example`):
56
+ 设置环境变量,或在以下位置之一创建 `.env`(可参考 `.env.example`):
57
+
58
+ - 当前目录:`./.env`
59
+ - 用户配置:`~/.config/zrcoder/.env`
60
+ - 兼容路径:`~/.zrcoder.env`
52
61
 
53
62
  ```bash
54
63
  export API_KEY=your_api_key
@@ -63,7 +72,7 @@ export BASE_URL=https://api.example.com/v1
63
72
  zrcoder
64
73
  ```
65
74
 
66
- 常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
75
+ 常用命令:`/help`、`/status`、`/new`、`/resume`、`/api-detail`、`/exit`。
67
76
 
68
77
  ## 变更与发版
69
78
 
@@ -1,6 +1,10 @@
1
1
  # Zrcoder
2
2
 
3
- 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持 `/help` 等斜杠命令。
3
+ <p align="center">
4
+ <img src="docs/zrcoder-banner.jpg" alt="Zrcoder — AI Terminal CLI" width="100%">
5
+ </p>
6
+
7
+ 终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持斜杠命令与会话恢复。
4
8
 
5
9
  ## 安装
6
10
 
@@ -20,7 +24,11 @@ pip install zrcoder
20
24
 
21
25
  ## 配置
22
26
 
23
- 在环境变量或项目目录的 `.env` 中设置(可参考 `.env.example`):
27
+ 设置环境变量,或在以下位置之一创建 `.env`(可参考 `.env.example`):
28
+
29
+ - 当前目录:`./.env`
30
+ - 用户配置:`~/.config/zrcoder/.env`
31
+ - 兼容路径:`~/.zrcoder.env`
24
32
 
25
33
  ```bash
26
34
  export API_KEY=your_api_key
@@ -35,7 +43,7 @@ export BASE_URL=https://api.example.com/v1
35
43
  zrcoder
36
44
  ```
37
45
 
38
- 常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
46
+ 常用命令:`/help`、`/status`、`/new`、`/resume`、`/api-detail`、`/exit`。
39
47
 
40
48
  ## 变更与发版
41
49
 
@@ -8,10 +8,10 @@ from urllib.parse import urlparse, urlunparse
8
8
 
9
9
  from dotenv import load_dotenv
10
10
  from pydantic_ai import Agent
11
- from pydantic_ai.models.openai import OpenAIChatModel
12
11
  from pydantic_ai.providers.openai import OpenAIProvider
13
12
 
14
13
  from .hooks import hooks
14
+ from .openai_compat import CompatibleOpenAIChatModel
15
15
  from .tools import TOOLS
16
16
 
17
17
  load_dotenv()
@@ -41,7 +41,7 @@ def _normalize_openai_base_url(url: str) -> str:
41
41
 
42
42
  MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-v4-flash")
43
43
 
44
- model = OpenAIChatModel(
44
+ model = CompatibleOpenAIChatModel(
45
45
  MODEL_NAME,
46
46
  provider=OpenAIProvider(base_url=_normalize_openai_base_url(BASE_URL), api_key=API_KEY),
47
47
  )
@@ -0,0 +1,147 @@
1
+ """
2
+ 挂在 Agent 上的 hooks:
3
+ 1. API 调用元数据记录(/api-detail 命令用)
4
+ 2. API 请求失败时的自动重试(wrap_model_request)
5
+ 3. 工具执行异常的兜底处理(on_tool_execute_error)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from pydantic_ai.capabilities import Hooks
15
+ from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior
16
+
17
+ from core.ui.render import console
18
+
19
+ MAX_RETRIES = 3
20
+
21
+
22
+ @dataclass
23
+ class ApiCall:
24
+ """
25
+ 一次 model API 调用的元数据。before_model_request 创建并填充上半部分,
26
+ after_model_request 填充下半部分。
27
+ """
28
+
29
+ # request 侧
30
+ model: str
31
+ messages_count: int
32
+ # 这次发送给模型的 messages 中最后一条消息的最后一个 part
33
+ last_part: Any
34
+ tools: list[str]
35
+ # response 侧(after hook 填充)
36
+ finish_reason: str = ""
37
+ parts_kinds: list[str] = field(default_factory=list)
38
+ input_tokens: int = 0
39
+ output_tokens: int = 0
40
+
41
+
42
+ # 主循环在每轮 agent.iter() 之前清空它
43
+ api_call_log: list[ApiCall] = []
44
+
45
+ hooks = Hooks()
46
+
47
+
48
+ # ---------- API 调用记录 ----------
49
+
50
+
51
+ @hooks.on.before_model_request
52
+ async def _record_request(ctx: Any, request_context: Any) -> Any:
53
+ """
54
+ 每次发起 model 调用之前,创建一条 ApiCall 记录。
55
+ """
56
+ msgs = list(request_context.messages)
57
+ last_part = msgs[-1].parts[-1] if msgs and msgs[-1].parts else None
58
+ try:
59
+ tool_names = [t.name for t in request_context.model_request_parameters.function_tools]
60
+ except AttributeError:
61
+ tool_names = []
62
+ api_call_log.append(
63
+ ApiCall(
64
+ model=request_context.model.model_name,
65
+ messages_count=len(msgs),
66
+ last_part=last_part,
67
+ tools=tool_names,
68
+ )
69
+ )
70
+ return request_context
71
+
72
+
73
+ @hooks.on.after_model_request
74
+ async def _record_response(ctx: Any, *, request_context: Any, response: Any) -> Any:
75
+ """
76
+ 每次 model 调用返回后,填充上面这条 ApiCall 的 response 字段。
77
+ """
78
+ if api_call_log:
79
+ call = api_call_log[-1]
80
+ call.finish_reason = str(response.finish_reason) if response.finish_reason else "unknown"
81
+ call.parts_kinds = [p.part_kind for p in response.parts]
82
+ call.input_tokens = response.usage.input_tokens
83
+ call.output_tokens = response.usage.output_tokens
84
+ return response
85
+
86
+
87
+ # ---------- API 请求重试 ----------
88
+
89
+
90
+ @hooks.on.model_request
91
+ async def _retry_on_error(ctx: Any, *, request_context: Any, handler: Any) -> Any:
92
+ """
93
+ 包裹 model 请求,遇到可重试错误时自动指数退避重试。
94
+
95
+ 重试在 wrap 内部完成,对话历史和 before/after hooks 不受影响。
96
+ 兼容网关偶发的 UnexpectedModelBehavior(响应 schema 不对)也会重试。
97
+ """
98
+ for attempt in range(MAX_RETRIES + 1):
99
+ try:
100
+ return await handler(request_context)
101
+ except ModelHTTPError as e:
102
+ if e.status_code < 500:
103
+ raise
104
+ if attempt >= MAX_RETRIES:
105
+ console.print(f"[bold red]✗ HTTP {e.status_code},重试 {MAX_RETRIES} 次后仍失败[/]")
106
+ raise
107
+ wait = 2**attempt
108
+ console.print(
109
+ f"[bold yellow]⟳ HTTP {e.status_code},{wait}s 后重试 "
110
+ f"({attempt + 1}/{MAX_RETRIES})...[/]"
111
+ )
112
+ await asyncio.sleep(wait)
113
+ except UnexpectedModelBehavior as e:
114
+ if attempt >= MAX_RETRIES:
115
+ console.print(f"[bold red]✗ 模型响应异常,重试 {MAX_RETRIES} 次后仍失败:{e}[/]")
116
+ raise
117
+ wait = 2**attempt
118
+ console.print(
119
+ f"[bold yellow]⟳ 模型响应异常,{wait}s 后重试 ({attempt + 1}/{MAX_RETRIES})...[/]"
120
+ )
121
+ await asyncio.sleep(wait)
122
+ except ModelAPIError:
123
+ if attempt >= MAX_RETRIES:
124
+ console.print(f"[bold red]✗ 网络连接失败,重试 {MAX_RETRIES} 次后仍无法连接[/]")
125
+ raise
126
+ wait = 2**attempt
127
+ console.print(
128
+ f"[bold yellow]⟳ 网络连接失败,{wait}s 后重试 ({attempt + 1}/{MAX_RETRIES})...[/]"
129
+ )
130
+ await asyncio.sleep(wait)
131
+
132
+ raise RuntimeError("unreachable") # pragma: no cover
133
+
134
+
135
+ # ---------- 工具执行异常兜底 ----------
136
+
137
+
138
+ @hooks.on.tool_execute_error
139
+ async def _handle_tool_error(
140
+ ctx: Any, *, call: Any, tool_def: Any, args: Any, error: Exception
141
+ ) -> str:
142
+ """
143
+ 工具函数抛出未捕获异常时,不让进程崩溃,
144
+ 而是把错误信息作为 tool result 返回给模型,让它自行纠正。
145
+ """
146
+ console.print(f"[bold red]✗ 工具 {call.tool_name} 出错:{error}[/]")
147
+ return f"工具执行出错:{type(error).__name__}: {error}"
@@ -0,0 +1,44 @@
1
+ """
2
+ 对 OpenAI 兼容网关做响应清洗:部分服务商偶发返回不规范字段,
3
+ 导致 pydantic-ai 校验 ChatCompletion 失败(表现为「好一次、坏一次」)。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from openai.types import chat
11
+ from pydantic_ai.models import openai as openai_model
12
+ from pydantic_ai.models.openai import OpenAIChatModel
13
+
14
+
15
+ def _coerce_chat_completion_payload(data: dict[str, Any]) -> dict[str, Any]:
16
+ """
17
+ 尽量把兼容网关的松散 JSON 修成 OpenAI ChatCompletion 能过校验的形状。
18
+ """
19
+ if data.get("object") != "chat.completion":
20
+ data["object"] = "chat.completion"
21
+
22
+ choices = data.get("choices")
23
+ if isinstance(choices, list):
24
+ for i, choice in enumerate(choices):
25
+ if not isinstance(choice, dict):
26
+ continue
27
+ index = choice.get("index", i)
28
+ if isinstance(index, str) and index.isdigit():
29
+ choice["index"] = int(index)
30
+ elif not isinstance(index, int):
31
+ choice["index"] = i
32
+ if choice.get("finish_reason") is None:
33
+ choice["finish_reason"] = "stop"
34
+ return data
35
+
36
+
37
+ class CompatibleOpenAIChatModel(OpenAIChatModel):
38
+ """
39
+ 覆盖校验钩子:先 coerce,再走与父类相同的 _ChatCompletion 校验。
40
+ """
41
+
42
+ def _validate_completion(self, response: chat.ChatCompletion) -> Any:
43
+ data = _coerce_chat_completion_payload(response.model_dump())
44
+ return openai_model._ChatCompletion.model_validate(data)
@@ -0,0 +1,66 @@
1
+ """
2
+ Coding Agent 用到的三个工具:读文件、写文件、跑 shell 命令。
3
+
4
+ 每个工具自己处理已知错误:能靠换参数纠正的(路径不存在、没权限)抛 ModelRetry
5
+ 让模型重试,不能纠正的(二进制文件、命令出错)直接返回错误信息。
6
+ 意料之外的异常由 hooks 里的 on_tool_execute_error 统一兜底。
7
+ """
8
+
9
+ import subprocess
10
+
11
+ from pydantic_ai.exceptions import ModelRetry
12
+
13
+
14
+ def read_file(path: str) -> str:
15
+ """
16
+ 读取指定文件的内容。
17
+ """
18
+ try:
19
+ with open(path, encoding="utf-8") as f:
20
+ return f.read()
21
+ except FileNotFoundError as e:
22
+ raise ModelRetry(f"文件 {path} 不存在,请确认路径或换一个文件") from e
23
+ except PermissionError as e:
24
+ raise ModelRetry(f"没有权限读取 {path},请换一个可读的文件") from e
25
+ except IsADirectoryError as e:
26
+ raise ModelRetry(f"{path} 是一个目录,请指定目录下的具体文件") from e
27
+ except UnicodeDecodeError:
28
+ return f"错误:{path} 不是文本文件,无法读取"
29
+
30
+
31
+ def write_file(path: str, content: str) -> str:
32
+ """
33
+ 将内容写入指定文件。
34
+ """
35
+ try:
36
+ with open(path, "w", encoding="utf-8") as f:
37
+ f.write(content)
38
+ return f"已写入 {path}"
39
+ except FileNotFoundError as e:
40
+ raise ModelRetry(f"目录不存在,无法写入 {path},请换一个已存在的目录") from e
41
+ except PermissionError as e:
42
+ raise ModelRetry(f"没有权限写入 {path},请换一个可写的路径") from e
43
+ except OSError as e:
44
+ return f"错误:写入 {path} 失败 ({e})"
45
+
46
+
47
+ def run_command(command: str) -> str:
48
+ """
49
+ 执行一条 shell 命令并返回输出。
50
+ """
51
+ try:
52
+ result = subprocess.run(
53
+ command, shell=True, capture_output=True, text=True, errors="replace", timeout=10
54
+ )
55
+ output = result.stdout
56
+ if result.returncode != 0:
57
+ output += f"\n[错误] {result.stderr}"
58
+ return output or "(无输出)"
59
+ except subprocess.TimeoutExpired:
60
+ return "[错误] 命令执行超时(10秒)"
61
+ except OSError as e:
62
+ return f"[错误] 无法执行命令 ({e})"
63
+
64
+
65
+ # Pydantic AI 支持 tools=[plain_function],从函数签名 + docstring 自动生成 JSON Schema
66
+ TOOLS = [read_file, write_file, run_command]
@@ -12,6 +12,7 @@ from pydantic_ai import Agent
12
12
  from pydantic_graph import End
13
13
 
14
14
  from core.agent import MODEL_NAME, agent, api_call_log
15
+ from core.session import append_messages, new_session_id
15
16
  from core.ui.commands import COMMANDS, SessionState, print_divider, print_part
16
17
  from core.ui.render import console, print_welcome_banner
17
18
 
@@ -55,18 +56,19 @@ def handle_command(user_input: str, state: SessionState) -> CommandAction:
55
56
 
56
57
  def apply_result(state: SessionState, result: Any) -> None:
57
58
  """
58
- 跑完一轮 Agent 后,把结果同步到 SessionState
59
+ 跑完一轮 Agent 后,把结果同步到 SessionState,并追加写入会话文件。
59
60
  """
60
61
  state.history = result.all_messages()
61
62
  usage = result.usage
62
63
  state.input_tokens += usage.input_tokens
63
64
  state.output_tokens += usage.output_tokens
64
65
  state.last_api_calls = list(api_call_log)
66
+ append_messages(state.session_id, result.new_messages())
65
67
 
66
68
 
67
69
  async def run_agent_loop(user_input: str, state: SessionState) -> None:
68
70
  """
69
- 逐节点驱动 Agent 循环,每步实时打印。
71
+ 展开 agent.run_sync(),逐节点驱动 Agent 循环,每步实时打印。
70
72
  """
71
73
  api_call_log.clear()
72
74
 
@@ -82,7 +84,8 @@ async def run_agent_loop(user_input: str, state: SessionState) -> None:
82
84
 
83
85
  elif Agent.is_model_request_node(node):
84
86
  for request_part in node.request.parts:
85
- if getattr(request_part, "part_kind", None) == "tool-return":
87
+ kind = getattr(request_part, "part_kind", None)
88
+ if kind in ("tool-return", "retry-prompt"):
86
89
  print_part(request_part)
87
90
 
88
91
  apply_result(state, run.result)
@@ -90,7 +93,10 @@ async def run_agent_loop(user_input: str, state: SessionState) -> None:
90
93
 
91
94
 
92
95
  def main() -> None:
93
- state = SessionState(model_name=MODEL_NAME)
96
+ state = SessionState(
97
+ model_name=MODEL_NAME,
98
+ session_id=new_session_id(),
99
+ )
94
100
  print_welcome_banner("Zrcoder")
95
101
 
96
102
  while True:
@@ -109,7 +115,12 @@ def main() -> None:
109
115
  continue
110
116
 
111
117
  # 核心 Agent 循环:自己驱动节点流转,实时打印每一步
112
- asyncio.run(run_agent_loop(user_input, state))
118
+ try:
119
+ asyncio.run(run_agent_loop(user_input, state))
120
+ except KeyboardInterrupt:
121
+ console.print("\n[bold yellow]已中断[/]\n")
122
+ except Exception as e:
123
+ console.print(f"\n[bold red]✗ {type(e).__name__}: {e}[/]\n")
113
124
 
114
125
 
115
126
  if __name__ == "__main__":
@@ -0,0 +1,91 @@
1
+ """
2
+ 会话持久化:把对话历史写成 jsonl 文件,支持扫描和恢复历史会话。
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ import uuid
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pydantic_ai.messages import ModelMessagesTypeAdapter
15
+ from pydantic_core import to_jsonable_python
16
+
17
+ # 所有会话记录的根目录
18
+ STORAGE_ROOT = Path.home() / ".zrcoder" / "projects"
19
+
20
+
21
+ def sanitize_path(path: str) -> str:
22
+ """
23
+ 把项目绝对路径转码成合法的目录名:非字母数字字符一律换成 -
24
+ """
25
+ return re.sub(r"[^a-zA-Z0-9]", "-", path)
26
+
27
+
28
+ def project_dir() -> Path:
29
+ """
30
+ 当前项目(工作目录)对应的会话存储目录。
31
+ """
32
+ return STORAGE_ROOT / sanitize_path(str(Path.cwd()))
33
+
34
+
35
+ def new_session_id() -> str:
36
+ return str(uuid.uuid4())
37
+
38
+
39
+ def session_file(session_id: str) -> Path:
40
+ return project_dir() / f"{session_id}.jsonl"
41
+
42
+
43
+ def append_messages(session_id: str, messages: Any) -> None:
44
+ """
45
+ 把本轮新增的消息追加到会话文件末尾,一行一条。
46
+ """
47
+ if not session_id:
48
+ return
49
+ path = session_file(session_id)
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ with open(path, "a", encoding="utf-8") as f:
52
+ for msg in messages:
53
+ f.write(json.dumps(to_jsonable_python(msg), ensure_ascii=False) + "\n")
54
+
55
+
56
+ def load_history(session_id: str) -> list[Any]:
57
+ """
58
+ 读取整个会话文件,把每行 JSON 还原成 SDK 的消息对象列表。
59
+ """
60
+ lines = session_file(session_id).read_text(encoding="utf-8").splitlines()
61
+ return ModelMessagesTypeAdapter.validate_python([json.loads(line) for line in lines])
62
+
63
+
64
+ def first_prompt(path: Path) -> str:
65
+ """
66
+ 只读文件第一行,提取首条用户输入作为这个会话的摘要。
67
+ """
68
+ with open(path, encoding="utf-8") as f:
69
+ head = f.readline()
70
+ if not head:
71
+ return "(空会话)"
72
+ msg = json.loads(head)
73
+ for part in msg.get("parts", []):
74
+ if part.get("part_kind") == "user-prompt":
75
+ return str(part.get("content", ""))
76
+ return "(空会话)"
77
+
78
+
79
+ def list_sessions() -> list[tuple[str, datetime, str]]:
80
+ """
81
+ 扫描当前项目的所有会话文件,按修改时间从新到旧返回
82
+ (session_id, 修改时间, 首条用户输入) 列表。
83
+ """
84
+ if not project_dir().exists():
85
+ return []
86
+ files = sorted(
87
+ project_dir().glob("*.jsonl"),
88
+ key=lambda p: p.stat().st_mtime,
89
+ reverse=True,
90
+ )
91
+ return [(p.stem, datetime.fromtimestamp(p.stat().st_mtime), first_prompt(p)) for p in files]
@@ -1,13 +1,17 @@
1
1
  from collections.abc import Callable, Iterable
2
2
  from dataclasses import dataclass, field
3
+ from datetime import datetime
3
4
  from typing import Any
4
5
 
6
+ import questionary
5
7
  from rich.console import Console, ConsoleOptions, RenderResult
6
8
  from rich.markdown import Heading, Markdown
7
9
  from rich.markup import escape
8
10
  from rich.padding import Padding
9
11
  from rich.rule import Rule
10
12
 
13
+ from core import session
14
+
11
15
  from .render import console, print_step
12
16
 
13
17
 
@@ -38,6 +42,8 @@ class SessionState:
38
42
  model_name: str = ""
39
43
  # 最近一轮 user input 触发的所有 model API 调用记录
40
44
  last_api_calls: list[Any] = field(default_factory=list)
45
+ # 当前会话 id,对应 ~/.…/projects/<cwd>/<id>.jsonl
46
+ session_id: str = ""
41
47
 
42
48
 
43
49
  @dataclass
@@ -157,18 +163,74 @@ def cmd_help(state: SessionState) -> bool:
157
163
 
158
164
  def cmd_new(state: SessionState) -> bool:
159
165
  """
160
- 开启新会话:清空历史、token 计数、API 调用记录。
166
+ 开启新会话:清空历史、token 计数、API 调用记录,并换一个 session_id。
161
167
  """
162
168
  state.history.clear()
163
169
  state.input_tokens = 0
164
170
  state.output_tokens = 0
165
171
  state.last_api_calls.clear()
172
+ state.session_id = session.new_session_id()
166
173
  console.print("已开启新会话\n")
167
174
  return True
168
175
 
169
176
 
177
+ def _summary_line(mtime: datetime, prompt: str) -> str:
178
+ """
179
+ 拼一条会话列表的展示文本:修改时间 + 首条用户输入摘要。
180
+ """
181
+ prompt = " ".join(str(prompt).split())
182
+ if len(prompt) > 50:
183
+ prompt = prompt[:50] + "..."
184
+ return f"{mtime:%m-%d %H:%M} {prompt}"
185
+
186
+
187
+ def cmd_resume(state: SessionState) -> bool:
188
+ """
189
+ 列出当前项目的历史会话,选中后恢复对话历史。
190
+ """
191
+ sessions = session.list_sessions()
192
+ if not sessions:
193
+ console.print("(当前项目还没有历史会话)\n")
194
+ return True
195
+
196
+ choices = [
197
+ questionary.Choice(title=_summary_line(mtime, prompt), value=sid)
198
+ for sid, mtime, prompt in sessions
199
+ ]
200
+ selected = questionary.select(
201
+ "选择要恢复的会话(上下键移动,回车确认):",
202
+ choices=choices,
203
+ ).ask()
204
+ # 用户按 Ctrl+C 取消选择
205
+ if selected is None:
206
+ return True
207
+
208
+ # 还原对话历史,并把会话 ID 切换成选中的旧会话,后续消息继续追加到同一个文件
209
+ state.history = session.load_history(selected)
210
+ state.session_id = selected
211
+
212
+ # jsonl 里每条模型回复都带 usage,把会话的 token 用量累加回来
213
+ state.input_tokens = sum(
214
+ m.usage.input_tokens for m in state.history if getattr(m, "kind", None) == "response"
215
+ )
216
+ state.output_tokens = sum(
217
+ m.usage.output_tokens for m in state.history if getattr(m, "kind", None) == "response"
218
+ )
219
+ # 最近一轮的 API 调用记录只在进程内有效,没法恢复,清空
220
+ state.last_api_calls.clear()
221
+
222
+ # 把恢复的对话回放到屏幕上
223
+ console.print(f"\n已恢复会话 {selected[:8]},共 {len(state.history)} 条消息:\n")
224
+ for msg in state.history:
225
+ for part in msg.parts:
226
+ print_part(part)
227
+ console.print()
228
+ return True
229
+
230
+
170
231
  def cmd_status(state: SessionState) -> bool:
171
232
  console.print(f"模型: {state.model_name}")
233
+ console.print(f"会话 ID: {state.session_id or '(无)'}")
172
234
  console.print(f"历史消息条数: {len(state.history)}")
173
235
  console.print(f"累计输入 tokens:{state.input_tokens}")
174
236
  console.print(f"累计输出 tokens:{state.output_tokens}\n")
@@ -205,6 +267,7 @@ def cmd_api_detail(state: SessionState) -> bool:
205
267
 
206
268
  COMMANDS = {
207
269
  "new": Command("new", "开启新会话", cmd_new),
270
+ "resume": Command("resume", "恢复历史会话", cmd_resume),
208
271
  "status": Command("status", "显示当前会话状态", cmd_status),
209
272
  "api-detail": Command("api-detail", "显示最近一轮 model API 调用详情", cmd_api_detail),
210
273
  "help": Command("help", "显示可用命令", cmd_help),
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zrcoder"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -9,6 +9,7 @@ dependencies = [
9
9
  "prompt-toolkit>=3.0.53",
10
10
  "pydantic-ai>=2.40.0",
11
11
  "python-dotenv>=1.2.3",
12
+ "questionary>=2.1.1",
12
13
  "rich>=15.0.0",
13
14
  ]
14
15
  keywords = [
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "zrcoder"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -12,6 +12,7 @@ dependencies = [
12
12
  "prompt-toolkit>=3.0.53",
13
13
  "pydantic-ai>=2.40.0",
14
14
  "python-dotenv>=1.2.3",
15
+ "questionary>=2.1.1",
15
16
  "rich>=15.0.0",
16
17
  ]
17
18
  keywords = ["cli", "agent", "coding", "terminal", "openai"]
@@ -1,73 +0,0 @@
1
- """
2
- 挂在 Agent 上的 hooks,用来抓每次 model API 调用的元数据。
3
-
4
- 主循环在每轮 run_sync 之前清空 api_call_log,跑完后快照到 SessionState 里,
5
- /api-detail 命令再把这一轮的所有调用展示给用户。
6
- """
7
-
8
- from dataclasses import dataclass, field
9
- from typing import Any
10
-
11
- from pydantic_ai.capabilities import Hooks
12
-
13
-
14
- @dataclass
15
- class ApiCall:
16
- """
17
- 一次 model API 调用的元数据。before_model_request 创建并填充上半部分,
18
- after_model_request 填充下半部分。
19
- """
20
-
21
- # request 侧
22
- model: str
23
- messages_count: int
24
- # 这次发送给模型的 messages 中最后一条消息的最后一个 part
25
- last_part: Any
26
- tools: list[str]
27
- # response 侧(after hook 填充)
28
- finish_reason: str = ""
29
- parts_kinds: list[str] = field(default_factory=list)
30
- input_tokens: int = 0
31
- output_tokens: int = 0
32
-
33
-
34
- # 主循环在每轮 run_sync 之前清空它
35
- api_call_log: list[ApiCall] = []
36
-
37
- hooks = Hooks()
38
-
39
-
40
- @hooks.on.before_model_request
41
- async def _record_request(ctx: Any, request_context: Any) -> Any:
42
- """
43
- 每次发起 model 调用之前,创建一条 ApiCall 记录。
44
- """
45
- msgs = list(request_context.messages)
46
- last_part = msgs[-1].parts[-1] if msgs and msgs[-1].parts else None
47
- try:
48
- tool_names = [t.name for t in request_context.model_request_parameters.function_tools]
49
- except AttributeError:
50
- tool_names = []
51
- api_call_log.append(
52
- ApiCall(
53
- model=request_context.model.model_name,
54
- messages_count=len(msgs),
55
- last_part=last_part,
56
- tools=tool_names,
57
- )
58
- )
59
- return request_context
60
-
61
-
62
- @hooks.on.after_model_request
63
- async def _record_response(ctx: Any, request_context: Any, response: Any) -> Any:
64
- """
65
- 每次 model 调用返回后,填充上面这条 ApiCall 的 response 字段。
66
- """
67
- if api_call_log:
68
- call = api_call_log[-1]
69
- call.finish_reason = str(response.finish_reason) if response.finish_reason else "unknown"
70
- call.parts_kinds = [p.part_kind for p in response.parts]
71
- call.input_tokens = response.usage.input_tokens
72
- call.output_tokens = response.usage.output_tokens
73
- return response
@@ -1,45 +0,0 @@
1
- """
2
- Coding Agent 用到的三个工具:读文件、写文件、跑 shell 命令。
3
- """
4
-
5
- import subprocess
6
-
7
-
8
- def read_file(path: str) -> str:
9
- """
10
- 读取指定文件的内容。
11
- """
12
- try:
13
- with open(path, encoding="utf-8") as f:
14
- return f.read()
15
- except FileNotFoundError:
16
- return f"错误:文件 {path} 不存在"
17
-
18
-
19
- def write_file(path: str, content: str) -> str:
20
- """
21
- 将内容写入指定文件。
22
- """
23
- with open(path, "w", encoding="utf-8") as f:
24
- f.write(content)
25
- return f"已写入 {path}"
26
-
27
-
28
- def run_command(command: str) -> str:
29
- """
30
- 执行一条 shell 命令并返回输出。
31
- """
32
- try:
33
- result = subprocess.run(
34
- command, shell=True, capture_output=True, text=True, errors="replace", timeout=10
35
- )
36
- output = result.stdout
37
- if result.returncode != 0:
38
- output += f"\n[错误] {result.stderr}"
39
- return output or "(无输出)"
40
- except subprocess.TimeoutExpired:
41
- return "[错误] 命令执行超时(10秒)"
42
-
43
-
44
- # Pydantic AI 支持 tools=[plain_function],从函数签名 + docstring 自动生成 JSON Schema
45
- TOOLS = [read_file, write_file, run_command]
File without changes
File without changes
File without changes
File without changes
File without changes