@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
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-panel.ts — 以 lean-tidy 的 Claude 风格接管 @tintinweb/pi-subagents
|
|
3
|
+
* 的完成通知面板(subagent-notification)。
|
|
4
|
+
*
|
|
5
|
+
* 原理:pi 的 getMessageRenderer 按【扩展加载顺序】取第一个命中 customType 的
|
|
6
|
+
* 渲染器。lean-tidy 位于 ~/.pi/agent/extensions/,先于 npm 包(settings.json
|
|
7
|
+
* packages)加载,因此这里注册的渲染器胜出,包内的 ✓/⎿/transcript 面板被
|
|
8
|
+
* 静默忽略(首到先得,非冲突)。Ctrl+O 展开由 pi 内置 CustomMessageComponent
|
|
9
|
+
* 驱动(options.expanded);lean-tidy 的 renderGeneric 对 CustomMessageComponent
|
|
10
|
+
* 走原生路径,本组件的输出原样进入 transcript,无需额外 patch。
|
|
11
|
+
*
|
|
12
|
+
* details 契约来自该包 src/index.ts 的 buildNotificationDetails:
|
|
13
|
+
* { id, description, status, toolUses, turnCount, maxTurns?, totalTokens,
|
|
14
|
+
* totalCost?, durationMs, outputFile?, error?, resultPreview, others? }
|
|
15
|
+
* status ∈ "completed" | "steered" | "error" | "stopped" | "aborted"(防御式
|
|
16
|
+
* 读取,未知 status 按失败处理;升级 npm 包时注意核对字段漂移)。
|
|
17
|
+
* 细节:host(CustomMessageComponent)每次 rebuild 都会重新调用 renderer 创建组件,
|
|
18
|
+
* 闭包/实例状态会丢,因此点击展开状态用 WeakMap 挂在【每个 agent 条目对象】
|
|
19
|
+
* (session 内稳定引用)上——组通知里点谁展开谁,互不联动;点击交互与
|
|
20
|
+
* lean-tidy 工具块一致:块内任意行(头行/⎿ 行/正文/transcript)都是 toggle
|
|
21
|
+
* 热区,展开后点正文即折叠回去,块间空行不响应。Ctrl+O 全局展开仍由
|
|
22
|
+
* host 驱动,两者 OR 叠加。
|
|
23
|
+
*/
|
|
24
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { bold, expandHint, fg, fit, linesOf, oneLine, prefixed } from "./claude-tools.ts";
|
|
26
|
+
|
|
27
|
+
/** 只用到 pi 传入 options 的 expanded;outputPad 由内置 Spacer(1) 承担,忽略。 */
|
|
28
|
+
interface PanelOptions {
|
|
29
|
+
expanded: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SubagentPanelHooks {
|
|
33
|
+
/** 点击切换展开后请求整屏重绘(TUI requestRender)。 */
|
|
34
|
+
redraw(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** ⎿ 分支前缀,与 claude-view.ts 工具结果/技能块同款(3 空格 + ⎿ + 2 空格,续行 6 空格)。 */
|
|
38
|
+
const branch = (theme: any): string => ` ${fg(theme, "dim", "⎿")} `;
|
|
39
|
+
const BRANCH_REST = " ";
|
|
40
|
+
|
|
41
|
+
/** 与该包 ui/agent-widget.ts 的 formatTokens 对齐:"33.8k token" / "1.2M token"。 */
|
|
42
|
+
function tokensText(count: unknown): string {
|
|
43
|
+
const value = typeof count === "number" && Number.isFinite(count) && count > 0 ? count : 0;
|
|
44
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M token`;
|
|
45
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k token`;
|
|
46
|
+
return `${value} token`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 与该包 ui/agent-widget.ts 的 formatMs 对齐:"17.2s"。 */
|
|
50
|
+
function msText(ms: unknown): string {
|
|
51
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "?";
|
|
52
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 与该包 ui/agent-widget.ts 的 formatTurns 对齐:"↻5≤30" / "↻5"。 */
|
|
56
|
+
function turnsText(turnCount: unknown, maxTurns: unknown): string {
|
|
57
|
+
const turns = typeof turnCount === "number" && turnCount > 0 ? turnCount : 0;
|
|
58
|
+
if (turns <= 0) return "";
|
|
59
|
+
return typeof maxTurns === "number" && Number.isFinite(maxTurns) && maxTurns > 0 ? `↻${turns}≤${maxTurns}` : `↻${turns}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 单条 agent 通知 → lean-tidy 块行。折叠态零正文预览(与其他 previewLines=0 一致)。 */
|
|
63
|
+
function panelLines(d: any, expanded: boolean, theme: any, width: number): string[] {
|
|
64
|
+
const description = oneLine(d?.description) || "subagent";
|
|
65
|
+
const failed = d?.status === "error" || d?.status === "stopped" || d?.status === "aborted";
|
|
66
|
+
const icon = fg(theme, failed ? "error" : "success", "●");
|
|
67
|
+
const first = branch(theme);
|
|
68
|
+
|
|
69
|
+
// ⎿ 行:状态文案(muted)+ dim 统计尾巴
|
|
70
|
+
let status: string;
|
|
71
|
+
switch (d?.status) {
|
|
72
|
+
case "completed": status = `Completed in ${msText(d?.durationMs)}`; break;
|
|
73
|
+
case "steered": status = `Completed in ${msText(d?.durationMs)} (steered)`; break;
|
|
74
|
+
case "error": status = `Error: ${oneLine(d?.error) || "unknown"}`; break;
|
|
75
|
+
case "stopped": status = "Stopped"; break;
|
|
76
|
+
case "aborted": status = "Aborted (max turns exceeded)"; break;
|
|
77
|
+
default: status = String(d?.status ?? "unknown");
|
|
78
|
+
}
|
|
79
|
+
let tail = fg(theme, "muted", status);
|
|
80
|
+
const stats: string[] = [];
|
|
81
|
+
const turns = turnsText(d?.turnCount, d?.maxTurns);
|
|
82
|
+
if (turns) stats.push(turns);
|
|
83
|
+
if (typeof d?.toolUses === "number" && d.toolUses > 0) stats.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
|
|
84
|
+
if (typeof d?.totalTokens === "number" && d.totalTokens > 0) stats.push(tokensText(d.totalTokens));
|
|
85
|
+
if (stats.length) tail += fg(theme, "dim", ` · ${stats.join(" · ")}`);
|
|
86
|
+
|
|
87
|
+
const out = [fit(` ${icon} ${bold(theme, "Subagent")}(${description})`, width)];
|
|
88
|
+
out.push(...prefixed(tail, width, first, BRANCH_REST));
|
|
89
|
+
|
|
90
|
+
// 展开态:resultPreview 全文(其本身已在包内截到 resultMaxLen)+ transcript 路径
|
|
91
|
+
if (expanded) {
|
|
92
|
+
const body = linesOf(typeof d?.resultPreview === "string" ? d.resultPreview : "");
|
|
93
|
+
for (const line of body) out.push(...prefixed(fg(theme, "dim", line), width, first, BRANCH_REST));
|
|
94
|
+
if (typeof d?.outputFile === "string" && d.outputFile) {
|
|
95
|
+
out.push(...prefixed(fg(theme, "dim", `transcript: ${d.outputFile}`), width, first, BRANCH_REST));
|
|
96
|
+
}
|
|
97
|
+
} else if (typeof d?.resultPreview === "string" && d.resultPreview.trim()) {
|
|
98
|
+
// 折叠态不显示正文,但保留可展开提示(有正文才提示)
|
|
99
|
+
out.push(...prefixed(expandHint(theme), width, first, BRANCH_REST));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default function registerSubagentPanels(pi: ExtensionAPI, hooks: SubagentPanelHooks): void {
|
|
105
|
+
// 每个 agent 条目(details 对象,含 others 内的条目)独立的点击展开状态。
|
|
106
|
+
const clickExpanded = new WeakMap<object, boolean>();
|
|
107
|
+
|
|
108
|
+
pi.registerMessageRenderer("subagent-notification", (message: any, options: PanelOptions, theme: any) => {
|
|
109
|
+
const details = message?.details;
|
|
110
|
+
if (!details || typeof details !== "object") return undefined;
|
|
111
|
+
|
|
112
|
+
// 组通知(others):每条 agent 渲染为独立块,块间空一行,与 lean-tidy 单块一工具的节奏一致
|
|
113
|
+
const entries = [details, ...(Array.isArray(details.others) ? details.others : [])];
|
|
114
|
+
// 本组件实例内每块的行范围(render 时记录;块内任意行点击 toggle,块间空行不响应)
|
|
115
|
+
let spans: { entry: any; start: number; end: number }[] = [];
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
render(width: number): string[] {
|
|
119
|
+
spans = [];
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
let y = 0;
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (out.length) { out.push(""); y++; } // 块间空行:不属于任何块,不响应点击
|
|
124
|
+
const lines = panelLines(entry, options.expanded || clickExpanded.get(entry) === true, theme, width);
|
|
125
|
+
spans.push({ entry, start: y, end: y + lines.length });
|
|
126
|
+
out.push(...lines);
|
|
127
|
+
y += lines.length;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
},
|
|
131
|
+
invalidate(): void {},
|
|
132
|
+
// 与 lean-tidy 工具块同款:点击块内任意行(头行/⎿ 行/正文/transcript)
|
|
133
|
+
// toggle 该块 —— 展开后点正文即折叠回去;空行与块外不响应。
|
|
134
|
+
handleMouse(event: any): any {
|
|
135
|
+
if (event?.type !== "click" || event?.button !== "left") return undefined;
|
|
136
|
+
const span = spans.find((s) => event.y >= s.start && event.y < s.end);
|
|
137
|
+
if (!span) return undefined;
|
|
138
|
+
clickExpanded.set(span.entry, clickExpanded.get(span.entry) !== true);
|
|
139
|
+
try { hooks.redraw(); } catch {}
|
|
140
|
+
return { handled: true };
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Vendored UI code attribution
|
|
2
|
+
|
|
3
|
+
- `diff/`, `ansi-color.ts`, `ansi-text.ts`, `tool-result-sanitize.ts`, `show-more-hint.ts` and `diff-config.ts` are adapted from [minuque/pi-cc-extensions](https://github.com/minuque/pi-cc-extensions) (MIT); its rich diff renderer was adapted from [MasuRii/pi-tool-display](https://github.com/MasuRii/pi-tool-display). See [`diff/ATTRIBUTION.md`](diff/ATTRIBUTION.md) for the latter's license.
|
|
4
|
+
- `../working-message.ts` and `../running-effect.ts` are adapted from [minuque/pi-cc-extensions](https://github.com/minuque/pi-cc-extensions) (MIT).
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2026 minuque. Used under the MIT License. The license text is reproduced below:
|
|
7
|
+
|
|
8
|
+
> MIT License
|
|
9
|
+
>
|
|
10
|
+
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
> of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
> in the Software without restriction, including without limitation the rights
|
|
13
|
+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
> copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
> furnished to do so, subject to the following conditions:
|
|
16
|
+
>
|
|
17
|
+
> The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
> copies or substantial portions of the Software.
|
|
19
|
+
>
|
|
20
|
+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
> SOFTWARE.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** ANSI 16 色 → RGB(标准 xterm 色序)。 */
|
|
2
|
+
const ANSI_16_RGB: readonly [number, number, number][] = [
|
|
3
|
+
[0, 0, 0],
|
|
4
|
+
[128, 0, 0],
|
|
5
|
+
[0, 128, 0],
|
|
6
|
+
[128, 128, 0],
|
|
7
|
+
[0, 0, 128],
|
|
8
|
+
[128, 0, 128],
|
|
9
|
+
[0, 128, 128],
|
|
10
|
+
[192, 192, 192],
|
|
11
|
+
[128, 128, 128],
|
|
12
|
+
[255, 0, 0],
|
|
13
|
+
[0, 255, 0],
|
|
14
|
+
[255, 255, 0],
|
|
15
|
+
[0, 0, 255],
|
|
16
|
+
[255, 0, 255],
|
|
17
|
+
[0, 255, 255],
|
|
18
|
+
[255, 255, 255],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** ANSI 16 色码(0-15)→ RGB;越界回退白。 */
|
|
22
|
+
export function ansi16ToRgb(index: number): [number, number, number] {
|
|
23
|
+
return ANSI_16_RGB[index] ?? [255, 255, 255];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** ANSI 256 色码 → RGB(0-15 标准色 + 232-255 灰阶 + 16-231 六阶立方体)。 */
|
|
27
|
+
export function ansi256ToRgb(code: number): [number, number, number] {
|
|
28
|
+
// SGR 正则可解析任意位数字,入口先规范:取整并钳制到合法区间,
|
|
29
|
+
// 否则灰阶分支对 code > 255 会算出超出 0-255 的非法 RGB。
|
|
30
|
+
if (!Number.isFinite(code)) return [0, 0, 0];
|
|
31
|
+
code = Math.min(255, Math.max(0, Math.trunc(code)));
|
|
32
|
+
if (code < 16) return ansi16ToRgb(code);
|
|
33
|
+
if (code >= 232) {
|
|
34
|
+
const gray = 8 + (code - 232) * 10;
|
|
35
|
+
return [gray, gray, gray];
|
|
36
|
+
}
|
|
37
|
+
const cubeIndex = code - 16;
|
|
38
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
39
|
+
return [
|
|
40
|
+
levels[Math.floor(cubeIndex / 36)]!,
|
|
41
|
+
levels[Math.floor((cubeIndex % 36) / 6)]!,
|
|
42
|
+
levels[cubeIndex % 6]!,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 顶层共享的 ANSI/终端控制序列处理。
|
|
3
|
+
*
|
|
4
|
+
* 收敛原先散落在 renderer/grouping.ts / renderer/mouse/packets.ts /
|
|
5
|
+
* renderer/compact-mode.ts 的重复剥离逻辑,供 renderer 与 feature 共用。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 单个 CSI 序列(颜色、光标等 SGR/CUP/ED 等)。 */
|
|
9
|
+
const CSI_SEQUENCE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
10
|
+
/** OSC 序列(如 \x1b]8;;url\x07 或 ST 结尾)。 */
|
|
11
|
+
const OSC_SEQUENCE_RE = /\x1b\][^\x07]*(?:\x07|\x1b\\)/g;
|
|
12
|
+
/** compact-mode 原有 hasVisibleText 用的更严格 OSC(内容不允许内嵌 ESC)。 */
|
|
13
|
+
const OSC_SEQUENCE_STRICT_RE = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
14
|
+
|
|
15
|
+
/** 剥离一行内所有 CSI 序列。 */
|
|
16
|
+
export function stripAnsi(line: string): string {
|
|
17
|
+
return line.replace(CSI_SEQUENCE_RE, "");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 仅剥离终端序列、保留原布局(换行/空白不动),用于命中区间计算。 */
|
|
21
|
+
export function stripTerminalSequencesPreservingLayout(value: string): string {
|
|
22
|
+
return value.replace(OSC_SEQUENCE_RE, "").replace(CSI_SEQUENCE_RE, "");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 剥离终端序列并折叠空白(用于纯文本比较)。 */
|
|
26
|
+
export function stripTerminalSequences(value: string): string {
|
|
27
|
+
return stripTerminalSequencesPreservingLayout(value).replace(/\s+/g, " ").trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 去掉行内所有 CSI/OSC 序列后是否仍有可见文本(判断工具卡首尾内容行)。 */
|
|
31
|
+
export function hasVisibleText(line: string): boolean {
|
|
32
|
+
// OSC 用更严格的变体:内容内嵌 ESC 时不吞并后续文本,保持 compact-mode 原有语义。
|
|
33
|
+
return line.replace(CSI_SEQUENCE_RE, "").replace(OSC_SEQUENCE_STRICT_RE, "").trim().length > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 剥离背景色 ANSI(用于重新铺背景行)。 */
|
|
37
|
+
export function stripBackgroundAnsi(line: string): string {
|
|
38
|
+
return line.replace(/\x1b\[(?:4[0-9]|10[0-7]|48(?:(?:;|:)[0-9]+)+|49)m/g, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 剥离行首状态图标(展开组内工具首行复用)。 */
|
|
42
|
+
export function stripLeadingStatusIcon(line: string): string {
|
|
43
|
+
return line.replace(
|
|
44
|
+
/^((?:\x1b\[[0-9;]*m|[ \t]|[├└│─])*)(?:\x1b\[[0-9;]*m)*(?:[✓✗●○■⬤•·])(?:\x1b\[[0-9;]*m)*\s+/,
|
|
45
|
+
"$1",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Attribution
|
|
2
|
+
|
|
3
|
+
The rich diff renderer in this directory is adapted from
|
|
4
|
+
[MasuRii/pi-tool-display](https://github.com/MasuRii/pi-tool-display) at commit
|
|
5
|
+
`91cef7580078371f8dc49a8607222807ad6a424d`.
|
|
6
|
+
|
|
7
|
+
Copyright (c) MasuRii and pi-tool-display contributors. Used under the MIT
|
|
8
|
+
License. The upstream license text is reproduced below.
|
|
9
|
+
|
|
10
|
+
> MIT License
|
|
11
|
+
>
|
|
12
|
+
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
> of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
> in the Software without restriction, including without limitation the rights
|
|
15
|
+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
> copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
> furnished to do so, subject to the following conditions:
|
|
18
|
+
>
|
|
19
|
+
> The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
> copies or substantial portions of the Software.
|
|
21
|
+
>
|
|
22
|
+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
> SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
|
|
2
|
+
const STYLE_RESET_PARAMS = [39, 22, 23, 24, 25, 27, 28, 29, 59] as const;
|
|
3
|
+
|
|
4
|
+
export { ANSI_SGR_PATTERN, STYLE_RESET_PARAMS };
|
|
5
|
+
|
|
6
|
+
export function expandSgrReset(param: number): readonly number[] | undefined {
|
|
7
|
+
return param === 0 ? STYLE_RESET_PARAMS : undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function toSgrParams(rawParams: string): number[] {
|
|
11
|
+
if (!rawParams.trim()) {
|
|
12
|
+
return [0];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parsed = rawParams
|
|
16
|
+
.split(";")
|
|
17
|
+
.map((token) => Number.parseInt(token, 10))
|
|
18
|
+
.filter((value) => Number.isFinite(value));
|
|
19
|
+
|
|
20
|
+
return parsed.length > 0 ? parsed : [];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isFiniteSgrParam(value: number | undefined): value is number {
|
|
24
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readSgrColorSequence(params: number[], index: number): number[] | undefined {
|
|
28
|
+
const param = params[index];
|
|
29
|
+
if (param !== 38 && param !== 48) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const colorMode = params[index + 1];
|
|
34
|
+
if (colorMode === 5) {
|
|
35
|
+
const colorValue = params[index + 2];
|
|
36
|
+
return isFiniteSgrParam(colorValue) ? [param, colorMode, colorValue] : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (colorMode === 2) {
|
|
40
|
+
const red = params[index + 2];
|
|
41
|
+
const green = params[index + 3];
|
|
42
|
+
const blue = params[index + 4];
|
|
43
|
+
return isFiniteSgrParam(red) && isFiniteSgrParam(green) && isFiniteSgrParam(blue)
|
|
44
|
+
? [param, colorMode, red, green, blue]
|
|
45
|
+
: undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function stripBackgroundSgrParams(params: readonly number[]): number[] {
|
|
52
|
+
const sanitized: number[] = [];
|
|
53
|
+
|
|
54
|
+
for (let index = 0; index < params.length; index++) {
|
|
55
|
+
const param = params[index] ?? 0;
|
|
56
|
+
|
|
57
|
+
if (param === 0) {
|
|
58
|
+
sanitized.push(...expandSgrReset(param)!);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (param === 49 || (param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (param === 38 || param === 48) {
|
|
67
|
+
const sequence = readSgrColorSequence(params as number[], index);
|
|
68
|
+
if (sequence) {
|
|
69
|
+
if (param === 38) {
|
|
70
|
+
sanitized.push(...sequence);
|
|
71
|
+
}
|
|
72
|
+
index += sequence.length - 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const advance = params[index + 1] === 5 ? 2 : params[index + 1] === 2 ? 4 : 0;
|
|
76
|
+
if (advance > 0) {
|
|
77
|
+
index += advance;
|
|
78
|
+
if (param === 38) {
|
|
79
|
+
sanitized.push(param);
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
sanitized.push(param);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return sanitized;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function filterSgrSequences(text: string, filter: (params: number[]) => number[]): string {
|
|
92
|
+
if (!text || !text.includes("\x1b[")) {
|
|
93
|
+
return text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return text.replace(ANSI_SGR_PATTERN, (_sequence, rawParams: string) => {
|
|
97
|
+
const parsed = toSgrParams(rawParams);
|
|
98
|
+
if (parsed.length === 0) {
|
|
99
|
+
return "";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sanitized = filter(parsed);
|
|
103
|
+
if (sanitized.length === 0) {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return `\x1b[${sanitized.join(";")}m`;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function sanitizeAnsiForThemedOutput(text: string): string {
|
|
112
|
+
return filterSgrSequences(text, stripBackgroundSgrParams);
|
|
113
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_TOOL_DISPLAY_CONFIG,
|
|
3
|
+
type DiffIndicatorMode,
|
|
4
|
+
type ToolDisplayConfig,
|
|
5
|
+
} from "../diff-config.ts";
|
|
6
|
+
import { stripTerminalSequencesPreservingLayout } from "../ansi-text.ts";
|
|
7
|
+
import { RICH_DIFF_COMPONENT } from "../diff-config.ts";
|
|
8
|
+
import type { DiffPresentationMode } from "./diff-presentation.ts";
|
|
9
|
+
export { RICH_DIFF_COMPONENT };
|
|
10
|
+
|
|
11
|
+
/** Snapshot or live getter — panel changes must apply on the next paint. */
|
|
12
|
+
export type DisplayConfigInput = ToolDisplayConfig | (() => ToolDisplayConfig);
|
|
13
|
+
|
|
14
|
+
export interface DiffRenderOptions {
|
|
15
|
+
expanded: boolean;
|
|
16
|
+
filePath?: string;
|
|
17
|
+
previousContent?: string;
|
|
18
|
+
fileExistedBeforeWrite?: boolean;
|
|
19
|
+
headerLabel?: string;
|
|
20
|
+
/** Live hover state for the collapsed hint row (muted → text on hover). */
|
|
21
|
+
isHovered?: () => boolean;
|
|
22
|
+
invalidate?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isRichDiffComponent(value: unknown): boolean {
|
|
26
|
+
return Boolean(
|
|
27
|
+
value &&
|
|
28
|
+
typeof value === "object" &&
|
|
29
|
+
(value as Record<symbol, unknown>)[RICH_DIFF_COMPONENT] === true,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveLiveDisplayConfig(input: DisplayConfigInput): ToolDisplayConfig {
|
|
34
|
+
return typeof input === "function" ? input() : input;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 比对用归一化:ANSI、缩进前缀和右侧填充不影响判断。
|
|
39
|
+
* 折叠态 remainder 行里有任意正文,入口必须先由组件声明再比对文本。
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeCollapsedHintLine(line: string | undefined): string | undefined {
|
|
42
|
+
if (line === undefined) return undefined;
|
|
43
|
+
return stripTerminalSequencesPreservingLayout(line).replace(/\s+/g, " ").trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Cache key fragment so indicator/wrap/limits invalidate without host recreate. */
|
|
47
|
+
export function displayConfigCacheKey(config: ToolDisplayConfig): string {
|
|
48
|
+
return [
|
|
49
|
+
config.diffViewMode,
|
|
50
|
+
config.diffIndicatorMode,
|
|
51
|
+
String(config.diffSplitMinWidth),
|
|
52
|
+
String(config.editDiffCollapsedLines),
|
|
53
|
+
String(config.writeDiffCollapsedLines),
|
|
54
|
+
config.diffWordWrap ? "1" : "0",
|
|
55
|
+
].join(":");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function resolveDiffIndicatorMode(
|
|
59
|
+
config: Partial<Pick<ToolDisplayConfig, "diffIndicatorMode">>,
|
|
60
|
+
): DiffIndicatorMode {
|
|
61
|
+
return config.diffIndicatorMode ?? DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createDiffRenderCache() {
|
|
65
|
+
let cachedWidth: number | undefined;
|
|
66
|
+
let cachedExpanded: boolean | undefined;
|
|
67
|
+
let cachedMode: DiffPresentationMode | undefined;
|
|
68
|
+
let cachedConfigKey: string | undefined;
|
|
69
|
+
let cachedHovered: boolean | undefined;
|
|
70
|
+
let cachedLines: string[] | undefined;
|
|
71
|
+
/** 与 cachedLines 同一次渲染的折叠态 remainder 行文本。 */
|
|
72
|
+
let cachedHintLine: string | undefined;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
get(
|
|
76
|
+
width: number,
|
|
77
|
+
expanded: boolean,
|
|
78
|
+
mode: DiffPresentationMode,
|
|
79
|
+
configKey: string,
|
|
80
|
+
hovered: boolean,
|
|
81
|
+
): string[] | undefined {
|
|
82
|
+
if (
|
|
83
|
+
cachedLines &&
|
|
84
|
+
cachedWidth === width &&
|
|
85
|
+
cachedExpanded === expanded &&
|
|
86
|
+
cachedMode === mode &&
|
|
87
|
+
cachedConfigKey === configKey &&
|
|
88
|
+
cachedHovered === hovered
|
|
89
|
+
) {
|
|
90
|
+
return cachedLines;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
},
|
|
94
|
+
getHintLine(): string | undefined {
|
|
95
|
+
return cachedHintLine;
|
|
96
|
+
},
|
|
97
|
+
set(
|
|
98
|
+
width: number,
|
|
99
|
+
expanded: boolean,
|
|
100
|
+
mode: DiffPresentationMode,
|
|
101
|
+
configKey: string,
|
|
102
|
+
hovered: boolean,
|
|
103
|
+
lines: string[],
|
|
104
|
+
hintLine?: string,
|
|
105
|
+
): string[] {
|
|
106
|
+
cachedWidth = width;
|
|
107
|
+
cachedExpanded = expanded;
|
|
108
|
+
cachedMode = mode;
|
|
109
|
+
cachedConfigKey = configKey;
|
|
110
|
+
cachedHovered = hovered;
|
|
111
|
+
cachedLines = lines;
|
|
112
|
+
cachedHintLine = hintLine;
|
|
113
|
+
return lines;
|
|
114
|
+
},
|
|
115
|
+
invalidate(): void {
|
|
116
|
+
cachedWidth = undefined;
|
|
117
|
+
cachedExpanded = undefined;
|
|
118
|
+
cachedMode = undefined;
|
|
119
|
+
cachedConfigKey = undefined;
|
|
120
|
+
cachedHovered = undefined;
|
|
121
|
+
cachedLines = undefined;
|
|
122
|
+
cachedHintLine = undefined;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|