qi-coding-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.
Files changed (62) hide show
  1. qi_agent/__init__.py +18 -0
  2. qi_agent/abort.py +40 -0
  3. qi_agent/auth.py +216 -0
  4. qi_agent/cli.py +2178 -0
  5. qi_agent/compaction.py +475 -0
  6. qi_agent/config.py +409 -0
  7. qi_agent/docs/cli.md +188 -0
  8. qi_agent/docs/compaction.md +148 -0
  9. qi_agent/docs/configuration.md +89 -0
  10. qi_agent/docs/containerization.md +80 -0
  11. qi_agent/docs/docs.json +1 -0
  12. qi_agent/docs/environment-variables.md +47 -0
  13. qi_agent/docs/extensions.md +382 -0
  14. qi_agent/docs/how-qi-works.md +93 -0
  15. qi_agent/docs/index.md +61 -0
  16. qi_agent/docs/json.md +112 -0
  17. qi_agent/docs/keybindings.md +83 -0
  18. qi_agent/docs/models.md +138 -0
  19. qi_agent/docs/packages.md +155 -0
  20. qi_agent/docs/providers.md +288 -0
  21. qi_agent/docs/quickstart.md +109 -0
  22. qi_agent/docs/security.md +69 -0
  23. qi_agent/docs/session-format.md +299 -0
  24. qi_agent/docs/sessions.md +127 -0
  25. qi_agent/docs/settings.md +138 -0
  26. qi_agent/docs/skills.md +124 -0
  27. qi_agent/docs/slash-commands.md +55 -0
  28. qi_agent/docs/themes.md +78 -0
  29. qi_agent/docs/tui.md +109 -0
  30. qi_agent/docs/usage.md +112 -0
  31. qi_agent/export_html.py +95 -0
  32. qi_agent/extensions.py +2267 -0
  33. qi_agent/llm.py +609 -0
  34. qi_agent/loader.py +299 -0
  35. qi_agent/model_catalog.py +108 -0
  36. qi_agent/models.py +103 -0
  37. qi_agent/packages.py +430 -0
  38. qi_agent/paths.py +242 -0
  39. qi_agent/pep723.py +74 -0
  40. qi_agent/presets.py +293 -0
  41. qi_agent/prompt.py +150 -0
  42. qi_agent/registry.py +485 -0
  43. qi_agent/runner.py +584 -0
  44. qi_agent/runtime.py +1940 -0
  45. qi_agent/session.py +739 -0
  46. qi_agent/settings.py +501 -0
  47. qi_agent/share.py +111 -0
  48. qi_agent/system_prompt.py +278 -0
  49. qi_agent/theme.py +389 -0
  50. qi_agent/themes/dark.json +90 -0
  51. qi_agent/themes/light.json +89 -0
  52. qi_agent/titling.py +90 -0
  53. qi_agent/tools/__init__.py +349 -0
  54. qi_agent/tools/shell.py +276 -0
  55. qi_agent/trust.py +106 -0
  56. qi_agent/tui.py +6224 -0
  57. qi_agent/workspaces.py +116 -0
  58. qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
  59. qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
  60. qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  61. qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  62. qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,278 @@
1
+ """system prompt 构建(对齐 pi 的 `dist/core/system-prompt.js`)。
2
+
3
+ pi 的默认 prompt 是**代码内字符串 + 运行时拼接**;qi v1 曾把基座写成包数据
4
+ `src/qi_agent/SYSTEM.md`(静态 Markdown,无占位符)。本模块改为对齐 pi:
5
+
6
+ 1. **默认基座在代码里**(`DEFAULT_*` 三段静态文案 + 按实际工具集生成的
7
+ 「可用工具」「指南」两块动态文案);
8
+ 2. `SYSTEM.md`(项目 > 用户)一旦存在就**整体替换**默认基座 —— pi 的
9
+ customPrompt 语义,不是只换某一层;
10
+ 3. 无论走哪条分支,后面统一追加:**项目上下文**(AGENTS.md 等)→ **技能清单**(XML)
11
+ → **当前工作目录**。
12
+
13
+ P-E4c 起 core **不再追加角色层与数据源** —— 两者都是“角色”的概念,归 qi-agents
14
+ (E1.1/E15):角色说明由它通过 `before_agent_start` 拼进来(那个钩子的返回值是链式的),
15
+ 数据源实例住在角色目录里。默认基座的身份描述也因此不再宣称“多 agent”。
16
+
17
+ 副作用(与 pi 一致,已写进 docs/configuration.md):自定义 SYSTEM.md 会丢掉
18
+ 默认基座里的「可用工具 / 指南」两块 —— 那两块只随默认基座出现。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING, Sequence
26
+
27
+ if TYPE_CHECKING: # 只为类型标注,避免运行期耦合
28
+ from .extensions import Tool
29
+ from .models import Skill
30
+
31
+ # ── 默认基座:静态文案 ───────────────────────────────────────
32
+
33
+ DEFAULT_IDENTITY = (
34
+ "你是运行在 qi 框架中的 AI 助手。qi 是一个编码 agent 框架:默认单 agent 干活;"
35
+ "装了 qi-agents 扩展后可以按某个角色运行,也能把任务委派给别的角色。"
36
+ )
37
+
38
+ DEFAULT_ENVIRONMENT = """环境:
39
+ - 你有一个**会话工作目录**(见文末),所有文件工具的相对路径都基于它;只在该目录内读写。
40
+ - **需要事实时才用工具**:涉及本仓库/文件/命令的问题,用工具确认而不是凭记忆猜。
41
+ 寒暄、闲聊、概念性问答不需要动工具 —— 不要为了“显得在干活”而调工具。
42
+ - 需求不明确时用 `clarify` 提问;不要猜一个需求就动手。"""
43
+
44
+ DEFAULT_METHOD = """做法:
45
+ 1. **先看再做**:改文件前先读;改代码前先定位调用点与影响面。
46
+ 2. **小步验证**:改动后尽量用工具(读回、跑测试)确认结果,再报告结论。
47
+ 3. **不编造**:工具没返回的信息就说没有;不确定就说不确定。失败与报错如实说,并给出下一步建议。
48
+ 4. **务实**:能一步到位的不要拆成五步;不要为了完整而完整。"""
49
+
50
+
51
+ def build_guidelines(tool_names: Sequence[str], tools: Sequence[Tool] = ()) -> list[str]:
52
+ """按**实际可用工具**生成指南(pi 的条件化 guidelines)。
53
+
54
+ pi 只在「有 bash/powershell 但没有 grep/find/ls」时提示用 shell 做文件操作,
55
+ 并按两者是否同时存在分三种措辞。qi 的默认工具集自带 ls/find/grep,所以这条通常
56
+ 不出现,但收窄到 `["read","bash","edit","write"]` 时就会出现 —— 与 pi 同形。
57
+
58
+ `tools` 非空时额外追加**工具自带的指南**(pi 的 `promptGuidelines`):它们随工具
59
+ 启用而出现,所以写工具的人不必把“用这个工具而不是那个”塞进 description。
60
+ 自带指南**必须点名工具**(平铺追加时“这个”指谁看不出来)。
61
+ """
62
+ has = set(tool_names)
63
+ out: list[str] = []
64
+
65
+ def add(text: str) -> None:
66
+ if text not in out:
67
+ out.append(text)
68
+
69
+ if ({"bash", "powershell"} & has) and not ({"grep", "find", "ls"} & has):
70
+ if "bash" in has and "powershell" in has:
71
+ add("用 bash 或 PowerShell 做文件操作:列目录、搜索、找文件")
72
+ elif "powershell" in has:
73
+ add("用 PowerShell 做文件操作:列目录、搜索、找文件")
74
+ else:
75
+ add("用 bash 做文件操作:列目录、搜索、找文件")
76
+ for tool in tools:
77
+ for line in tool.prompt_guidelines:
78
+ add(line)
79
+ add("结论先行,简明扼要")
80
+ add("涉及文件时把路径写清楚")
81
+ add("使用与用户一致的语言(默认中文)")
82
+ return out
83
+
84
+
85
+ def docs_navigation() -> list[tuple[str, list[tuple[str, str]]]]:
86
+ """读 `docs/docs.json` 的 `navigation`:`[(分组标题, [(手册标题, 路径)])]`。
87
+
88
+ 读不到(打包漏了 `docs.json`、开发树里没有 docs/、文件坏了)→ 空列表。
89
+ 调用方据此**整块不注入**,不报错也不注入半个块。
90
+ """
91
+ from .paths import DOCS_DIR_NAME, DOCS_INDEX_FILE_NAME, docs_dir # 延迟导入,避免环
92
+
93
+ root = docs_dir()
94
+ if root is None:
95
+ return []
96
+ try:
97
+ data = json.loads((root / DOCS_INDEX_FILE_NAME).read_text(encoding="utf-8"))
98
+ except (OSError, ValueError):
99
+ return []
100
+ out: list[tuple[str, list[tuple[str, str]]]] = []
101
+
102
+ def collect(items: object) -> list[tuple[str, str]]:
103
+ """把(可能嵌套的)分组的条目展平成一串 (标题, 路径)。
104
+
105
+ `docs.json` 的分组照 pi 的写法可以有子分组("指南 → 运行 qi / 定制 qi / 构建于 qi"),
106
+ 而提示词里只需要一份平铺的“标题: 文件”清单 —— 递归取到底即可。
107
+ """
108
+ flat: list[tuple[str, str]] = []
109
+ for item in items if isinstance(items, list) else []:
110
+ if not isinstance(item, dict):
111
+ continue
112
+ path = str(item.get("path") or "")
113
+ if path:
114
+ flat.append((str(item.get("title") or ""), path))
115
+ else:
116
+ flat.extend(collect(item.get("items")))
117
+ return flat
118
+
119
+ for group in data.get("navigation") or []:
120
+ if not isinstance(group, dict):
121
+ continue
122
+ items = [(title, name) for title, name in collect(group.get("items")) if title and name]
123
+ if items:
124
+ out.append((str(group.get("title") or DOCS_DIR_NAME), items))
125
+ return out
126
+
127
+
128
+ def render_docs_index(tool_names: Sequence[str]) -> str:
129
+ """把手册索引渲染成提示词里的一节(pi 的 `Additional docs:` 的 qi 版)。
130
+
131
+ 两个与 pi 的差别:
132
+ - pi 把主题→文件写**死在源码里**;qi 从 `docs.json` 的 `navigation` 生成 ——
133
+ 手册增删时不会漂。
134
+ - 与 `render_skills` 同规矩:没有能读文件的工具(`read` / `bash`)就**整块不注入**
135
+ (给了路径也读不到)。
136
+
137
+ 路径给**绝对**的(`docs_dir()` 在 wheel 里与源码树里不一样,不能写死 `docs/`),
138
+ 正文不塞进上下文 —— 模型自己用 read 打开。
139
+ """
140
+ if not ({"read", "bash"} & set(tool_names)):
141
+ return ""
142
+ from .paths import docs_dir
143
+
144
+ root = docs_dir()
145
+ navigation = docs_navigation()
146
+ if root is None or not navigation:
147
+ return ""
148
+ lines = ["文档(qi 自带的手册;要查用法、或要改 qi 自身,先读对应文件,不要凭记忆猜):",
149
+ f"- 根目录: {root}"]
150
+ for title, items in navigation:
151
+ listed = "、".join(f"{name}" for _name_title, name in items)
152
+ lines.append(f"- {title}: {listed}")
153
+ lines.append("读的时候把根目录与文件名拼成绝对路径;文件之间互相引用时按相对链接跟着走。")
154
+ return "\n".join(lines)
155
+
156
+
157
+ def default_base_prompt(tools: Sequence[Tool] = ()) -> str:
158
+ """默认基座(身份 + 可用工具 + 指南 + 环境 + 做法)。
159
+
160
+ 只在没有自定义 `SYSTEM.md` 时使用;`tools` 为**本回合解析后**的工具集
161
+ (`ToolCatalog.resolve` 的结果),清单即模型可调用的全集 —— 所以这里不写
162
+ pi 那句“你可能还有别的工具”:qi 不会给出清单外的工具。
163
+ """
164
+ listed = "\n".join(f"- {t.name}: {t.prompt_line}" for t in tools) or "(无)"
165
+ guidelines = "\n".join(f"- {g}" for g in build_guidelines([t.name for t in tools], tools))
166
+ parts = [
167
+ DEFAULT_IDENTITY,
168
+ f"可用工具:\n{listed}",
169
+ f"指南:\n{guidelines}",
170
+ DEFAULT_ENVIRONMENT,
171
+ DEFAULT_METHOD,
172
+ ]
173
+ # 手册索引只随**默认基座**出现(自定义 `SYSTEM.md` 是“整体替换”,作者自己决定要不要提)
174
+ if (index := render_docs_index([t.name for t in tools])):
175
+ parts.append(index)
176
+ return "\n\n".join(parts)
177
+
178
+
179
+ # ── 技能清单(pi 的 <available_skills> 形态,渐进披露)──────────
180
+
181
+ _SKILL_READ_TOOLS = ("read", "bash") # 能读 SKILL.md 全文的工具,优先 read
182
+
183
+
184
+ def _xml(value: object) -> str:
185
+ return (str(value).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
186
+ .replace('"', "&quot;").replace("'", "&apos;"))
187
+
188
+
189
+ def render_skills(skills: Sequence[Skill], tool_names: Sequence[str]) -> str:
190
+ """技能清单:只给名字/描述/路径,正文用时再读(渐进披露)。
191
+
192
+ 与 pi 一致:没有能读文件的工具(`read` / `bash`)就**整块不注入** —— 注入了也读不到,
193
+ 只会诱导模型调用不存在的工具。
194
+ """
195
+ if not skills:
196
+ return ""
197
+ reader = next((t for t in _SKILL_READ_TOOLS if t in set(tool_names)), None)
198
+ if reader is None:
199
+ return ""
200
+ how = "用 read 读取它的 SKILL.md 全文" if reader == "read" else "用 bash 读取它的 SKILL.md 全文"
201
+ lines = [
202
+ f"以下技能为特定任务提供专门的执行说明;任务与其描述匹配时,{how}执行。",
203
+ "技能文件里出现相对路径时,以 SKILL.md 所在目录(技能目录)为基准解析,"
204
+ "并在工具命令里使用绝对路径。",
205
+ "",
206
+ "<available_skills>",
207
+ ]
208
+ for s in skills:
209
+ lines += [
210
+ " <skill>",
211
+ f" <name>{_xml(s.name)}</name>",
212
+ f" <description>{_xml(s.description)}</description>",
213
+ f" <location>{_xml(s.path)}</location>",
214
+ " </skill>",
215
+ ]
216
+ lines.append("</available_skills>")
217
+ return "\n".join(lines)
218
+
219
+
220
+ # ── 项目上下文(pi 的 <project_context>)──────────────────────
221
+
222
+ def render_project_context(files: Sequence[tuple[Path, str]]) -> str:
223
+ """把 AGENTS.md 等**内容**渲染成 `<project_context>`(路径放在属性上)。"""
224
+ if not files:
225
+ return ""
226
+ lines = ["<project_context>", "", "项目专属说明与约定:", ""]
227
+ for path, content in files:
228
+ lines.append(f'<project_instructions path="{path}">\n{content.strip()}\n'
229
+ "</project_instructions>")
230
+ lines.append("")
231
+ lines.append("</project_context>")
232
+ return "\n".join(lines)
233
+
234
+
235
+ # ── 组装 ────────────────────────────────────────────────────
236
+
237
+ def build_system_prompt(base_prompt: str | None = None, *,
238
+ cwd: Path | None = None,
239
+ tools: Sequence[Tool] = (),
240
+ context_files: Sequence[tuple[Path, str]] | None = None,
241
+ skills: Sequence[Skill] = (),
242
+ append: str | None = None) -> str:
243
+ """拼出 core 的 system prompt:**基座 → 项目上下文 → 技能 → 工作目录**。
244
+
245
+ 参数:
246
+ - `base_prompt`:自定义基座(`SYSTEM.md` 正文)。空/None → 默认基座;
247
+ 非空 → **整体替换**默认基座(pi 的 customPrompt 语义)。
248
+ - `tools`:本回合实际可用的工具集,决定「可用工具 / 指南 / 技能能否被读取」。
249
+ - `cwd`:会话工作目录,写进 prompt 末尾;`context_files=None` 时也用它去找
250
+ `AGENTS.md`(`context_files=[]` 表示显式不注入)。
251
+ - `skills`:**顶层**技能(六层来源,见 loader)。
252
+
253
+ **没有“角色层”与“数据源”** —— 两者都是 agent 的概念,归 qi-agents(E1.1/E15):
254
+ 角色说明由它通过 `before_agent_start` 拼进来(那个钩子的返回值是链式的),数据源
255
+ 实例也在 agent 目录里。core 只负责“基座 + 项目上下文 + 技能 + cwd”。
256
+ """
257
+ tool_names = [t.name for t in tools]
258
+ base = (base_prompt or "").strip() or default_base_prompt(tools)
259
+ parts: list[str] = [base]
260
+
261
+ if context_files is None:
262
+ from .loader import load_project_context # 延迟导入:避免 loader ↔ 本模块静态环
263
+
264
+ context_files = load_project_context(cwd)
265
+ ctx = render_project_context(context_files)
266
+ if ctx:
267
+ parts.append(ctx)
268
+
269
+ rendered_skills = render_skills(skills, tool_names)
270
+ if rendered_skills:
271
+ parts.append(rendered_skills)
272
+
273
+ parts.append(f"当前工作目录: {Path(cwd) if cwd else Path.cwd()}")
274
+ # `--append-system-prompt`:用户显式追加的一段,**放最后**(与 pi 同义:追加到
275
+ # system prompt 末尾,而不是插进某个中间层)。空/空白不占位。
276
+ if append and append.strip():
277
+ parts.append(append.strip())
278
+ return "\n\n".join(p for p in parts if p and p.strip())
qi_agent/theme.py ADDED
@@ -0,0 +1,389 @@
1
+ """pi 主题移植(TUI 视觉基线)。
2
+
3
+ `themes/dark.json` / `themes/light.json` 逐字取自 pi
4
+ (`@earendil-works/pi-coding-agent` 的 `dist/modes/interactive/theme/`),
5
+ 键名与语义保持一致 —— 这样 qi 的配色和 pi 是同一份数据,日后可直接 diff 上游。
6
+
7
+ 对外只暴露三件事:
8
+ - `load_palette(name)`:读调色板(vars → colors 解析)
9
+ - `resolve_theme(setting)`:settings/env 决定 dark|light,`auto` 时探测终端背景
10
+ - 若干格式化纯函数(footer 用),与 pi `footer.js` 的行为一致
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ import dataclasses
17
+ import json
18
+ import os
19
+ import re
20
+ import select
21
+ import sys
22
+ import termios
23
+ import time
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ THEMES_DIR = Path(__file__).resolve().parent / "themes"
29
+
30
+ THEME_CHOICES = ("dark", "light")
31
+ """内置主题名(与 pi 的两个 JSON 一一对应)。"""
32
+
33
+ AUTO_THEME = "auto"
34
+ """跟随终端背景(对齐 pi 的默认行为)。"""
35
+
36
+
37
+ class ThemeError(Exception):
38
+ """主题名未知 / 调色板损坏。"""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Palette:
43
+ """一份 pi 调色板(semantic key → #rrggbb)。
44
+
45
+ `terminal_bg` 是探测到的终端背景色:Textual 的 inline 区域必须有不透明底色,
46
+ 把它设成终端背景色才能在视觉上“不填色”(pi 本身不画底色)。
47
+ """
48
+
49
+ name: str
50
+ colors: dict[str, str]
51
+ page_bg: str = "#18181e"
52
+ terminal_bg: str | None = None
53
+
54
+ @property
55
+ def is_dark(self) -> bool:
56
+ return self.name != "light"
57
+
58
+ def hex(self, key: str) -> str:
59
+ """取语义色;未知名直接报错(拼错颜色名时不要静默变白)。"""
60
+ try:
61
+ return self.colors[key]
62
+ except KeyError as exc: # pragma: no cover - 只在配置/代码写错时触发
63
+ raise ThemeError(f"未知主题色: {key}") from exc
64
+
65
+ def fg(self, key: str, text: str) -> str:
66
+ """Rich markup 前景色片段(hex 直写,无需注册)。"""
67
+ return f"[{self.hex(key)}]{text}[/]"
68
+
69
+ def bg_markup(self, key: str, text: str) -> str:
70
+ return f"[on {self.hex(key)}]{text}[/]"
71
+
72
+ def style(self, key: str, *, bold: bool = False, italic: bool = False,
73
+ dim: bool = False, bg: bool = False) -> str:
74
+ """Rich style 字符串:`#rrggbb`(+on #rrggbb),可选 bold/italic/dim。"""
75
+ color = f"on {self.hex(key)}" if bg else self.hex(key)
76
+ parts = [color]
77
+ if bold:
78
+ parts.append("bold")
79
+ if italic:
80
+ parts.append("italic")
81
+ if dim:
82
+ parts.append("dim")
83
+ return " ".join(parts)
84
+
85
+
86
+ def load_palette(name: str) -> Palette:
87
+ """读内置调色板;`colors` 里的 var 名按 pi 规则解析成 hex。"""
88
+ if name not in THEME_CHOICES:
89
+ raise ThemeError(f"未知主题 {name!r};内置:{', '.join(THEME_CHOICES)}")
90
+ path = THEMES_DIR / f"{name}.json"
91
+ try:
92
+ data = json.loads(path.read_text(encoding="utf-8"))
93
+ except (OSError, json.JSONDecodeError) as exc: # pragma: no cover
94
+ raise ThemeError(f"主题文件不可用: {path}") from exc
95
+ variables: dict[str, str] = data.get("vars") or {}
96
+ raw: dict[str, str] = data.get("colors") or {}
97
+ # 统一小写:pi 的 JSON 里大小写混用(#2D2838 / #343541)。归一后才可预期 ——
98
+ # 注意 Textual 的 `Color.hex` 会**原样保留**传入字符串的大小写,所以别指望它帮你归一。
99
+ colors = {
100
+ key: (value if value.startswith("#") else variables.get(value, value)).lower()
101
+ for key, value in raw.items()
102
+ }
103
+ page_bg = ((data.get("export") or {}).get("pageBg") or "#18181e").lower()
104
+ return Palette(name=name, colors=colors, page_bg=page_bg)
105
+
106
+
107
+ # ── 终端背景探测(OSC 11)────────────────────────────────
108
+
109
+
110
+ def _luminance(r: int, g: int, b: int) -> float:
111
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255
112
+
113
+
114
+ def parse_background_color(reply: str) -> str | None:
115
+ """从 OSC 11 回复里取出背景色 `#rrggbb`(支持 8/16 位分量与 #RRGGBB)。"""
116
+ match = re.search(r"rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)", reply)
117
+ if match:
118
+ channels = []
119
+ for part in match.groups():
120
+ value = int(part, 16)
121
+ channels.append(value >> 8 if len(part) >= 3 else value)
122
+ return "#{:02x}{:02x}{:02x}".format(*[min(255, c) for c in channels])
123
+ match = re.search(r"#([0-9a-fA-F]{6})", reply)
124
+ if match:
125
+ return "#" + match.group(1).lower()
126
+ return None
127
+
128
+
129
+ def parse_osc11(reply: str) -> str | None:
130
+ """把终端的 OSC 11 回复解析成 dark|light(解析不出返回 None)。"""
131
+ color = parse_background_color(reply)
132
+ if color is None:
133
+ return None
134
+ rgb = tuple(int(color[i:i + 2], 16) for i in (1, 3, 5))
135
+ return "light" if _luminance(*rgb) > 0.5 else "dark"
136
+
137
+
138
+ def _query_osc11(timeout: float) -> str | None:
139
+ """向终端发 OSC 11 查询并读回复(必须在 Textual 接管 stdin 之前调用)。"""
140
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
141
+ return None
142
+ fd = sys.stdin.fileno()
143
+ try:
144
+ saved = termios.tcgetattr(fd)
145
+ except (termios.error, ValueError, OSError):
146
+ return None
147
+ raw = copy.deepcopy(saved)
148
+ raw[3] &= ~(termios.ICANON | termios.ECHO)
149
+ raw[6][termios.VMIN] = 0
150
+ raw[6][termios.VTIME] = 0
151
+ try:
152
+ termios.tcsetattr(fd, termios.TCSANOW, raw)
153
+ os.write(sys.stdout.fileno(), b"\x1b]11;?\x07")
154
+ sys.stdout.flush()
155
+ deadline = time.monotonic() + timeout
156
+ buf = ""
157
+ while time.monotonic() < deadline:
158
+ ready, _, _ = select.select([fd], [], [], max(0.0, deadline - time.monotonic()))
159
+ if not ready:
160
+ break
161
+ chunk = os.read(fd, 64)
162
+ if not chunk:
163
+ break
164
+ buf += chunk.decode("utf-8", "replace")
165
+ if "\x07" in buf or "\x1b\\" in buf:
166
+ break
167
+ return buf or None
168
+ except OSError:
169
+ return None
170
+ finally:
171
+ termios.tcsetattr(fd, termios.TCSANOW, saved)
172
+
173
+
174
+ def detect_terminal_background(timeout: float = 0.15) -> str | None:
175
+ """终端背景色 `#rrggbb`;不支持 OSC 11 / 超时返回 None。"""
176
+ reply = _query_osc11(timeout)
177
+ return parse_background_color(reply) if reply else None
178
+
179
+
180
+ def detect_terminal_theme(timeout: float = 0.15) -> str | None:
181
+ """终端背景明暗(dark|light);探测失败返回 None。"""
182
+ bg = detect_terminal_background(timeout)
183
+ if bg is None:
184
+ return None
185
+ rgb = tuple(int(bg[i:i + 2], 16) for i in (1, 3, 5))
186
+ return "light" if _luminance(*rgb) > 0.5 else "dark"
187
+
188
+
189
+ def theme_from_env() -> str | None:
190
+ """`QI_THEME` 显式覆盖(取值 dark|light|auto)。"""
191
+ value = (os.environ.get("QI_THEME") or "").strip().lower()
192
+ return value or None
193
+
194
+
195
+ def resolve_theme(setting: str | None = None, *, probe: bool = True) -> Palette:
196
+ """决定用哪份调色板。
197
+
198
+ 优先级:`QI_THEME` > `settings.theme` > 探测终端背景 > dark。
199
+ `auto`(或未设)走探测;探测不出来对齐 pi 落 dark。探测到的背景色
200
+ 一并带在 `Palette.terminal_bg` 上(Textual inline 区域用色)。
201
+ """
202
+ choice = (theme_from_env() or setting or AUTO_THEME).strip().lower()
203
+ background = detect_terminal_background() if probe else None
204
+ detected = parse_osc11(background or "")
205
+ if choice in THEME_CHOICES:
206
+ palette = load_palette(choice)
207
+ elif choice in (AUTO_THEME, "", "default", "none"):
208
+ palette = load_palette(detected or "dark")
209
+ else:
210
+ # 未知名字:不静默,交给调用方决定怎么提示;这里退回 dark 更稳妥
211
+ palette = load_palette("dark")
212
+ return dataclasses.replace(palette, terminal_bg=background)
213
+
214
+
215
+ # ── footer 格式化(逐条对齐 pi footer.js)──────────────
216
+
217
+
218
+ def format_tokens(count: int) -> str:
219
+ """紧凑 token 数:`1234` → `1.2k`(与 pi `formatTokens` 一致)。"""
220
+ if count < 1000:
221
+ return str(count)
222
+ if count < 10_000:
223
+ return f"{count / 1000:.1f}k"
224
+ if count < 1_000_000:
225
+ return f"{round(count / 1000)}k"
226
+ if count < 10_000_000:
227
+ return f"{count / 1_000_000:.1f}M"
228
+ return f"{round(count / 1_000_000)}M"
229
+
230
+
231
+ def shorten_home(path: str, home: str | None = None) -> str:
232
+ """把家目录缩成 `~`(pi `formatCwdForFooter` 的最小版本)。"""
233
+ home = home if home is not None else str(Path.home())
234
+ if home and (path == home or path.startswith(home + os.sep)):
235
+ return "~" + path[len(home):]
236
+ return path
237
+
238
+
239
+ def format_cwd_line(cwd: str, *, home: str | None = None, branch: str | None = None,
240
+ session_name: str | None = None) -> str:
241
+ """footer 第一行:`~/repo (main) • 会话名`。"""
242
+ text = shorten_home(cwd, home)
243
+ if branch:
244
+ text = f"{text} ({branch})"
245
+ if session_name:
246
+ text = f"{text} • {session_name}"
247
+ return text
248
+
249
+
250
+ # ── Rich 渲染桥接(markdown / 代码高亮)──────────────
251
+
252
+
253
+ def rich_theme(palette: Palette):
254
+ """Rich 的主题名 → pi 语义色。
255
+
256
+ Rich 的 markdown 渲染走 `console.get_style("markdown.h1")` 这类名字,
257
+ 所以把这套名字映射到 pi 的 md* 键,词法高亮之外的 markdown 视觉才一致。
258
+ """
259
+ from rich.style import Style
260
+ from rich.theme import Theme as RichTheme
261
+
262
+ c = palette.colors
263
+ heading = Style(color=c["mdHeading"], bold=True)
264
+ return RichTheme(
265
+ {
266
+ "markdown.h1": heading,
267
+ "markdown.h2": heading,
268
+ "markdown.h3": heading,
269
+ "markdown.h4": heading,
270
+ "markdown.h5": heading,
271
+ "markdown.h6": heading,
272
+ "markdown.paragraph": Style(color=c["text"]),
273
+ "markdown.item": Style(color=c["text"]),
274
+ "markdown.item.bullet": Style(color=c["mdListBullet"]),
275
+ "markdown.item.number": Style(color=c["mdListBullet"]),
276
+ "markdown.hr": Style(color=c["mdHr"]),
277
+ "markdown.link": Style(color=c["mdLink"]),
278
+ "markdown.link_url": Style(color=c["mdLinkUrl"]),
279
+ "markdown.block_quote": Style(color=c["mdQuote"]),
280
+ "markdown.code": Style(color=c["mdCode"]),
281
+ "markdown.code_block": Style(color=c["mdCodeBlock"]),
282
+ "markdown.kbd": Style(bold=True),
283
+ },
284
+ inherit=True,
285
+ )
286
+
287
+
288
+ def syntax_theme(palette: Palette):
289
+ """pi `syntax*` 键 → pygments token 的 Rich 代码高亮主题。
290
+
291
+ pi 用 highlight.js,qi 用 pygments;这里按**语义**对齐(注释/关键字/函数/
292
+ 字符串/数字/类型/运算符…),颜色取同一份调色板,所以两种实现看起来是一套。
293
+ 背景保持终端默认(pi 的代码块没有底色)。
294
+ """
295
+ from pygments.style import Style as PygStyle
296
+ from pygments.token import (
297
+ Comment,
298
+ Generic,
299
+ Keyword,
300
+ Name,
301
+ Number,
302
+ Operator,
303
+ Punctuation,
304
+ String,
305
+ Text as PygText,
306
+ Token,
307
+ )
308
+ from rich.syntax import PygmentsSyntaxTheme
309
+
310
+ c = palette.colors
311
+
312
+ class QiPygmentsStyle(PygStyle):
313
+ # None = 不画底色(pi 的代码块没有背景,只有 mdCodeBlock 前景色)。
314
+ # pygments 上游把它注解成 str,这里用 Any 声明才能写 None。
315
+ background_color: Any = None
316
+ styles = {
317
+ Token: c["text"],
318
+ PygText: c["text"],
319
+ Comment: c["syntaxComment"],
320
+ Keyword: c["syntaxKeyword"],
321
+ Name: c["text"],
322
+ Name.Function: c["syntaxFunction"],
323
+ Name.Builtin: c["syntaxType"],
324
+ Name.Class: c["syntaxType"],
325
+ Name.Variable: c["syntaxVariable"],
326
+ Name.Attribute: c["syntaxVariable"],
327
+ String: c["syntaxString"],
328
+ Number: c["syntaxNumber"],
329
+ Operator: c["syntaxOperator"],
330
+ Punctuation: c["syntaxPunctuation"],
331
+ Generic.Deleted: c["toolDiffRemoved"],
332
+ Generic.Inserted: c["toolDiffAdded"],
333
+ }
334
+
335
+ return PygmentsSyntaxTheme(QiPygmentsStyle)
336
+
337
+
338
+ def textual_theme(palette: Palette):
339
+ """pi 调色板 → Textual 主题。
340
+
341
+ background 取探测到的终端背景色:Textual inline 区域总会有一个不透明底色,
342
+ 只有与终端一致才看不出“被填色”。另外把 markdown 的 md* 色挂成 CSS 变量,
343
+ 供 App.CSS 里覆盖 MarkdownFence / MarkdownH1 等组件默认样式。
344
+ """
345
+ from textual.theme import Theme as TuiTheme
346
+
347
+ c = palette.colors
348
+ return TuiTheme(
349
+ name=f"qi-{palette.name}",
350
+ dark=palette.is_dark,
351
+ primary=c["accent"],
352
+ secondary=c["border"],
353
+ accent=c["borderAccent"],
354
+ foreground=c["text"],
355
+ background=palette.terminal_bg or palette.page_bg,
356
+ success=c["success"],
357
+ warning=c["warning"],
358
+ error=c["error"],
359
+ surface=c["userMessageBg"],
360
+ panel=c["toolPendingBg"],
361
+ variables={
362
+ "md-code": c["mdCode"],
363
+ "md-code-block": c["mdCodeBlock"],
364
+ "md-code-block-border": c["mdCodeBlockBorder"],
365
+ "md-quote": c["mdQuote"],
366
+ "md-hr": c["mdHr"],
367
+ "md-list-bullet": c["mdListBullet"],
368
+ "md-link": c["mdLink"],
369
+ "md-link-url": c["mdLinkUrl"],
370
+ "md-heading": c["mdHeading"],
371
+ },
372
+ )
373
+
374
+
375
+ def git_branch(cwd: str | os.PathLike[str]) -> str | None:
376
+ """当前 git 分支;非仓库/无 git 时 None(不打印错误)。"""
377
+ import subprocess
378
+
379
+ try:
380
+ out = subprocess.run(
381
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
382
+ cwd=str(cwd), capture_output=True, text=True, timeout=1.0,
383
+ )
384
+ except (OSError, subprocess.SubprocessError):
385
+ return None
386
+ if out.returncode != 0:
387
+ return None
388
+ branch = out.stdout.strip()
389
+ return branch if branch and branch != "HEAD" else None