@yinan_chen/pi-claude-code-ui 0.0.0-stage → 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +44 -2
- package/package.json +34 -5
- package/src/claude-tools.ts +274 -0
- package/src/claude-view.ts +775 -0
- package/src/index.ts +566 -0
- package/src/pi-runtime.ts +232 -0
- package/src/preview.ts +147 -0
- package/src/running-effect.ts +29 -0
- package/src/skill-block-recollapse.ts +362 -0
- package/src/sticky-first-message.ts +1114 -0
- package/src/subagent-panel.ts +144 -0
- package/src/vendor/ATTRIBUTION.md +26 -0
- package/src/vendor/ansi-color.ts +44 -0
- package/src/vendor/ansi-text.ts +47 -0
- package/src/vendor/diff/ATTRIBUTION.md +28 -0
- package/src/vendor/diff/ansi-utils.ts +113 -0
- package/src/vendor/diff/diff-component.ts +125 -0
- package/src/vendor/diff/diff-edit-render.ts +237 -0
- package/src/vendor/diff/diff-header.ts +137 -0
- package/src/vendor/diff/diff-highlight.ts +115 -0
- package/src/vendor/diff/diff-inline.ts +324 -0
- package/src/vendor/diff/diff-layout.ts +762 -0
- package/src/vendor/diff/diff-limits.ts +229 -0
- package/src/vendor/diff/diff-palette.ts +410 -0
- package/src/vendor/diff/diff-parse.ts +535 -0
- package/src/vendor/diff/diff-presentation.ts +70 -0
- package/src/vendor/diff/diff-renderer.ts +23 -0
- package/src/vendor/diff/diff-text.ts +45 -0
- package/src/vendor/diff/diff-write-render.ts +572 -0
- package/src/vendor/diff/index.ts +126 -0
- package/src/vendor/diff/line-width-safety.ts +92 -0
- package/src/vendor/diff/shiki-highlight.ts +92 -0
- package/src/vendor/diff/write-execution.ts +113 -0
- package/src/vendor/diff-config.ts +30 -0
- package/src/vendor/show-more-hint.ts +38 -0
- package/src/vendor/tool-result-sanitize.ts +24 -0
- package/src/working-message.ts +140 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chenyn273
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,45 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @yinan_chen/pi-claude-code-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
> 原名 lean-tidy —— Claude Code 风格的 Pi 界面增强扩展:Claude 式对话转录视图、富文本 diff 渲染、子代理面板与工作状态提示。
|
|
6
|
+
|
|
7
|
+
## 安装
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@yinan_chen/pi-claude-code-ui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
试运行(不写入配置):
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi -e npm:@yinan_chen/pi-claude-code-ui
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## 功能
|
|
20
|
+
|
|
21
|
+
- **Claude 式视图** — 对话转录适配器,Pi 原生回退
|
|
22
|
+
- **吸附消息泡(sticky)** — 滚动时顶部吸附显示当前正在回答的用户消息原文,点击回位(v0.2.0 内置,原 sticky-first-message 扩展)
|
|
23
|
+
- **Skill 折叠(recollapse)** — 展开的 skill 全文自动折回 `/skill:名 参数`,覆盖 /tree、/fork 回填等四处盲区(v0.2.0 内置,原 skill-block-recollapse 扩展)
|
|
24
|
+
- **富 diff 渲染** — edit/write 工具的语法高亮 diff(vendored, MIT)
|
|
25
|
+
- **子代理面板** — 后台 agent 状态一目了然
|
|
26
|
+
- **工作状态提示** — working message / running effect
|
|
27
|
+
- **只读工具美化** — read/bash/grep/find/ls 结果渲染
|
|
28
|
+
|
|
29
|
+
## 可选增强
|
|
30
|
+
|
|
31
|
+
在 `~/.pi/agent/npm` 下安装 `@shikijs/cli` 可启用 diff 的 shiki 语法高亮(须在包解析链上,全局安装无效;未安装时自动降级为纯 +/- 底色着色):
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd ~/.pi/agent/npm && npm install @shikijs/cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 兼容性
|
|
38
|
+
|
|
39
|
+
按 `VERIFIED_PI_VERSION = 1.0.2` 验收;版本不一致时扩展会在 session 启动时提示一次。
|
|
40
|
+
|
|
41
|
+
## 从源码安装
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pi install git:github.com/chenyn273/pi-claude-code-ui@v0.2.0
|
|
45
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@yinan_chen/pi-claude-code-ui",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Claude Code-style UI for Pi: transcript view, rich diffs, subagent panels and working-message status.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chenyn273/pi-claude-code-ui.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/chenyn273/pi-claude-code-ui/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/chenyn273/pi-claude-code-ui#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-extension",
|
|
18
|
+
"pi-coding-agent",
|
|
19
|
+
"claude-code"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./src/index.ts"
|
|
28
|
+
],
|
|
29
|
+
"image": "https://github.com/chenyn273/pi-claude-code-ui/raw/main/assets/preview.png"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/** Claude-style tool presentation. Display only: never executes tools or changes result content. */
|
|
2
|
+
import { getLanguageFromPath, highlightCode, keyText } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
|
|
6
|
+
export interface ToolView {
|
|
7
|
+
name: string;
|
|
8
|
+
args: Record<string, any>;
|
|
9
|
+
result?: any;
|
|
10
|
+
partial: boolean;
|
|
11
|
+
started: boolean;
|
|
12
|
+
expanded: boolean;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ToolAppearance {
|
|
16
|
+
bashPreviewLines: number;
|
|
17
|
+
codePreviewLines: number;
|
|
18
|
+
errorPreviewLines: number;
|
|
19
|
+
}
|
|
20
|
+
export interface PaintedTool {
|
|
21
|
+
lines: string[];
|
|
22
|
+
headerHeight: number;
|
|
23
|
+
moreRow?: number;
|
|
24
|
+
}
|
|
25
|
+
export const BUILTIN_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
|
|
26
|
+
/** Only these tools join thinking in Claude's process summary. Mutating tools stay visible. */
|
|
27
|
+
export const PROCESS_TOOL_NAMES = new Set(["read", "bash", "powershell", "grep", "find", "ls"]);
|
|
28
|
+
const SUMMARY_ONLY_NAMES = new Set(["read", "grep", "find", "ls"]);
|
|
29
|
+
/** Structural failure signal: pi's isError, or an extension-reported error code in details.error
|
|
30
|
+
* (pi-mcp-adapter "script_error", pi-web-access "No query provided", …). Never text heuristics. */
|
|
31
|
+
export function failedTool(v: ToolView): boolean {
|
|
32
|
+
const d = v.result?.details;
|
|
33
|
+
return !!v.result?.isError || (typeof d?.error === "string" && d.error.trim() !== "");
|
|
34
|
+
}
|
|
35
|
+
export function canExpandTool(v: ToolView): boolean {
|
|
36
|
+
return failedTool(v) || !SUMMARY_ONLY_NAMES.has(v.name);
|
|
37
|
+
}
|
|
38
|
+
export const isImageLine = (s: string): boolean => s.includes("\x1b_G") || s.includes("\x1b]1337;File=");
|
|
39
|
+
export const stripStyle = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
|
|
40
|
+
export const isBlank = (s: string): boolean => !stripStyle(s).trim();
|
|
41
|
+
export const textOf = (r: any): string => Array.isArray(r?.content)
|
|
42
|
+
? r.content.filter((c: any) => c?.type === "text" && typeof c.text === "string").map((c: any) => c.text).join("\n")
|
|
43
|
+
: typeof r?.output === "string" ? r.output : typeof r?.error === "string" ? r.error : "";
|
|
44
|
+
export const hasImages = (r: any): boolean => r?.content?.some((c: any) => c?.type === "image") ?? false;
|
|
45
|
+
export function fg(theme: any, token: string, s: string): string {
|
|
46
|
+
try { return theme?.fg(token, s) ?? s; } catch { return s; }
|
|
47
|
+
}
|
|
48
|
+
export function bold(theme: any, s: string): string { return theme?.bold?.(s) ?? `\x1b[1m${s}\x1b[22m`; }
|
|
49
|
+
export function fit(s: string, width: number): string {
|
|
50
|
+
return isImageLine(s) || visibleWidth(s) <= width ? s : truncateToWidth(s, Math.max(0, width), "…");
|
|
51
|
+
}
|
|
52
|
+
export function wrap(s: string, width: number): string[] {
|
|
53
|
+
if (width <= 0) return [""];
|
|
54
|
+
if (visibleWidth(s) <= width) return [s];
|
|
55
|
+
return wrapTextWithAnsi(s, width).map((l) => fit(l, width));
|
|
56
|
+
}
|
|
57
|
+
export function prefixed(s: string, width: number, first: string, rest = first): string[] {
|
|
58
|
+
const prefixW = Math.min(Math.max(visibleWidth(first), visibleWidth(rest)), Math.max(0, width - 1));
|
|
59
|
+
const a = truncateToWidth(first, prefixW, ""), b = truncateToWidth(rest, prefixW, "");
|
|
60
|
+
return wrap(s, Math.max(1, width - prefixW)).map((l, i) => fit(`${i ? b : a}${l}`, width));
|
|
61
|
+
}
|
|
62
|
+
export const oneLine = (v: unknown): string => String(v ?? "").replace(/\s+/g, " ").trim();
|
|
63
|
+
export function shortPath(path: string, cwd?: string): string {
|
|
64
|
+
if (cwd && path === cwd) return ".";
|
|
65
|
+
if (cwd && path.startsWith(cwd.replace(/\/$/, "") + "/")) return path.slice(cwd.replace(/\/$/, "").length + 1);
|
|
66
|
+
const home = homedir();
|
|
67
|
+
return path === home ? "~" : path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
|
|
68
|
+
}
|
|
69
|
+
export function middle(s: string, budget: number): string {
|
|
70
|
+
if (visibleWidth(s) <= budget) return s;
|
|
71
|
+
if (budget < 3) return fit(s, budget);
|
|
72
|
+
const segments = [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(s)].map((x) => x.segment);
|
|
73
|
+
let tail = "", used = 0;
|
|
74
|
+
while (segments.length && used + visibleWidth(segments.at(-1)!) <= Math.floor(budget * .65)) {
|
|
75
|
+
const next = segments.pop()!; tail = next + tail; used += visibleWidth(next);
|
|
76
|
+
}
|
|
77
|
+
return truncateToWidth(segments.join(""), Math.max(0, budget - used - 1), "") + "…" + tail;
|
|
78
|
+
}
|
|
79
|
+
export function displayName(name: string): string {
|
|
80
|
+
const known: Record<string, string> = { read: "Read", write: "Write", edit: "Edit", bash: "Bash", powershell: "PowerShell", grep: "Grep", find: "Find", ls: "Ls", codemode: "Codemode", web_search: "WebSearch", fetch_content: "Fetch", source_check: "SourceCheck", get_search_content: "SearchContent", ask_user_question: "AskUserQuestion", subagent: "Subagent", mcpScript: "McpScript" };
|
|
81
|
+
return known[name] ?? name.replace(/(^|_)([a-z])/g, (_m, _a, c) => c.toUpperCase());
|
|
82
|
+
}
|
|
83
|
+
/** codemode 头行目标提示:首个非 @options 注释行;没有可读注释就报脚本行数。 */
|
|
84
|
+
export function codemodeTarget(code: string): string {
|
|
85
|
+
const lines = code.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
86
|
+
const head = lines.find((l) => l.startsWith("//") && !/^\/\/\s*@options\b/i.test(l));
|
|
87
|
+
if (head) return oneLine(head.replace(/^\/\/+\s*/, ""));
|
|
88
|
+
const n = lines.length;
|
|
89
|
+
return `script · ${n} ${n === 1 ? "line" : "lines"}`;
|
|
90
|
+
}
|
|
91
|
+
export function targetOf(name: string, a: Record<string, any>, cwd?: string): { text: string; path: boolean } {
|
|
92
|
+
// codemode 头行括号提示已停用(一南 2026-09-30):注释/行数不再上头行;codemodeTarget 保留备用。
|
|
93
|
+
if (name === "codemode") return { text: "", path: false };
|
|
94
|
+
if (typeof a.command === "string") return { text: oneLine(a.command), path: false };
|
|
95
|
+
if ((name === "grep" || name === "find") && a.pattern) return { text: `pattern: ${JSON.stringify(a.pattern)}${a.path ? `, path: ${shortPath(a.path, cwd)}` : ""}`, path: false };
|
|
96
|
+
if (typeof a.path === "string") {
|
|
97
|
+
const range = name === "read" && (a.offset || a.limit) ? `:${a.offset || 1}${a.limit ? `-${(a.offset || 1) + a.limit - 1}` : "-"}` : "";
|
|
98
|
+
return { text: shortPath(a.path, cwd) + range, path: true };
|
|
99
|
+
}
|
|
100
|
+
if (a.agent || a.workflow) return { text: oneLine([a.agent || a.workflow, a.task].filter(Boolean).join(": ")), path: false };
|
|
101
|
+
for (const k of ["url", "urls", "query", "queries", "pattern", "claim", "action", "name", "tool", "search", "describe", "project_id"]) {
|
|
102
|
+
if (a[k]) return { text: oneLine(Array.isArray(a[k]) ? a[k].join("; ") : a[k]), path: false };
|
|
103
|
+
}
|
|
104
|
+
return { text: "", path: false };
|
|
105
|
+
}
|
|
106
|
+
export function headerOf(v: ToolView, theme: any, width: number): string[] {
|
|
107
|
+
const name = displayName(v.name), target = targetOf(v.name, v.args, v.cwd);
|
|
108
|
+
const color = v.partial ? "muted" : failedTool(v) ? "error" : "success";
|
|
109
|
+
// 执行中的 bullet 明灭(与折叠 Running 摘要行同款 500ms 节奏,一南 2026-09-26);
|
|
110
|
+
// 完成(success/失败 error)后静态。
|
|
111
|
+
const glyph = v.partial && Math.floor(Date.now() / 500) % 2 === 0 ? " " : "●";
|
|
112
|
+
const prefix = ` ${fg(theme, color, glyph)} `;
|
|
113
|
+
// codemode 的注释提示常自带编号/括号收尾("0) …(…)"),外层不叠闭合括号。
|
|
114
|
+
const close = (s: string): string => v.name === "codemode" && /[))]$/.test(s) ? "" : ")";
|
|
115
|
+
if (v.expanded) return prefixed(`${bold(theme, name)}${target.text ? `(${target.text}${close(target.text)}` : ""}`, width, prefix, " ");
|
|
116
|
+
const budget = Math.max(0, width - 3 - visibleWidth(name) - 2);
|
|
117
|
+
const shown = target.path ? middle(target.text, budget) : fit(target.text, budget);
|
|
118
|
+
return [fit(`${prefix}${bold(theme, name)}${shown ? `(${shown}${close(shown)}` : ""}`, width)];
|
|
119
|
+
}
|
|
120
|
+
export function expandHint(theme: any): string {
|
|
121
|
+
let key = "ctrl+o";
|
|
122
|
+
try { key = keyText("app.tools.expand") || key; } catch {}
|
|
123
|
+
return fg(theme, "dim", ` (${key} to expand)`);
|
|
124
|
+
}
|
|
125
|
+
export function linesOf(text: string): string[] {
|
|
126
|
+
if (!text) return [];
|
|
127
|
+
return text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
|
|
128
|
+
}
|
|
129
|
+
export function readBody(text: string): string {
|
|
130
|
+
return text.replace(/\n\n\[(?:\d+ more lines? in file|Showing lines )[^\]]*\]\s*$/, "");
|
|
131
|
+
}
|
|
132
|
+
export function diffCounts(diff: string): { add: number; remove: number } {
|
|
133
|
+
let add = 0, remove = 0;
|
|
134
|
+
for (const line of diff.split("\n")) {
|
|
135
|
+
if (line.startsWith("+") && !line.startsWith("+++")) add++;
|
|
136
|
+
if (line.startsWith("-") && !line.startsWith("---")) remove++;
|
|
137
|
+
}
|
|
138
|
+
return { add, remove };
|
|
139
|
+
}
|
|
140
|
+
function expandTabs(text: string): string {
|
|
141
|
+
let col = 0, out = "";
|
|
142
|
+
for (const g of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
|
|
143
|
+
const s = g.segment;
|
|
144
|
+
if (s === "\t") { const n = 4 - col % 4; out += " ".repeat(n); col += n; }
|
|
145
|
+
else { out += s; col += visibleWidth(s); }
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
function codeLines(text: string, path: unknown, theme: any, offset = 1): string[] {
|
|
150
|
+
const raw = linesOf(text).map(expandTabs);
|
|
151
|
+
let rendered = raw;
|
|
152
|
+
try { const lang = typeof path === "string" ? getLanguageFromPath(path) : undefined; if (lang) rendered = highlightCode(raw.join("\n"), lang); } catch {}
|
|
153
|
+
const digits = String(Math.max(offset, offset + raw.length - 1)).length;
|
|
154
|
+
return raw.map((line, i) => `${fg(theme, "dim", String(offset + i).padStart(digits))} ${rendered[i] ?? line}`);
|
|
155
|
+
}
|
|
156
|
+
function diffLines(diff: string, theme: any): string[] {
|
|
157
|
+
return linesOf(diff).map((l) => fg(theme, l.startsWith("+") ? "toolDiffAdded" : l.startsWith("-") ? "toolDiffRemoved" : "toolDiffContext", expandTabs(l)));
|
|
158
|
+
}
|
|
159
|
+
function countRecords(text: string, empty: RegExp): number {
|
|
160
|
+
if (!text.trim() || empty.test(text.trim())) return 0;
|
|
161
|
+
return text.split("\n").filter((l) => l.trim() && !/^\[.*(?:limit|truncated|results).*\]$/i.test(l)).length;
|
|
162
|
+
}
|
|
163
|
+
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
164
|
+
/** Summary uses semantic records, never the JSON line count of an unknown tool. */
|
|
165
|
+
export function summaryOf(v: ToolView, theme: any): string | undefined {
|
|
166
|
+
const text = textOf(v.result), d = v.result?.details ?? {};
|
|
167
|
+
const count = (n: number, noun: string) => `${bold(theme, String(n))} ${noun}${n === 1 ? "" : "s"}`;
|
|
168
|
+
if (v.name === "read") {
|
|
169
|
+
if (hasImages(v.result)) return "Read image";
|
|
170
|
+
const shown = text.match(/\[Showing lines (\d+)-(\d+) of (\d+)/);
|
|
171
|
+
const more = text.match(/\[(\d+) more lines? in file/);
|
|
172
|
+
let s = `Read ${count(linesOf(readBody(text)).length, "line")}`;
|
|
173
|
+
if (shown) s += fg(theme, "dim", ` (${shown[1]}-${shown[2]} of ${shown[3]}, truncated)`);
|
|
174
|
+
else if (d.truncation?.truncated) s += fg(theme, "dim", " (truncated)");
|
|
175
|
+
else if (more) s += fg(theme, "dim", ` (+${more[1]} more in file)`);
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
if (v.name === "find") return `Found ${count(d.resultLimitReached ?? countRecords(text, /^No files found/), "file")}${d.resultLimitReached ? "+ (limit)" : ""}`;
|
|
179
|
+
if (v.name === "ls") return `Listed ${count(d.entryLimitReached ?? countRecords(text, /^\(empty directory\)/), "entry").replace(/entrys$/, "entries")}${d.entryLimitReached ? "+ (limit)" : ""}`;
|
|
180
|
+
if (v.name === "grep") {
|
|
181
|
+
const rows = text.split("\n").filter((l) => /^.+:\d+:/.test(l));
|
|
182
|
+
const files = new Set(rows.map((l) => l.replace(/:\d+:.*$/, "")));
|
|
183
|
+
const matches = d.matchLimitReached ?? (/^No matches found/.test(text.trim()) ? 0 : rows.length);
|
|
184
|
+
if (!matches && text.trim() && !/^No matches found/.test(text.trim())) return undefined;
|
|
185
|
+
return `Found ${bold(theme, String(matches))}${d.matchLimitReached ? "+" : ""} ${matches === 1 ? "match" : "matches"} in ${count(files.size, "file")}`;
|
|
186
|
+
}
|
|
187
|
+
if (v.name === "write" && typeof v.args.content === "string") return `Wrote ${count(linesOf(v.args.content).length, "line")} to ${shortPath(v.args.path ?? "", v.cwd)}`;
|
|
188
|
+
if (v.name === "edit" && typeof d.diff === "string") {
|
|
189
|
+
const c = diffCounts(d.diff);
|
|
190
|
+
return `${fg(theme, "toolDiffAdded", `Added ${plural(c.add, "line")}`)}, ${fg(theme, "toolDiffRemoved", `removed ${plural(c.remove, "line")}`)}`;
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
export function paintTool(v: ToolView, theme: any, width: number, config: ToolAppearance): PaintedTool {
|
|
195
|
+
const lines = headerOf(v, theme, width), headerHeight = lines.length;
|
|
196
|
+
let moreRow: number | undefined;
|
|
197
|
+
const result = (s: string) => lines.push(...prefixed(s, width, ` ${fg(theme, "dim", "⎿")} `, " "));
|
|
198
|
+
const body = (source: string[], limit: number, full = v.expanded, firstResult = false) => {
|
|
199
|
+
const shown = full ? source : source.slice(0, limit);
|
|
200
|
+
let clipped = false;
|
|
201
|
+
shown.forEach((line, i) => {
|
|
202
|
+
const first = firstResult && i === 0 ? ` ${fg(theme, "dim", "⎿")} ` : " ";
|
|
203
|
+
if (full) lines.push(...prefixed(line, width, first, " "));
|
|
204
|
+
else {
|
|
205
|
+
const pw = Math.min(6, Math.max(0, width - 1)), bw = Math.max(1, width - pw);
|
|
206
|
+
clipped ||= visibleWidth(line) > bw;
|
|
207
|
+
lines.push(fit(truncateToWidth(first, pw, "") + fit(line, bw), width));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
const remaining = source.length - shown.length;
|
|
211
|
+
if (!full && (remaining || clipped)) {
|
|
212
|
+
moreRow = lines.length;
|
|
213
|
+
result(fg(theme, "dim", remaining ? `… +${remaining} lines` : "… long lines clipped") + expandHint(theme));
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const text = textOf(v.result), d = v.result?.details ?? {};
|
|
217
|
+
if (v.partial) {
|
|
218
|
+
result(fg(theme, "dim", v.started ? "Running…" : "Waiting…"));
|
|
219
|
+
// bashPreviewLines = 0 → 折叠态无预览(slice(-0) 会返回全部行,须显式跳过)。
|
|
220
|
+
if (text && (v.expanded || config.bashPreviewLines > 0)) body(linesOf(text).slice(v.expanded ? 0 : -config.bashPreviewLines).map((l) => fg(theme, "toolOutput", expandTabs(l))), config.bashPreviewLines);
|
|
221
|
+
return { lines, headerHeight, moreRow };
|
|
222
|
+
}
|
|
223
|
+
if (failedTool(v)) {
|
|
224
|
+
const raw = linesOf(text);
|
|
225
|
+
const exit = raw.find((l) => /(?:Command exited with code|exit code)\s*:?\s*\d+/i.test(l));
|
|
226
|
+
const idx = raw.findIndex((l) => /\b(?:error|failed|fail|exception|fatal|ENOENT|EACCES)\b/i.test(l));
|
|
227
|
+
// errorPreviewLines = 0 → 空预览(slice(-0) 同样会返回全部行)。
|
|
228
|
+
const preview =
|
|
229
|
+
idx >= 0 || config.errorPreviewLines <= 0
|
|
230
|
+
? raw.slice(idx, idx + config.errorPreviewLines)
|
|
231
|
+
: raw.slice(-config.errorPreviewLines);
|
|
232
|
+
if (exit && !preview.includes(exit)) preview.push(exit);
|
|
233
|
+
const picked = v.expanded ? raw : preview;
|
|
234
|
+
body((picked.length ? picked : ["Error"]).map((l) => fg(theme, "error", expandTabs(l))), config.errorPreviewLines, true, true);
|
|
235
|
+
if (!v.expanded && picked.length < raw.length) { moreRow = lines.length; result(fg(theme, "dim", `… +${raw.length - picked.length} lines`) + expandHint(theme)); }
|
|
236
|
+
return { lines, headerHeight, moreRow };
|
|
237
|
+
}
|
|
238
|
+
const summary = summaryOf(v, theme);
|
|
239
|
+
if (summary) result(summary);
|
|
240
|
+
// Read/search/list output is intentionally semantic-only in both collapsed and
|
|
241
|
+
// detailed process views. The model still receives the unchanged full result.
|
|
242
|
+
if (["read", "grep", "find", "ls"].includes(v.name)) {
|
|
243
|
+
if (hasImages(v.result)) { /* Images are appended by ToolExecutionComponent. */ }
|
|
244
|
+
}
|
|
245
|
+
else if (v.name === "edit" && typeof d.diff === "string") body(diffLines(d.diff, theme), config.codePreviewLines);
|
|
246
|
+
else if (v.name === "write" && typeof v.args.content === "string") {
|
|
247
|
+
if (d.created === false && d.diff) body(diffLines(d.diff, theme), config.codePreviewLines);
|
|
248
|
+
else body(codeLines(v.args.content, v.args.path, theme), config.codePreviewLines);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const raw = linesOf(text).map((l) => fg(theme, "toolOutput", expandTabs(l)));
|
|
252
|
+
body(raw.length ? raw : [fg(theme, "dim", hasImages(v.result) ? "Image" : "(No content)")], config.bashPreviewLines, v.expanded, !summary);
|
|
253
|
+
}
|
|
254
|
+
if (d.truncation?.truncated && v.name !== "read") result(fg(theme, "dim", "Output truncated by tool"));
|
|
255
|
+
if (typeof d.fullOutputPath === "string") result(fg(theme, "dim", `Full output: ${shortPath(d.fullOutputPath, v.cwd)}`));
|
|
256
|
+
return { lines, headerHeight, moreRow };
|
|
257
|
+
}
|
|
258
|
+
/** Apply a shared detail-region background; preserve image control sequences. */
|
|
259
|
+
export function paintBackground(line: string, width: number, theme: any, token: string): string {
|
|
260
|
+
if (isImageLine(line)) return line;
|
|
261
|
+
let bg = "";
|
|
262
|
+
try { bg = theme?.getBgAnsi?.(token) ?? ""; } catch {}
|
|
263
|
+
if (!bg || bg === "\x1b[49m") return line;
|
|
264
|
+
const body = line.replace(/\x1b\[(?:0|49)m/g, (reset) => reset + bg);
|
|
265
|
+
return `${bg}${body}${" ".repeat(Math.max(0, width - visibleWidth(line)))}\x1b[49m`;
|
|
266
|
+
}
|
|
267
|
+
export function prefixZones(lines: string[]): string[] {
|
|
268
|
+
return lines.map((line) => {
|
|
269
|
+
if (!line.includes("\x1b]133;")) return line;
|
|
270
|
+
let zones = "";
|
|
271
|
+
const body = line.replace(/\x1b\]133;[ABC](?:\x07|\x1b\\)/g, (s) => { zones += s; return ""; });
|
|
272
|
+
return zones + body;
|
|
273
|
+
});
|
|
274
|
+
}
|